pi-minimal 0.2.3 → 0.3.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/CHANGELOG.md CHANGED
@@ -10,6 +10,29 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.0] - 2026-10-25
14
+
15
+ ### Added
16
+ - `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
17
+ with one child row per nested call. Built-in tools inside a script read exactly like their
18
+ top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
19
+ `key=value` instead of raw JSON. Children update live while the script runs. The script source
20
+ and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
21
+ status colours. A failed nested call or script error shows its first line under the row, like
22
+ `bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
23
+ highlighted script, JSON arguments, an output preview and a `Took …` timer.
24
+
25
+ ### Changed
26
+ - `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
27
+ 52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
28
+ 4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
29
+ - `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
30
+ (now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
31
+ spinner or a check mark.
32
+ - `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
33
+ `toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
34
+ their slab; failures keep `toolErrorBg`.
35
+
13
36
  ## [0.2.3] - 2026-10-24
14
37
 
15
38
  ### Added
package/README.md CHANGED
@@ -30,14 +30,14 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
30
30
 
31
31
  ## What it changes
32
32
 
33
- - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block.
33
+ - **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output.
34
34
  - **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
35
35
  - **Thinking gutter** – thinking paragraphs are prefixed with `┊` so they read as secondary.
36
36
  - **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr siblings.
37
37
  - **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
38
38
  - **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
39
39
  - **Alignment** – chrome follows pi's `outputPad` setting. Pi's editor uses the separate `editorPaddingX` setting (default `0`); set `"editorPaddingX": 1` for a flush left edge with the transcript.
40
- - **Theme** – ships `minimal-dark`: neutral tool backgrounds, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
40
+ - **Theme** – ships `minimal-dark`: no background behind tool rows (failures excepted), a grey ladder whose readable tiers stay near 4.5:1, syntax colours kept off the status hues, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
41
41
 
42
42
  ## Configuration
43
43
 
@@ -0,0 +1,109 @@
1
+ import { firstLine, formatDuration, resultText, toolItem } from "./tool-rows.js";
2
+ const KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
3
+ const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/;
4
+ /** Argument keys toolItem() reads, in the order it prefers them. */
5
+ const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"];
6
+ export function nestedCalls(result) {
7
+ const calls = result?.details?.calls;
8
+ if (!Array.isArray(calls))
9
+ return [];
10
+ return calls.filter((call) => Boolean(call) && typeof call === "object");
11
+ }
12
+ /** Script output without pi-core's "Script completed / Wall time / Output:" header. */
13
+ export function scriptOutput(result) {
14
+ return resultText(result).replace(SCRIPT_HEADER, "").trim();
15
+ }
16
+ /**
17
+ * Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
18
+ * still yields the first string value of each known key, which is all a one-line row needs.
19
+ */
20
+ export function parseArgs(args) {
21
+ if (!args)
22
+ return {};
23
+ try {
24
+ const value = JSON.parse(args);
25
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
26
+ }
27
+ catch {
28
+ const out = {};
29
+ const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g;
30
+ for (const m of args.matchAll(pair)) {
31
+ if (m[1] in out)
32
+ continue;
33
+ if (m[2] !== undefined)
34
+ out[m[1]] = unescape(m[2]);
35
+ else if (m[3] !== undefined)
36
+ out[m[1]] = JSON.parse(m[3]);
37
+ }
38
+ return out;
39
+ }
40
+ }
41
+ function unescape(raw) {
42
+ // A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
43
+ const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "");
44
+ try {
45
+ return JSON.parse(`"${safe}"`);
46
+ }
47
+ catch {
48
+ return safe;
49
+ }
50
+ }
51
+ function scalar(value) {
52
+ if (typeof value === "string")
53
+ return value.split("\n")[0]?.trim();
54
+ if (typeof value === "number" || typeof value === "boolean")
55
+ return String(value);
56
+ return undefined;
57
+ }
58
+ /**
59
+ * The item column of a nested call. Built-in tools read exactly like their top-level rows
60
+ * (`bash` shows its command, `read` a short path). Other tools show up to three scalar
61
+ * arguments as `key=value`, strings unquoted, instead of raw JSON.
62
+ */
63
+ export function nestedItem(call) {
64
+ const name = call.name ?? "tool";
65
+ const args = parseArgs(call.args);
66
+ if (KINDS.has(name)) {
67
+ const picked = {};
68
+ for (const key of ITEM_KEYS)
69
+ if (key in args)
70
+ picked[key] = args[key];
71
+ return toolItem(name, picked);
72
+ }
73
+ const parts = [];
74
+ for (const [key, value] of Object.entries(args)) {
75
+ const shown = scalar(value);
76
+ if (shown === undefined || shown === "")
77
+ continue;
78
+ parts.push(`${key}=${shown}`);
79
+ if (parts.length === 3)
80
+ break;
81
+ }
82
+ return parts.join(" ");
83
+ }
84
+ /** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
85
+ export function callSummary(calls) {
86
+ if (calls.length === 0)
87
+ return "script";
88
+ const counts = new Map();
89
+ for (const call of calls)
90
+ counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1);
91
+ const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ");
92
+ return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`;
93
+ }
94
+ /** Right-hand outcome of the parent row, and the line shown under it when something failed. */
95
+ export function codemodeOutcome(result, isError, durationMs) {
96
+ const calls = nestedCalls(result);
97
+ const output = scriptOutput(result);
98
+ const failedCalls = calls.filter((call) => call.status === "error");
99
+ const dur = formatDuration(durationMs);
100
+ const join = (...parts) => parts.filter(Boolean).join(" · ");
101
+ if (isError || failedCalls.length > 0) {
102
+ const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed";
103
+ const scriptError = /Script error:\s*(.*)/.exec(output)?.[1];
104
+ const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output);
105
+ return { text: join(label, dur), failed: true, errorLine };
106
+ }
107
+ const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0;
108
+ return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false };
109
+ }
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
- import { Container, truncateToWidth } from "@earendil-works/pi-tui";
1
+ 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
+ import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput } from "./codemode-rows.js";
4
5
  import { createLiveWatch } from "./live-watch.js";
