@oh-my-pi/pi-tui 18.7.0 → 18.8.0

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 (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -49,7 +49,14 @@ export type AgentTranscriptEntry = SessionMessageEntryLike | { type: "model_chan
49
49
 
50
50
  /** Local filesystem and session parsing capabilities supplied by the host. */
51
51
  export interface AgentTranscriptSource {
52
- fs: Pick<typeof fs, "openSync" | "closeSync" | "readSync" | "readFileSync" | "statSync">;
52
+ /**
53
+ * Sync primitives drive the first paint and the incremental tail. When the
54
+ * host also supplies `promises.readFile` (e.g. passes `node:fs` itself), full
55
+ * reloads after rotation/rewrite read the file off the UI thread.
56
+ */
57
+ fs: Pick<typeof fs, "openSync" | "closeSync" | "readSync" | "readFileSync" | "statSync"> & {
58
+ promises?: Pick<typeof fs.promises, "readFile">;
59
+ };
53
60
  parseEntries(text: string): AgentTranscriptEntry[];
54
61
  }
55
62
 
@@ -86,6 +93,8 @@ export interface AgentTranscriptViewerDeps {
86
93
 
87
94
  /** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
88
95
  const POLL_MS = 250;
96
+ /** Every Nth idle poll re-verifies sentinels, catching same-size/mtime rewrites. */
97
+ const IDLE_SENTINEL_CHECK_EVERY = 5;
89
98
 
90
99
  const SENTINEL_BYTES = 4096;
91
100
 
@@ -183,6 +192,10 @@ export class AgentTranscriptViewer implements Component {
183
192
  #model: string | undefined;
184
193
  #pollTimer: NodeJS.Timeout | undefined;
185
194
  #disposed = false;
195
+ /** Async full reload in flight; polls are skipped until it lands. */
196
+ #localLoadInFlight = false;
197
+ /** Idle polls since the sentinels were last verified. */
198
+ #idlePolls = 0;
186
199
  #initialEntryId: string | undefined;
187
200
  /** Last described node and the visible inputs it was built from. */
188
201
  #nativeCache: { signature: string; node: NativeNode } | undefined;
@@ -215,8 +228,10 @@ export class AgentTranscriptViewer implements Component {
215
228
  this.#editor.setMaxHeight(4);
216
229
  this.#editor.onSubmit = text => this.#submit(text);
217
230
  }
218
- this.#refresh();
219
- this.#pollTimer = setInterval(() => this.#refresh(), POLL_MS);
231
+ // First paint loads synchronously so the initial entry can be revealed
232
+ // immediately; later full reloads from the poll may go async.
233
+ this.#refresh(false);
234
+ this.#pollTimer = setInterval(() => this.#refresh(true), POLL_MS);
220
235
  this.#pollTimer.unref?.();
221
236
  }
222
237
 
@@ -245,12 +260,13 @@ export class AgentTranscriptViewer implements Component {
245
260
  // ========================================================================
246
261
 
247
262
  /** Refresh the transcript from a local file or remote host. */
248
- #refresh(): void {
263
+ #refresh(allowAsync: boolean): void {
249
264
  if (this.#disposed) return;
250
265
  if (this.#deps.remote) {
251
266
  this.#fetchRemote();
252
267
  return;
253
268
  }
269
+ if (this.#localLoadInFlight) return;
254
270
  const sessionFile = this.#deps.registry.get(this.#deps.agentId)?.sessionFile;
255
271
  if (!sessionFile) {
256
272
  this.#clearLocal("none");
@@ -264,14 +280,29 @@ export class AgentTranscriptViewer implements Component {
264
280
  return;
265
281
  }
266
282
  const state = this.#localState;
267
- if (state && this.#canAppendLocal(sessionFile, stat, state)) {
268
- if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
269
- if (stat.size > state.size) {
270
- this.#appendLocal(sessionFile, stat, state);
283
+ if (state) {
284
+ // Idle fast path: an unchanged identity/size/mtime costs one stat;
285
+ // sentinels are re-read only every Nth idle poll so a rewrite that
286
+ // keeps size and mtime is still noticed.
287
+ if (
288
+ state.path === sessionFile &&
289
+ state.dev === stat.dev &&
290
+ state.ino === stat.ino &&
291
+ stat.size === state.size &&
292
+ stat.mtimeMs === state.mtimeMs
293
+ ) {
294
+ if (++this.#idlePolls < IDLE_SENTINEL_CHECK_EVERY) return;
295
+ this.#idlePolls = 0;
296
+ if (this.#canAppendLocal(sessionFile, stat, state)) return;
297
+ this.#loadLocalFull(sessionFile, stat, allowAsync);
298
+ return;
299
+ }
300
+ if (stat.size > state.size && this.#canAppendLocal(sessionFile, stat, state)) {
301
+ this.#appendLocal(sessionFile, stat, state, allowAsync);
271
302
  return;
272
303
  }
273
304
  }
274
- this.#loadLocalFull(sessionFile, stat);
305
+ this.#loadLocalFull(sessionFile, stat, allowAsync);
275
306
  }
276
307
 
277
308
  #clearLocal(reason: string): void {
@@ -305,7 +336,23 @@ export class AgentTranscriptViewer implements Component {
305
336
  return true;
306
337
  }
307
338
 
308
- #loadLocalFull(sessionFile: string, stat: fs.Stats): void {
339
+ #loadLocalFull(sessionFile: string, stat: fs.Stats, allowAsync: boolean): void {
340
+ const promises = allowAsync ? this.#deps.transcript.fs.promises : undefined;
341
+ if (promises) {
342
+ this.#localLoadInFlight = true;
343
+ void promises
344
+ .readFile(sessionFile)
345
+ .then(data => {
346
+ this.#localLoadInFlight = false;
347
+ if (!this.#disposed) this.#applyLocalFull(sessionFile, stat, data);
348
+ })
349
+ .catch((err: unknown) => {
350
+ // Leave #localState unchanged so a transient read error retries next poll.
351
+ this.#localLoadInFlight = false;
352
+ logger.debug("transcript viewer: read failed", { err: String(err) });
353
+ });
354
+ return;
355
+ }
309
356
  let data: Buffer;
310
357
  try {
311
358
  data = this.#deps.transcript.fs.readFileSync(sessionFile);
@@ -314,6 +361,10 @@ export class AgentTranscriptViewer implements Component {
314
361
  logger.debug("transcript viewer: read failed", { err: String(err) });
315
362
  return;
316
363
  }
364
+ this.#applyLocalFull(sessionFile, stat, data);
365
+ }
366
+
367
+ #applyLocalFull(sessionFile: string, stat: fs.Stats, data: Buffer): void {
317
368
  // The file may have grown between the earlier `statSync` and this read.
318
369
  // Anchor the tail cursor to what we actually consumed so the next poll's
319
370
  // `#appendLocal` never re-renders bytes already in the rebuilt transcript;
@@ -347,7 +398,7 @@ export class AgentTranscriptViewer implements Component {
347
398
  this.#rebuild(this.#extractMessages(this.#deps.transcript.parseEntries(complete)));
348
399
  }
349
400
 
350
- #appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
401
+ #appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState, allowAsync: boolean): void {
351
402
  let chunk: string;
352
403
  try {
353
404
  chunk = readFileRangeSync(
@@ -358,7 +409,7 @@ export class AgentTranscriptViewer implements Component {
358
409
  ).toString("utf-8");
359
410
  } catch (err) {
360
411
  logger.debug("transcript viewer: tail read failed", { err: String(err) });
361
- this.#loadLocalFull(sessionFile, stat);
412
+ this.#loadLocalFull(sessionFile, stat, allowAsync);
362
413
  return;
363
414
  }
364
415
  const combined = state.pending + chunk;
@@ -373,7 +424,7 @@ export class AgentTranscriptViewer implements Component {
373
424
  // File unlinked/rotated mid-poll: fall back to a guarded full reload
374
425
  // instead of letting the open escape the poll timer.
375
426
  logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
376
- this.#loadLocalFull(sessionFile, stat);
427
+ this.#loadLocalFull(sessionFile, stat, allowAsync);
377
428
  return;
378
429
  }
379
430
  this.#localState = {
@@ -15,7 +15,7 @@ import type { TspPickerAction, TspPickerColumn, TspPickerItem, TspPickerScope, T
15
15
  import {
16
16
  type Component,
17
17
  Editor,
18
- fuzzyMatch,
18
+ FuzzyQuery,
19
19
  Input,
20
20
  matchesKey,
21
21
  replaceTabs,
@@ -194,12 +194,12 @@ function listRowId(rowDef: ListRow): string {
194
194
  return rowDef.kind === "new" ? "new" : `agent:${rowDef.agent.source}:${rowDef.agent.name}`;
195
195
  }
196
196
 
197
- function matchAgent(agent: HubAgent, query: string): boolean {
197
+ /** All `tokens` (prepared once per query) fuzzy-match the agent's searchable text. */
198
+ function matchAgent(agent: HubAgent, tokens: readonly FuzzyQuery[]): boolean {
199
+ // Not memoized: `overrideModel` is edited in place, and the module index
200
+ // cache already reuses the per-text index for unchanged agents.
198
201
  const text = `${agent.name} ${agent.description} ${SOURCE_LABEL[agent.source]} ${agent.overrideModel ?? ""}`;
199
- return query
200
- .trim()
201
- .split(/\s+/)
202
- .every(token => fuzzyMatch(token, text).matches);
202
+ return tokens.every(token => token.match(text).matches);
203
203
  }
204
204
 
205
205
  /**
@@ -373,7 +373,14 @@ export class AgentsHubComponent implements Component {
373
373
  const scoped =
374
374
  entry.kind === "source" ? this.#allAgents.filter(agent => agent.source === entry.source) : this.#allAgents;
375
375
  const query = this.#search.getValue();
376
- const filtered = query ? scoped.filter(agent => matchAgent(agent, query)) : scoped;
376
+ let filtered = scoped;
377
+ if (query) {
378
+ const tokens = query
379
+ .trim()
380
+ .split(/\s+/)
381
+ .map(token => new FuzzyQuery(token));
382
+ filtered = scoped.filter(agent => matchAgent(agent, tokens));
383
+ }
377
384
  this.#rows = [...filtered.map(agent => ({ kind: "agent", agent }) as ListRow), { kind: "new" }];
378
385
  }
379
386
 
@@ -327,6 +327,12 @@ interface ControlRegion {
327
327
  end: number;
328
328
  }
329
329
 
330
+ /** A block's preview rows (highlighted, width-cut, plus the "more lines" row) and its full line count. */
331
+ interface BlockPreview {
332
+ rows: string[];
333
+ lineCount: number;
334
+ }
335
+
330
336
  export class CopySelectorComponent implements Component {
331
337
  #builder: ChatTranscriptBuilder;
332
338
  #browser: TranscriptBrowser;
@@ -348,6 +354,8 @@ export class CopySelectorComponent implements Component {
348
354
 
349
355
  /** Last described root and the state it was built from. */
350
356
  #native: { memo: string; targets: OutlineTarget[]; blocks: CopyBlock[] | undefined; node: NativeNode } | undefined;
357
+ /** ANSI block previews (syntax highlight + width cut) of the exploded turn. */
358
+ #blockPreviews: { blocks: CopyBlock[]; inner: number; previews: readonly BlockPreview[] } | undefined;
351
359
  /** Transcript list items, rebuilt only when the replayed targets change. */
352
360
  #nativeItems: { targets: OutlineTarget[]; truncated: boolean; items: NativeNode[] } | undefined;
353
361
  /** Timeline picker items of the replayed targets. */
@@ -414,6 +422,7 @@ export class CopySelectorComponent implements Component {
414
422
  }
415
423
 
416
424
  invalidate(): void {
425
+ this.#blockPreviews = undefined;
417
426
  this.#builder.container.invalidate();
418
427
  this.#browser.invalidate();
419
428
  }
@@ -945,18 +954,13 @@ export class CopySelectorComponent implements Component {
945
954
  */
946
955
  #composeBlocks(blocks: CopyBlock[], columnWidth: number, lineOffset: number): ComposedColumn {
947
956
  const inner = Math.max(10, columnWidth - 4);
957
+ const previews = this.#previewBlocks(blocks, inner);
948
958
  const lines: string[] = [];
949
959
  let selStart = -1;
950
960
  let selEnd = -1;
951
961
  for (let index = 0; index < blocks.length; index++) {
952
962
  const block = blocks[index]!;
953
- const raw = block.content.split("\n");
954
- const shown = raw.slice(0, BLOCK_PREVIEW_LINES);
955
- const styled = block.language ? highlightCode(shown.join("\n"), block.language) : shown;
956
- const rows = styled.map(row => truncateToWidth(replaceTabs(row), inner));
957
- if (raw.length > shown.length) {
958
- rows.push(theme.fg("dim", `… +${raw.length - shown.length} more lines`));
959
- }
963
+ const { rows, lineCount } = previews[index]!;
960
964
  const selected = index === this.#blockSelected;
961
965
  const captionColor: ThemeColor = selected ? OUTLINE_COLOR : "dim";
962
966
  const controls: Array<{ action: ControlRegion["action"]; text: string }> = [
@@ -965,7 +969,7 @@ export class CopySelectorComponent implements Component {
965
969
  if (block.href && this.deps.onOpen) controls.push({ action: "open", text: `${theme.cmd.share} open` });
966
970
  const controlsWidth = controls.reduce((sum, control) => sum + visibleWidth(control.text) + 2, 0);
967
971
  const summary = truncateToWidth(
968
- `${index + 1}/${blocks.length}${theme.sep.dot}${block.label}${theme.sep.dot}${raw.length} line${raw.length === 1 ? "" : "s"}`,
972
+ `${index + 1}/${blocks.length}${theme.sep.dot}${block.label}${theme.sep.dot}${lineCount} line${lineCount === 1 ? "" : "s"}`,
969
973
  Math.max(4, inner - controlsWidth),
970
974
  );
971
975
  // Caption: two-space gutter, summary, then the controls, each preceded by two spaces.
@@ -998,6 +1002,24 @@ export class CopySelectorComponent implements Component {
998
1002
  lines.push("");
999
1003
  return { lines, selStart, selEnd };
1000
1004
  }
1005
+
1006
+ /** Highlighted, width-cut previews of each block; rebuilt when the block set or width changes. */
1007
+ #previewBlocks(blocks: CopyBlock[], inner: number): readonly BlockPreview[] {
1008
+ const cached = this.#blockPreviews;
1009
+ if (cached?.blocks === blocks && cached.inner === inner) return cached.previews;
1010
+ const previews = blocks.map(block => {
1011
+ const raw = block.content.split("\n");
1012
+ const shown = raw.slice(0, BLOCK_PREVIEW_LINES);
1013
+ const styled = block.language ? highlightCode(shown.join("\n"), block.language) : shown;
1014
+ const rows = styled.map(row => truncateToWidth(replaceTabs(row), inner));
1015
+ if (raw.length > shown.length) {
1016
+ rows.push(theme.fg("dim", `… +${raw.length - shown.length} more lines`));
1017
+ }
1018
+ return { rows, lineCount: raw.length };
1019
+ });
1020
+ this.#blockPreviews = { blocks, inner, previews };
1021
+ return previews;
1022
+ }
1001
1023
  }
1002
1024
 
1003
1025
  /** Raw multi-line text of a user message (string or text blocks). */
@@ -1,5 +1,5 @@
1
1
  /** Pure dashboard tree, filtering, and selection state. */
2
- import { fuzzyMatch } from "../../fuzzy";
2
+ import { FuzzyQuery, FuzzyText } from "../../fuzzy";
3
3
  import {
4
4
  type DashboardState,
5
5
  type DisabledReason,
@@ -118,6 +118,50 @@ export function flattenTree(tree: TreeNode[]): FlatTreeItem[] {
118
118
  return flat;
119
119
  }
120
120
 
121
+ /** Searchable fields an extension's cached {@link FuzzyText} was built from. */
122
+ interface ExtensionSearchMemo {
123
+ name: string;
124
+ displayName: string;
125
+ description: string | undefined;
126
+ trigger: string | undefined;
127
+ providerName: string;
128
+ kind: string;
129
+ text: FuzzyText;
130
+ }
131
+
132
+ /** Prepared search text per extension; rebuilt if any searchable field changes. */
133
+ const extensionSearchCache = new WeakMap<Extension, ExtensionSearchMemo>();
134
+
135
+ function extensionSearchText(ext: Extension): FuzzyText {
136
+ const memo = extensionSearchCache.get(ext);
137
+ if (
138
+ memo !== undefined &&
139
+ memo.name === ext.name &&
140
+ memo.displayName === ext.displayName &&
141
+ memo.description === ext.description &&
142
+ memo.trigger === ext.trigger &&
143
+ memo.providerName === ext.source.providerName &&
144
+ memo.kind === ext.kind
145
+ ) {
146
+ return memo.text;
147
+ }
148
+ const text = new FuzzyText(
149
+ [ext.name, ext.displayName, ext.description || "", ext.trigger || "", ext.source.providerName, ext.kind].join(
150
+ " ",
151
+ ),
152
+ );
153
+ extensionSearchCache.set(ext, {
154
+ name: ext.name,
155
+ displayName: ext.displayName,
156
+ description: ext.description,
157
+ trigger: ext.trigger,
158
+ providerName: ext.source.providerName,
159
+ kind: ext.kind,
160
+ text,
161
+ });
162
+ return text;
163
+ }
164
+
121
165
  /**
122
166
  * Apply fuzzy filter to extensions.
123
167
  */
@@ -126,22 +170,18 @@ export function applyFilter(extensions: Extension[], query: string): Extension[]
126
170
  return extensions;
127
171
  }
128
172
 
129
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
173
+ const tokens = query
174
+ .toLowerCase()
175
+ .split(/\s+/)
176
+ .filter(Boolean)
177
+ .map(token => new FuzzyQuery(token));
130
178
  if (tokens.length === 0) {
131
179
  return extensions;
132
180
  }
133
181
 
134
182
  return extensions.filter(ext => {
135
- const searchable = [
136
- ext.name,
137
- ext.displayName,
138
- ext.description || "",
139
- ext.trigger || "",
140
- ext.source.providerName,
141
- ext.kind,
142
- ].join(" ");
143
-
144
- return tokens.every(token => fuzzyMatch(token, searchable).matches);
183
+ const searchable = extensionSearchText(ext);
184
+ return tokens.every(token => token.match(searchable).matches);
145
185
  });
146
186
  }
147
187
 
@@ -65,6 +65,13 @@ const PICKER_KEY = "picker";
65
65
  /** Visible result rows; also the jump distance for PageUp/PageDown. */
66
66
  const MAX_VISIBLE = 10;
67
67
 
68
+ /**
69
+ * Quiet period after the last query edit before the synchronous storage
70
+ * search runs. Only applied when the host wires {@link HistorySearchComponent.setOnRequestRender},
71
+ * since the deferred results need a repaint to show up.
72
+ */
73
+ const SEARCH_DEBOUNCE_MS = 100;
74
+
68
75
  /** Split a query the same way `HistorySource` tokenizes it, so highlights align with matches. */
69
76
  function queryTokens(query: string): string[] {
70
77
  return query
@@ -106,6 +113,8 @@ class HistoryResultsList implements Component {
106
113
  #menu: MenuSelection<HistorySearchEntry>;
107
114
  #tokens: string[] = [];
108
115
  #maxVisible = MAX_VISIBLE;
116
+ /** Highlighted prompt text per entry, valid for one `(tokens, budget)` pair; reset on invalidate (theme). */
117
+ #highlightCache = new WeakMap<HistorySearchEntry, { tokens: string[]; budget: number; text: string }>();
109
118
 
110
119
  constructor(menu: MenuSelection<HistorySearchEntry>) {
111
120
  this.#menu = menu;
@@ -116,7 +125,17 @@ class HistoryResultsList implements Component {
116
125
  }
117
126
 
118
127
  invalidate(): void {
119
- // No cached state to invalidate currently
128
+ this.#highlightCache = new WeakMap();
129
+ }
130
+
131
+ /** Truncated, token-highlighted prompt of `entry` for `budget` cells. */
132
+ #highlightedPrompt(entry: HistorySearchEntry, budget: number): string {
133
+ const cached = this.#highlightCache.get(entry);
134
+ if (cached !== undefined && cached.tokens === this.#tokens && cached.budget === budget) return cached.text;
135
+ const normalized = entry.prompt.replace(/\s+/g, " ").trim();
136
+ const text = highlightTokens(truncateToWidth(normalized, budget), this.#tokens);
137
+ this.#highlightCache.set(entry, { tokens: this.#tokens, budget, text });
138
+ return text;
120
139
  }
121
140
 
122
141
  render(width: number): readonly string[] {
@@ -151,9 +170,7 @@ class HistoryResultsList implements Component {
151
170
  const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
152
171
 
153
172
  const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
154
- const normalized = entry.prompt.replace(/\s+/g, " ").trim();
155
- const plain = truncateToWidth(normalized, promptBudget);
156
- const highlighted = highlightTokens(plain, this.#tokens);
173
+ const highlighted = this.#highlightedPrompt(entry, promptBudget);
157
174
 
158
175
  const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(gutterWidth);
159
176
  let line = cursor + (isSelected ? theme.bold(highlighted) : highlighted);
@@ -186,12 +203,22 @@ export class HistorySearchComponent extends OverlayPanel {
186
203
  #nativeHints: NativeNode | undefined;
187
204
  #nativeMemo: HistoryNativeMemo | undefined;
188
205
  #pickerItems: { items: readonly HistorySearchEntry[]; rows: readonly TspPickerItem[] } | undefined;
206
+ /** Trimmed query the current results were fetched for; `undefined` before the first fetch. */
207
+ #resultsQuery: string | undefined;
208
+ #searchTimer: NodeJS.Timeout | undefined;
209
+ #onRequestRender?: () => void;
189
210
 
190
211
  constructor(historyStorage: HistorySource, onSelect: (prompt: string) => void, onCancel: () => void) {
191
212
  super("History", "omp.overlay.history");
192
213
  this.#historyStorage = historyStorage;
193
- this.#onSelect = onSelect;
194
- this.#onCancel = onCancel;
214
+ this.#onSelect = prompt => {
215
+ this.#cancelPendingSearch();
216
+ onSelect(prompt);
217
+ };
218
+ this.#onCancel = () => {
219
+ this.#cancelPendingSearch();
220
+ onCancel();
221
+ };
195
222
 
196
223
  this.#menu = new MenuSelection<HistorySearchEntry>([], {
197
224
  getKey: entry => `${entry.created_at}:${entry.prompt}`,
@@ -199,6 +226,7 @@ export class HistorySearchComponent extends OverlayPanel {
199
226
  });
200
227
  this.#searchInput = new Input();
201
228
  this.#searchInput.onSubmit = () => {
229
+ this.#flushPendingSearch();
202
230
  const selected = this.#menu.selectedItem;
203
231
  if (selected) {
204
232
  this.#onSelect(selected.prompt);
@@ -225,38 +253,59 @@ export class HistorySearchComponent extends OverlayPanel {
225
253
  this.#updateResults();
226
254
  }
227
255
 
256
+ /**
257
+ * Repaint hook for debounced result updates. Once set, query edits search
258
+ * storage after a {@link SEARCH_DEBOUNCE_MS} quiet period instead of on
259
+ * every key; without it every edit searches synchronously.
260
+ */
261
+ setOnRequestRender(callback: () => void): void {
262
+ this.#onRequestRender = callback;
263
+ }
264
+
265
+ override dispose(): void {
266
+ this.#cancelPendingSearch();
267
+ super.dispose();
268
+ }
269
+
228
270
  handleInput(keyData: string): void {
229
271
  if (matchesSelectUp(keyData)) {
272
+ this.#flushPendingSearch();
230
273
  this.#menu.move(-1, false);
231
274
  return;
232
275
  }
233
276
 
234
277
  if (matchesSelectDown(keyData)) {
278
+ this.#flushPendingSearch();
235
279
  this.#menu.move(1, false);
236
280
  return;
237
281
  }
238
282
 
239
283
  if (matchesSelectPageUp(keyData)) {
284
+ this.#flushPendingSearch();
240
285
  this.#menu.move(-MAX_VISIBLE, false);
241
286
  return;
242
287
  }
243
288
 
244
289
  if (matchesSelectPageDown(keyData)) {
290
+ this.#flushPendingSearch();
245
291
  this.#menu.move(MAX_VISIBLE, false);
246
292
  return;
247
293
  }
248
294
 
249
295
  if (matchesKey(keyData, "home")) {
296
+ this.#flushPendingSearch();
250
297
  this.#menu.moveToBoundary("first");
251
298
  return;
252
299
  }
253
300
 
254
301
  if (matchesKey(keyData, "end")) {
302
+ this.#flushPendingSearch();
255
303
  this.#menu.moveToBoundary("last");
256
304
  return;
257
305
  }
258
306
 
259
307
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
308
+ this.#flushPendingSearch();
260
309
  const selected = this.#menu.selectedItem;
261
310
  if (selected) {
262
311
  this.#onSelect(selected.prompt);
@@ -270,7 +319,7 @@ export class HistorySearchComponent extends OverlayPanel {
270
319
  }
271
320
 
272
321
  this.#searchInput.handleInput(keyData);
273
- this.#updateResults();
322
+ this.#scheduleResults();
274
323
  }
275
324
 
276
325
  /**
@@ -372,6 +421,9 @@ export class HistorySearchComponent extends OverlayPanel {
372
421
  * Picker: a row click highlights, a second click or `Insert` inserts it
373
422
  * (Enter), `Close` cancels (Esc), `Clear search` empties the query.
374
423
  * List: picking a result does what highlighting it and pressing Enter does.
424
+ * A row picked while a debounced search is pending belongs to the previous
425
+ * query: results are refreshed first and the pick only lands if that entry
426
+ * is still among them.
375
427
  */
376
428
  handleNativeEvent(event: NativeUiEvent): void {
377
429
  const ev = pickerEvent(event, PICKER_KEY);
@@ -385,6 +437,7 @@ export class HistorySearchComponent extends OverlayPanel {
385
437
  }
386
438
  return;
387
439
  }
440
+ this.#flushPendingSearch();
388
441
  const index = this.#menu.visibleItems.findIndex(entry => nativeEntryKey(entry) === ev.item);
389
442
  if (index < 0) return;
390
443
  this.#menu.setSelectedIndex(index);
@@ -392,6 +445,7 @@ export class HistorySearchComponent extends OverlayPanel {
392
445
  return;
393
446
  }
394
447
  if ((event.type !== "select" && event.type !== "activate") || event.key !== "list") return;
448
+ this.#flushPendingSearch();
395
449
  const index = this.#menu.visibleItems.findIndex(entry => nativeEntryKey(entry) === event.item);
396
450
  const target = this.#menu.visibleItems[index];
397
451
  if (!target) return;
@@ -399,8 +453,47 @@ export class HistorySearchComponent extends OverlayPanel {
399
453
  this.#onSelect(target.prompt);
400
454
  }
401
455
 
456
+ /**
457
+ * Refresh results after a query edit: no-op when the trimmed query is
458
+ * unchanged (cursor moves, trailing spaces), immediate for the empty query
459
+ * or when no repaint hook is wired, debounced otherwise.
460
+ */
461
+ #scheduleResults(): void {
462
+ const query = this.#searchInput.getValue().trim();
463
+ if (query === this.#resultsQuery) {
464
+ this.#cancelPendingSearch();
465
+ return;
466
+ }
467
+ const requestRender = this.#onRequestRender;
468
+ if (!query || !requestRender) {
469
+ this.#updateResults();
470
+ return;
471
+ }
472
+ this.#cancelPendingSearch();
473
+ this.#searchTimer = setTimeout(() => {
474
+ this.#searchTimer = undefined;
475
+ this.#updateResults();
476
+ requestRender();
477
+ }, SEARCH_DEBOUNCE_MS);
478
+ }
479
+
480
+ /** Run a pending debounced search now, so selection acts on the current query's results. */
481
+ #flushPendingSearch(): void {
482
+ if (this.#searchTimer === undefined) return;
483
+ this.#cancelPendingSearch();
484
+ this.#updateResults();
485
+ }
486
+
487
+ #cancelPendingSearch(): void {
488
+ if (this.#searchTimer === undefined) return;
489
+ clearTimeout(this.#searchTimer);
490
+ this.#searchTimer = undefined;
491
+ }
492
+
402
493
  #updateResults(): void {
494
+ this.#cancelPendingSearch();
403
495
  const query = this.#searchInput.getValue().trim();
496
+ this.#resultsQuery = query;
404
497
  const results = query
405
498
  ? this.#historyStorage.search(query, this.#resultLimit)
406
499
  : this.#historyStorage.getRecent(this.#resultLimit);
@@ -110,6 +110,8 @@ export class JobsSheet implements Component {
110
110
  readonly #source: JobsSheetSource;
111
111
  #selectedId: string | undefined;
112
112
  #native: { key: string; output: string | undefined; node: NativeNode } | undefined;
113
+ /** Fallback output tail, reused while the selected job's output is unchanged. */
114
+ #tail: { id: string; output: string; lines: readonly string[] } | undefined;
113
115
 
114
116
  constructor(source: JobsSheetSource) {
115
117
  this.#source = source;
@@ -177,8 +179,9 @@ export class JobsSheet implements Component {
177
179
  detail.artifactId && `artifact://${detail.artifactId}`,
178
180
  ]);
179
181
  if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
180
- const tail = (detail.output ?? "").trimEnd().split("\n").slice(-FALLBACK_TAIL_LINES);
181
- for (const line of tail) if (line) lines.push(truncateToWidth(` ${line}`, width));
182
+ for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
183
+ if (line) lines.push(truncateToWidth(` ${line}`, width));
184
+ }
182
185
  }
183
186
  return lines;
184
187
  }
@@ -210,6 +213,14 @@ export class JobsSheet implements Component {
210
213
  return { jobs, selected };
211
214
  }
212
215
 
216
+ #tailLines(id: string, output: string): readonly string[] {
217
+ const cached = this.#tail;
218
+ if (cached?.id === id && cached.output === output) return cached.lines;
219
+ const lines = tailLines(output, FALLBACK_TAIL_LINES);
220
+ this.#tail = { id, output, lines };
221
+ return lines;
222
+ }
223
+
213
224
  #move(delta: number): void {
214
225
  const { jobs, selected } = this.#current();
215
226
  if (!selected) return;
@@ -228,6 +239,23 @@ function jobAge(job: JobsPanelJob, nowMs: number): number {
228
239
  return Math.max(0, (job.endTime ?? nowMs) - job.startTime);
229
240
  }
230
241
 
242
+ /**
243
+ * The last `count` pieces of `text.trimEnd().split("\n")`, found by scanning
244
+ * back from the end so a long running output is never split as a whole.
245
+ */
246
+ function tailLines(text: string, count: number): string[] {
247
+ const trimmed = text.trimEnd();
248
+ const lines: string[] = [];
249
+ let end = trimmed.length;
250
+ while (lines.length < count) {
251
+ const newline = end > 0 ? trimmed.lastIndexOf("\n", end - 1) : -1;
252
+ lines.push(trimmed.slice(newline + 1, end));
253
+ if (newline < 0) break;
254
+ end = newline;
255
+ }
256
+ return lines.reverse();
257
+ }
258
+
231
259
  /** A task job as an `agent` node when the terminal draws them, else a one-line dot row. */
232
260
  function describeJob(job: JobsPanelJob, nowMs: number, agent: boolean): NativeNode {
233
261
  const settled = job.status !== "running";