perf(coding-agent): accelerated session selector with tiered search

- Implemented tiered search strategy using synchronous literal matching for immediate results and debounced asynchronous fuzzy matching to prevent input blocking.
- Cached session search data and introduced an indexed `FuzzyText` structure to reduce redundant string processing.
- Added comprehensive test suite verifying search convergence, stale task orphaning, and selection stability.
- Enhanced `tui` fuzzy matching utilities to support prebuilt search indexes for improved multi-match performance.
This commit is contained in:
can1357
2026-07-11 15:52:35 +02:00
parent ab7b776f9a
commit c6b83c1d96
6 changed files with 537 additions and 48 deletions
+1
View File
@@ -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
@@ -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;
const textLower = sessionTextLower(session);
if (isLiteralMatch(textLower, tokens)) {
literal.push({ session, score: 0, index });
continue;
}
score += match.score;
worstTokenScore = Math.max(worstTokenScore, match.score);
if (!textLower.includes(token)) literal = false;
const match = scoreFuzzySession(session, index, tokens, new FuzzyText(textLower));
if (match) fuzzyMatches.push(match);
}
if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
results.push({ session, score, literal, index });
}
}
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();
}
@@ -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> = {}): 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);
});
});
+4
View File
@@ -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:…`.
+1 -2
View File
@@ -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))
);
}),
+32 -4
View File
@@ -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<T>(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<T>[] = [];
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 });
}