5
6
  import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
6
7
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
@@ -157,6 +158,141 @@ export function compactRenderers(kind) {
157
158
  },
158
159
  };
159
160
  }
161
+ /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
162
+ const NESTED_PREVIEW = 8;
163
+ /** Width of the nested tool-name column. */
164
+ const NESTED_NAME_MAX = 14;
165
+ function nestedGlyph(call, frame) {
166
+ if (call.status === "running")
167
+ return { glyph: rowGlyph("running", frame), color: "warning" };
168
+ if (call.status === "error")
169
+ return { glyph: "\u2717", color: "error" };
170
+ if (call.status === "cancelled")
171
+ return { glyph: "\u2298", color: "dim" };
172
+ return { glyph: "\u2713", color: "success" };
173
+ }
174
+ /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
175
+ function ruledLines(text, width, theme, indent, label) {
176
+ const inner = Math.max(0, width - indent.length - 2);
177
+ const rule = theme.fg("borderMuted", "\u2502");
178
+ let lines = text.split("\n");
179
+ if (lines.length > EXPANDED_MAX_LINES)
180
+ lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`];
181
+ const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : [];
182
+ return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`));
183
+ }
184
+ /**
185
+ * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
186
+ * child row per nested call. The script and its output appear only when expanded, undecorated,
187
+ * so syntax colours never compete with status colours in the transcript.
188
+ */
189
+ export function codemodeRenderers() {
190
+ return {
191
+ renderShell: "self",
192
+ renderCall(args, theme, rawContext) {
193
+ const context = rawContext;
194
+ const code = typeof args?.code === "string" ? (args.code) : "";
195
+ context.state.code = code;
196
+ const id = context.toolCallId || "codemode";
197
+ const running = rowIsRunning(context);
198
+ context.state.running = running;
199
+ if (running) {
200
+ context.state.startedAt ??= Date.now();
201
+ live.watch(id, context.invalidate);
202
+ }
203
+ else
204
+ live.unwatch(id);
205
+ return {
206
+ render(width) {
207
+ const state = context.state;
208
+ const status = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok";
209
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
210
+ const calls = state.calls ?? [];
211
+ const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
212
+ const inner = Math.max(8, width - rowIndent());
213
+ const row = layoutRow(inner, "code", callSummary(calls), right);
214
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
215
+ const lines = [
216
+ " ".repeat(rowIndent()) +
217
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
218
+ " " +
219
+ theme.fg("dim", row.verb) +
220
+ " " +
221
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
222
+ " ".repeat(row.gap) +
223
+ theme.fg(rightColor, row.outcome),
224
+ ];
225
+ const expanded = Boolean(context.expanded);
226
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
227
+ const childIndent = " ".repeat(rowIndent() + 4);
228
+ if (shown.length < calls.length) {
229
+ lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`);
230
+ }
231
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
232
+ for (const call of shown) {
233
+ const { glyph, color } = nestedGlyph(call, frame);
234
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth);
235
+ const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
236
+ const room = Math.max(8, width - childIndent.length);
237
+ // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
238
+ const prefixWidth = 2 + nameWidth + 1;
239
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
240
+ const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026");
241
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
242
+ lines.push(childIndent +
243
+ theme.fg(color, glyph) +
244
+ " " +
245
+ theme.fg("dim", name) +
246
+ " " +
247
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
248
+ " ".repeat(gap) +
249
+ theme.fg(call.status === "error" ? "error" : "dim", dur));
250
+ }
251
+ return lines;
252
+ },
253
+ invalidate() { },
254
+ };
255
+ },
256
+ renderResult(result, options, theme, rawContext) {
257
+ const context = rawContext;
258
+ const typed = result;
259
+ context.state.calls = nestedCalls(typed);
260
+ if (!context.isPartial) {
261
+ context.state.done = true;
262
+ context.state.running = false;
263
+ const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs);
264
+ context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
265
+ context.state.output = scriptOutput(typed);
266
+ live.unwatch(context.toolCallId || "codemode");
267
+ }
268
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
269
+ // paints afterwards, so the call row's lazy render() already sees these calls. Calling
270
+ // invalidate() would re-enter updateDisplay() and recurse.
271
+ const container = new Container();
272
+ const expanded = Boolean(options.expanded);
273
+ const errorLine = context.state.outcome?.errorLine;
274
+ if (errorLine && !expanded)
275
+ container.addChild(errorLineBlock(errorLine, theme));
276
+ if (expanded) {
277
+ const indent = " ".repeat(rowIndent() + 4);
278
+ const code = context.state.code ?? "";
279
+ const output = context.state.output ?? "";
280
+ container.addChild({
281
+ render(width) {
282
+ const lines = [];
283
+ if (code)
284
+ lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`));
285
+ if (output)
286
+ lines.push(...ruledLines(output, width, theme, indent, "// output"));
287
+ return lines;
288
+ },
289
+ invalidate() { },
290
+ });
291
+ }
292
+ return container;
293
+ },
294
+ };
295
+ }
160
296
  /**
161
297
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
162
298
  * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
@@ -165,6 +301,8 @@ export function installToolRenderers(pi) {
165
301
  if (typeof pi.registerToolRenderer !== "function")
166
302
  return;
167
303
  pi.registerToolRenderer((toolName, next) => {
304
+ if (toolName === "codemode")
305
+ return codemodeRenderers();
168
306
  if (!isToolKind(toolName))
169
307
  return next();
170
308
  return compactRenderers(toolName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,31 +1,32 @@
1
1
  ### Added
2
- - Versioned, session-scoped footer activity contribution API. Companion extensions
3
- can publish bounded activity rows after existing hints and tracker summaries
4
- without replacing the footer. No visible change without a contributor.
5
- - Icon-first activity rows with pi-minimal's spinner/check/failure colors,
6
- plain/static modes, overflow handling, optional elapsed time/token metrics,
7
- and optional task links with independent task-state badges.
8
- - Snapshot validation, immutable metadata, exclusive slot ownership, stale-update
9
- protection, and integration documentation.
10
- - Independent plan-summary contribution slot with violet slanted progress segments
11
- and exact accepted/total counts, rendered below the input. One blank row
12
- separates nonempty footer groups; activity rows stay compact. The summary is
13
- display-only and does not add task storage or infer acceptance from agent runs.
14
- - Release checks for matching manifest/lockfile versions and a dated changelog entry.
2
+ - `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
3
+ with one child row per nested call. Built-in tools inside a script read exactly like their
4
+ top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
5
+ `key=value` instead of raw JSON. Children update live while the script runs. The script source
6
+ and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
7
+ status colours. A failed nested call or script error shows its first line under the row, like
8
+ `bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
9
+ highlighted script, JSON arguments, an output preview and a `Took …` timer.
15
10
 
16
- ### Fixed
17
- - Dispose activity subscriptions and animation timers across footer replacement
18
- and shutdown; prevent delayed settle callbacks from reinstalling stale chrome.
19
- - Synchronize the previously stale lockfile package version with the manifest.
11
+ ### Changed
12
+ - `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
13
+ 52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
14
+ 4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
15
+ - `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
16
+ (now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
17
+ spinner or a check mark.
18
+ - `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
19
+ `toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
20
+ their slab; failures keep `toolErrorBg`.
20
21
 
21
22
  ## What's Changed
22
- * feat: add cooperative footer activity contributions by @raviy06 in https://github.com/raviy06/pi-minimal/pull/14
23
+ * feat: render codemode as a tool row; raise minimal-dark contrast (0.3.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/15
23
24
 
24
25
 
25
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
26
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.3...v0.3.0
26
27
 
27
28
  ---
28
29
  **Install / upgrade**
29
30
  ```bash
30
- pi install npm:pi-minimal@0.2.3 # or: pi update --extensions
31
+ pi install npm:pi-minimal@0.3.0 # or: pi update --extensions
31
32
  ```
@@ -0,0 +1,121 @@
1
+ import { firstLine, formatDuration, resultText, toolItem, type ToolKind, type ToolResultLike } from "./tool-rows.js"
2
+
3
+ /** Mirrors pi-core's `CodemodeNestedCall`; every field is optional so older hosts degrade quietly. */
4
+ export type NestedCall = {
5
+ id?: string
6
+ name?: string
7
+ /** Compact JSON of the arguments, truncated by the host at 200 chars (may not parse). */
8
+ args?: string
9
+ status?: "running" | "ok" | "error" | "cancelled"
10
+ durationMs?: number
11
+ error?: string
12
+ cost?: number
13
+ }
14
+
15
+ const KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
16
+ const SCRIPT_HEADER = /^Script (?:completed|failed)\nWall time [\d.]+ seconds\nOutput:\n?/
17
+ /** Argument keys toolItem() reads, in the order it prefers them. */
18
+ const ITEM_KEYS = ["command", "path", "file_path", "target_directory", "pattern"] as const
19
+
20
+ export function nestedCalls(result: ToolResultLike | undefined): NestedCall[] {
21
+ const calls = (result?.details as { calls?: unknown } | undefined)?.calls
22
+ if (!Array.isArray(calls)) return []
23
+ return calls.filter((call): call is NestedCall => Boolean(call) && typeof call === "object")
24
+ }
25
+
26
+ /** Script output without pi-core's "Script completed / Wall time / Output:" header. */
27
+ export function scriptOutput(result: ToolResultLike | undefined): string {
28
+ return resultText(result).replace(SCRIPT_HEADER, "").trim()
29
+ }
30
+
31
+ /**
32
+ * Parse the host's argument preview. Complete JSON parses directly; a preview cut at 200 chars
33
+ * still yields the first string value of each known key, which is all a one-line row needs.
34
+ */
35
+ export function parseArgs(args: string | undefined): Record<string, unknown> {
36
+ if (!args) return {}
37
+ try {
38
+ const value = JSON.parse(args) as unknown
39
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
40
+ } catch {
41
+ const out: Record<string, unknown> = {}
42
+ const pair = /"([A-Za-z_$][\w$]*)":(?:"((?:[^"\\]|\\.)*)"?|(-?\d+(?:\.\d+)?|true|false|null))/g
43
+ for (const m of args.matchAll(pair)) {
44
+ if (m[1] in out) continue
45
+ if (m[2] !== undefined) out[m[1]] = unescape(m[2])
46
+ else if (m[3] !== undefined) out[m[1]] = JSON.parse(m[3])
47
+ }
48
+ return out
49
+ }
50
+ }
51
+
52
+ function unescape(raw: string): string {
53
+ // A cut can land inside an escape; drop a dangling backslash so JSON.parse accepts the rest.
54
+ const safe = raw.replace(/\\(u[0-9a-fA-F]{0,3})?$/, "")
55
+ try {
56
+ return JSON.parse(`"${safe}"`) as string
57
+ } catch {
58
+ return safe
59
+ }
60
+ }
61
+
62
+ function scalar(value: unknown): string | undefined {
63
+ if (typeof value === "string") return value.split("\n")[0]?.trim()
64
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
65
+ return undefined
66
+ }
67
+
68
+ /**
69
+ * The item column of a nested call. Built-in tools read exactly like their top-level rows
70
+ * (`bash` shows its command, `read` a short path). Other tools show up to three scalar
71
+ * arguments as `key=value`, strings unquoted, instead of raw JSON.
72
+ */
73
+ export function nestedItem(call: NestedCall): string {
74
+ const name = call.name ?? "tool"
75
+ const args = parseArgs(call.args)
76
+ if (KINDS.has(name)) {
77
+ const picked: Record<string, unknown> = {}
78
+ for (const key of ITEM_KEYS) if (key in args) picked[key] = args[key]
79
+ return toolItem(name as ToolKind, picked)
80
+ }
81
+ const parts: string[] = []
82
+ for (const [key, value] of Object.entries(args)) {
83
+ const shown = scalar(value)
84
+ if (shown === undefined || shown === "") continue
85
+ parts.push(`${key}=${shown}`)
86
+ if (parts.length === 3) break
87
+ }
88
+ return parts.join(" ")
89
+ }
90
+
91
+ /** `3 calls · plan_tracker ×2, bash` — names in first-call order with repeat counts. */
92
+ export function callSummary(calls: readonly NestedCall[]): string {
93
+ if (calls.length === 0) return "script"
94
+ const counts = new Map<string, number>()
95
+ for (const call of calls) counts.set(call.name ?? "tool", (counts.get(call.name ?? "tool") ?? 0) + 1)
96
+ const names = [...counts].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(", ")
97
+ return `${calls.length} call${calls.length === 1 ? "" : "s"} · ${names}`
98
+ }
99
+
100
+ export type CodemodeOutcome = { text: string; failed: boolean; errorLine?: string }
101
+
102
+ /** Right-hand outcome of the parent row, and the line shown under it when something failed. */
103
+ export function codemodeOutcome(
104
+ result: ToolResultLike | undefined,
105
+ isError: boolean,
106
+ durationMs: number | undefined,
107
+ ): CodemodeOutcome {
108
+ const calls = nestedCalls(result)
109
+ const output = scriptOutput(result)
110
+ const failedCalls = calls.filter((call) => call.status === "error")
111
+ const dur = formatDuration(durationMs)
112
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
113
+ if (isError || failedCalls.length > 0) {
114
+ const label = failedCalls.length > 0 ? `${failedCalls.length} failed` : "failed"
115
+ const scriptError = /Script error:\s*(.*)/.exec(output)?.[1]
116
+ const errorLine = firstLine(failedCalls[0]?.error ?? "") ?? scriptError ?? firstLine(output)
117
+ return { text: join(label, dur), failed: true, errorLine }
118
+ }
119
+ const lines = output ? output.split("\n").filter((line) => line.length > 0).length : 0
120
+ return { text: join(lines === 0 ? "ok" : `${lines} line${lines === 1 ? "" : "s"}`, dur), failed: false }
121
+ }
package/src/tools.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
2
- import { Container, truncateToWidth } from "@earendil-works/pi-tui"
2
+ import { Container, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
3
3
  import { rowIsRunning } from "./activity.js"
4
4
  import * as chrome from "./chrome.js"
5
+ import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
5
6
  import { createLiveWatch } from "./live-watch.js"
6
7
  import {
7
8
 
@@ -203,6 +204,141 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
203
204
  }
204
205
  }
