From fb8b65fef1e96196a1e4c7cd6a8bd3db109109e1 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 14:47:55 +0300 Subject: [PATCH 1/3] fix: improve search match highlighting Separate search match coloring from header/path styling so terminal output keeps query matches visible in titles, summaries, and path segments. Use yellow for matched spans while preserving cyan for headers. --- src/commands/code/grep.test.ts | 2 +- src/commands/search.test.ts | 9 +- src/commands/search.ts | 130 +++++++++++++++++-- src/services/package-intelligence-service.ts | 2 +- src/shared/colors.test.ts | 22 ++++ src/shared/colors.ts | 10 +- src/shared/grep-repo-response.test.ts | 2 +- src/shared/index.ts | 1 + 8 files changed, 162 insertions(+), 16 deletions(-) diff --git a/src/commands/code/grep.test.ts b/src/commands/code/grep.test.ts index 147a3415..737da50f 100644 --- a/src/commands/code/grep.test.ts +++ b/src/commands/code/grep.test.ts @@ -91,7 +91,7 @@ describe("pkgGrepAction", () => { const output = writes.join(""); expect(output).toContain("src/index.js\n4:module.exports = "); expect(output).toContain( - "\u001b[1m\u001b[36mrequire\u001b[0m('./lib/express');", + "\u001b[1m\u001b[33mrequire\u001b[0m('./lib/express');", ); expect(output).not.toContain( "src/index.js:4:module.exports = require('./lib/express');", diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 528edffa..c26def6f 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -420,9 +420,12 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("\u001b[1m\u001b[36mmiddleware\u001b[0m"); + expect(output).toContain("\u001b[1m\u001b[33mmiddleware\u001b[0m"); expect(output).toContain( - "function \u001b[1m\u001b[36mrouter\u001b[0m(req, res, next) { ... }", + "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + ); + expect(output).toContain( + "function \u001b[1m\u001b[33mrouter\u001b[0m(req, res, next) { ... }", ); } finally { consoleSpy.mockRestore(); @@ -483,7 +486,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain(" line 1"); expect(output).toContain( - ` ${"\u001b[1m\u001b[36m"}line 2${"\u001b[0m"}`, + ` ${"\u001b[1m\u001b[33m"}line 2${"\u001b[0m"}`, ); } finally { consoleSpy.mockRestore(); diff --git a/src/commands/search.ts b/src/commands/search.ts index 006224af..751594d5 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -11,6 +11,7 @@ import { buildUnifiedSearchSuccessPayload, dim, highlight, + highlightMatch, highlightRanges, InvalidArgumentError, knownSymbolCategoryList, @@ -403,7 +404,7 @@ function formatUnifiedSearchTerminal(payload: { }>; searchRef?: string; progress?: { targetsReady?: number; targetsTotal?: number }; - query: { warnings?: string[] }; + query: { raw?: string; warnings?: string[] }; sourceStatus?: SourceStatusEntry[]; }): string { const lines: string[] = []; @@ -458,7 +459,12 @@ function formatUnifiedSearchTerminal(payload: { for (const entry of display) { const location = formatUnifiedSearchLocation(entry.locator); - const header = formatUnifiedSearchHeader(entry, useColors, location); + const header = formatUnifiedSearchHeader( + entry, + useColors, + location, + payload.query.raw, + ); lines.push(header); const metadata = formatUnifiedSearchMetadata(entry, useColors); if (metadata.length > 0) { @@ -779,18 +785,124 @@ function formatUnifiedSearchHeader( }, useColors: boolean, location: string | undefined, + rawQuery: string | undefined, ): string { - const primary = - entry.type === "documentation_page" - ? entry.target - : location - ? `${entry.target} ${location}` - : entry.target; + const primary = formatUnifiedSearchPrimary( + entry.type, + entry.target, + location, + rawQuery, + useColors, + ); const badge = `[${formatUnifiedSearchResultLabel(entry.type)}]`; const title = entry.title ? highlightRanges(entry.title, entry.highlights?.title, useColors) : undefined; - return `${highlight(primary, useColors)} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`; + return `${primary} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`; +} + +function formatUnifiedSearchPrimary( + type: string, + target: string, + location: string | undefined, + rawQuery: string | undefined, + useColors: boolean, +): string { + const formattedTarget = highlight(target, useColors); + if (type === "documentation_page" || !location) { + return formattedTarget; + } + + return `${formattedTarget} ${formatLocationWithQueryHighlights( + location, + rawQuery, + useColors, + )}`; +} + +function formatLocationWithQueryHighlights( + location: string, + rawQuery: string | undefined, + useColors: boolean, +): string { + const ranges = buildQueryTermRanges(location, rawQuery); + if (ranges.length === 0) return highlight(location, useColors); + if (!useColors) return location; + + let result = ""; + let cursor = 0; + for (const [start, end] of ranges) { + if (cursor < start) + result += highlight(location.slice(cursor, start), true); + result += highlightMatch(location.slice(start, end), true); + cursor = end; + } + if (cursor < location.length) + result += highlight(location.slice(cursor), true); + return result; +} + +function buildQueryTermRanges( + text: string, + rawQuery: string | undefined, +): Array { + const terms = extractQueryHighlightTerms(rawQuery); + if (terms.length === 0) return []; + + const lowerText = text.toLowerCase(); + const ranges: Array = []; + for (const term of terms) { + const lowerTerm = term.toLowerCase(); + let cursor = 0; + while (cursor < lowerText.length) { + const start = lowerText.indexOf(lowerTerm, cursor); + if (start === -1) break; + const end = start + lowerTerm.length; + ranges.push([start, end]); + cursor = end; + } + } + + return mergeRanges(ranges); +} + +function extractQueryHighlightTerms(rawQuery: string | undefined): string[] { + if (!rawQuery) return []; + + const booleanOperators = new Set(["AND", "OR", "NOT"]); + const terms = new Set(); + for (const [candidate] of rawQuery.matchAll(/[A-Za-z0-9_./@:-]+/g)) { + const normalised = /^[A-Za-z]+:.+/.test(candidate) + ? candidate.split(":").slice(1).join(":") + : candidate; + const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, ""); + if (term.length < 2) continue; + if (booleanOperators.has(term.toUpperCase())) continue; + terms.add(term); + } + + return Array.from(terms).sort((left, right) => right.length - left.length); +} + +function mergeRanges( + ranges: Array, +): Array { + const sorted = ranges + .filter(([start, end]) => end > start) + .sort((left, right) => left[0] - right[0] || left[1] - right[1]); + const merged: Array = []; + for (const current of sorted) { + const previous = merged[merged.length - 1]; + if (!previous || current[0] > previous[1]) { + merged.push(current); + continue; + } + merged[merged.length - 1] = [ + previous[0], + Math.max(previous[1], current[1]), + ]; + } + return merged; } function formatUnifiedSearchMetadata( diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 1020cc56..0c510c1b 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -2044,7 +2044,7 @@ export class PackageIntelligenceServiceImpl // Backend returns source=null for package version entries that have no // changelog entry. Treat no-source as NOT_FOUND only when no entries // came back at all. - const source = data.source && data.source.trim() ? data.source : undefined; + const source = data.source?.trim() ? data.source : undefined; const rawEntries = data.entries ?? []; if (!source && rawEntries.length === 0) { const target = diff --git a/src/shared/colors.test.ts b/src/shared/colors.test.ts index 3c98cf36..0d8f70a8 100644 --- a/src/shared/colors.test.ts +++ b/src/shared/colors.test.ts @@ -5,6 +5,8 @@ import { dim, error, highlight, + highlightMatch, + highlightRanges, shouldUseColors, success, warning, @@ -113,3 +115,23 @@ describe("highlight", () => { expect(highlight("important", false)).toBe("important"); }); }); + +describe("highlightMatch", () => { + it("wraps with bold yellow when enabled", () => { + const result = highlightMatch("match", true); + expect(result).toBe(`${colors.bold}${colors.yellow}match${colors.reset}`); + }); + + it("returns plain text when disabled", () => { + expect(highlightMatch("match", false)).toBe("match"); + }); +}); + +describe("highlightRanges", () => { + it("renders matched spans with the search-match color", () => { + const result = highlightRanges("router middleware", [[7, 17]], true); + expect(result).toBe( + `router ${colors.bold}${colors.yellow}middleware${colors.reset}`, + ); + }); +}); diff --git a/src/shared/colors.ts b/src/shared/colors.ts index 080f3099..9e0f6f6c 100644 --- a/src/shared/colors.ts +++ b/src/shared/colors.ts @@ -70,6 +70,14 @@ export function highlight(text: string, useColors: boolean): string { return `${colors.bold}${colors.cyan}${text}${colors.reset}`; } +/** + * Highlight matched search terms. + */ +export function highlightMatch(text: string, useColors: boolean): string { + if (!useColors) return text; + return `${colors.bold}${colors.yellow}${text}${colors.reset}`; +} + /** * Apply half-open character spans to a string. * Invalid or overlapping spans are ignored/merged conservatively. @@ -117,7 +125,7 @@ export function highlightRanges( let cursor = 0; for (const [start, end] of merged) { if (cursor < start) result += text.slice(cursor, start); - result += highlight(text.slice(start, end), useColors); + result += highlightMatch(text.slice(start, end), useColors); cursor = end; } if (cursor < text.length) result += text.slice(cursor); diff --git a/src/shared/grep-repo-response.test.ts b/src/shared/grep-repo-response.test.ts index 8262d8bc..f31cac25 100644 --- a/src/shared/grep-repo-response.test.ts +++ b/src/shared/grep-repo-response.test.ts @@ -122,7 +122,7 @@ describe("formatGrepRepoTerminal", () => { useColors: true, }); expect(stdout).toContain( - `src/index.js:10:const ${"\u001b[1m\u001b[36m"}app = e${"\u001b[0m"}xpress();`, + `src/index.js:10:const ${"\u001b[1m\u001b[33m"}app = e${"\u001b[0m"}xpress();`, ); }); diff --git a/src/shared/index.ts b/src/shared/index.ts index 045785f3..3f420f45 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -31,6 +31,7 @@ export { dim, error, highlight, + highlightMatch, highlightRanges, shouldUseColors, success, From 89f3b7476846bd7f938039b0d49aa426913de7cd Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 14:55:58 +0300 Subject: [PATCH 2/3] fix: align search-status highlighting Carry the backend query through search-status result payloads so completed and partial follow-up output can apply the same location-term highlights as immediate search output. Make fallback query-term highlighting deterministic by prioritizing longer overlapping terms, and preserve quoted phrases as phrase terms for path highlighting. --- src/commands/search.test.ts | 32 +++++++++++ src/commands/search.ts | 64 +++++++++++++++++----- src/shared/unified-search-response.test.ts | 4 ++ src/shared/unified-search-response.ts | 17 ++++++ 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index c26def6f..034231ba 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -674,8 +674,40 @@ describe("searchStatusAction", () => { const payload = JSON.parse(String(consoleSpy.mock.calls[0]?.[0])); expect(payload.completed).toBe(true); expect(payload.searchRef).toBe("search-ref-123"); + expect(payload.result.query.raw).toBe("router middleware"); expect(payload.result.results).toHaveLength(1); expect(payload).not.toHaveProperty("query.raw"); consoleSpy.mockRestore(); }); + + it("renders location term highlights for completed search-status results", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + const originalIsTTY = process.stdout.isTTY; + const noColor = process.env.NO_COLOR; + try { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + + await searchStatusAction("search-ref-123", {}, createDeps()); + + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output).toContain( + "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + ); + } finally { + consoleSpy.mockRestore(); + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + if (noColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = noColor; + } + } + }); }); diff --git a/src/commands/search.ts b/src/commands/search.ts index 751594d5..7f3d4782 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -556,7 +556,10 @@ function formatSearchStatusCompletedTerminal(payload: { results: payload.result.results, searchRef: payload.searchRef, progress: undefined, - query: { warnings: payload.result.warnings }, + query: { + raw: payload.result.query?.raw, + warnings: payload.result.warnings, + }, sourceStatus: payload.result.sourceStatus, }); } @@ -573,7 +576,10 @@ function formatSearchStatusPartialTerminal( results: payload.result.results, searchRef: payload.searchRef, progress: payload.progress, - query: { warnings: payload.result.warnings }, + query: { + raw: payload.result.query?.raw, + warnings: payload.result.warnings, + }, sourceStatus: payload.result.sourceStatus, }); } @@ -851,14 +857,16 @@ function buildQueryTermRanges( const lowerText = text.toLowerCase(); const ranges: Array = []; - for (const term of terms) { + for (const term of terms.sort((left, right) => right.length - left.length)) { const lowerTerm = term.toLowerCase(); let cursor = 0; while (cursor < lowerText.length) { const start = lowerText.indexOf(lowerTerm, cursor); if (start === -1) break; const end = start + lowerTerm.length; - ranges.push([start, end]); + if (!ranges.some((range) => rangesOverlap(range, [start, end]))) { + ranges.push([start, end]); + } cursor = end; } } @@ -871,17 +879,47 @@ function extractQueryHighlightTerms(rawQuery: string | undefined): string[] { const booleanOperators = new Set(["AND", "OR", "NOT"]); const terms = new Set(); - for (const [candidate] of rawQuery.matchAll(/[A-Za-z0-9_./@:-]+/g)) { - const normalised = /^[A-Za-z]+:.+/.test(candidate) - ? candidate.split(":").slice(1).join(":") - : candidate; - const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, ""); - if (term.length < 2) continue; - if (booleanOperators.has(term.toUpperCase())) continue; - terms.add(term); + const quotedRanges: Array = []; + // Preserve quoted phrases as a single best-effort location term so a phrase + // query does not degrade into scattered word highlights in paths. + for (const match of rawQuery.matchAll(/"([^"]+)"/g)) { + const phrase = match[1]; + if (phrase) addQueryHighlightTerm(phrase, terms, booleanOperators); + if (typeof match.index === "number") { + quotedRanges.push([match.index, match.index + match[0].length]); + } + } + + for (const match of rawQuery.matchAll(/[A-Za-z0-9_./@:-]+/g)) { + const index = match.index ?? 0; + if (quotedRanges.some(([start, end]) => index >= start && index < end)) { + continue; + } + addQueryHighlightTerm(match[0], terms, booleanOperators); } - return Array.from(terms).sort((left, right) => right.length - left.length); + return Array.from(terms); +} + +function addQueryHighlightTerm( + candidate: string, + terms: Set, + booleanOperators: Set, +): void { + const normalised = /^[A-Za-z]+:.+/.test(candidate) + ? candidate.split(":").slice(1).join(":") + : candidate; + const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, ""); + if (term.length < 2) return; + if (booleanOperators.has(term.toUpperCase())) return; + terms.add(term); +} + +function rangesOverlap( + left: readonly [number, number], + right: readonly [number, number], +): boolean { + return left[0] < right[1] && right[0] < left[1]; } function mergeRanges( diff --git a/src/shared/unified-search-response.test.ts b/src/shared/unified-search-response.test.ts index 34bbbdad..9fcafca8 100644 --- a/src/shared/unified-search-response.test.ts +++ b/src/shared/unified-search-response.test.ts @@ -527,6 +527,10 @@ describe("buildUnifiedSearchStatusPayload", () => { } expect(payload.result).toEqual({ + query: { + raw: "router middleware", + sources: ["code"], + }, sources: ["code"], hasMore: false, results: [ diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index 09da1ba0..a64d7b4f 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -159,6 +159,7 @@ export interface UnifiedSearchErrorPayload { } export interface UnifiedSearchStatusResultPayload { + query?: UnifiedSearchQueryEcho; warnings?: string[]; sources?: string[]; hasMore: boolean; @@ -317,6 +318,7 @@ function buildUnifiedSearchStatusResultPayload( result: UnifiedSearchCompleted["result"], ): UnifiedSearchStatusResultPayload { const payload: UnifiedSearchStatusResultPayload = { + query: buildStatusQueryEcho(result), hasMore: result.page.hasMore, results: result.results.map(buildHitPayload), }; @@ -335,6 +337,21 @@ function buildUnifiedSearchStatusResultPayload( return payload; } +function buildStatusQueryEcho( + result: UnifiedSearchCompleted["result"], +): UnifiedSearchQueryEcho { + const query: UnifiedSearchQueryEcho = { + raw: result.query, + }; + if (result.queryWarnings.length > 0) { + query.warnings = result.queryWarnings; + } + if (result.sources.length > 0) { + query.sources = result.sources.map((entry) => entry.toLowerCase()); + } + return query; +} + function buildQueryEcho( params: UnifiedSearchParams, rawQuery: string, From bf55bfe0d11deb847b9bf678cbdb3fe2863684e1 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 5 May 2026 15:02:53 +0300 Subject: [PATCH 3/3] test: cover location highlight term priority Document the reduced search-status query echo, make local term ordering explicit, preserve quoted terms verbatim for location highlighting, and lock longest-overlap behavior with CLI coverage. --- src/commands/search.test.ts | 34 +++++++++++++++++++++++++++ src/commands/search.ts | 19 +++++++++++---- src/shared/unified-search-response.ts | 2 ++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 034231ba..dad4119c 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -441,6 +441,40 @@ describe("searchAction", () => { } }); + it("prefers longer overlapping query terms for location highlights", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + const originalIsTTY = process.stdout.isTTY; + const noColor = process.env.NO_COLOR; + try { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + + await searchAction("route router", { in: ["npm:express"] }, createDeps()); + + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output).toContain( + "\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m", + ); + expect(output).not.toContain( + "\u001b[1m\u001b[33mroute\u001b[0m\u001b[1m\u001b[36mr/index.js", + ); + } finally { + consoleSpy.mockRestore(); + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + if (noColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = noColor; + } + } + }); + it("preserves CRLF-based summary highlight offsets", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); const originalIsTTY = process.stdout.isTTY; diff --git a/src/commands/search.ts b/src/commands/search.ts index 7f3d4782..d486eaee 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -857,7 +857,10 @@ function buildQueryTermRanges( const lowerText = text.toLowerCase(); const ranges: Array = []; - for (const term of terms.sort((left, right) => right.length - left.length)) { + const orderedTerms = [...terms].sort( + (left, right) => right.length - left.length, + ); + for (const term of orderedTerms) { const lowerTerm = term.toLowerCase(); let cursor = 0; while (cursor < lowerText.length) { @@ -884,7 +887,11 @@ function extractQueryHighlightTerms(rawQuery: string | undefined): string[] { // query does not degrade into scattered word highlights in paths. for (const match of rawQuery.matchAll(/"([^"]+)"/g)) { const phrase = match[1]; - if (phrase) addQueryHighlightTerm(phrase, terms, booleanOperators); + if (phrase) { + addQueryHighlightTerm(phrase, terms, booleanOperators, { + stripQualifier: false, + }); + } if (typeof match.index === "number") { quotedRanges.push([match.index, match.index + match[0].length]); } @@ -905,10 +912,12 @@ function addQueryHighlightTerm( candidate: string, terms: Set, booleanOperators: Set, + options: { stripQualifier: boolean } = { stripQualifier: true }, ): void { - const normalised = /^[A-Za-z]+:.+/.test(candidate) - ? candidate.split(":").slice(1).join(":") - : candidate; + const normalised = + options.stripQualifier && /^[A-Za-z]+:.+/.test(candidate) + ? candidate.split(":").slice(1).join(":") + : candidate; const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, ""); if (term.length < 2) return; if (booleanOperators.has(term.toUpperCase())) return; diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index a64d7b4f..0aa60873 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -340,6 +340,8 @@ function buildUnifiedSearchStatusResultPayload( function buildStatusQueryEcho( result: UnifiedSearchCompleted["result"], ): UnifiedSearchQueryEcho { + // Status responses only retain the backend result query. Request-only + // compile/filter metadata is not available when following up by searchRef. const query: UnifiedSearchQueryEcho = { raw: result.query, };