@hheei/pi-ext-ui 0.6.0 → 0.7.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.
@@ -1,4 +1,5 @@
1
- import { truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
1
+ import { Box, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
+ import { backgroundFill } from "./background.js";
2
3
  import { toContentRows, toSingleRow } from "./tool-view.js";
3
4
  /**
4
5
  * A transcript notice, not a tool execution.
@@ -59,10 +60,22 @@ export class MessageView {
59
60
  #localExpanded;
60
61
  #lastWidth = 80;
61
62
  #lastHeight = 0;
63
+ /** The final rows the host draws: the background shell's output, not the pre-shell lines. */
62
64
  #cache;
65
+ /** The rows the shell currently fills; replaced on every layout. */
66
+ #shellRows = [];
67
+ /**
68
+ * The block background a standalone message owns. Pi's custom-message shell only draws its own
69
+ * `customMessageBg` box when no renderer is installed, so this view supplies the same block
70
+ * itself. Inside an activity group the rows come from `previewRows`/`bodyRows` and keep the
71
+ * group's background instead.
72
+ */
73
+ #shell = new Box(0, 0);
63
74
  constructor(spec) {
64
75
  this.#spec = spec;
65
76
  this.#localExpanded = remembered(spec.stateHolder, spec.defaultExpanded === true);
77
+ this.#shell.addChild({ render: () => this.#shellRows, invalidate() { } });
78
+ this.#shell.setBgFn(backgroundFill(spec.theme, "customMessageBg"));
66
79
  }
67
80
  get expandable() {
68
81
  return hasRows(this.#spec.body);
@@ -91,17 +104,20 @@ export class MessageView {
91
104
  const safeWidth = Math.max(1, width - pad * 2);
92
105
  this.#lastWidth = width;
93
106
  const leftPad = pad > 0 ? " ".repeat(pad) : "";
94
- const lines = [
107
+ this.#shellRows = [
95
108
  this.#header(safeWidth, expanded),
96
109
  ...layRows(this.#spec.preview, safeWidth),
97
110
  ...(expanded ? layRows(this.#spec.body, safeWidth) : []),
98
111
  ].map((line) => (leftPad === "" ? line : `${leftPad}${line}`));
99
- this.#lastHeight = lines.length;
112
+ this.#lastHeight = this.#shellRows.length;
113
+ // Cache the shell's output: a cached hit must return the same filled, padded rows.
114
+ const lines = this.#shell.render(width);
100
115
  this.#cache = { width, expanded, lines };
101
116
  return lines;
102
117
  }
103
118
  invalidate() {
104
119
  this.#cache = undefined;
120
+ this.#shell.invalidate();
105
121
  invalidateRows(this.#spec.preview);
106
122
  invalidateRows(this.#spec.body);
107
123
  }
@@ -125,15 +141,19 @@ export class MessageView {
125
141
  get kind() {
126
142
  return this.#spec.header.kind;
127
143
  }
128
- /**
129
- * Detail rows inside an activity group: preview and body below the summary row the group already
130
- * draws, regardless of local fold. Repeating the header there would print the summary twice.
131
- */
132
- detailRows(width) {
144
+ /** Preview rows inside an activity group: visible content below the summary, folded or not. */
145
+ previewRows(width) {
146
+ return this.#rows(this.#spec.preview, width);
147
+ }
148
+ /** Body rows inside an activity group: what opening this notice reveals. */
149
+ bodyRows(width) {
150
+ return this.#rows(this.#spec.body, width);
151
+ }
152
+ #rows(source, width) {
133
153
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
134
154
  const safeWidth = Math.max(1, width - pad * 2);
135
155
  const leftPad = pad > 0 ? " ".repeat(pad) : "";
136
- return [...layRows(this.#spec.preview, safeWidth), ...layRows(this.#spec.body, safeWidth)].map((line) => (leftPad === "" ? line : `${leftPad}${line}`));
156
+ return layRows(source, safeWidth).map((line) => (leftPad === "" ? line : `${leftPad}${line}`));
137
157
  }
138
158
  /** Single-row label used when this notice sits inside an activity group. */
139
159
  get activitySummary() {
@@ -143,13 +163,6 @@ export class MessageView {
143
163
  const summary = summaryRaw === "" ? "" : theme.fg(header.tone ?? "dim", summaryRaw);
144
164
  return summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
145
165
  }
146
- /**
147
- * Whether anything would render below the label. `expandable` asks about the body only; a
148
- * preview-only notice still has detail to show, so the group keys its pin on this instead.
149
- */
150
- get hasDetail() {
151
- return hasRows(this.#spec.preview) || hasRows(this.#spec.body);
152
- }
153
166
  #header(width, expanded) {
154
167
  const pin = this.expandable ? `${this.#spec.theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
155
168
  return truncate(`${pin}${this.activitySummary}`, width);
@@ -0,0 +1,69 @@
1
+ import { type AgentToolResult, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type NestedCallRecord } from "./payload-parser.js";
3
+ import type { ToolStatusKind } from "./tool-view.js";
4
+ import type { CapturedToolCall } from "./tracker.js";
5
+ /**
6
+ * The single owner of Pi's own tool vocabulary in this UI. Concrete tool packages state their own
7
+ * semantics through ext-core's presentation point; this module is the explicit adapter for the tools
8
+ * Pi itself ships (read, write, edit, bash, grep, find, codemode, python_eval and the background and
9
+ * subagent helpers), so a top-level row, a nested row and a restored row read the same facts.
10
+ */
11
+ export declare const EDIT_TOOL_NAME = "edit";
12
+ export declare const WRITE_TOOL_NAME = "write";
13
+ export declare const CODEMODE_OPTIONS_PREFIX = "// @options:";
14
+ export declare function toolPath(args: unknown): string;
15
+ /**
16
+ * A native tool's header summary. read states its own tokens, bash keeps its command as the row's
17
+ * content, and the rest state their argument vocabulary; undefined means the frame falls back to the
18
+ * generic argument line.
19
+ */
20
+ export interface NativeHeaderSummary {
21
+ readonly summary: string;
22
+ /** Whether the summary already carries the tool's own colors. */
23
+ readonly styled: boolean;
24
+ /** Whether secondary facts stay in the summary or move to the dim meta slot. */
25
+ readonly factsInSummary: boolean;
26
+ }
27
+ export declare function nativeHeaderSummary(toolName: string, args: unknown, theme: Theme, cwd: string): NativeHeaderSummary | undefined;
28
+ /** The syntax a tool's request is written in, for display only. */
29
+ export declare function nativeCodeLanguage(toolName: string): string | undefined;
30
+ /** The language a tool's own output is written in, when the tool states it through its arguments. */
31
+ export declare function nativeOutputLanguage(toolName: string, args: unknown): string | undefined;
32
+ /** A deadline is a secondary fact about a call, not part of what the tool was asked to do. */
33
+ export declare function nativeTimeoutFact(toolName: string, args: unknown): string | undefined;
34
+ /** The request block a call is drawn from: its bytes, its syntax, and whether it is a shell command. */
35
+ export interface NativeRequestBlock {
36
+ readonly text: string;
37
+ readonly language: string | undefined;
38
+ readonly shell: boolean;
39
+ }
40
+ export declare function nativeRequestBlock(toolName: string, args: unknown): NativeRequestBlock | undefined;
41
+ /**
42
+ * The request a heavy-input tool received, byte for byte: a copy of it must not trim or sanitize
43
+ * model-controlled text. Only an all-whitespace request has no body, so it reads as no request.
44
+ */
45
+ export declare function nativeHeavyRequest(toolName: string, args: unknown): string | undefined;
46
+ export declare function nativeSummaryArg(toolName: string, args: unknown): string;
47
+ /** The one-row result text a native tool reports, plus the wall time codemode states in its header. */
48
+ export declare function nativeResultText(toolName: string, result: AgentToolResult<unknown>): {
49
+ readonly outputText: string;
50
+ readonly wallTimeSeconds: number | undefined;
51
+ };
52
+ /** Compact nested-row result: a one-line summary plus the status it reports. */
53
+ export interface InlinePresentation {
54
+ readonly summary: string;
55
+ readonly status: ToolStatusKind;
56
+ }
57
+ /**
58
+ * One compact nested summary for a tool call. Adopts the tool's standard collapsed
59
+ * single-line format when recognized, degrading to undefined so callers can use
60
+ * the default argument rendering.
61
+ */
62
+ export declare function inlinePresentation(toolName: string, args: unknown, details: unknown, isError: boolean): InlinePresentation | undefined;
63
+ /**
64
+ * The request bytes one call carries. A record whose request the session truncated has none: copying
65
+ * the size-limited preview or the summary would present a partial request as the full one.
66
+ */
67
+ export declare function nativeRequestCopy(call: CapturedToolCall | NestedCallRecord): string | undefined;
68
+ /** What a child row copies: its own request when it has one, otherwise the result it produced. */
69
+ export declare function extractChildToolCopyText(call: CapturedToolCall | NestedCallRecord, full?: boolean): string;
@@ -0,0 +1,414 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { getLanguageFromPath, } from "@earendil-works/pi-coding-agent";
5
+ import { getCapabilities, hyperlink, stripTerminalSequences } from "@earendil-works/pi-tui";
6
+ import { diffStats } from "./diff-view.js";
7
+ import { formatTimeoutFact, isRecord, parseCallArgs, } from "./payload-parser.js";
8
+ /**
9
+ * The single owner of Pi's own tool vocabulary in this UI. Concrete tool packages state their own
10
+ * semantics through ext-core's presentation point; this module is the explicit adapter for the tools
11
+ * Pi itself ships (read, write, edit, bash, grep, find, codemode, python_eval and the background and
12
+ * subagent helpers), so a top-level row, a nested row and a restored row read the same facts.
13
+ */
14
+ export const EDIT_TOOL_NAME = "edit";
15
+ export const WRITE_TOOL_NAME = "write";
16
+ export const CODEMODE_OPTIONS_PREFIX = "// @options:";
17
+ const CODEMODE_SCRIPT_HEADER = /^Script (?:completed|failed)\r?\nWall time ([\d.]+) seconds\r?\nOutput:\r?\n/;
18
+ // Pi <1.3 has no exported read-range formatter. Keep this UI summary independent of that export.
19
+ function readLineNumber(value) {
20
+ const parsed = typeof value === "number"
21
+ ? value
22
+ : typeof value === "string" && value.trim() !== ""
23
+ ? Number(value)
24
+ : Number.NaN;
25
+ return Number.isFinite(parsed) ? parsed : undefined;
26
+ }
27
+ /** The read line range as the native row draws it (`:12`, `:12-40`), or empty. */
28
+ function readLineRange(args) {
29
+ const parsed = parseCallArgs(args);
30
+ const offset = parsed?.offset;
31
+ const limit = parsed?.limit;
32
+ if (offset == null && limit == null)
33
+ return "";
34
+ const start = offset == null ? 1 : readLineNumber(offset);
35
+ if (start === undefined)
36
+ return "";
37
+ if (limit == null)
38
+ return `:${start}`;
39
+ const count = readLineNumber(limit);
40
+ if (count === undefined)
41
+ return "";
42
+ const end = start + count - 1;
43
+ return `:${start}${end ? `-${end}` : ""}`;
44
+ }
45
+ function shortenHome(path) {
46
+ const home = homedir();
47
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
48
+ }
49
+ export function toolPath(args) {
50
+ const parsed = parseCallArgs(args);
51
+ if (!parsed)
52
+ return "";
53
+ const val = parsed.path ?? parsed.file_path;
54
+ return typeof val === "string" ? val.trim() : "";
55
+ }
56
+ /**
57
+ * read's header summary with the tokens the native read row uses: the accent path, linked to the
58
+ * file when the terminal draws OSC 8 links, followed by its warning line range.
59
+ */
60
+ function readHeaderSummary(args, theme, cwd) {
61
+ const path = toolPath(args);
62
+ if (path === "")
63
+ return "";
64
+ const label = theme.fg("accent", shortenHome(path));
65
+ const linked = getCapabilities().hyperlinks
66
+ ? hyperlink(label, pathToFileURL(resolve(cwd, path)).href)
67
+ : label;
68
+ const range = readLineRange(args);
69
+ return `${linked}${range === "" ? "" : theme.fg("warning", range)}`;
70
+ }
71
+ export function nativeHeaderSummary(toolName, args, theme, cwd) {
72
+ if (toolName === "read") {
73
+ const semantic = readHeaderSummary(args, theme, cwd);
74
+ return semantic === "" ? undefined : { summary: semantic, styled: true, factsInSummary: false };
75
+ }
76
+ if (toolName === "bash") {
77
+ return { summary: nativeSummaryArg(toolName, args), styled: false, factsInSummary: false };
78
+ }
79
+ const summary = nativeSummaryArg(toolName, args);
80
+ return summary === "" ? undefined : { summary, styled: false, factsInSummary: true };
81
+ }
82
+ /** The syntax a tool's request is written in, for display only. */
83
+ export function nativeCodeLanguage(toolName) {
84
+ if (toolName === "bash")
85
+ return "bash";
86
+ if (toolName === "codemode")
87
+ return "javascript";
88
+ if (toolName === "python_eval")
89
+ return "python";
90
+ return undefined;
91
+ }
92
+ /** The language a tool's own output is written in, when the tool states it through its arguments. */
93
+ export function nativeOutputLanguage(toolName, args) {
94
+ if (toolName !== "read")
95
+ return undefined;
96
+ const path = toolPath(args);
97
+ return path === "" ? undefined : getLanguageFromPath(path);
98
+ }
99
+ /** A deadline is a secondary fact about a call, not part of what the tool was asked to do. */
100
+ export function nativeTimeoutFact(toolName, args) {
101
+ if (toolName !== "bash")
102
+ return undefined;
103
+ const timeout = isRecord(args) ? args.timeout : undefined;
104
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)
105
+ return undefined;
106
+ return formatTimeoutFact(timeout * 1000);
107
+ }
108
+ export function nativeRequestBlock(toolName, args) {
109
+ const text = nativeHeavyRequest(toolName, args);
110
+ if (text === undefined)
111
+ return undefined;
112
+ return { text, language: nativeCodeLanguage(toolName), shell: toolName === "bash" };
113
+ }
114
+ /**
115
+ * The request a heavy-input tool received, byte for byte: a copy of it must not trim or sanitize
116
+ * model-controlled text. Only an all-whitespace request has no body, so it reads as no request.
117
+ */
118
+ export function nativeHeavyRequest(toolName, args) {
119
+ if (!isRecord(args))
120
+ return undefined;
121
+ const raw = toolName === "bash" && typeof args.command === "string"
122
+ ? args.command
123
+ : toolName === "python_eval" && typeof args.code === "string"
124
+ ? args.code
125
+ : toolName === "codemode" && typeof args.code === "string"
126
+ ? args.code
127
+ : undefined;
128
+ return raw === undefined || raw.trim() === "" ? undefined : raw;
129
+ }
130
+ export function nativeSummaryArg(toolName, args) {
131
+ if (toolName === "codemode") {
132
+ return extractCodemodeHeaderFacts(args);
133
+ }
134
+ const parsed = parseCallArgs(args);
135
+ if (!parsed)
136
+ return "";
137
+ const firstLine = (val) => {
138
+ if (typeof val !== "string" || val.trim() === "")
139
+ return "";
140
+ const clean = stripTerminalSequences(val.trim());
141
+ return clean.split("\n")[0]?.trim() ?? clean;
142
+ };
143
+ switch (toolName) {
144
+ case "bash":
145
+ case "bg_bash_spawn":
146
+ return firstLine(parsed.command);
147
+ case "read":
148
+ case "edit":
149
+ case "write":
150
+ case "ls": {
151
+ const path = typeof parsed.path === "string"
152
+ ? parsed.path.trim()
153
+ : typeof parsed.file_path === "string"
154
+ ? parsed.file_path.trim()
155
+ : "";
156
+ return stripTerminalSequences(path);
157
+ }
158
+ case "grep":
159
+ case "find": {
160
+ const pattern = typeof parsed.pattern === "string" ? parsed.pattern.trim() : "";
161
+ const path = typeof parsed.path === "string" ? parsed.path.trim() : "";
162
+ if (!pattern)
163
+ return path ? stripTerminalSequences(path) : "";
164
+ const target = path && path !== "." ? ` ${path}` : "";
165
+ return `${pattern}${target}`;
166
+ }
167
+ case "python_eval":
168
+ return firstLine(parsed.code);
169
+ case "bg_bash_get":
170
+ case "bg_bash_stop":
171
+ case "agents_get":
172
+ case "agents_stop":
173
+ return typeof parsed.id === "string" ? parsed.id.trim() : "";
174
+ case "agents_delegate": {
175
+ const agent = typeof parsed.agent === "string" ? parsed.agent.trim() : "";
176
+ const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
177
+ const task = firstLine(parsed.task);
178
+ if (agent && task)
179
+ return `${agent}: ${task}`;
180
+ return agent || id || task;
181
+ }
182
+ case "tool_search":
183
+ return typeof parsed.query === "string" ? parsed.query.trim() : "";
184
+ default:
185
+ return "";
186
+ }
187
+ }
188
+ /** codemode states its own options in the request's first line; they are header facts, not content. */
189
+ function extractCodemodeHeaderFacts(args) {
190
+ if (!isRecord(args))
191
+ return "";
192
+ const options = {};
193
+ for (const [key, value] of Object.entries(args)) {
194
+ if (key !== "code" && value !== undefined && value !== null) {
195
+ options[key] = value;
196
+ }
197
+ }
198
+ if (typeof args.code === "string") {
199
+ const firstLine = args.code.split(/\r?\n/, 1)[0]?.trim() ?? "";
200
+ if (firstLine.startsWith(CODEMODE_OPTIONS_PREFIX)) {
201
+ try {
202
+ const parsed = JSON.parse(firstLine.slice(CODEMODE_OPTIONS_PREFIX.length).trim());
203
+ if (isRecord(parsed)) {
204
+ Object.assign(options, parsed);
205
+ }
206
+ }
207
+ catch { }
208
+ }
209
+ }
210
+ const facts = [];
211
+ const timeoutMs = typeof options.timeout_ms === "number" ? options.timeout_ms : undefined;
212
+ if (timeoutMs !== undefined)
213
+ facts.push(formatTimeoutFact(timeoutMs));
214
+ if (typeof options.max_output_tokens === "number") {
215
+ facts.push(`(tokens ${String(options.max_output_tokens)})`);
216
+ }
217
+ if (typeof options.target === "string" && options.target !== "local") {
218
+ facts.push(`(${options.target})`);
219
+ }
220
+ return facts.join(" ");
221
+ }
222
+ /** The one-row result text a native tool reports, plus the wall time codemode states in its header. */
223
+ export function nativeResultText(toolName, result) {
224
+ const parts = [];
225
+ for (const block of result.content) {
226
+ if (block.type === "text" && block.text.trim() !== "") {
227
+ parts.push(block.text);
228
+ }
229
+ }
230
+ const joined = parts.join("\n");
231
+ if (toolName === "codemode") {
232
+ const stripped = stripTerminalSequences(joined);
233
+ const match = CODEMODE_SCRIPT_HEADER.exec(stripped);
234
+ const wallTimeSeconds = match?.[1] !== undefined ? Number.parseFloat(match[1]) : undefined;
235
+ const cleaned = joined.replace(CODEMODE_SCRIPT_HEADER, "").trim();
236
+ return { outputText: cleaned, wallTimeSeconds };
237
+ }
238
+ return { outputText: joined, wallTimeSeconds: undefined };
239
+ }
240
+ function fileToolPresentation(toolName, args, _details, isError) {
241
+ const path = toolPath(args);
242
+ if (toolName === "read") {
243
+ if (path === "")
244
+ return undefined;
245
+ return { summary: `${path}${readLineRange(args)}`, status: isError ? "error" : "ok" };
246
+ }
247
+ if (toolName === WRITE_TOOL_NAME) {
248
+ return path === "" ? undefined : { summary: path, status: isError ? "error" : "ok" };
249
+ }
250
+ return undefined;
251
+ }
252
+ /** Pi native edit persists a unified patch plus the same change as a numbered display diff. */
253
+ function nativeDiffSource(details) {
254
+ if (!isRecord(details))
255
+ return undefined;
256
+ if (typeof details.patch === "string" && details.patch.trim() !== "") {
257
+ return { kind: "unifiedPatch", text: details.patch };
258
+ }
259
+ if (typeof details.diff === "string" && details.diff.trim() !== "") {
260
+ return displayDiffSource(details.diff);
261
+ }
262
+ return undefined;
263
+ }
264
+ function displayDiffSource(diff) {
265
+ const hunks = [];
266
+ let current = [];
267
+ let pendingBreak = false;
268
+ for (const raw of diff.split(/\r?\n/)) {
269
+ if (raw === "")
270
+ continue;
271
+ const sign = raw.charAt(0);
272
+ const match = /^ *(\d+)? ?(.*)$/.exec(raw.slice(1));
273
+ const lineNumber = match?.[1];
274
+ if (lineNumber === undefined) {
275
+ pendingBreak = true;
276
+ continue;
277
+ }
278
+ if (pendingBreak && current.length > 0) {
279
+ hunks.push(current);
280
+ current = [];
281
+ }
282
+ pendingBreak = false;
283
+ const kind = sign === "+" ? "add" : sign === "-" ? "del" : "ctx";
284
+ current.push({ kind, line: Number(lineNumber), text: match?.[2] ?? "" });
285
+ }
286
+ if (current.length === 0)
287
+ return undefined;
288
+ hunks.push(current);
289
+ return { kind: "hunks", hunks: hunks.map((lines) => ({ lines })) };
290
+ }
291
+ /**
292
+ * The name-free part of a summary for tools whose call arguments read well on their own. The tool
293
+ * name is the row's title, so repeating it here would print it twice.
294
+ */
295
+ function formatArgumentSummary(toolName, parsed) {
296
+ const path = typeof parsed.path === "string"
297
+ ? parsed.path.trim()
298
+ : typeof parsed.file_path === "string"
299
+ ? parsed.file_path.trim()
300
+ : "";
301
+ switch (toolName) {
302
+ case "bash": {
303
+ // The row states model-controlled text: strip before cutting so no half sequence survives.
304
+ const cmd = typeof parsed.command === "string" ? stripTerminalSequences(parsed.command.trim()) : "";
305
+ if (!cmd)
306
+ return undefined;
307
+ return cmd.length > 70 ? `${cmd.slice(0, 67)}…` : cmd;
308
+ }
309
+ case "grep":
310
+ case "find": {
311
+ const pattern = typeof parsed.pattern === "string" ? parsed.pattern.trim() : "";
312
+ if (!pattern)
313
+ return undefined;
314
+ const target = path && path !== "." ? ` ${path}` : "";
315
+ return `${pattern}${target}`;
316
+ }
317
+ default: {
318
+ const keyArg = nativeSummaryArg(toolName, parsed);
319
+ return keyArg ? keyArg : undefined;
320
+ }
321
+ }
322
+ }
323
+ /**
324
+ * One compact nested summary for a tool call. Adopts the tool's standard collapsed
325
+ * single-line format when recognized, degrading to undefined so callers can use
326
+ * the default argument rendering.
327
+ */
328
+ export function inlinePresentation(toolName, args, details, isError) {
329
+ const fileTool = fileToolPresentation(toolName, args, details, isError);
330
+ if (fileTool !== undefined)
331
+ return fileTool;
332
+ const parsed = parseCallArgs(args);
333
+ if (parsed === undefined)
334
+ return undefined;
335
+ if (toolName === EDIT_TOOL_NAME) {
336
+ const path = toolPath(parsed);
337
+ const source = isError ? undefined : nativeDiffSource(details);
338
+ const stats = source === undefined ? undefined : diffStats(source);
339
+ if (path === "" && stats === undefined)
340
+ return undefined;
341
+ return {
342
+ summary: stats === undefined
343
+ ? path
344
+ : `${path} (+${String(stats.added)} -${String(stats.removed)})`.trim(),
345
+ status: isError ? "error" : "ok",
346
+ };
347
+ }
348
+ const baseSummary = formatArgumentSummary(toolName, parsed);
349
+ if (baseSummary === undefined)
350
+ return undefined;
351
+ return {
352
+ summary: baseSummary,
353
+ status: isError ? "error" : "ok",
354
+ };
355
+ }
356
+ /**
357
+ * The request bytes one call carries. A record whose request the session truncated has none: copying
358
+ * the size-limited preview or the summary would present a partial request as the full one.
359
+ */
360
+ export function nativeRequestCopy(call) {
361
+ if ("argsTruncated" in call && call.argsTruncated === true)
362
+ return undefined;
363
+ const args = "args" in call ? call.args : undefined;
364
+ if (!isRecord(args))
365
+ return undefined;
366
+ const toolName = "toolName" in call ? call.toolName : call.name;
367
+ if (toolName === "write" && typeof args.content === "string")
368
+ return args.content;
369
+ if (toolName === "bash" && typeof args.command === "string")
370
+ return args.command;
371
+ if ((toolName === "codemode" || toolName === "python_eval") && typeof args.code === "string") {
372
+ return args.code;
373
+ }
374
+ return undefined;
375
+ }
376
+ function resultText(result) {
377
+ if (result === undefined || !Array.isArray(result.content))
378
+ return undefined;
379
+ const texts = result.content
380
+ .filter((c) => isRecord(c) && c.type === "text" && typeof c.text === "string")
381
+ .map((c) => c.text);
382
+ return texts.length === 0 ? undefined : texts.join("\n");
383
+ }
384
+ /** What a child row copies: its own request when it has one, otherwise the result it produced. */
385
+ export function extractChildToolCopyText(call, full) {
386
+ const toolName = "toolName" in call ? call.toolName : call.name;
387
+ const result = "result" in call ? call.result : undefined;
388
+ const args = "args" in call ? call.args : undefined;
389
+ const request = nativeRequestCopy(call);
390
+ if (request !== undefined) {
391
+ // write's request is the whole file: its result only confirms the write.
392
+ if (full !== true || toolName === WRITE_TOOL_NAME)
393
+ return request;
394
+ const output = resultText(result);
395
+ return output === undefined ? request : `${request}\n---\n${output}`;
396
+ }
397
+ if (result !== undefined) {
398
+ const text = resultText(result);
399
+ if (text !== undefined)
400
+ return text;
401
+ }
402
+ if (args !== undefined) {
403
+ if (typeof args === "string")
404
+ return args;
405
+ try {
406
+ return JSON.stringify(args, null, 2);
407
+ }
408
+ catch {
409
+ return String(args);
410
+ }
411
+ }
412
+ // A summary is a summary: without arguments or a result there is no request to copy.
413
+ return "";
414
+ }
@@ -1,16 +1,20 @@
1
1
  import { type Theme, type ToolRenderers } from "@earendil-works/pi-coding-agent";
2
2
  import { type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
3
3
  import type { ToolPresentationProvider } from "@hheei/pi-ext-core";
4
- import { type NestedCallRecord, type ToolRenderContext } from "./renderer.js";
4
+ import type { NestedCallRecord, ToolRenderContext } from "./renderer.js";
5
5
  import { type Collapsible } from "./tool-view.js";
6
6
  import type { CapturedToolCall } from "./tracker.js";
7
- export declare function extractChildToolCopyText(call: CapturedToolCall | NestedCallRecord, full?: boolean): string;
8
7
  export interface NestedCallsViewProps {
9
8
  readonly resolvePresentation?: ((toolName: string) => ToolPresentationProvider | undefined) | undefined;
10
9
  readonly calls: readonly (CapturedToolCall | NestedCallRecord)[];
11
10
  readonly theme: Theme;
12
11
  readonly context: ToolRenderContext;
13
12
  readonly resolveChildRenderer?: ((toolName: string) => ToolRenderers | undefined) | undefined;
13
+ /**
14
+ * The owning layer's themed summary for a child call. A board cannot recover a child's semantic
15
+ * tokens from text, so the layer that owns that tool states them here.
16
+ */
17
+ readonly headerSummary?: ((toolName: string, args: unknown, theme: Theme) => string | undefined) | undefined;
14
18
  readonly copyToClipboard?: ((text: string) => Promise<void>) | undefined;
15
19
  readonly stateHolder?: Record<string, unknown> | undefined;
16
20
  readonly invalidate?: (() => void) | undefined;