pi-minimal 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,51 @@ Releases are cut automatically: when the `version` in `package.json` changes on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.3.1] - 2026-10-25
14
+
15
+ ### Added
16
+ - Compact rows for tools that bring no renderer of their own, such as most MCP tools and
17
+ `subagents_enable`: `✓ mcp re_mcp · query_prometheus query=… step=1m 12 lines · 1.4s`.
18
+ The verb is `mcp`, `agent` (subagent tools) or `tool`; up to three scalar arguments show as
19
+ `key=value`; a short single-line result shows verbatim, otherwise a line count. Failures show
20
+ their first line underneath; `ctrl+o` shows the full result. Tools that draw themselves
21
+ (the `subagent` live view, pi-gauntlet's trackers) keep their own renderer.
22
+
23
+ ### Changed
24
+ - Bash rows drop `cd <session cwd> && ` and shorten other `cd <dir> && ` prefixes to the
25
+ directory name (`pi-subagents › sed -n …`), so the part that differs between rows is visible
26
+ and the outcome column stays put. Multi-line commands such as heredocs say how many lines follow
27
+ (`python3 - <<'EOF' · +4 lines`).
28
+ - The thinking gutter `┊` now runs down every line of a thinking paragraph, including lines the
29
+ terminal wraps: paragraphs are pre-wrapped to the content width the host renders at (re-done on
30
+ resize). Previously only the first line of each paragraph had it, so wrapped thinking sat flush
31
+ with prose. Inline code stays whole; lists, quotes and fenced code are untouched.
32
+ - The task tracker line is a segmented bar coloured by status (`Tasks ▰▰▰▰▱ 4/5`) instead of a
33
+ run of check marks; past 12 tasks it scales to 10 segments.
34
+
35
+ ## [0.3.0] - 2026-10-25
36
+
37
+ ### Added
38
+ - `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
39
+ with one child row per nested call. Built-in tools inside a script read exactly like their
40
+ top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
41
+ `key=value` instead of raw JSON. Children update live while the script runs. The script source
42
+ and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
43
+ status colours. A failed nested call or script error shows its first line under the row, like
44
+ `bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
45
+ highlighted script, JSON arguments, an output preview and a `Took …` timer.
46
+
47
+ ### Changed
48
+ - `minimal-dark` contrast: `dim` (verbs, outcomes, tool output) L 48 % → 56 % and `thinkingText`
49
+ 52 % → 58 %, estimated ~3.1:1 → ~4.4:1 and ~3.7:1 → ~4.8:1 on the background. They were within
50
+ 4 % of each other and below WCAG's 4.5:1 text target. `faint` 34 % → 36 %.
51
+ - `minimal-dark` keeps syntax colours off the status hues: `syntaxFunction` was warning-yellow
52
+ (now lavender) and `syntaxNumber` was success-green (now teal), so code no longer looks like a
53
+ spinner or a check mark.
54
+ - `minimal-dark` draws no background behind pending/successful tool output (`toolPendingBg`,
55
+ `toolSuccessBg` → `bg`). Tools pi-minimal does not restyle yet (`subagent`, MCP tools) lose
56
+ their slab; failures keep `toolErrorBg`.
57
+
13
58
  ## [0.2.3] - 2026-10-24
14
59
 
15
60
  ### 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. Tools without a renderer of their own (most MCP tools) get the same one-line row; tools that draw themselves keep their look. Bash rows drop a leading `cd <session cwd> &&` and shorten other `cd` prefixes to the directory name.
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
- - **Thinking gutter** – thinking paragraphs are prefixed with `┊` so they read as secondary.
35
+ - **Thinking gutter** – every line of a thinking paragraph, wrapped lines included, starts with `┊`, so thinking reads as a column beside prose.
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/index.js CHANGED
@@ -8,6 +8,7 @@ import { installCwd } from "./cwd.js";
8
8
  import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
9
9
  import { hideGauntletWidgets } from "./tracker.js";
10
10
  import { installToolRenderers, stopLiveWatch } from "./tools.js";
11
+ import { setSessionCwd } from "./tool-rows.js";
11
12
  import { applyReclaim, reclaimPolicy } from "./reclaim.js";
12
13
  import { installWorkingStatus } from "./working-status.js";
13
14
  import { installTurnRule } from "./turn-rule.js";
@@ -170,6 +171,7 @@ export default function piMinimalExtension(pi) {
170
171
  });
171
172
  hideWidgets = config.hideGauntletWidgets;
172
173
  currentCwd = ctx.cwd;
