@quandev104/pi-style 0.2.0 → 0.2.1

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.
@@ -1,9 +1,46 @@
1
- import { visibleWidth } from "@earendil-works/pi-tui";
1
+ import { visibleWidth } from "../../shared/ansi.js";
2
2
 
3
3
  const OSC133_ZONE_START = "\x1b]133;A\x07";
4
4
  const OSC133_ZONE_END = "\x1b]133;B\x07";
5
5
  const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
6
6
  type OscParts = { start: string; body: string; end: string };
7
+ type LineAnalysis = {
8
+ visibleWidth: number;
9
+ hasContent: boolean;
10
+ oscEnvelope: OscParts | undefined;
11
+ hasOscStart: boolean;
12
+ leadingMarkers: { head: string; rest: string };
13
+ isBackgroundWrapped: boolean;
14
+ backgroundAnsi: string;
15
+ backgroundBody: string | undefined;
16
+ backgroundBodyWidth: number | undefined;
17
+ };
18
+ type DecoratedRenderCacheEntry = {
19
+ nativeRef: readonly string[];
20
+ nativeLines: readonly string[];
21
+ result: readonly string[];
22
+ };
23
+ type MessageDecorationTestState = {
24
+ decoratePasses: number;
25
+ cacheHits: number;
26
+ cacheMisses: number;
27
+ lineCacheHits: number;
28
+ lineCacheMisses: number;
29
+ };
30
+
31
+ const BG_RESET = "\x1b[49m";
32
+ const MAX_RENDER_CACHE_KEYS_PER_INSTANCE = 8;
33
+ const MAX_LINE_ANALYSIS_ENTRIES = 4096;
34
+
35
+ let renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
36
+ let lineAnalysisCache = new Map<string, LineAnalysis>();
37
+ const messageDecorationTestState: MessageDecorationTestState = {
38
+ decoratePasses: 0,
39
+ cacheHits: 0,
40
+ cacheMisses: 0,
41
+ lineCacheHits: 0,
42
+ lineCacheMisses: 0,
43
+ };
7
44
 
8
45
  function extractOscEnvelope(line: string): OscParts | undefined {
9
46
  if (!line.startsWith(OSC133_ZONE_START)) return undefined;
@@ -12,8 +49,6 @@ function extractOscEnvelope(line: string): OscParts | undefined {
12
49
  return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) };
13
50
  }
14
51
 
15
- const BG_RESET = "\x1b[49m";
16
-
17
52
  /** Leading zero-width OSC sequences (e.g. OSC133 markers) of a line. */
