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/CHANGELOG.md +60 -0
- package/CONTEXT.md +83 -0
- package/README.md +6 -3
- package/dist/config.js +21 -9
- 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 +23 -206
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +224 -281
- 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 +4 -1
- package/release-notes.md +45 -19
- package/src/config.ts +20 -9
- 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 +25 -199
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +279 -312
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +41 -20
- 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 +98 -0
- 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,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
|
|
26
|
-
|
|
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?:
|
|
47
|
+
args?: unknown
|
|
42
48
|
toolCallId?: string
|
|
43
49
|
expanded?: boolean
|
|
44
50
|
durationMs?: number
|
|
45
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
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(
|
|
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
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
*
|
|
261
|
-
*
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
|