@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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 (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,1161 @@
1
+ // Compact Tools — consecutive tool calls share one warning-yellow card.
2
+ // The parent toggles child summary rows; a child's summary or plain output
3
+ // toggles that child's output. Interactive controls keep first refusal.
4
+ // ctrl+o (app.tools.expand) still expands/collapses everything. Child rows show
5
+ // tool-kind icons and explicit status; calls running for 1s stay visible even
6
+ // when the parent is folded. Custom renderers keep their styling and chips.
7
+ // Cards have no background fill. Parent-aligned Skills and failure rows follow
8
+ // the children and remain visible when folded. Running rows sit last at the same
9
+ // inset, with five live output lines; their full view opens independently.
10
+ // Image results have independent disclosure rows alongside tools, not nested
11
+ // inside tool output; parent folds
12
+ // retain each image's state. Image-only results replace their ordinary tool row.
13
+ // Reads of <skill>/SKILL.md do not count as ordinary files; nested reads use
14
+ // Pi's recorded call metadata.
15
+ //
16
+ // Grouping (pi 1.4): a row's renderers only see its own toolCallId, so groups
17
+ // are derived from the session branch plus the in-flight assistant message.
18
+ // A group is a run of tool calls with no user message, custom message or
19
+ // non-empty assistant text between them. The first call of a group (its
20
+ // head) draws the entire group card; the others render nothing. With
21
+ // renderShell: "self", a row whose components render zero lines takes zero
22
+ // lines — no spacer, no background box. Failed calls stay visible, one error
23
+ // line each, under the head.
24
+ //
25
+ // Thinking: pi draws it in AssistantMessageComponent, outside any tool
26
+ // renderer, so with hideThinkingBlock a "Thinking..." label would split every
27
+ // group. The prototype patch below drops thinking from assistant messages that
28
+ // are tool calls only (no text) — thinking is absorbed into the group. While
29
+ // the agent runs, the last group's line shows the latest thought instead.
30
+ // Messages with text keep their thinking; hideThinkingBlock: false (or the
31
+ // thinking toggle) shows it everywhere as before.
32
+ //
33
+ // Uses pi.registerToolRenderer(): renderers are attached to tools by name,
34
+ // while the tools themselves stay exactly as pi (or another extension)
35
+ // registered them. Execution is untouched; disabled tools are never
36
+ // resurrected (a renderer for a tool that never runs is inert), so no
37
+ // active-set check or session_start gating is needed — registration happens
38
+ // at plain load time.
39
+ //
40
+ // Covers the overridable built-ins (read, bash, powershell, edit, write,
41
+ // grep, find, ls), codemode, direct-exposure MCP tools, and — dynamically,
42
+ // without knowing names — every other tool whose definition lacks renderers.
43
+ //
44
+ // Codemode-exposure MCP tools never render as their own rows — they run as
45
+ // nested calls inside codemode scripts, covered by the codemode renderer.
46
+ // Direct-exposure MCP tools (name always starts with "mcp__") do render as
47
+ // their own rows; tool_search and any other built-in keeps its stock
48
+ // renderers.
49
+ //
50
+ // For every other tool (custom tools from other extensions), the resolver
51
+ // calls next() FIRST and keeps whatever renderers the chain or the registered
52
+ // definition provides for the expanded view — a tool that ships its own
53
+ // renderCall/renderResult is expanded inside the shared card, with its own
54
+ // colors and chips intact (grouping needs renderShell "self").
55
+ // Only the missing halves are filled in with compact ones. Because next()
56
+ // runs before the decision, later extensions' resolver entries still apply.
57
+ // Renderers are keyed by name: if another extension registers its own
58
+ // "bash", ours would render it too.
59
+
60
+ import { AssistantMessageComponent, ToolExecutionComponent, keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
61
+ import { Box, getCellDimensions, getImageDimensions, Image, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
62
+ import { homedir } from "os";
63
+
64
+ const TUI_CONTEXT = Symbol.for("compact-tools.tuiContext");
65
+ const OWN_IMAGES = Symbol.for("compact-tools.ownsImages");
66
+
67
+ type RenderArgs = Record<string, any>;
68
+ type RenderOptions = { expanded: boolean; isPartial: boolean };
69
+ type RenderTheme = any;
70
+ // The subset of pi's ToolRenderContext this file reads.
71
+ type RenderContext = {
72
+ expanded?: boolean;
73
+ toolCallId?: string;
74
+ invalidate?: () => void;
75
+ isError?: boolean;
76
+ outputPad?: number;
77
+ isPartial?: boolean;
78
+ executionStarted?: boolean;
79
+ showImages?: boolean;
80
+ lastComponent?: CallComponent;
81
+ [TUI_CONTEXT]?: boolean;
82
+ };
83
+ // Duck-typed pi-tui Component — pi adds renderCall's return value to a
84
+ // Container, so anything with render(width): string[] works.
85
+ // invalidate is required: pi wraps rows in a MouseRegion that calls
86
+ // child.invalidate() unconditionally.
87
+ type CallComponent = { render(width: number): string[]; invalidate(): void; setPaddingX?(paddingX: number): void; handleMouse?(event: any): any };
88
+ type ImageBlock = { type: "image"; data: string; mimeType: string };
89
+ type ImageState = { source: ImageBlock; open: boolean; dimensions: ReturnType<typeof getImageDimensions>; preview?: Image; maxWidth?: number; theme?: RenderTheme; view?: CallComponent };
90
+ type RenderResult = { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean; details?: unknown };
91
+
92
+ function shortenPath(path: string): string {
93
+ const home = homedir();
94
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
95
+ }
96
+
97
+ function firstText(result: RenderResult): string | null {
98
+ const c = result.content.find((c) => c.type === "text" && c.text);
99
+ if (c?.type !== "text") return null;
100
+ return c.text ?? null;
101
+ }
102
+
103
+ // All text blocks joined. codemode splits its result across blocks — the
104
+ // header is one block, the script output follows in later blocks — so it must
105
+ // read them all; firstText would only ever see the header.
106
+ function allText(result: RenderResult): string {
107
+ return result.content
108
+ .filter((c) => c.type === "text" && c.text)
109
+ .map((c) => (c as { text: string }).text)
110
+ .join("\n");
111
+ }
112
+
113
+ function fullOutput(text: string, theme: RenderTheme): Text {
114
+ const output = text
115
+ .trim()
116
+ .split("\n")
117
+ .map((line) => theme.fg("toolOutput", line))
118
+ .join("\n");
119
+ return output ? new Text(`\n${output}`, 0, 0) : new Text("", 0, 0);
120
+ }
121
+
122
+ // --- renderers --------------------------------------------------------------
123
+
124
+ // Collapsed cap for shell call lines: at most this many visual (wrapped) lines.
125
+ const MAX_SHELL_CALL_LINES = 1;
126
+
127
+ // A call line that word-wraps like Text but, when collapsed, never exceeds
128
+ // MAX_SHELL_CALL_LINES visual lines: the overflow is cut off at terminal width
129
+ // with a dim hint naming the hidden line count, spliced into the last kept
130
+ // line. No character cap — the cap is
131
+ // measured in rendered lines, so it adapts to terminal width. Caches per width,
132
+ // since it renders on every frame.
133
+ class ShellCallLine {
134
+ private cachedWidth?: number;
135
+ private cachedLines?: string[];
136
+ private text: string;
137
+ private theme: RenderTheme;
138
+ private paddingX = 0;
139
+ hasMore = false;
140
+
141
+ constructor(text: string, theme: RenderTheme) {
142
+ this.text = text;
143
+ this.theme = theme;
144
+ }
145
+
146
+ setPaddingX(paddingX: number): void {
147
+ this.paddingX = paddingX;
148
+ this.invalidate();
149
+ }
150
+
151
+ render(width: number): string[] {
152
+ if (this.cachedLines === undefined || this.cachedWidth !== width) {
153
+ const lines = new Text(this.text, this.paddingX, 0).render(width);
154
+ this.hasMore = lines.length > MAX_SHELL_CALL_LINES;
155
+ if (lines.length <= MAX_SHELL_CALL_LINES) {
156
+ this.cachedLines = lines;
157
+ } else {
158
+ const hidden = lines.length - MAX_SHELL_CALL_LINES;
159
+ const hint = this.theme.fg("dim", ` … (+${hidden} more line${hidden === 1 ? "" : "s"})`);
160
+ // Text pads every line to full width; trim that padding before
161
+ // splicing the hint into the last kept line.
162
+ const kept = lines.slice(0, MAX_SHELL_CALL_LINES);
163
+ const last = kept[kept.length - 1].replace(/\s+$/, "");
164
+ const avail = Math.max(0, width - visibleWidth(hint));
165
+ kept[kept.length - 1] =
166
+ avail > 0 ? truncateToWidth(last, avail, "") + hint : truncateToWidth(last, width, "…");
167
+ this.cachedLines = kept;
168
+ }
169
+ this.cachedWidth = width;
170
+ }
171
+ return this.cachedLines;
172
+ }
173
+
174
+ invalidate(): void {
175
+ this.cachedWidth = undefined;
176
+ this.cachedLines = undefined;
177
+ }
178
+ }
179
+
180
+ // bash / powershell share everything except the prompt prefix. The full
181
+ // command always renders (word-wrapped by the TUI); when collapsed it is
182
+ // capped at MAX_SHELL_CALL_LINES visual lines instead of being truncated at a
183
+ // fixed character count, and ctrl+o shows it without the cap.
184
+ function shellRenderCall(prompt: string) {
185
+ return (args: RenderArgs, theme: RenderTheme, context: RenderContext): Text | ShellCallLine => {
186
+ const cmd = args.command ?? "...";
187
+ const text = theme.fg("toolTitle", theme.bold(`${prompt}${cmd}`));
188
+ if (context.expanded) return new Text(text, 0, 0);
189
+ return new ShellCallLine(text, theme);
190
+ };
191
+ }
192
+
193
+ function shellRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
194
+ if (isPartial && !expanded) return new Text("", 0, 0);
195
+
196
+ const text = firstText(result) ?? "";
197
+ const exitMatch = text.match(/exit code: (\d+)/);
198
+ const failed = result.isError || (exitMatch !== null && exitMatch[1] !== "0");
199
+
200
+ // Non-zero exit: always show the error line.
201
+ if (failed) {
202
+ const firstLine = text.trim().split("\n").find((l) => l.trim()) ?? `exit code: ${exitMatch?.[1]}`;
203
+ return new Text(theme.fg("error", firstLine.slice(0, 200)), 0, 0);
204
+ }
205
+
206
+ if (!expanded) return new Text("", 0, 0);
207
+ return fullOutput(text, theme);
208
+ }
209
+
210
+ function readRenderCall(args: RenderArgs, theme: RenderTheme, _context: unknown): Text {
211
+ const path = args.path ? shortenPath(args.path) : "...";
212
+ let text = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", path)}`;
213
+ if (args.offset !== undefined || args.limit !== undefined) {
214
+ const start = args.offset ?? 1;
215
+ const range = args.limit !== undefined ? `${start}-${start + args.limit - 1}` : `${start}-`;
216
+ text += theme.fg("dim", `:${range}`);
217
+ }
218
+ return new Text(text, 0, 0);
219
+ }
220
+
221
+ function readRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
222
+ if (isPartial && !expanded) return new Text("", 0, 0);
223
+
224
+ const text = firstText(result);
225
+ // Image blocks are handled by the common per-image disclosure layer.
226
+ if (text === null) return new Text("", 0, 0);
227
+
228
+ // Errors stay visible even collapsed.
229
+ if (result.isError || text.startsWith("Error")) {
230
+ return new Text(theme.fg("error", text.split("\n")[0]), 0, 0);
231
+ }
232
+
233
+ if (!expanded) return new Text("", 0, 0);
234
+ return fullOutput(text, theme);
235
+ }
236
+
237
+ function pathRenderCall(name: string) {
238
+ return (args: RenderArgs, theme: RenderTheme, _context: unknown): Text => {
239
+ const path = args.path ? shortenPath(args.path) : ".";
240
+ return new Text(`${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("accent", path)}`, 0, 0);
241
+ };
242
+ }
243
+
244
+ function writeRenderCall(args: RenderArgs, theme: RenderTheme, _context: unknown): Text {
245
+ const path = args.path ? shortenPath(args.path) : "...";
246
+ const lineCount = typeof args.content === "string" ? args.content.split("\n").length : 0;
247
+ const info = lineCount > 0 ? theme.fg("dim", ` (${lineCount} lines)`) : "";
248
+ return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", path)}${info}`, 0, 0);
249
+ }
250
+
251
+ function grepRenderCall(args: RenderArgs, theme: RenderTheme, _context: unknown): Text {
252
+ const path = args.path ? shortenPath(args.path) : ".";
253
+ let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", String(args.pattern ?? ""))}`;
254
+ if (args.glob) text += theme.fg("dim", ` (${args.glob})`);
255
+ text += theme.fg("dim", ` in ${path}`);
256
+ return new Text(text, 0, 0);
257
+ }
258
+
259
+ function findRenderCall(args: RenderArgs, theme: RenderTheme, _context: unknown): Text {
260
+ const path = args.path ? shortenPath(args.path) : ".";
261
+ return new Text(
262
+ `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", String(args.pattern ?? ""))}${theme.fg("dim", ` in ${path}`)}`,
263
+ 0,
264
+ 0,
265
+ );
266
+ }
267
+
268
+ // --- codemode ---------------------------------------------------------------
269
+
270
+ // The "Script completed\nWall time ...\nOutput:\n" header the tool prepends.
271
+ const CODEMODE_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n$/;
272
+
273
+ function codemodeRenderCall(args: RenderArgs, theme: RenderTheme, _context: unknown): Text {
274
+ const code = typeof args.code === "string" ? args.code : "";
275
+ const firstLine = code
276
+ .split("\n")
277
+ .map((l) => l.trim())
278
+ .find((l) => l && !l.startsWith("//")) ?? "...";
279
+ const shown = firstLine.length > 120 ? `${firstLine.slice(0, 117)}...` : firstLine;
280
+ return new Text(`${theme.fg("toolTitle", theme.bold("codemode"))} ${theme.fg("accent", shown)}`, 0, 0);
281
+ }
282
+
283
+ // Nested call records as pi actually builds them: { id, name, args, status,
284
+ // durationMs?, error?, cost? } — args is a JSON preview string pi truncates at
285
+ // 200 chars (ARGS_PREVIEW_CHARS); status is "running" | "ok" | "error" |
286
+ // "cancelled", with "running" only ever seen in partial updates.
287
+ type CodemodeCall = { name: string; args?: string; status?: string };
288
+
289
+ function codemodeRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
290
+ const calls = ((result.details as { calls?: CodemodeCall[] } | undefined)?.calls) ?? [];
291
+
292
+ // While the script runs, one dim line of live progress: calls so far and the
293
+ // latest nested call (the stock renderer streams every call; that is not
294
+ // compact).
295
+ if (isPartial && !expanded) {
296
+ if (calls.length === 0) return new Text("", 0, 0);
297
+ const last = calls[calls.length - 1];
298
+ return new Text(theme.fg("dim", `(${calls.length} calls… ${last.name})`), 0, 0);
299
+ }
300
+
301
+ const text = allText(result);
302
+ const body = text.replace(CODEMODE_HEADER, "");
303
+
304
+ // Failed scripts always show their error line, even collapsed.
305
+ if (result.isError || text.startsWith("Script failed")) {
306
+ const lines = body.split("\n");
307
+ const firstLine = lines.find((l) => l.startsWith("Script error:")) ?? lines.find((l) => l.trim()) ?? "Script failed";
308
+ return new Text(theme.fg("error", firstLine.slice(0, 200)), 0, 0);
309
+ }
310
+
311
+ // Collapsed: one dim line naming the nested calls; nothing when the script
312
+ // called no tools.
313
+ if (!expanded) {
314
+ if (calls.length === 0) return new Text("", 0, 0);
315
+ const label = calls.length === 1 ? "nested call" : "nested calls";
316
+ return new Text(theme.fg("dim", `(${calls.length} ${label})`), 0, 0);
317
+ }
318
+
319
+ // Expanded: nested calls with status icons, then the script output.
320
+ const lines = calls.map((call) => {
321
+ const icon =
322
+ call.status === "error"
323
+ ? theme.fg("error", "\uea6c") // nf-cod-warning
324
+ : call.status === "cancelled"
325
+ ? theme.fg("muted", "\udb81\udf3a") // nf-md-cancel
326
+ : call.status === "running"
327
+ ? theme.fg("muted", "\udb82\udd96") // nf-md-progress_clock
328
+ : theme.fg("success", "\uf00c"); // nf-fa-check
329
+ const argsText = call.args ?? "";
330
+ const shownArgs = argsText.length > 120 ? `${argsText.slice(0, 117)}...` : argsText;
331
+ return `${icon} ${theme.fg("toolTitle", call.name)} ${theme.fg("dim", shownArgs)}`;
332
+ });
333
+ const output = body.trim();
334
+ if (output) lines.push(...output.split("\n").map((l) => theme.fg("toolOutput", l)));
335
+ return lines.length > 0 ? new Text(`\n${lines.join("\n")}`, 0, 0) : new Text("", 0, 0);
336
+ }
337
+
338
+ // grep / find / ls: identical result rendering.
339
+ function simpleRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
340
+ if (isPartial && !expanded) return new Text("", 0, 0);
341
+
342
+ const text = firstText(result) ?? "";
343
+ if (result.isError) {
344
+ return new Text(theme.fg("error", text.split("\n")[0]), 0, 0);
345
+ }
346
+
347
+ if (!expanded) return new Text("", 0, 0);
348
+ return fullOutput(text, theme);
349
+ }
350
+
351
+ function writeRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
352
+ if (isPartial && !expanded) return new Text("", 0, 0);
353
+
354
+ const text = firstText(result) ?? "";
355
+ if (result.isError || text.startsWith("Error")) {
356
+ return new Text(theme.fg("error", text.split("\n")[0]), 0, 0);
357
+ }
358
+
359
+ if (!expanded) return new Text("", 0, 0);
360
+ return fullOutput(text, theme);
361
+ }
362
+
363
+ function editRenderResult(result: RenderResult, { expanded, isPartial }: RenderOptions, theme: RenderTheme, _context: unknown): Text {
364
+ if (isPartial && !expanded) return new Text("", 0, 0);
365
+
366
+ const text = firstText(result) ?? "";
367
+ if (result.isError || text.startsWith("Error")) {
368
+ return new Text(theme.fg("error", text.split("\n")[0]), 0, 0);
369
+ }
370
+
371
+ if (!expanded) return new Text("", 0, 0);
372
+
373
+ // Prefer the diff from details when available.
374
+ const details = result.details as { diff?: string } | undefined;
375
+ if (details?.diff) {
376
+ return fullOutput(details.diff, theme);
377
+ }
378
+ return fullOutput(text, theme);
379
+ }
380
+
381
+ // Fallback call line for tools with no registered definition or no renderCall
382
+ // of their own: bold label plus a truncated one-line JSON arg preview.
383
+ function argPreviewCall(label: string) {
384
+ return (args: RenderArgs, theme: RenderTheme, _context: unknown): Text => {
385
+ const shownArgs = JSON.stringify(args) ?? "";
386
+ const shown = shownArgs.length > 120 ? `${shownArgs.slice(0, 117)}...` : shownArgs;
387
+ return new Text(`${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("dim", shown)}`, 0, 0);
388
+ };
389
+ }
390
+
391
+ // MCP tool names are mangled (`mcp__<server>__<tool>`, non-alphanumerics
392
+ // replaced with "_"); show them as server/tool.
393
+ function mcpRenderCall(name: string) {
394
+ return argPreviewCall(name.replace(/^mcp__/, "").replace(/__/g, "/"));
395
+ }
396
+
397
+ // One entry per tool whose rendering compact-tools overrides. Add future
398
+ // built-ins here (nothing else to register — no factory, no execution shim).
399
+ interface CompactRenderers {
400
+ renderCall(args: RenderArgs, theme: RenderTheme, context: RenderContext): Text | CallComponent;
401
+ renderResult(result: RenderResult, options: RenderOptions, theme: RenderTheme, context: unknown): Text;
402
+ }
403
+
404
+ const compactRenderers: Record<string, CompactRenderers> = {
405
+ read: { renderCall: readRenderCall, renderResult: readRenderResult },
406
+ bash: { renderCall: shellRenderCall("$ "), renderResult: shellRenderResult },
407
+ powershell: { renderCall: shellRenderCall("PS> "), renderResult: shellRenderResult },
408
+ edit: { renderCall: pathRenderCall("edit"), renderResult: editRenderResult },
409
+ write: { renderCall: writeRenderCall, renderResult: writeRenderResult },
410
+ grep: { renderCall: grepRenderCall, renderResult: simpleRenderResult },
411
+ find: { renderCall: findRenderCall, renderResult: simpleRenderResult },
412
+ ls: { renderCall: pathRenderCall("ls"), renderResult: simpleRenderResult },
413
+ codemode: { renderCall: codemodeRenderCall, renderResult: codemodeRenderResult },
414
+ };
415
+
416
+ // --- grouping ---------------------------------------------------------------
417
+
418
+ type SkillCall = { name?: string; arguments?: RenderArgs; args?: string };
419
+ type SkillResult = { nestedCalls?: { calls?: SkillCall[] }; details?: unknown };
420
+ type Block = { type: string; text?: string; thinking?: string; id?: string; name?: string; arguments?: RenderArgs };
421
+ type Msg = SkillResult & { role: string; content?: unknown; toolCallId?: string; toolName?: string };
422
+ type GroupCall = { id: string; name: string; skill?: boolean; images?: number; hasText?: boolean };
423
+ type Group = { head: string; calls: GroupCall[]; members: string[]; skills: string[] };
424
+
425
+ function imageBlocks(content: unknown): ImageBlock[] {
426
+ return Array.isArray(content) ? content.filter((block): block is ImageBlock =>
427
+ block?.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") : [];
428
+ }
429
+
430
+ function skillName(name: string | undefined, args: RenderArgs | undefined): string | undefined {
431
+ if (name !== "read" || typeof args?.path !== "string") return undefined;
432
+ return args.path.replace(/\\/g, "/").match(/(?:^|\/)([^/]+)\/SKILL\.md$/i)?.[1];
433
+ }
434
+
435
+ // Full arguments are persisted on toolResult.nestedCalls. During codemode's
436
+ // live updates only details.calls and its JSON previews may be available.
437
+ function nestedSkills(result: SkillResult, toolName: string | undefined): string[] {
438
+ const recorded = result.nestedCalls?.calls;
439
+ // Other tools own their details schema; only codemode defines these previews.
440
+ const previews = toolName === "codemode" ? (result.details as { calls?: SkillCall[] } | undefined)?.calls : undefined;
441
+ const calls = [...(Array.isArray(recorded) ? recorded : []), ...(Array.isArray(previews) ? previews : [])];
442
+ return calls.flatMap((call) => {
443
+ if (call?.name !== "read") return [];
444
+ let args = call.arguments;
445
+ if (!args && typeof call.args === "string") {
446
+ try { args = JSON.parse(call.args); }
447
+ catch { return []; } // A truncated preview is not reliable metadata.
448
+ }
449
+ const name = skillName(call.name, args);
450
+ return name ? [name] : [];
451
+ });
452
+ }
453
+
454
+ // Pure: walk messages in chat order, assigning each tool call to a group.
455
+ // Anything visible between two calls (user/custom message, assistant text)
456
+ // starts a new group; tool results and thinking do not. A call seen twice
457
+ // (the in-flight copy of an already persisted message) keeps its first group.
458
+ function buildGroups(messages: Msg[]): { groupOf: Map<string, Group>; last?: Group } {
459
+ const groupOf = new Map<string, Group>();
460
+ let current: Group | undefined;
461
+ let last: Group | undefined;
462
+ for (const m of messages) {
463
+ if (m.role === "toolResult") {
464
+ const group = m.toolCallId ? groupOf.get(m.toolCallId) : undefined;
465
+ if (group) {
466
+ group.skills.push(...nestedSkills(m, m.toolName));
467
+ const call = group.calls.find((call) => call.id === m.toolCallId);
468
+ if (call) {
469
+ call.images = imageBlocks(m.content).length;
470
+ call.hasText = Array.isArray(m.content) && m.content.some((block) => block?.type === "text" && block.text?.trim());
471
+ }
472
+ }
473
+ continue;
474
+ }
475
+ if (m.role !== "assistant" || !Array.isArray(m.content)) {
476
+ current = undefined;
477
+ continue;
478
+ }
479
+ for (const c of m.content as Block[]) {
480
+ if (c.type === "text" && c.text?.trim()) current = undefined;
481
+ if (c.type !== "toolCall" || !c.id || groupOf.has(c.id)) continue;
482
+ current ??= { head: c.id, calls: [], members: [], skills: [] };
483
+ const skill = skillName(c.name, c.arguments);
484
+ if (skill) current.skills.push(skill);
485
+ current.calls.push({ id: c.id, name: c.name ?? "tool", skill: !!skill });
486
+ current.members.push(c.id);
487
+ groupOf.set(c.id, current);
488
+ last = current;
489
+ }
490
+ }
491
+ return { groupOf, last };
492
+ }
493
+
494
+ // [verb, singular, plural]; tools sharing verb+noun are counted together.
495
+ const GROUP_LABELS: Record<string, [string, string, string]> = {
496
+ read: ["read", "file", "files"],
497
+ bash: ["ran", "command", "commands"],
498
+ powershell: ["ran", "command", "commands"],
499
+ edit: ["edited", "file", "files"],
500
+ write: ["wrote", "file", "files"],
501
+ grep: ["searched", "pattern", "patterns"],
502
+ find: ["searched", "pattern", "patterns"],
503
+ ls: ["listed", "directory", "directories"],
504
+ codemode: ["ran", "script", "scripts"],
505
+ };
506
+ const OTHER_LABEL: [string, string, string] = ["called", "tool", "tools"];
507
+
508
+ function summarize(names: string[], images = 0): string {
509
+ const counts = new Map<string, { label: [string, string, string]; n: number }>();
510
+ for (const name of names) {
511
+ const label = GROUP_LABELS[name] ?? OTHER_LABEL;
512
+ const entry = counts.get(label.join()) ?? counts.set(label.join(), { label, n: 0 }).get(label.join())!;
513
+ entry.n++;
514
+ }
515
+ const parts = [...counts.values()].map(({ label: [verb, one, many], n }) => `${verb} ${n} ${n === 1 ? one : many}`);
516
+ if (images) parts.push(`viewed ${images} ${images === 1 ? "image" : "images"}`);
517
+ const text = parts.join(", ");
518
+ return text.charAt(0).toUpperCase() + text.slice(1);
519
+ }
520
+
521
+ // Group lines and hidden rows are opaque (no `text` field): bg-bash's badge
522
+ // resolver wraps ours and prefixes any call component exposing `text`, which
523
+ // must only hit real per-call lines. A hidden row renders zero lines; with
524
+ // renderShell "self" the whole row then disappears.
525
+ function opaque(text: string, paddingX: number): CallComponent {
526
+ const inner = new Text(text, paddingX, 0);
527
+ return { render: (width) => inner.render(width), invalidate: () => inner.invalidate() };
528
+ }
529
+
530
+ // A card is rendered by the head only: Pi inserts a blank line before every
531
+ // non-empty tool row, so framing individual rows would leave gaps in the card.
532
+ function card(getSections: () => CallComponent[][], theme: RenderTheme, padding: number): CallComponent {
533
+ let sections: {
534
+ content: CallComponent[]; box: Box; padding: number;
535
+ width?: number; edge?: string; rendered?: string[]; lines?: string[];
536
+ }[] = [];
537
+ let layout: { box: Box; y: number; height: number }[] = [];
538
+ let output: { frame: string; lines: string[] } | undefined;
539
+ let inset = 0;
540
+ let inner = 0;
541
+ const border = (line: string) => theme.fg("warning", line);
542
+ return {
543
+ render(width) {
544
+ if (width < 1) { output = undefined; layout = []; return []; }
545
+ inset = width < 4 ? 0 : 1;
546
+ inner = width - inset * 2;
547
+ const rule = (left: string, right: string) => border(left + "─".repeat(inner) + right);
548
+ const edge = inset ? border("│") : "";
549
+ const rules = inset ? [rule("╭", "╮"), rule("├", "┤"), rule("╰", "╯")] : ["", "", ""];
550
+ const frame = edge + rules.join("");
551
+ const paddingX = Math.min(padding, Math.floor((inner - 1) / 2));
552
+ const content = getSections();
553
+ let changed = output?.frame !== frame || content.length !== sections.length;
554
+ sections = content.map((content, i) => {
555
+ const previous = sections[i];
556
+ if (previous && previous.content.length === content.length && content.every((child, j) => child === previous.content[j])) return previous;
557
+ const box = new Box(paddingX, 0);
558
+ for (const child of content) box.addChild(child);
559
+ return { content, box, padding: paddingX };
560
+ });
561
+ for (const section of sections) {
562
+ const { box } = section;
563
+ // Pi's setter invalidates its cache even when the padding is unchanged.
564
+ if (section.padding !== paddingX) {
565
+ box.setPaddingX(paddingX);
566
+ section.padding = paddingX;
567
+ }
568
+ // Poll children every frame: custom components can change without a
569
+ // renderer callback. Reuse only the expensive fitting of equal output.
570
+ const rendered = box.render(inner);
571
+ if (!section.lines || section.width !== inner || section.edge !== edge ||
572
+ section.rendered?.length !== rendered.length || rendered.some((line, i) => line !== section.rendered![i])) {
573
+ section.width = inner;
574
+ section.edge = edge;
575
+ section.rendered = rendered;
576
+ // Image protocol payloads/row markers must not pass through text fitting.
577
+ section.lines = rendered.map((line) => line.includes("\x1b_G") || line.includes("\x1b]1337;File=")
578
+ ? line : edge + truncateToWidth(line, inner, "", true) + edge);
579
+ changed = true;
580
+ }
581
+ }
582
+ // Only reuse the whole frame after polling all children above.
583
+ if (!changed) return output!.lines;
584
+ layout = [];
585
+ const lines = inset ? [rules[0]] : [];
586
+ for (const { box, lines: fitted } of sections) {
587
+ if (!fitted?.length) continue;
588
+ if (layout.length && inset) lines.push(rules[1]);
589
+ layout.push({ box, y: lines.length, height: fitted.length });
590
+ for (const line of fitted) lines.push(line);
591
+ }
592
+ if (inset) lines.push(rules[2]);
593
+ output = { frame, lines };
594
+ return lines;
595
+ },
596
+ handleMouse(event) {
597
+ if (event.x < inset || event.x >= inner + inset) return undefined;
598
+ const section = layout.find(({ y, height }) => event.y >= y && event.y < y + height);
599
+ return section?.box.handleMouse({ ...event, x: event.x - inset, y: event.y - section.y, width: inner, height: section.height });
600
+ },
601
+ // groupCard owns child invalidation, including currently hidden rows.
602
+ invalidate() { sections = []; layout = []; output = undefined; },
603
+ };
604
+ }
605
+
606
+ // bg-bash's outer resolver badges `text` after renderCall returns. Forward
607
+ // that mutation to the inner call, never to the card's summary or border.
608
+ function badgeTarget(wrapper: CallComponent, child: CallComponent | undefined): CallComponent {
609
+ const target = child as { text?: string; setText?(text: string): void } | undefined;
610
+ if (typeof target?.text === "string") {
611
+ Object.defineProperty(wrapper, "text", {
612
+ get: () => target.text,
613
+ set: (text: string) => {
614
+ if (target.setText) target.setText(text);
615
+ else target.text = text;
616
+ child!.invalidate();
617
+ },
618
+ });
619
+ }
620
+ return wrapper;
621
+ }
622
+
623
+ // First line of the newest thinking block, for the running group's line.
624
+ function latestThought(message: Msg | undefined): string | undefined {
625
+ if (!Array.isArray(message?.content)) return undefined;
626
+ const thinking = (message.content as Block[]).filter((c) => c.type === "thinking" && c.thinking?.trim()).pop();
627
+ const line = thinking?.thinking?.split("\n").map((l) => l.replace(/[*_#`]/g, "").trim()).find(Boolean);
628
+ return line && line.length > 80 ? `${line.slice(0, 77)}...` : line;
629
+ }
630
+
631
+ // Drops thinking from tool-call-only assistant messages while thinking is
632
+ // hidden. lastMessage is restored so toggling thinking back on re-renders the
633
+ // original. Guarded: /reload must not wrap the method twice.
634
+ const PATCHED = Symbol.for("compact-tools.absorbThinking");
635
+ function absorbThinking(): void {
636
+ const proto = AssistantMessageComponent.prototype as any;
637
+ if (proto[PATCHED]) return;
638
+ proto[PATCHED] = true;
639
+ const original = proto.updateContent;
640
+ proto.updateContent = function (this: any, message: { content: Block[] }, ...rest: unknown[]) {
641
+ const toolOnly =
642
+ this.hideThinkingBlock &&
643
+ message.content.some((c) => c.type === "toolCall") &&
644
+ !message.content.some((c) => c.type === "text" && c.text?.trim());
645
+ if (!toolOnly) return original.call(this, message, ...rest);
646
+ original.call(this, { ...message, content: message.content.filter((c) => c.type !== "thinking") }, ...rest);
647
+ this.lastMessage = message;
648
+ };
649
+ }
650
+
651
+ // HTML export shares the renderer API, but consumes calls/results separately.
652
+ // Mark actual transcript contexts so exports keep full per-tool output and do
653
+ // not touch the live card's state. Guarded like absorbThinking for /reload.
654
+ function markToolContexts(): void {
655
+ const proto = ToolExecutionComponent.prototype as any;
656
+ if (!proto[TUI_CONTEXT] && typeof proto.getRenderContext === "function") {
657
+ const original = proto.getRenderContext;
658
+ proto.getRenderContext = function (...args: unknown[]) {
659
+ return { ...original.apply(this, args), [TUI_CONTEXT]: true };
660
+ };
661
+ proto[TUI_CONTEXT] = true;
662
+ }
663
+ // Pi 1.4 appends images outside custom renderers, even with renderShell self.
664
+ // Hide only that final render pass for our renderer. Keep native image setup
665
+ // intact (including PNG transcoder initialization for JPEG/WebP on Kitty).
666
+ // The setting and result/model content are never changed.
667
+ if (!proto[OWN_IMAGES] && typeof proto.render === "function") {
668
+ const original = proto.render;
669
+ proto.render = function (...args: unknown[]) {
670
+ if (!this.toolDefinition?.renderResult?.[OWN_IMAGES]) return original.apply(this, args);
671
+ const images = this.imageComponents;
672
+ this.imageComponents = [];
673
+ try { return original.apply(this, args); }
674
+ finally { this.imageComponents = images; }
675
+ };
676
+ proto[OWN_IMAGES] = true;
677
+ }
678
+ }
679
+
680
+ export default function (pi: ExtensionAPI) {
681
+ let retired = false;
682
+ let sessionManager: ExtensionContext["sessionManager"] | undefined;
683
+ let live: Msg | undefined; // in-flight assistant message, maybe not persisted yet
684
+ let running = false;
685
+ let cache: { key: string; groupOf: Map<string, Group>; last?: Group } | undefined;
686
+ const invalidators = new Map<string, () => void>(); // toolCallId -> row redraw
687
+ // Native expanded belongs to Ctrl+O; pointer disclosure is local to the card.
688
+ const openHeads = new Set<string>();
689
+ type Row = { call?: CallComponent; result?: CallComponent; resultView?: CallComponent; error?: CallComponent; summary?: CallComponent;
690
+ isPartial?: boolean; isError?: boolean; skills?: string[]; nativeExpanded?: boolean; open?: boolean; canExpand?: boolean;
691
+ executionStarted?: boolean; revealed?: boolean; timer?: ReturnType<typeof setTimeout>;
692
+ makeResult?: () => CallComponent; resultDirty?: boolean; images?: ImageState[]; showImages?: boolean;
693
+ args?: RenderArgs; toolName?: string; hasText?: boolean; customResult?: boolean };
694
+ const rows = new Map<string, Row>();
695
+ const pendingHeads = new Set<string>();
696
+ // nf-fa-hourglass_*
697
+ const activityFrames = ["\uf251", "\uf252", "\uf253"];
698
+ let activityFrame = 0;
699
+ let activityTimer: ReturnType<typeof setInterval> | undefined;
700
+
701
+ function stopActivity() {
702
+ if (activityTimer !== undefined) clearInterval(activityTimer);
703
+ activityTimer = undefined;
704
+ activityFrame = 0;
705
+ }
706
+
707
+ function syncActivity() {
708
+ const head = running && !latestThought(live) ? groups().last?.head : undefined;
709
+ if (!head || !invalidators.has(head)) { stopActivity(); return; }
710
+ if (activityTimer !== undefined) return;
711
+ // One timer for the current parent, never one per row or historical card.
712
+ activityTimer = setInterval(() => {
713
+ syncActivity();
714
+ if (activityTimer === undefined) return;
715
+ activityFrame = (activityFrame + 1) % activityFrames.length;
716
+ invalidators.get(groups().last!.head)?.();
717
+ }, 160);
718
+ }
719
+
720
+ function rowState(id: string) {
721
+ if (!rows.has(id)) rows.set(id, {});
722
+ return rows.get(id)!;
723
+ }
724
+
725
+ function refreshHead(group: Group | undefined, id: string) {
726
+ if (!group || group.head === id || pendingHeads.has(group.head)) return;
727
+ pendingHeads.add(group.head);
728
+ queueMicrotask(() => {
729
+ if (pendingHeads.delete(group.head)) invalidators.get(group.head)?.();
730
+ });
731
+ }
732
+
733
+ function syncExpanded(group: Group | undefined, id: string, context: RenderContext) {
734
+ const state = rowState(id);
735
+ const expanded = !!context.expanded;
736
+ if (state.nativeExpanded !== expanded) {
737
+ const first = state.nativeExpanded === undefined;
738
+ state.nativeExpanded = expanded;
739
+ for (const image of state.images ?? []) image.open = expanded;
740
+ if (!first || state.open === undefined || expanded) state.open = expanded;
741
+ if (!group || group.head === id) {
742
+ if (expanded) openHeads.add(id);
743
+ else openHeads.delete(id);
744
+ for (const member of group?.members ?? []) rowState(member).open = expanded;
745
+ }
746
+ }
747
+ }
748
+
749
+ function updateTimer(id: string, state: Row) {
750
+ if (!state.isPartial || !state.executionStarted) {
751
+ if (state.timer !== undefined) clearTimeout(state.timer);
752
+ state.timer = undefined;
753
+ state.revealed = false;
754
+ } else if (!state.revealed && state.timer === undefined) {
755
+ state.timer = setTimeout(() => {
756
+ state.timer = undefined;
757
+ state.revealed = true;
758
+ const head = groups().groupOf.get(id)?.head ?? id;
759
+ invalidators.get(head)?.();
760
+ }, 1000);
761
+ }
762
+ }
763
+
764
+ function clearTimers() {
765
+ for (const state of rows.values()) {
766
+ if (state.timer !== undefined) clearTimeout(state.timer);
767
+ state.timer = undefined;
768
+ }
769
+ }
770
+
771
+ function groups() {
772
+ const content = Array.isArray(live?.content) ? (live.content as Block[]) : [];
773
+ const texts = content.filter((c) => c.type === "text" && c.text?.trim()).length;
774
+ // A streaming read can acquire its skill path without adding a block.
775
+ const paths = JSON.stringify(content.filter((c) => c.type === "toolCall" && c.name === "read").map((c) => c.arguments?.path));
776
+ const key = `${sessionManager?.getSessionId()}:${sessionManager?.getLeafId()}:${content.length}:${texts}:${paths}`;
777
+ if (cache?.key !== key) {
778
+ const branch = sessionManager?.getBranch() ?? [];
779
+ const messages = branch.flatMap((e: any) => (e.type === "message" ? [e.message as Msg] : []));
780
+ cache = { key, ...buildGroups(live ? [...messages, live] : messages) };
781
+ }
782
+ return cache;
783
+ }
784
+
785
+ // Redraw the newest group's rows: its head line changes as calls join or
786
+ // the agent stops, and rows drawn before their call was indexed regroup.
787
+ function refresh() {
788
+ syncActivity();
789
+ for (const id of groups().last?.members ?? []) invalidators.get(id)?.();
790
+ }
791
+
792
+ absorbThinking();
793
+ markToolContexts();
794
+
795
+ pi.on("session_start", (_event, ctx) => {
796
+ retired = false;
797
+ running = false;
798
+ stopActivity();
799
+ clearTimers();
800
+ sessionManager = ctx.sessionManager;
801
+ live = undefined;
802
+ cache = undefined;
803
+ invalidators.clear();
804
+ openHeads.clear();
805
+ rows.clear();
806
+ pendingHeads.clear();
807
+ });
808
+ pi.on("session_shutdown", () => {
809
+ retired = true;
810
+ sessionManager = undefined;
811
+ live = undefined;
812
+ cache = undefined;
813
+ running = false;
814
+ stopActivity();
815
+ clearTimers();
816
+ invalidators.clear();
817
+ pendingHeads.clear();
818
+ });
819
+ pi.on("agent_start", () => {
820
+ running = true;
821
+ syncActivity();
822
+ });
823
+ pi.on("agent_end", () => {
824
+ running = false;
825
+ live = undefined;
826
+ refresh();
827
+ });
828
+ pi.on("message_start", (event) => {
829
+ if (event.message.role === "assistant") {
830
+ live = event.message as Msg;
831
+ syncActivity();
832
+ } else refresh();
833
+ });
834
+ pi.on("message_update", (event) => {
835
+ if (event.message.role !== "assistant") return;
836
+ live = event.message as Msg;
837
+ refresh();
838
+ });
839
+
840
+ function groupLine(group: Group, theme: RenderTheme, context: RenderContext): string {
841
+ const names: string[] = [];
842
+ let images = 0;
843
+ for (const call of group.calls) {
844
+ const state = rows.get(call.id);
845
+ const count = state?.images?.length ?? call.images ?? 0;
846
+ images += count;
847
+ if (!call.skill && (!count || (call.name !== "read" && ((state?.hasText ?? call.hasText) || state?.customResult)))) names.push(call.name);
848
+ }
849
+ // nf-fa-caret_down and nf-fa-caret_right
850
+ let text = theme.fg("warning", theme.bold(`${openHeads.has(group.head) ? "\uf0d7" : "\uf0da"} ${summarize(names, images) || "Skill instructions"}`));
851
+ if (running && group === groups().last) {
852
+ const thought = latestThought(live);
853
+ // nf-md-thought_bubble_outline
854
+ text += theme.fg("dim", thought ? ` \udb81\udff7 ${thought}` : ` ${activityFrames[activityFrame]}`);
855
+ } else {
856
+ text += theme.fg("dim", " (") + keyHint("app.tools.expand", context.expanded ? "to collapse" : "to expand") + theme.fg("dim", ")");
857
+ }
858
+ return text;
859
+ }
860
+
861
+ function disclosure(component: CallComponent, toggle: () => void): CallComponent {
862
+ return {
863
+ render: (width) => component.render(width),
864
+ invalidate: () => component.invalidate(),
865
+ handleMouse(event) {
866
+ const handled = component.handleMouse?.(event);
867
+ if (handled?.handled) return handled;
868
+ if (event.type !== "click" || event.button !== "left") return handled;
869
+ toggle();
870
+ return { handled: true };
871
+ },
872
+ };
873
+ }
874
+
875
+ function statusLabel(state: Row): string {
876
+ // nf-md-progress_clock for all unfinished calls (queued or executing)
877
+ // nf-fa-check for success
878
+ // nf-cod-warning for error
879
+ return state.isPartial ? "\udb82\udd96 working" : state.isError ? "\uea6c error " : "\uf00c success";
880
+ }
881
+
882
+ function toggleChild(id: string) {
883
+ const state = rowState(id);
884
+ if (isRunning(state) && state.canExpand === false) return;
885
+ state.open = !state.open;
886
+ if (state.open && state.makeResult) { state.result = state.makeResult(); state.resultDirty = false; }
887
+ // Recreate this row's renderer with its new local expanded flag too
888
+ // (custom chips, wrapped commands and bg-bash's outer badge resolver).
889
+ invalidators.get(id)?.();
890
+ refreshHead(groups().groupOf.get(id), id);
891
+ }
892
+
893
+ function isRunning(state: Row): boolean {
894
+ return !!(state.isPartial && state.executionStarted);
895
+ }
896
+
897
+ function childPrefix(state: Row, open: boolean, glyph: string, theme: RenderTheme, canExpand = true): string {
898
+ return theme.fg(state.isError ? "error" : state.isPartial ? "muted" : "success",
899
+ `${isRunning(state) ? "" : " "}${canExpand ? open ? "\uf0d7" : "\uf0da" : " "} ${statusLabel(state)} `) + theme.fg("warning", `${glyph} `);
900
+ }
901
+
902
+ function childSummary(id: string, toolName: string, theme: RenderTheme): CallComponent {
903
+ const glyph = ({
904
+ read: "\ueb1b", // nf-cod-mail_read
905
+ bash: "\uf4b5", // nf-oct-command_palette
906
+ powershell: "\uf4b5", // nf-oct-command_palette
907
+ edit: "\uea73", // nf-cod-edit
908
+ write: "\u{f0219}", // nf-md-file_document
909
+ grep: "\u{f0451}", // nf-md-regex
910
+ find: "\uf002", // nf-fa-magnifying_glass
911
+ ls: "\uf00b", // nf-fa-table_list
912
+ codemode: "\udb81\udc77", // nf-md-script_outline
913
+ } as Record<string, string>)[toolName] ?? "\udb84\udc64"; // nf-md-tools
914
+ // Render and hit-test the same prefix so icon spacing cannot shift clicks.
915
+ // nf-fa-caret_down and nf-fa-caret_right
916
+ const prefix = (state: Row, open: boolean) => childPrefix(state, open, glyph, theme, !isRunning(state) || state.canExpand !== false);
917
+ const content: CallComponent = {
918
+ render(width) {
919
+ const state = rowState(id);
920
+ const open = state.open && (isRunning(state) || openHeads.has(groups().groupOf.get(id)?.head ?? id));
921
+ let chrome = prefix(state, !!open);
922
+ const available = Math.max(1, width - visibleWidth(chrome));
923
+ const lines = state.call?.render(available) ?? [toolName];
924
+ if (isRunning(state)) {
925
+ const output = state.result?.render(width) ?? [];
926
+ // Ignore the renderer's leading spacer; keep disclosure for hidden
927
+ // command lines too, even when the live output fits in five lines.
928
+ state.canExpand = lines.length > 1 || (state.call instanceof ShellCallLine && state.call.hasMore)
929
+ || output.length - (output[0]?.trim() ? 0 : 1) > 5;
930
+ chrome = prefix(state, !!open);
931
+ }
932
+ return (open ? lines : lines.slice(0, 1)).map((line, i) => (i ? " ".repeat(visibleWidth(chrome)) : chrome) + line);
933
+ },
934
+ handleMouse(event) {
935
+ const state = rowState(id);
936
+ const offset = visibleWidth(prefix(state, false));
937
+ if (event.x < offset) return undefined;
938
+ return state.call?.handleMouse?.({ ...event, x: event.x - offset, width: Math.max(1, event.width - offset) });
939
+ },
940
+ invalidate() {}, // the card invalidates the underlying retained call
941
+ };
942
+ return disclosure(content, () => toggleChild(id));
943
+ }
944
+
945
+ function imageRow(id: string, image: ImageState, index: number, theme: RenderTheme): CallComponent {
946
+ return disclosure({
947
+ render(width) {
948
+ const state = rowState(id);
949
+ const size = image.dimensions ? ` · ${image.dimensions.widthPx}×${image.dimensions.heightPx}` : "";
950
+ const path = state.toolName === "read" && typeof state.args?.path === "string" ? shortenPath(state.args.path) : undefined;
951
+ const label = path ? theme.fg("toolTitle", theme.bold("image")) + theme.fg("dim", ` ${path}${(state.images?.length ?? 0) > 1 ? ` · Image #${index + 1}` : ""}`)
952
+ : theme.fg("toolTitle", theme.bold(state.toolName ?? "tool")) + theme.fg("dim", ` · Image #${index + 1}`);
953
+ // nf-oct-image; same status chrome and inset as ordinary tool children.
954
+ const lines = [truncateToWidth(childPrefix(state, image.open, "\uf4e5", theme) + label + theme.fg("dim", size), width, "…")];
955
+ if (image.open) {
956
+ // A read's image metadata belongs to its image row, not a second
957
+ // file row. Preserve that text once when the first image is open.
958
+ if (index === 0 && state.toolName === "read" && state.hasText) {
959
+ if (state.resultDirty && state.makeResult) { state.result = state.makeResult(); state.resultDirty = false; }
960
+ lines.push(...(state.result?.render(width) ?? []));
961
+ }
962
+ const natural = image.dimensions ? Math.max(1, Math.floor(image.dimensions.widthPx / getCellDimensions().widthPx)) : 60;
963
+ const maxWidth = Math.min(natural, pi.getSettings().terminal?.imageWidthCells ?? natural);
964
+ if (!image.preview || image.maxWidth !== maxWidth || image.theme !== theme) {
965
+ image.maxWidth = maxWidth;
966
+ image.theme = theme;
967
+ image.preview = new Image(image.source.data, image.source.mimeType, { fallbackColor: (text: string) => theme.fg("dim", text) },
968
+ { maxWidthCells: maxWidth, maxHeightCells: Number.MAX_SAFE_INTEGER }, image.dimensions ?? undefined);
969
+ }
970
+ lines.push(...image.preview.render(width));
971
+ }
972
+ return lines;
973
+ },
974
+ invalidate() { image.preview?.invalidate(); },
975
+ }, () => {
976
+ image.open = !image.open;
977
+ invalidators.get(id)?.();
978
+ refreshHead(groups().groupOf.get(id), id);
979
+ });
980
+ }
981
+
982
+ function groupCard(id: string, toolName: string, theme: RenderTheme, context: RenderContext): CallComponent {
983
+ const summary = new Text("", 0, 0);
984
+ const header = disclosure(summary, () => {
985
+ if (openHeads.has(id)) openHeads.delete(id);
986
+ else openHeads.add(id);
987
+ invalidators.get(id)?.();
988
+ });
989
+ const skills = new Text("", 0, 0);
990
+ let summaryText: string | undefined;
991
+ let skillsText: string | undefined;
992
+ const frame = card(() => {
993
+ const group = groups().groupOf.get(id) ?? { head: id, calls: [{ id, name: toolName }], members: [id], skills: [] };
994
+ const expanded = openHeads.has(group.head);
995
+ const text = groupLine(group, theme, context);
996
+ if (text !== summaryText) {
997
+ summary.setText(text);
998
+ summaryText = text;
999
+ }
1000
+ const sections: CallComponent[][] = [[header]];
1001
+ const liveSections: CallComponent[][] = [];
1002
+ for (const member of group.members) {
1003
+ const state = rows.get(member);
1004
+ if (!state || (!expanded && !(isRunning(state) && state.revealed))) continue;
1005
+ const active = isRunning(state);
1006
+ const target = active ? liveSections : sections;
1007
+ const showImages = (state.images?.length ?? 0) > 0 && state.showImages !== false && pi.getSettings().terminal?.showImages !== false;
1008
+ // Image-only results need no intermediate tool disclosure. Keep mixed
1009
+ // text and custom controls on their own row, independent of the images.
1010
+ if (active || !showImages || ((state.hasText || state.customResult) && state.toolName !== "read") || state.isError) {
1011
+ if ((state.open || active) && state.resultDirty && state.makeResult) { state.result = state.makeResult(); state.resultDirty = false; }
1012
+ if (!state.resultView) {
1013
+ let offset = 0;
1014
+ let retainedSpacer = 0;
1015
+ state.resultView = disclosure({
1016
+ render(width) {
1017
+ const lines = state.result?.render(width) ?? [];
1018
+ // Tail five wrapped content lines, retaining the renderer's leading spacer.
1019
+ offset = isRunning(state) && !state.open ? Math.max(0, lines.length - 5) : 0;
1020
+ retainedSpacer = offset > 0 && !lines[0]?.trim() ? 1 : 0;
1021
+ return retainedSpacer ? [lines[0], ...lines.slice(offset)] : lines.slice(offset);
1022
+ },
1023
+ invalidate: () => state.result?.invalidate(),
1024
+ handleMouse: (event) => state.result?.handleMouse?.({ ...event,
1025
+ y: retainedSpacer && event.y === 0 ? 0 : event.y + offset - retainedSpacer,
1026
+ height: event.height + offset - retainedSpacer }),
1027
+ }, () => toggleChild(member));
1028
+ }
1029
+ const content = [state.summary, ...(active || (expanded && state.open) ? [state.resultView] : [])].filter((c): c is CallComponent => !!c);
1030
+ if (content.length) target.push(content);
1031
+ }
1032
+ if (expanded && showImages) for (const image of state.images ?? []) if (image.view) target.push([image.view]);
1033
+ }
1034
+ const names = [...new Set([...group.skills, ...group.members.flatMap((member) => rows.get(member)?.skills ?? [])])];
1035
+ if (names.length) {
1036
+ // nf-oct-light_bulb and nf-oct-dot_fill
1037
+ const label = theme.fg("warning", theme.bold("\uf400")) + " " + theme.fg("accent", theme.bold("Skills: ")) + names.map((name) => theme.fg("accent", name)).join(theme.fg("dim", " \ueb8a "));
1038
+ if (label !== skillsText) {
1039
+ skills.setText(label);
1040
+ skillsText = label;
1041
+ }
1042
+ sections.push([skills]);
1043
+ }
1044
+ for (const member of group.members) {
1045
+ const state = rows.get(member);
1046
+ // An explicitly opened child's output already contains its error.
1047
+ if (state?.error && !(state.open && expanded)) sections.push([state.error]);
1048
+ }
1049
+ sections.push(...liveSections);
1050
+ return sections;
1051
+ }, theme, context.outputPad ?? 1);
1052
+ return {
1053
+ // Old cards remain mounted briefly after /reload revokes their pi/ctx.
1054
+ render: (width) => retired ? [] : frame.render(width),
1055
+ handleMouse(event) {
1056
+ if (retired) return;
1057
+ const result = frame.handleMouse?.(event);
1058
+ return result?.handled ? result : event.type === "click" && event.button === "left" ? { handled: true } : result;
1059
+ },
1060
+ invalidate() {
1061
+ if (retired) return;
1062
+ frame.invalidate();
1063
+ summary.invalidate();
1064
+ skills.invalidate();
1065
+ const group = groups().groupOf.get(id);
1066
+ for (const member of group?.members ?? [id]) {
1067
+ rows.get(member)?.call?.invalidate();
1068
+ rows.get(member)?.result?.invalidate();
1069
+ for (const image of rows.get(member)?.images ?? []) image.view?.invalidate();
1070
+ rows.get(member)?.error?.invalidate();
1071
+ }
1072
+ },
1073
+ };
1074
+ }
1075
+
1076
+ pi.registerToolRenderer((toolName, next) => {
1077
+ // Known names: fully owned renderers. Everything else — custom tools from
1078
+ // other extensions, direct-exposure MCP tools — keeps whatever renderers
1079
+ // exist (own, or from a later resolver via next()) and gets compact ones
1080
+ // only where the stock fallback would show full args / a 5-line preview.
1081
+ const own = Object.hasOwn(compactRenderers, toolName) ? compactRenderers[toolName] : undefined;
1082
+ const base = own
1083
+ ? undefined
1084
+ : (next() as { renderCall?: CompactRenderers["renderCall"]; renderResult?: CompactRenderers["renderResult"] } | undefined);
1085
+ const renderCall =
1086
+ own?.renderCall ??
1087
+ base?.renderCall ??
1088
+ (toolName.startsWith("mcp__") ? mcpRenderCall(toolName) : argPreviewCall(toolName));
1089
+ const renderResult = own?.renderResult ?? base?.renderResult ?? simpleRenderResult;
1090
+
1091
+ const renderers = {
1092
+ renderShell: "self" as const,
1093
+ renderCall: (args: RenderArgs, theme: RenderTheme, context: RenderContext) => {
1094
+ if (retired) return opaque("", 0);
1095
+ if (!context[TUI_CONTEXT]) return renderCall(args, theme, context);
1096
+ const id = context.toolCallId ?? "";
1097
+ if (context.invalidate) invalidators.set(id, context.invalidate);
1098
+ syncActivity();
1099
+ const group = groups().groupOf.get(id);
1100
+ syncExpanded(group, id, context);
1101
+ const state = rowState(id);
1102
+ state.args = args;
1103
+ state.toolName = toolName;
1104
+ const expanded = !!state.open;
1105
+ state.isPartial = context.isPartial ?? state.isPartial ?? true;
1106
+ state.executionStarted = context.executionStarted ?? state.executionStarted ?? false;
1107
+ updateTimer(id, state);
1108
+ state.isError = context.isError ?? state.isError;
1109
+ {
1110
+ const color = state.isPartial ? "muted" : state.isError ? "error" : undefined;
1111
+ // Override only the semantic title token; output, args, badges,
1112
+ // and successful titles keep their renderer's existing styling.
1113
+ const callTheme = color ? Object.assign(Object.create(theme), {
1114
+ fg: (token: string, text: string) => token === "toolTitle"
1115
+ ? theme.fg(color, theme.bold(text)) : theme.fg(token, text),
1116
+ }) : theme;
1117
+ state.call = renderCall(args, callTheme, { ...context, expanded, outputPad: 0, lastComponent: state.call });
1118
+ }
1119
+ state.summary = childSummary(id, toolName, theme);
1120
+ refreshHead(group, id);
1121
+ const wrapper = !group || group.head === id ? groupCard(id, toolName, theme, context) : opaque("", 0);
1122
+ return badgeTarget(wrapper, state.call);
1123
+ },
1124
+ renderResult: (result: RenderResult, options: RenderOptions, theme: RenderTheme, context: RenderContext) => {
1125
+ if (retired) return opaque("", 0);
1126
+ if (!context[TUI_CONTEXT]) return renderResult(result, options, theme, context);
1127
+ const id = context.toolCallId ?? "";
1128
+ const group = groups().groupOf.get(id);
1129
+ syncExpanded(group, id, context);
1130
+ const state = rowState(id);
1131
+ state.isPartial = options.isPartial;
1132
+ state.executionStarted = context.executionStarted ?? state.executionStarted ?? false;
1133
+ updateTimer(id, state);
1134
+ state.isError = context.isError ?? result.isError;
1135
+ state.skills = nestedSkills(result, toolName);
1136
+ state.showImages = context.showImages;
1137
+ state.toolName = toolName;
1138
+ state.hasText = !!allText(result).trim();
1139
+ state.customResult = !!base?.renderResult;
1140
+ state.images = imageBlocks(result.content)
1141
+ .map((source, index) => {
1142
+ const previous = state.images?.[index];
1143
+ return previous?.source.data === source.data && previous.source.mimeType === source.mimeType ? previous
1144
+ : { source, open: !!context.expanded, dimensions: getImageDimensions(source.data, source.mimeType) };
1145
+ });
1146
+ state.images.forEach((image, index) => { image.view = imageRow(id, image, index, theme); });
1147
+ // Pi keeps isError on context, not on the result passed to renderers.
1148
+ state.makeResult = () => renderResult({ ...result, isError: state.isError }, { ...options, expanded: true }, theme,
1149
+ { ...context, expanded: true, outputPad: 0, lastComponent: state.result });
1150
+ state.resultDirty = true;
1151
+ if (state.open) { state.result = state.makeResult(); state.resultDirty = false; }
1152
+ const line = (allText(result).trim().split("\n")[0] || "error").slice(0, 200);
1153
+ state.error = !options.isPartial && state.isError ? new Text(theme.fg("error", `\uea6c ${theme.bold(`${toolName}:`)} ${line}`), 0, 0) : undefined;
1154
+ refreshHead(group, id);
1155
+ return opaque("", 0);
1156
+ },
1157
+ };
1158
+ Object.defineProperty(renderers.renderResult, OWN_IMAGES, { value: true });
1159
+ return renderers;
1160
+ });
1161
+ }