@oh-my-pi/pi-tui 18.4.2 → 18.4.3

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 (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/chat/thinking-display.d.ts +2 -0
  3. package/dist/types/components/editor.d.ts +5 -0
  4. package/dist/types/loop-watchdog.d.ts +9 -0
  5. package/dist/types/overlays/model-picker.d.ts +2 -1
  6. package/dist/types/setup/scenes/sign-in.d.ts +9 -9
  7. package/dist/types/setup/scenes/types.d.ts +0 -26
  8. package/dist/types/stdin-buffer.d.ts +10 -7
  9. package/dist/types/terminal.d.ts +9 -0
  10. package/dist/types/tools/web-search-types.d.ts +90 -0
  11. package/dist/types/tools/web-search.d.ts +1 -168
  12. package/package.json +9 -9
  13. package/src/chat/assistant-message.ts +192 -40
  14. package/src/chat/thinking-display.ts +73 -11
  15. package/src/chrome/transcript-container.ts +102 -17
  16. package/src/components/editor.ts +23 -9
  17. package/src/components/markdown.ts +110 -56
  18. package/src/loop-watchdog.ts +38 -10
  19. package/src/overlays/model-picker.ts +9 -6
  20. package/src/overlays/settings-selector.ts +2 -10
  21. package/src/setup/scenes/sign-in.ts +17 -12
  22. package/src/setup/scenes/types.ts +0 -27
  23. package/src/setup/wizard.ts +1 -1
  24. package/src/status-line/component.ts +16 -2
  25. package/src/stdin-buffer.ts +27 -21
  26. package/src/terminal.ts +23 -9
  27. package/src/theme/mermaid-cache.ts +10 -1
  28. package/src/theme/tui-adapters.ts +14 -1
  29. package/src/tools/bash.ts +53 -13
  30. package/src/tools/edit.ts +110 -29
  31. package/src/tools/streaming-output.ts +79 -16
  32. package/src/tools/web-search-types.ts +99 -0
  33. package/src/tools/web-search.ts +1 -172
  34. package/src/tui.ts +1 -1
  35. package/dist/types/setup/scenes/providers.d.ts +0 -3
  36. package/dist/types/setup/scenes/web-search.d.ts +0 -22
  37. package/src/setup/scenes/providers.ts +0 -110
  38. package/src/setup/scenes/web-search.ts +0 -165
@@ -48,6 +48,13 @@ import type {
48
48
  StatusLineSettings,
49
49
  } from "./types";
50
50
 
51
+ /**
52
+ * Freshness window for the git segment's working-tree counts. A whole-worktree
53
+ * `git status` costs ~1 CPU-second on large repos and every open session polls
54
+ * it, so edits surface within this window; HEAD moves refetch immediately via
55
+ * {@link StatusLineComponent.invalidateGitCaches}.
56
+ */
57
+ const GIT_STATUS_TTL_MS = 10_000;
51
58
  const JJ_REFRESH_TTL_MS = 5000;
52
59
  const JJ_COMMAND_TIMEOUT_MS = 5_000;
53
60
  const WATCHER_FAILURE_POLL_TTL_MS = 5000;
@@ -586,6 +593,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
586
593
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
587
594
  #cachedGitStatusCwd: string | undefined = undefined;
588
595
  #gitStatusLastFetch = 0;
596
+ #gitStatusGeneration = 0;
589
597
  #gitStatusInFlightCwd: string | undefined = undefined;
590
598
  #cachedJjBranch: string | null = null;
591
599
  #jjBranchLastFetch = 0;
@@ -1254,6 +1262,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1254
1262
  this.#branchLastFetch = undefined;
1255
1263
  this.#branchCacheGeneration++;
1256
1264
  this.#cachedPrContext = undefined;
1265
+ // A HEAD move (commit, checkout, reset) changes the dirty counts; keep the
1266
+ // stale counts on screen but refetch on the next render. The generation
1267
+ // bump stops an in-flight pre-move result from re-stamping freshness.
1268
+ this.#gitStatusLastFetch = 0;
1269
+ this.#gitStatusGeneration++;
1257
1270
  // jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
1258
1271
  // colocated `jj new`/bookmark move) must drop the throttled jj caches too,
1259
1272
  // so the next render refetches.
@@ -1493,11 +1506,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1493
1506
  if (this.#gitStatusInFlightCwd !== undefined) {
1494
1507
  return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1495
1508
  }
1496
- if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
1509
+ if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < GIT_STATUS_TTL_MS) {
1497
1510
  return this.#cachedGitStatus;
1498
1511
  }
