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:
@@ -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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:…`.
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user