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.
@@ -1,23 +1,77 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  export const THINKING_GUTTER = "┊ ";
2
3
  const FENCE = /^\s*(```|~~~)/;
3
4
  const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/;
5
+ /** Below this width wrapping would only make thinking harder to read; prefix source lines instead. */
6
+ const MIN_WRAP_WIDTH = 20;
7
+ /** An inline code span stays whole; everything else splits on spaces. */
8
+ const TOKEN = /`[^`]*`|[^\s`]+|`/g;
9
+ function stripGutter(line) {
10
+ return line.startsWith(THINKING_GUTTER) ? line.slice(THINKING_GUTTER.length) : line;
11
+ }
12
+ /** Split a word wider than `room` into `room`-wide pieces (URLs, long paths). */
13
+ function splitWide(word, room) {
14
+ if (word.startsWith("`") || visibleWidth(word) <= room)
15
+ return [word];
16
+ const pieces = [];
17
+ let piece = "";
18
+ for (const char of word) {
19
+ if (visibleWidth(piece + char) > room) {
20
+ pieces.push(piece);
21
+ piece = "";
22
+ }
23
+ piece += char;
24
+ }
25
+ if (piece)
26
+ pieces.push(piece);
27
+ return pieces;
28
+ }
29
+ /**
30
+ * Greedy word wrap at `room` cells. Markdown markers (`*`, backticks) count toward the width
31
+ * but are removed when rendered, so lines can only come out shorter than `room`, never wider.
32
+ */
33
+ export function wrapLine(line, room) {
34
+ const words = (line.match(TOKEN) ?? []).flatMap((word) => splitWide(word, room));
35
+ if (words.length === 0)
36
+ return [""];
37
+ const out = [];
38
+ let current = "";
39
+ for (const word of words) {
40
+ if (!current)
41
+ current = word;
42
+ else if (visibleWidth(current) + 1 + visibleWidth(word) <= room)
43
+ current += ` ${word}`;
44
+ else {
45
+ out.push(current);
46
+ current = word;
47
+ }
48
+ }
49
+ out.push(current);
50
+ return out;
51
+ }
4
52
  /**
5
- * Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
6
- * Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
7
- * they keep parsing, and nothing inside a fenced code block is touched.
53
+ * Put the thinking gutter on every rendered line of every plain paragraph, so thinking reads as a
54
+ * column beside prose rather than a bullet on its first line. With `width` (the content width the
55
+ * host will render at) paragraph lines are pre-wrapped to `width - 2` so wrapped lines carry the
56
+ * gutter too; pi-tui keeps single newlines as line breaks. Without a usable width only source
57
+ * lines are prefixed.
58
+ *
59
+ * Block syntax (heading, list, quote, table, fence, rule) is left untouched so it still parses,
60
+ * including a list or quote that interrupts a paragraph, and nothing inside fenced code changes.
8
61
  */
9
- export function gutterThinking(markdown) {
62
+ export function gutterThinking(markdown, width) {
10
63
  if (!markdown)
11
64
  return markdown;
12
- const lines = markdown.split("\n");
65
+ const room = width !== undefined && width >= MIN_WRAP_WIDTH ? width - visibleWidth(THINKING_GUTTER) : undefined;
13
66
  const out = [];
14
67
  let inFence = false;
15
- let atParagraphStart = true;
16
- for (const line of lines) {
68
+ /** "para": inside a plain paragraph; "block": inside block syntax until the next blank line. */
69
+ let mode = "none";
70
+ for (const line of markdown.split("\n")) {
17
71
  if (FENCE.test(line)) {
18
72
  inFence = !inFence;
19
73
  out.push(line);
20
- atParagraphStart = !inFence;
74
+ mode = "none";
21
75
  continue;
22
76
  }
23
77
  if (inFence) {
@@ -26,16 +80,21 @@ export function gutterThinking(markdown) {
26
80
  }
27
81
  if (line.trim() === "") {
28
82
  out.push(line);
29
- atParagraphStart = true;
83
+ mode = "none";
30
84
  continue;
31
85
  }
32
- if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
33
- out.push(`${THINKING_GUTTER}${line}`);
34
- }
35
- else {
86
+ const body = stripGutter(line);
87
+ if (mode === "block" || BLOCK_START.test(body)) {
36
88
  out.push(line);
89
+ mode = "block";
90
+ continue;
37
91
  }
38
- atParagraphStart = false;
92
+ mode = "para";
93
+ if (room === undefined)
94
+ out.push(`${THINKING_GUTTER}${body}`);
95
+ else
96
+ for (const piece of wrapLine(body, room))
97
+ out.push(`${THINKING_GUTTER}${piece}`);
39
98
  }
40
99
  return out.join("\n");
41
100
  }
@@ -45,6 +104,6 @@ export function installTranscript(pi) {
45
104
  pi.registerMarkdownTransformer((markdown, context) => {
46
105
  if (context.messageType !== "assistant-thinking")
47
106
  return markdown;
48
- return gutterThinking(markdown);
107
+ return gutterThinking(markdown, context.availableWidth);
49
108
  });
50
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
package/release-notes.md CHANGED
@@ -1,31 +1,31 @@
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
+ - Compact rows for tools that bring no renderer of their own, such as most MCP tools and
3
+ `subagents_enable`: `✓ mcp re_mcp · query_prometheus query=… step=1m 12 lines · 1.4s`.
4
+ The verb is `mcp`, `agent` (subagent tools) or `tool`; up to three scalar arguments show as
5
+ `key=value`; a short single-line result shows verbatim, otherwise a line count. Failures show
6
+ their first line underneath; `ctrl+o` shows the full result. Tools that draw themselves
7
+ (the `subagent` live view, pi-gauntlet's trackers) keep their own renderer.
15
8
 
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.
9
+ ### Changed
10
+ - Bash rows drop `cd <session cwd> && ` and shorten other `cd <dir> && ` prefixes to the
11
+ directory name (`pi-subagents › sed -n …`), so the part that differs between rows is visible
12
+ and the outcome column stays put. Multi-line commands such as heredocs say how many lines follow
13
+ (`python3 - <<'EOF' · +4 lines`).
14
+ - The thinking gutter `┊` now runs down every line of a thinking paragraph, including lines the
15
+ terminal wraps: paragraphs are pre-wrapped to the content width the host renders at (re-done on
16
+ resize). Previously only the first line of each paragraph had it, so wrapped thinking sat flush
17
+ with prose. Inline code stays whole; lists, quotes and fenced code are untouched.
18
+ - The task tracker line is a segmented bar coloured by status (`Tasks ▰▰▰▰▱ 4/5`) instead of a
19
+ run of check marks; past 12 tasks it scales to 10 segments.
20
20
 
21
21
  ## What's Changed
22
- * feat: add cooperative footer activity contributions by @raviy06 in https://github.com/raviy06/pi-minimal/pull/14
22
+ * feat: compact rows for renderer-less tools, shorter bash items, task bar, wrapped thinking gutter (0.3.1) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/17
23
23
 
24
24
 
25
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
25
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.0...v0.3.1
26
26
 
27
27
  ---
28
28
  **Install / upgrade**
29
29
  ```bash
30
- pi install npm:pi-minimal@0.2.3 # or: pi update --extensions
30
+ pi install npm:pi-minimal@0.3.1 # or: pi update --extensions
31
31
  ```
@@ -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/index.ts CHANGED
@@ -8,6 +8,7 @@ import { installCwd } from "./cwd.js"
8
8
  import { listSiblings, parseAgentList, parseTabList, type Sibling } 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, type ReclaimReason } from "./reclaim.js"
12
13
  import { installWorkingStatus } from "./working-status.js"
13
14
  import { installTurnRule } from "./turn-rule.js"
@@ -167,6 +168,7 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
167
168
  })
168
169
  hideWidgets = config.hideGauntletWidgets
169
170
  currentCwd = ctx.cwd
171
+ setSessionCwd(ctx.cwd)
170
172
  try {
171
173
  const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
172
174
  setChromeMargin(settings.getOutputPad())
package/src/tool-rows.ts CHANGED
@@ -31,9 +31,46 @@ export function shortName(path: string): string {
31
31
  return parts.slice(-2).join("/")
32
32
  }
33
33
 
34
+ let sessionCwd: string | undefined
35
+ let homeDir: string | undefined = process.env.HOME
36
+
37
+ /** The session cwd, so `cd <cwd> && \u2026` can be dropped from bash rows. */
38
+ export function setSessionCwd(cwd: string | undefined, home = process.env.HOME): void {
39
+ sessionCwd = cwd
40
+ homeDir = home
41
+ }
42
+
43
+ const CD_PREFIX = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*/
44
+
45
+ function expandHome(path: string): string {
46
+ const unquoted = path.replace(/^(["'])(.*)\1$/, "$2").replace(/\/+$/, "")
47
+ if (homeDir && (unquoted === "~" || unquoted.startsWith("~/"))) return homeDir + unquoted.slice(1)
48
+ return unquoted
49
+ }
50
+
51
+ /**
52
+ * The item column of a bash row. Long agent commands usually start with `cd <repo> && `, which
53
+ * pushes the part that differs between rows off the screen: drop it when it is the session cwd,
54
+ * otherwise shorten it to the directory name (`pi-subagents \u203a sed -n \u2026`). Multi-line
55
+ * commands (heredocs, scripts) say how many lines follow instead of ending at `<<'EOF'`.
56
+ */
57
+ export function bashItem(command: string): string {
58
+ const lines = command.replace(/\r/g, "").trim().split("\n")
59
+ let first = lines[0]?.trim() ?? ""
60
+ const cd = CD_PREFIX.exec(first)
61
+ if (cd && first.length > cd[0].length) {
62
+ const target = expandHome(cd[1])
63
+ const rest = first.slice(cd[0].length)
64
+ const cwd = sessionCwd?.replace(/\/+$/, "")
65
+ first = cwd && target === cwd ? rest : `${target.split("/").filter(Boolean).at(-1) ?? target} \u203a ${rest}`
66
+ }
67
+ const more = lines.slice(1).filter((line) => line.trim()).length
68
+ return more > 0 ? `${first} \u00b7 +${more} line${more === 1 ? "" : "s"}` : first
69
+ }
70
+
34
71
  export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
35
72
  if (kind === "bash") {
36
- const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : ""
73
+ const command = typeof args?.command === "string" ? bashItem(args.command) : ""
37
74
  return command || "bash"
38
75
  }
39
76
  if (kind === "grep" || kind === "find") {
package/src/tools.ts CHANGED
@@ -1,10 +1,10 @@
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
8
  bashBody,
9
9
  formatDuration,
10
10
  formatOutcome,
@@ -203,6 +203,246 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
203
203
  }
204
204
  }
205
205
 
206
+ /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
207
+ function rowLine(theme: Theme, width: number, status: RowStatus, frame: 0 | 1, verb: string, item: string, right: string): string {
208
+ const inner = Math.max(8, width - rowIndent())
209
+ const row = layoutRow(inner, verb, item, right)
210
+ const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
211
+ return (
212
+ " ".repeat(rowIndent()) +
213
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
214
+ " " +
215
+ theme.fg("dim", row.verb) +
216
+ " " +
217
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
218
+ " ".repeat(row.gap) +
219
+ theme.fg(rightColor, row.outcome)
220
+ )
221
+ }
222
+
223
+ /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
224
+ const OUTCOME_TEXT_MAX = 24
225
+
226
+ /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
227
+ export function genericVerb(toolName: string): string {
228
+ if (toolName.startsWith("mcp__")) return "mcp"
229
+ if (/subagent/.test(toolName)) return "agent"
230
+ return "tool"
231
+ }
232
+
233
+ /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
234
+ export function genericItem(toolName: string, args: unknown): string {
235
+ let json: string
236
+ try {
237
+ json = JSON.stringify(args ?? {}) ?? ""
238
+ } catch {
239
+ json = ""
240
+ }
241
+ const params = nestedItem({ name: toolName, args: json })
242
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName)
243
+ const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName
244
+ return params ? `${label} ${params}` : label
245
+ }
246
+
247
+ export function genericOutcome(result: ToolResultLike | undefined, isError: boolean, durationMs: number | undefined): Outcome {
248
+ const text = resultText(result)
249
+ const dur = formatDuration(durationMs)
250
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" \u00b7 ")
251
+ const lines = text.split("\n").filter((line) => line.trim().length > 0)
252
+ if (isError) return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" }
253
+ if (lines.length === 0) return { text: join("ok", dur) }
254
+ const first = lines[0].trim()
255
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX) return { text: join(first, dur) }
256
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) }
257
+ }
258
+
259
+ /**
260
+ * A one-line row for tools that bring no renderer of their own (most MCP tools,
261
+ * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
262
+ * Without it they fall through to pi-core's default: bold name, JSON arguments, a text preview.
263
+ */
264
+ export function genericRenderers(toolName: string): ToolRenderers {
265
+ const verb = genericVerb(toolName)
266
+ return {
267
+ renderShell: "self",
268
+ renderCall(args, theme, rawContext) {
269
+ const context = rawContext as RenderContext
270
+ const id = context.toolCallId || toolName
271
+ const item = genericItem(toolName, args)
272
+ const running = rowIsRunning(context)
273
+ context.state.running = running
274
+ if (running) {
275
+ context.state.startedAt ??= Date.now()
276
+ live.watch(id, context.invalidate)
277
+ } else live.unwatch(id)
278
+ return {
279
+ render(width: number): string[] {
280
+ const status = rowStatus(context)
281
+ const frame = status === "running" && live.has(id) ? live.frame : 0
282
+ const right =
283
+ status === "running"
284
+ ? context.state.startedAt
285
+ ? formatDuration(Date.now() - context.state.startedAt)
286
+ : ""
287
+ : (context.state.outcome?.text ?? "")
288
+ return [rowLine(theme, width, status, frame, verb, item, right)]
289
+ },
290
+ invalidate() {},
291
+ }
292
+ },
293
+ renderResult(result, options, theme, rawContext) {
294
+ const context = rawContext as RenderContext
295
+ const typed = result as ToolResultLike
296
+ if (!context.isPartial) {
297
+ context.state.done = true
298
+ context.state.running = false
299
+ context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs)
300
+ live.unwatch(context.toolCallId || toolName)
301
+ }
302
+ const container = new Container()
303
+ const expanded = Boolean((options as RenderOptions).expanded)
304
+ const errorLine = context.state.outcome?.errorLine
305
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
306
+ if (expanded) {
307
+ const text = resultText(typed)
308
+ if (text) {
309
+ const indent = " ".repeat(rowIndent() + 2)
310
+ container.addChild({ render: (width: number) => ruledLines(text, width, theme, indent), invalidate() {} })
311
+ }
312
+ }
313
+ return container
314
+ },
315
+ }
316
+ }
317
+
318
+ /** True when a tool definition draws its own call or result. */
319
+ function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
320
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
321
+ }
322
+
323
+ /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
324
+ const NESTED_PREVIEW = 8
325
+ /** Width of the nested tool-name column. */
326
+ const NESTED_NAME_MAX = 14
327
+
328
+ type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
329
+
330
+ function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
331
+ if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
332
+ if (call.status === "error") return { glyph: "\u2717", color: "error" }
333
+ if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
334
+ return { glyph: "\u2713", color: "success" }
335
+ }
336
+
337
+ /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
338
+ function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
339
+ const inner = Math.max(0, width - indent.length - 2)
340
+ const rule = theme.fg("borderMuted", "\u2502")
341
+ let lines = text.split("\n")
342
+ if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
343
+ const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
344
+ return out.concat(lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`))
345
+ }
346
+
347
+ /**
348
+ * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
349
+ * child row per nested call. The script and its output appear only when expanded, undecorated,
350
+ * so syntax colours never compete with status colours in the transcript.
351
+ */
352
+ export function codemodeRenderers(): ToolRenderers {
353
+ return {
354
+ renderShell: "self",
355
+ renderCall(args, theme, rawContext) {
356
+ const context = rawContext as RenderContext & { state: CodemodeState }
357
+ const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
358
+ context.state.code = code
359
+ const id = context.toolCallId || "codemode"
360
+ const running = rowIsRunning(context)
361
+ context.state.running = running
362
+ if (running) {
363
+ context.state.startedAt ??= Date.now()
364
+ live.watch(id, context.invalidate)
365
+ } else live.unwatch(id)
366
+ return {
367
+ render(width: number): string[] {
368
+ const state = context.state
369
+ const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
370
+ const frame = status === "running" && live.has(id) ? live.frame : 0
371
+ const calls = state.calls ?? []
372
+ const right =
373
+ status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
374
+ const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)]
375
+ const expanded = Boolean(context.expanded)
376
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
377
+ const childIndent = " ".repeat(rowIndent() + 4)
378
+ if (shown.length < calls.length) {
379
+ lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
380
+ }
381
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
382
+ for (const call of shown) {
383
+ const { glyph, color } = nestedGlyph(call, frame)
384
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
385
+ const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
386
+ const room = Math.max(8, width - childIndent.length)
387
+ // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
388
+ const prefixWidth = 2 + nameWidth + 1
389
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
390
+ const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
391
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
392
+ lines.push(
393
+ childIndent +
394
+ theme.fg(color, glyph) +
395
+ " " +
396
+ theme.fg("dim", name) +
397
+ " " +
398
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
399
+ " ".repeat(gap) +
400
+ theme.fg(call.status === "error" ? "error" : "dim", dur),
401
+ )
402
+ }
403
+ return lines
404
+ },
405
+ invalidate() {},
406
+ }
407
+ },
408
+ renderResult(result, options, theme, rawContext) {
409
+ const context = rawContext as RenderContext & { state: CodemodeState }
410
+ const typed = result as ToolResultLike
411
+ context.state.calls = nestedCalls(typed)
412
+ if (!context.isPartial) {
413
+ context.state.done = true
414
+ context.state.running = false
415
+ const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
416
+ context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
417
+ context.state.output = scriptOutput(typed)
418
+ live.unwatch(context.toolCallId || "codemode")
419
+ }
420
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
421
+ // paints afterwards, so the call row's lazy render() already sees these calls. Calling
422
+ // invalidate() would re-enter updateDisplay() and recurse.
423
+ const container = new Container()
424
+ const expanded = Boolean((options as RenderOptions).expanded)
425
+ const errorLine = context.state.outcome?.errorLine
426
+ if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
427
+ if (expanded) {
428
+ const indent = " ".repeat(rowIndent() + 4)
429
+ const code = context.state.code ?? ""
430
+ const output = context.state.output ?? ""
431
+ container.addChild({
432
+ render(width: number): string[] {
433
+ const lines: string[] = []
434
+ if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
435
+ if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
436
+ return lines
437
+ },
438
+ invalidate() {},
439
+ })
440
+ }
441
+ return container
442
+ },
443
+ }
444
+ }
445
+
206
446
  /**
207
447
  * Attach the compact renderers to the built-in tools without re-registering them. Registered at
208
448
  * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
@@ -210,7 +450,11 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
210
450
  export function installToolRenderers(pi: ExtensionAPI): void {
211
451
  if (typeof pi.registerToolRenderer !== "function") return
212
452
  pi.registerToolRenderer((toolName, next) => {
213
- if (!isToolKind(toolName)) return next()
214
- return compactRenderers(toolName)
453
+ if (toolName === "codemode") return codemodeRenderers()
454
+ if (isToolKind(toolName)) return compactRenderers(toolName)
455
+ // Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
456
+ const definition = next()
457
+ if (hasOwnRenderer(definition)) return definition
458
+ return { ...definition, ...genericRenderers(toolName) }
215
459
  })
216
460
  }