@esso0428/pi-subagents 0.15.17 → 0.17.0

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 (96) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -12
  3. package/dist/agent-history-list.d.ts.map +1 -1
  4. package/dist/agent-history-list.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +20 -40
  7. package/dist/index.js.map +1 -1
  8. package/dist/output-file.js +3 -3
  9. package/dist/output-file.js.map +1 -1
  10. package/dist/settings.d.ts +1 -8
  11. package/dist/settings.d.ts.map +1 -1
  12. package/dist/settings.js +0 -5
  13. package/dist/settings.js.map +1 -1
  14. package/dist/ui/agent-widget.d.ts +66 -28
  15. package/dist/ui/agent-widget.d.ts.map +1 -1
  16. package/dist/ui/agent-widget.js +325 -134
  17. package/dist/ui/agent-widget.js.map +1 -1
  18. package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
  19. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
  20. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
  21. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
  22. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
  23. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
  24. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  25. package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
  26. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  27. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
  28. package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
  29. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
  30. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
  31. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
  32. package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
  33. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
  34. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  35. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  36. package/dist/ui/ccstyle/tool-renderer.js +1 -1
  37. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  38. package/dist/ui/ccstyle/tool-result.d.ts +9 -1
  39. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  40. package/dist/ui/ccstyle/tool-result.js +137 -5
  41. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  42. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  43. package/dist/ui/conversation-blocks.js +14 -13
  44. package/dist/ui/conversation-blocks.js.map +1 -1
  45. package/dist/ui/conversation-role.d.ts +1 -1
  46. package/dist/ui/conversation-role.d.ts.map +1 -1
  47. package/dist/ui/conversation-search.js +4 -4
  48. package/dist/ui/conversation-search.js.map +1 -1
  49. package/dist/ui/conversation-timeline.d.ts +17 -1
  50. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  51. package/dist/ui/conversation-timeline.js +116 -43
  52. package/dist/ui/conversation-timeline.js.map +1 -1
  53. package/dist/ui/conversation-viewer.d.ts +5 -1
  54. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  55. package/dist/ui/conversation-viewer.js +67 -26
  56. package/dist/ui/conversation-viewer.js.map +1 -1
  57. package/dist/write-execution.d.ts.map +1 -1
  58. package/dist/write-execution.js +1 -1
  59. package/dist/write-execution.js.map +1 -1
  60. package/package.json +6 -5
  61. package/src/agent-history-list.ts +1 -1
  62. package/src/index.ts +19 -73
  63. package/src/output-file.ts +3 -3
  64. package/src/settings.ts +1 -12
  65. package/src/ui/agent-widget.ts +264 -114
  66. package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
  67. package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
  68. package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
  69. package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
  70. package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
  71. package/src/ui/ccstyle/tool-renderer.ts +3 -3
  72. package/src/ui/ccstyle/tool-result.ts +175 -7
  73. package/src/ui/conversation-role.ts +1 -1
  74. package/src/ui/conversation-search.ts +4 -4
  75. package/src/ui/conversation-timeline.ts +7 -7
  76. package/src/ui/conversation-viewer.ts +16 -7
  77. package/src/write-execution.ts +2 -2
  78. package/dist/ui/fleet-list.d.ts +0 -122
  79. package/dist/ui/fleet-list.d.ts.map +0 -1
  80. package/dist/ui/fleet-list.js +0 -375
  81. package/dist/ui/fleet-list.js.map +0 -1
  82. package/src/ui/deferred-ui-refresh.ts +0 -72
  83. package/src/ui/fleet-list.ts +0 -585
  84. package/test/agent-history-list.test.ts +0 -121
  85. package/test/agent-manager-history.test.ts +0 -88
  86. package/test/agent-menu-history-navigation.test.ts +0 -86
  87. package/test/output-file-history.test.ts +0 -50
  88. package/test/ui/agent-widget.test.ts +0 -172
  89. package/test/ui/conversation-blocks.test.ts +0 -103
  90. package/test/ui/conversation-diff.test.ts +0 -91
  91. package/test/ui/conversation-search.test.ts +0 -55
  92. package/test/ui/conversation-viewer.test.ts +0 -438
  93. package/test/ui/deferred-ui-refresh.test.ts +0 -55
  94. package/test/ui/fleet-list.test.ts +0 -389
  95. package/test/write-execution.test.ts +0 -83
  96. package/tsconfig.json +0 -16
