pi-minimal 0.4.0 → 0.5.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.
package/dist/tools.js CHANGED
@@ -2,7 +2,7 @@ import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui
2
2
  import { rowIsRunning } from "./activity.js";
3
3
  import * as chrome from "./chrome.js";
4
4
  import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput } from "./codemode-rows.js";
5
- import { diffWords } from "diff";
5
+ import { colorDiffLines, editDiffText } from "./edit-diff.js";
6
6
  import { createLiveWatch } from "./live-watch.js";
7
7
  import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
8
8
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
@@ -14,6 +14,8 @@ const rowIndent = () => margin() + 1;
14
14
  const EXPANDED_MAX_LINES = 400;
15
15
  /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
16
16
  const INLINE_DIFF_MAX_LINES = 40;
17
+ /** With `editDiff: "expanded"`, diffs this short are shown inline anyway: hiding them saves nothing. */
18
+ const SMALL_DIFF_MAX_LINES = 8;
17
19
  let editDiffMode = "expanded";
18
20
  /** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
19
21
  export function setEditDiffMode(mode) {
@@ -22,13 +24,10 @@ export function setEditDiffMode(mode) {
22
24
  export function stopLiveWatch() {
23
25
  live.stop();
24
26
  }
25
- function liveId(context, item) {
26
- return context.toolCallId || item;
27
- }
28
27
  function rowStatus(context) {
29
28
  if (context.isError)
30
29
  return "error";
31
- if (context.state.outcome?.errorLine)
30
+ if (context.state.outcome?.errorLine !== undefined)
32
31
  return "error";
33
32
  return rowIsRunning(context) ? "running" : "ok";
34
33
  }
@@ -39,243 +38,176 @@ function statusColor(status) {
39
38
  return "warning";
40
39
  return "success";
41
40
  }
42
- function expandedLines(kind, result) {
43
- let text = resultText(result);
44
- if (kind === "bash")
45
- text = bashBody(text).body;
46
- if (!text)
47
- return [];
48
- const lines = text.split("\n");
49
- if (lines.length > EXPANDED_MAX_LINES) {
50
- const hidden = lines.length - EXPANDED_MAX_LINES;
51
- return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`];
52
- }
53
- return lines;
54
- }
55
- function outputBlock(kind, result, theme) {
56
- const lines = expandedLines(kind, result);
57
- if (lines.length === 0)
58
- return new Container();
59
- const indent = " ".repeat(rowIndent() + 2);
60
- const rule = theme.fg("borderMuted", "│");
41
+ // ---------------------------------------------------------------------------------------------
42
+ // Blocks under a row
43
+ const ruled = (lines, theme, indentCells, tone = "dim") => {
44
+ const indent = " ".repeat(rowIndent() + indentCells);
45
+ const rule = theme.fg(tone === "error" ? "error" : "borderMuted", "│");
61
46
  return {
62
47
  render(width) {
63
48
  const inner = Math.max(0, width - indent.length - 2);
64
- return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`);
49
+ return lines.map((line) => `${indent}${rule} ${tone ? theme.fg(tone, truncateToWidth(line, inner)) : truncateToWidth(line, inner)}`);
65
50
  },
66
51
  invalidate() { },
67
52
  };
53
+ };
54
+ function capped(lines, max, more) {
55
+ if (lines.length <= max)
56
+ return lines;
57
+ return [...lines.slice(0, max), more(lines.length - max)];
68
58
  }
69
- function editDiffText(result) {
70
- const details = result?.details;
71
- return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined;
72
- }
73
- /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
74
- function parseDiffLine(line) {
75
- const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line);
76
- if (!match)
59
+ /** Plain text (output, file content, script) in the ruled block, dim, capped at the expanded limit. */
60
+ function textBlock(text, theme, indentCells, label) {
61
+ if (!text)
77
62
  return undefined;
78
- return { prefix: match[1], lineNum: match[2], content: match[3].replace(/\t/g, " ") };
79
- }
80
- /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
81
- function highlightPair(removed, added, theme) {
82
- let out = { removed: "", added: "" };
83
- let firstRemoved = true;
84
- let firstAdded = true;
85
- for (const part of diffWords(removed, added)) {
86
- if (part.removed || part.added) {
87
- const key = part.removed ? "removed" : "added";
88
- let value = part.value;
89
- if (part.removed ? firstRemoved : firstAdded) {
90
- const ws = /^(\s*)/.exec(value)?.[1] ?? "";
91
- value = value.slice(ws.length);
92
- out = { ...out, [key]: out[key] + ws };
93
- if (part.removed)
94
- firstRemoved = false;
95
- else
96
- firstAdded = false;
97
- }
98
- if (value)
99
- out = { ...out, [key]: out[key] + theme.inverse(value) };
100
- }
101
- else {
102
- out = { removed: out.removed + part.value, added: out.added + part.value };
103
- }
104
- }
105
- return out;
63
+ const lines = capped(text.split("\n"), EXPANDED_MAX_LINES, (hidden) => `… ${hidden} more lines`);
64
+ return ruled(label ? [label, ...lines] : lines, theme, indentCells);
106
65
  }
