pi-minimal 0.4.0 → 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,8 +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 { diffWords } from "diff"
7
6
  import type { EditDiffMode } from "./config.js"
7
+ import { colorDiffLines, editDiffText } from "./edit-diff.js"
8
8
  import { createLiveWatch } from "./live-watch.js"
9
9
  import {
10
10
  bashBody,
@@ -24,27 +24,50 @@ import {
24
24
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
25
25
  const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
26
26
 
27
- type RenderOptions = {
28
- expanded?: boolean
29
- }
27
+ type Component = { render(width: number): string[]; invalidate(): void }
28
+
29
+ type RenderOptions = { expanded?: boolean }
30
30
 
31
- type RowState = {
31
+ export type RowState = {
32
32
  done?: boolean
33
33
  running?: boolean
34
34
  startedAt?: number
35
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
36
40
  }
37
41
 
38
- type RenderContext = {
42
+ export type RenderContext = {
39
43
  isPartial?: boolean
40
44
  isError?: boolean
41
45
  invalidate: () => void
42
46
  state: RowState
43
- args?: ToolArgs
47
+ args?: unknown
44
48
  toolCallId?: string
45
49
  expanded?: boolean
46
50
  durationMs?: number
47
- 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
48
71
  }
49
72
 
50
73
  const live = createLiveWatch(400)
@@ -55,6 +78,8 @@ const rowIndent = () => margin() + 1
55
78
  const EXPANDED_MAX_LINES = 400
56
79
  /** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
57
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
58
83
 
59
84
  let editDiffMode: EditDiffMode = "expanded"
60
85
 
@@ -67,13 +92,9 @@ export function stopLiveWatch(): void {
67
92
  live.stop()
68
93
  }
69
94
 
70
- function liveId(context: RenderContext, item: string): string {
71
- return context.toolCallId || item
72
- }
73
-
74
95
  function rowStatus(context: RenderContext): RowStatus {
75
96
  if (context.isError) return "error"
76
- if (context.state.outcome?.errorLine) return "error"
97
+ if (context.state.outcome?.errorLine !== undefined) return "error"
77
98
  return rowIsRunning(context) ? "running" : "ok"
78
99
  }
79
100
 
@@ -83,459 +104,309 @@ function statusColor(status: RowStatus): "error" | "warning" | "success" {
83
104
  return "success"
84
105
  }
85
106
 
86
- function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): string[] {
87
- let text = resultText(result)
88
- if (kind === "bash") text = bashBody(text).body
89
- if (!text) return []
90
- const lines = text.split("\n")
91
- if (lines.length > EXPANDED_MAX_LINES) {
92
- const hidden = lines.length - EXPANDED_MAX_LINES
93
- return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`]
94
- }
95
- return lines
96
- }
107
+ // ---------------------------------------------------------------------------------------------
108
+ // Blocks under a row
97
109
 
98
- function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
99
- const lines = expandedLines(kind, result)
100
- if (lines.length === 0) return new Container()
101
- const indent = " ".repeat(rowIndent() + 2)
102
- 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", "│")
103
113
  return {
104
114
  render(width: number): string[] {
105
115
  const inner = Math.max(0, width - indent.length - 2)
106
- 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)}`)
107
117
  },
108
118
  invalidate() {},
109
119
  }
110
120
  }
111
121
 
112
- function editDiffText(result: ToolResultLike | undefined): string | undefined {
113
- const details = result?.details as { diff?: unknown } | undefined
114
- return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
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)]
115
125
  }
116
126
 
117
- type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
118
-
119
- /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
120
- function parseDiffLine(line: string): DiffLine | undefined {
121
- const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
122
- if (!match) return undefined
123
- return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
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)
124
132
  }
125
133
 
126
- /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
127
- function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
128
- let out = { removed: "", added: "" }
129
- let firstRemoved = true
130
- let firstAdded = true
131
- for (const part of diffWords(removed, added)) {
132
- if (part.removed || part.added) {
133
- const key = part.removed ? "removed" : "added"
134
- let value = part.value
135
- if (part.removed ? firstRemoved : firstAdded) {
136
- const ws = /^(\s*)/.exec(value)?.[1] ?? ""
137
- value = value.slice(ws.length)
138
- out = { ...out, [key]: out[key] + ws }
139
- if (part.removed) firstRemoved = false
140
- else firstAdded = false
141
- }
142
- if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
143
- } else {
144
- out = { removed: out.removed + part.value, added: out.added + part.value }
145
- }
146
- }
147
- return out
148
- }
149
-
150
- /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
151
- function colorDiffLines(lines: string[], theme: Theme): string[] {
152
- const out: string[] = []
153
- for (let i = 0; i < lines.length; ) {
154
- const parsed = parseDiffLine(lines[i]!)
155
- if (!parsed) { out.push(theme.fg("toolDiffContext", lines[i]!)); i++; continue }
156
- if (parsed.prefix === " ") { out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`)); i++; continue }
157
- const removed: DiffLine[] = []
158
- const added: DiffLine[] = []
159
- for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
160
- for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
161
- if (removed.length === 1 && added.length === 1) {
162
- const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
163
- out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
164
- out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
165
- continue
166
- }
167
- for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
168
- for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
169
- }
170
- return out
171
- }
172
-
173
- /** The edit's diff in the `│`-ruled block. Inline views are capped; expanded views show up to the usual limit. */
174
- function diffBlock(diff: string, theme: Theme, expanded: boolean) {
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 {
175
136
  const all = diff.replace(/\n$/, "").split("\n")
176
137
  const max = expanded ? EXPANDED_MAX_LINES : INLINE_DIFF_MAX_LINES
177
- const lines = colorDiffLines(all.length > max ? all.slice(0, max) : all, theme)
178
- const hidden = all.length - lines.length
179
- const indent = " ".repeat(rowIndent() + 2)
180
- const rule = theme.fg("borderMuted", "│")
181
- return {
182
- render(width: number): string[] {
183
- const inner = Math.max(0, width - indent.length - 2)
184
- const out = lines.map((line) => `${indent}${rule} ${truncateToWidth(line, inner)}`)
185
- if (hidden > 0) out.push(`${indent}${rule} ${theme.fg("dim", `… ${hidden} more lines${expanded ? "" : " (ctrl+o)"}`)}`)
186
- return out
187
- },
188
- invalidate() {},
189
- }
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)
190
142
  }
191
143
 
192
- function errorLineBlock(line: string | undefined, theme: Theme) {
193
- if (!line) return undefined
194
- const indent = " ".repeat(rowIndent() + 2)
195
- const rule = theme.fg("error", "│")
196
- return {
197
- render(width: number): string[] {
198
- const inner = Math.max(0, width - indent.length - 2)
199
- return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`]
200
- },
201
- invalidate() {},
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
202
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
+ )
203
174
  }
204
175
 
205
- function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, context: RenderContext) {
206
- const item = toolItem(kind, args)
207
- const id = liveId(context, item)
208
- const running = rowIsRunning(context)
209
- context.state.running = running
210
- if (running) {
211
- context.state.startedAt ??= Date.now()
212
- live.watch(id, context.invalidate)
213
- } else {
214
- live.unwatch(id)
215
- }
176
+ export function createRowRenderers(strategy: RowStrategy): ToolRenderers {
177
+ const detailIndent = strategy.detailIndent ?? 2
216
178
  return {
217
- render(width: number): string[] {
218
- const status = rowStatus(context)
219
- const blink = status === "running" && live.has(id)
220
- const glyph = rowGlyph(status, blink ? live.frame : 0)
221
- const outcome = context.state.outcome
222
- const diff = status !== "running" ? outcome?.diff : undefined
223
- let right = ""
224
- if (status === "running") {
225
- const started = context.state.startedAt
226
- right = started ? formatDuration(Date.now() - started) : ""
227
- } else if (outcome) {
228
- right = outcome.text
229
- if (diff) {
230
- const stats = `+${diff.added} −${diff.removed}`
231
- right = right ? `${stats} · ${right}` : stats
232
- }
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() {},
233
200
  }
234
- const inner = Math.max(8, width - rowIndent())
235
- const row = layoutRow(inner, kind, item, right)
236
-
237
- let coloredRight: string
238
- if (status === "running") coloredRight = theme.fg("warning", row.outcome)
239
- else if (status === "error") coloredRight = theme.fg("error", row.outcome)
240
- else if (diff && row.outcome) {
241
- const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length)
242
- coloredRight =
243
- theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest)
244
- } else coloredRight = theme.fg("dim", row.outcome)
245
-
246
- const line =
247
- " ".repeat(rowIndent()) +
248
- theme.fg(statusColor(status), glyph) +
249
- " " +
250
- theme.fg("dim", row.verb) +
251
- " " +
252
- theme.fg(status === "error" ? "text" : "muted", row.item) +
253
- " ".repeat(row.gap) +
254
- coloredRight
255
- return [line]
256
201
  },
257
- 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
+ },
258
225
  }
259
226
  }
260
227
 
228
+ // ---------------------------------------------------------------------------------------------
229
+ // Strategy: built-in tools (bash, read, edit, write, grep, find, ls)
230
+
261
231
  const TOOL_KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
262
232
 
263
233
  export function isToolKind(name: string): name is ToolKind {
264
234
  return TOOL_KINDS.has(name)
265
235
  }
266
236
 
267
- 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 {
268
245
  return {
269
- renderShell: "self",
270
- renderCall(args, theme, context) {
271
- return activityRow(kind, args as ToolArgs, theme, context as RenderContext)
272
- },
273
- renderResult(result, options, theme, context) {
274
- const renderContext = context as RenderContext
275
- const typedResult = result as ToolResultLike
276
- if (!renderContext.isPartial) {
277
- renderContext.state.done = true
278
- renderContext.state.running = false
279
- renderContext.state.outcome = formatOutcome(
280
- kind,
281
- renderContext.args,
282
- typedResult,
283
- Boolean(renderContext.isError),
284
- renderContext.durationMs,
285
- )
286
- 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
+ }
287
257
  }
288
- const container = new Container()
289
- const expanded = Boolean((options as RenderOptions).expanded)
290
- const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
291
- if (errorLine && !expanded) container.addChild(errorLine)
292
- const diff = kind === "edit" && !renderContext.isError && !renderContext.isPartial ? editDiffText(typedResult) : undefined
293
- const showDiff = diff !== undefined && (editDiffMode === "always" || (editDiffMode === "expanded" && expanded))
294
- if (showDiff) container.addChild(diffBlock(diff, theme, expanded))
295
- else if (expanded) container.addChild(outputBlock(kind, typedResult, theme))
296
- 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)
297
265
  },
298
266
  }
299
267
  }
300
268
 
301
- /** One `glyph verb item … outcome` line in the tool-row grid, shared by every self-rendered row. */
302
- function rowLine(theme: Theme, width: number, status: RowStatus, frame: 0 | 1, verb: string, item: string, right: string): string {
303
- const inner = Math.max(8, width - rowIndent())
304
- const row = layoutRow(inner, verb, item, right)
305
- const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
306
- return (
307
- " ".repeat(rowIndent()) +
308
- theme.fg(statusColor(status), rowGlyph(status, frame)) +
309
- " " +
310
- theme.fg("dim", row.verb) +
311
- " " +
312
- theme.fg(status === "error" ? "text" : "muted", row.item) +
313
- " ".repeat(row.gap) +
314
- theme.fg(rightColor, row.outcome)
315
- )
269
+ export function compactRenderers(kind: ToolKind): ToolRenderers {
270
+ return createRowRenderers(compactStrategy(kind))
316
271
  }
317
272
 
273
+ // ---------------------------------------------------------------------------------------------
274
+ // Strategy: tools without a renderer of their own (most MCP tools, subagents_enable)
275
+
318
276
  /** Longest first result line shown verbatim as an outcome; longer results show a line count. */
319
277
  const OUTCOME_TEXT_MAX = 24
320
278
 
321
279
  /** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
322
280
  export function genericVerb(toolName: string): string {
323
- if (toolName.startsWith("mcp__")) return "mcp"
324
- if (/subagent/.test(toolName)) return "agent"
325
- return "tool"
281
+ if (toolName.startsWith("mcp__")) return "mcp"
282
+ if (/subagent/.test(toolName)) return "agent"
283
+ return "tool"
326
284
  }
327
285
 
328
286
  /** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
329
287
  export function genericItem(toolName: string, args: unknown): string {
330
- let json: string
331
- try {
332
- json = JSON.stringify(args ?? {}) ?? ""
333
- } catch {
334
- json = ""
335
- }
336
- const params = nestedItem({ name: toolName, args: json })
337
- const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName)
338
- const label = mcp ? `${mcp[1]} \u00b7 ${mcp[2]}` : toolName
339
- 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
340
298
  }
341
299
 
342
300
  export function genericOutcome(result: ToolResultLike | undefined, isError: boolean, durationMs: number | undefined): Outcome {
343
- const text = resultText(result)
344
- const dur = formatDuration(durationMs)
345
- const join = (...parts: string[]) => parts.filter(Boolean).join(" \u00b7 ")
346
- const lines = text.split("\n").filter((line) => line.trim().length > 0)
347
- if (isError) return { text: join("failed", dur), errorLine: lines[0]?.trim() ?? "" }
348
- if (lines.length === 0) return { text: join("ok", dur) }
349
- const first = lines[0].trim()
350
- if (lines.length === 1 && visibleWidth(first) <= OUTCOME_TEXT_MAX) return { text: join(first, dur) }
351
- 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) }
352
310
  }
353
311
 
354
312
  /**
355
- * A one-line row for tools that bring no renderer of their own (most MCP tools,
356
- * `subagents_enable`): `\u2713 mcp re_mcp \u00b7 query_prometheus query=\u2026 12 lines \u00b7 1.4s`.
357
- * 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.
358
315
  */
359
316
  export function genericRenderers(toolName: string): ToolRenderers {
360
- const verb = genericVerb(toolName)
361
- return {
362
- renderShell: "self",
363
- renderCall(args, theme, rawContext) {
364
- const context = rawContext as RenderContext
365
- const id = context.toolCallId || toolName
366
- const item = genericItem(toolName, args)
367
- const running = rowIsRunning(context)
368
- context.state.running = running
369
- if (running) {
370
- context.state.startedAt ??= Date.now()
371
- live.watch(id, context.invalidate)
372
- } else live.unwatch(id)
373
- return {
374
- render(width: number): string[] {
375
- const status = rowStatus(context)
376
- const frame = status === "running" && live.has(id) ? live.frame : 0
377
- const right =
378
- status === "running"
379
- ? context.state.startedAt
380
- ? formatDuration(Date.now() - context.state.startedAt)
381
- : ""
382
- : (context.state.outcome?.text ?? "")
383
- return [rowLine(theme, width, status, frame, verb, item, right)]
384
- },
385
- invalidate() {},
386
- }
387
- },
388
- renderResult(result, options, theme, rawContext) {
389
- const context = rawContext as RenderContext
390
- const typed = result as ToolResultLike
391
- if (!context.isPartial) {
392
- context.state.done = true
393
- context.state.running = false
394
- context.state.outcome = genericOutcome(typed, Boolean(context.isError), context.durationMs)
395
- live.unwatch(context.toolCallId || toolName)
396
- }
397
- const container = new Container()
398
- const expanded = Boolean((options as RenderOptions).expanded)
399
- const errorLine = context.state.outcome?.errorLine
400
- if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
401
- if (expanded) {
402
- const text = resultText(typed)
403
- if (text) {
404
- const indent = " ".repeat(rowIndent() + 2)
405
- container.addChild({ render: (width: number) => ruledLines(text, width, theme, indent), invalidate() {} })
406
- }
407
- }
408
- return container
409
- },
410
- }
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
+ })
411
323
  }
412
324
 
413
325
  /** True when a tool definition draws its own call or result. */
414
326
  function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
415
- return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
327
+ return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
416
328
  }
417
329
 
330
+ // ---------------------------------------------------------------------------------------------
331
+ // Strategy: codemode, one row per nested call
332
+
418
333
  /** Max nested calls shown under a collapsed codemode row; the rest are counted. */
419
334
  const NESTED_PREVIEW = 8
420
335
  /** Width of the nested tool-name column. */
421
336
  const NESTED_NAME_MAX = 14
422
337
 
423
- 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
424
341
 
425
342
  function nestedGlyph(call: NestedCall, frame: 0 | 1): { glyph: string; color: "error" | "warning" | "success" | "dim" } {
426
- if (call.status === "running") return { glyph: rowGlyph("running", frame), color: "warning" }
427
- if (call.status === "error") return { glyph: "\u2717", color: "error" }
428
- if (call.status === "cancelled") return { glyph: "\u2298", color: "dim" }
429
- 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" }
430
347
  }
431
348
 
432
- /** Lines of a `\u2502`-ruled block, dim and truncated, for script source or output on expand. */
433
- function ruledLines(text: string, width: number, theme: Theme, indent: string, label?: string): string[] {
434
- const inner = Math.max(0, width - indent.length - 2)
435
- const rule = theme.fg("borderMuted", "\u2502")
436
- let lines = text.split("\n")
437
- if (lines.length > EXPANDED_MAX_LINES) lines = [...lines.slice(0, EXPANDED_MAX_LINES), `\u2026 ${lines.length - EXPANDED_MAX_LINES} more lines`]
438
- const out = label ? [`${indent}${rule} ${theme.fg("dim", truncateToWidth(label, inner))}`] : []
439
- 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
440
376
  }
441
377
 
442
378
  /**
443
- * codemode as a tool row: `\u2713 code 3 calls \u00b7 plan_tracker \u00d72, bash \u2026 54 lines \u00b7 1.2s`, then one
444
- * child row per nested call. The script and its output appear only when expanded, undecorated,
445
- * 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.
446
382
  */
447
383
  export function codemodeRenderers(): ToolRenderers {
448
- return {
449
- renderShell: "self",
450
- renderCall(args, theme, rawContext) {
451
- const context = rawContext as RenderContext & { state: CodemodeState }
452
- const code = typeof (args as { code?: unknown })?.code === "string" ? ((args as { code: string }).code) : ""
453
- context.state.code = code
454
- const id = context.toolCallId || "codemode"
455
- const running = rowIsRunning(context)
456
- context.state.running = running
457
- if (running) {
458
- context.state.startedAt ??= Date.now()
459
- live.watch(id, context.invalidate)
460
- } else live.unwatch(id)
461
- return {
462
- render(width: number): string[] {
463
- const state = context.state
464
- const status: RowStatus = state.outcome?.errorLine !== undefined || context.isError ? "error" : rowIsRunning(context) ? "running" : "ok"
465
- const frame = status === "running" && live.has(id) ? live.frame : 0
466
- const calls = state.calls ?? []
467
- const right =
468
- status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
469
- const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)]
470
- const expanded = Boolean(context.expanded)
471
- const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
472
- const childIndent = " ".repeat(rowIndent() + 4)
473
- if (shown.length < calls.length) {
474
- lines.push(`${childIndent}${theme.fg("dim", `\u2026 ${calls.length - shown.length} earlier calls`)}`)
475
- }
476
- const nameWidth = Math.min(NESTED_NAME_MAX, Math.max(0, ...shown.map((call) => (call.name ?? "tool").length)))
477
- for (const call of shown) {
478
- const { glyph, color } = nestedGlyph(call, frame)
479
- const name = truncateToWidth(call.name ?? "tool", nameWidth, "\u2026").padEnd(nameWidth)
480
- const dur = call.status === "error" ? `failed \u00b7 ${formatDuration(call.durationMs)}` : formatDuration(call.durationMs)
481
- const room = Math.max(8, width - childIndent.length)
482
- // Reuse the row grid with the name as the "verb" column, then fix the glyph slot.
483
- const prefixWidth = 2 + nameWidth + 1
484
- const itemRoom = Math.max(0, room - prefixWidth - (dur ? visibleWidth(dur) + 2 : 0))
485
- const item = truncateToWidth(nestedItem(call), itemRoom, "\u2026")
486
- const gap = dur ? Math.max(2, room - prefixWidth - visibleWidth(item) - visibleWidth(dur)) : 0
487
- lines.push(
488
- childIndent +
489
- theme.fg(color, glyph) +
490
- " " +
491
- theme.fg("dim", name) +
492
- " " +
493
- theme.fg(call.status === "error" ? "text" : "muted", item) +
494
- " ".repeat(gap) +
495
- theme.fg(call.status === "error" ? "error" : "dim", dur),
496
- )
497
- }
498
- return lines
499
- },
500
- invalidate() {},
501
- }
502
- },
503
- renderResult(result, options, theme, rawContext) {
504
- const context = rawContext as RenderContext & { state: CodemodeState }
505
- const typed = result as ToolResultLike
506
- context.state.calls = nestedCalls(typed)
507
- if (!context.isPartial) {
508
- context.state.done = true
509
- context.state.running = false
510
- const outcome = codemodeOutcome(typed, Boolean(context.isError), context.durationMs)
511
- context.state.outcome = { text: outcome.text, errorLine: outcome.failed ? (outcome.errorLine ?? "") : undefined }
512
- context.state.output = scriptOutput(typed)
513
- live.unwatch(context.toolCallId || "codemode")
514
- }
515
- // No invalidate here: the host runs renderCall and renderResult in one updateDisplay() and
516
- // paints afterwards, so the call row's lazy render() already sees these calls. Calling
517
- // invalidate() would re-enter updateDisplay() and recurse.
518
- const container = new Container()
519
- const expanded = Boolean((options as RenderOptions).expanded)
520
- const errorLine = context.state.outcome?.errorLine
521
- if (errorLine && !expanded) container.addChild(errorLineBlock(errorLine, theme)!)
522
- if (expanded) {
523
- const indent = " ".repeat(rowIndent() + 4)
524
- const code = context.state.code ?? ""
525
- const output = context.state.output ?? ""
526
- container.addChild({
527
- render(width: number): string[] {
528
- const lines: string[] = []
529
- if (code) lines.push(...ruledLines(code.trimEnd(), width, theme, indent, `// script \u00b7 ${code.trimEnd().split("\n").length} lines`))
530
- if (output) lines.push(...ruledLines(output, width, theme, indent, "// output"))
531
- return lines
532
- },
533
- invalidate() {},
534
- })
535
- }
536
- return container
537
- },
538
- }
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
+ })
539
410
  }
540
411
 
541
412
  /**
@@ -553,3 +424,4 @@ export function installToolRenderers(pi: ExtensionAPI): void {
553
424
  return { ...definition, ...genericRenderers(toolName) }
554
425
  })
555
426
  }
427
+