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/CHANGELOG.md +46 -0
- package/CONTEXT.md +83 -0
- package/README.md +3 -3
- package/dist/config.js +2 -1
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/git-state.js +31 -11
- package/dist/index.js +24 -208
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +1 -1
- package/release-notes.md +46 -12
- package/src/config.ts +2 -1
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/git-state.ts +29 -10
- package/src/index.ts +26 -201
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +2 -2
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +45 -3
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
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
|
|
28
|
-
|
|
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?:
|
|
47
|
+
args?: unknown
|
|
44
48
|
toolCallId?: string
|
|
45
49
|
expanded?: boolean
|
|
46
50
|
durationMs?: number
|
|
47
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
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(
|
|
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
|
|
113
|
-
|
|
114
|
-
return
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
127
|
-
function
|
|
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
|
|
178
|
-
const hidden = all.length -
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
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
|
|
206
|
-
const
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
*
|
|
356
|
-
*
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
|