@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3563 -3540
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/config/settings-schema.d.ts +18 -8
  5. package/dist/types/config/settings.d.ts +3 -2
  6. package/dist/types/discovery/helpers.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  8. package/dist/types/irc/bus.d.ts +4 -0
  9. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  10. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  11. package/dist/types/modes/components/index.d.ts +2 -1
  12. package/dist/types/modes/components/model-browser.d.ts +97 -0
  13. package/dist/types/modes/components/model-hub.d.ts +51 -0
  14. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  15. package/dist/types/modes/components/session-selector.d.ts +13 -0
  16. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  17. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/shared.d.ts +1 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/session-context.d.ts +9 -0
  23. package/dist/types/task/executor.d.ts +26 -13
  24. package/dist/types/task/index.d.ts +12 -11
  25. package/dist/types/task/label.d.ts +4 -0
  26. package/dist/types/task/repair-args.d.ts +8 -8
  27. package/dist/types/task/types.d.ts +31 -56
  28. package/dist/types/tools/ask.d.ts +12 -0
  29. package/dist/types/tools/conflict-detect.d.ts +17 -1
  30. package/dist/types/tools/job.d.ts +16 -0
  31. package/package.json +12 -12
  32. package/src/async/job-manager.ts +9 -0
  33. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  34. package/src/config/settings-schema.ts +17 -6
  35. package/src/config/settings.ts +13 -4
  36. package/src/discovery/helpers.ts +3 -4
  37. package/src/extensibility/custom-tools/loader.ts +70 -37
  38. package/src/extensibility/extensions/types.ts +46 -0
  39. package/src/irc/bus.ts +61 -20
  40. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  41. package/src/modes/components/advisor-config.ts +30 -22
  42. package/src/modes/components/ask-dialog.ts +888 -0
  43. package/src/modes/components/index.ts +2 -1
  44. package/src/modes/components/model-browser.ts +688 -0
  45. package/src/modes/components/model-hub.ts +1655 -0
  46. package/src/modes/components/pause-screen.ts +208 -0
  47. package/src/modes/components/session-selector.ts +299 -42
  48. package/src/modes/components/tool-execution.ts +2 -0
  49. package/src/modes/controllers/event-controller.ts +8 -2
  50. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  51. package/src/modes/controllers/selector-controller.ts +143 -87
  52. package/src/modes/controllers/tan-command-controller.ts +1 -1
  53. package/src/modes/interactive-mode.ts +3 -0
  54. package/src/modes/shared.ts +1 -1
  55. package/src/modes/theme/theme.ts +3 -3
  56. package/src/modes/types.ts +2 -0
  57. package/src/modes/utils/ui-helpers.ts +31 -4
  58. package/src/prompts/agents/scout.md +0 -1
  59. package/src/prompts/agents/task.md +1 -1
  60. package/src/prompts/system/subagent-system-prompt.md +1 -5
  61. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  62. package/src/prompts/system/task-label.md +23 -0
  63. package/src/prompts/tools/job.md +1 -1
  64. package/src/prompts/tools/task-summary.md +3 -0
  65. package/src/prompts/tools/task.md +17 -18
  66. package/src/session/agent-session.ts +2 -1
  67. package/src/session/session-context.test.ts +73 -0
  68. package/src/session/session-context.ts +43 -26
  69. package/src/slash-commands/builtin-registry.ts +9 -0
  70. package/src/task/agents.ts +2 -0
  71. package/src/task/executor.ts +159 -46
  72. package/src/task/index.ts +377 -239
  73. package/src/task/label.ts +38 -0
  74. package/src/task/render.ts +74 -22
  75. package/src/task/repair-args.ts +20 -31
  76. package/src/task/spawn-policy.test.ts +4 -4
  77. package/src/task/types.ts +46 -66
  78. package/src/tools/ask.ts +233 -40
  79. package/src/tools/conflict-detect.ts +102 -5
  80. package/src/tools/index.ts +1 -0
  81. package/src/tools/irc.ts +20 -11
  82. package/src/tools/job.ts +158 -18
  83. package/src/tools/write.ts +70 -6
  84. package/src/vibe/runtime.ts +1 -1
  85. package/src/web/search/providers/browser-headers.ts +30 -13
  86. package/dist/types/modes/components/model-selector.d.ts +0 -37
  87. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  88. package/src/modes/components/model-selector.ts +0 -1291
  89. package/src/tools/bash-command-fixup.ts +0 -4
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Fullscreen `/pause` screen.
3
+ *
4
+ * `/pause` engages the process-global {@link agentPauseGate}, freezing every
5
+ * agent loop in the process (main agent, in-process subagents, advisor) at its
6
+ * next safe boundary — nothing is aborted, so a later resume continues exactly
7
+ * where each loop parked. While engaged, this component owns the alternate
8
+ * screen (the `runStartupSplash` idiom) and paints a large pause glyph with a
9
+ * live hold timer; esc / enter / space / ctrl+c releases the gate.
10
+ *
11
+ * Use case: freeze a busy session, hand-edit the repo, resume, then explain
12
+ * the change via a normal steering message.
13
+ */
14
+ import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
15
+ import {
16
+ type Component,
17
+ matchesKey,
18
+ type OverlayFocusOwner,
19
+ type OverlayHandle,
20
+ type OverlayOptions,
21
+ visibleWidth,
22
+ } from "@oh-my-pi/pi-tui";
23
+ import { formatDuration } from "../../slash-commands/helpers/format";
24
+ import { theme } from "../theme/theme";
25
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
26
+
27
+ /**
28
+ * Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
29
+ * can exercise the full engage → hold → release lifecycle without a real TUI.
30
+ */
31
+ export interface PauseScreenHost {
32
+ ui: {
33
+ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
34
+ setFocus(component: Component): void;
35
+ requestRender(): void;
36
+ readonly terminal: { readonly rows: number };
37
+ };
38
+ showStatus(message: string, options?: { dim?: boolean }): void;
39
+ readonly sessionName?: string;
40
+ }
41
+
42
+ /** Refresh cadence for the live "paused for" clock. */
43
+ const TICK_MS = 1_000;
44
+
45
+ /** Pause-bar glyph geometry (rows × columns of full blocks per bar). */
46
+ const BAR_ROWS = 7;
47
+ const BAR_WIDTH = 5;
48
+ const BAR_GAP = 4;
49
+
50
+ /** Below either bound the full scene cannot breathe; drop to the compact card. */
51
+ const MIN_FULL_WIDTH = 64;
52
+ const MIN_FULL_HEIGHT = 18;
53
+
54
+ const TITLE = "P A U S E D";
55
+ const BODY_LINES = [
56
+ "Main agent, subagents, and advisor hold at their next step.",
57
+ "In-flight calls finish; nothing new starts until you resume.",
58
+ ] as const;
59
+ const RESUME_HINT = "esc · enter · space — resume";
60
+
61
+ function centerLine(line: string, width: number): string {
62
+ const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
63
+ return pad > 0 ? " ".repeat(pad) + line : line;
64
+ }
65
+
66
+ /** Live hold clock, seconds-precise: `0:07`, `12:34`, `1:02:03`. */
67
+ function formatClock(ms: number): string {
68
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
69
+ const seconds = totalSeconds % 60;
70
+ const minutes = Math.floor(totalSeconds / 60) % 60;
71
+ const hours = Math.floor(totalSeconds / 3600);
72
+ if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
73
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
74
+ }
75
+
76
+ /**
77
+ * Paint the pause scene as exactly `height` rows, vertically centered.
78
+ * Exported for tests.
79
+ */
80
+ export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
81
+ const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
82
+ const content: string[] = [];
83
+
84
+ if (compact) {
85
+ if (sessionName) {
86
+ content.push(centerLine(theme.bold(sessionName), width));
87
+ content.push("");
88
+ }
89
+ content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width));
90
+ content.push("");
91
+ content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
92
+ content.push(centerLine(theme.fg("dim", "esc to resume"), width));
93
+ } else {
94
+ if (sessionName) {
95
+ content.push(centerLine(theme.bold(sessionName), width));
96
+ content.push("");
97
+ content.push("");
98
+ }
99
+ const bar = "█".repeat(BAR_WIDTH);
100
+ const glyphRow = `${bar}${" ".repeat(BAR_GAP)}${bar}`;
101
+ for (let i = 0; i < BAR_ROWS; i++) {
102
+ content.push(centerLine(theme.fg("accent", glyphRow), width));
103
+ }
104
+ content.push("");
105
+ content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), width));
106
+ content.push("");
107
+ for (const line of BODY_LINES) {
108
+ content.push(centerLine(theme.fg("muted", line), width));
109
+ }
110
+ content.push("");
111
+ content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
112
+ content.push("");
113
+ content.push(centerLine(theme.fg("dim", RESUME_HINT), width));
114
+ }
115
+
116
+ const topPad = Math.max(0, Math.floor((height - content.length) / 2));
117
+ const lines: string[] = new Array(topPad).fill("");
118
+ lines.push(...content);
119
+ while (lines.length < height) lines.push("");
120
+ return lines.slice(0, Math.max(1, height));
121
+ }
122
+
123
+ /** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
124
+ export class PauseScreenComponent implements Component, OverlayFocusOwner {
125
+ #timer: NodeJS.Timeout | undefined;
126
+ #done = Promise.withResolvers<void>();
127
+ #disposed = false;
128
+ #startedAt = Date.now();
129
+
130
+ constructor(readonly host: PauseScreenHost) {}
131
+
132
+ /** Start the clock; resolves once the user asks to resume. */
133
+ run(): Promise<void> {
134
+ this.#startedAt = agentPauseGate.pausedAt ?? Date.now();
135
+ this.#timer ??= setInterval(() => {
136
+ if (!this.#disposed) this.host.ui.requestRender();
137
+ }, TICK_MS);
138
+ this.host.ui.requestRender();
139
+ return this.#done.promise;
140
+ }
141
+
142
+ dispose(): void {
143
+ this.#disposed = true;
144
+ if (this.#timer) {
145
+ clearInterval(this.#timer);
146
+ this.#timer = undefined;
147
+ }
148
+ }
149
+
150
+ ownsOverlayFocusTarget(component: Component): boolean {
151
+ return component === this;
152
+ }
153
+
154
+ handleInput(data: string): void {
155
+ // Every dismissal path resumes — including ctrl+c, which must never
156
+ // double as "abort agents" while the whole point of the screen is that
157
+ // nothing gets lost.
158
+ if (
159
+ matchesAppInterrupt(data) ||
160
+ matchesKey(data, "enter") ||
161
+ matchesKey(data, "return") ||
162
+ matchesKey(data, "space") ||
163
+ matchesKey(data, "ctrl+c")
164
+ ) {
165
+ if (!this.#disposed) this.#done.resolve();
166
+ }
167
+ }
168
+
169
+ render(width: number): readonly string[] {
170
+ const elapsed = Date.now() - this.#startedAt;
171
+ return renderPauseScreen(
172
+ Math.max(1, width),
173
+ Math.max(1, this.host.ui.terminal.rows),
174
+ elapsed,
175
+ this.host.sessionName,
176
+ );
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Engage the global pause gate and hold the fullscreen pause screen until the
182
+ * user resumes. No-op when the gate is already engaged. Always releases the
183
+ * gate on the way out (including teardown throws) — a leaked pause would
184
+ * freeze every agent in the process with no UI left to release it.
185
+ */
186
+ export async function runPauseScreen(host: PauseScreenHost): Promise<void> {
187
+ if (!agentPauseGate.pause()) return;
188
+ const component = new PauseScreenComponent(host);
189
+ const overlay = host.ui.showOverlay(component, {
190
+ width: "100%",
191
+ maxHeight: "100%",
192
+ anchor: "top-left",
193
+ margin: 0,
194
+ fullscreen: true,
195
+ });
196
+ try {
197
+ host.ui.setFocus(component);
198
+ await component.run();
199
+ } finally {
200
+ component.dispose();
201
+ host.ui.setFocus(component);
202
+ overlay.hide();
203
+ const heldMs = agentPauseGate.resume();
204
+ if (heldMs !== undefined) {
205
+ host.showStatus(`Resumed after ${formatDuration(heldMs)} — agents are running again.`);
206
+ }
207
+ }
208
+ }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  type Component,
3
3
  Container,
4
- fuzzyMatch,
4
+ FuzzyText,
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
@@ -59,6 +59,33 @@ function sessionSearchText(session: SessionInfo): string {
59
59
  return parts.filter(Boolean).join(" ");
60
60
  }
61
61
 
62
+ /**
63
+ * Lowercased per-session search haystack, built once and cached on the
64
+ * {@link SessionInfo} itself (so it dies with the listing that produced it).
65
+ * Rebuilding it per keystroke — a ~4KB string join plus `toLowerCase` per
66
+ * session — was one of the costs that made resume search visibly lag.
67
+ *
68
+ * Only the string is cached. A prebuilt fuzzy index (~60KB per 4KB session)
69
+ * would cost hundreds of MB on multi-thousand-session listings, so fuzzy
70
+ * indexes are built transiently per scan visit instead (see
71
+ * {@link scoreFuzzySession} callers).
72
+ */
73
+ const kSearchTextLower = Symbol("session.searchTextLower");
74
+
75
+ interface SearchableSessionInfo extends SessionInfo {
76
+ [kSearchTextLower]?: string;
77
+ }
78
+
79
+ function sessionTextLower(session: SessionInfo): string {
80
+ const tagged = session as SearchableSessionInfo;
81
+ let textLower = tagged[kSearchTextLower];
82
+ if (textLower === undefined) {
83
+ textLower = sessionSearchText(session).toLowerCase();
84
+ tagged[kSearchTextLower] = textLower;
85
+ }
86
+ return textLower;
87
+ }
88
+
62
89
  function tokenizeSessionQuery(query: string): string[] {
63
90
  const trimmed = query.trim().toLowerCase();
64
91
  return trimmed ? trimmed.split(/\s+/) : [];
@@ -70,6 +97,59 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
70
97
 
71
98
  const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
72
99
 
100
+ /** One ranked search hit; `index` is the session's position in the unfiltered list (recency order). */
101
+ interface RankedSessionMatch {
102
+ session: SessionInfo;
103
+ score: number;
104
+ index: number;
105
+ }
106
+
107
+ /**
108
+ * True when every query token appears verbatim in the haystack. Literal
109
+ * matches rank purely by recency, so they skip fuzzy scoring entirely — a pure
110
+ * fast path, not a semantic change: a contiguous substring of the lowercased
111
+ * text always lies within one normalized word per query sub-token, so every
112
+ * literal token also fuzzy-matches.
113
+ */
114
+ function isLiteralMatch(textLower: string, tokens: string[]): boolean {
115
+ for (const token of tokens) {
116
+ if (!textLower.includes(token)) return false;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Fuzzy-score one non-literal session against every query token. Returns
123
+ * undefined when a token fails to match or the weakest token is pure-fuzzy
124
+ * noise. The caller builds `fuzzy` once per session visit so multi-token
125
+ * queries share a single index.
126
+ */
127
+ function scoreFuzzySession(
128
+ session: SessionInfo,
129
+ index: number,
130
+ tokens: string[],
131
+ fuzzy: FuzzyText,
132
+ ): RankedSessionMatch | undefined {
133
+ let score = 0;
134
+ let worstTokenScore = Number.NEGATIVE_INFINITY;
135
+ for (const token of tokens) {
136
+ const match = fuzzy.match(token);
137
+ if (!match.matches) return undefined;
138
+ score += match.score;
139
+ worstTokenScore = Math.max(worstTokenScore, match.score);
140
+ }
141
+ if (worstTokenScore >= MIN_PURE_FUZZY_TOKEN_SCORE) return undefined;
142
+ return { session, score, index };
143
+ }
144
+
145
+ function compareLiteralRank(a: RankedSessionMatch, b: RankedSessionMatch): number {
146
+ return compareSessionRecency(a.session, b.session) || a.index - b.index;
147
+ }
148
+
149
+ function compareFuzzyRank(a: RankedSessionMatch, b: RankedSessionMatch): number {
150
+ return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
151
+ }
152
+
73
153
  /**
74
154
  * Filter and rank session picker search results.
75
155
  *
@@ -77,44 +157,33 @@ const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
77
157
  * as a literal substring, newer sessions should beat a slightly better fuzzy
78
158
  * position match. Pure fuzzy/acronym matches still sort by fuzzy score after
79
159
  * literal matches, but weak pure fuzzy tokens are dropped as noise.
160
+ *
161
+ * This is the synchronous reference implementation; {@link SessionList} runs
162
+ * the same primitives incrementally so huge listings never block a keystroke.
80
163
  */
81
164
  export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
82
165
  const tokens = tokenizeSessionQuery(query);
83
166
  if (tokens.length === 0) return allSessions;
84
167
 
85
- const results: Array<{ session: SessionInfo; score: number; literal: boolean; index: number }> = [];
168
+ const literal: RankedSessionMatch[] = [];
169
+ const fuzzyMatches: RankedSessionMatch[] = [];
86
170
  for (let index = 0; index < allSessions.length; index++) {
87
171
  const session = allSessions[index]!;
88
- const text = sessionSearchText(session);
89
- const textLower = text.toLowerCase();
90
- let score = 0;
91
- let worstTokenScore = Number.NEGATIVE_INFINITY;
92
- let literal = true;
93
- let matches = true;
94
-
95
- for (const token of tokens) {
96
- const match = fuzzyMatch(token, textLower);
97
- if (!match.matches) {
98
- matches = false;
99
- break;
100
- }
101
- score += match.score;
102
- worstTokenScore = Math.max(worstTokenScore, match.score);
103
- if (!textLower.includes(token)) literal = false;
104
- }
105
-
106
- if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
107
- results.push({ session, score, literal, index });
172
+ const textLower = sessionTextLower(session);
173
+ if (isLiteralMatch(textLower, tokens)) {
174
+ literal.push({ session, score: 0, index });
175
+ continue;
108
176
  }
177
+ const match = scoreFuzzySession(session, index, tokens, new FuzzyText(textLower));
178
+ if (match) fuzzyMatches.push(match);
109
179
  }
110
180
 
111
- results.sort((a, b) => {
112
- if (a.literal !== b.literal) return a.literal ? -1 : 1;
113
- if (a.literal) return compareSessionRecency(a.session, b.session) || a.index - b.index;
114
- return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
115
- });
116
-
117
- return results.map(result => result.session);
181
+ literal.sort(compareLiteralRank);
182
+ fuzzyMatches.sort(compareFuzzyRank);
183
+ const out: SessionInfo[] = [];
184
+ for (const match of literal) out.push(match.session);
185
+ for (const match of fuzzyMatches) out.push(match.session);
186
+ return out;
118
187
  }
119
188
 
120
189
  /**
@@ -156,6 +225,37 @@ export function mergeSessionRanking(
156
225
  return [...historyMatches, ...metadataOnly];
157
226
  }
158
227
 
228
+ /**
229
+ * Delay before the prompt-history DB is consulted for the current query.
230
+ * History matching hits SQLite synchronously (an FTS lookup plus a LIKE scan
231
+ * over every stored prompt — tens to hundreds of ms on a year-old database),
232
+ * so it must never run per keystroke: fuzzy results render immediately and
233
+ * the history merge lands once typing pauses.
234
+ */
235
+ const HISTORY_MERGE_DEBOUNCE_MS = 150;
236
+ /**
237
+ * Minimum query length for history augmentation. A single character matches
238
+ * essentially every stored prompt — the most expensive FTS prefix to expand —
239
+ * and only reorders the recency-ranked list by noise.
240
+ */
241
+ const HISTORY_MERGE_MIN_QUERY = 2;
242
+
243
+ /**
244
+ * Sessions fuzzy-scored synchronously inside the keystroke itself. Small
245
+ * listings finish within it, keeping the complete-in-one-frame behavior;
246
+ * anything left spills into async chunks. A fuzzy visit costs ~100µs (index
247
+ * build over the ≤4KB per-session corpus dominates), so 100 visits ≈ 10ms —
248
+ * about one frame. Counts rather than a deadline keep chunk boundaries
249
+ * deterministic (and testable under fake timers).
250
+ */
251
+ const FUZZY_SCAN_INLINE_COUNT = 100;
252
+ /**
253
+ * Sessions fuzzy-scored per async chunk (~15ms). Each chunk yields back to
254
+ * the event loop so the next keystroke is never blocked behind a long scan; a
255
+ * new query bumps the scan generation and orphans pending chunks.
256
+ */
257
+ const FUZZY_SCAN_CHUNK_COUNT = 150;
258
+
159
259
  /**
160
260
  * Custom session list component with multi-line items and search
161
261
  */
@@ -182,6 +282,29 @@ class SessionList implements Component {
182
282
  #allSessions: SessionInfo[];
183
283
  #showCwd: boolean;
184
284
  readonly #historyMatcher?: SessionHistoryMatcher;
285
+ #historyMergeTimer: NodeJS.Timeout | undefined;
286
+ /** Re-render hook for async list updates (fuzzy scan chunks, history merge). */
287
+ onRequestRender?: () => void;
288
+
289
+ // ── Incremental search state ──────────────────────────────────────────
290
+ // #filteredSessions is always composed from these three inputs (see
291
+ // #composeFiltered), so late-arriving fuzzy chunks and the debounced
292
+ // history merge can land in any order without clobbering each other.
293
+ /** Recency-ranked sessions whose text contains every query token verbatim. */
294
+ #literalRanked: RankedSessionMatch[] = [];
295
+ /** Score-ranked fuzzy-only matches, appended by scan chunks. */
296
+ #fuzzyRanked: RankedSessionMatch[] = [];
297
+ /** Prompt-history session IDs for the current query, once the merge landed. */
298
+ #historyIds: string[] = [];
299
+ /** Invalidates in-flight scan chunks when the query or dataset changes. */
300
+ #scanGeneration = 0;
301
+ #scanTimer: NodeJS.Timeout | undefined;
302
+ /**
303
+ * True once the user moved the selection for the current query; blocks the
304
+ * history merge from reordering the list under their cursor. (Fuzzy chunks
305
+ * only append below the literal group, which never shifts existing rows.)
306
+ */
307
+ #selectionMoved = false;
185
308
 
186
309
  constructor(
187
310
  sessions: SessionInfo[],
@@ -233,23 +356,130 @@ class SessionList implements Component {
233
356
  }
234
357
 
235
358
  #filterSessions(query: string): void {
236
- const fuzzy = rankSessionSearchMatches(this.#allSessions, query);
237
- this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy);
359
+ this.#scanGeneration++;
360
+ if (this.#scanTimer !== undefined) {
361
+ clearTimeout(this.#scanTimer);
362
+ this.#scanTimer = undefined;
363
+ }
364
+ this.#selectionMoved = false;
365
+ this.#historyIds = [];
366
+ this.#literalRanked = [];
367
+ this.#fuzzyRanked = [];
368
+
369
+ const tokens = tokenizeSessionQuery(query);
370
+ if (tokens.length === 0) {
371
+ this.#filteredSessions = this.#allSessions;
372
+ this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
373
+ this.#scheduleHistoryMerge(query);
374
+ return;
375
+ }
376
+
377
+ // Literal pass: one substring scan per token per session, synchronous so
378
+ // every keystroke gets immediate recency-ranked feedback regardless of
379
+ // listing size.
380
+ const literal: RankedSessionMatch[] = [];
381
+ const rest: number[] = [];
382
+ const all = this.#allSessions;
383
+ for (let index = 0; index < all.length; index++) {
384
+ if (isLiteralMatch(sessionTextLower(all[index]!), tokens)) {
385
+ literal.push({ session: all[index]!, score: 0, index });
386
+ } else {
387
+ rest.push(index);
388
+ }
389
+ }
390
+ literal.sort(compareLiteralRank);
391
+ this.#literalRanked = literal;
392
+
393
+ // Fuzzy pass: building a fuzzy index per session is too expensive to run
394
+ // across a huge listing inside one keystroke, so scan a bounded slice now
395
+ // and spill the remainder into async chunks.
396
+ this.#scanFuzzySlice(this.#scanGeneration, tokens, rest, 0, FUZZY_SCAN_INLINE_COUNT);
397
+ this.#composeFiltered();
398
+ this.#scheduleHistoryMerge(query);
399
+ }
400
+
401
+ /**
402
+ * Score up to `budget` sessions from `rest[start..]` (indexes into the
403
+ * unfiltered list), then schedule the remainder on a macrotask so pending
404
+ * input events run first. Chunks that added matches recompose the visible
405
+ * list and request a render; a stale generation aborts silently.
406
+ */
407
+ #scanFuzzySlice(generation: number, tokens: string[], rest: number[], start: number, budget: number): void {
408
+ const all = this.#allSessions;
409
+ const end = Math.min(rest.length, start + budget);
410
+ for (let i = start; i < end; i++) {
411
+ const index = rest[i]!;
412
+ const session = all[index]!;
413
+ const match = scoreFuzzySession(session, index, tokens, new FuzzyText(sessionTextLower(session)));
414
+ if (match) this.#fuzzyRanked.push(match);
415
+ }
416
+ if (end >= rest.length) return;
417
+ this.#scanTimer = setTimeout(() => {
418
+ this.#scanTimer = undefined;
419
+ if (generation !== this.#scanGeneration) return;
420
+ const before = this.#fuzzyRanked.length;
421
+ this.#scanFuzzySlice(generation, tokens, rest, end, FUZZY_SCAN_CHUNK_COUNT);
422
+ if (this.#fuzzyRanked.length > before) {
423
+ this.#composeFiltered();
424
+ this.onRequestRender?.();
425
+ }
426
+ }, 0);
427
+ }
428
+
429
+ /**
430
+ * Rebuild {@link #filteredSessions} from the current literal, fuzzy, and
431
+ * history inputs: literal matches first (recency), fuzzy-only matches below
432
+ * (score), prompt-history matches promoted to the top when present.
433
+ */
434
+ #composeFiltered(): void {
435
+ this.#fuzzyRanked.sort(compareFuzzyRank);
436
+ const base: SessionInfo[] = [];
437
+ for (const match of this.#literalRanked) base.push(match.session);
438
+ for (const match of this.#fuzzyRanked) base.push(match.session);
439
+ this.#filteredSessions =
440
+ this.#historyIds.length > 0 ? mergeSessionRanking(this.#allSessions, base, this.#historyIds) : base;
238
441
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
239
442
  }
240
443
 
241
444
  /**
242
- * Augment fuzzy results with prompt-history matches without replacing them.
445
+ * Augment ranked results with prompt-history matches without replacing them.
243
446
  * The session-list corpus only sees the first 4KB of each session, so a prompt
244
- * typed deep into a long session is invisible to fuzzy search; `historyMatcher`
245
- * recovers those via `history.db`.
447
+ * typed deep into a long session is invisible to text search; `historyMatcher`
448
+ * recovers those via `history.db`. The lookup hits SQLite synchronously, so it
449
+ * is debounced off the keystroke path ({@link HISTORY_MERGE_DEBOUNCE_MS}) and
450
+ * composed in when it lands, discarded if the query changed meanwhile.
246
451
  */
247
- #mergeHistoryMatches(query: string, fuzzy: SessionInfo[]): SessionInfo[] {
452
+ #scheduleHistoryMerge(query: string): void {
453
+ if (this.#historyMergeTimer !== undefined) {
454
+ clearTimeout(this.#historyMergeTimer);
455
+ this.#historyMergeTimer = undefined;
456
+ }
457
+ const matcher = this.#historyMatcher;
248
458
  const trimmed = query.trim();
249
- if (!trimmed || !this.#historyMatcher) return fuzzy;
250
- const historyIds = this.#historyMatcher(trimmed);
251
- if (historyIds.length === 0) return fuzzy;
252
- return mergeSessionRanking(this.#allSessions, fuzzy, historyIds);
459
+ if (!matcher || trimmed.length < HISTORY_MERGE_MIN_QUERY) return;
460
+ this.#historyMergeTimer = setTimeout(() => {
461
+ this.#historyMergeTimer = undefined;
462
+ if (this.#searchInput.getValue() !== query) return;
463
+ if (this.#selectionMoved) return;
464
+ const historyIds = matcher(trimmed);
465
+ if (historyIds.length === 0) return;
466
+ this.#historyIds = historyIds;
467
+ this.#composeFiltered();
468
+ this.onRequestRender?.();
469
+ }, HISTORY_MERGE_DEBOUNCE_MS);
470
+ }
471
+
472
+ /** Cancel pending async search work; idempotent, called on every picker exit path. */
473
+ dispose(): void {
474
+ this.#scanGeneration++;
475
+ if (this.#scanTimer !== undefined) {
476
+ clearTimeout(this.#scanTimer);
477
+ this.#scanTimer = undefined;
478
+ }
479
+ if (this.#historyMergeTimer !== undefined) {
480
+ clearTimeout(this.#historyMergeTimer);
481
+ this.#historyMergeTimer = undefined;
482
+ }
253
483
  }
254
484
 
255
485
  removeSession(sessionPath: string): void {
@@ -272,6 +502,7 @@ class SessionList implements Component {
272
502
  /** Wheel notch: move the selection one step (clamped, no wrap). */
273
503
  handleWheel(delta: -1 | 1): void {
274
504
  if (this.#filteredSessions.length === 0) return;
505
+ this.#selectionMoved = true;
275
506
  this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
276
507
  }
277
508
 
@@ -433,21 +664,25 @@ class SessionList implements Component {
433
664
  }
434
665
  // Up arrow
435
666
  if (matchesSelectUp(keyData)) {
667
+ this.#selectionMoved = true;
436
668
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
437
669
  return;
438
670
  }
439
671
  // Down arrow
440
672
  if (matchesSelectDown(keyData)) {
673
+ this.#selectionMoved = true;
441
674
  this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1);
442
675
  return;
443
676
  }
444
677
  // Page up - jump up by maxVisible items
445
678
  if (matchesKey(keyData, "pageUp")) {
679
+ this.#selectionMoved = true;
446
680
  this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount());
447
681
  return;
448
682
  }
449
683
  // Page down - jump down by maxVisible items
450
684
  if (matchesKey(keyData, "pageDown")) {
685
+ this.#selectionMoved = true;
451
686
  this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#visibleCount());
452
687
  return;
453
688
  }
@@ -566,9 +801,21 @@ export class SessionSelectorComponent extends Container {
566
801
  // user to Tab into all-projects rather than silently surfacing other
567
802
  // projects' history (issue #3099).
568
803
  this.#sessionList = new SessionList(sessions, false, options.historyMatcher, options.getTerminalRows);
569
- this.#sessionList.onSelect = onSelect;
570
- this.#sessionList.onCancel = onCancel;
571
- this.#sessionList.onExit = onExit;
804
+ // Every exit path cancels the list's pending history merge, so a stale
805
+ // debounce timer can never run its SQLite lookup after the picker closed.
806
+ this.#sessionList.onSelect = session => {
807
+ this.#sessionList.dispose();
808
+ onSelect(session);
809
+ };
810
+ this.#sessionList.onCancel = () => {
811
+ this.#sessionList.dispose();
812
+ onCancel();
813
+ };
814
+ this.#sessionList.onExit = () => {
815
+ this.#sessionList.dispose();
816
+ onExit();
817
+ };
818
+ this.#sessionList.onRequestRender = () => this.#onRequestRender?.();
572
819
  this.#sessionList.onDeleteRequest = (session: SessionInfo) => {
573
820
  this.#showDeleteConfirmation(session);
574
821
  };
@@ -628,6 +875,16 @@ export class SessionSelectorComponent extends Container {
628
875
  this.#onRequestRender = callback;
629
876
  }
630
877
 
878
+ /**
879
+ * Dispose the session list explicitly: while the delete-confirmation dialog
880
+ * is mounted the list is detached from the child tree, so Container's
881
+ * child-walking dispose would miss its pending history-merge timer.
882
+ */
883
+ dispose(): void {
884
+ this.#sessionList.dispose();
885
+ super.dispose();
886
+ }
887
+
631
888
  #clearError(): void {
632
889
  this.#messageContainer.clear();
633
890
  }
@@ -180,6 +180,8 @@ export interface ToolExecutionHandle extends Component {
180
180
  ): void;
181
181
  setArgsComplete(toolCallId?: string): void;
182
182
  setExpanded(expanded: boolean): void;
183
+ /** Freeze the block as final history: stop spinners and let it commit to scrollback. */
184
+ seal(): void;
183
185
  }
184
186
 
185
187
  /** Redraw live tool blocks at the spinner's glyph-advance rate. Rendering more
@@ -943,12 +943,18 @@ export class EventController {
943
943
  if (component) {
944
944
  const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state;
945
945
  const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
946
+ // A final async snapshot is terminal only for a parked background
947
+ // block (the call already returned and was kept alive for its jobs).
948
+ // While the call is still executing — a mixed blocking+async task
949
+ // call whose jobs settle before its blocking subset — treat it as a
950
+ // partial frame: `tool_execution_end` still owns the terminal result.
951
+ const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId);
946
952
  component.updateResult(
947
953
  { ...event.partialResult, isError: asyncState === "failed" },
948
- !isFinalAsyncState,
954
+ !isTerminal,
949
955
  event.toolCallId,
950
956
  );
951
- if (isFinalAsyncState) {
957
+ if (isTerminal) {
952
958
  this.ctx.pendingTools.delete(event.toolCallId);
953
959
  this.#backgroundToolCallIds.delete(event.toolCallId);
954
960
  }