107
- /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
108
- function colorDiffLines(lines, theme) {
109
- const out = [];
110
- for (let i = 0; i < lines.length;) {
111
- const parsed = parseDiffLine(lines[i]);
112
- if (!parsed) {
113
- out.push(theme.fg("toolDiffContext", lines[i]));
114
- i++;
115
- continue;
116
- }
117
- if (parsed.prefix === " ") {
118
- out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`));
119
- i++;
120
- continue;
121
- }
122
- const removed = [];
123
- const added = [];
124
- for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? ""))
125
- removed.push(p);
126
- for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? ""))
127
- added.push(p);
128
- if (removed.length === 1 && added.length === 1) {
129
- const pair = highlightPair(removed[0].content, added[0].content, theme);
130
- out.push(theme.fg("toolDiffRemoved", `-${removed[0].lineNum} ${pair.removed}`));
131
- out.push(theme.fg("toolDiffAdded", `+${added[0].lineNum} ${pair.added}`));
132
- continue;
133
- }
134
- for (const line of removed)
135
- out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`));
136
- for (const line of added)
137
- out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`));
138
- }
139
- return out;
140
- }
141
- /** The edit's diff in the `│`-ruled block. Inline views are capped; expanded views show up to the usual limit. */
66
+ /** The edit's diff in the ruled block, coloured per line; inline views are capped and point at ctrl+o. */
142
67
  function diffBlock(diff, theme, expanded) {
143
68
  const all = diff.replace(/\n$/, "").split("\n");
144
69
  const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES;
145
- const lines = colorDiffLines(all.length > max ? all.slice(0, max) : all, theme);
146
- const hidden = all.length - lines.length;
147
- const indent = " ".repeat(rowIndent() + 2);
148
- const rule = theme.fg("borderMuted", "│");
149
- return {
150
- render(width) {
151
- const inner = Math.max(0, width - indent.length - 2);
152
- const out = lines.map((line) => `${indent}${rule} ${truncateToWidth(line, inner)}`);
153
- if (hidden > 0)
154
- out.push(`${indent}${rule} ${theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`)}`);
155
- return out;
156
- },
157
- invalidate() { },
158
- };
70
+ const shown = colorDiffLines(all.slice(0, max), theme);
71
+ const hidden = all.length - shown.length;
72
+ if (hidden > 0)
73
+ shown.push(theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`));
74
+ return ruled(shown, theme, 2, undefined);
159
75
  }
160
- function errorLineBlock(line, theme) {
161
- if (!line)
162
- return undefined;
163
- const indent = " ".repeat(rowIndent() + 2);
164
- const rule = theme.fg("error", "│");
165
- return {
166
- render(width) {
167
- const inner = Math.max(0, width - indent.length - 2);
168
- return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`];
169
- },
170
- invalidate() { },
171
- };
172
- }
173
- function activityRow(kind, args, theme, context) {
174
- const item = toolItem(kind, args);
175
- const id = liveId(context, item);
176
- const running = rowIsRunning(context);
177
- context.state.running = running;
178
- if (running) {
179
- context.state.startedAt ??= Date.now();
180
- live.watch(id, context.invalidate);
76
+ // ---------------------------------------------------------------------------------------------
77
+ // The one row lifecycle
78
+ /** `glyph verb item … outcome` on one line; an edit's `+N −M` is coloured success/error. */
79
+ function rowLine(theme, width, status, frame, verb, item, outcome, elapsed) {
80
+ const diff = status !== "running" ? outcome?.diff : undefined;
81
+ let right = status === "running" ? elapsed : (outcome?.text ?? "");
82
+ if (diff) {
83
+ const stats = `+${diff.added} −${diff.removed}`;
84
+ right = right ? `${stats} · ${right}` : stats;
181
85
  }
182
- else {
183
- live.unwatch(id);
86
+ const inner = Math.max(8, width - rowIndent());
87
+ const row = layoutRow(inner, verb, item, right);
88
+ let coloredRight;
89
+ if (status === "running")
90
+ coloredRight = theme.fg("warning", row.outcome);
91
+ else if (status === "error")
92
+ coloredRight = theme.fg("error", row.outcome);
93
+ else if (diff && row.outcome) {
94
+ const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length);
95
+ coloredRight = theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest);
184
96
  }
97
+ else
98
+ coloredRight = theme.fg("dim", row.outcome);
99
+ return (" ".repeat(rowIndent()) +
100
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
101
+ " " +
102
+ theme.fg("dim", row.verb) +
103
+ " " +
104
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
105
+ " ".repeat(row.gap) +
106
+ coloredRight);
107
+ }
108
+ export function createRowRenderers(strategy) {
109
+ const detailIndent = strategy.detailIndent ?? 2;
185
110
  return {
186
- render(width) {
187
- const status = rowStatus(context);
188
- const blink = status === "running" && live.has(id);
189
- const glyph = rowGlyph(status, blink ? live.frame : 0);
190
- const outcome = context.state.outcome;
191
- const diff = status !== "running" ? outcome?.diff : undefined;
192
- let right = "";
193
- if (status === "running") {
194
- const started = context.state.startedAt;
195
- right = started ? formatDuration(Date.now() - started) : "";
111
+ renderShell: "self",
112
+ renderCall(args, theme, rawContext) {
113
+ const context = rawContext;
114
+ const id = (context.state.liveId ??= context.toolCallId || `${strategy.verb}:${strategy.item(args, context.state)}`);
115
+ const running = rowIsRunning(context);
116
+ context.state.running = running;
117
+ if (running) {
118
+ context.state.startedAt ??= Date.now();
119
+ live.watch(id, context.invalidate);
196
120
  }
197
- else if (outcome) {
198
- right = outcome.text;
199
- if (diff) {
200
- const stats = `+${diff.added} −${diff.removed}`;
201
- right = right ? `${stats} · ${right}` : stats;
202
- }
121
+ else
122
+ live.unwatch(id);
123
+ return {
124
+ render(width) {
125
+ const status = rowStatus(context);
126
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
127
+ const elapsed = context.state.startedAt ? formatDuration(Date.now() - context.state.startedAt) : "";
128
+ const item = strategy.item(args, context.state);
129
+ const lines = [rowLine(theme, width, status, frame, strategy.verb, item, context.state.outcome, elapsed)];
130
+ if (strategy.childLines)
131
+ lines.push(...strategy.childLines(context.state, theme, width, frame, Boolean(context.expanded)));
132
+ return lines;
133
+ },
134
+ invalidate() { },
135
+ };
136
+ },
137
+ renderResult(result, options, theme, rawContext) {
138
+ const context = rawContext;
139
+ const typed = result;
140
+ const isError = Boolean(context.isError);
141
+ strategy.onResult?.(typed, context.state);
142
+ if (!context.isPartial) {
143
+ context.state.done = true;
144
+ context.state.running = false;
145
+ context.state.outcome = strategy.outcome(context.args, typed, isError, context.durationMs);
146
+ live.unwatch(context.state.liveId ?? context.toolCallId ?? `${strategy.verb}:${strategy.item(context.args, context.state)}`);
203
147
  }
204
- const inner = Math.max(8, width - rowIndent());
205
- const row = layoutRow(inner, kind, item, right);
206
- let coloredRight;
207
- if (status === "running")
208
- coloredRight = theme.fg("warning", row.outcome);
209
- else if (status === "error")
210
- coloredRight = theme.fg("error", row.outcome);
211
- else if (diff && row.outcome) {
212
- const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length);
213
- coloredRight =
214
- theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest);
148
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
149
+ // paints afterwards, so the call row's lazy render() already sees this state.
150
+ const container = new Container();
151
+ const expanded = Boolean(options.expanded);
152
+ const errorLine = context.state.outcome?.errorLine;
153
+ if (errorLine && !expanded)
154
+ container.addChild(ruled([errorLine], theme, detailIndent, "error"));
155
+ if (!context.isPartial) {
156
+ const detail = strategy.detail?.(context.args, typed, context.state, theme, expanded, isError);
157
+ if (detail)
158
+ container.addChild(detail);
215
159
  }
216
- else
217
- coloredRight = theme.fg("dim", row.outcome);
218
- const line = " ".repeat(rowIndent()) +
219
- theme.fg(statusColor(status), glyph) +
220
- " " +
221
- theme.fg("dim", row.verb) +
222
- " " +
223
- theme.fg(status === "error" ? "text" : "muted", row.item) +
224
- " ".repeat(row.gap) +
225
- coloredRight;
226
- return [line];
160
+ return container;
227
161
  },
228
- invalidate() { },
229
162
  };
230
163
  }
164
+ // ---------------------------------------------------------------------------------------------
165
+ // Strategy: built-in tools (bash, read, edit, write, grep, find, ls)
231
166
  const TOOL_KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
232
167
  export function isToolKind(name) {
233
168
  return TOOL_KINDS.has(name);
234
169
  }
235
- export function compactRenderers(kind) {
170
+ /** Which edit diffs show without ctrl+o under the current setting. */
171
+ function diffShownInline(diffLines) {
172
+ if (editDiffMode === "always")
173
+ return true;
174
+ if (editDiffMode === "expanded")
175
+ return diffLines <= SMALL_DIFF_MAX_LINES;
176
+ return false;
177
+ }
178
+ function compactStrategy(kind) {
236
179
  return {
237
- renderShell: "self",
238
- renderCall(args, theme, context) {
239
- return activityRow(kind, args, theme, context);
240
- },
241
- renderResult(result, options, theme, context) {
242
- const renderContext = context;
243
- const typedResult = result;
244
- if (!renderContext.isPartial) {
245
- renderContext.state.done = true;
246
- renderContext.state.running = false;
247
- renderContext.state.outcome = formatOutcome(kind, renderContext.args, typedResult, Boolean(renderContext.isError), renderContext.durationMs);
248
- live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)));
180
+ verb: kind,
181
+ item: (args) => toolItem(kind, args),
182
+ outcome: (args, result, isError, durationMs) => formatOutcome(kind, args, result, isError, durationMs),
183
+ detail(args, result, _state, theme, expanded, isError) {
184
+ if (kind === "edit" && !isError) {
185
+ const diff = editDiffText(result);
186
+ if (diff) {
187
+ const lines = diff.replace(/\n$/, "").split("\n").length;
188
+ if (expanded ? editDiffMode !== "never" : diffShownInline(lines))
189
+ return diffBlock(diff, theme, expanded);
190
+ if (!expanded)
191
+ return undefined;
192
+ }
249
193
  }
250
- const container = new Container();
251
- const expanded = Boolean(options.expanded);
252
- const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme);
253
- if (errorLine && !expanded)
254
- container.addChild(errorLine);
255
- const diff = kind === "edit" && !renderContext.isError && !renderContext.isPartial ? editDiffText(typedResult) : undefined;
256
- const showDiff = diff !== undefined && (editDiffMode === "always" || (editDiffMode === "expanded" && expanded));
257
- if (showDiff)
258
- container.addChild(diffBlock(diff, theme, expanded));
259
- else if (expanded)
260
- container.addChild(outputBlock(kind, typedResult, theme));
261
- return container;
194
+ if (!expanded)
195
+ return undefined;
196
+ if (kind === "write" && !isError) {
197
+ const content = args?.content;
198
+ if (typeof content === "string")
199
+ return textBlock(content.replace(/\n$/, ""), theme, 2);
200
+ }
201
+ const text = kind === "bash" ? bashBody(resultText(result)).body : resultText(result);
202
+ return textBlock(text, theme, 2);
262
203
  },
263
204
  };
264
205
  }
265
- /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
266
- function rowLine(theme, width, status, frame, verb, item, right) {
267
- const inner = Math.max(8, width - rowIndent());
268
- const row = layoutRow(inner, verb, item, right);
269
- const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
270
- return (" ".repeat(rowIndent()) +
271
- theme.fg(statusColor(status), rowGlyph(status, frame)) +
272
- " " +
273
- theme.fg("dim", row.verb) +
274
- " " +
275
- theme.fg(status === "error" ? "text" : "muted", row.item) +
276
- " ".repeat(row.gap) +
277
- theme.fg(rightColor, row.outcome));
206
+ export function compactRenderers(kind) {
207
+ return createRowRenderers(compactStrategy(kind));
278
208
  }
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Strategy: tools without a renderer of their own (most MCP tools, subagents_enable)
279
211
  /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
280
212
  const OUTCOME_TEXT_MAX = 24;
281
213
  /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
@@ -297,13 +229,13 @@ export function genericItem(toolName, args) {
297
229
  }
298
230
  const params = nestedItem({ name: toolName, args: json });
299
231
  const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
300
- const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName;
232
+ const label = mcp ? `${mcp[1]} · ${mcp[2]}` : toolName;
301
233
  return params ? `${label} ${params}` : label;
302
234
  }
303
235
  export function genericOutcome(result, isError, durationMs) {
304
236
  const text = resultText(result);
305
237
  const dur = formatDuration(durationMs);
306
- const join = (...parts) => parts.filter(Boolean).join(" \u00b7 ");
238
+ const join = (...parts) => parts.filter(Boolean).join(" · ");
307
239
  const lines = text.split("\n").filter((line) => line.trim().length > 0);
308
240
  if (isError)
309
241
  return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" };
@@ -315,191 +247,98 @@ export function genericOutcome(result, isError, durationMs) {
315
247
  return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) };
316
248
  }
317
249
  /**
318
- * A one-line row for tools that bring no renderer of their own (most MCP tools,
319
- * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
320
- * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
250
+ * `✓ mcp re_mcp · query_prometheus query=… 12 lines · 1.4s`. Without it such tools fall
251
+ * through to pi-core's default: bold name, JSON arguments, a text preview.
321
252
  */
322
253
  export function genericRenderers(toolName) {
323
- const verb = genericVerb(toolName);
324
- return {
325
- renderShell: "self",
326
- renderCall(args, theme, rawContext) {
327
- const context = rawContext;
328
- const id = context.toolCallId || toolName;
329
- const item = genericItem(toolName, args);
330
- const running = rowIsRunning(context);
331
- context.state.running = running;
332
- if (running) {
333
- context.state.startedAt ??= Date.now();
334
- live.watch(id, context.invalidate);
335
- }
336
- else
337
- live.unwatch(id);
338
- return {
339
- render(width) {
340
- const status = rowStatus(context);
341
- const frame = status === "running" && live.has(id) ? live.frame : 0;
342
- const right = status === "running"
343
- ? context.state.startedAt
344
- ? formatDuration(Date.now() - context.state.startedAt)
345
- : ""
346
- : (context.state.outcome?.text ?? "");
347
- return [rowLine(theme, width, status, frame, verb, item, right)];
348
- },
349
- invalidate() { },
350
- };
351
- },
352
- renderResult(result, options, theme, rawContext) {
353
- const context = rawContext;
354
- const typed = result;
355
- if (!context.isPartial) {
356
- context.state.done = true;
357
- context.state.running = false;
358
- context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs);
359
- live.unwatch(context.toolCallId || toolName);
360
- }
361
- const container = new Container();
362
- const expanded = Boolean(options.expanded);
363
- const errorLine = context.state.outcome?.errorLine;
364
- if (errorLine && !expanded)
365
- container.addChild(errorLineBlock(errorLine, theme));
366
- if (expanded) {
367
- const text = resultText(typed);
368
- if (text) {
369
- const indent = " ".repeat(rowIndent() + 2);
370
- container.addChild({ render: (width) => ruledLines(text, width, theme, indent), invalidate() { } });
371
- }
372
- }
373
- return container;
374
- },
375
- };
254
+ return createRowRenderers({
255
+ verb: genericVerb(toolName),
256
+ item: (args) => genericItem(toolName, args),
257
+ outcome: (_args, result, isError, durationMs) => genericOutcome(result, isError, durationMs),
258
+ detail: (_args, result, _state, theme, expanded) => (expanded ? textBlock(resultText(result), theme, 2) : undefined),
259
+ });
376
260
  }
