pi-minimal 0.3.0 → 0.4.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 +36 -0
- package/README.md +7 -4
- package/dist/config.js +19 -8
- package/dist/index.js +4 -1
- package/dist/tool-rows.js +34 -1
- package/dist/tools.js +230 -18
- package/dist/tracker.js +16 -12
- package/dist/transcript.js +74 -15
- package/package.json +4 -1
- package/release-notes.md +12 -21
- package/src/config.ts +18 -8
- package/src/index.ts +4 -1
- package/src/tool-rows.ts +38 -1
- package/src/tools.ts +220 -18
- package/src/tracker.ts +14 -9
- package/src/transcript.ts +93 -38
- package/test/codemode-rows.test.ts +1 -1
- package/test/config.test.ts +40 -19
- package/test/package.test.ts +0 -23
- package/test/tool-rows.test.ts +17 -1
- package/test/tools.test.ts +121 -3
- package/test/tracker.test.ts +18 -0
- package/test/transcript.test.ts +50 -3
package/release-notes.md
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
### Added
|
|
2
|
-
- `codemode` renders as a tool row: `✓ code 3 calls · plan_tracker ×2, bash … 54 lines · 1.2s`,
|
|
3
|
-
with one child row per nested call. Built-in tools inside a script read exactly like their
|
|
4
|
-
top-level rows (`bash` shows its command); other tools show up to three scalar arguments as
|
|
5
|
-
`key=value` instead of raw JSON. Children update live while the script runs. The script source
|
|
6
|
-
and output appear only on `ctrl+o`, in plain dim text, so syntax colours no longer compete with
|
|
7
|
-
status colours. A failed nested call or script error shows its first line under the row, like
|
|
8
|
-
`bash`. Previously codemode fell through to pi-core's default panel: a background slab with the
|
|
9
|
-
highlighted script, JSON arguments, an output preview and a `Took …` timer.
|
|
10
2
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
their slab; failures keep `toolErrorBg`.
|
|
3
|
+
- `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
|
|
4
|
+
change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
|
|
5
|
+
block when the row is expanded with `ctrl+o`, where the bare "Successfully
|
|
6
|
+
replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
|
|
7
|
+
capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
|
|
8
|
+
the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
|
|
9
|
+
`toolDiffContext` colours and keep pi's line-number gutter; when one line
|
|
10
|
+
replaces one line, only the changed tokens are inverse-highlighted, as in pi's
|
|
11
|
+
own edit renderer. Failed edits and results without a diff are unchanged.
|
|
21
12
|
|
|
22
13
|
## What's Changed
|
|
23
|
-
* feat:
|
|
14
|
+
* feat: editDiff setting — show edit diffs always, never, or behind ctrl+o (0.4.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/18
|
|
24
15
|
|
|
25
16
|
|
|
26
|
-
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.
|
|
17
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.1...v0.4.0
|
|
27
18
|
|
|
28
19
|
---
|
|
29
20
|
**Install / upgrade**
|
|
30
21
|
```bash
|
|
31
|
-
pi install npm:pi-minimal@0.
|
|
22
|
+
pi install npm:pi-minimal@0.4.0 # or: pi update --extensions
|
|
32
23
|
```
|
package/src/config.ts
CHANGED
|
@@ -2,12 +2,18 @@ import { readFileSync } from "node:fs"
|
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"
|
|
4
4
|
|
|
5
|
+
/** When an `edit` row shows its diff: inline as soon as the edit lands, never, or only while expanded with ctrl+o. */
|
|
6
|
+
export type EditDiffMode = "always" | "never" | "expanded"
|
|
7
|
+
export const EDIT_DIFF_MODES: readonly EditDiffMode[] = ["always", "never", "expanded"]
|
|
8
|
+
|
|
5
9
|
export type PiMinimalConfig = {
|
|
6
10
|
hideGauntletWidgets: boolean
|
|
11
|
+
editDiff: EditDiffMode
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
const DEFAULTS: PiMinimalConfig = {
|
|
10
15
|
hideGauntletWidgets: true,
|
|
16
|
+
editDiff: "expanded",
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export function loadPiMinimalConfig(options: {
|
|
@@ -44,16 +50,20 @@ function applyLayer(config: PiMinimalConfig, path: string): void {
|
|
|
44
50
|
return
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
// Each key is validated on its own so one bad value does not discard the rest of the layer.
|
|
47
54
|
const record = parsed as Record<string, unknown>
|
|
48
|
-
if (
|
|
49
|
-
|
|
55
|
+
if (Object.hasOwn(record, "hideGauntletWidgets")) {
|
|
56
|
+
const value = record.hideGauntletWidgets
|
|
57
|
+
if (typeof value === "boolean") config.hideGauntletWidgets = value
|
|
58
|
+
else console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
|
|
50
59
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
console.error(`pi-minimal:
|
|
55
|
-
return
|
|
60
|
+
if (Object.hasOwn(record, "editDiff")) {
|
|
61
|
+
const value = record.editDiff
|
|
62
|
+
if (isEditDiffMode(value)) config.editDiff = value
|
|
63
|
+
else console.error(`pi-minimal: editDiff must be one of ${EDIT_DIFF_MODES.map((mode) => `"${mode}"`).join(", ")} in ${path}`)
|
|
56
64
|
}
|
|
65
|
+
}
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
function isEditDiffMode(value: unknown): value is EditDiffMode {
|
|
68
|
+
return typeof value === "string" && (EDIT_DIFF_MODES as readonly string[]).includes(value)
|
|
59
69
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { bindFooterContributionEvents } from "./footer-contribution-events.js"
|
|
|
7
7
|
import { installCwd } from "./cwd.js"
|
|
8
8
|
import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
|
|
9
9
|
import { hideGauntletWidgets } from "./tracker.js"
|
|
10
|
-
import { installToolRenderers, stopLiveWatch } from "./tools.js"
|
|
10
|
+
import { installToolRenderers, setEditDiffMode, stopLiveWatch } from "./tools.js"
|
|
11
|
+
import { setSessionCwd } from "./tool-rows.js"
|
|
11
12
|
import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
|
|
12
13
|
import { installWorkingStatus } from "./working-status.js"
|
|
13
14
|
import { installTurnRule } from "./turn-rule.js"
|
|
@@ -166,7 +167,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
166
167
|
projectTrusted: ctx.isProjectTrusted(),
|
|
167
168
|
})
|
|
168
169
|
hideWidgets = config.hideGauntletWidgets
|
|
170
|
+
setEditDiffMode(config.editDiff)
|
|
169
171
|
currentCwd = ctx.cwd
|
|
172
|
+
setSessionCwd(ctx.cwd)
|
|
170
173
|
try {
|
|
171
174
|
const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
|
|
172
175
|
setChromeMargin(settings.getOutputPad())
|
package/src/tool-rows.ts
CHANGED
|
@@ -31,9 +31,46 @@ export function shortName(path: string): string {
|
|
|
31
31
|
return parts.slice(-2).join("/")
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
let sessionCwd: string | undefined
|
|
35
|
+
let homeDir: string | undefined = process.env.HOME
|
|
36
|
+
|
|
37
|
+
/** The session cwd, so `cd <cwd> && \u2026` can be dropped from bash rows. */
|
|
38
|
+
export function setSessionCwd(cwd: string | undefined, home = process.env.HOME): void {
|
|
39
|
+
sessionCwd = cwd
|
|
40
|
+
homeDir = home
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CD_PREFIX = /^cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*/
|
|
44
|
+
|
|
45
|
+
function expandHome(path: string): string {
|
|
46
|
+
const unquoted = path.replace(/^(["'])(.*)\1$/, "$2").replace(/\/+$/, "")
|
|
47
|
+
if (homeDir && (unquoted === "~" || unquoted.startsWith("~/"))) return homeDir + unquoted.slice(1)
|
|
48
|
+
return unquoted
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The item column of a bash row. Long agent commands usually start with `cd <repo> && `, which
|
|
53
|
+
* pushes the part that differs between rows off the screen: drop it when it is the session cwd,
|
|
54
|
+
* otherwise shorten it to the directory name (`pi-subagents \u203a sed -n \u2026`). Multi-line
|
|
55
|
+
* commands (heredocs, scripts) say how many lines follow instead of ending at `<<'EOF'`.
|
|
56
|
+
*/
|
|
57
|
+
export function bashItem(command: string): string {
|
|
58
|
+
const lines = command.replace(/\r/g, "").trim().split("\n")
|
|
59
|
+
let first = lines[0]?.trim() ?? ""
|
|
60
|
+
const cd = CD_PREFIX.exec(first)
|
|
61
|
+
if (cd && first.length > cd[0].length) {
|
|
62
|
+
const target = expandHome(cd[1])
|
|
63
|
+
const rest = first.slice(cd[0].length)
|
|
64
|
+
const cwd = sessionCwd?.replace(/\/+$/, "")
|
|
65
|
+
first = cwd && target === cwd ? rest : `${target.split("/").filter(Boolean).at(-1) ?? target} \u203a ${rest}`
|
|
66
|
+
}
|
|
67
|
+
const more = lines.slice(1).filter((line) => line.trim()).length
|
|
68
|
+
return more > 0 ? `${first} \u00b7 +${more} line${more === 1 ? "" : "s"}` : first
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
|
|
35
72
|
if (kind === "bash") {
|
|
36
|
-
const command = typeof args?.command === "string" ? args.command
|
|
73
|
+
const command = typeof args?.command === "string" ? bashItem(args.command) : ""
|
|
37
74
|
return command || "bash"
|
|
38
75
|
}
|
|
39
76
|
if (kind === "grep" || kind === "find") {
|
package/src/tools.ts
CHANGED
|
@@ -3,9 +3,10 @@ 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
|
+
import type { EditDiffMode } from "./config.js"
|
|
6
8
|
import { createLiveWatch } from "./live-watch.js"
|
|
7
9
|
import {
|
|
8
|
-
|
|
9
10
|
bashBody,
|
|
10
11
|
formatDuration,
|
|
11
12
|
formatOutcome,
|
|
@@ -52,6 +53,15 @@ const live = createLiveWatch(400)
|
|
|
52
53
|
const rowIndent = () => margin() + 1
|
|
53
54
|
/** Max lines of output shown when a row is expanded. */
|
|
54
55
|
const EXPANDED_MAX_LINES = 400
|
|
56
|
+
/** Max diff lines shown inline under an edit row with `editDiff: "always"`; ctrl+o shows the rest. */
|
|
57
|
+
const INLINE_DIFF_MAX_LINES = 40
|
|
58
|
+
|
|
59
|
+
let editDiffMode: EditDiffMode = "expanded"
|
|
60
|
+
|
|
61
|
+
/** Applies the `editDiff` setting; read at session start, so a change needs a new session or /reload. */
|
|
62
|
+
export function setEditDiffMode(mode: EditDiffMode): void {
|
|
63
|
+
editDiffMode = mode
|
|
64
|
+
}
|
|
55
65
|
|
|
56
66
|
export function stopLiveWatch(): void {
|
|
57
67
|
live.stop()
|
|
@@ -99,6 +109,86 @@ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme:
|
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
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
|
|
115
|
+
}
|
|
116
|
+
|
|
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, " ") }
|
|
124
|
+
}
|
|
125
|
+
|
|
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) {
|
|
175
|
+
const all = diff.replace(/\n$/, "").split("\n")
|
|
176
|
+
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
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
102
192
|
function errorLineBlock(line: string | undefined, theme: Theme) {
|
|
103
193
|
if (!line) return undefined
|
|
104
194
|
const indent = " ".repeat(rowIndent() + 2)
|
|
@@ -196,14 +286,135 @@ export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
|
196
286
|
live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
|
|
197
287
|
}
|
|
198
288
|
const container = new Container()
|
|
289
|
+
const expanded = Boolean((options as RenderOptions).expanded)
|
|
199
290
|
const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
|
|
200
|
-
if (errorLine && !
|
|
201
|
-
|
|
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))
|
|
202
296
|
return container
|
|
203
297
|
},
|
|
204
298
|
}
|
|
205
299
|
}
|
|
206
300
|
|
|
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
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Longest first result line shown verbatim as an outcome; longer results show a line count. */
|
|
319
|
+
const OUTCOME_TEXT_MAX = 24
|
|
320
|
+
|
|
321
|
+
/** `mcp` for MCP tools, `agent` for subagent tools, `tool` for anything else. */
|
|
322
|
+
export function genericVerb(toolName: string): string {
|
|
323
|
+
if (toolName.startsWith("mcp__")) return "mcp"
|
|
324
|
+
if (/subagent/.test(toolName)) return "agent"
|
|
325
|
+
return "tool"
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `server · tool key=value …` for MCP tools, `name key=value …` for the rest. */
|
|
329
|
+
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
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
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) }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
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.
|
|
358
|
+
*/
|
|
359
|
+
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
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** True when a tool definition draws its own call or result. */
|
|
414
|
+
function hasOwnRenderer(definition: ToolRenderers | undefined): boolean {
|
|
415
|
+
return typeof definition?.renderCall === "function" || typeof definition?.renderResult === "function"
|
|
416
|
+
}
|
|
417
|
+
|
|
207
418
|
/** Max nested calls shown under a collapsed codemode row; the rest are counted. */
|
|
208
419
|
const NESTED_PREVIEW = 8
|
|
209
420
|
/** Width of the nested tool-name column. */
|
|
@@ -255,19 +466,7 @@ export function codemodeRenderers(): ToolRenderers {
|
|
|
255
466
|
const calls = state.calls ?? []
|
|
256
467
|
const right =
|
|
257
468
|
status === "running" ? (state.startedAt ? formatDuration(Date.now() - state.startedAt) : "") : (state.outcome?.text ?? "")
|
|
258
|
-
const
|
|
259
|
-
const row = layoutRow(inner, "code", callSummary(calls), right)
|
|
260
|
-
const rightColor = status === "running" ? "warning" : status === "error" ? "error" : "dim"
|
|
261
|
-
const lines = [
|
|
262
|
-
" ".repeat(rowIndent()) +
|
|
263
|
-
theme.fg(statusColor(status), rowGlyph(status, frame)) +
|
|
264
|
-
" " +
|
|
265
|
-
theme.fg("dim", row.verb) +
|
|
266
|
-
" " +
|
|
267
|
-
theme.fg(status === "error" ? "text" : "muted", row.item) +
|
|
268
|
-
" ".repeat(row.gap) +
|
|
269
|
-
theme.fg(rightColor, row.outcome),
|
|
270
|
-
]
|
|
469
|
+
const lines = [rowLine(theme, width, status, frame, "code", callSummary(calls), right)]
|
|
271
470
|
const expanded = Boolean(context.expanded)
|
|
272
471
|
const shown = expanded ? calls : calls.slice(-NESTED_PREVIEW)
|
|
273
472
|
const childIndent = " ".repeat(rowIndent() + 4)
|
|
@@ -347,7 +546,10 @@ export function installToolRenderers(pi: ExtensionAPI): void {
|
|
|
347
546
|
if (typeof pi.registerToolRenderer !== "function") return
|
|
348
547
|
pi.registerToolRenderer((toolName, next) => {
|
|
349
548
|
if (toolName === "codemode") return codemodeRenderers()
|
|
350
|
-
if (
|
|
351
|
-
|
|
549
|
+
if (isToolKind(toolName)) return compactRenderers(toolName)
|
|
550
|
+
// Tools that draw themselves (subagent's live view, gauntlet's trackers) keep their look.
|
|
551
|
+
const definition = next()
|
|
552
|
+
if (hasOwnRenderer(definition)) return definition
|
|
553
|
+
return { ...definition, ...genericRenderers(toolName) }
|
|
352
554
|
})
|
|
353
555
|
}
|
package/src/tracker.ts
CHANGED
|
@@ -67,19 +67,24 @@ export function formatTrackerLines(
|
|
|
67
67
|
}
|
|
68
68
|
if (tasks && tasks.length > 0) {
|
|
69
69
|
const done = tasks.filter((t) => t.status === "complete").length
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
// One segment per task, coloured by status, so the list reads as a single bar instead of a
|
|
71
|
+
// row of check marks. Past 12 tasks the bar is scaled to 10 segments (status order kept).
|
|
72
|
+
const segment = (status: string | undefined) => {
|
|
73
|
+
if (status === "complete") return theme.fg("success", "▰")
|
|
74
|
+
if (status === "in_progress") return theme.fg("warning", "▰")
|
|
75
|
+
if (status === "failed") return theme.fg("error", "▰")
|
|
76
|
+
return theme.fg("dim", "▱")
|
|
77
|
+
}
|
|
78
|
+
const shown =
|
|
79
|
+
tasks.length <= 12
|
|
80
|
+
? tasks.map((t) => t.status)
|
|
81
|
+
: Array.from({ length: 10 }, (_, i) => tasks[Math.floor((i * tasks.length) / 10)]?.status)
|
|
82
|
+
const icons = shown.map(segment).join("")
|
|
78
83
|
const current =
|
|
79
84
|
tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending")
|
|
80
85
|
const name = current ? theme.fg("text", ` ${current.name}`) : ""
|
|
81
86
|
lines.push(
|
|
82
|
-
`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim",
|
|
87
|
+
`${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `${done}/${tasks.length}`)}${name}`,
|
|
83
88
|
)
|
|
84
89
|
}
|
|
85
90
|
return lines
|
package/src/transcript.ts
CHANGED
|
@@ -1,51 +1,106 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
2
3
|
|
|
3
4
|
export const THINKING_GUTTER = "┊ "
|
|
4
5
|
|
|
5
6
|
const FENCE = /^\s*(```|~~~)/
|
|
6
7
|
const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/
|
|
8
|
+
/** Below this width wrapping would only make thinking harder to read; prefix source lines instead. */
|
|
9
|
+
const MIN_WRAP_WIDTH = 20
|
|
10
|
+
/** An inline code span stays whole; everything else splits on spaces. */
|
|
11
|
+
const TOKEN = /`[^`]*`|[^\s`]+|`/g
|
|
12
|
+
|
|
13
|
+
function stripGutter(line: string): string {
|
|
14
|
+
return line.startsWith(THINKING_GUTTER) ? line.slice(THINKING_GUTTER.length) : line
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Split a word wider than `room` into `room`-wide pieces (URLs, long paths). */
|
|
18
|
+
function splitWide(word: string, room: number): string[] {
|
|
19
|
+
if (word.startsWith("`") || visibleWidth(word) <= room) return [word]
|
|
20
|
+
const pieces: string[] = []
|
|
21
|
+
let piece = ""
|
|
22
|
+
for (const char of word) {
|
|
23
|
+
if (visibleWidth(piece + char) > room) {
|
|
24
|
+
pieces.push(piece)
|
|
25
|
+
piece = ""
|
|
26
|
+
}
|
|
27
|
+
piece += char
|
|
28
|
+
}
|
|
29
|
+
if (piece) pieces.push(piece)
|
|
30
|
+
return pieces
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Greedy word wrap at `room` cells. Markdown markers (`*`, backticks) count toward the width
|
|
35
|
+
* but are removed when rendered, so lines can only come out shorter than `room`, never wider.
|
|
36
|
+
*/
|
|
37
|
+
export function wrapLine(line: string, room: number): string[] {
|
|
38
|
+
const words = (line.match(TOKEN) ?? []).flatMap((word) => splitWide(word, room))
|
|
39
|
+
if (words.length === 0) return [""]
|
|
40
|
+
const out: string[] = []
|
|
41
|
+
let current = ""
|
|
42
|
+
for (const word of words) {
|
|
43
|
+
if (!current) current = word
|
|
44
|
+
else if (visibleWidth(current) + 1 + visibleWidth(word) <= room) current += ` ${word}`
|
|
45
|
+
else {
|
|
46
|
+
out.push(current)
|
|
47
|
+
current = word
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
out.push(current)
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
7
53
|
|
|
8
54
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
55
|
+
* Put the thinking gutter on every rendered line of every plain paragraph, so thinking reads as a
|
|
56
|
+
* column beside prose rather than a bullet on its first line. With `width` (the content width the
|
|
57
|
+
* host will render at) paragraph lines are pre-wrapped to `width - 2` so wrapped lines carry the
|
|
58
|
+
* gutter too; pi-tui keeps single newlines as line breaks. Without a usable width only source
|
|
59
|
+
* lines are prefixed.
|
|
60
|
+
*
|
|
61
|
+
* Block syntax (heading, list, quote, table, fence, rule) is left untouched so it still parses,
|
|
62
|
+
* including a list or quote that interrupts a paragraph, and nothing inside fenced code changes.
|
|
12
63
|
*/
|
|
13
|
-
export function gutterThinking(markdown: string): string {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
export function gutterThinking(markdown: string, width?: number): string {
|
|
65
|
+
if (!markdown) return markdown
|
|
66
|
+
const room = width !== undefined && width >= MIN_WRAP_WIDTH ? width - visibleWidth(THINKING_GUTTER) : undefined
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
let inFence = false
|
|
69
|
+
/** "para": inside a plain paragraph; "block": inside block syntax until the next blank line. */
|
|
70
|
+
let mode: "none" | "para" | "block" = "none"
|
|
71
|
+
for (const line of markdown.split("\n")) {
|
|
72
|
+
if (FENCE.test(line)) {
|
|
73
|
+
inFence = !inFence
|
|
74
|
+
out.push(line)
|
|
75
|
+
mode = "none"
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (inFence) {
|
|
79
|
+
out.push(line)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (line.trim() === "") {
|
|
83
|
+
out.push(line)
|
|
84
|
+
mode = "none"
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const body = stripGutter(line)
|
|
88
|
+
if (mode === "block" || BLOCK_START.test(body)) {
|
|
89
|
+
out.push(line)
|
|
90
|
+
mode = "block"
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
mode = "para"
|
|
94
|
+
if (room === undefined) out.push(`${THINKING_GUTTER}${body}`)
|
|
95
|
+
else for (const piece of wrapLine(body, room)) out.push(`${THINKING_GUTTER}${piece}`)
|
|
96
|
+
}
|
|
97
|
+
return out.join("\n")
|
|
43
98
|
}
|
|
44
99
|
|
|
45
100
|
export function installTranscript(pi: ExtensionAPI): void {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
if (typeof pi.registerMarkdownTransformer !== "function") return
|
|
102
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
103
|
+
if (context.messageType !== "assistant-thinking") return markdown
|
|
104
|
+
return gutterThinking(markdown, context.availableWidth)
|
|
105
|
+
})
|
|
51
106
|
}
|
|
@@ -21,7 +21,7 @@ test("parseArgs reads complete JSON and recovers keys from a preview cut at 200
|
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
test("nestedItem reads built-ins like their top-level rows and others as key=value", () => {
|
|
24
|
-
assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status")
|
|
24
|
+
assert.equal(nestedItem({ name: "bash", args: '{"command":"git status\\ngit diff","timeout":10}' }), "git status · +1 line")
|
|
25
25
|
assert.equal(nestedItem({ name: "read", args: '{"path":"/a/b/c/file.ts"}' }), "c/file.ts")
|
|
26
26
|
assert.equal(
|
|
27
27
|
nestedItem({ name: "plan_tracker", args: '{"action":"update","index":6,"status":"complete"}' }),
|