diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f2997f6a3..049ccb186 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -29,6 +29,7 @@ ### Fixed +- Fixed visible per-keystroke lag while searching in the `/resume` session picker. Literal matches now rank synchronously from a cached per-session haystack, fuzzy scoring runs in bounded background chunks that converge to the same ranking (large listings previously rebuilt a fuzzy index per token per session on every keystroke), and the prompt-history SQLite lookup — an FTS query plus a LIKE scan over every stored prompt — is debounced off the keystroke path. - Fixed compiled Linux binary extension loading when bundled web-search header generation cannot read `header-generator` data files from the build-time path. ([#5178](https://github.com/can1357/oh-my-pi/issues/5178)) ## [16.4.4] - 2026-07-11 diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 0290abc42..cef724333 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -1,7 +1,7 @@ import { type Component, Container, - fuzzyMatch, + FuzzyText, Input, matchesKey, padding, @@ -59,6 +59,33 @@ function sessionSearchText(session: SessionInfo): string { return parts.filter(Boolean).join(" "); } +/** + * Lowercased per-session search haystack, built once and cached on the + * {@link SessionInfo} itself (so it dies with the listing that produced it). + * Rebuilding it per keystroke — a ~4KB string join plus `toLowerCase` per + * session — was one of the costs that made resume search visibly lag. + * + * Only the string is cached. A prebuilt fuzzy index (~60KB per 4KB session) + * would cost hundreds of MB on multi-thousand-session listings, so fuzzy + * indexes are built transiently per scan visit instead (see + * {@link scoreFuzzySession} callers). + */ +const kSearchTextLower = Symbol("session.searchTextLower"); + +interface SearchableSessionInfo extends SessionInfo { + [kSearchTextLower]?: string; +} + +function sessionTextLower(session: SessionInfo): string { + const tagged = session as SearchableSessionInfo; + let textLower = tagged[kSearchTextLower]; + if (textLower === undefined) { + textLower = sessionSearchText(session).toLowerCase(); + tagged[kSearchTextLower] = textLower; + } + return textLower; +} + function tokenizeSessionQuery(query: string): string[] { const trimmed = query.trim().toLowerCase(); return trimmed ? trimmed.split(/\s+/) : []; @@ -70,6 +97,59 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number { const MIN_PURE_FUZZY_TOKEN_SCORE = -20; +/** One ranked search hit; `index` is the session's position in the unfiltered list (recency order). */ +interface RankedSessionMatch { + session: SessionInfo; + score: number; + index: number; +} + +/** + * True when every query token appears verbatim in the haystack. Literal + * matches rank purely by recency, so they skip fuzzy scoring entirely — a pure + * fast path, not a semantic change: a contiguous substring of the lowercased + * text always lies within one normalized word per query sub-token, so every + * literal token also fuzzy-matches. + */ +function isLiteralMatch(textLower: string, tokens: string[]): boolean { + for (const token of tokens) { + if (!textLower.includes(token)) return false; + } + return true; +} + +/** + * Fuzzy-score one non-literal session against every query token. Returns + * undefined when a token fails to match or the weakest token is pure-fuzzy + * noise. The caller builds `fuzzy` once per session visit so multi-token + * queries share a single index. + */ +function scoreFuzzySession( + session: SessionInfo, + index: number, + tokens: string[], + fuzzy: FuzzyText, +): RankedSessionMatch | undefined { + let score = 0; + let worstTokenScore = Number.NEGATIVE_INFINITY; + for (const token of tokens) { + const match = fuzzy.match(token); + if (!match.matches) return undefined; + score += match.score; + worstTokenScore = Math.max(worstTokenScore, match.score); + } + if (worstTokenScore >= MIN_PURE_FUZZY_TOKEN_SCORE) return undefined; + return { session, score, index }; +} + +function compareLiteralRank(a: RankedSessionMatch, b: RankedSessionMatch): number { + return compareSessionRecency(a.session, b.session) || a.index - b.index; +} + +function compareFuzzyRank(a: RankedSessionMatch, b: RankedSessionMatch): number { + return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index; +} + /** * Filter and rank session picker search results. * @@ -77,44 +157,33 @@ const MIN_PURE_FUZZY_TOKEN_SCORE = -20; * as a literal substring, newer sessions should beat a slightly better fuzzy * position match. Pure fuzzy/acronym matches still sort by fuzzy score after * literal matches, but weak pure fuzzy tokens are dropped as noise. + * + * This is the synchronous reference implementation; {@link SessionList} runs + * the same primitives incrementally so huge listings never block a keystroke. */ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] { const tokens = tokenizeSessionQuery(query); if (tokens.length === 0) return allSessions; - const results: Array<{ session: SessionInfo; score: number; literal: boolean; index: number }> = []; + const literal: RankedSessionMatch[] = []; + const fuzzyMatches: RankedSessionMatch[] = []; for (let index = 0; index < allSessions.length; index++) { const session = allSessions[index]!; - const text = sessionSearchText(session); - const textLower = text.toLowerCase(); - let score = 0; - let worstTokenScore = Number.NEGATIVE_INFINITY; - let literal = true; - let matches = true; - - for (const token of tokens) { - const match = fuzzyMatch(token, textLower); - if (!match.matches) { - matches = false; - break; - } - score += match.score; - worstTokenScore = Math.max(worstTokenScore, match.score); - if (!textLower.includes(token)) literal = false; - } - - if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) { - results.push({ session, score, literal, index }); + const textLower = sessionTextLower(session); + if (isLiteralMatch(textLower, tokens)) { + literal.push({ session, score: 0, index }); + continue; } + const match = scoreFuzzySession(session, index, tokens, new FuzzyText(textLower)); + if (match) fuzzyMatches.push(match); } - results.sort((a, b) => { - if (a.literal !== b.literal) return a.literal ? -1 : 1; - if (a.literal) return compareSessionRecency(a.session, b.session) || a.index - b.index; - return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index; - }); - - return results.map(result => result.session); + literal.sort(compareLiteralRank); + fuzzyMatches.sort(compareFuzzyRank); + const out: SessionInfo[] = []; + for (const match of literal) out.push(match.session); + for (const match of fuzzyMatches) out.push(match.session); + return out; } /** @@ -156,6 +225,37 @@ export function mergeSessionRanking( return [...historyMatches, ...metadataOnly]; } +/** + * Delay before the prompt-history DB is consulted for the current query. + * History matching hits SQLite synchronously (an FTS lookup plus a LIKE scan + * over every stored prompt — tens to hundreds of ms on a year-old database), + * so it must never run per keystroke: fuzzy results render immediately and + * the history merge lands once typing pauses. + */ +const HISTORY_MERGE_DEBOUNCE_MS = 150; +/** + * Minimum query length for history augmentation. A single character matches + * essentially every stored prompt — the most expensive FTS prefix to expand — + * and only reorders the recency-ranked list by noise. + */ +const HISTORY_MERGE_MIN_QUERY = 2; + +/** + * Sessions fuzzy-scored synchronously inside the keystroke itself. Small + * listings finish within it, keeping the complete-in-one-frame behavior; + * anything left spills into async chunks. A fuzzy visit costs ~100µs (index + * build over the ≤4KB per-session corpus dominates), so 100 visits ≈ 10ms — + * about one frame. Counts rather than a deadline keep chunk boundaries + * deterministic (and testable under fake timers). + */ +const FUZZY_SCAN_INLINE_COUNT = 100; +/** + * Sessions fuzzy-scored per async chunk (~15ms). Each chunk yields back to + * the event loop so the next keystroke is never blocked behind a long scan; a + * new query bumps the scan generation and orphans pending chunks. + */ +const FUZZY_SCAN_CHUNK_COUNT = 150; + /** * Custom session list component with multi-line items and search */ @@ -182,6 +282,29 @@ class SessionList implements Component { #allSessions: SessionInfo[]; #showCwd: boolean; readonly #historyMatcher?: SessionHistoryMatcher; + #historyMergeTimer: NodeJS.Timeout | undefined; + /** Re-render hook for async list updates (fuzzy scan chunks, history merge). */ + onRequestRender?: () => void; + + // ── Incremental search state ────────────────────────────────────────── + // #filteredSessions is always composed from these three inputs (see + // #composeFiltered), so late-arriving fuzzy chunks and the debounced + // history merge can land in any order without clobbering each other. + /** Recency-ranked sessions whose text contains every query token verbatim. */ + #literalRanked: RankedSessionMatch[] = []; + /** Score-ranked fuzzy-only matches, appended by scan chunks. */ + #fuzzyRanked: RankedSessionMatch[] = []; + /** Prompt-history session IDs for the current query, once the merge landed. */ + #historyIds: string[] = []; + /** Invalidates in-flight scan chunks when the query or dataset changes. */ + #scanGeneration = 0; + #scanTimer: NodeJS.Timeout | undefined; + /** + * True once the user moved the selection for the current query; blocks the + * history merge from reordering the list under their cursor. (Fuzzy chunks + * only append below the literal group, which never shifts existing rows.) + */ + #selectionMoved = false; constructor( sessions: SessionInfo[], @@ -233,23 +356,130 @@ class SessionList implements Component { } #filterSessions(query: string): void { - const fuzzy = rankSessionSearchMatches(this.#allSessions, query); - this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy); + this.#scanGeneration++; + if (this.#scanTimer !== undefined) { + clearTimeout(this.#scanTimer); + this.#scanTimer = undefined; + } + this.#selectionMoved = false; + this.#historyIds = []; + this.#literalRanked = []; + this.#fuzzyRanked = []; + + const tokens = tokenizeSessionQuery(query); + if (tokens.length === 0) { + this.#filteredSessions = this.#allSessions; + this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1)); + this.#scheduleHistoryMerge(query); + return; + } + + // Literal pass: one substring scan per token per session, synchronous so + // every keystroke gets immediate recency-ranked feedback regardless of + // listing size. + const literal: RankedSessionMatch[] = []; + const rest: number[] = []; + const all = this.#allSessions; + for (let index = 0; index < all.length; index++) { + if (isLiteralMatch(sessionTextLower(all[index]!), tokens)) { + literal.push({ session: all[index]!, score: 0, index }); + } else { + rest.push(index); + } + } + literal.sort(compareLiteralRank); + this.#literalRanked = literal; + + // Fuzzy pass: building a fuzzy index per session is too expensive to run + // across a huge listing inside one keystroke, so scan a bounded slice now + // and spill the remainder into async chunks. + this.#scanFuzzySlice(this.#scanGeneration, tokens, rest, 0, FUZZY_SCAN_INLINE_COUNT); + this.#composeFiltered(); + this.#scheduleHistoryMerge(query); + } + + /** + * Score up to `budget` sessions from `rest[start..]` (indexes into the + * unfiltered list), then schedule the remainder on a macrotask so pending + * input events run first. Chunks that added matches recompose the visible + * list and request a render; a stale generation aborts silently. + */ + #scanFuzzySlice(generation: number, tokens: string[], rest: number[], start: number, budget: number): void { + const all = this.#allSessions; + const end = Math.min(rest.length, start + budget); + for (let i = start; i < end; i++) { + const index = rest[i]!; + const session = all[index]!; + const match = scoreFuzzySession(session, index, tokens, new FuzzyText(sessionTextLower(session))); + if (match) this.#fuzzyRanked.push(match); + } + if (end >= rest.length) return; + this.#scanTimer = setTimeout(() => { + this.#scanTimer = undefined; + if (generation !== this.#scanGeneration) return; + const before = this.#fuzzyRanked.length; + this.#scanFuzzySlice(generation, tokens, rest, end, FUZZY_SCAN_CHUNK_COUNT); + if (this.#fuzzyRanked.length > before) { + this.#composeFiltered(); + this.onRequestRender?.(); + } + }, 0); + } + + /** + * Rebuild {@link #filteredSessions} from the current literal, fuzzy, and + * history inputs: literal matches first (recency), fuzzy-only matches below + * (score), prompt-history matches promoted to the top when present. + */ + #composeFiltered(): void { + this.#fuzzyRanked.sort(compareFuzzyRank); + const base: SessionInfo[] = []; + for (const match of this.#literalRanked) base.push(match.session); + for (const match of this.#fuzzyRanked) base.push(match.session); + this.#filteredSessions = + this.#historyIds.length > 0 ? mergeSessionRanking(this.#allSessions, base, this.#historyIds) : base; this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1)); } /** - * Augment fuzzy results with prompt-history matches without replacing them. + * Augment ranked results with prompt-history matches without replacing them. * The session-list corpus only sees the first 4KB of each session, so a prompt - * typed deep into a long session is invisible to fuzzy search; `historyMatcher` - * recovers those via `history.db`. + * typed deep into a long session is invisible to text search; `historyMatcher` + * recovers those via `history.db`. The lookup hits SQLite synchronously, so it + * is debounced off the keystroke path ({@link HISTORY_MERGE_DEBOUNCE_MS}) and + * composed in when it lands, discarded if the query changed meanwhile. */ - #mergeHistoryMatches(query: string, fuzzy: SessionInfo[]): SessionInfo[] { + #scheduleHistoryMerge(query: string): void { + if (this.#historyMergeTimer !== undefined) { + clearTimeout(this.#historyMergeTimer); + this.#historyMergeTimer = undefined; + } + const matcher = this.#historyMatcher; const trimmed = query.trim(); - if (!trimmed || !this.#historyMatcher) return fuzzy; - const historyIds = this.#historyMatcher(trimmed); - if (historyIds.length === 0) return fuzzy; - return mergeSessionRanking(this.#allSessions, fuzzy, historyIds); + if (!matcher || trimmed.length < HISTORY_MERGE_MIN_QUERY) return; + this.#historyMergeTimer = setTimeout(() => { + this.#historyMergeTimer = undefined; + if (this.#searchInput.getValue() !== query) return; + if (this.#selectionMoved) return; + const historyIds = matcher(trimmed); + if (historyIds.length === 0) return; + this.#historyIds = historyIds; + this.#composeFiltered(); + this.onRequestRender?.(); + }, HISTORY_MERGE_DEBOUNCE_MS); + } + + /** Cancel pending async search work; idempotent, called on every picker exit path. */ + dispose(): void { + this.#scanGeneration++; + if (this.#scanTimer !== undefined) { + clearTimeout(this.#scanTimer); + this.#scanTimer = undefined; + } + if (this.#historyMergeTimer !== undefined) { + clearTimeout(this.#historyMergeTimer); + this.#historyMergeTimer = undefined; + } } removeSession(sessionPath: string): void { @@ -272,6 +502,7 @@ class SessionList implements Component { /** Wheel notch: move the selection one step (clamped, no wrap). */ handleWheel(delta: -1 | 1): void { if (this.#filteredSessions.length === 0) return; + this.#selectionMoved = true; this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta)); } @@ -433,21 +664,25 @@ class SessionList implements Component { } // Up arrow if (matchesSelectUp(keyData)) { + this.#selectionMoved = true; this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); return; } // Down arrow if (matchesSelectDown(keyData)) { + this.#selectionMoved = true; this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1); return; } // Page up - jump up by maxVisible items if (matchesKey(keyData, "pageUp")) { + this.#selectionMoved = true; this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount()); return; } // Page down - jump down by maxVisible items if (matchesKey(keyData, "pageDown")) { + this.#selectionMoved = true; this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#visibleCount()); return; } @@ -566,9 +801,21 @@ export class SessionSelectorComponent extends Container { // user to Tab into all-projects rather than silently surfacing other // projects' history (issue #3099). this.#sessionList = new SessionList(sessions, false, options.historyMatcher, options.getTerminalRows); - this.#sessionList.onSelect = onSelect; - this.#sessionList.onCancel = onCancel; - this.#sessionList.onExit = onExit; + // Every exit path cancels the list's pending history merge, so a stale + // debounce timer can never run its SQLite lookup after the picker closed. + this.#sessionList.onSelect = session => { + this.#sessionList.dispose(); + onSelect(session); + }; + this.#sessionList.onCancel = () => { + this.#sessionList.dispose(); + onCancel(); + }; + this.#sessionList.onExit = () => { + this.#sessionList.dispose(); + onExit(); + }; + this.#sessionList.onRequestRender = () => this.#onRequestRender?.(); this.#sessionList.onDeleteRequest = (session: SessionInfo) => { this.#showDeleteConfirmation(session); }; @@ -628,6 +875,16 @@ export class SessionSelectorComponent extends Container { this.#onRequestRender = callback; } + /** + * Dispose the session list explicitly: while the delete-confirmation dialog + * is mounted the list is detached from the child tree, so Container's + * child-walking dispose would miss its pending history-merge timer. + */ + dispose(): void { + this.#sessionList.dispose(); + super.dispose(); + } + #clearError(): void { this.#messageContainer.clear(); } diff --git a/packages/coding-agent/test/session-selector-search.test.ts b/packages/coding-agent/test/session-selector-search.test.ts new file mode 100644 index 000000000..e5dc04956 --- /dev/null +++ b/packages/coding-agent/test/session-selector-search.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { + rankSessionSearchMatches, + SessionSelectorComponent, +} from "@oh-my-pi/pi-coding-agent/modes/components/session-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing"; + +/** + * Contracts of the session picker's incremental search engine: a keystroke + * synchronously surfaces literal matches, background fuzzy chunks converge to + * exactly the synchronous reference ranking, stale scans are orphaned by a + * query change, and the prompt-history SQLite lookup is debounced off the + * keystroke path. + */ + +function makeSession(id: string, overrides: Partial = {}): SessionInfo { + return { + path: `${id}.jsonl`, + id, + cwd: "/repo", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + size: 100, + firstMessage: "", + allMessagesText: "", + ...overrides, + }; +} + +/** + * 400 sessions, newest last; every fifth carries the "zzmarker" token. Large + * enough that the non-literal remainder (320) overflows the inline fuzzy + * budget (100) and must complete through async chunks. + */ +function makeCorpus(): SessionInfo[] { + const sessions: SessionInfo[] = []; + for (let i = 0; i < 400; i++) { + sessions.push( + makeSession(`s-${i}`, { + firstMessage: i % 5 === 0 ? `zzmarker topic ${i}` : `unrelated filler text ${i}`, + modified: new Date(1700000000000 + i * 1000), + }), + ); + } + return sessions; +} + +interface Harness { + selector: SessionSelectorComponent; + type: (text: string) => void; + /** Sessions currently in the filtered list, probed through the public selection surface. */ + filtered: () => SessionInfo[]; + renders: () => number; +} + +function makeHarness(sessions: SessionInfo[], historyMatcher?: (query: string) => string[]): Harness { + let renders = 0; + const selector = new SessionSelectorComponent( + sessions, + () => {}, + () => {}, + () => {}, + historyMatcher ? { historyMatcher } : {}, + ); + selector.setOnRequestRender(() => renders++); + const list = selector.getSessionList(); + const filtered = (): SessionInfo[] => { + const out: SessionInfo[] = []; + list.onSelect = session => { + out.push(session); + }; + for (let i = 0; ; i++) { + const before = out.length; + list.selectAndConfirm(i); + if (out.length === before) break; + } + list.onSelect = undefined; + return out; + }; + return { + selector, + type: text => { + for (const ch of text) list.handleInput(ch); + }, + filtered, + renders: () => renders, + }; +} + +const ids = (sessions: SessionInfo[]): string[] => sessions.map(s => s.id); + +beforeAll(async () => { + await initTheme(false); +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("session picker incremental search", () => { + it("surfaces literal matches synchronously and converges async fuzzy chunks to the reference ranking", () => { + const sessions = makeCorpus(); + const harness = makeHarness(sessions); + + // Literal query: complete and recency-ranked within the keystroke itself. + harness.type("zzmarker"); + const literalReference = rankSessionSearchMatches(sessions, "zzmarker"); + expect(literalReference.length).toBe(80); + expect(ids(harness.filtered())).toEqual(ids(literalReference)); + expect(harness.filtered()[0]!.id).toBe("s-395"); + + // Typo query: no literal hits, so results accumulate through fuzzy chunks. + harness.type("\x7f\x7f"); // "zzmarker" -> "zzmark" + harness.type("r"); // "zzmarkr" + const reference = rankSessionSearchMatches(sessions, "zzmarkr"); + expect(reference.length).toBe(80); + // The inline slice covers only part of the corpus; the rest is pending. + expect(harness.filtered().length).toBeLessThan(reference.length); + + vi.runAllTimers(); // drain the zero-delay chunk chain + expect(ids(harness.filtered())).toEqual(ids(reference)); + }); + + it("orphans in-flight fuzzy chunks when the query changes mid-scan", () => { + const sessions = makeCorpus(); + const harness = makeHarness(sessions); + + harness.type("zzmarkr"); // schedules chunks for the typo query + harness.type("\x7f"); // "zzmark" — new scan generation while chunks are pending + harness.type("er"); // "zzmarker" + vi.runAllTimers(); + + // A leaked stale chunk would append fuzzy duplicates of sessions already + // ranked literally, so exact equality proves the generation guard held. + expect(ids(harness.filtered())).toEqual(ids(rankSessionSearchMatches(sessions, "zzmarker"))); + }); + + it("debounces the prompt-history lookup off the keystroke path and promotes its matches once typing pauses", () => { + const sessions = makeCorpus(); + const calls: string[] = []; + const harness = makeHarness(sessions, query => { + calls.push(query); + return ["s-105"]; + }); + + harness.type("zzmarker"); + // The SQLite-backed matcher must never run inside a keystroke. + expect(calls).toEqual([]); + const rendersBefore = harness.renders(); + + vi.advanceTimersByTime(149); + expect(calls).toEqual([]); + vi.advanceTimersByTime(1); + expect(calls).toEqual(["zzmarker"]); + + // History match leads the ranking and the merge requested a re-render. + expect(harness.filtered()[0]!.id).toBe("s-105"); + expect(harness.renders()).toBeGreaterThan(rendersBefore); + }); + + it("skips the history merge after the user moves the selection", () => { + const sessions = makeCorpus(); + const calls: string[] = []; + const harness = makeHarness(sessions, query => { + calls.push(query); + return ["s-105"]; + }); + + harness.type("zzmarker"); + const before = ids(harness.filtered()); + harness.selector.getSessionList().handleInput("\x1b[B"); // arrow down + vi.runAllTimers(); + + expect(calls).toEqual([]); + expect(ids(harness.filtered())).toEqual(before); + }); + + it("dispose cancels pending fuzzy chunks and the history merge", () => { + const sessions = makeCorpus(); + const calls: string[] = []; + const harness = makeHarness(sessions, query => { + calls.push(query); + return ["s-105"]; + }); + + harness.type("zzmarkr"); // partial fuzzy scan + scheduled history merge + const partial = harness.filtered().length; + harness.selector.dispose(); + vi.runAllTimers(); + + expect(calls).toEqual([]); + expect(harness.filtered().length).toBe(partial); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 474b9c78d..ea227a6c7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `FuzzyText`, a prepared fuzzy-match handle that builds the search index once and matches many queries against it — for callers whose corpus exceeds the internal index cache's admission size (e.g. session/transcript search). + ### Fixed - Fixed the mid-prompt `/` autocomplete popup lingering until Esc on tokens that are neither a path nor skill-shaped. Skill suggestions previously stayed alive through fuzzy subsequence matches against long skill descriptions, so nearly any prose token kept the popup hovering; matching is now gated to the `skill:` namespace (bare `/`, `s`, `sk`, …), explicit `skill:` queries (fuzzy search retained), and bare skill-name prefixes (`/hum` → `skill:humanizer`). Everything else falls through to path completion or dismisses the popup, and the Tab/Enter staleness guard shares the same gate so a stale popup can no longer rewrite tokens like `/scan` into `/skill:…`. diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 29b516413..7ffe104af 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -389,8 +389,7 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s commands.filter(cmd => { const name = getCommandName(cmd); return ( - name !== undefined && - name.startsWith(SKILL_NAMESPACE) && + name?.startsWith(SKILL_NAMESPACE) && midPromptSkillTokenMatches(lowerPrefix, name, getStaticCommandDescription(cmd)) ); }), diff --git a/packages/tui/src/fuzzy.ts b/packages/tui/src/fuzzy.ts index 5cc8845b3..94dd48a73 100644 --- a/packages/tui/src/fuzzy.ts +++ b/packages/tui/src/fuzzy.ts @@ -280,12 +280,11 @@ function prepareQuery(query: string): PreparedQuery | null { return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") }; } -function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch { +function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatch { if (pq === null) { return { matches: true, score: 0 }; } - const index = buildSearchIndex(text); if (index.words.length === 0) { return { matches: false, score: 0 }; } @@ -315,7 +314,32 @@ function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch { } export function fuzzyMatch(query: string, text: string): FuzzyMatch { - return fuzzyMatchCore(prepareQuery(query), text); + const pq = prepareQuery(query); + if (pq === null) return { matches: true, score: 0 }; + return fuzzyMatchCore(pq, buildSearchIndex(text)); +} + +/** + * A text prepared once for repeated fuzzy matching. + * + * `fuzzyMatch` builds a search index per call; the module cache only admits + * texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or + * transcript search) rebuild the index on every keystroke — the dominant cost + * when a selector re-filters a stable candidate list as the user types. Build + * one `FuzzyText` per candidate and call {@link match} per query instead; the + * index lives exactly as long as the caller's reference. + */ +export class FuzzyText { + readonly #index: SearchIndex; + + constructor(text: string) { + this.#index = buildUncachedSearchIndex(text); + } + + /** Match `query` (space-separated tokens; all must match) against the prepared text. */ + match(query: string): FuzzyMatch { + return fuzzyMatchCore(prepareQuery(query), this.#index); + } } /** @@ -327,10 +351,14 @@ export function fuzzyRank(items: T[], query: string, getText: (item: T) => st return items.map(item => ({ item, score: 0 })); } + // A non-blank query that normalizes to empty (pure punctuation) matches + // everything with score 0, but still calls getText per item — consumers rely + // on its side effects (see fuzzy-cache.test.ts). const pq = prepareQuery(query); const results: FuzzyFilterResult[] = []; for (const item of items) { - const match = fuzzyMatchCore(pq, getText(item)); + const text = getText(item); + const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, buildSearchIndex(text)); if (match.matches) { results.push({ item, score: match.score }); }