205
206
 
207
+ /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
208
+ const NESTED_PREVIEW = 8
209
+ /** Width of the nested tool-name column. */
210
+ const NESTED_NAME_MAX = 14
211
+
212
+ type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
213
+
214
+ function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
215
+ if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
216
+ if (call.status === "error") return { glyph: "\u2717", color: "error" }
217
+ if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
218
+ return { glyph: "\u2713", color: "success" }
219
+ }
220
+
221
+ /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
222
+ function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
223
+ const inner = Math.max(0, width - indent.length - 2)
224
+ const rule = theme.fg("borderMuted", "\u2502")
225
+ let lines = text.split("\n")
226
+ if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
227
+ const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
228
+ return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`))
229
+ }
230
+
231
+ /**
232
+ * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
233
+ * child row per nested call. The script and its output appear only when expanded, undecorated,
234
+ * so syntax colours never compete with status colours in the transcript.
235
+ */
236
+ export function codemodeRenderers(): ToolRenderers {
237
+ return {
238
+ renderShell: "self",
239
+ renderCall(args, theme, rawContext) {
240
+ const context = rawContext as RenderContext & { state: CodemodeState }
241
+ const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
242
+ context.state.code = code
243
+ const id = context.toolCallId || "codemode"
244
+ const running = rowIsRunning(context)
245
+ context.state.running = running
246
+ if (running) {
247
+ context.state.startedAt ??= Date.now()
248
+ live.watch(id, context.invalidate)
249
+ } else live.unwatch(id)
250
+ return {
251
+ render(width: number): string[] {
252
+ const state = context.state
253
+ const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
254
+ const frame = status === "running" && live.has(id) ? live.frame : 0
255
+ const calls = state.calls ?? []
256
+ const right =
257
+ status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
258
+ const inner = Math.max(8, width - rowIndent())
259
+ const row = layoutRow(inner, "code", callSummary(calls), right)
260
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
261
+ const lines = [
262
+ " ".repeat(rowIndent()) +
263
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
264
+ " " +
265
+ theme.fg("dim", row.verb) +
266
+ " " +
267
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
268
+ " ".repeat(row.gap) +
269
+ theme.fg(rightColor, row.outcome),
270
+ ]
271
+ const expanded = Boolean(context.expanded)
272
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
273
+ const childIndent = " ".repeat(rowIndent() + 4)
274
+ if (shown.length < calls.length) {
275
+ lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
276
+ }
277
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
278
+ for (const call of shown) {
279
+ const { glyph, color } = nestedGlyph(call, frame)
280
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
281
+ const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
282
+ const room = Math.max(8, width - childIndent.length)
283
+ // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
284
+ const prefixWidth = 2 + nameWidth + 1
285
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
286
+ const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
287
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
288
+ lines.push(
289
+ childIndent +
290
+ theme.fg(color, glyph) +
291
+ " " +
292
+ theme.fg("dim", name) +
293
+ " " +
294
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
295
+ " ".repeat(gap) +
296
+ theme.fg(call.status === "error" ? "error" : "dim", dur),
297
+ )
298
+ }
299
+ return lines
300
+ },
301
+ invalidate() {},
302
+ }
303
+ },
304
+ renderResult(result, options, theme, rawContext) {
305
+ const context = rawContext as RenderContext & { state: CodemodeState }
306
+ const typed = result as ToolResultLike
307
+ context.state.calls = nestedCalls(typed)
308
+ if (!context.isPartial) {
309
+ context.state.done = true
310
+ context.state.running = false
311
+ const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
312
+ context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
313
+ context.state.output = scriptOutput(typed)
314
+ live.unwatch(context.toolCallId || "codemode")
315
+ }
316
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
317
+ // paints afterwards, so the call row's lazy render() already sees these calls. Calling
318
+ // invalidate() would re-enter updateDisplay() and recurse.
319
+ const container = new Container()
320
+ const expanded = Boolean((options as RenderOptions).expanded)
321
+ const errorLine = context.state.outcome?.errorLine
322
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
323
+ if (expanded) {
324
+ const indent = " ".repeat(rowIndent() + 4)
325
+ const code = context.state.code ?? ""
326
+ const output = context.state.output ?? ""
327
+ container.addChild({
328
+ render(width: number): string[] {
329
+ const lines: string[] = []
330
+ if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
331
+ if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
332
+ return lines
333
+ },
334
+ invalidate() {},
335
+ })
336
+ }
337
+ return container
338
+ },
339
+ }
340
+ }
341
+
206
342
  /**
207
343
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
208
344
  * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
@@ -210,6 +346,7 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
210
346
  export function installToolRenderers(pi: ExtensionAPI): void {
211
347
  if (typeof pi.registerToolRenderer !== "function") return
212
348
  pi.registerToolRenderer((toolName, next) => {
349
+ if (toolName === "codemode") return codemodeRenderers()
213
350
  if (!isToolKind(toolName)) return next()
214
351
  return compactRenderers(toolName)
215
352
  })
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { callSummary, codemodeOutcome, nestedCalls, nestedItem, parseArgs, scriptOutput } from "../src/codemode-rows.js"
4
+
5
+ const result = (text: string, calls: unknown[] = []) => ({
6
+ content: [
7
+ { type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
8
+ { type: "text", text },
9
+ ],
10
+ details: { calls },
11
+ })
12
+
13
+ test("parseArgs reads complete JSON and recovers keys from a preview cut at 200 chars", () => {
14
+ assert.deepEqual(parseArgs('{"action":"update","index":6}'), { action: "update", index: 6 })
15
+ const cut = '{"command":"cd ~/repo && sed -n 168,180p docs/a.md && echo \\"===\\" && rg -n x docs/b.md | sed -n 1,30p && ech...'
16
+ assert.equal(parseArgs(cut).command, 'cd ~/repo && sed -n 168,180p docs/a.md && echo "===" && rg -n x docs/b.md | sed -n 1,30p && ech...')
17
+ assert.deepEqual(parseArgs(""), {})
18
+ assert.deepEqual(parseArgs("[1,2]"), {})
19
+ // A cut inside an escape must not throw.
20
+ assert.equal(typeof parseArgs('{"command":"echo \\').command, "string")
21
+ })
22
+
23
+ test("nestedItem reads built-ins like their top-level rows and others as key=value", () => {
24
+ assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status")
25
+ assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
26
+ assert.equal(
27
+ nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
28
+ "action=update index=6 status=complete",
29
+ )
30
+ assert.equal(nestedItem({ name: "mcp__x__q", args: '{"query":"a","nested":{"b":1},"limit":5,"extra":1,"more":2}' }), "query=a limit=5 extra=1")
31
+ assert.equal(nestedItem({ name: "subagents_enable", args: "{}" }), "")
32
+ })
33
+
34
+ test("callSummary names tools in first-call order with repeat counts", () => {
35
+ assert.equal(callSummary([]), "script")
36
+ assert.equal(callSummary([{ name: "bash" }]), "1 call · bash")
37
+ assert.equal(
38
+ callSummary([{ name: "plan_tracker" }, { name: "plan_tracker" }, { name: "bash" }]),
39
+ "3 calls · plan_tracker ×2, bash",
40
+ )
41
+ })
42
+
43
+ test("scriptOutput drops pi-core's script header; nestedCalls tolerates missing details", () => {
44
+ assert.equal(scriptOutput(result("hello\n")), "hello")
45
+ assert.deepEqual(nestedCalls({ content: [] }), [])
46
+ assert.deepEqual(nestedCalls({ content: [], details: { calls: [null, { name: "bash" }] } }), [{ name: "bash" }])
47
+ })
48
+
49
+ test("codemodeOutcome counts output lines and surfaces the first failure", () => {
50
+ assert.deepEqual(codemodeOutcome(result("a\nb\n\nc"), false, 1200), { text: "3 lines · 1.2s", failed: false })
51
+ assert.deepEqual(codemodeOutcome(result(""), false, 300), { text: "ok · 0.3s", failed: false })
52
+ const failed = codemodeOutcome(
53
+ result("TEST_EXIT=1", [{ name: "bash", status: "ok" }, { name: "bash", status: "error", error: "\nboom: exit 2\nmore" }]),
54
+ false,
55
+ 274_000,
56
+ )
57
+ assert.deepEqual(failed, { text: "1 failed · 4m 34s", failed: true, errorLine: "boom: exit 2" })
58
+ const scriptError = codemodeOutcome(result("partial\nScript error: ReferenceError: x is not defined"), true, 50)
59
+ assert.equal(scriptError.failed, true)
60
+ assert.equal(scriptError.errorLine, "ReferenceError: x is not defined")
61
+ })
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"
5
5
  test("package manifest exposes the committed extension", () => {
6
6
  const pkg = JSON.parse(readFileSync("package.json", "utf8"))
7
7
  assert.equal(pkg.name, "pi-minimal")
8
- assert.equal(pkg.version, "0.2.3")
8
+ assert.equal(pkg.version, "0.3.0")
9
9
  const lock = JSON.parse(readFileSync("package-lock.json", "utf8"))
10
10
  assert.equal(lock.version, pkg.version, "lockfile root version matches package")
11
11
  assert.equal(lock.packages[""].version, pkg.version, "lockfile package version matches package")
@@ -43,3 +43,31 @@ test("minimal-dark frames code quietly and neutralises tool backgrounds", () =>
43
43
  assert.equal(theme.colors.toolSuccessBg, theme.colors.toolPendingBg)
44
44
  assert.notEqual(theme.colors.mdHeading, theme.colors.warning)
45
45
  })
46
+
47
+ const lightness = (value: string) => {
48
+ const resolved = value in theme.vars ? theme.vars[value] : value
49
+ const m = /okhsl\(\s*[\d.]+\s+[\d.]+%\s+([\d.]+)%\s*\)/.exec(resolved)
50
+ assert.ok(m, `okhsl literal for ${value}`)
51
+ return Number(m![1]) / 100
52
+ }
53
+
54
+ test("minimal-dark keeps a readable grey ladder: text > muted > thinking > dim > faint", () => {
55
+ const ladder = [theme.vars.text, theme.vars.muted, theme.colors.thinkingText, theme.vars.dim, theme.vars.faint].map(lightness)
56
+ for (let i = 1; i < ladder.length; i++) assert.ok(ladder[i] < ladder[i - 1], `step ${i}: ${ladder.join(" > ")}`)
57
+ // Readable tiers stay at or above L .56 (~4.4:1 on bg L .11, estimating Y ≈ L³).
58
+ assert.ok(lightness(theme.vars.dim) >= 0.56)
59
+ assert.ok(lightness(theme.colors.thinkingText) >= 0.56)
60
+ })
61
+
62
+ test("minimal-dark keeps status hues out of syntax colours", () => {
63
+ const status = new Set([theme.colors.success, theme.colors.warning, theme.colors.error])
64
+ for (const role of ["syntaxFunction", "syntaxNumber", "syntaxString", "syntaxVariable"]) {
65
+ assert.ok(!status.has(theme.colors[role]), `${role} reuses a status colour`)
66
+ }
67
+ })
68
+
69
+ test("minimal-dark draws no slab behind tool rows; only failures get a background", () => {
70
+ assert.equal(theme.colors.toolSuccessBg, "bg")
71
+ assert.equal(theme.colors.toolPendingBg, "bg")
72
+ assert.notEqual(theme.colors.toolErrorBg, "bg")
73
+ })
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict"
2
2
  import test from "node:test"
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
- import { compactRenderers, installToolRenderers, isToolKind } from "../src/tools.js"
4
+ import { codemodeRenderers, compactRenderers, installToolRenderers, isToolKind, stopLiveWatch } from "../src/tools.js"
5
5
 
6
6
  test("isToolKind recognises the built-in tools only", () => {
7
7
  for (const name of ["bash", "read", "edit", "write", "grep", "find", "ls"]) assert.equal(isToolKind(name), true)
@@ -19,7 +19,9 @@ test("installToolRenderers registers a resolver at load time that only claims bu
19
19
  installToolRenderers(pi)
20
20
  assert.ok(resolver, "resolver registered synchronously, before session_start")
21
21
  const fallback = { renderShell: "default" }
22
- assert.equal(resolver!("codemode", () => fallback), fallback)
22
+ assert.equal(resolver!("mcp__foo__bar", () => fallback), fallback)
23
+ const codemode = resolver!("codemode", () => fallback) as { renderShell?: string }
24
+ assert.equal(codemode.renderShell, "self")
23
25
  const mine = resolver!("bash", () => fallback) as { renderShell?: string }
24
26
  assert.equal(mine.renderShell, "self")
25
27
  assert.equal(typeof (mine as { renderCall?: unknown }).renderCall, "function")
@@ -53,3 +55,71 @@ test("compactRenderers render a one-line row with the outcome once the result la
53
55
  const [line] = row.render(60)
54
56
  assert.match(line, /✓ bash\s+ls\s+2 lines · 1.2s$/)
55
57
  })
58
+
59
+ function codemodeContext(extra: Record<string, unknown> = {}) {
60
+ return {
61
+ args: { code: "await tools.bash({command:'ls'})\ntext(1)" },
62
+ toolCallId: "c1",
63
+ invalidate() {},
64
+ state: {} as Record<string, unknown>,
65
+ isPartial: false,
66
+ isError: false,
67
+ durationMs: 1200,
68
+ ...extra,
69
+ }
70
+ }
71
+
72
+ const plainTheme = { fg: (_c: string, s: string) => s } as never
73
+ const codemodeResult = (calls: unknown[], output = "a\nb") => ({
74
+ content: [
75
+ { type: "text", text: "Script completed\nWall time 1.2 seconds\nOutput:\n" },
76
+ { type: "text", text: output },
77
+ ],
78
+ details: { calls },
79
+ })
80
+
81
+ test("codemode renders one parent row plus one child row per nested call, no script", () => {
82
+ const r = codemodeRenderers()
83
+ const context = codemodeContext()
84
+ const calls = [
85
+ { name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}', status: "ok", durationMs: 309 },
86
+ { name: "bash", args: '{"command":"cd ~/r && sed -n 1,2p a.md"}', status: "ok", durationMs: 636 },
87
+ ]
88
+ const body = r.renderResult!(codemodeResult(calls) as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
89
+ const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
90
+ assert.equal(lines.length, 3)
91
+ assert.match(lines[0], /✓ code\s+2 calls · plan_tracker, bash\s+2 lines · 1.2s$/)
92
+ assert.match(lines[1], /✓ plan_tracker action=update index=6 status=complete\s+0.3s$/)
93
+ assert.match(lines[2], /✓ bash\s+cd ~\/r && sed -n 1,2p a.md\s+0.6s$/)
94
+ assert.ok(lines.every((line) => !line.includes("await")), "script hidden when collapsed")
95
+ assert.deepEqual(body.render(100), [])
96
+ for (const line of lines) assert.ok(line.length <= 100)
97
+ })
98
+
99
+ test("codemode shows the first failure under the row, and script + output when expanded", () => {
100
+ const r = codemodeRenderers()
101
+ const context = codemodeContext({ durationMs: 274_000 })
102
+ const calls = [{ name: "bash", args: '{"command":"npm test"}', status: "error", durationMs: 274_000, error: "fail 2" }]
103
+ const collapsed = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: false, isPartial: false } as never, plainTheme, context as never)
104
+ const [row, child] = r.renderCall!(context.args as never, plainTheme, context as never).render(100)
105
+ assert.match(row, /✗ code\s+1 call · bash\s+1 failed · 4m 34s$/)
106
+ assert.match(child, /✗ bash npm test\s+failed · 4m 34s$/)
107
+ assert.match(collapsed.render(100).join("\n"), /│ fail 2/)
108
+
109
+ const open = r.renderResult!(codemodeResult(calls, "TEST_EXIT=1") as never, { expanded: true, isPartial: false } as never, plainTheme, { ...context, expanded: true } as never)
110
+ const text = open.render(100).join("\n")
111
+ assert.match(text, /\/\/ script · 2 lines/)
112
+ assert.match(text, /await tools.bash/)
113
+ assert.match(text, /\/\/ output\n.*TEST_EXIT=1/)
114
+ })
115
+
116
+ test("codemode shows nested calls live while the script is still running", () => {
117
+ const r = codemodeRenderers()
118
+ const context = codemodeContext({ isPartial: true, durationMs: undefined })
119
+ r.renderResult!(codemodeResult([{ name: "bash", args: '{"command":"sleep 5"}', status: "running" }], "") as never, { expanded: false, isPartial: true } as never, plainTheme, context as never)
120
+ const lines = r.renderCall!(context.args as never, plainTheme, context as never).render(80)
121
+ assert.match(lines[0], /^\s+[◐◓] code\s+1 call · bash/)
122
+ assert.match(lines[1], /[◐◓] bash sleep 5/)
123
+ assert.equal((context.state as { done?: boolean }).done, undefined)
124
+ stopLiveWatch()
125
+ })
@@ -7,8 +7,8 @@
7
7
  "panel": "okhsl(230 5% 17%)",
8
8
  "text": "okhsl(234 3% 90%)",
9
9
  "muted": "okhsl(229 6% 64%)",
10
- "dim": "okhsl(229 7% 48%)",
11
- "faint": "okhsl(230 6% 34%)",
10
+ "dim": "okhsl(229 7% 56%)",
11
+ "faint": "okhsl(230 6% 36%)",
12
12
  "violet": "okhsl(295 50% 67%)",
13
13
  "violetSoft": "okhsl(295 45% 74%)",
14
14
  "blue": "okhsl(232 54% 67%)",
@@ -29,7 +29,7 @@
29
29
  "muted": "muted",
30
30
  "dim": "dim",
31
31
  "text": "text",
32
- "thinkingText": "okhsl(229 6% 52%)",
32
+ "thinkingText": "okhsl(229 6% 58%)",
33
33
  "selectedBg": "userBg",
34
34
  "scrollbarTrack": "faint",
35
35
  "scrollbarThumb": "dim",
@@ -40,8 +40,8 @@
40
40
  "customMessageBg": "okhsl(295 30% 20%)",
41
41
  "customMessageText": "muted",
42
42
  "customMessageLabel": "violet",
43
- "toolPendingBg": "panel",
44
- "toolSuccessBg": "panel",
43
+ "toolPendingBg": "bg",
44
+ "toolSuccessBg": "bg",
45
45
  "toolErrorBg": "errorBg",
46
46
  "toolTitle": "text",
47
47
  "toolOutput": "dim",
@@ -60,10 +60,10 @@
60
60
  "toolDiffContext": "dim",
61
61
  "syntaxComment": "dim",
62
62
  "syntaxKeyword": "blue",
63
- "syntaxFunction": "yellow",
63
+ "syntaxFunction": "okhsl(265 45% 76%)",
64
64
  "syntaxVariable": "okhsl(202 58% 70%)",
65
65
  "syntaxString": "okhsl(52 67% 70%)",
66
- "syntaxNumber": "green",
66
+ "syntaxNumber": "okhsl(180 40% 68%)",
67
67
  "syntaxType": "violet",
68
68
  "syntaxOperator": "muted",
69
69
  "syntaxPunctuation": "muted",