@hheei/pi-ext-ui 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,3 +5,5 @@ Pi UI primitives and a small set of specialized tool renderers.
5
5
  `pi-ext-ui` owns the visual result, not tool execution. Its default resolver only handles tools with an explicit specialized renderer (`edit`, `read`, `write`, `apply_patch`, `eval`, and `codemode`). Other tools remain on Pi's native renderer. Extensions can opt into the shared visual API with `createToolView`, `ToolView`, and `DiffView`, or explicitly add a tool through resolver options.
6
6
 
7
7
  The shared view API owns fold levels, compact sections, status glyphs, copy feedback, and width-safe rendering. It does not read files, execute tools, infer outcomes, or import a concrete extension.
8
+
9
+ Background Bash and subagent tools own their display formats through ext-core `ToolPresentation` providers. This resolver consumes those providers automatically, including nested codemode calls, and adds shared folding and copy controls. Without `pi-ext-ui`, the same tools retain native Pi renderers. No direct dependency on `pi-ext-ui` is required by the tool extensions.
package/dist/extension.js CHANGED
@@ -1,4 +1,15 @@
1
- import { registerCollapsibleToolRenderer } from "./renderer.js";
1
+ import { messageViewPoint, registerExtensionHook, registerExtensionLifecycle, toolRendererPoint, } from "@hheei/pi-ext-core";
2
+ import { createMessageView } from "./message-view.js";
3
+ import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
2
4
  export default function (pi) {
3
5
  registerCollapsibleToolRenderer(pi);
6
+ registerExtensionLifecycle(pi, {
7
+ key: "@hheei/pi-ext-ui",
8
+ start(context) {
9
+ const toolRenderers = registerExtensionHook(pi, toolRendererPoint, createOptionalToolRendererFactory(pi));
10
+ context.resources.add("tool-renderer", () => toolRenderers.dispose());
11
+ const handle = registerExtensionHook(context.pi, messageViewPoint, createMessageView);
12
+ context.resources.add("message-view", () => handle.dispose());
13
+ },
14
+ });
4
15
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { type DiffHunk, type DiffLine, type DiffSource, type DiffStats, DiffView, type DiffViewOptions, diffStats, } from "./diff-view.js";
2
- export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, WEB_ACCESS_TOOLS, type WebAccessToolName, } from "./renderer.js";
3
- export { CollapsibleToolFrame, type CollapsibleToolFrameOptions, createToolView, type FoldLevel, type RailColorToken, type RailSection, type ToolStatusKind, ToolView, type ToolViewCopySpec, type ToolViewHeaderSpec, type ToolViewSpec, transitionFoldLevel, } from "./tool-view.js";
2
+ export { createMessageView, type MessageRows, type MessageTone, MessageView, type MessageViewHeader, type MessageViewSpec, } from "./message-view.js";
3
+ export { extractChildToolCopyText, NestedCallsView, type NestedCallsViewProps, } from "./nested-calls-view.js";
4
+ export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, inlinePresentation, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, WEB_ACCESS_TOOLS, type WebAccessToolName, } from "./renderer.js";
5
+ export { type Collapsible, createToolView, type FoldLevel, formatStatusGlyph, formatToolHeaderText, isCollapsible, type RailColorToken, type RailSection, type ToolStatusKind, ToolView, type ToolViewCopySpec, type ToolViewHeaderSpec, type ToolViewSpec, transitionFoldLevel, } from "./tool-view.js";
6
+ export { type CapturedToolCall, clearCapturedNestedCalls, getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
4
7
  export { formatTurnFlow, type TurnFlowData, type TurnToolCallSummary, } from "./turn-copy.js";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { DiffView, diffStats, } from "./diff-view.js";
2
- export { createCollapsibleToolRendererResolver, estimateStreamingTokens, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, WEB_ACCESS_TOOLS, } from "./renderer.js";
3
- export { CollapsibleToolFrame, createToolView, ToolView, transitionFoldLevel, } from "./tool-view.js";
2
+ export { createMessageView, MessageView, } from "./message-view.js";
3
+ export { extractChildToolCopyText, NestedCallsView, } from "./nested-calls-view.js";
4
+ export { createCollapsibleToolRendererResolver, estimateStreamingTokens, inlinePresentation, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, WEB_ACCESS_TOOLS, } from "./renderer.js";
5
+ export { createToolView, formatStatusGlyph, formatToolHeaderText, isCollapsible, ToolView, transitionFoldLevel, } from "./tool-view.js";
6
+ export { clearCapturedNestedCalls, getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
4
7
  export { formatTurnFlow, } from "./turn-copy.js";
@@ -0,0 +1,15 @@
1
+ import { type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import type { MessageViewSpec } from "@hheei/pi-ext-core";
3
+ export type { MessageRows, MessageTone, MessageViewHeader, MessageViewSpec, } from "@hheei/pi-ext-core";
4
+ export declare function createMessageView(spec: MessageViewSpec): MessageView;
5
+ export declare class MessageView implements Component {
6
+ #private;
7
+ constructor(spec: MessageViewSpec);
8
+ get expandable(): boolean;
9
+ /** What the user sees. Host ctrl+o wins over the remembered collapse. */
10
+ get expanded(): boolean;
11
+ toggle(): void;
12
+ render(width: number): string[];
13
+ invalidate(): void;
14
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
15
+ }
@@ -0,0 +1,130 @@
1
+ import { truncateToWidth, } from "@earendil-works/pi-tui";
2
+ import { toContentRows, toSingleRow } from "./tool-view.js";
3
+ /**
4
+ * A transcript notice, not a tool execution.
5
+ *
6
+ * ToolView owns status, copy, rails, and the three-level fold. A message is a kind plus a
7
+ * summary, optional preview lines, and a body that opens in one step. Expansion is remembered
8
+ * on `stateHolder` by identity, so a host rebuild does not write a field into a session entry.
9
+ */
10
+ const expansion = new WeakMap();
11
+ const truncate = (text, width) => truncateToWidth(text, width, "…");
12
+ function remembered(holder, fallback) {
13
+ if (holder === undefined)
14
+ return fallback;
15
+ return expansion.get(holder) ?? fallback;
16
+ }
17
+ function remember(holder, value) {
18
+ if (holder === undefined)
19
+ return;
20
+ expansion.set(holder, value);
21
+ }
22
+ function hasRows(source) {
23
+ if (source === undefined)
24
+ return false;
25
+ if (typeof source === "string")
26
+ return source.trim() !== "";
27
+ if (Array.isArray(source))
28
+ return source.some((line) => line.trim() !== "");
29
+ return true;
30
+ }
31
+ function isComponent(source) {
32
+ return typeof source !== "string" && !Array.isArray(source);
33
+ }
34
+ function layRows(source, width) {
35
+ if (source === undefined || !hasRows(source))
36
+ return [];
37
+ const inner = Math.max(1, width - 2);
38
+ const raw = isComponent(source)
39
+ ? source.render(inner).flatMap((line) => toContentRows(line))
40
+ : (typeof source === "string" ? [source] : source).flatMap((line) => toContentRows(line));
41
+ return raw.map((line) => truncate(` ${line}`, width));
42
+ }
43
+ function invalidateRows(source) {
44
+ if (source !== undefined && isComponent(source))
45
+ source.invalidate();
46
+ }
47
+ export function createMessageView(spec) {
48
+ return new MessageView(spec);
49
+ }
50
+ export class MessageView {
51
+ #spec;
52
+ #localExpanded;
53
+ #lastWidth = 80;
54
+ #lastHeight = 0;
55
+ #cache;
56
+ constructor(spec) {
57
+ this.#spec = spec;
58
+ this.#localExpanded = remembered(spec.stateHolder, spec.defaultExpanded === true);
59
+ }
60
+ get expandable() {
61
+ return hasRows(this.#spec.body);
62
+ }
63
+ /** What the user sees. Host ctrl+o wins over the remembered collapse. */
64
+ get expanded() {
65
+ return this.expandable && (this.#spec.forcedExpanded === true || this.#localExpanded);
66
+ }
67
+ toggle() {
68
+ if (!this.expandable)
69
+ return;
70
+ const next = !this.expanded;
71
+ this.#localExpanded = next;
72
+ remember(this.#spec.stateHolder, next);
73
+ this.#cache = undefined;
74
+ this.#spec.invalidate?.();
75
+ }
76
+ render(width) {
77
+ const expanded = this.expanded;
78
+ if (this.#cache !== undefined &&
79
+ this.#cache.width === width &&
80
+ this.#cache.expanded === expanded) {
81
+ return this.#cache.lines;
82
+ }
83
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
84
+ const safeWidth = Math.max(1, width - pad * 2);
85
+ this.#lastWidth = width;
86
+ const leftPad = pad > 0 ? " ".repeat(pad) : "";
87
+ const lines = [
88
+ this.#header(safeWidth, expanded),
89
+ ...layRows(this.#spec.preview, safeWidth),
90
+ ...(expanded ? layRows(this.#spec.body, safeWidth) : []),
91
+ ].map((line) => (leftPad === "" ? line : `${leftPad}${line}`));
92
+ this.#lastHeight = lines.length;
93
+ this.#cache = { width, expanded, lines };
94
+ return lines;
95
+ }
96
+ invalidate() {
97
+ this.#cache = undefined;
98
+ invalidateRows(this.#spec.preview);
99
+ invalidateRows(this.#spec.body);
100
+ }
101
+ handleMouse(event) {
102
+ if (!this.expandable ||
103
+ event.type === "wheel" ||
104
+ event.type !== "click" ||
105
+ event.button !== "left") {
106
+ return undefined;
107
+ }
108
+ const effectiveWidth = event.width > 0 ? event.width : this.#lastWidth;
109
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
110
+ const innerX = event.x - pad;
111
+ const innerWidth = Math.max(1, effectiveWidth - pad * 2);
112
+ if (innerX < 0 || innerX >= innerWidth)
113
+ return undefined;
114
+ if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight))
115
+ return undefined;
116
+ if (event.y !== 0)
117
+ return undefined;
118
+ this.toggle();
119
+ return { handled: true, render: true };
120
+ }
121
+ #header(width, expanded) {
122
+ const { theme, header } = this.#spec;
123
+ const kind = theme.fg("dim", toSingleRow(header.kind));
124
+ const summaryRaw = header.summary === undefined ? "" : toSingleRow(header.summary);
125
+ const summary = summaryRaw === "" ? "" : theme.fg(header.tone ?? "dim", summaryRaw);
126
+ const label = summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
127
+ const pin = this.expandable ? `${theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
128
+ return truncate(`${pin}${label}`, width);
129
+ }
130
+ }
@@ -0,0 +1,28 @@
1
+ import { type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
+ import type { ToolPresentationProvider } from "@hheei/pi-ext-core";
4
+ import { type NestedCallRecord, type ToolRenderContext } from "./renderer.js";
5
+ import { type Collapsible } from "./tool-view.js";
6
+ import type { CapturedToolCall } from "./tracker.js";
7
+ export declare function extractChildToolCopyText(call: CapturedToolCall | NestedCallRecord, full?: boolean): string;
8
+ export interface NestedCallsViewProps {
9
+ readonly resolvePresentation?: ((toolName: string) => ToolPresentationProvider | undefined) | undefined;
10
+ readonly calls: readonly (CapturedToolCall | NestedCallRecord)[];
11
+ readonly theme: Theme;
12
+ readonly context: ToolRenderContext;
13
+ readonly resolveChildRenderer?: ((toolName: string) => ToolRenderers | undefined) | undefined;
14
+ readonly copyToClipboard?: ((text: string) => Promise<void>) | undefined;
15
+ readonly stateHolder?: Record<string, unknown> | undefined;
16
+ readonly invalidate?: (() => void) | undefined;
17
+ }
18
+ export declare const NESTED_CALLS_EXPANDED_KEY = "__pi_ext_ui_nested_expanded__";
19
+ export declare const NESTED_CALLS_COPIED_KEY = "__pi_ext_ui_nested_copied__";
20
+ export declare class NestedCallsView implements Component, Collapsible {
21
+ #private;
22
+ constructor(props: NestedCallsViewProps);
23
+ render(width: number): string[];
24
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
25
+ dispose(): void;
26
+ collapseAll(): void;
27
+ invalidate(): void;
28
+ }
@@ -0,0 +1,405 @@
1
+ import { copyToClipboard as copyToSystemClipboard, getLanguageFromPath, highlightCode, } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
+ import { inlinePresentation } from "./renderer.js";
4
+ import { formatToolHeaderText, ToolView, toContentRows, transitionFoldLevel, } from "./tool-view.js";
5
+ function truncate(text, width) {
6
+ return truncateToWidth(text, Math.max(1, width));
7
+ }
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ export function extractChildToolCopyText(call, full) {
12
+ const toolName = "toolName" in call ? call.toolName : call.name;
13
+ const result = "result" in call ? call.result : undefined;
14
+ const args = "args" in call ? call.args : undefined;
15
+ if (toolName === "write" && isRecord(args) && typeof args.content === "string") {
16
+ return args.content;
17
+ }
18
+ const heavyCommand = toolName === "bash" && isRecord(args) && typeof args.command === "string"
19
+ ? args.command
20
+ : toolName === "codemode" && isRecord(args) && typeof args.code === "string"
21
+ ? args.code
22
+ : undefined;
23
+ if (heavyCommand !== undefined && heavyCommand.trim() !== "") {
24
+ if (full !== true)
25
+ return heavyCommand;
26
+ const output = resultText(result);
27
+ return output === undefined ? heavyCommand : `${heavyCommand}\n---\n${output}`;
28
+ }
29
+ if (result && Array.isArray(result.content)) {
30
+ const texts = result.content
31
+ .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
32
+ .map((c) => c.text);
33
+ if (texts.length > 0)
34
+ return texts.join("\n");
35
+ }
36
+ if (args !== undefined) {
37
+ if (typeof args === "string")
38
+ return args;
39
+ try {
40
+ return JSON.stringify(args, null, 2);
41
+ }
42
+ catch {
43
+ return String(args);
44
+ }
45
+ }
46
+ if ("summary" in call && typeof call.summary === "string") {
47
+ return call.summary;
48
+ }
49
+ return "";
50
+ }
51
+ function resultText(result) {
52
+ if (result === undefined || !Array.isArray(result.content))
53
+ return undefined;
54
+ const texts = result.content
55
+ .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
56
+ .map((c) => c.text);
57
+ return texts.length === 0 ? undefined : texts.join("\n");
58
+ }
59
+ function formatDuration(ms) {
60
+ if (ms === undefined || !Number.isFinite(ms))
61
+ return "";
62
+ return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
63
+ }
64
+ export const NESTED_CALLS_EXPANDED_KEY = "__pi_ext_ui_nested_expanded__";
65
+ export const NESTED_CALLS_COPIED_KEY = "__pi_ext_ui_nested_copied__";
66
+ export class NestedCallsView {
67
+ #props;
68
+ #inMemoryLevels = new Map();
69
+ #copiedIndex = undefined;
70
+ #copiedTimer = undefined;
71
+ #lineMapping = [];
72
+ #renderCache;
73
+ constructor(props) {
74
+ this.#props = props;
75
+ const state = props.stateHolder;
76
+ if (state !== undefined) {
77
+ const stored = state[NESTED_CALLS_EXPANDED_KEY];
78
+ if (isRecord(stored)) {
79
+ for (const [k, v] of Object.entries(stored)) {
80
+ const idx = Number(k);
81
+ if (!Number.isNaN(idx) && (v === 1 || v === 2)) {
82
+ this.#inMemoryLevels.set(idx, v);
83
+ }
84
+ }
85
+ }
86
+ else if (Array.isArray(stored)) {
87
+ for (const item of stored) {
88
+ if (typeof item === "number") {
89
+ this.#inMemoryLevels.set(item, 1);
90
+ }
91
+ }
92
+ }
93
+ const copiedState = state[NESTED_CALLS_COPIED_KEY];
94
+ if (isRecord(copiedState) &&
95
+ typeof copiedState.index === "number" &&
96
+ typeof copiedState.until === "number") {
97
+ const remaining = copiedState.until - Date.now();
98
+ if (remaining > 0) {
99
+ this.#copiedIndex = copiedState.index;
100
+ this.#copiedTimer = setTimeout(() => {
101
+ this.#copiedIndex = undefined;
102
+ this.#renderCache = undefined;
103
+ const s = this.#props.stateHolder;
104
+ if (s !== undefined) {
105
+ delete s[NESTED_CALLS_COPIED_KEY];
106
+ }
107
+ this.#props.invalidate?.();
108
+ }, remaining);
109
+ this.#copiedTimer.unref?.();
110
+ }
111
+ else {
112
+ delete state[NESTED_CALLS_COPIED_KEY];
113
+ }
114
+ }
115
+ }
116
+ }
117
+ #getExpansionLevel(index) {
118
+ const state = this.#props.stateHolder;
119
+ if (state !== undefined) {
120
+ const stored = state[NESTED_CALLS_EXPANDED_KEY];
121
+ if (isRecord(stored)) {
122
+ const val = stored[String(index)];
123
+ if (val === 1 || val === 2)
124
+ return val;
125
+ }
126
+ else if (Array.isArray(stored)) {
127
+ if (stored.includes(index))
128
+ return 1;
129
+ }
130
+ }
131
+ return this.#inMemoryLevels.get(index) ?? 0;
132
+ }
133
+ #getLevelsKey() {
134
+ const parts = [];
135
+ for (let i = 0; i < this.#props.calls.length; i++) {
136
+ const level = this.#getExpansionLevel(i);
137
+ if (level !== 0)
138
+ parts.push(`${i}:${level}`);
139
+ }
140
+ return parts.join(",");
141
+ }
142
+ #setExpansionLevel(index, level) {
143
+ if (level === 0) {
144
+ this.#inMemoryLevels.delete(index);
145
+ }
146
+ else {
147
+ this.#inMemoryLevels.set(index, level);
148
+ }
149
+ this.#renderCache = undefined;
150
+ const state = this.#props.stateHolder;
151
+ if (state !== undefined) {
152
+ const record = {};
153
+ for (const [k, v] of this.#inMemoryLevels.entries()) {
154
+ record[String(k)] = v;
155
+ }
156
+ state[NESTED_CALLS_EXPANDED_KEY] = record;
157
+ }
158
+ }
159
+ /**
160
+ * The render context of one nested call. A nested call has no host-mounted component, so its
161
+ * context is the parent's with the child's own args, id, duration and failure state.
162
+ */
163
+ #callContext(call) {
164
+ const { context } = this.#props;
165
+ const toolCallId = "toolCallId" in call && typeof call.toolCallId === "string"
166
+ ? call.toolCallId
167
+ : context.toolCallId;
168
+ return {
169
+ ...context,
170
+ args: "args" in call ? call.args : undefined,
171
+ toolCallId,
172
+ durationMs: call.durationMs,
173
+ isError: Boolean(call.isError || ("status" in call && call.status === "error")),
174
+ isPartial: false,
175
+ argsComplete: true,
176
+ };
177
+ }
178
+ #triggerCopy(callIndex, full) {
179
+ const call = this.#props.calls[callIndex];
180
+ if (!call)
181
+ return;
182
+ const provider = this.#props.resolvePresentation?.(call.toolName);
183
+ const presentation = provider?.("args" in call ? call.args : undefined, "result" in call ? call.result : undefined, this.#props.theme, this.#callContext(call));
184
+ const text = stripTerminalSequences((full ? presentation?.copy?.full : presentation?.copy?.primary) ??
185
+ extractChildToolCopyText(call, full));
186
+ if (!text)
187
+ return;
188
+ const writeClip = this.#props.copyToClipboard ?? copyToSystemClipboard;
189
+ void writeClip(text).catch(() => { });
190
+ const durationMs = 1500;
191
+ const until = Date.now() + durationMs;
192
+ this.#copiedIndex = callIndex;
193
+ this.#renderCache = undefined;
194
+ const state = this.#props.stateHolder;
195
+ if (state !== undefined) {
196
+ state[NESTED_CALLS_COPIED_KEY] = {
197
+ index: callIndex,
198
+ until,
199
+ };
200
+ }
201
+ if (this.#copiedTimer !== undefined) {
202
+ clearTimeout(this.#copiedTimer);
203
+ }
204
+ this.#copiedTimer = setTimeout(() => {
205
+ this.#copiedIndex = undefined;
206
+ this.#renderCache = undefined;
207
+ const s = this.#props.stateHolder;
208
+ if (s !== undefined) {
209
+ delete s[NESTED_CALLS_COPIED_KEY];
210
+ }
211
+ this.#props.invalidate?.();
212
+ }, durationMs);
213
+ this.#copiedTimer.unref?.();
214
+ this.#props.invalidate?.();
215
+ }
216
+ render(width) {
217
+ const safeWidth = Math.max(1, width);
218
+ const levelsKey = this.#getLevelsKey();
219
+ if (this.#renderCache !== undefined &&
220
+ this.#renderCache.width === safeWidth &&
221
+ this.#renderCache.copiedIndex === this.#copiedIndex &&
222
+ this.#renderCache.levelsKey === levelsKey) {
223
+ this.#lineMapping = this.#renderCache.lineMapping;
224
+ return this.#renderCache.lines;
225
+ }
226
+ this.#lineMapping = [];
227
+ const { calls, theme } = this.#props;
228
+ const lines = [];
229
+ const rightMargin = 1;
230
+ for (let i = 0; i < calls.length; i++) {
231
+ const call = calls[i];
232
+ if (!call)
233
+ continue;
234
+ const toolName = "toolName" in call ? call.toolName : call.name;
235
+ const isError = Boolean(call.isError || ("status" in call && call.status === "error"));
236
+ const isRunning = ("status" in call && call.status === "running") ||
237
+ (!("status" in call) && call.result === undefined);
238
+ const expansionLevel = this.#getExpansionLevel(i);
239
+ const isExpanded = expansionLevel !== 0;
240
+ const rawSummary = "summary" in call ? call.summary : undefined;
241
+ const rawArgs = "args" in call ? call.args : undefined;
242
+ const inline = inlinePresentation(toolName, rawArgs ?? rawSummary, "result" in call ? call.result?.details : undefined, isError);
243
+ const argText = typeof rawArgs === "string" ? rawArgs : typeof rawSummary === "string" ? rawSummary : "";
244
+ const provider = this.#props.resolvePresentation?.(toolName);
245
+ const presentation = provider?.(rawArgs, "result" in call ? call.result : undefined, theme, this.#callContext(call));
246
+ // The child row is the child's own header minus the fold pin, so it carries the same token
247
+ // assignment as the row the same call would draw at top level.
248
+ const childSummary = stripTerminalSequences(presentation?.summary ?? inline?.summary ?? argText);
249
+ const duration = formatDuration(call.durationMs);
250
+ const leftContent = formatToolHeaderText({
251
+ status: isRunning ? "running" : isError ? "error" : "ok",
252
+ title: toolName,
253
+ summary: [childSummary, duration].filter(Boolean).join(" · "),
254
+ }, theme);
255
+ const isCopied = this.#copiedIndex === i;
256
+ const copyIcon = isCopied ? theme.fg("success", "✓") : theme.fg("dim", "󰆏");
257
+ const copySlotWidth = 2 + rightMargin;
258
+ const maxLeft = Math.max(1, width - copySlotWidth);
259
+ const truncatedLeft = truncate(leftContent, maxLeft);
260
+ const leftVis = visibleWidth(truncatedLeft);
261
+ const gap = " ".repeat(Math.max(1, width - leftVis - rightMargin - 1));
262
+ const headerLine = truncate(truncatedLeft + gap + copyIcon + " ".repeat(rightMargin), width);
263
+ const headerLineIdx = lines.length;
264
+ lines.push(headerLine);
265
+ this.#lineMapping[headerLineIdx] = {
266
+ callIndex: i,
267
+ isHeader: true,
268
+ copyRegion: { startX: width - copySlotWidth - 1, endX: width },
269
+ };
270
+ if (isExpanded) {
271
+ const bodyLines = this.#renderChildBody(call, Math.max(1, width - 2), expansionLevel);
272
+ for (const rawLine of bodyLines) {
273
+ for (const bodyLine of toContentRows(rawLine)) {
274
+ const bodyLineIdx = lines.length;
275
+ lines.push(truncate(` ${bodyLine}`, width));
276
+ this.#lineMapping[bodyLineIdx] = {
277
+ callIndex: i,
278
+ isHeader: false,
279
+ };
280
+ }
281
+ }
282
+ }
283
+ }
284
+ this.#renderCache = {
285
+ width: safeWidth,
286
+ copiedIndex: this.#copiedIndex,
287
+ levelsKey,
288
+ lines,
289
+ lineMapping: this.#lineMapping,
290
+ };
291
+ return lines;
292
+ }
293
+ #renderChildBody(call, width, expansionLevel = 1) {
294
+ const { theme, resolveChildRenderer } = this.#props;
295
+ const toolName = "toolName" in call ? call.toolName : call.name;
296
+ const result = "result" in call ? call.result : undefined;
297
+ const args = "args" in call ? call.args : undefined;
298
+ if (result && resolveChildRenderer) {
299
+ const renderer = resolveChildRenderer(toolName);
300
+ if (renderer?.renderResult) {
301
+ try {
302
+ const childContext = {
303
+ ...this.#callContext(call),
304
+ state: undefined,
305
+ };
306
+ const comp = renderer.renderResult(result, { expanded: expansionLevel === 2, isPartial: false }, theme, childContext);
307
+ if (comp instanceof ToolView) {
308
+ comp.setFoldLevel(expansionLevel);
309
+ // Render body cleanly via deep headless interface without outer rail or header row
310
+ return comp.renderBody(width, { noRail: true });
311
+ }
312
+ return comp.render(width);
313
+ }
314
+ catch {
315
+ // Fall through to plain text extraction on renderer failure
316
+ }
317
+ }
318
+ }
319
+ // Fallback rendering
320
+ if (result && Array.isArray(result.content)) {
321
+ const text = result.content
322
+ .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
323
+ .map((c) => c.text)
324
+ .join("\n")
325
+ .trim();
326
+ if (text) {
327
+ let lines;
328
+ const filePath = typeof args === "object" &&
329
+ args !== null &&
330
+ "path" in args &&
331
+ typeof args.path === "string"
332
+ ? args.path
333
+ : "";
334
+ const lang = filePath ? getLanguageFromPath(filePath) : undefined;
335
+ if (lang) {
336
+ try {
337
+ lines = highlightCode(text, lang);
338
+ }
339
+ catch {
340
+ lines = text.split("\n");
341
+ }
342
+ }
343
+ else {
344
+ lines = text.split("\n");
345
+ }
346
+ if (expansionLevel === 2 || lines.length <= 20) {
347
+ return lines;
348
+ }
349
+ return [...lines.slice(0, 20), `... (${lines.length - 20} more lines)`];
350
+ }
351
+ }
352
+ if ("error" in call && typeof call.error === "string" && call.error) {
353
+ return call.error.split("\n").map((l) => theme.fg("error", l));
354
+ }
355
+ if (args !== undefined) {
356
+ const text = typeof args === "string" ? args : JSON.stringify(args, null, 2);
357
+ return text.split("\n").map((l) => theme.fg("dim", l));
358
+ }
359
+ return [theme.fg("dim", "(no output)")];
360
+ }
361
+ handleMouse(event) {
362
+ if (event.type !== "click" || event.button !== "left")
363
+ return undefined;
364
+ const mapping = this.#lineMapping[event.y];
365
+ if (!mapping)
366
+ return { handled: true };
367
+ if (mapping.isHeader) {
368
+ if (mapping.copyRegion && event.x >= mapping.copyRegion.startX) {
369
+ this.#triggerCopy(mapping.callIndex, event.shift === true);
370
+ return { handled: true, render: true };
371
+ }
372
+ const isCtrl = event.ctrl === true;
373
+ const currentLevel = this.#getExpansionLevel(mapping.callIndex);
374
+ const nextLevel = transitionFoldLevel(currentLevel, isCtrl);
375
+ this.#setExpansionLevel(mapping.callIndex, nextLevel);
376
+ this.#props.invalidate?.();
377
+ return { handled: true, render: true };
378
+ }
379
+ return { handled: true };
380
+ }
381
+ dispose() {
382
+ if (this.#copiedTimer !== undefined) {
383
+ clearTimeout(this.#copiedTimer);
384
+ this.#copiedTimer = undefined;
385
+ }
386
+ this.#renderCache = undefined;
387
+ }
388
+ collapseAll() {
389
+ if (this.#copiedTimer !== undefined) {
390
+ clearTimeout(this.#copiedTimer);
391
+ this.#copiedTimer = undefined;
392
+ }
393
+ this.#inMemoryLevels.clear();
394
+ const state = this.#props.stateHolder;
395
+ if (state !== undefined) {
396
+ state[NESTED_CALLS_EXPANDED_KEY] = {};
397
+ }
398
+ this.#lineMapping = [];
399
+ this.#renderCache = undefined;
400
+ }
401
+ invalidate() {
402
+ this.#lineMapping = [];
403
+ this.#renderCache = undefined;
404
+ }
405
+ }