@@ -1,39 +1,37 @@
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
1
2
  import {
3
+ type Component,
2
4
  Text,
3
5
  truncateToWidth,
4
6
  visibleWidth,
5
7
  wrapTextWithAnsi,
6
- type Component,
7
8
  } from "@earendil-works/pi-tui";
8
- import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
9
9
  import {
10
10
  ANSI_SGR_PATTERN,
11
- STYLE_RESET_PARAMS,
11
+ expandSgrReset,
12
12
  filterSgrSequences,
13
- toSgrParams,
14
13
  readSgrColorSequence,
15
- isFiniteSgrParam,
16
- expandSgrReset,
14
+ toSgrParams,
17
15
  } from "./ansi-utils.js";
18
- import {
19
- buildCollapsedDiffHintText,
20
- clampRenderedLineToWidth,
21
- clampRenderedLinesToWidth,
22
- } from "./line-width-safety.js";
23
16
  import {
24
17
  buildDiffSummaryText,
18
+ type DiffPresentationMode,
25
19
  normalizeDiffRenderWidth,
26
20
  resolveDiffPresentationMode,
27
- type DiffPresentationMode,
28
21
  } from "./diff-presentation.js";
29
- import { pluralize, sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./render-utils.js";
30
- import { splitWriteContentLines } from "./write-display-utils.js";
22
+ import {
23
+ buildCollapsedDiffHintText,
24
+ clampRenderedLinesToWidth,
25
+ clampRenderedLineToWidth,
26
+ } from "./line-width-safety.js";
27
+ import { sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./render-utils.js";
31
28
  import {
32
29
  DEFAULT_TOOL_DISPLAY_CONFIG,
33
30
  type DiffIndicatorMode,
34
- type ToolDisplayConfig,
35
31
  type PersistedDiff,
32
+ type ToolDisplayConfig,
36
33
  } from "./types.js";
34
+ import { splitWriteContentLines } from "./write-display-utils.js";
37
35
 
38
36
  const MAX_HL_CHARS = 200_000;
39
37
 
@@ -267,7 +265,7 @@ function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
267
265
  }
268
266
 
269
267
  function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
270
- if (!rawPath || !rawPath.trim()) {
268
+ if (!rawPath?.trim()) {
271
269
  return undefined;
272
270
  }
273
271
  const normalizedPath = rawPath.replace(/^@/, "").trim();
@@ -281,7 +279,7 @@ function resolveLanguageFromPath(rawPath: string | undefined): string | undefine
281
279
  }
282
280
  }
283
281
 