18
53
  function splitLeadingMarkers(line: string): { head: string; rest: string } {
19
54
  let index = 0;
@@ -51,27 +86,81 @@ function isBackgroundSgr(sequence: string): boolean {
51
86
  return false;
52
87
  }
53
88
 
54
- /**
55
- * Rebuild a native line so `lead` (prompt prefix / continuation indent) and the
56
- * full target width are covered by the line's background.
57
- *
58
- * Native Box lines (user messages) are `bgAnsi + body + \x1b[49m`; prepending the
59
- * prefix outside that wrap shifted the input row's background right by the
60
- * prefix width while keeping the full container width, producing a staircase
61
- * box (indented left, overflowing right). Rebuilding inside the wrap keeps the
62
- * background flush across every row; plain (unwrapped) lines are padded to the
63
- * target width instead so left/right edges stay aligned.
64
- */
65
- function rebuildAtWidth(line: string, width: number, lead: string): string {
66
- const { head, rest } = splitLeadingMarkers(line);
67
- const bgAnsi = leadingSgr(rest);
68
- if (bgAnsi && isBackgroundSgr(bgAnsi) && rest.endsWith(BG_RESET)) {
69
- const body = rest.slice(bgAnsi.length, rest.length - BG_RESET.length);
70
- const pad = " ".repeat(Math.max(0, width - visibleWidth(lead) - visibleWidth(body)));
71
- return `${head}${bgAnsi}${lead}${body}${pad}${BG_RESET}`;
89
+ function contentText(line: string): string {
90
+ let output = "";
91
+ for (let index = 0; index < line.length; index++) {
92
+ if (line.charCodeAt(index) !== 27) {
93
+ output += line[index];
94
+ continue;
95
+ }
96
+ const next = line[index + 1];
97
+ if (next === "]") {
98
+ index += 2;
99
+ while (index < line.length && line.charCodeAt(index) !== 7) index++;
100
+ continue;
101
+ }
102
+ if (next === "[") {
103
+ index += 2;
104
+ while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
105
+ }
106
+ }
107
+ return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
108
+ }
109
+
110
+ function hasContent(line: string): boolean {
111
+ return [...contentText(line)].some((character) => !/\s/u.test(character));
112
+ }
113
+
114
+ function getLineAnalysis(line: string): LineAnalysis {
115
+ const cached = lineAnalysisCache.get(line);
116
+ if (cached) {
117
+ messageDecorationTestState.lineCacheHits++;
118
+ return cached;
119
+ }
120
+ messageDecorationTestState.lineCacheMisses++;
121
+ const leadingMarkers = splitLeadingMarkers(line);
122
+ const backgroundAnsi = leadingSgr(leadingMarkers.rest);
123
+ const isBackgroundWrapped =
124
+ backgroundAnsi !== "" && isBackgroundSgr(backgroundAnsi) && leadingMarkers.rest.endsWith(BG_RESET);
125
+ const backgroundBody = isBackgroundWrapped
126
+ ? leadingMarkers.rest.slice(backgroundAnsi.length, leadingMarkers.rest.length - BG_RESET.length)
127
+ : undefined;
128
+ const analysis: LineAnalysis = {
129
+ visibleWidth: visibleWidth(line),
130
+ hasContent: hasContent(line),
131
+ oscEnvelope: extractOscEnvelope(line),
132
+ hasOscStart: line.startsWith(OSC133_ZONE_START),
133
+ leadingMarkers,
134
+ isBackgroundWrapped,
135
+ backgroundAnsi,
136
+ backgroundBody,
137
+ backgroundBodyWidth: backgroundBody === undefined ? undefined : visibleWidth(backgroundBody),
138
+ };
139
+ lineAnalysisCache.set(line, analysis);
140
+ if (lineAnalysisCache.size > MAX_LINE_ANALYSIS_ENTRIES) {
141
+ const oldestKey = lineAnalysisCache.keys().next().value;
142
+ if (oldestKey !== undefined) lineAnalysisCache.delete(oldestKey);
143
+ }
144
+ return analysis;
145
+ }
146
+
147
+ function rebuildAtWidth(
148
+ line: string,
149
+ width: number,
150
+ lead: string,
151
+ leadWidth: number,
152
+ analysis = getLineAnalysis(line),
153
+ ): string {
154
+ if (
155
+ analysis.isBackgroundWrapped &&
156
+ analysis.backgroundBody !== undefined &&
157
+ analysis.backgroundBodyWidth !== undefined
158
+ ) {
159
+ const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.backgroundBodyWidth));
160
+ return `${analysis.leadingMarkers.head}${analysis.backgroundAnsi}${lead}${analysis.backgroundBody}${pad}${BG_RESET}`;
72
161
  }
73
- const padded = `${lead}${line}`;
74
- return `${padded}${" ".repeat(Math.max(0, width - visibleWidth(padded)))}`;
162
+ const pad = " ".repeat(Math.max(0, width - leadWidth - analysis.visibleWidth));
163
+ return `${lead}${line}${pad}`;
75
164
  }
76
165
 
77
166
  function decorateMessageLine(
@@ -85,20 +174,23 @@ function decorateMessageLine(
85
174
  firstHasStart: boolean;
86
175
  multilineEnvelope: boolean;
87
176
  prefix: string;
177
+ prefixWidth: number;
88
178
  },
179
+ analysis = getLineAnalysis(line),
89
180
  ): string {
90
- const { firstEnvelope, firstHasStart, multilineEnvelope, prefix } = options;
91
- const prefixWidth = visibleWidth(prefix);
181
+ const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth } = options;
92
182
  const lead = index === contentIndex ? prefix : index > contentIndex ? " ".repeat(prefixWidth) : "";
183
+ const leadWidth = index < contentIndex ? 0 : prefixWidth;
93
184
  if (index === contentIndex && firstEnvelope)
94
- return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix)}${firstEnvelope.end}`;
185
+ return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix, prefixWidth)}${firstEnvelope.end}`;
95
186
  if (index === contentIndex && firstHasStart)
