pi-minimal 0.3.1 → 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,6 +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 { colorDiffLines, editDiffText } from "./edit-diff.js";
5
6
  import { createLiveWatch } from "./live-watch.js";
6
7
  import { bashBody, formatDuration, formatOutcome, layoutRow, resultText, rowGlyph, toolItem, } from "./tool-rows.js";
7
8
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
@@ -11,16 +12,22 @@ const live = createLiveWatch(400);
11
12
  const rowIndent = () => margin() + 1;
12
13
  /** Max lines of output shown when a row is expanded. */
13
14
  const EXPANDED_MAX_LINES = 400;
15
+ /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
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;
19
+ let editDiffMode = "expanded";
20
+ /** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
21
+ export function setEditDiffMode(mode) {
22
+ editDiffMode = mode;
23
+ }
14
24
  export function stopLiveWatch() {
15
25
  live.stop();
16
26
  }
17
- function liveId(context, item) {
18
- return context.toolCallId || item;
19
- }
20
27
  function rowStatus(context) {
21
28
  if (context.isError)
22
29
  return "error";
23
- if (context.state.outcome?.errorLine)
30
+ if (context.state.outcome?.errorLine !== undefined)
24
31
  return "error";
25
32
  return rowIsRunning(context) ? "running" : "ok";
26
33
  }
@@ -31,147 +38,176 @@ function statusColor(status) {
31
38
  return "warning";
32
39
  return "success";
33
40
  }
34
- function expandedLines(kind, result) {
35
- let text = resultText(result);
36
- if (kind === "bash")
37
- text = bashBody(text).body;
38
- if (!text)
39
- return [];
40
- const lines = text.split("\n");
41
- if (lines.length > EXPANDED_MAX_LINES) {
42
- const hidden = lines.length - EXPANDED_MAX_LINES;
43
- return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`];
44
- }
45
- return lines;
46
- }
47
- function outputBlock(kind, result, theme) {
48
- const lines = expandedLines(kind, result);
49
- if (lines.length === 0)
50
- return new Container();
51
- const indent = " ".repeat(rowIndent() + 2);
52
- 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", "│");
53
46
  return {
54
47
  render(width) {
55
48
  const inner = Math.max(0, width - indent.length - 2);
56
- 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)}`);
57
50
  },
58
51
  invalidate() { },
59
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)];
60
58
  }
61
- function errorLineBlock(line, theme) {
62
- if (!line)
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)
63
62
  return undefined;
64
- const indent = " ".repeat(rowIndent() + 2);
65
- const rule = theme.fg("error", "│");
66
- return {
67
- render(width) {
68
- const inner = Math.max(0, width - indent.length - 2);
69
- return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`];
70
- },
71
- invalidate() { },
72
- };
63
+ const lines = capped(text.split("\n"), EXPANDED_MAX_LINES, (hidden) => `… ${hidden} more lines`);
64
+ return ruled(label ? [label, ...lines] : lines, theme, indentCells);
65
+ }
66
+ /** The edit's diff in the ruled block, coloured per line; inline views are capped and point at ctrl+o. */
67
+ function diffBlock(diff, theme, expanded) {
68
+ const all = diff.replace(/\n$/, "").split("\n");
69
+ const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES;
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);
73
75
  }
