@quandev104/pi-style 0.2.0 → 0.2.2
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 +16 -0
- package/README.md +1 -1
- package/dist/extensions/pi-style.js +1256 -491
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +99 -86
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- package/extension-src/pi-style/domain/status-renderer.ts +40 -8
- package/extension-src/pi-style/domain/status.ts +15 -5
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +97 -66
- package/extension-src/pi-style/features/messages/index.ts +469 -90
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
- package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -22
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +28 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +83 -6
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
|
@@ -110,6 +110,37 @@ function separatorsFor(config: NormalizedPiStyleConfig, theme: ResolvedTheme): s
|
|
|
110
110
|
return theme.apply("separator", style);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Per-(theme, config) derived values shared across status-line renders. */
|
|
114
|
+
interface ThemeAssets {
|
|
115
|
+
readonly resolved: ResolvedTheme;
|
|
116
|
+
readonly separator: string;
|
|
117
|
+
}
|
|
118
|
+
/** Resolved theme + separator keyed by Pi theme identity then config identity; a fresh configure() object misses naturally. */
|
|
119
|
+
const themeAssetsCache = new WeakMap<ActivePiTheme, WeakMap<NormalizedPiStyleConfig, ThemeAssets>>();
|
|
120
|
+
function themeAssetsFor(activeTheme: ActivePiTheme, config: NormalizedPiStyleConfig): ThemeAssets {
|
|
121
|
+
let byConfig = themeAssetsCache.get(activeTheme);
|
|
122
|
+
if (!byConfig) {
|
|
123
|
+
byConfig = new WeakMap();
|
|
124
|
+
themeAssetsCache.set(activeTheme, byConfig);
|
|
125
|
+
}
|
|
126
|
+
let assets = byConfig.get(config);
|
|
127
|
+
if (!assets) {
|
|
128
|
+
const resolved = resolveTheme(
|
|
129
|
+
activeTheme.colors || activeTheme.fg
|
|
130
|
+
? {
|
|
131
|
+
...(activeTheme.colors ? { colors: activeTheme.colors } : {}),
|
|
132
|
+
// Call through the theme instance so `this` binds correctly inside Pi's fg().
|
|
133
|
+
...(activeTheme.fg ? { fg: (color: string, text: string) => activeTheme.fg?.(color, text) ?? text } : {}),
|
|
134
|
+
}
|
|
135
|
+
: undefined,
|
|
136
|
+
config,
|
|
137
|
+
);
|
|
138
|
+
assets = { resolved, separator: separatorsFor(config, resolved) };
|
|
139
|
+
byConfig.set(config, assets);
|
|
140
|
+
}
|
|
141
|
+
return assets;
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
export function installStatusLine(options: StatusLineInstallOptions): StatusLineInstallation {
|
|
114
145
|
const existing = installationMap(options.host).get(options.generation);
|
|
115
146
|
if (existing) return existing;
|
|
@@ -143,18 +174,9 @@ export function installStatusLine(options: StatusLineInstallOptions): StatusLine
|
|
|
143
174
|
|
|
144
175
|
const render = (activeTheme: ActivePiTheme, width: number, secondary: boolean): string[] => {
|
|
145
176
|
if (width <= 0 || !config.enabled || !config.statusLine.enabled) return [];
|
|
146
|
-
const resolved =
|
|
147
|
-
activeTheme.colors || activeTheme.fg
|
|
148
|
-
? {
|
|
149
|
-
...(activeTheme.colors ? { colors: activeTheme.colors } : {}),
|
|
150
|
-
// Call through the theme instance so `this` binds correctly inside Pi's fg().
|
|
151
|
-
...(activeTheme.fg ? { fg: (color: string, text: string) => activeTheme.fg?.(color, text) ?? text } : {}),
|
|
152
|
-
}
|
|
153
|
-
: undefined,
|
|
154
|
-
config,
|
|
155
|
-
);
|
|
177
|
+
const { resolved, separator } = themeAssetsFor(activeTheme, config);
|
|
156
178
|
const result = renderStatus(config.statusLine.layout, effectiveSnapshot(snapshot), width, {
|
|
157
|
-
separator
|
|
179
|
+
separator,
|
|
158
180
|
segments,
|
|
159
181
|
theme: resolved,
|
|
160
182
|
options: {
|
|
@@ -241,12 +263,20 @@ export function installStatusLine(options: StatusLineInstallOptions): StatusLine
|
|
|
241
263
|
(secondary: boolean): WidgetFactory =>
|
|
242
264
|
(tui, theme) => {
|
|
243
265
|
const currentTheme = theme;
|
|
266
|
+
// Per-component render cache: Pi repaints widgets on every frame
|
|
267
|
+
// (keystrokes, streaming chunks, tickers). Same width without an
|
|
268
|
+
// invalidate() (snapshot update / configure / footer-branch change)
|
|
269
|
+
// means the previous lines are still current; return them as-is.
|
|
270
|
+
let renderCache: { width: number; lines: string[] } | undefined;
|
|
244
271
|
const component: RenderComponent = {
|
|
245
272
|
render(width) {
|
|
273
|
+
if (renderCache?.width === width) return renderCache.lines;
|
|
246
274
|
const lines = render(currentTheme, width, secondary);
|
|
275
|
+
renderCache = { width, lines };
|
|
247
276
|
return lines;
|
|
248
277
|
},
|
|
249
278
|
invalidate() {
|
|
279
|
+
renderCache = undefined;
|
|
250
280
|
// Pi supplies a fresh theme to the factory on theme replacement. Do not retain
|
|
251
281
|
// pre-rendered ANSI strings; the next render reads the current component theme.
|
|
252
282
|
primaryComponent = secondary ? primaryComponent : component;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
boxLine,
|
|
20
20
|
boxWidth,
|
|
21
21
|
formatBoxedRunningStatus,
|
|
22
|
+
themeCacheKey,
|
|
22
23
|
} from "../../shared/box.js";
|
|
23
24
|
import { getThemeExtra } from "../../shared/theme-extras.js";
|
|
24
25
|
|
|
@@ -30,6 +31,11 @@ export function setBashExecutionTheme(theme: BoxTheme | undefined): void {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Structural view of the native BashExecutionComponent as used by the patch. */
|
|
34
|
+
interface BashExecutionRenderCache {
|
|
35
|
+
key: string;
|
|
36
|
+
lines: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
interface BashExecutionInstance {
|
|
34
40
|
command: string;
|
|
35
41
|
status: "running" | "cancelled" | "error" | "complete";
|
|
@@ -37,6 +43,7 @@ interface BashExecutionInstance {
|
|
|
37
43
|
contentContainer: { render(width: number): string[] };
|
|
38
44
|
/** Wall-clock start captured on the first boxed render for the live `◌ Running · Ns` footer. */
|
|
39
45
|
piStyleStart?: number;
|
|
46
|
+
piStyleRenderCache?: BashExecutionRenderCache;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
const TOP_LEFT = "╭";
|
|
@@ -92,13 +99,15 @@ export function renderBashExecutionBox(instance: unknown, args: unknown[]): stri
|
|
|
92
99
|
try {
|
|
93
100
|
if (host.piStyleStart === undefined) host.piStyleStart = Date.now();
|
|
94
101
|
const renderedWidth = boxWidth(width);
|
|
102
|
+
const cacheKey = `${themeCacheKey(theme)}|${width}|${host.status}|${host.exitCode ?? ""}|${host.command}`;
|
|
103
|
+
if (host.status !== "running" && host.piStyleRenderCache?.key === cacheKey) return host.piStyleRenderCache.lines;
|
|
95
104
|
const inner = boxInnerWidth(renderedWidth);
|
|
96
105
|
// The native Text children render one leading padding space per line;
|
|
97
106
|
// drop it so boxLine's own side padding produces symmetric borders.
|
|
98
107
|
const wrapped = content
|
|
99
108
|
.render(inner)
|
|
100
109
|
.map((line) => boxLine(theme, line.startsWith(" ") ? line.slice(1) : line, renderedWidth));
|
|
101
|
-
|
|
110
|
+
const lines = [
|
|
102
111
|
"",
|
|
103
112
|
boxLabeledBorder(theme, TOP_LEFT, TOP_RIGHT, bashBoxTitle(theme, host), undefined, renderedWidth),
|
|
104
113
|
boxBlankLine(theme, renderedWidth),
|
|
@@ -106,6 +115,8 @@ export function renderBashExecutionBox(instance: unknown, args: unknown[]): stri
|
|
|
106
115
|
boxBlankLine(theme, renderedWidth),
|
|
107
116
|
boxLabeledBorder(theme, BOTTOM_LEFT, BOTTOM_RIGHT, bashBoxFooter(theme, host), undefined, renderedWidth),
|
|
108
117
|
];
|
|
118
|
+
if (host.status !== "running") host.piStyleRenderCache = { key: cacheKey, lines };
|
|
119
|
+
return lines;
|
|
109
120
|
} catch {
|
|
110
121
|
return undefined;
|
|
111
122
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderBoxedToolResult,
|
|
16
16
|
replaceTabs,
|
|
17
17
|
shortenPath,
|
|
18
|
+
themeCacheKey,
|
|
18
19
|
} from "../../../shared/box.js";
|
|
19
20
|
import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../shared/render-budget.js";
|
|
20
21
|
import { parseSimpleBashCommand } from "./command-shape.js";
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
} from "./output-tree.js";
|
|
53
54
|
import {
|
|
54
55
|
getStateElapsedMs,
|
|
56
|
+
getToolsRenderCacheSignature,
|
|
55
57
|
getToolsRenderConfig,
|
|
56
58
|
isResultSeen,
|
|
57
59
|
markResultSeen,
|
|
@@ -59,7 +61,14 @@ import {
|
|
|
59
61
|
startElapsedTicker,
|
|
60
62
|
stopElapsedTicker,
|
|
61
63
|
} from "./session-config.js";
|
|
62
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
type BoxedToolContext,
|
|
66
|
+
type BoxedToolDefinition,
|
|
67
|
+
getRenderCacheKey,
|
|
68
|
+
memoizedStateComponent,
|
|
69
|
+
noteBoxedCallState,
|
|
70
|
+
noteExecutionStart,
|
|
71
|
+
} from "./shared.js";
|
|
63
72
|
|
|
64
73
|
const MAX_LINE_CHARS = 2000;
|
|
65
74
|
const ESC = "\x1b";
|
|
@@ -179,6 +188,38 @@ function countNewlines(text: string, from: number, to: number): number {
|
|
|
179
188
|
return count;
|
|
180
189
|
}
|
|
181
190
|
|
|
191
|
+
/** Index just past the `need`-th newline counted backwards from `end` (0 when
|
|
192
|
+
* the window holds fewer), so only `text.slice(index, end)` needs further
|
|
193
|
+
* processing. Plain char scan, no allocation. */
|
|
194
|
+
function findBackwardLineStart(text: string, need: number, end: number = text.length): number {
|
|
195
|
+
let found = 0;
|
|
196
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
197
|
+
if (text.charCodeAt(i) === 10 && ++found >= need) return i + 1;
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Whitespace per `String.prototype.trim` (superset of ASCII blank/line
|
|
203
|
+
* terminators); anything else counts as visible output. */
|
|
204
|
+
function isOutputWhitespaceCode(code: number): boolean {
|
|
205
|
+
if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) return true;
|
|
206
|
+
if (code === 0x85 || code === 0xa0 || code === 0x1680) return true;
|
|
207
|
+
if (code >= 0x2000 && code <= 0x200a) return true;
|
|
208
|
+
return code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** End index (exclusive) of the last non-whitespace character in `text` — the
|
|
212
|
+
* streaming equivalent of `stripAnsi(text).trimEnd()`: trailing blank lines and
|
|
213
|
+
* padding never push the visible tail out of the processing window. ANSI
|
|
214
|
+
* escape bytes count as non-whitespace; a slice ending inside one still
|
|
215
|
+
* strips correctly downstream. */
|
|
216
|
+
function lastVisibleEnd(text: string): number {
|
|
217
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
218
|
+
if (!isOutputWhitespaceCode(text.charCodeAt(i))) return i + 1;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
182
223
|
function stripBashToolNoticeLines(text: string): string {
|
|
183
224
|
const filteredLines = text
|
|
184
225
|
.replace(/\r/g, "")
|
|
@@ -379,8 +420,19 @@ function renderBashStreamingResult(
|
|
|
379
420
|
options: { expanded: boolean },
|
|
380
421
|
context: BoxedToolContext,
|
|
381
422
|
): Component {
|
|
382
|
-
|
|
383
|
-
|
|
423
|
+
// Tail-only processing: the preview collapses to maxCollapsedLines lines
|
|
424
|
+
// anyway, so only the last maxCollapsedLines + 10 raw lines (the same headroom
|
|
425
|
+
// the final collapsed scan uses, covering notice lines stripped from the
|
|
426
|
+
// tail) get ANSI stripping/truncation work, and trailing blank lines never
|
|
427
|
+
// push real content out of the window (the raw-string equivalent of the old
|
|
428
|
+
// whole-buffer stripAnsi + trimEnd). Streaming passes stay O(tail) as the
|
|
429
|
+
// output grows instead of re-stripping the whole buffer each pass.
|
|
430
|
+
const contentEnd = lastVisibleEnd(raw);
|
|
431
|
+
const hasOutput = contentEnd > 0;
|
|
432
|
+
const tailStart = hasOutput
|
|
433
|
+
? findBackwardLineStart(raw, getToolsRenderConfig().maxCollapsedLines + 10, contentEnd)
|
|
434
|
+
: 0;
|
|
435
|
+
const body = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart, contentEnd)));
|
|
384
436
|
const elapsed = getStateElapsedMs(context.state);
|
|
385
437
|
const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
|
|
386
438
|
if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
|
|
@@ -420,17 +472,7 @@ function renderBashFinalResult(
|
|
|
420
472
|
if (!options.expanded) {
|
|
421
473
|
// Collapsed: only process the tail of the output (notices stripped per line).
|
|
422
474
|
const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
|
|
423
|
-
|
|
424
|
-
let tailStart = 0;
|
|
425
|
-
for (let i = statusStripped.length - 1; i >= 0; i--) {
|
|
426
|
-
if (statusStripped.charCodeAt(i) === 10) {
|
|
427
|
-
nlCount++;
|
|
428
|
-
if (nlCount >= scanLines) {
|
|
429
|
-
tailStart = i + 1;
|
|
430
|
-
break;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
475
|
+
const tailStart = findBackwardLineStart(statusStripped, scanLines);
|
|
434
476
|
const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
|
|
435
477
|
const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
|
|
436
478
|
const preview = createBashResultPreview(theme, tail, options, outputColor);
|
|
@@ -499,17 +541,7 @@ function createBashResultPreview(
|
|
|
499
541
|
if (!expanded) {
|
|
500
542
|
// Collapsed: only process the tail of the output
|
|
501
543
|
const needed = cfg.maxCollapsedLines;
|
|
502
|
-
|
|
503
|
-
let scanFrom = 0; // default: take full text if fewer than needed newlines
|
|
504
|
-
for (let i = text.length - 1; i >= 0; i--) {
|
|
505
|
-
if (text.charCodeAt(i) === 10) {
|
|
506
|
-
totalNewlines++;
|
|
507
|
-
if (totalNewlines === needed) {
|
|
508
|
-
scanFrom = i + 1;
|
|
509
|
-
break;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
544
|
+
const scanFrom = findBackwardLineStart(text, needed); // full text when fewer newlines
|
|
513
545
|
|
|
514
546
|
if (text.length === 0) {
|
|
515
547
|
cacheKey = cacheId;
|
|
@@ -539,10 +571,14 @@ function createBashResultPreview(
|
|
|
539
571
|
return cacheLines;
|
|
540
572
|
}
|
|
541
573
|
|
|
542
|
-
// Expanded:
|
|
574
|
+
// Expanded: only the tail lines the expanded budget can show receive
|
|
575
|
+
// clamp/truncate/color work; earlier lines collapse into one `… N earlier
|
|
576
|
+
// lines` head row, so per-line cost scales with maxExpandedLines instead
|
|
577
|
+
// of the full output.
|
|
543
578
|
const normalized = replaceTabs(text);
|
|
544
|
-
const
|
|
545
|
-
const
|
|
579
|
+
const rawLines = normalized.split("\n");
|
|
580
|
+
const totalLines = rawLines.length;
|
|
581
|
+
const hasOutput = !(totalLines === 1 && rawLines[0] === "");
|
|
546
582
|
|
|
547
583
|
if (!hasOutput) {
|
|
548
584
|
cacheKey = cacheId;
|
|
@@ -550,17 +586,17 @@ function createBashResultPreview(
|
|
|
550
586
|
return cacheLines;
|
|
551
587
|
}
|
|
552
588
|
|
|
553
|
-
const truncatedLines = logicalLines.map((line) => safeTruncateToWidth(line, bodyWidth, "…"));
|
|
554
|
-
const expandedLines = truncatedLines.length === 1 && truncatedLines[0] === "" ? [] : truncatedLines;
|
|
555
589
|
const applyColor = (l: string) =>
|
|
556
590
|
color === "error"
|
|
557
591
|
? formatToolOutputLine(theme, l, "error")
|
|
558
592
|
: cfg.dimOutput
|
|
559
593
|
? formatToolOutputLine(theme, l)
|
|
560
594
|
: formatToolOutputLine(theme, l, "text");
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
595
|
+
const renderRawLine = (line: string) => safeTruncateToWidth(clampLineLength(line), bodyWidth, "…");
|
|
596
|
+
|
|
597
|
+
if (cfg.maxExpandedLines > 0 && totalLines > cfg.maxExpandedLines) {
|
|
598
|
+
const truncated = rawLines.slice(-cfg.maxExpandedLines).map((line) => applyColor(renderRawLine(line)));
|
|
599
|
+
const remaining = totalLines - cfg.maxExpandedLines;
|
|
564
600
|
truncated.unshift(theme.fg("dim", `… ${remaining} earlier lines`));
|
|
565
601
|
cacheKey = cacheId;
|
|
566
602
|
cacheLines = truncated;
|
|
@@ -568,7 +604,7 @@ function createBashResultPreview(
|
|
|
568
604
|
}
|
|
569
605
|
|
|
570
606
|
cacheKey = cacheId;
|
|
571
|
-
cacheLines =
|
|
607
|
+
cacheLines = rawLines.map((line) => applyColor(renderRawLine(line)));
|
|
572
608
|
return cacheLines;
|
|
573
609
|
},
|
|
574
610
|
};
|
|
@@ -754,13 +790,25 @@ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree
|
|
|
754
790
|
return { matches };
|
|
755
791
|
}
|
|
756
792
|
|
|
793
|
+
type FinalSemanticRenderCache = {
|
|
794
|
+
key: string;
|
|
795
|
+
lines: string[];
|
|
796
|
+
};
|
|
797
|
+
|
|
757
798
|
interface BashTreeState {
|
|
758
|
-
|
|
799
|
+
cls: BashSemanticClass;
|
|
759
800
|
/** Raw command, so the call panel can render the boxed bash call on fallback. */
|
|
760
|
-
|
|
801
|
+
command: string;
|
|
761
802
|
/** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
|
|
762
803
|
parsed?: ParsedSemantic;
|
|
763
804
|
fallback?: boolean;
|
|
805
|
+
finished: boolean;
|
|
806
|
+
revision: number;
|
|
807
|
+
renderCache?: FinalSemanticRenderCache;
|
|
808
|
+
/** Raw output length at the last streaming parse attempt: partial passes
|
|
809
|
+
* with smaller growth than PARTIAL_REPARSE_THRESHOLD skip the re-parse (the
|
|
810
|
+
* final pass always parses the settled output in full). */
|
|
811
|
+
lastParsedLength?: number;
|
|
764
812
|
}
|
|
765
813
|
|
|
766
814
|
/** Classified semantic command: a bash tree (ls/find/grep), a git card, or a
|
|
@@ -817,6 +865,11 @@ function isGitActionClass(cls: BashSemanticClass): boolean {
|
|
|
817
865
|
return !isBashTreeClass(cls) && (cls as GitSemanticClass).kind === "action";
|
|
818
866
|
}
|
|
819
867
|
|
|
868
|
+
/** Minimum raw-output growth (chars) before a streaming partial pass re-parses
|
|
869
|
+
* a live tree command's output; smaller deltas keep the current tree until the
|
|
870
|
+
* final pass re-parses everything. */
|
|
871
|
+
const PARTIAL_REPARSE_THRESHOLD = 4096;
|
|
872
|
+
|
|
820
873
|
function parseSemanticOutput(cls: BashSemanticClass, output: string): ParsedSemantic | null {
|
|
821
874
|
if (isBashTreeClass(cls)) return parseBashTreeOutput(cls, output);
|
|
822
875
|
if (isGhClass(cls)) return parseGhOutput(cls, output);
|
|
@@ -879,29 +932,37 @@ function renderSemanticPanel(theme: BoxTheme, toolCallId: string, context: Boxed
|
|
|
879
932
|
invalidate() {},
|
|
880
933
|
render(width: number): string[] {
|
|
881
934
|
if (!state) return [];
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
935
|
+
const renderFresh = () => {
|
|
936
|
+
if (state.fallback) {
|
|
937
|
+
return renderBoxedBashCall(
|
|
938
|
+
theme,
|
|
939
|
+
state.command.split("\n"),
|
|
940
|
+
context,
|
|
941
|
+
bashWidthKey(state.command, context?.args?.timeout),
|
|
942
|
+
).render(width);
|
|
943
|
+
}
|
|
944
|
+
if (isBashTreeClass(state.cls)) {
|
|
945
|
+
const treeState: { cls: BashTreeClass; parsed?: ParsedBashTree } = { cls: state.cls };
|
|
946
|
+
if (state.parsed !== undefined) treeState.parsed = state.parsed as ParsedBashTree;
|
|
947
|
+
return renderBashTreeLines(theme, treeState, width);
|
|
948
|
+
}
|
|
949
|
+
// Git classes only ever carry git parsed values (parseSemanticOutput
|
|
950
|
+
// dispatches on the class), so the narrowed cast is exact.
|
|
951
|
+
if (isGhClass(state.cls)) {
|
|
952
|
+
const ghState: { cls: GhSemanticClass; parsed?: GhParsedSemantic } = { cls: state.cls };
|
|
953
|
+
if (state.parsed !== undefined) ghState.parsed = state.parsed as GhParsedSemantic;
|
|
954
|
+
return renderGhCardLines(theme, ghState, width);
|
|
955
|
+
}
|
|
956
|
+
const gitState: { cls: GitSemanticClass; parsed?: GitParsedSemantic } = { cls: state.cls };
|
|
957
|
+
if (state.parsed !== undefined) gitState.parsed = state.parsed as GitParsedSemantic;
|
|
958
|
+
return renderGitCardLines(theme, gitState, width);
|
|
959
|
+
};
|
|
960
|
+
if (!state.finished) return renderFresh();
|
|
961
|
+
const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, state.revision].join("|");
|
|
962
|
+
if (state.renderCache?.key === cacheKey) return state.renderCache.lines;
|
|
963
|
+
const lines = renderFresh();
|
|
964
|
+
state.renderCache = { key: cacheKey, lines };
|
|
965
|
+
return lines;
|
|
905
966
|
},
|
|
906
967
|
};
|
|
907
968
|
}
|
|
@@ -911,7 +972,27 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
911
972
|
noteExecutionStart(context);
|
|
912
973
|
const cls = classifyBashSemantic(String(args?.command ?? ""));
|
|
913
974
|
if (cls) {
|
|
914
|
-
|
|
975
|
+
const command = String(args?.command ?? "");
|
|
976
|
+
const existing = semanticStates.get(context.toolCallId);
|
|
977
|
+
if (existing) {
|
|
978
|
+
if (existing.command !== command || existing.cls.kind !== cls.kind) {
|
|
979
|
+
delete existing.parsed;
|
|
980
|
+
delete existing.fallback;
|
|
981
|
+
existing.finished = false;
|
|
982
|
+
existing.revision++;
|
|
983
|
+
delete existing.renderCache;
|
|
984
|
+
delete existing.lastParsedLength;
|
|
985
|
+
}
|
|
986
|
+
existing.command = command;
|
|
987
|
+
existing.cls = cls;
|
|
988
|
+
} else {
|
|
989
|
+
semanticStates.set(context.toolCallId, {
|
|
990
|
+
cls,
|
|
991
|
+
command,
|
|
992
|
+
finished: false,
|
|
993
|
+
revision: 0,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
915
996
|
return renderSemanticPanel(theme, context.toolCallId, context);
|
|
916
997
|
}
|
|
917
998
|
noteBoxedCallState(context);
|
|
@@ -936,15 +1017,34 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
936
1017
|
}
|
|
937
1018
|
if (!options.isPartial || isBashTreeClass(cls)) {
|
|
938
1019
|
const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
|
|
939
|
-
const parsed = parseSemanticOutput(cls, output);
|
|
940
1020
|
const state = semanticStates.get(context.toolCallId);
|
|
1021
|
+
// Live tree classes re-parse on streaming passes, but only once the raw
|
|
1022
|
+
// output grew ≥ PARTIAL_REPARSE_THRESHOLD chars since the last parse
|
|
1023
|
+
// attempt: re-parsing the full buffer on every partial pass made
|
|
1024
|
+
// streaming O(n²). Small deltas keep the current tree; the final pass
|
|
1025
|
+
// always re-parses, so the settled registry state matches the ungated
|
|
1026
|
+
// path byte for byte.
|
|
1027
|
+
const shouldParse =
|
|
1028
|
+
!options.isPartial ||
|
|
1029
|
+
state === undefined ||
|
|
1030
|
+
state.lastParsedLength === undefined ||
|
|
1031
|
+
output.length - state.lastParsedLength >= PARTIAL_REPARSE_THRESHOLD;
|
|
1032
|
+
const parsed = shouldParse ? parseSemanticOutput(cls, output) : undefined;
|
|
941
1033
|
if (parsed) {
|
|
942
|
-
if (state)
|
|
943
|
-
|
|
1034
|
+
if (state) {
|
|
1035
|
+
state.parsed = parsed;
|
|
1036
|
+
state.finished = !options.isPartial;
|
|
1037
|
+
state.revision++;
|
|
1038
|
+
delete state.renderCache;
|
|
1039
|
+
if (options.isPartial) state.lastParsedLength = output.length;
|
|
1040
|
+
} else
|
|
944
1041
|
semanticStates.set(context.toolCallId, {
|
|
945
1042
|
cls,
|
|
946
1043
|
command: String(context?.args?.command ?? ""),
|
|
947
1044
|
parsed,
|
|
1045
|
+
finished: !options.isPartial,
|
|
1046
|
+
revision: 0,
|
|
1047
|
+
...(options.isPartial ? { lastParsedLength: output.length } : {}),
|
|
948
1048
|
});
|
|
949
1049
|
// `git diff` / `git show` render a boxed adaptive-diff result (one frame
|
|
950
1050
|
// per file); `gh run view --job=<id>` renders a boxed log result. Every
|
|
@@ -958,10 +1058,26 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
958
1058
|
}
|
|
959
1059
|
return EMPTY_BASH_TREE_RESULT;
|
|
960
1060
|
}
|
|
1061
|
+
if (!shouldParse) {
|
|
1062
|
+
// Skipped re-parse (sub-threshold growth): keep the current panel. A
|
|
1063
|
+
// live parsed tree still owns the display (the call panel renders it, the
|
|
1064
|
+
// result adds nothing); a fallback keeps streaming raw output into the
|
|
1065
|
+
// open box below.
|
|
1066
|
+
if (state?.parsed !== undefined) return EMPTY_BASH_TREE_RESULT;
|
|
1067
|
+
}
|
|
961
1068
|
// Unparseable output (ls -l, raw rg summary, non-git output): the boxed
|
|
962
1069
|
// shell owns the result; flag the call panel to render nothing so the
|
|
963
|
-
// two don't duplicate.
|
|
964
|
-
|
|
1070
|
+
// two don't duplicate. Skipped passes (sub-threshold growth) leave the
|
|
1071
|
+
// current panel untouched.
|
|
1072
|
+
if (shouldParse) {
|
|
1073
|
+
if (state) {
|
|
1074
|
+
state.fallback = true;
|
|
1075
|
+
state.finished = !options.isPartial;
|
|
1076
|
+
state.revision++;
|
|
1077
|
+
delete state.renderCache;
|
|
1078
|
+
if (options.isPartial) state.lastParsedLength = output.length;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
965
1081
|
}
|
|
966
1082
|
} else if (options.isPartial) {
|
|
967
1083
|
startElapsedTicker(context.state, context.invalidate);
|
|
@@ -976,6 +1092,19 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
976
1092
|
if (firstResultPass) return EMPTY_BASH_RESULT;
|
|
977
1093
|
return renderBashStreamingResult(theme, raw, options, context);
|
|
978
1094
|
}
|
|
979
|
-
return
|
|
1095
|
+
return memoizedStateComponent(
|
|
1096
|
+
context.state,
|
|
1097
|
+
"__piStyleBashFinalResult",
|
|
1098
|
+
getRenderCacheKey(
|
|
1099
|
+
"bash-final-result",
|
|
1100
|
+
theme,
|
|
1101
|
+
Boolean(options.expanded),
|
|
1102
|
+
Boolean(context.isError),
|
|
1103
|
+
String(context?.args?.command ?? ""),
|
|
1104
|
+
raw,
|
|
1105
|
+
getStateElapsedMs(context.state) ?? "",
|
|
1106
|
+
),
|
|
1107
|
+
() => renderBashFinalResult(theme, raw, options, context),
|
|
1108
|
+
);
|
|
980
1109
|
},
|
|
981
1110
|
};
|