96
- return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix)}`;
187
+ return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix, prefixWidth)}`;
97
188
  if (index === lastIndex && multilineEnvelope && index !== contentIndex)
98
189
  return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
99
190
  line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
100
191
  width,
101
192
  lead,
193
+ leadWidth,
102
194
  )}`;
103
195
  if (
104
196
  index === contentIndex &&
@@ -110,43 +202,61 @@ function decorateMessageLine(
110
202
  line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
111
203
  width,
112
204
  prefix,
205
+ prefixWidth,
113
206
  )}`;
114
- return rebuildAtWidth(line, width, lead);
207
+ return rebuildAtWidth(line, width, lead, leadWidth, analysis);
115
208
  }
116
209
 
117
- function contentText(line: string): string {
118
- let output = "";
119
- for (let index = 0; index < line.length; index++) {
120
- if (line.charCodeAt(index) !== 27) {
121
- output += line[index];
122
- continue;
123
- }
124
- const next = line[index + 1];
125
- if (next === "]") {
126
- index += 2;
127
- while (index < line.length && line.charCodeAt(index) !== 7) index++;
128
- continue;
129
- }
130
- if (next === "[") {
131
- index += 2;
132
- while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
133
- }
210
+ function sameLines(left: readonly string[], right: readonly string[]): boolean {
211
+ if (left === right) return true;
212
+ if (left.length !== right.length) return false;
213
+ for (let index = 0; index < left.length; index++) {
214
+ if (left[index] !== right[index]) return false;
134
215
  }
135
- return output.replaceAll(OSC133_ZONE_START, "").replaceAll(OSC133_ZONE_END, "").replaceAll(OSC133_ZONE_FINAL, "");
216
+ return true;
136
217
  }
137
218
 
138
- function hasContent(line: string): boolean {
139
- return [...contentText(line)].some((character) => !/\s/u.test(character));
219
+ function cacheKey(width: number, prefix: string): string {
220
+ return `${width}\u0000${prefix}`;
221
+ }
222
+
223
+ function getRenderCache(instance: object): Map<string, DecoratedRenderCacheEntry> {
224
+ let cache = renderCacheByInstance.get(instance);
225
+ if (!cache) {
226
+ cache = new Map();
227
+ renderCacheByInstance.set(instance, cache);
228
+ }
229
+ return cache;
230
+ }
231
+
232
+ function storeRenderCache(
233
+ instance: object,
234
+ width: number,
235
+ prefix: string,
236
+ native: readonly string[],
237
+ result: readonly string[],
238
+ ): void {
239
+ const cache = getRenderCache(instance);
240
+ const key = cacheKey(width, prefix);
241
+ if (cache.has(key)) cache.delete(key);
242
+ cache.set(key, { nativeRef: native, nativeLines: [...native], result: [...result] });
243
+ while (cache.size > MAX_RENDER_CACHE_KEYS_PER_INSTANCE) {
244
+ const oldestKey = cache.keys().next().value;
245
+ if (oldestKey === undefined) break;
246
+ cache.delete(oldestKey);
247
+ }
140
248
  }
141
249
 
142
250
  function prefixNative(lines: unknown, width: number, prefix: string): string[] | undefined {
143
251
  if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined;
252
+ messageDecorationTestState.decoratePasses++;
144
253
  const nativeLines = lines as string[];
145
254
  const prefixWidth = visibleWidth(prefix);
146
255
  if (width <= prefixWidth) return undefined;
147
256
  const bodyWidth = width - prefixWidth;
148
257
  const first = nativeLines[0] ?? "";
149
258
  const last = nativeLines.at(-1) ?? "";
259
+ const lastAnalysis = getLineAnalysis(last);
150
260
  const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
151
261
  // The last line is a content-start candidate only when the envelope is
152
262
  // single-line, or when no earlier line carries content. Assistant messages
@@ -154,22 +264,26 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
154
264
  // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
155
265
  // would drop the prefix for every short assistant reply.
156
266
  const firstContentIndex = nativeLines.findIndex((line, index) => {
157
- if (index !== nativeLines.length - 1 || !multilineEnvelope) return hasContent(line);
158
- return !nativeLines.slice(0, index).some((earlier) => hasContent(earlier)) && hasContent(line);
267
+ if (index !== nativeLines.length - 1 || !multilineEnvelope) return getLineAnalysis(line).hasContent;
268
+ return (
269
+ !nativeLines.slice(0, index).some((earlier) => getLineAnalysis(earlier).hasContent) && lastAnalysis.hasContent
270
+ );
159
271
  });
160
272
  if (firstContentIndex < 0) return nativeLines;
161
- const firstEnvelope = firstContentIndex === 0 ? extractOscEnvelope(first) : undefined;
162
- const firstHasStart = firstContentIndex === 0 && first.startsWith(OSC133_ZONE_START);
273
+ const firstAnalysis = getLineAnalysis(first);
274
+ const firstEnvelope = firstContentIndex === 0 ? firstAnalysis.oscEnvelope : undefined;
275
+ const firstHasStart = firstContentIndex === 0 && firstAnalysis.hasOscStart;
163
276
  const decorated = nativeLines.map((line, index) =>
164
277
  decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, width, {
165
278
  firstEnvelope,
166
279
  firstHasStart,
167
280
  multilineEnvelope,
168
281
  prefix,
282
+ prefixWidth,
169
283
  }),
170
284
  );
171
285
  if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
172
- if (!nativeLines.every((line) => visibleWidth(line) <= bodyWidth)) return undefined;
286
+ if (!nativeLines.every((line) => getLineAnalysis(line).visibleWidth <= bodyWidth)) return undefined;
173
287
  return decorated;
174
288
  }
175
289
 
@@ -182,6 +296,20 @@ export type MessageDecorationSnapshot = Readonly<{
182
296
  hideInterimText: boolean;
183
297
  }>;
184
298
 
299
+ export function __getMessageDecorationTestState(): Readonly<MessageDecorationTestState> {
300
+ return { ...messageDecorationTestState };
301
+ }
302
+
303
+ export function __resetMessageDecorationTestState(): void {
304
+ messageDecorationTestState.decoratePasses = 0;
305
+ messageDecorationTestState.cacheHits = 0;
306
+ messageDecorationTestState.cacheMisses = 0;
307
+ messageDecorationTestState.lineCacheHits = 0;
308
+ messageDecorationTestState.lineCacheMisses = 0;
309
+ renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
310
+ lineAnalysisCache = new Map<string, LineAnalysis>();
311
+ }
312
+
185
313
  export function decorateMessageRender(
186
314
  original: unknown,
187
315
  instance: object,
@@ -197,12 +325,22 @@ export function decorateMessageRender(
197
325
  const width = typeof args[0] === "number" ? args[0] : 0;
198
326
  const prefix = snapshot.assistantPrefix;
199
327
  if (!snapshot.assistantEnabled) return Reflect.apply(original, instance, args);
200
- if (width <= visibleWidth(prefix)) return Reflect.apply(original, instance, args);
328
+ const prefixWidth = visibleWidth(prefix);
329
+ if (width <= prefixWidth) return Reflect.apply(original, instance, args);
201
330
  // Exactly one native invocation. If the reduced render cannot be certified, the
202
331
  // already-obtained result is the only safe fallback; retrying can mutate state.
203
- const reducedWidth = width - visibleWidth(prefix);
332
+ const reducedWidth = width - prefixWidth;
204
333
  const native = Reflect.apply(original, instance, [reducedWidth, ...args.slice(1)]);
205
- return prefixNative(native, width, prefix) ?? native;
334
+ if (!Array.isArray(native) || !native.every((line) => typeof line === "string")) return native;
335
+ const cached = getRenderCache(instance).get(cacheKey(width, prefix));
336
+ if (cached && (cached.nativeRef === native || sameLines(cached.nativeLines, native))) {
337
+ messageDecorationTestState.cacheHits++;
338
+ return [...cached.result];
339
+ }
340
+ messageDecorationTestState.cacheMisses++;
341
+ const decorated = prefixNative(native, width, prefix) ?? native;
342
+ storeRenderCache(instance, width, prefix, native, decorated);
343
+ return decorated;
206
344
  }
207
345
 
208
346
  /** Spacer-like: renders empty lines and exposes only setLines among these surfaces. */
@@ -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";
@@ -754,13 +763,21 @@ function parseBashTreeOutput(cls: BashTreeClass, output: string): ParsedBashTree
754
763
  return { matches };
755
764
  }
756
765
 
766
+ type FinalSemanticRenderCache = {
767
+ key: string;
768
+ lines: string[];
769
+ };
770
+
757
771
  interface BashTreeState {
758
- readonly cls: BashSemanticClass;
772
+ cls: BashSemanticClass;
759
773
  /** Raw command, so the call panel can render the boxed bash call on fallback. */
760
- readonly command: string;
774
+ command: string;
761
775
  /** `parsed` once the result arrives; `fallback` when the boxed shell takes over. */
762
776
  parsed?: ParsedSemantic;
763
777
  fallback?: boolean;
778
+ finished: boolean;
779
+ revision: number;
780
+ renderCache?: FinalSemanticRenderCache;
764
781
  }
765
782
 
766
783
  /** Classified semantic command: a bash tree (ls/find/grep), a git card, or a
@@ -879,29 +896,37 @@ function renderSemanticPanel(theme: BoxTheme, toolCallId: string, context: Boxed
879
896
  invalidate() {},
880
897
  render(width: number): string[] {
881
898
  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);
899
+ const renderFresh = () => {
900
+ if (state.fallback) {
901
+ return renderBoxedBashCall(
902
+ theme,
903
+ state.command.split("\n"),
904
+ context,
905
+ bashWidthKey(state.command, context?.args?.timeout),
906
+ ).render(width);
907
+ }
908
+ if (isBashTreeClass(state.cls)) {
909
+ const treeState: { cls: BashTreeClass; parsed?: ParsedBashTree } = { cls: state.cls };
910
+ if (state.parsed !== undefined) treeState.parsed = state.parsed as ParsedBashTree;
911
+ return renderBashTreeLines(theme, treeState, width);
912
+ }
913
+ // Git classes only ever carry git parsed values (parseSemanticOutput
914
+ // dispatches on the class), so the narrowed cast is exact.
915
+ if (isGhClass(state.cls)) {
916
+ const ghState: { cls: GhSemanticClass; parsed?: GhParsedSemantic } = { cls: state.cls };
917
+ if (state.parsed !== undefined) ghState.parsed = state.parsed as GhParsedSemantic;
918
+ return renderGhCardLines(theme, ghState, width);
919
+ }
920
+ const gitState: { cls: GitSemanticClass; parsed?: GitParsedSemantic } = { cls: state.cls };
921
+ if (state.parsed !== undefined) gitState.parsed = state.parsed as GitParsedSemantic;
922
+ return renderGitCardLines(theme, gitState, width);
923
+ };
924
+ if (!state.finished) return renderFresh();
925
+ const cacheKey = [themeCacheKey(theme), getToolsRenderCacheSignature(), width, state.revision].join("|");
926
+ if (state.renderCache?.key === cacheKey) return state.renderCache.lines;
927
+ const lines = renderFresh();
928
+ state.renderCache = { key: cacheKey, lines };
929
+ return lines;
905
930
  },
906
931
  };
907
932
  }
@@ -911,7 +936,26 @@ export const bashTool: BoxedToolDefinition = {
911
936
  noteExecutionStart(context);
912
937
  const cls = classifyBashSemantic(String(args?.command ?? ""));
913
938
  if (cls) {
914
- semanticStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
939
+ const command = String(args?.command ?? "");
940
+ const existing = semanticStates.get(context.toolCallId);
941
+ if (existing) {
942
+ if (existing.command !== command || existing.cls.kind !== cls.kind) {
943
+ delete existing.parsed;
944
+ delete existing.fallback;
945
+ existing.finished = false;
946
+ existing.revision++;
947
+ delete existing.renderCache;
948
+ }
949
+ existing.command = command;
950
+ existing.cls = cls;
951
+ } else {
952
+ semanticStates.set(context.toolCallId, {
953
+ cls,
954
+ command,
955
+ finished: false,
956
+ revision: 0,
957
+ });
958
+ }
915
959
  return renderSemanticPanel(theme, context.toolCallId, context);
916
960
  }
917
961
  noteBoxedCallState(context);
@@ -939,12 +983,18 @@ export const bashTool: BoxedToolDefinition = {
939
983
  const parsed = parseSemanticOutput(cls, output);
940
984
  const state = semanticStates.get(context.toolCallId);
941
985
  if (parsed) {
942
- if (state) state.parsed = parsed;
943
- else
986
+ if (state) {
987
+ state.parsed = parsed;
988
+ state.finished = !options.isPartial;
989
+ state.revision++;
990
+ delete state.renderCache;
991
+ } else
944
992
  semanticStates.set(context.toolCallId, {
945
993
  cls,
946
994
  command: String(context?.args?.command ?? ""),
947
995
  parsed,
996
+ finished: !options.isPartial,
997
+ revision: 0,
948
998
  });
949
999
  // `git diff` / `git show` render a boxed adaptive-diff result (one frame
950
1000
  // per file); `gh run view --job=<id>` renders a boxed log result. Every
@@ -961,7 +1011,12 @@ export const bashTool: BoxedToolDefinition = {
961
1011
  // Unparseable output (ls -l, raw rg summary, non-git output): the boxed
962
1012
  // shell owns the result; flag the call panel to render nothing so the
963
1013
  // two don't duplicate.
964
- if (state) state.fallback = true;
1014
+ if (state) {
1015
+ state.fallback = true;
1016
+ state.finished = !options.isPartial;
1017
+ state.revision++;
1018
+ delete state.renderCache;
1019
+ }
965
1020
  }
966
1021
  } else if (options.isPartial) {
967
1022
  startElapsedTicker(context.state, context.invalidate);
@@ -976,6 +1031,19 @@ export const bashTool: BoxedToolDefinition = {
976
1031
  if (firstResultPass) return EMPTY_BASH_RESULT;
977
1032
  return renderBashStreamingResult(theme, raw, options, context);
978
1033
  }
979
- return renderBashFinalResult(theme, raw, options, context);
1034
+ return memoizedStateComponent(
1035
+ context.state,
1036
+ "__piStyleBashFinalResult",
1037
+ getRenderCacheKey(
1038
+ "bash-final-result",
1039
+ theme,
1040
+ Boolean(options.expanded),
1041
+ Boolean(context.isError),
1042
+ String(context?.args?.command ?? ""),
1043
+ raw,
1044
+ getStateElapsedMs(context.state) ?? "",
1045
+ ),
1046
+ () => renderBashFinalResult(theme, raw, options, context),
1047
+ );
980
1048
  },
981
1049
  };