74
- function activityRow(kind, args, theme, context) {
75
- const item = toolItem(kind, args);
76
- const id = liveId(context, item);
77
- const running = rowIsRunning(context);
78
- context.state.running = running;
79
- if (running) {
80
- context.state.startedAt ??= Date.now();
81
- 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;
82
85
  }
83
- else {
84
- 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);
85
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;
86
110
  return {
87
- render(width) {
88
- const status = rowStatus(context);
89
- const blink = status === "running" && live.has(id);
90
- const glyph = rowGlyph(status, blink ? live.frame : 0);
91
- const outcome = context.state.outcome;
92
- const diff = status !== "running" ? outcome?.diff : undefined;
93
- let right = "";
94
- if (status === "running") {
95
- const started = context.state.startedAt;
96
- 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);
97
120
  }
98
- else if (outcome) {
99
- right = outcome.text;
100
- if (diff) {
101
- const stats = `+${diff.added} −${diff.removed}`;
102
- right = right ? `${stats} · ${right}` : stats;
103
- }
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)}`);
104
147
  }
105
- const inner = Math.max(8, width - rowIndent());
106
- const row = layoutRow(inner, kind, item, right);
107
- let coloredRight;
108
- if (status === "running")
109
- coloredRight = theme.fg("warning", row.outcome);
110
- else if (status === "error")
111
- coloredRight = theme.fg("error", row.outcome);
112
- else if (diff && row.outcome) {
113
- const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length);
114
- coloredRight =
115
- 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);
116
159
  }
117
- else
118
- coloredRight = theme.fg("dim", row.outcome);
119
- const line = " ".repeat(rowIndent()) +
120
- theme.fg(statusColor(status), glyph) +
121
- " " +
122
- theme.fg("dim", row.verb) +
123
- " " +
124
- theme.fg(status === "error" ? "text" : "muted", row.item) +
125
- " ".repeat(row.gap) +
126
- coloredRight;
127
- return [line];
160
+ return container;
128
161
  },
129
- invalidate() { },
130
162
  };
131
163
  }
164
+ // ---------------------------------------------------------------------------------------------
165
+ // Strategy: built-in tools (bash, read, edit, write, grep, find, ls)
132
166
  const TOOL_KINDS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
133
167
  export function isToolKind(name) {
134
168
  return TOOL_KINDS.has(name);
135
169
  }
136
- 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) {
137
179
  return {
138
- renderShell: "self",
139
- renderCall(args, theme, context) {
140
- return activityRow(kind, args, theme, context);
141
- },
142
- renderResult(result, options, theme, context) {
143
- const renderContext = context;
144
- const typedResult = result;
145
- if (!renderContext.isPartial) {
146
- renderContext.state.done = true;
147
- renderContext.state.running = false;
148
- renderContext.state.outcome = formatOutcome(kind, renderContext.args, typedResult, Boolean(renderContext.isError), renderContext.durationMs);
149
- 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
+ }
150
193
  }
151
- const container = new Container();
152
- const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme);
153
- if (errorLine && !options.expanded)
154
- container.addChild(errorLine);
155
- if (options.expanded)
156
- container.addChild(outputBlock(kind, typedResult, theme));
157
- 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);
158
203
  },
159
204
  };
160
205
  }
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));
206
+ export function compactRenderers(kind) {
207
+ return createRowRenderers(compactStrategy(kind));
174
208
  }
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Strategy: tools without a renderer of their own (most MCP tools, subagents_enable)
175
211
  /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
176
212
  const OUTCOME_TEXT_MAX = 24;
177
213
  /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
@@ -193,13 +229,13 @@ export function genericItem(toolName, args) {
193
229
  }
194
230
  const params = nestedItem({ name: toolName, args: json });
195
231
  const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
196
- const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName;
232
+ const label = mcp ? `${mcp[1]} · ${mcp[2]}` : toolName;
197
233
  return params ? `${label} ${params}` : label;
198
234
  }
199
235
  export function genericOutcome(result, isError, durationMs) {
200
236
  const text = resultText(result);
201
237
  const dur = formatDuration(durationMs);
202
- const join = (...parts) => parts.filter(Boolean).join(" \u00b7 ");
238
+ const join = (...parts) => parts.filter(Boolean).join(" · ");
203
239
  const lines = text.split("\n").filter((line) => line.trim().length > 0);
204
240
  if (isError)
205
241
  return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" };
@@ -211,191 +247,98 @@ export function genericOutcome(result, isError, durationMs) {
211
247
  return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) };
212
248
  }
213
249
  /**
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.
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.
217
252
  */
218
253
  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
- };
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
+ });
272
260
  }
273
261
  /** True when a tool definition draws its own call or result. */
274
262
  function hasOwnRenderer(definition) {
275
263
  return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function";
276
264
  }
265
+ // ---------------------------------------------------------------------------------------------
266
+ // Strategy: codemode, one row per nested call
277
267
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
278
268
  const NESTED_PREVIEW = 8;
279
269
  /** Width of the nested tool-name column. */
280
270
  const NESTED_NAME_MAX = 14;
271
+ const codemodeExtra = (state) => (state.extra ??= {});
281
272
  function nestedGlyph(call, frame) {
282
273
  if (call.status === "running")
283
274
  return { glyph: rowGlyph("running", frame), color: "warning" };
284
275
  if (call.status === "error")
285
- return { glyph: "\u2717", color: "error" };
276
+ return { glyph: "✗", color: "error" };
286
277
  if (call.status === "cancelled")
287
- return { glyph: "\u2298", color: "dim" };
288
- return { glyph: "\u2713", color: "success" };
278
+ return { glyph: "⊘", color: "dim" };
279
+ return { glyph: "✓", color: "success" };
289
280
  }
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))}`));
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;
299
307
  }
300
308
  /**
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.
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.
304
312
  */
305
313
  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
- };
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);
359
322
  },
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;
323
+ outcome(_args, result, isError, durationMs) {
324
+ const outcome = codemodeOutcome(result, isError, durationMs);
325
+ return { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined };
397
326
  },
398
- };
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
+ });
399
342
  }
400
343
  /**
401
344
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
package/dist/tracker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
2
- function readSnapshot(ctx) {
2
+ export function readGauntletProjection(ctx) {
3
3
  let phases;
4
4
  let tasks;
5
5
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -21,7 +21,7 @@ function phaseActive(phases) {
21
21
  return PHASES.some((p) => phases[p]?.status && phases[p].status !== "pending");
22
22
  }
23
23
  export function formatTrackerLines(ctx, theme) {
24
- const { phases, tasks } = readSnapshot(ctx);
24
+ const { phases, tasks } = readGauntletProjection(ctx);
25
25
  const lines = [];
26
26
  if (phases && phaseActive(phases)) {
27
27
  const parts = PHASES.map((p) => {
@@ -184,8 +184,9 @@ export function installWorkingStatus(pi) {
184
184
  stop();
185
185
  });
186
186
  pi.on("session_shutdown", () => {
187
- ctx = undefined;
187
+ // Clear the message while ctx is still known; dropping ctx first left it on screen.
188
188
  stop();
189
+ ctx = undefined;
189
190
  });
190
191
  return stop;
191
192
  }
@@ -0,0 +1,3 @@
1
+ # Archive
2
+
3
+ The spec and plan here describe the 0.1 package (September 2026) and are superseded by [README.md](../../README.md) and [CONTEXT.md](../../CONTEXT.md).