174
+ setSessionCwd(ctx.cwd);
173
175
  try {
174
176
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
175
177
  setChromeMargin(settings.getOutputPad());
package/dist/tool-rows.js CHANGED
@@ -8,9 +8,42 @@ export function shortName(path) {
8
8
  return path || "";
9
9
  return parts.slice(-2).join("/");
10
10
  }
11
+ let sessionCwd;
12
+ let homeDir = process.env.HOME;
13
+ /** The session cwd, so `cd <cwd> && \u2026` can be dropped from bash rows. */
14
+ export function setSessionCwd(cwd, home = process.env.HOME) {
15
+ sessionCwd = cwd;
16
+ homeDir = home;
17
+ }
18
+ const CD_PREFIX = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*/;
19
+ function expandHome(path) {
20
+ const unquoted = path.replace(/^(["'])(.*)\1$/, "$2").replace(/\/+$/, "");
21
+ if (homeDir && (unquoted === "~" || unquoted.startsWith("~/")))
22
+ return homeDir + unquoted.slice(1);
23
+ return unquoted;
24
+ }
25
+ /**
26
+ * The item column of a bash row. Long agent commands usually start with `cd <repo> && `, which
27
+ * pushes the part that differs between rows off the screen: drop it when it is the session cwd,
28
+ * otherwise shorten it to the directory name (`pi-subagents \u203a sed -n \u2026`). Multi-line
29
+ * commands (heredocs, scripts) say how many lines follow instead of ending at `<<'EOF'`.
30
+ */
31
+ export function bashItem(command) {
32
+ const lines = command.replace(/\r/g, "").trim().split("\n");
33
+ let first = lines[0]?.trim() ?? "";
34
+ const cd = CD_PREFIX.exec(first);
35
+ if (cd && first.length > cd[0].length) {
36
+ const target = expandHome(cd[1]);
37
+ const rest = first.slice(cd[0].length);
38
+ const cwd = sessionCwd?.replace(/\/+$/, "");
39
+ first = cwd && target === cwd ? rest : `${target.split("/").filter(Boolean).at(-1) ?? target} \u203a ${rest}`;
40
+ }
41
+ const more = lines.slice(1).filter((line) => line.trim()).length;
42
+ return more > 0 ? `${first} \u00b7 +${more} line${more === 1 ? "" : "s"}` : first;
43
+ }
11
44
  export function toolItem(kind, args) {
12
45
  if (kind === "bash") {
13
- const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : "";
46
+ const command = typeof args?.command === "string" ? bashItem(args.command) : "";
14
47
  return command || "bash";
15
48
  }
16
49
  if (kind === "grep" || kind === "find") {
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,245 @@ export function compactRenderers(kind) {
157
158
  },
158
159
  };
159
160
  }
161
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
162
+ function rowLine(theme, width, status, frame, verb, item, right) {
163
+ const inner = Math.max(8, width - rowIndent());
164
+ const row = layoutRow(inner, verb, item, right);
165
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim";
166
+ return (" ".repeat(rowIndent()) +
167
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
168
+ " " +
169
+ theme.fg("dim", row.verb) +
170
+ " " +
171
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
172
+ " ".repeat(row.gap) +
173
+ theme.fg(rightColor, row.outcome));
174
+ }
175
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
176
+ const OUTCOME_TEXT_MAX = 24;
177
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
178
+ export function genericVerb(toolName) {
179
+ if (toolName.startsWith("mcp__"))
180
+ return "mcp";
181
+ if (/subagent/.test(toolName))
182
+ return "agent";
183
+ return "tool";
184
+ }
185
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
186
+ export function genericItem(toolName, args) {
187
+ let json;
188
+ try {
189
+ json = JSON.stringify(args ?? {}) ?? "";
190
+ }
191
+ catch {
192
+ json = "";
193
+ }
194
+ const params = nestedItem({ name: toolName, args: json });
195
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
196
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName;
197
+ return params ? `${label} ${params}` : label;
198
+ }
199
+ export function genericOutcome(result, isError, durationMs) {
200
+ const text = resultText(result);
201
+ const dur = formatDuration(durationMs);
202
+ const join = (...parts) => parts.filter(Boolean).join(" \u00b7 ");
203
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
204
+ if (isError)
205
+ return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" };
206
+ if (lines.length === 0)
207
+ return { text: join("ok", dur) };
208
+ const first = lines[0].trim();
209
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX)
210
+ return { text: join(first, dur) };
211
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) };
212
+ }
213
+ /**
214
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
215
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
216
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
217
+ */
218
+ export function genericRenderers(toolName) {
219
+ const verb = genericVerb(toolName);
220
+ return {
221
+ renderShell: "self",
222
+ renderCall(args, theme, rawContext) {
223
+ const context = rawContext;
224
+ const id = context.toolCallId || toolName;
225
+ const item = genericItem(toolName, args);
226
+ const running = rowIsRunning(context);
227
+ context.state.running = running;
228
+ if (running) {
229
+ context.state.startedAt ??= Date.now();
230
+ live.watch(id, context.invalidate);
231
+ }
232
+ else
233
+ live.unwatch(id);
234
+ return {
235
+ render(width) {
236
+ const status = rowStatus(context);
237
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
238
+ const right = status === "running"
239
+ ? context.state.startedAt
240
+ ? formatDuration(Date.now() - context.state.startedAt)
241
+ : ""
242
+ : (context.state.outcome?.text ?? "");
243
+ return [rowLine(theme, width, status, frame, verb, item, right)];
244
+ },
245
+ invalidate() { },
246
+ };
247
+ },
248
+ renderResult(result, options, theme, rawContext) {
249
+ const context = rawContext;
250
+ const typed = result;
251
+ if (!context.isPartial) {
252
+ context.state.done = true;
253
+ context.state.running = false;
254
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs);
255
+ live.unwatch(context.toolCallId || toolName);
256
+ }
257
+ const container = new Container();
258
+ const expanded = Boolean(options.expanded);
259
+ const errorLine = context.state.outcome?.errorLine;
260
+ if (errorLine && !expanded)
261
+ container.addChild(errorLineBlock(errorLine, theme));
262
+ if (expanded) {
263
+ const text = resultText(typed);
264
+ if (text) {
265
+ const indent = " ".repeat(rowIndent() + 2);
266
+ container.addChild({ render: (width) => ruledLines(text, width, theme, indent), invalidate() { } });
267
+ }
268
+ }
269
+ return container;
270
+ },
271
+ };
272
+ }
273
+ /** True when a tool definition draws its own call or result. */
274
+ function hasOwnRenderer(definition) {
275
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function";
276
+ }
277
+ /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
278
+ const NESTED_PREVIEW = 8;
279
+ /** Width of the nested tool-name column. */
280
+ const NESTED_NAME_MAX = 14;
281
+ function nestedGlyph(call, frame) {
282
+ if (call.status === "running")
283
+ return { glyph: rowGlyph("running", frame), color: "warning" };
284
+ if (call.status === "error")
285
+ return { glyph: "\u2717", color: "error" };
286
+ if (call.status === "cancelled")
287
+ return { glyph: "\u2298", color: "dim" };
288
+ return { glyph: "\u2713", color: "success" };
289
+ }
290
+ /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
291
+ function ruledLines(text, width, theme, indent, label) {
292
+ const inner = Math.max(0, width - indent.length - 2);
293
+ const rule = theme.fg("borderMuted", "\u2502");
294
+ let lines = text.split("\n");
295
+ if (lines.length > EXPANDED_MAX_LINES)
296
+ lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`];
297
+ const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : [];
298
+ return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`));
299
+ }
300
+ /**
301
+ * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
302
+ * child row per nested call. The script and its output appear only when expanded, undecorated,
303
+ * so syntax colours never compete with status colours in the transcript.
304
+ */
305
+ export function codemodeRenderers() {
306
+ return {
307
+ renderShell: "self",
308
+ renderCall(args, theme, rawContext) {
309
+ const context = rawContext;
310
+ const code = typeof args?.code === "string" ? (args.code) : "";
311
+ context.state.code = code;
312
+ const id = context.toolCallId || "codemode";
313
+ const running = rowIsRunning(context);
314
+ context.state.running = running;
315
+ if (running) {
316
+ context.state.startedAt ??= Date.now();
317
+ live.watch(id, context.invalidate);
318
+ }
319
+ else
320
+ live.unwatch(id);
321
+ return {
322
+ render(width) {
323
+ const state = context.state;
324
+ const status = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok";
325
+ const frame = status === "running" && live.has(id) ? live.frame : 0;
326
+ const calls = state.calls ?? [];
327
+ const right = status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "");
328
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)];
329
+ const expanded = Boolean(context.expanded);
330
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW);
331
+ const childIndent = " ".repeat(rowIndent() + 4);
332
+ if (shown.length < calls.length) {
333
+ lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`);
334
+ }
335
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)));
336
+ for (const call of shown) {
337
+ const { glyph, color } = nestedGlyph(call, frame);
338
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth);
339
+ const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs);
340
+ const room = Math.max(8, width - childIndent.length);
341
+ // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
342
+ const prefixWidth = 2 + nameWidth + 1;
343
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0));
344
+ const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026");
345
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0;
346
+ lines.push(childIndent +
347
+ theme.fg(color, glyph) +
348
+ " " +
349
+ theme.fg("dim", name) +
350
+ " " +
351
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
352
+ " ".repeat(gap) +
353
+ theme.fg(call.status === "error" ? "error" : "dim", dur));
354
+ }
355
+ return lines;
356
+ },
357
+ invalidate() { },
358
+ };
359
+ },
360
+ renderResult(result, options, theme, rawContext) {
361
+ const context = rawContext;
362
+ const typed = result;
363
+ context.state.calls = nestedCalls(typed);
364
+ if (!context.isPartial) {
365
+ context.state.done = true;
366
+ context.state.running = false;
367
+ const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs);
368
+ context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
369
+ context.state.output = scriptOutput(typed);
370
+ live.unwatch(context.toolCallId || "codemode");
371
+ }
372
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
373
+ // paints afterwards, so the call row's lazy render() already sees these calls. Calling
374
+ // invalidate() would re-enter updateDisplay() and recurse.
375
+ const container = new Container();
376
+ const expanded = Boolean(options.expanded);
377
+ const errorLine = context.state.outcome?.errorLine;
378
+ if (errorLine && !expanded)
379
+ container.addChild(errorLineBlock(errorLine, theme));
380
+ if (expanded) {
381
+ const indent = " ".repeat(rowIndent() + 4);
382
+ const code = context.state.code ?? "";
383
+ const output = context.state.output ?? "";
384
+ container.addChild({
385
+ render(width) {
386
+ const lines = [];
387
+ if (code)
388
+ lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`));
389
+ if (output)
390
+ lines.push(...ruledLines(output, width, theme, indent, "// output"));
391
+ return lines;
392
+ },
393
+ invalidate() { },
394
+ });
395
+ }
396
+ return container;
397
+ },
398
+ };
399
+ }
160
400
  /**
161
401
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
162
402
  * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
@@ -165,8 +405,14 @@ export function installToolRenderers(pi) {
165
405
  if (typeof pi.registerToolRenderer !== "function")
166
406
  return;
167
407
  pi.registerToolRenderer((toolName, next) => {
168
- if (!isToolKind(toolName))
169
- return next();
170
- return compactRenderers(toolName);
408
+ if (toolName === "codemode")
409
+ return codemodeRenderers();
410
+ if (isToolKind(toolName))
411
+ return compactRenderers(toolName);
412
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
413
+ const definition = next();
414
+ if (hasOwnRenderer(definition))
415
+ return definition;
416
+ return { ...definition, ...genericRenderers(toolName) };
171
417
  });
172
418
  }
package/dist/tracker.js CHANGED
@@ -40,20 +40,24 @@ export function formatTrackerLines(ctx, theme) {
40
40
  }
41
41
  if (tasks && tasks.length > 0) {
42
42
  const done = tasks.filter((t) => t.status === "complete").length;
43
- const icons = tasks
44
- .map((t) => {
45
- if (t.status === "complete")
46
- return theme.fg("success", "✓");
47
- if (t.status === "in_progress")
48
- return theme.fg("warning", "→");
49
- if (t.status === "failed")
50
- return theme.fg("error", "✗");
51
- return theme.fg("dim", "○");
52
- })
53
- .join("");
43
+ // One segment per task, coloured by status, so the list reads as a single bar instead of a
44
+ // row of check marks. Past 12 tasks the bar is scaled to 10 segments (status order kept).
45
+ const segment = (status) => {
46
+ if (status === "complete")
47
+ return theme.fg("success", "▰");
48
+ if (status === "in_progress")
49
+ return theme.fg("warning", "▰");
50
+ if (status === "failed")
51
+ return theme.fg("error", "▰");
52
+ return theme.fg("dim", "▱");
53
+ };
54
+ const shown = tasks.length <= 12
55
+ ? tasks.map((t) => t.status)
56
+ : Array.from({ length: 10 }, (_, i) => tasks[Math.floor((i * tasks.length) / 10)]?.status);
57
+ const icons = shown.map(segment).join("");
54
58
  const current = tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending");
55
59
  const name = current ? theme.fg("text", ` ${current.name}`) : "";
56
- lines.push(`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `(${done}/${tasks.length})`)}${name}`);
60
+ lines.push(`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `${done}/${tasks.length}`)}${name}`);
57
61
  }
58
62
  return lines;
59
63
  }