1499
1512
 
1500
1513
  this.#gitStatusInFlightCwd = gitCwd;
1514
+ const generation = this.#gitStatusGeneration;
1501
1515
 
1502
1516
  (async () => {
1503
1517
  let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -1510,7 +1524,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1510
1524
  const prev = this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1511
1525
  this.#cachedGitStatus = nextStatus;
1512
1526
  this.#cachedGitStatusCwd = gitCwd;
1513
- this.#gitStatusLastFetch = Date.now();
1527
+ this.#gitStatusLastFetch = this.#gitStatusGeneration === generation ? Date.now() : 0;
1514
1528
  this.#gitStatusInFlightCwd = undefined;
1515
1529
  if (!this.#disposed && JSON.stringify(prev) !== JSON.stringify(nextStatus)) {
1516
1530
  this.#invalidateStatusLineRenderCache();
@@ -424,11 +424,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
424
424
  #rawPasteCandidate = "";
425
425
  #rawPasteTimer?: NodeJS.Timeout;
426
426
  // Unbracketed raw-paste classification is a fallback for terminals that do
427
- // not wrap pastes in DECSET 2004 markers. When the terminal confirms mode
428
- // 2004 support, a genuine paste always arrives bracketed, so the heuristic
429
- // can only misfire on keystrokes an event-loop stall batched into one read
430
- // (issue #12540) — Terminal disables it via `setRawPasteClassification`.
431
- #rawPasteClassificationEnabled = true;
427
+ // not wrap pastes in DECSET 2004 markers. Once the terminal confirms mode
428
+ // 2004, Terminal installs this probe: an unmarked multiline burst is then
429
+ // an input-method commit (#13344) unless an event-loop stall batched the
430
+ // keystrokes into one read (#12540). See `setRawPasteStallProbe`.
431
+ #rawPasteStallProbe?: () => boolean;
432
432
  #stringDiscardActive = false;
433
433
  #stringDiscardBytes = 0;
434
434
  #stringDiscardEscHeld = false;
@@ -491,14 +491,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
491
491
  } else {
492
492
  this.#rawPasteCandidate += str;
493
493
  if (isRawMultilineBurst(this.#rawPasteCandidate)) {
494
- this.#emitRawPasteCandidate();
494
+ this.#classifyRawPasteCandidate();
495
495
  }
496
496
  return;
497
497
  }
498
498
  }
499
499
 
500
500
  if (
501
- this.#rawPasteClassificationEnabled &&
502
501
  this.#buffer.length === 0 &&
503
502
  str.indexOf(ESC) === -1 &&
504
503
  (str.indexOf("\r") !== -1 || str.indexOf("\n") !== -1)
@@ -508,7 +507,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
508
507
  // Enter is replayed unchanged when the fixed window expires.
509
508
  this.#rawPasteCandidate = str;
510
509
  if (isRawMultilineBurst(str)) {
511
- this.#emitRawPasteCandidate();
510
+ this.#classifyRawPasteCandidate();
512
511
  } else {
513
512
  this.#armRawPasteTimer();
514
513
  }
@@ -649,18 +648,18 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
649
648
  }
650
649
 
651
650
  /**
652
- * Enable or disable unbracketed raw-paste classification. Terminal disables
653
- * it once DECRQM confirms bracketed-paste (mode 2004) support: a genuine
654
- * paste then always arrives wrapped, so the heuristic can only misfire on
655
- * keystrokes an event-loop stall batched into one read (issue #12540).
656
- * Disabling flushes any candidate already held by the classification window
657
- * as ordinary key events so no buffered input is lost.
651
+ * Gate unbracketed raw-paste classification on event-loop responsiveness.
652
+ * Terminal installs a probe once DECRQM confirms bracketed-paste (mode
653
+ * 2004) support: a genuine paste then always arrives wrapped, so an
654
+ * unmarked multiline burst is either an input-method commit — IME,
655
+ * dictation, and emoji commits are typed input, never bracketed (#13344) —
656
+ * or keystrokes an event-loop stall batched into one read (#12540). A burst
657
+ * classified while `stalled()` reports a stall is replayed as the original
658
+ * keys so every Enter submits; otherwise it is delivered as one paste.
659
+ * `undefined` restores unconditional classification.
658
660
  */
659
- setRawPasteClassification(enabled: boolean): void {
660
- this.#rawPasteClassificationEnabled = enabled;
661
- if (!enabled && this.#rawPasteCandidate.length > 0) {
662
- this.#flushRawPasteCandidate();
663
- }
661
+ setRawPasteStallProbe(stalled: (() => boolean) | undefined): void {
662
+ this.#rawPasteStallProbe = stalled;
664
663
  }
665
664
 
666
665
  /** Start one fixed window from the first break-bearing raw read. */
@@ -686,8 +685,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
686
685
  return content;
687
686
  }
688
687
 
689
- /** Emit a classified raw multiline burst through the paste channel. */
690
- #emitRawPasteCandidate(): void {
688
+ /**
689
+ * Deliver a classified raw multiline burst: one paste, or the original
690
+ * keys when the stall probe says a blocked loop batched typed input.
691
+ */
692
+ #classifyRawPasteCandidate(): void {
693
+ if (this.#rawPasteStallProbe?.()) {
694
+ this.#flushRawPasteCandidate();
695
+ return;
696
+ }
691
697
  const content = this.#takeRawPasteCandidate();
692
698
  this.#pendingKittyPrintableCodepoint = undefined;
693
699
  this.emit("paste", content);
package/src/terminal.ts CHANGED
@@ -460,6 +460,15 @@ export interface TerminalStartOptions {
460
460
  * echoes even while module loading blocks the event loop.
461
461
  */
462
462
  deferInput?: boolean;
463
+ /**
464
+ * Reports whether the event loop is stalled or just recovered from a
465
+ * stall. Once the terminal confirms bracketed paste, an unmarked multiline
466
+ * burst is an input-method commit (IME, dictation) delivered as one paste
467
+ * on a responsive loop, but keystrokes a stall batched into one read —
468
+ * replayed as keys so Enter submits — while this returns true. Without a
469
+ * probe every such burst is treated as an input-method commit.
470
+ */
471
+ isLoopStalled?: () => boolean;
463
472
  }
464
473
  /** Identity of an accepted explicit terminal appearance refresh request. */
465
474
  export type TerminalAppearanceRequestToken = number;
@@ -717,6 +726,7 @@ export class ProcessTerminal implements Terminal {
717
726
  #resizeHandler?: () => void;
718
727
  /** True between a `deferInput` start() and enableInput(). */
719
728
  #inputDeferred = false;
729
+ #isLoopStalled?: () => boolean;
720
730
  #stdoutResizeListener?: () => void;
721
731
  #kittyProtocolActive = false;
722
732
  #kittyEnableSeq: string | null = null;
@@ -923,6 +933,7 @@ export class ProcessTerminal implements Terminal {
923
933
  this.#inputHandler = onInput;
924
934
  this.#resizeHandler = onResize;
925
935
  this.#disconnectHandler = onDisconnect;
936
+ this.#isLoopStalled = options?.isLoopStalled;
926
937
  // The host terminal's cursor visibility is unknown until we write it.
927
938
  this.#cursorVisible = undefined;
928
939
 
@@ -1081,9 +1092,10 @@ export class ProcessTerminal implements Terminal {
1081
1092
  this.#queryPrivateMode(2048);
1082
1093
  this.#queryPrivateMode(2031);
1083
1094
  // 2004 (bracketed paste) is queried only to confirm the terminal brackets
1084
- // pastes; once confirmed, the unbracketed raw-paste heuristic in
1085
- // StdinBuffer is disabled so keystrokes an event-loop stall batches into
1086
- // one read are never misclassified as a paste (#12540).
1095
+ // pastes; once confirmed, StdinBuffer's unbracketed raw-paste heuristic
1096
+ // consults the host's stall probe so keystrokes an event-loop stall
1097
+ // batches into one read stay keys (#12540) while input-method commits
1098
+ // still land as one paste (#13344).
1087
1099
  this.#queryPrivateMode(2004);
1088
1100
  for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
1089
1101
  this.#queryPrivateMode(mode);
@@ -1774,15 +1786,17 @@ export class ProcessTerminal implements Terminal {
1774
1786
  }
1775
1787
  if (mode === 2048 && supported) this.#enableInBandResize();
1776
1788
  if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
1777
- // Confirmed bracketed-paste support makes the unbracketed raw-paste
1778
- // heuristic pure downside — turn it off so stall-batched keystrokes are
1779
- // not misread as a paste (#12540). `supported` is only true here after an
1780
- // explicit DECRPM reply (the DA1-sentinel fallback resolves unsupported).
1789
+ // Confirmed bracketed-paste support means a genuine paste arrives
1790
+ // wrapped, so an unmarked multiline burst is an input-method commit
1791
+ // (#13344) unless an event-loop stall batched typed keys into one read
1792
+ // (#12540). Let the host's stall probe decide per burst. `supported` is
1793
+ // only true here after an explicit DECRPM reply (the DA1-sentinel
1794
+ // fallback resolves unsupported).
1781
1795
  if (mode === 2004 && supported) {
1782
- this.#stdinBuffer?.setRawPasteClassification(false);
1796
+ this.#stdinBuffer?.setRawPasteStallProbe(this.#isLoopStalled);
1783
1797
  // A terminal can reset this mode after the initial probe (for example,
1784
1798
  // iTerm2's Terminal State toggle). Keep the mode asserted while we own
1785
- // the TTY, since the raw fallback is disabled after confirmation.
1799
+ // the TTY, since a stalled loop now replays unmarked bursts as keys.
1786
1800
  this.#bracketedPasteRefreshTimer ??= setInterval(() => {
1787
1801
  if (this.#active && !this.#dead) this.#safeWrite("\x1b[?2004h");
1788
1802
  }, 1000);
@@ -1,4 +1,5 @@
1
1
  import type { MermaidRenderOptions } from "@oh-my-pi/pi-natives";
2
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
2
3
  import * as mermaidAscii from "@oh-my-pi/pi-utils/mermaid-ascii";
3
4
 
4
5
  /**
@@ -19,7 +20,15 @@ export interface MermaidResolveOptions extends MermaidRenderOptions {
19
20
  // Memoizes rendered ASCII (and failures) keyed on the render options + the
20
21
  // layout-direction variant + source. Width selection happens per call against
21
22
  // the cached renders, so a terminal resize re-decides without re-rendering.
22
- const cache = new Map<string, string | null>();
23
+ // Bounded by entry count and by UTF-16 length of key (which embeds the whole
24
+ // source) plus rendered ASCII, so long sessions cannot pin every diagram.
25
+ const MERMAID_CACHE_MAX = 256;
26
+ const MERMAID_CACHE_MAX_SIZE = 4 * 1024 * 1024;
27
+ const cache = new LRUCache<string, string | null>({
28
+ max: MERMAID_CACHE_MAX,
29
+ maxSize: MERMAID_CACHE_MAX_SIZE,
30
+ sizeCalculation: (ascii, key) => key.length + (ascii?.length ?? 0),
31
+ });
23
32
 
24
33
  /** Display columns, ignoring ANSI so themed diagrams are measured as drawn. */
25
34
  const DISPLAY_WIDTH = { countAnsiEscapeCodes: false } as const;
@@ -59,7 +59,20 @@ function getHighlightColors(t: Theme): NativeHighlightColors {
59
59
  * the 33ms frame budget and starving the spinner/render timers (the "TUI freeze").
60
60
  */
61
61
  const HIGHLIGHT_CACHE_MAX = 256;
62
- const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
62
+ // Size-bound like markdown's render cache: a streaming block (bash command,
63
+ // eval cell) inserts one full `code + highlighted` pair per growth frame, so a
64
+ // count cap alone would retain 256 near-copies of a large payload. Sizes are
65
+ // UTF-16 code units of key + value (ANSI output runs several times the source
66
+ // length). The per-entry cap stays generous so a large but settled code body
67
+ // repainting every frame still hits; only payloads past it re-highlight.
68
+ const HIGHLIGHT_CACHE_MAX_SIZE = 8 * 1024 * 1024;
69
+ const HIGHLIGHT_CACHE_MAX_ENTRY_SIZE = 1024 * 1024;
70
+ const highlightCache = new LRUCache<string, string>({
71
+ max: HIGHLIGHT_CACHE_MAX,
72
+ maxSize: HIGHLIGHT_CACHE_MAX_SIZE,
73
+ maxEntrySize: HIGHLIGHT_CACHE_MAX_ENTRY_SIZE,
74
+ sizeCalculation: (value, key) => Math.max(1, key.length + value.length),
75
+ });
63
76
  let highlightCacheTheme: Theme | undefined;
64
77
 
65
78
  function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {
package/src/tools/bash.ts CHANGED
@@ -130,19 +130,51 @@ function unescapePartialJsonString(value: string): string {
130
130
  return output;
131
131
  }
132
132
 
133
- function extractPartialBashEnv(partialJson: string | undefined): Record<string, string> | undefined {
133
+ // One-slot memo: every render path over one streamed args object (call
134
+ // preview, result header, repaint) asks for the same buffer.
135
+ let lastPartialEnvJson: string | undefined;
136
+ let lastPartialEnv: Readonly<Record<string, string>> | undefined;
137
+
138
+ function extractPartialBashEnv(partialJson: string | undefined): Readonly<Record<string, string>> | undefined {
134
139
  if (!partialJson) return undefined;
140
+ if (partialJson === lastPartialEnvJson) return lastPartialEnv;
141
+ let env: Record<string, string> | undefined;
135
142
  const envStart = partialJson.search(/"env"\s*:\s*\{/u);
136
- if (envStart === -1) return undefined;
137
- const objectStart = partialJson.indexOf("{", envStart);
138
- if (objectStart === -1) return undefined;
139
- const envBody = partialJson.slice(objectStart + 1);
140
- const env: Record<string, string> = {};
141
- const matcher = /"([A-Za-z_][A-Za-z0-9_]*)"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu;
142
- for (const match of envBody.matchAll(matcher)) {
143
- env[match[1]!] = unescapePartialJsonString(match[2]!);
143
+ const objectStart = envStart === -1 ? -1 : partialJson.indexOf("{", envStart);
144
+ if (objectStart !== -1) {
145
+ // Scan only the env object: stop at its closing brace (outside strings)
146
+ // so the keys that follow it are not read as env assignments.
147
+ let objectEnd = partialJson.length;
148
+ let depth = 0;
149
+ for (let i = objectStart + 1; i < partialJson.length; i++) {
150
+ const ch = partialJson.charCodeAt(i);
151
+ if (ch === 0x22) {
152
+ // Skip the string body, honoring escapes; an unterminated string runs to the end.
153
+ for (i++; i < partialJson.length; i++) {
154
+ const inner = partialJson.charCodeAt(i);
155
+ if (inner === 0x5c) i++;
156
+ else if (inner === 0x22) break;
157
+ }
158
+ } else if (ch === 0x7b || ch === 0x5b) {
159
+ depth++;
160
+ } else if (ch === 0x7d || ch === 0x5d) {
161
+ if (depth === 0) {
162
+ objectEnd = i;
163
+ break;
164
+ }
165
+ depth--;
166
+ }
167
+ }
168
+ const envBody = partialJson.slice(objectStart + 1, objectEnd);
169
+ const matcher = /"([A-Za-z_][A-Za-z0-9_]*)"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu;
170
+ for (const match of envBody.matchAll(matcher)) {
171
+ env ??= {};
172
+ env[match[1]!] = unescapePartialJsonString(match[2]!);
173
+ }
144
174
  }
145
- return Object.keys(env).length > 0 ? env : undefined;
175
+ lastPartialEnvJson = partialJson;
176
+ lastPartialEnv = env ? Object.freeze(env) : undefined;
177
+ return lastPartialEnv;
146
178
  }
147
179
 
148
180
  function formatWallTimeSeconds(wallTimeMs: number): string {
@@ -243,8 +275,12 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
243
275
  return {
244
276
  renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
245
277
  const renderArgs = toBashRenderArgs(args, config);
246
- const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
278
+ // Highlighting the whole (possibly still-streaming) command is the
279
+ // expensive part: defer it to the first paint, once per component,
280
+ // so a rebuild that is replaced before painting never pays for it.
281
+ let cmdLines: string[] | undefined;
247
282
  return framedToolCard(uiTheme, () => {
283
+ cmdLines ??= formatBashCommandLines(renderArgs, uiTheme);
248
284
  const header =
249
285
  config.showHeader === false
250
286
  ? undefined
@@ -275,7 +311,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
275
311
  args?: TArgs,
276
312
  ): Component {
277
313
  const renderArgs = toBashRenderArgs(args, config);
278
- const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
314
+ let cmdLines: string[] | undefined;
279
315
  const isError = result.isError === true;
280
316
  const isPartial = options.isPartial === true;
281
317
  const success = !isPartial && !isError;
@@ -438,7 +474,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
438
474
  {
439
475
  // Viewport-sized tail window in every state — streaming and final
440
476
  // render identically; only ctrl+o uncaps.
441
- content: capPreviewLines(cmdLines ?? [], uiTheme, { expanded }),
477
+ content: capPreviewLines(
478
+ args ? (cmdLines ??= formatBashCommandLines(renderArgs, uiTheme)) : [],
479
+ uiTheme,
480
+ { expanded },
481
+ ),
442
482
  },
443
483
  { label: uiTheme.fg("toolTitle", "Output"), content: outputLines },
444
484
  ],
package/src/tools/edit.ts CHANGED
@@ -239,16 +239,14 @@ function decodePartialJsonStringFragment(fragment: string): string {
239
239
  }
240
240
  }
241
241
 
242
- function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
243
- if (!partialJson) return undefined;
244
- const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
245
- const match = pattern.exec(partialJson);
246
- if (!match) return undefined;
247
- return decodePartialJsonStringFragment(match[1]);
248
- }
242
+ // `"path": "<possibly unterminated string>` in a raw streamed args buffer.
243
+ const PARTIAL_JSON_PATH_RE = /"path"\s*:\s*"((?:\\.|[^"\\])*)/u;
249
244
 
250
245
  function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
251
- return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
246
+ const partialJson = args.__partialJson;
247
+ if (!partialJson) return undefined;
248
+ const match = PARTIAL_JSON_PATH_RE.exec(partialJson);
249
+ return match ? decodePartialJsonStringFragment(match[1]!) : undefined;
252
250
  }
253
251
 
254
252
  /** Count distinct file paths in an edits array. */
@@ -427,8 +425,33 @@ function hasEditCallPayload(args: EditRenderArgs, renderContext: EditRenderConte
427
425
  return false;
428
426
  }
429
427
 
428
+ /**
429
+ * Head-window preview of streamed replacement text. Only the displayed head is
430
+ * sanitized and split; the hidden-line count comes from a raw newline scan.
431
+ * That matches sanitizing the whole text because, on ESC-free input,
432
+ * `sanitizeText` only deletes individual code units and never a `\n`: the
433
+ * cut sits on a `\n`, so it cannot split a surrogate pair. The one non-local
434
+ * rule — a lone surrogate anywhere drops every U+FFFD — is replayed on the
435
+ * head. ESC-bearing text (ANSI stripping can span lines) takes the full path.
436
+ */
430
437
  function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string {
431
- const previewLines = cappedHeadLines(sanitizeText(text).split("\n"), CALL_TEXT_PREVIEW_LINES);
438
+ let previewLines: { lines: readonly string[]; hidden: number };
439
+ let headEnd = -1;
440
+ if (!text.includes("\x1b")) {
441
+ for (let newlines = 0; newlines < CALL_TEXT_PREVIEW_LINES; newlines++) {
442
+ headEnd = text.indexOf("\n", headEnd + 1);
443
+ if (headEnd === -1) break;
444
+ }
445
+ }
446
+ if (headEnd === -1) {
447
+ previewLines = cappedHeadLines(sanitizeText(text).split("\n"), CALL_TEXT_PREVIEW_LINES);
448
+ } else {
449
+ let head = sanitizeText(text.slice(0, headEnd));
450
+ if (head.includes("\ufffd") && !text.isWellFormed()) head = head.replaceAll("\ufffd", "");
451
+ let hidden = 1;
452
+ for (let i = text.indexOf("\n", headEnd + 1); i !== -1; i = text.indexOf("\n", i + 1)) hidden++;
453
+ previewLines = { lines: head.split("\n"), hidden };
454
+ }
432
455
  let preview = "\n\n";
433
456
  for (const line of previewLines.lines) {
434
457
  preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
@@ -751,17 +774,30 @@ interface EditCallFacts {
751
774
  * fields tolerate a small lag) or when the payload shrinks (rewind). The
752
775
  * final frame (`isPartial === false`) always recomputes. This turns the
753
776
  * per-frame O(n) stringify+parse+scan into O(n^2/K) total per call.
777
+ *
778
+ * The reveal hands over a fresh args object on every frame, so the gate
779
+ * keys on the streamed raw payload (`__partialJson`) rather than on args
780
+ * identity: a frame reuses the facts only when its payload extends, byte for
781
+ * byte, the payload the facts were derived from, and every direct field the
782
+ * facts read (path, file_path, rename, op, edits) is unchanged. Final args
783
+ * carry no `__partialJson`, so the completed call always derives exact facts.
754
784
  */
755
785
  const EDIT_FACTS_MIN_GROWTH = 512;
756
- let lastFactsCache:
757
- | {
758
- editArgs: EditRenderArgs;
759
- isPartial: boolean;
760
- editMode: EditMode | undefined;
761
- length: number;
762
- facts: EditCallFacts;
763
- }
764
- | undefined;
786
+ interface EditFactsCacheEntry {
787
+ editArgs: EditRenderArgs;
788
+ isPartial: boolean;
789
+ editMode: EditMode | undefined;
790
+ length: number;
791
+ /** Streamed raw payload the facts were derived from; set only for streamed `input` payloads. */
792
+ streamedPayload: string | undefined;
793
+ filePath: unknown;
794
+ path: unknown;
795
+ rename: unknown;
796
+ op: Operation | undefined;
797
+ edits: EditRenderEntry[] | undefined;
798
+ facts: EditCallFacts;
799
+ }
800
+ let lastFactsCache: EditFactsCacheEntry | undefined;
765
801
 
766
802
  function editFactsInputLength(editArgs: EditRenderArgs): number {
767
803
  const input = editArgs.input ?? editArgs._input;
@@ -772,27 +808,72 @@ function editFactsInputLength(editArgs: EditRenderArgs): number {
772
808
  return Array.isArray(editArgs.edits) ? editArgs.edits.length : 0;
773
809
  }
774
810
 
811
+ /**
812
+ * Raw streamed payload of a mid-stream `input` edit, or undefined when the
813
+ * args are final (no `__partialJson`) or carry no `input` string — only the
814
+ * `input` payloads pay the expensive whole-payload inspect per frame.
815
+ */
816
+ function streamedInputPayload(editArgs: EditRenderArgs): string | undefined {
817
+ if (typeof (editArgs.input ?? editArgs._input) !== "string") return undefined;
818
+ const payload = editArgs.__partialJson;
819
+ return typeof payload === "string" ? payload : undefined;
820
+ }
821
+
822
+ /**
823
+ * Whether a fresh streamed frame continues the payload `cached` was derived
824
+ * from within the growth gate. Exact: the cached payload must be a verbatim
825
+ * prefix of the new one, so a rewind or another call's payload never reuses.
826
+ */
827
+ function continuesCachedPayload(cached: EditFactsCacheEntry, editArgs: EditRenderArgs): boolean {
828
+ const base = cached.streamedPayload;
829
+ if (base === undefined) return false;
830
+ const payload = streamedInputPayload(editArgs);
831
+ if (payload === undefined || payload.length < base.length) return false;
832
+ if (payload.length - base.length >= EDIT_FACTS_MIN_GROWTH) return false;
833
+ if (
834
+ editArgs.file_path !== cached.filePath ||
835
+ editArgs.path !== cached.path ||
836
+ editArgs.rename !== cached.rename ||
837
+ editArgs.op !== cached.op ||
838
+ editArgs.edits !== cached.edits
839
+ ) {
840
+ return false;
841
+ }
842
+ return payload.startsWith(base);
843
+ }
844
+
775
845
  function resolveEditCallFacts(
776
846
  editArgs: EditRenderArgs,
777
847
  isPartial: boolean,
778
848
  editMode: EditMode | undefined,
779
849
  ): EditCallFacts {
780
850
  const cached = lastFactsCache;
781
- if (
782
- cached !== undefined &&
783
- cached.editArgs === editArgs &&
784
- cached.isPartial === isPartial &&
785
- cached.editMode === editMode
786
- ) {
787
- const length = editFactsInputLength(editArgs);
788
- // Same args object, still growing gradually: reuse. A rewind
789
- // (shorter), a jump past the gate, or the final frame recomputes.
790
- if (isPartial && length >= cached.length && length - cached.length < EDIT_FACTS_MIN_GROWTH) {
851
+ if (cached !== undefined && isPartial && cached.isPartial && cached.editMode === editMode) {
852
+ if (cached.editArgs === editArgs) {
853
+ const length = editFactsInputLength(editArgs);
854
+ // Same args object, still growing gradually: reuse. A rewind
855
+ // (shorter), a jump past the gate, or the final frame recomputes.
856
+ if (length >= cached.length && length - cached.length < EDIT_FACTS_MIN_GROWTH) {
857
+ return cached.facts;
858
+ }
859
+ } else if (continuesCachedPayload(cached, editArgs)) {
791
860
  return cached.facts;
792
861
  }
793
862
  }
794
863
  const facts = resolveEditCallFactsUncached(editArgs, isPartial, editMode);
795
- lastFactsCache = { editArgs, isPartial, editMode, length: editFactsInputLength(editArgs), facts };
864
+ lastFactsCache = {
865
+ editArgs,
866
+ isPartial,
867
+ editMode,
868
+ length: editFactsInputLength(editArgs),
869
+ streamedPayload: streamedInputPayload(editArgs),
870
+ filePath: editArgs.file_path,
871
+ path: editArgs.path,
872
+ rename: editArgs.rename,
873
+ op: editArgs.op,
874
+ edits: editArgs.edits,
875
+ facts,
876
+ };
796
877
  return facts;
797
878
  }
798
879