377
261
  /** True when a tool definition draws its own call or result. */
378
262
  function hasOwnRenderer(definition) {
379
263
  return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function";
380
264
  }
265
+ // ---------------------------------------------------------------------------------------------
266
+ // Strategy: codemode, one row per nested call
381
267
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
382
268
  const NESTED_PREVIEW = 8;
383
269
  /** Width of the nested tool-name column. */
384
270
  const NESTED_NAME_MAX = 14;
271
+ const codemodeExtra = (state) => (state.extra ??= {});
385
272
  function nestedGlyph(call, frame) {
386
273
  if (call.status === "running")
387
274
  return { glyph: rowGlyph("running", frame), color: "warning" };
388
275
  if (call.status === "error")
389
- return { glyph: "\u2717", color: "error" };
276
+ return { glyph: "✗", color: "error" };
390
277
  if (call.status === "cancelled")
391
- return { glyph: "\u2298", color: "dim" };
392
- return { glyph: "\u2713", color: "success" };
278
+ return { glyph: "⊘", color: "dim" };
279
+ return { glyph: "✓", color: "success" };
393
280
  }
394
- /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
395
- function ruledLines(text, width, theme, indent, label) {
396
- const inner = Math.max(0, width - indent.length - 2);
397
- const rule = theme.fg("borderMuted", "\u2502");
398
- let lines = text.split("\n");
399
- if (lines.length > EXPANDED_MAX_LINES)
400
- lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`];
401
- const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : [];
402
- return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`));
281
+ function nestedCallLines(calls, theme, width, frame, expanded) {
282
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
283
+ const childIndent = " ".repeat(rowIndent() + 4);
284
+ const lines = [];
285
+ if (shown.length < calls.length)
286
+ lines.push(`${childIndent}${theme.fg("dim", `… ${calls.length - shown.length} earlier calls`)}`);
287
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
288
+ for (const call of shown) {
289
+ const { glyph, color } = nestedGlyph(call, frame);
290
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "…").padEnd(nameWidth);
291
+ const dur = call.status === "error" ? `failed · ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
292
+ const room = Math.max(8, width - childIndent.length);
293
+ const prefixWidth = 2 + nameWidth + 1;
294
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
295
+ const item = truncateToWidth(nestedItem(call), itemRoom, "…");
296
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
297
+ lines.push(childIndent +
298
+ theme.fg(color, glyph) +
299
+ " " +
300
+ theme.fg("dim", name) +
301
+ " " +
302
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
303
+ " ".repeat(gap) +
304
+ theme.fg(call.status === "error" ? "error" : "dim", dur));
305
+ }
306
+ return lines;
403
307
  }
404
308
  /**
405
- * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
406
- * child row per nested call. The script and its output appear only when expanded, undecorated,
407
- * so syntax colours never compete with status colours in the transcript.
309
+ * `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`, then one child row per nested
310
+ * call. The script and its output appear only when expanded, undecorated, so syntax colours
311
+ * never compete with status colours in the transcript.
408
312
  */
409
313
  export function codemodeRenderers() {
410
- return {
411
- renderShell: "self",
412
- renderCall(args, theme, rawContext) {
413
- const context = rawContext;
414
- const code = typeof args?.code === "string" ? (args.code) : "";
415
- context.state.code = code;
416
- const id = context.toolCallId || "codemode";
417
- const running = rowIsRunning(context);
418
- context.state.running = running;
419
- if (running) {
420
- context.state.startedAt ??= Date.now();
421
- live.watch(id, context.invalidate);
422
- }
423
- else
424
- live.unwatch(id);
425
- return {
426
- render(width) {
427
- const state = context.state;
428
- const status = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok";
429
- const frame = status === "running" && live.has(id) ? live.frame : 0;
430
- const calls = state.calls ?? [];
431
- const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
432
- const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)];
433
- const expanded = Boolean(context.expanded);
434
- const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
435
- const childIndent = " ".repeat(rowIndent() + 4);
436
- if (shown.length < calls.length) {
437
- lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`);
438
- }
439
- const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
440
- for (const call of shown) {
441
- const { glyph, color } = nestedGlyph(call, frame);
442
- const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth);
443
- const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
444
- const room = Math.max(8, width - childIndent.length);
445
- // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
446
- const prefixWidth = 2 + nameWidth + 1;
447
- const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
448
- const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026");
449
- const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
450
- lines.push(childIndent +
451
- theme.fg(color, glyph) +
452
- " " +
453
- theme.fg("dim", name) +
454
- " " +
455
- theme.fg(call.status === "error" ? "text" : "muted", item) +
456
- " ".repeat(gap) +
457
- theme.fg(call.status === "error" ? "error" : "dim", dur));
458
- }
459
- return lines;
460
- },
461
- invalidate() { },
462
- };
314
+ return createRowRenderers({
315
+ verb: "code",
316
+ item: (_args, state) => callSummary(codemodeExtra(state).calls ?? []),
317
+ detailIndent: 4,
318
+ onResult(result, state) {
319
+ const extra = codemodeExtra(state);
320
+ extra.calls = nestedCalls(result);
321
+ extra.output = scriptOutput(result);
463
322
  },
464
- renderResult(result, options, theme, rawContext) {
465
- const context = rawContext;
466
- const typed = result;
467
- context.state.calls = nestedCalls(typed);
468
- if (!context.isPartial) {
469
- context.state.done = true;
470
- context.state.running = false;
471
- const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs);
472
- context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
473
- context.state.output = scriptOutput(typed);
474
- live.unwatch(context.toolCallId || "codemode");
475
- }
476
- // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
477
- // paints afterwards, so the call row's lazy render() already sees these calls. Calling
478
- // invalidate() would re-enter updateDisplay() and recurse.
479
- const container = new Container();
480
- const expanded = Boolean(options.expanded);
481
- const errorLine = context.state.outcome?.errorLine;
482
- if (errorLine && !expanded)
483
- container.addChild(errorLineBlock(errorLine, theme));
484
- if (expanded) {
485
- const indent = " ".repeat(rowIndent() + 4);
486
- const code = context.state.code ?? "";
487
- const output = context.state.output ?? "";
488
- container.addChild({
489
- render(width) {
490
- const lines = [];
491
- if (code)
492
- lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`));
493
- if (output)
494
- lines.push(...ruledLines(output, width, theme, indent, "// output"));
495
- return lines;
496
- },
497
- invalidate() { },
498
- });
499
- }
500
- return container;
323
+ outcome(_args, result, isError, durationMs) {
324
+ const outcome = codemodeOutcome(result, isError, durationMs);
325
+ return { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
501
326
  },
502
- };
327
+ childLines: (state, theme, width, frame, expanded) => nestedCallLines(codemodeExtra(state).calls ?? [], theme, width, frame, expanded),
328
+ detail(args, _result, state, theme, expanded) {
329
+ if (!expanded)
330
+ return undefined;
331
+ const code = typeof args?.code === "string" ? args.code.trimEnd() : "";
332
+ const output = codemodeExtra(state).output ?? "";
333
+ const parts = [
334
+ code ? textBlock(code, theme, 4, `// script · ${code.split("\n").length} lines`) : undefined,
335
+ output ? textBlock(output, theme, 4, "// output") : undefined,
336
+ ].filter((part) => part !== undefined);
337
+ if (parts.length === 0)
338
+ return undefined;
339
+ return { render: (width) => parts.flatMap((part) => part.render(width)), invalidate() { } };
340
+ },
341
+ });
503
342
  }
504
343
  /**
505
344
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at