284
- function resolveShikiTheme(theme: DiffTheme): string {
282
+ function _resolveShikiTheme(theme: DiffTheme): string {
285
283
  if (process.env.DIFF_THEME) return process.env.DIFF_THEME;
286
284
  const background =
287
285
  parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
@@ -828,7 +826,9 @@ function tokenizeInlineDiff(input: string): Array<{ value: string; start: number
828
826
  const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
829
827
  let match: RegExpExecArray | null;
830
828
 
831
- while ((match = pattern.exec(input)) !== null) {
829
+ while (true) {
830
+ match = pattern.exec(input);
831
+ if (match === null) break;
832
832
  const value = match[0] ?? "";
833
833
  if (!value) {
834
834
  continue;
@@ -1139,13 +1139,15 @@ function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
1139
1139
  }
1140
1140
 
1141
1141
  function extractThemeBackgroundAnsi(text: string): string | undefined {
1142
- if (!text || !text.includes("\x1b[")) {
1142
+ if (!text?.includes("\x1b[")) {
1143
1143
  return undefined;
1144
1144
  }
1145
1145
 
1146
1146
  ANSI_SGR_PATTERN.lastIndex = 0;
1147
1147
  let match: RegExpExecArray | null;
1148
- while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
1148
+ while (true) {
1149
+ match = ANSI_SGR_PATTERN.exec(text);
1150
+ if (match === null) break;
1149
1151
  const parsed = toSgrParams(match[1] ?? "");
1150
1152
  for (let index = 0; index < parsed.length; index += 1) {
1151
1153
  const param = parsed[index] ?? 0;
@@ -1372,7 +1374,6 @@ function resolveIndicatorGlyph(
1372
1374
  return " ";
1373
1375
  }
1374
1376
  return kind === "add" ? "+" : "-";
1375
- case "none":
1376
1377
  default:
1377
1378
  return " ";
1378
1379
  }
@@ -1,6 +1,5 @@
1
1
  import { normalizeDiffRenderWidth } from "./diff-presentation.js";
2
- import { pluralize } from "./render-utils.js";
3
- import { showMoreHintText } from "./render-utils.js";
2
+ import { pluralize, showMoreHintText } from "./render-utils.js";
4
3
 
5
4
  export interface WidthMeasurementOps {
6
5
  measure(text: string): number;
@@ -0,0 +1,97 @@
1
+ /** Maximum source size eligible for optional asynchronous Shiki enhancement. */
2
+ export const MAX_HL_CHARS = 32_000;
3
+ const MAX_CACHE_ENTRIES = 48;
4
+
5
+ type CodeToAnsi = (code: string, lang: string, theme: string) => Promise<string>;
6
+ export type ShikiLoader = () => Promise<CodeToAnsi>;
7
+ type Subscriber = { fallback: readonly string[]; invalidate: () => void };
8
+ type Pending = { work: Promise<void>; subscribers: Set<Subscriber> };
9
+
10
+ let defaultLoad: Promise<CodeToAnsi> | undefined;
11
+ const loadDefault: ShikiLoader = () => {
12
+ if (!defaultLoad) {
13
+ defaultLoad = import("@shikijs/cli")
14
+ .then((module) => module.codeToANSI as CodeToAnsi)
15
+ .catch((error) => {
16
+ defaultLoad = undefined;
17
+ throw error;
18
+ });
19
+ }
20
+ return defaultLoad;
21
+ };
22
+
23
+ function differs(lines: readonly string[], fallback: readonly string[]): boolean {
24
+ return lines.length !== fallback.length || lines.some((line, index) => line !== fallback[index]);
25
+ }
26
+
27
+ /**
28
+ * Small, viewer-local async cache. The synchronous Pi highlighter remains the
29
+ * first paint; Shiki is only requested by an expanded card that has metadata.
30
+ */
31
+ export class ShikiHighlightCache {
32
+ private readonly cache = new Map<string, string[]>();
33
+ private readonly pending = new Map<string, Pending>();
34
+
35
+ constructor(private readonly loader: ShikiLoader = loadDefault) {}
36
+
37
+ get(
38
+ code: string,
39
+ lang: string | undefined,
40
+ theme: string,
41
+ fallback: readonly string[],
42
+ invalidate?: () => void,
43
+ ): string[] | undefined {
44
+ if (!lang || code.length > MAX_HL_CHARS) return undefined;
45
+
46
+ const key = `${theme}\0${lang}\0${code}`;
47
+ const cached = this.cache.get(key);
48
+ if (cached) {
49
+ // LRU: a frequently viewed card should not evict itself immediately.
50
+ this.cache.delete(key);
51
+ this.cache.set(key, cached);
52
+ return cached;
53
+ }
54
+
55
+ const existing = this.pending.get(key);
56
+ if (existing) {
57
+ if (invalidate && ![...existing.subscribers].some((item) => item.invalidate === invalidate)) {
58
+ existing.subscribers.add({ fallback, invalidate });
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ const pending: Pending = { work: undefined as never, subscribers: new Set() };
64
+ if (invalidate) pending.subscribers.add({ fallback, invalidate });
65
+ pending.work = Promise.resolve()
66
+ .then(() => this.loader())
67
+ .then((highlight) => highlight(code, lang, theme))
68
+ .then((ansi) => {
69
+ const lines = ansi.replace(/\r/g, "").replace(/\n$/, "").split("\n");
70
+ this.cache.set(key, lines);
71
+ while (this.cache.size > MAX_CACHE_ENTRIES) {
72
+ const oldest = this.cache.keys().next().value;
73
+ if (oldest !== undefined) this.cache.delete(oldest);
74
+ }
75
+ for (const subscriber of pending.subscribers) {
76
+ if (!differs(lines, subscriber.fallback)) continue;
77
+ try {
78
+ subscriber.invalidate();
79
+ } catch {
80
+ // A stale card must not prevent another card from being repainted.
81
+ }
82
+ }
83
+ })
84
+ .catch(() => {
85
+ // The synchronous Pi highlighter remains the durable fallback.
86
+ })
87
+ .finally(() => this.pending.delete(key));
88
+ this.pending.set(key, pending);
89
+ return undefined;
90
+ }
91
+
92
+ clear(): void {
93
+ this.cache.clear();
94
+ }
95
+ }
96
+
97
+ export const shikiHighlightCache = new ShikiHighlightCache();
@@ -1,6 +1,6 @@
1
- import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
2
- import { createViewerCcstyleResult, oneLine } from "./tool-result.js";
1
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
2
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "./diff/types.js";
3
+ import { createViewerCcstyleResult, oneLine } from "./tool-result.js";
4
4
 
5
5
  export interface ViewerToolRenderers {
6
6
  name: string;
@@ -82,5 +82,5 @@ export function createViewerCcstyleTool(toolName: string, isHovered: () => boole
82
82
  };
83
83
  }
84
84
 
85
- export { renderRichToolResult } from "./tool-result.js";
86
85
  export type { PersistedDiff, ViewerDiffConfig } from "./diff/types.js";
86
+ export { renderRichToolResult } from "./tool-result.js";
@@ -1,11 +1,13 @@
1
- import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
3
  import {
4
+ type DisplayConfigInput,
3
5
  renderEditDiffResult,
4
6
  renderWriteDiffResult,
5
- type DisplayConfigInput,
6
7
  } from "./diff/diff-renderer.js";
8
+ import { sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./diff/render-utils.js";
9
+ import { MAX_HL_CHARS, shikiHighlightCache } from "./diff/shiki-highlight.js";
7
10
  import { DEFAULT_TOOL_DISPLAY_CONFIG, type PersistedDiff, type ViewerDiffConfig } from "./diff/types.js";
8
- import { sanitizeToolResultText, showMoreHintText } from "./diff/render-utils.js";
9
11
 
10
12
  export function toolViewportWidth(width: number): number {
11
13
  return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
@@ -96,6 +98,97 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
96
98
  return rendered.length > maxChars ? `${rendered.slice(0, maxChars)}…` : rendered;
97
99
  }
98
100
 
101
+ type FencedCodeRegion = {
102
+ bodyStart: number;
103
+ bodyEnd: number;
104
+ language?: string;
105
+ };
106
+
107
+ type OutputHighlightPlan = {
108
+ lines: string[];
109
+ code: string;
110
+ codeLineIndexes: number[];
111
+ };
112
+
113
+ function parseFenceOpening(line: string): { marker: string; language?: string } | undefined {
114
+ const match = line.match(/^\s*(`{3,}|~{3,})(?:\s*([A-Za-z][\w+.-]*))?(?:\s+.*)?\s*$/);
115
+ if (!match) return undefined;
116
+ return { marker: match[1]!, language: match[2] };
117
+ }
118
+
119
+ function isFenceClosing(line: string, marker: string): boolean {
120
+ const trimmed = line.trim();
121
+ return trimmed.length >= marker.length &&
122
+ trimmed[0] === marker[0] &&
123
+ [...trimmed].every((character) => character === marker[0]);
124
+ }
125
+
126
+ function findFencedCodeRegion(lines: readonly string[]): FencedCodeRegion | undefined {
127
+ for (let openingIndex = 0; openingIndex < lines.length; openingIndex++) {
128
+ const opening = parseFenceOpening(lines[openingIndex]!);
129
+ if (!opening) continue;
130
+ let bodyEnd = lines.length;
131
+ for (let index = openingIndex + 1; index < lines.length; index++) {
132
+ if (isFenceClosing(lines[index]!, opening.marker)) {
133
+ bodyEnd = index;
134
+ break;
135
+ }
136
+ }
137
+ return {
138
+ bodyStart: openingIndex + 1,
139
+ bodyEnd,
140
+ ...(opening.language ? { language: opening.language } : {}),
141
+ };
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ function pathFromToolArgs(args: unknown): string | undefined {
147
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
148
+ const record = args as Record<string, unknown>;
149
+ const path = record.file_path ?? record.path;
150
+ return typeof path === "string" && path.trim() ? path : undefined;
151
+ }
152
+
153
+ /** Resolve only explicit tool/path or fenced metadata; no command/log heuristics. */
154
+ export function resolveToolOutputLanguage(args: unknown, output: string): string | undefined {
155
+ const path = pathFromToolArgs(args)?.replace(/^@/, "").trim();
156
+ if (path) {
157
+ try {
158
+ const language = getLanguageFromPath(path);
159
+ if (language) return language;
160
+ } catch {
161
+ // Fall through to an explicit fence when the path mapping is unavailable.
162
+ }
163
+ }
164
+ const lines = output.replace(/\t/g, " ").replace(/\n+$/, "").split("\n");
165
+ return findFencedCodeRegion(lines)?.language;
166
+ }
167
+
168
+ function outputHighlightPlan(output: string, language: string | undefined): OutputHighlightPlan | undefined {
169
+ if (!language || !output.trim() || output.length > MAX_HL_CHARS) return undefined;
170
+ const lines = output.replace(/\t/g, " ").replace(/\n+$/, "").split("\n");
171
+ // A path language follows Pi's read renderer and applies to the complete
172
+ // output. A fence-only language is also highlighted as a normal code body;
173
+ // keeping one line-to-line mapping preserves fences and wrapping safely.
174
+ const codeLineIndexes = lines.map((_, index) => index);
175
+ const codeLines = codeLineIndexes.map((index) => sanitizeToolResultText(lines[index] ?? "").replace(/\n/g, ""));
176
+ const code = codeLines.join("\n");
177
+ if (!code || code.length > MAX_HL_CHARS) return undefined;
178
+ return { lines, code, codeLineIndexes };
179
+ }
180
+
181
+ function syncHighlight(code: string, language: string, fallback: readonly string[]): string[] {
182
+ try {
183
+ const highlighted = highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
184
+ return highlighted.length === fallback.length
185
+ ? highlighted
186
+ : fallback.map((line, index) => highlighted[index] ?? line);
187
+ } catch {
188
+ return fallback.map(sanitizeAnsiForThemedOutput);
189
+ }
190
+ }
191
+
99
192
  function hasExpandableDetail(text: string, args: unknown): boolean {
100
193
  return countLines(text) > 1 || formatToolInputArgs(args).trim().length > 0;
101
194
  }
@@ -109,6 +202,19 @@ export class ExpandedToolIoView implements Component {
109
202
  private lineSections: Array<ToolIoSection | null> = [];
110
203
  private hoveredSection: ToolIoSection | null = null;
111
204
  private showMoreRows: Partial<Record<ToolIoSection, number>> = {};
205
+ private readonly outputLanguage?: string;
206
+ private readonly invalidateCard?: () => void;
207
+ private outputHighlightKey: string | undefined;
208
+ private outputHighlightState: {
209
+ plan: OutputHighlightPlan;
210
+ fallback: string[];
211
+ fallbackReady: boolean;
212
+ shiki?: string[];
213
+ } | undefined;
214
+ private readonly onHighlightReady = (): void => {
215
+ this.invalidate();
216
+ this.invalidateCard?.();
217
+ };
112
218
 
113
219
  constructor(
114
220
  private input: string,
@@ -117,11 +223,56 @@ export class ExpandedToolIoView implements Component {
117
223
  private readonly maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
118
224
  private readonly maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
119
225
  private readonly theme?: any,
120
- ) {}
226
+ outputLanguage?: string,
227
+ invalidateCard?: () => void,
228
+ ) {
229
+ this.outputLanguage = outputLanguage;
230
+ this.invalidateCard = invalidateCard;
231
+ }
121
232
 
122
233
  getInputBody(): string { return this.input; }
123
234
  getOutputBody(): string { return this.output.trim() ? this.output : "Done"; }
124
235
 
236
+ private highlightedOutputLines(body: string): string[] {
237
+ const plainLines = body.split("\n");
238
+ const plan = outputHighlightPlan(body, this.outputLanguage);
239
+ if (!plan) return plainLines;
240
+
241
+ const key = `${this.outputLanguage ?? ""}\0${plan.code}\0${plan.lines.join("\n")}`;
242
+ if (this.outputHighlightKey !== key || !this.outputHighlightState) {
243
+ this.outputHighlightKey = key;
244
+ this.outputHighlightState = {
245
+ plan,
246
+ // A plain fallback lets a cache hit avoid running the synchronous
247
+ // highlighter again. On a miss it is replaced below before paint.
248
+ fallback: plan.code.split("\n"),
249
+ fallbackReady: false,
250
+ };
251
+ }
252
+
253
+ const state = this.outputHighlightState;
254
+ const shikiLines = state.shiki ?? shikiHighlightCache.get(
255
+ plan.code,
256
+ this.outputLanguage,
257
+ process.env.DIFF_THEME || "github-dark",
258
+ state.fallback,
259
+ this.onHighlightReady,
260
+ );
261
+ if (shikiLines) {
262
+ state.shiki = shikiLines.map(sanitizeAnsiForThemedOutput);
263
+ } else if (!state.fallbackReady) {
264
+ state.fallback = syncHighlight(plan.code, this.outputLanguage!, state.fallback);
265
+ state.fallbackReady = true;
266
+ }
267
+
268
+ const highlightedCode = state.shiki ?? state.fallback;
269
+ const lines = plan.lines.slice();
270
+ for (const [codeIndex, lineIndex] of plan.codeLineIndexes.entries()) {
271
+ lines[lineIndex] = highlightedCode[codeIndex] ?? lines[lineIndex] ?? "";
272
+ }
273
+ return lines;
274
+ }
275
+
125
276
  setHoveredSection(section: ToolIoSection | null): void {
126
277
  if (this.hoveredSection === section) return;
127
278
  this.hoveredSection = section;
@@ -160,6 +311,7 @@ export class ExpandedToolIoView implements Component {
160
311
  return;
161
312
  }
162
313
  const sources = raw.split("\n");
314
+ const highlighted = input ? undefined : this.highlightedOutputLines(raw);
163
315
  const wrapped: string[] = [];
164
316
  const styleInput = (line: string): string => {
165
317
  const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
@@ -167,8 +319,14 @@ export class ExpandedToolIoView implements Component {
167
319
  ? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
168
320
  : color("muted", line);
169
321
  };
170
- for (const source of sources) {
171
- const styled = input ? styleInput(source) : color(bodyColor, source);
322
+ for (const [index, source] of sources.entries()) {
323
+ const outputLine = highlighted?.[index] ?? source;
324
+ // Shiki/core ANSI carries the actual token colors. Do not wrap it in
325
+ // theme.fg(), whose closing reset would erase nested token styles.
326
+ const hasAnsi = /\x1b\[[0-?]*[ -/]*[@-~]/.test(outputLine);
327
+ const styled = input
328
+ ? styleInput(source)
329
+ : hasAnsi ? sanitizeAnsiForThemedOutput(outputLine) : color(bodyColor, outputLine);
172
330
  const lines = wrapTextWithAnsi(styled, contentWidth);
173
331
  wrapped.push(...(lines.length ? lines : [styled]));
174
332
  }
@@ -284,7 +442,17 @@ function renderFallbackResult(result: any, options: any, theme: any, context: an
284
442
  const maxLines = Number.isFinite(context?.diffConfig?.expandedPreviewMaxLines)
285
443
  ? Math.max(1, Math.floor(context.diffConfig.expandedPreviewMaxLines))
286
444
  : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
287
- return new ExpandedToolIoView(input, text, Boolean(error), maxLines, maxLines, theme);
445
+ const language = resolveToolOutputLanguage(context?.args, text);
446
+ return new ExpandedToolIoView(
447
+ input,
448
+ text,
449
+ Boolean(error),
450
+ maxLines,
451
+ maxLines,
452
+ theme,
453
+ language,
454
+ context?.invalidate,
455
+ );
288
456
  }
289
457
  const count = text ? text.split("\n").length : 0;
290
458
  const summary = error ? oneLine(text) || "Failed" : count ? `${count} ${count === 1 ? "line" : "lines"} returned` : "Done";
@@ -1,6 +1,6 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import type { ConversationBlock } from "./conversation-blocks.js";
3
2
  import type { Theme } from "./agent-widget.js";
3
+ import type { ConversationBlock } from "./conversation-blocks.js";
4
4
 
5
5
  export type ConversationRoleTheme = Pick<Theme, "fg">;
6
6
 
@@ -105,10 +105,10 @@ export function highlightConversationLine(
105
105
 
106
106
  while (i < text.length) {
107
107
  if (text[i] === "\x1b") {
108
- const escape = text.slice(i).match(ANSI_ESCAPE_RE);
109
- if (escape) {
110
- result += escape[0];
111
- i += escape[0].length;
108
+ const ansiEscape = text.slice(i).match(ANSI_ESCAPE_RE);
109
+ if (ansiEscape) {
110
+ result += ansiEscape[0];
111
+ i += ansiEscape[0].length;
112
112
  continue;
113
113
  }
114
114
  }
@@ -1,26 +1,26 @@
1
1
  import * as CodingAgent from "@earendil-works/pi-coding-agent";
2
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
3
  import {
4
+ type Component,
3
5
  Markdown,
4
6
  Text,
5
- type Component,
6
7
  type TUI,
7
8
  type TuiMouseEvent,
8
9
  truncateToWidth,
9
10
  } from "@earendil-works/pi-tui";
10
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import type { Theme } from "./agent-widget.js";
13
+ import { showMoreHintText } from "./ccstyle/diff/render-utils.js";
14
+ import {
15
+ createViewerCcstyleTool,
16
+ type ViewerToolRenderers,
17
+ } from "./ccstyle/tool-renderer.js";
13
18
  import {
14
19
  type ConversationBlock,
15
20
  type ConversationToolResultSnapshot,
16
21
  } from "./conversation-blocks.js";
17
22
  import { renderConversationRoleHeader } from "./conversation-role.js";
18
23
  import { stripAnsi } from "./conversation-search.js";
19
- import {
20
- createViewerCcstyleTool,
21
- type ViewerToolRenderers,
22
- } from "./ccstyle/tool-renderer.js";
23
- import { showMoreHintText } from "./ccstyle/diff/render-utils.js";
24
24
 
25
25
  export interface TimelineRenderLine {
26
26
  text: string;
@@ -5,20 +5,20 @@
5
5
  * blocks are formatted data, while Markdown/ANSI lines are a bounded cache.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
8
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { copyToClipboard } from "@earendil-works/pi-coding-agent";
10
10
  import { type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
- import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
16
- import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
17
- import { renderConversationRoleHeader } from "./conversation-role.js";
15
+ import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
16
+ import { type ConversationBlock, formatConversationMessages } from "./conversation-blocks.js";
18
17
  import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
19
- import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
18
+ import { renderConversationRoleHeader } from "./conversation-role.js";
19
+ import { type ConversationMatch, findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi } from "./conversation-search.js";
20
+ import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
20
21
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
21
- import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
22
22
 
23
23
  /** Base lines consumed by the overlay: borders, header, separator and footer. */
24
24
  const CHROME_LINES_BASE = 6;
@@ -569,7 +569,16 @@ export class ConversationViewer implements Component {
569
569
  this.timeline.setShowTools(this.showTools);
570
570
  this.timeline.render(width);
571
571
  const timelineSnapshot = this.timeline.getSnapshot();
572
- const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
572
+ const lines: RenderLine[] = timelineSnapshot.lines.map((line) => {
573
+ const rawText = typeof line?.text === "string" ? line.text : "";
574
+ const text = truncateToWidth(rawText, width, "", true);
575
+ return {
576
+ text,
577
+ plain: stripAnsi(text),
578
+ blockIndex: typeof line?.blockIndex === "number" ? line.blockIndex : -1,
579
+ ...(line?.railable ? { railable: true } : {}),
580
+ };
581
+ });
573
582
  this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
574
583
  this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
575
584
  if (this.hasFocusedBlock) {
@@ -1,6 +1,6 @@
1
- import { createWriteToolDefinition, generateDiffString, generateUnifiedPatch, type ExtensionContext, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
1
  import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { createWriteToolDefinition, type ExtensionContext, generateDiffString, generateUnifiedPatch, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  /** Maximum UTF-8 size of the durable display diff plus unified patch. */
6
6
  export const MAX_DURABLE_DIFF_BYTES = 4 * 1024 * 1024;
@@ -58,7 +58,7 @@ function resolveWritePath(path: string, cwd: string): string {
58
58
  }
59
59
 
60
60
  async function capturePreviousContent(absolutePath: string): Promise<WriteExecutionMeta> {
61
- let info;
61
+ let info: Awaited<ReturnType<typeof lstat>>;
62
62
  try {
63
63
  info = await lstat(absolutePath);
64
64
  } catch (error: any) {