@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.
- package/CHANGELOG.md +20 -0
- package/dist/types/chat/thinking-display.d.ts +2 -0
- package/dist/types/components/editor.d.ts +5 -0
- package/dist/types/loop-watchdog.d.ts +9 -0
- package/dist/types/overlays/model-picker.d.ts +2 -1
- package/dist/types/setup/scenes/sign-in.d.ts +9 -9
- package/dist/types/setup/scenes/types.d.ts +0 -26
- package/dist/types/stdin-buffer.d.ts +10 -7
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/tools/web-search-types.d.ts +90 -0
- package/dist/types/tools/web-search.d.ts +1 -168
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +192 -40
- package/src/chat/thinking-display.ts +73 -11
- package/src/chrome/transcript-container.ts +102 -17
- package/src/components/editor.ts +23 -9
- package/src/components/markdown.ts +110 -56
- package/src/loop-watchdog.ts +38 -10
- package/src/overlays/model-picker.ts +9 -6
- package/src/overlays/settings-selector.ts +2 -10
- package/src/setup/scenes/sign-in.ts +17 -12
- package/src/setup/scenes/types.ts +0 -27
- package/src/setup/wizard.ts +1 -1
- package/src/status-line/component.ts +16 -2
- package/src/stdin-buffer.ts +27 -21
- package/src/terminal.ts +23 -9
- package/src/theme/mermaid-cache.ts +10 -1
- package/src/theme/tui-adapters.ts +14 -1
- package/src/tools/bash.ts +53 -13
- package/src/tools/edit.ts +110 -29
- package/src/tools/streaming-output.ts +79 -16
- package/src/tools/web-search-types.ts +99 -0
- package/src/tools/web-search.ts +1 -172
- package/src/tui.ts +1 -1
- package/dist/types/setup/scenes/providers.d.ts +0 -3
- package/dist/types/setup/scenes/web-search.d.ts +0 -22
- package/src/setup/scenes/providers.ts +0 -110
- 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 <
|
|
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();
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -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.
|
|
428
|
-
// 2004
|
|
429
|
-
//
|
|
430
|
-
// (
|
|
431
|
-
#
|
|
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.#
|
|
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.#
|
|
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
|
-
*
|
|
653
|
-
*
|
|
654
|
-
* paste then always arrives wrapped, so
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
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
|
-
|
|
660
|
-
this.#
|
|
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
|
-
/**
|
|
690
|
-
|
|
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,
|
|
1085
|
-
//
|
|
1086
|
-
// one read
|
|
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
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
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?.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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 = {
|
|
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
|
|