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/src/tools.ts CHANGED
@@ -3,6 +3,8 @@ 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
5
  import { callSummary, codemodeOutcome, nestedCalls, nestedItem, scriptOutput, type NestedCall } from "./codemode-rows.js"
6
+ import type { EditDiffMode } from "./config.js"
7
+ import { colorDiffLines, editDiffText } from "./edit-diff.js"
6
8
  import { createLiveWatch } from "./live-watch.js"
7
9
  import {
8
10
  bashBody,
@@ -22,27 +24,50 @@ import {
22
24
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
23
25
  const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
24
26
 
25
- type RenderOptions = {
26
- expanded?: boolean
27
- }
27
+ type Component = { render(width: number): string[]; invalidate(): void }
28
+
29
+ type RenderOptions = { expanded?: boolean }
28
30
 
29
- type RowState = {
31
+ export type RowState = {
30
32
  done?: boolean
31
33
  running?: boolean
32
34
  startedAt?: number
33
35
  outcome?: Outcome
36
+ /** Key of this row's live spinner subscription, fixed at the first render. */
37
+ liveId?: string
38
+ /** Strategy-private data captured from the call or result (codemode keeps its nested calls here). */
39
+ extra?: unknown
34
40
  }
35
41
 
36
- type RenderContext = {
42
+ export type RenderContext = {
37
43
  isPartial?: boolean
38
44
  isError?: boolean
39
45
  invalidate: () => void
40
46
  state: RowState
41
- args?: ToolArgs
47
+ args?: unknown
42
48
  toolCallId?: string
43
49
  expanded?: boolean
44
50
  durationMs?: number
45
- outputPad?: number
51
+ }
52
+
53
+ /**
54
+ * What differs between tool rows. `createRowRenderers` owns everything else: running state, the
55
+ * live spinner keyed by tool call, status colouring, the error line under a failed row, and the
56
+ * expand/collapse plumbing.
57
+ */
58
+ export type RowStrategy = {
59
+ verb: string
60
+ /** The row's subject; may read `state` when it depends on the result (codemode's call summary). */
61
+ item(args: unknown, state: RowState): string
62
+ /** Called when the result lands (partial or final) before anything renders. */
63
+ onResult?(result: ToolResultLike, state: RowState): void
64
+ outcome(args: unknown, result: ToolResultLike, isError: boolean, durationMs: number | undefined): Outcome
65
+ /** Lines drawn under the call row itself, every frame (codemode's nested calls). */
66
+ childLines?(state: RowState, theme: Theme, width: number, frame: 0 | 1, expanded: boolean): string[]
67
+ /** Block under the row once the result is in. `expanded` is the ctrl+o state; return nothing to show nothing. */
68
+ detail?(args: unknown, result: ToolResultLike, state: RowState, theme: Theme, expanded: boolean, isError: boolean): Component | undefined
69
+ /** Indent of detail blocks relative to the row glyph (2 for plain rows, 4 under codemode's child rows). */
70
+ detailIndent?: number
46
71
  }
47
72
 
48
73
  const live = createLiveWatch(400)
@@ -51,18 +76,25 @@ const live = createLiveWatch(400)
51
76
  const rowIndent = () => margin() + 1
52
77
  /** Max lines of output shown when a row is expanded. */
53
78
  const EXPANDED_MAX_LINES = 400
79
+ /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
80
+ const INLINE_DIFF_MAX_LINES = 40
81
+ /** With `editDiff: "expanded"`, diffs this short are shown inline anyway: hiding them saves nothing. */
82
+ const SMALL_DIFF_MAX_LINES = 8
54
83
 
55
- export function stopLiveWatch(): void {
56
- live.stop()
84
+ let editDiffMode: EditDiffMode = "expanded"
85
+
86
+ /** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
87
+ export function setEditDiffMode(mode: EditDiffMode): void {
88
+ editDiffMode = mode
57
89
  }
58
90
 
59
- function liveId(context: RenderContext, item: string): string {
60
- return context.toolCallId || item
91
+ export function stopLiveWatch(): void {
92
+ live.stop()
61
93
  }
62
94
 
63
95
  function rowStatus(context: RenderContext): RowStatus {
64
96
  if (context.isError) return "error"
65
- if (context.state.outcome?.errorLine) return "error"
97
+ if (context.state.outcome?.errorLine !== undefined) return "error"
66
98
  return rowIsRunning(context) ? "running" : "ok"
67
99
  }
68
100
 
@@ -72,375 +104,309 @@ function statusColor(status: RowStatus): "error" | "warning" | "success" {
72
104
  return "success"
73
105
  }
74
106
 
75
- function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): string[] {
76
- let text = resultText(result)
77
- if (kind === "bash") text = bashBody(text).body
78
- if (!text) return []
79
- const lines = text.split("\n")
80
- if (lines.length > EXPANDED_MAX_LINES) {
81
- const hidden = lines.length - EXPANDED_MAX_LINES
82
- return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`]
83
- }
84
- return lines
85
- }
107
+ // ---------------------------------------------------------------------------------------------
108
+ // Blocks under a row
86
109
 
87
- function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
88
- const lines = expandedLines(kind, result)
89
- if (lines.length === 0) return new Container()
90
- const indent = " ".repeat(rowIndent() + 2)
91
- const rule = theme.fg("borderMuted", "│")
110
+ const ruled = (lines: string[], theme: Theme, indentCells: number, tone: "dim" | "error" | undefined = "dim"): Component => {
111
+ const indent = " ".repeat(rowIndent() + indentCells)
112
+ const rule = theme.fg(tone === "error" ? "error" : "borderMuted", "│")
92
113
  return {
93
114
  render(width: number): string[] {
94
115
  const inner = Math.max(0, width - indent.length - 2)
95
- return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`)
116
+ return lines.map((line) => `${indent}${rule} ${tone ? theme.fg(tone, truncateToWidth(line, inner)) : truncateToWidth(line, inner)}`)
96
117
  },
97
118
  invalidate() {},
98
119
  }
99
120
  }
100
121
 
101
- function errorLineBlock(line: string | undefined, theme: Theme) {
102
- if (!line) return undefined
103
- const indent = " ".repeat(rowIndent() + 2)
104
- const rule = theme.fg("error", "│")
105
- return {
106
- render(width: number): string[] {
107
- const inner = Math.max(0, width - indent.length - 2)
108
- return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`]
109
- },
110
- invalidate() {},
111
- }
122
+ function capped(lines: string[], max: number, more: (hidden: number) => string): string[] {
123
+ if (lines.length <= max) return lines
124
+ return [...lines.slice(0, max), more(lines.length - max)]
125
+ }
126
+
127
+ /** Plain text (output, file content, script) in the ruled block, dim, capped at the expanded limit. */
128
+ function textBlock(text: string, theme: Theme, indentCells: number, label?: string): Component | undefined {
129
+ if (!text) return undefined
130
+ const lines = capped(text.split("\n"), EXPANDED_MAX_LINES, (hidden) => `… ${hidden} more lines`)
131
+ return ruled(label ? [label, ...lines] : lines, theme, indentCells)
132
+ }
133
+
134
+ /** The edit's diff in the ruled block, coloured per line; inline views are capped and point at ctrl+o. */
135
+ function diffBlock(diff: string, theme: Theme, expanded: boolean): Component {
136
+ const all = diff.replace(/\n$/, "").split("\n")
137
+ const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES
138
+ const shown = colorDiffLines(all.slice(0, max), theme)
139
+ const hidden = all.length - shown.length
140
+ if (hidden > 0) shown.push(theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`))
141
+ return ruled(shown, theme, 2, undefined)
112
142
  }
113
143
 
114
- function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, context: RenderContext) {
115
- const item = toolItem(kind, args)
116
- const id = liveId(context, item)
117
- const running = rowIsRunning(context)
118
- context.state.running = running
119
- if (running) {
120
- context.state.startedAt ??= Date.now()
121
- live.watch(id, context.invalidate)
122
- } else {
123
- live.unwatch(id)
144
+ // ---------------------------------------------------------------------------------------------
145
+ // The one row lifecycle
146
+
147
+ /** `glyph verb item … outcome` on one line; an edit's `+N −M` is coloured success/error. */
148
+ function rowLine(theme: Theme, width: number, status: RowStatus, frame: 0 | 1, verb: string, item: string, outcome: Outcome | undefined, elapsed: string): string {
149
+ const diff = status !== "running" ? outcome?.diff : undefined
150
+ let right = status === "running" ? elapsed : (outcome?.text ?? "")
151
+ if (diff) {
152
+ const stats = `+${diff.added} −${diff.removed}`
153
+ right = right ? `${stats} · ${right}` : stats
124
154
  }
155
+ const inner = Math.max(8, width - rowIndent())
156
+ const row = layoutRow(inner, verb, item, right)
157
+ let coloredRight: string
158
+ if (status === "running") coloredRight = theme.fg("warning", row.outcome)
159
+ else if (status === "error") coloredRight = theme.fg("error", row.outcome)
160
+ else if (diff && row.outcome) {
161
+ const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length)
162
+ coloredRight = theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest)
163
+ } else coloredRight = theme.fg("dim", row.outcome)
164
+ return (
165
+ " ".repeat(rowIndent()) +
166
+ theme.fg(statusColor(status), rowGlyph(status, frame)) +
167
+ " " +
168
+ theme.fg("dim", row.verb) +
169
+ " " +
170
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
171
+ " ".repeat(row.gap) +
172
+ coloredRight
173
+ )
174
+ }
175
+
176
+ export function createRowRenderers(strategy: RowStrategy): ToolRenderers {
177
+ const detailIndent = strategy.detailIndent ?? 2
125
178
  return {
126
- render(width: number): string[] {
127
- const status = rowStatus(context)
128
- const blink = status === "running" && live.has(id)
129
- const glyph = rowGlyph(status, blink ? live.frame : 0)
130
- const outcome = context.state.outcome
131
- const diff = status !== "running" ? outcome?.diff : undefined
132
- let right = ""
133
- if (status === "running") {
134
- const started = context.state.startedAt
135
- right = started ? formatDuration(Date.now() - started) : ""
136
- } else if (outcome) {
137
- right = outcome.text
138
- if (diff) {
139
- const stats = `+${diff.added} −${diff.removed}`
140
- right = right ? `${stats} · ${right}` : stats
141
- }
179
+ renderShell: "self",
180
+ renderCall(args, theme, rawContext) {
181
+ const context = rawContext as RenderContext
182
+ const id = (context.state.liveId ??= context.toolCallId || `${strategy.verb}:${strategy.item(args, context.state)}`)
183
+ const running = rowIsRunning(context)
184
+ context.state.running = running
185
+ if (running) {
186
+ context.state.startedAt ??= Date.now()
187
+ live.watch(id, context.invalidate)
188
+ } else live.unwatch(id)
189
+ return {
190
+ render(width: number): string[] {
191
+ const status = rowStatus(context)
192
+ const frame: 0 | 1 = status === "running" && live.has(id) ? live.frame : 0
193
+ const elapsed = context.state.startedAt ? formatDuration(Date.now() - context.state.startedAt) : ""
194
+ const item = strategy.item(args, context.state)
195
+ const lines = [rowLine(theme, width, status, frame, strategy.verb, item, context.state.outcome, elapsed)]
196
+ if (strategy.childLines) lines.push(...strategy.childLines(context.state, theme, width, frame, Boolean(context.expanded)))
197
+ return lines
198
+ },
199
+ invalidate() {},
142
200
  }
143
- const inner = Math.max(8, width - rowIndent())
144
- const row = layoutRow(inner, kind, item, right)
145
-
146
- let coloredRight: string
147
- if (status === "running") coloredRight = theme.fg("warning", row.outcome)
148
- else if (status === "error") coloredRight = theme.fg("error", row.outcome)
149
- else if (diff && row.outcome) {
150
- const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length)
151
- coloredRight =
152
- theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest)
153
- } else coloredRight = theme.fg("dim", row.outcome)
154
-
155
- const line =
156
- " ".repeat(rowIndent()) +
157
- theme.fg(statusColor(status), glyph) +
158
- " " +
159
- theme.fg("dim", row.verb) +
160
- " " +
161
- theme.fg(status === "error" ? "text" : "muted", row.item) +
162
- " ".repeat(row.gap) +
163
- coloredRight
164
- return [line]
165
201
  },
166
- invalidate() {},
202
+ renderResult(result, options, theme, rawContext) {
203
+ const context = rawContext as RenderContext
204
+ const typed = result as ToolResultLike
205
+ const isError = Boolean(context.isError)
206
+ strategy.onResult?.(typed, context.state)
207
+ if (!context.isPartial) {
208
+ context.state.done = true
209
+ context.state.running = false
210
+ context.state.outcome = strategy.outcome(context.args, typed, isError, context.durationMs)
211
+ live.unwatch(context.state.liveId ?? context.toolCallId ?? `${strategy.verb}:${strategy.item(context.args, context.state)}`)
212
+ }
213
+ // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
214
+ // paints afterwards, so the call row's lazy render() already sees this state.
215
+ const container = new Container()
216
+ const expanded = Boolean((options as RenderOptions).expanded)
217
+ const errorLine = context.state.outcome?.errorLine
218
+ if (errorLine && !expanded) container.addChild(ruled([errorLine], theme, detailIndent, "error"))
219
+ if (!context.isPartial) {
220
+ const detail = strategy.detail?.(context.args, typed, context.state, theme, expanded, isError)
221
+ if (detail) container.addChild(detail)
222
+ }
223
+ return container
224
+ },
167
225
  }
168
226
  }
169
227
 
228
+ // ---------------------------------------------------------------------------------------------
229
+ // Strategy: built-in tools (bash, read, edit, write, grep, find, ls)
230
+
170
231
  const TOOL_KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
171
232
 
172
233
  export function isToolKind(name: string): name is ToolKind {
173
234
  return TOOL_KINDS.has(name)
174
235
  }
175
236
 
176
- export function compactRenderers(kind: ToolKind): ToolRenderers {
237
+ /** Which edit diffs show without ctrl+o under the current setting. */
238
+ function diffShownInline(diffLines: number): boolean {
239
+ if (editDiffMode === "always") return true
240
+ if (editDiffMode === "expanded") return diffLines <= SMALL_DIFF_MAX_LINES
241
+ return false
242
+ }
243
+
244
+ function compactStrategy(kind: ToolKind): RowStrategy {
177
245
  return {
178
- renderShell: "self",
179
- renderCall(args, theme, context) {
180
- return activityRow(kind, args as ToolArgs, theme, context as RenderContext)
181
- },
182
- renderResult(result, options, theme, context) {
183
- const renderContext = context as RenderContext
184
- const typedResult = result as ToolResultLike
185
- if (!renderContext.isPartial) {
186
- renderContext.state.done = true
187
- renderContext.state.running = false
188
- renderContext.state.outcome = formatOutcome(
189
- kind,
190
- renderContext.args,
191
- typedResult,
192
- Boolean(renderContext.isError),
193
- renderContext.durationMs,
194
- )
195
- live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
246
+ verb: kind,
247
+ item: (args) => toolItem(kind, args as ToolArgs),
248
+ outcome: (args, result, isError, durationMs) => formatOutcome(kind, args as ToolArgs, result, isError, durationMs),
249
+ detail(args, result, _state, theme, expanded, isError) {
250
+ if (kind === "edit" && !isError) {
251
+ const diff = editDiffText(result)
252
+ if (diff) {
253
+ const lines = diff.replace(/\n$/, "").split("\n").length
254
+ if (expanded ? editDiffMode !== "never" : diffShownInline(lines)) return diffBlock(diff, theme, expanded)
255
+ if (!expanded) return undefined
256
+ }
196
257
  }
197
- const container = new Container()
198
- const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
199
- if (errorLine && !(options as RenderOptions).expanded) container.addChild(errorLine)
200
- if ((options as RenderOptions).expanded) container.addChild(outputBlock(kind, typedResult, theme))
201
- return container
258
+ if (!expanded) return undefined
259
+ if (kind === "write" && !isError) {
260
+ const content = (args as ToolArgs | undefined)?.content
261
+ if (typeof content === "string") return textBlock(content.replace(/\n$/, ""), theme, 2)
262
+ }
263
+ const text = kind === "bash" ? bashBody(resultText(result)).body : resultText(result)
264
+ return textBlock(text, theme, 2)
202
265
  },
203
266
  }
204
267
  }
205
268
 
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
- )
269
+ export function compactRenderers(kind: ToolKind): ToolRenderers {
270
+ return createRowRenderers(compactStrategy(kind))
221
271
  }
222
272
 
273
+ // ---------------------------------------------------------------------------------------------
274
+ // Strategy: tools without a renderer of their own (most MCP tools, subagents_enable)
275
+
223
276
  /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
224
277
  const OUTCOME_TEXT_MAX = 24
225
278
 
226
279
  /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
227
280
  export function genericVerb(toolName: string): string {
228
- if (toolName.startsWith("mcp__")) return "mcp"
229
- if (/subagent/.test(toolName)) return "agent"
230
- return "tool"
281
+ if (toolName.startsWith("mcp__")) return "mcp"
282
+ if (/subagent/.test(toolName)) return "agent"
283
+ return "tool"
231
284
  }
232
285
 
233
286
  /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
234
287
  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
288
+ let json: string
289
+ try {
290
+ json = JSON.stringify(args ?? {}) ?? ""
291
+ } catch {
292
+ json = ""
293
+ }
294
+ const params = nestedItem({ name: toolName, args: json })
295
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName)
296
+ const label = mcp ? `${mcp[1]} · ${mcp[2]}` : toolName
297
+ return params ? `${label} ${params}` : label
245
298
  }
246
299
 
247
300
  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) }
301
+ const text = resultText(result)
302
+ const dur = formatDuration(durationMs)
303
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
304
+ const lines = text.split("\n").filter((line) => line.trim().length > 0)
305
+ if (isError) return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" }
306
+ if (lines.length === 0) return { text: join("ok", dur) }
307
+ const first = lines[0].trim()
308
+ if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX) return { text: join(first, dur) }
309
+ return { text: join(`${lines.length} line${lines.length === 1 ? "" : "s"}`, dur) }
257
310
  }
258
311
 
259
312
  /**
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.
313
+ * `✓ mcp re_mcp · query_prometheus query=… 12 lines · 1.4s`. Without it such tools fall
314
+ * through to pi-core's default: bold name, JSON arguments, a text preview.
263
315
  */
264
316
  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
- }
317
+ return createRowRenderers({
318
+ verb: genericVerb(toolName),
319
+ item: (args) => genericItem(toolName, args),
320
+ outcome: (_args, result, isError, durationMs) => genericOutcome(result, isError, durationMs),
321
+ detail: (_args, result, _state, theme, expanded) => (expanded ? textBlock(resultText(result), theme, 2) : undefined),
322
+ })
316
323
  }
317
324
 
318
325
  /** True when a tool definition draws its own call or result. */
319
326
  function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
320
- return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
327
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
321
328
  }
322
329
 
330
+ // ---------------------------------------------------------------------------------------------
331
+ // Strategy: codemode, one row per nested call
332
+
323
333
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
324
334
  const NESTED_PREVIEW = 8
325
335
  /** Width of the nested tool-name column. */
326
336
  const NESTED_NAME_MAX = 14
327
337
 
328
- type CodemodeState = RowState & { calls?: NestedCall[]; output?: string; code?: string }
338
+ type CodemodeExtra = { calls?: NestedCall[]; output?: string; code?: string }
339
+
340
+ const codemodeExtra = (state: RowState): CodemodeExtra => (state.extra ??= {}) as CodemodeExtra
329
341
 
330
342
  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" }
343
+ if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
344
+ if (call.status === "error") return { glyph: "✗", color: "error" }
345
+ if (call.status === "cancelled") return { glyph: "⊘", color: "dim" }
346
+ return { glyph: "✓", color: "success" }
335
347
  }
336
348
 
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))}`))
349
+ function nestedCallLines(calls: NestedCall[], theme: Theme, width: number, frame: 0 | 1, expanded: boolean): string[] {
350
+ const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
351
+ const childIndent = " ".repeat(rowIndent() + 4)
352
+ const lines: string[] = []
353
+ if (shown.length < calls.length) lines.push(`${childIndent}${theme.fg("dim", `… ${calls.length - shown.length} earlier calls`)}`)
354
+ const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
355
+ for (const call of shown) {
356
+ const { glyph, color } = nestedGlyph(call, frame)
357
+ const name = truncateToWidth(call.name ?? "tool", nameWidth, "…").padEnd(nameWidth)
358
+ const dur = call.status === "error" ? `failed · ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
359
+ const room = Math.max(8, width - childIndent.length)
360
+ const prefixWidth = 2 + nameWidth + 1
361
+ const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
362
+ const item = truncateToWidth(nestedItem(call), itemRoom, "…")
363
+ const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
364
+ lines.push(
365
+ childIndent +
366
+ theme.fg(color, glyph) +
367
+ " " +
368
+ theme.fg("dim", name) +
369
+ " " +
370
+ theme.fg(call.status === "error" ? "text" : "muted", item) +
371
+ " ".repeat(gap) +
372
+ theme.fg(call.status === "error" ? "error" : "dim", dur),
373
+ )
374
+ }
375
+ return lines
345
376
  }
346
377
 
347
378
  /**
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.
379
+ * `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`, then one child row per nested
380
+ * call. The script and its output appear only when expanded, undecorated, so syntax colours
381
+ * never compete with status colours in the transcript.
351
382
  */
352
383
  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
- }
384
+ return createRowRenderers({
385
+ verb: "code",
386
+ item: (_args, state) => callSummary(codemodeExtra(state).calls ?? []),
387
+ detailIndent: 4,
388
+ onResult(result, state) {
389
+ const extra = codemodeExtra(state)
390
+ extra.calls = nestedCalls(result)
391
+ extra.output = scriptOutput(result)
392
+ },
393
+ outcome(_args, result, isError, durationMs) {
394
+ const outcome = codemodeOutcome(result, isError, durationMs)
395
+ return { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
396
+ },
397
+ childLines: (state, theme, width, frame, expanded) => nestedCallLines(codemodeExtra(state).calls ?? [], theme, width, frame, expanded),
398
+ detail(args, _result, state, theme, expanded) {
399
+ if (!expanded) return undefined
400
+ const code = typeof (args as { code?: unknown } | undefined)?.code === "string" ? (args as { code: string }).code.trimEnd() : ""
401
+ const output = codemodeExtra(state).output ?? ""
402
+ const parts = [
403
+ code ? textBlock(code, theme, 4, `// script · ${code.split("\n").length} lines`) : undefined,
404
+ output ? textBlock(output, theme, 4, "// output") : undefined,
405
+ ].filter((part): part is Component => part !== undefined)
406
+ if (parts.length === 0) return undefined
407
+ return { render: (width) => parts.flatMap((part) => part.render(width)), invalidate() {} }
408
+ },
409
+ })
444
410
  }
445
411
 
446
412
  /**
@@ -458,3 +424,4 @@ export function installToolRenderers(pi: ExtensionAPI): void {
458
424
  return { ...definition, ...genericRenderers(toolName) }
459
425
  })
460
426
  }
427
+