@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/dist/extensions/pi-style.js +1256 -491
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/index.ts +3 -2
  6. package/extension-src/pi-style/app/runtime.ts +99 -86
  7. package/extension-src/pi-style/app/snapshot.ts +41 -2
  8. package/extension-src/pi-style/domain/status-renderer.ts +40 -8
  9. package/extension-src/pi-style/domain/status.ts +15 -5
  10. package/extension-src/pi-style/domain/theme.ts +32 -1
  11. package/extension-src/pi-style/features/editor/index.ts +97 -66
  12. package/extension-src/pi-style/features/messages/index.ts +469 -90
  13. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  14. package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +195 -66
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +60 -10
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +45 -25
  18. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  19. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  21. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -22
  23. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  24. package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -14
  25. package/extension-src/pi-style/features/tools/boxed/shared.ts +51 -0
  26. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  27. package/extension-src/pi-style/pi/compatibility-probe.ts +1 -1
  28. package/extension-src/pi-style/pi/index.ts +28 -13
  29. package/extension-src/pi-style/pi/session-usage.ts +204 -21
  30. package/extension-src/pi-style/shared/ansi.ts +17 -5
  31. package/extension-src/pi-style/shared/box.ts +83 -6
  32. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  33. 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 = resolveTheme(
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: separatorsFor(config, resolved),
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
- return [
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 { type BoxedToolContext, type BoxedToolDefinition, noteBoxedCallState, noteExecutionStart } from "./shared.js";
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
- const body = stripBashToolNoticeLines(stripAnsi(raw));
383
- const hasOutput = body.trim().length > 0;
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
- let nlCount = 0;
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
- let totalNewlines = 0;
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: process all lines
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 logicalLines = normalized.split("\n").map((l) => clampLineLength(l));
545
- const hasOutput = !(logicalLines.length === 1 && logicalLines[0] === "");
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
- if (cfg.maxExpandedLines > 0 && expandedLines.length > cfg.maxExpandedLines) {
562
- const truncated = expandedLines.slice(-cfg.maxExpandedLines).map(applyColor);
563
- const remaining = expandedLines.length - cfg.maxExpandedLines;
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 = expandedLines.map(applyColor);
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
- readonly cls: BashSemanticClass;
799
+ cls: BashSemanticClass;
759
800
  /** Raw command, so the call panel can render the boxed bash call on fallback. */
760
- readonly command: string;
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
- if (state.fallback) {
883
- return renderBoxedBashCall(
884
- theme,
885
- state.command.split("\n"),
886
- context,
887
- bashWidthKey(state.command, context?.args?.timeout),
888
- ).render(width);
889
- }
890
- if (isBashTreeClass(state.cls)) {
891
- const treeState: { cls: BashTreeClass; parsed?: ParsedBashTree } = { cls: state.cls };
892
- if (state.parsed !== undefined) treeState.parsed = state.parsed as ParsedBashTree;
893
- return renderBashTreeLines(theme, treeState, width);
894
- }
895
- // Git classes only ever carry git parsed values (parseSemanticOutput
896
- // dispatches on the class), so the narrowed cast is exact.
897
- if (isGhClass(state.cls)) {
898
- const ghState: { cls: GhSemanticClass; parsed?: GhParsedSemantic } = { cls: state.cls };
899
- if (state.parsed !== undefined) ghState.parsed = state.parsed as GhParsedSemantic;
900
- return renderGhCardLines(theme, ghState, width);
901
- }
902
- const gitState: { cls: GitSemanticClass; parsed?: GitParsedSemantic } = { cls: state.cls };
903
- if (state.parsed !== undefined) gitState.parsed = state.parsed as GitParsedSemantic;
904
- return renderGitCardLines(theme, gitState, width);
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
- semanticStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
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) state.parsed = parsed;
943
- else
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
- if (state) state.fallback = true;
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 renderBashFinalResult(theme, raw, options, context);
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
  };