pi-minimal 0.1.0 → 0.2.2
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/.github/dependabot.yml +11 -0
- package/.github/workflows/publish.yml +178 -14
- package/.github/workflows/release.yml +42 -0
- package/CHANGELOG.md +81 -0
- package/README.md +28 -1
- package/SECURITY.md +21 -0
- package/dist/activity.js +0 -3
- package/dist/chrome.js +15 -0
- package/dist/code-box.js +69 -0
- package/dist/cwd.js +95 -53
- package/dist/footer.js +30 -29
- package/dist/git-state.js +63 -0
- package/dist/index.js +59 -6
- package/dist/tool-rows.js +143 -12
- package/dist/tools.js +146 -77
- package/dist/transcript.js +50 -0
- package/dist/turn-rule.js +121 -0
- package/package.json +14 -3
- package/release-notes.md +32 -0
- package/src/activity.ts +0 -4
- package/src/chrome.ts +18 -0
- package/src/code-box.ts +86 -0
- package/src/cwd.ts +104 -52
- package/src/footer.ts +38 -34
- package/src/git-state.ts +71 -0
- package/src/index.ts +60 -6
- package/src/tool-rows.ts +170 -19
- package/src/tools.ts +160 -105
- package/src/transcript.ts +51 -0
- package/src/turn-rule.ts +139 -0
- package/test/activity.test.ts +4 -15
- package/test/code-box.test.ts +55 -0
- package/test/footer.test.ts +38 -0
- package/test/git-state.test.ts +52 -0
- package/test/index.test.ts +40 -4
- package/test/package.test.ts +3 -2
- package/test/theme.test.ts +45 -0
- package/test/tool-rows.test.ts +109 -0
- package/test/tools.test.ts +49 -42
- package/test/transcript.test.ts +22 -0
- package/test/turn-rule.test.ts +33 -0
- package/themes/minimal-dark.json +84 -0
package/src/tool-rows.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
2
|
+
|
|
1
3
|
export type ToolKind = "read" | "edit" | "write" | "bash" | "grep" | "find" | "ls"
|
|
2
4
|
|
|
3
5
|
export type ToolArgs = {
|
|
@@ -6,24 +8,22 @@ export type ToolArgs = {
|
|
|
6
8
|
file_path?: unknown
|
|
7
9
|
pattern?: unknown
|
|
8
10
|
target_directory?: unknown
|
|
11
|
+
content?: unknown
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
export type ToolResultLike = {
|
|
15
|
+
content?: Array<{ type?: string; text?: string }>
|
|
16
|
+
details?: unknown
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
type
|
|
17
|
-
verb: string
|
|
18
|
-
leadId: string
|
|
19
|
-
seen: Set<string>
|
|
20
|
-
}
|
|
19
|
+
export type RowStatus = "running" | "ok" | "error"
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
export type RowGlyph = "◐" | "◓" | "✓" | "✗"
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
/** Width of the verb column so items align across rows. */
|
|
24
|
+
export const VERB_WIDTH = 5
|
|
25
|
+
|
|
26
|
+
const BASH_STATUS = /^Command (?:exited with code (\d+)|terminated without an exit code)$/
|
|
27
27
|
|
|
28
28
|
export function shortName(path: string): string {
|
|
29
29
|
const parts = path.split("/").filter(Boolean)
|
|
@@ -47,12 +47,163 @@ export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
|
|
|
47
47
|
return path ? shortName(path) : kind
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
export function resultText(result: ToolResultLike | undefined): string {
|
|
51
|
+
if (!result?.content) return ""
|
|
52
|
+
return result.content
|
|
53
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
54
|
+
.map((part) => part.text as string)
|
|
55
|
+
.join("\n")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function rowGlyph(status: RowStatus, frame: 0 | 1): RowGlyph {
|
|
59
|
+
if (status === "running") return frame === 1 ? "◓" : "◐"
|
|
60
|
+
return status === "error" ? "✗" : "✓"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatDuration(ms: number | undefined): string {
|
|
64
|
+
if (ms === undefined || !Number.isFinite(ms)) return ""
|
|
65
|
+
if (ms < 1000) return `${Math.round(ms / 100) / 10}s`
|
|
66
|
+
if (ms < 60_000) return `${Math.round(ms / 100) / 10}s`
|
|
67
|
+
const minutes = Math.floor(ms / 60_000)
|
|
68
|
+
const seconds = Math.round((ms % 60_000) / 1000)
|
|
69
|
+
return `${minutes}m ${seconds}s`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function plural(n: number, word: string, pluralWord = `${word}s`): string {
|
|
73
|
+
return `${n} ${n === 1 ? word : pluralWord}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countLines(text: string): number {
|
|
77
|
+
if (!text) return 0
|
|
78
|
+
return text.split("\n").filter((line) => line.length > 0).length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Strip pi's bash status trailer and truncation note from output text. */
|
|
82
|
+
export function bashBody(text: string): { body: string; exitCode: number | undefined } {
|
|
83
|
+
const lines = text.split("\n")
|
|
84
|
+
let exitCode: number | undefined
|
|
85
|
+
while (lines.length > 0) {
|
|
86
|
+
const last = lines[lines.length - 1].trim()
|
|
87
|
+
const match = BASH_STATUS.exec(last)
|
|
88
|
+
if (match) {
|
|
89
|
+
exitCode = match[1] !== undefined ? Number(match[1]) : undefined
|
|
90
|
+
lines.pop()
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (last.startsWith("[Showing ") && last.endsWith("]")) {
|
|
94
|
+
lines.pop()
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (last === "") {
|
|
98
|
+
lines.pop()
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
return { body: lines.join("\n"), exitCode }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function diffStats(diff: string): { added: number; removed: number } {
|
|
107
|
+
let added = 0
|
|
108
|
+
let removed = 0
|
|
109
|
+
for (const line of diff.split("\n")) {
|
|
110
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue
|
|
111
|
+
if (line.startsWith("+")) added += 1
|
|
112
|
+
else if (line.startsWith("-")) removed += 1
|
|
113
|
+
}
|
|
114
|
+
return { added, removed }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type Outcome = {
|
|
118
|
+
/** Short right-aligned summary, e.g. "34 lines · 1.0s". */
|
|
119
|
+
text: string
|
|
120
|
+
/** Optional +N / −N pair rendered in success/error colours. */
|
|
121
|
+
diff?: { added: number; removed: number }
|
|
122
|
+
/** First meaningful line of an error, shown under the row. */
|
|
123
|
+
errorLine?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatOutcome(
|
|
127
|
+
kind: ToolKind,
|
|
128
|
+
args: ToolArgs | undefined,
|
|
129
|
+
result: ToolResultLike | undefined,
|
|
130
|
+
isError: boolean,
|
|
131
|
+
durationMs: number | undefined,
|
|
132
|
+
): Outcome {
|
|
133
|
+
const text = resultText(result)
|
|
134
|
+
const dur = formatDuration(durationMs)
|
|
135
|
+
const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
|
|
136
|
+
|
|
137
|
+
if (kind === "bash") {
|
|
138
|
+
const { body, exitCode } = bashBody(text)
|
|
139
|
+
if (isError || (exitCode !== undefined && exitCode !== 0)) {
|
|
140
|
+
const code = exitCode !== undefined ? `exit ${exitCode}` : "failed"
|
|
141
|
+
return { text: join(code, dur), errorLine: firstLine(body) }
|
|
142
|
+
}
|
|
143
|
+
const lines = countLines(body)
|
|
144
|
+
const summary = lines === 0 || body === "(no output)" ? "ok" : plural(lines, "line")
|
|
145
|
+
return { text: join(summary, dur) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isError) return { text: join("failed", dur), errorLine: firstLine(text) }
|
|
149
|
+
|
|
150
|
+
if (kind === "edit") {
|
|
151
|
+
const details = result?.details as { diff?: unknown } | undefined
|
|
152
|
+
const diff = typeof details?.diff === "string" ? diffStats(details.diff) : undefined
|
|
153
|
+
return diff ? { text: dur, diff } : { text: join("edited", dur) }
|
|
154
|
+
}
|
|
155
|
+
if (kind === "write") {
|
|
156
|
+
const bytes = typeof args?.content === "string" ? Buffer.byteLength(args.content, "utf8") : undefined
|
|
157
|
+
return { text: join(bytes === undefined ? "written" : formatBytes(bytes), dur) }
|
|
158
|
+
}
|
|
159
|
+
if (kind === "read") {
|
|
160
|
+
return { text: join(plural(countLines(text), "line"), dur) }
|
|
161
|
+
}
|
|
162
|
+
if (kind === "grep") {
|
|
163
|
+
if (/^No matches found/m.test(text)) return { text: join("0 matches", dur) }
|
|
164
|
+
return { text: join(plural(countLines(text), "match"), dur) }
|
|
165
|
+
}
|
|
166
|
+
// find / ls
|
|
167
|
+
return { text: join(plural(countLines(text), "entry", "entries"), dur) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function firstLine(text: string): string | undefined {
|
|
171
|
+
for (const line of text.split("\n")) {
|
|
172
|
+
const trimmed = line.trim()
|
|
173
|
+
if (trimmed && trimmed !== "(no output)") return trimmed
|
|
174
|
+
}
|
|
175
|
+
return undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatBytes(n: number): string {
|
|
179
|
+
if (n < 1024) return `${n} B`
|
|
180
|
+
if (n < 1024 * 1024) return `${Math.round(n / 102.4) / 10} KB`
|
|
181
|
+
return `${Math.round(n / (1024 * 102.4)) / 10} MB`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Lay out one row: `<glyph> <verb> <item> ... <outcome>`. The item is truncated so the outcome
|
|
186
|
+
* stays visible; the outcome is dropped only when the row is too narrow to show both.
|
|
187
|
+
*/
|
|
188
|
+
export function layoutRow(
|
|
189
|
+
width: number,
|
|
190
|
+
verb: string,
|
|
191
|
+
item: string,
|
|
192
|
+
outcome: string,
|
|
193
|
+
): { verb: string; item: string; gap: number; outcome: string } {
|
|
194
|
+
const verbCol = verb.padEnd(VERB_WIDTH)
|
|
195
|
+
// glyph + space + verb column + space
|
|
196
|
+
const prefixWidth = 2 + VERB_WIDTH + 1
|
|
197
|
+
const outcomeWidth = visibleWidth(outcome)
|
|
198
|
+
const minGap = 2
|
|
199
|
+
let shownOutcome = outcome
|
|
200
|
+
let itemRoom = width - prefixWidth - (outcomeWidth ? outcomeWidth + minGap : 0)
|
|
201
|
+
if (itemRoom < 12 && outcomeWidth) {
|
|
202
|
+
shownOutcome = ""
|
|
203
|
+
itemRoom = width - prefixWidth
|
|
55
204
|
}
|
|
56
|
-
|
|
57
|
-
|
|
205
|
+
const shownItem = truncateToWidth(item, Math.max(0, itemRoom), "…")
|
|
206
|
+
const used = prefixWidth + visibleWidth(shownItem) + visibleWidth(shownOutcome)
|
|
207
|
+
const gap = shownOutcome ? Math.max(minGap, width - used) : 0
|
|
208
|
+
return { verb: verbCol, item: shownItem, gap, outcome: shownOutcome }
|
|
58
209
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,30 +1,26 @@
|
|
|
1
|
-
import type { ExtensionAPI,
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
createFindToolDefinition,
|
|
6
|
-
createGrepToolDefinition,
|
|
7
|
-
createLsToolDefinition,
|
|
8
|
-
createReadToolDefinition,
|
|
9
|
-
createWriteToolDefinition,
|
|
10
|
-
SettingsManager,
|
|
11
|
-
} from "@earendil-works/pi-coding-agent"
|
|
12
|
-
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"
|
|
13
|
-
import { activityBullet, rowIsRunning } from "./activity.js"
|
|
1
|
+
import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { Container, truncateToWidth } from "@earendil-works/pi-tui"
|
|
3
|
+
import { rowIsRunning } from "./activity.js"
|
|
4
|
+
import * as chrome from "./chrome.js"
|
|
14
5
|
import { createLiveWatch } from "./live-watch.js"
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
type ToolArgs = {
|
|
18
|
-
command?: unknown
|
|
19
|
-
path?: unknown
|
|
20
|
-
file_path?: unknown
|
|
21
|
-
pattern?: unknown
|
|
22
|
-
target_directory?: unknown
|
|
23
|
-
}
|
|
6
|
+
import {
|
|
24
7
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
8
|
+
bashBody,
|
|
9
|
+
formatDuration,
|
|
10
|
+
formatOutcome,
|
|
11
|
+
layoutRow,
|
|
12
|
+
resultText,
|
|
13
|
+
rowGlyph,
|
|
14
|
+
toolItem,
|
|
15
|
+
type Outcome,
|
|
16
|
+
type RowStatus,
|
|
17
|
+
type ToolArgs,
|
|
18
|
+
type ToolKind,
|
|
19
|
+
type ToolResultLike,
|
|
20
|
+
} from "./tool-rows.js"
|
|
21
|
+
|
|
22
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
23
|
+
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
28
24
|
|
|
29
25
|
type RenderOptions = {
|
|
30
26
|
expanded?: boolean
|
|
@@ -33,6 +29,8 @@ type RenderOptions = {
|
|
|
33
29
|
type RowState = {
|
|
34
30
|
done?: boolean
|
|
35
31
|
running?: boolean
|
|
32
|
+
startedAt?: number
|
|
33
|
+
outcome?: Outcome
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
type RenderContext = {
|
|
@@ -43,10 +41,17 @@ type RenderContext = {
|
|
|
43
41
|
args?: ToolArgs
|
|
44
42
|
toolCallId?: string
|
|
45
43
|
expanded?: boolean
|
|
44
|
+
durationMs?: number
|
|
45
|
+
outputPad?: number
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const live = createLiveWatch(400)
|
|
49
49
|
|
|
50
|
+
/** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
|
|
51
|
+
const rowIndent = () => margin() + 1
|
|
52
|
+
/** Max lines of output shown when a row is expanded. */
|
|
53
|
+
const EXPANDED_MAX_LINES = 400
|
|
54
|
+
|
|
50
55
|
export function stopLiveWatch(): void {
|
|
51
56
|
live.stop()
|
|
52
57
|
}
|
|
@@ -55,107 +60,157 @@ function liveId(context: RenderContext, item: string): string {
|
|
|
55
60
|
return context.toolCallId || item
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
function rowStatus(context: RenderContext): RowStatus {
|
|
64
|
+
if (context.isError) return "error"
|
|
65
|
+
if (context.state.outcome?.errorLine) return "error"
|
|
66
|
+
return rowIsRunning(context) ? "running" : "ok"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function statusColor(status: RowStatus): "error" | "warning" | "success" {
|
|
70
|
+
if (status === "error") return "error"
|
|
71
|
+
if (status === "running") return "warning"
|
|
72
|
+
return "success"
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
function
|
|
67
|
-
|
|
75
|
+
function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): string[] {
|
|
76
|
+
let text = resultText(result)
|
|
77
|
+
if (kind === "bash") text = bashBody(text).body
|
|
78
|
+
if (!text) return []
|
|
79
|
+
const lines = text.split("\n")
|
|
80
|
+
if (lines.length > EXPANDED_MAX_LINES) {
|
|
81
|
+
const hidden = lines.length - EXPANDED_MAX_LINES
|
|
82
|
+
return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`]
|
|
83
|
+
}
|
|
84
|
+
return lines
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
|
|
88
|
+
const lines = expandedLines(kind, result)
|
|
89
|
+
if (lines.length === 0) return new Container()
|
|
90
|
+
const indent = " ".repeat(rowIndent() + 2)
|
|
91
|
+
const rule = theme.fg("borderMuted", "│")
|
|
92
|
+
return {
|
|
93
|
+
render(width: number): string[] {
|
|
94
|
+
const inner = Math.max(0, width - indent.length - 2)
|
|
95
|
+
return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`)
|
|
96
|
+
},
|
|
97
|
+
invalidate() {},
|
|
98
|
+
}
|
|
68
99
|
}
|
|
69
100
|
|
|
70
|
-
function
|
|
71
|
-
if (!
|
|
72
|
-
const
|
|
73
|
-
|
|
101
|
+
function errorLineBlock(line: string | undefined, theme: Theme) {
|
|
102
|
+
if (!line) return undefined
|
|
103
|
+
const indent = " ".repeat(rowIndent() + 2)
|
|
104
|
+
const rule = theme.fg("error", "│")
|
|
105
|
+
return {
|
|
106
|
+
render(width: number): string[] {
|
|
107
|
+
const inner = Math.max(0, width - indent.length - 2)
|
|
108
|
+
return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`]
|
|
109
|
+
},
|
|
110
|
+
invalidate() {},
|
|
111
|
+
}
|
|
74
112
|
}
|
|
75
113
|
|
|
76
|
-
function
|
|
77
|
-
kind: ToolKind,
|
|
78
|
-
args: ToolArgs | undefined,
|
|
79
|
-
theme: Theme,
|
|
80
|
-
context: RenderContext,
|
|
81
|
-
) {
|
|
114
|
+
function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, context: RenderContext) {
|
|
82
115
|
const item = toolItem(kind, args)
|
|
83
116
|
const id = liveId(context, item)
|
|
84
|
-
const row = nextGroupedRow(kind, item, id)
|
|
85
117
|
const running = rowIsRunning(context)
|
|
86
118
|
context.state.running = running
|
|
87
|
-
if (running)
|
|
88
|
-
|
|
89
|
-
|
|
119
|
+
if (running) {
|
|
120
|
+
context.state.startedAt ??= Date.now()
|
|
121
|
+
live.watch(id, context.invalidate)
|
|
122
|
+
} else {
|
|
123
|
+
live.unwatch(id)
|
|
124
|
+
}
|
|
90
125
|
return {
|
|
91
126
|
render(width: number): string[] {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
const status = rowStatus(context)
|
|
128
|
+
const blink = status === "running" && live.has(id)
|
|
129
|
+
const glyph = rowGlyph(status, blink ? live.frame : 0)
|
|
130
|
+
const outcome = context.state.outcome
|
|
131
|
+
const diff = status !== "running" ? outcome?.diff : undefined
|
|
132
|
+
let right = ""
|
|
133
|
+
if (status === "running") {
|
|
134
|
+
const started = context.state.startedAt
|
|
135
|
+
right = started ? formatDuration(Date.now() - started) : ""
|
|
136
|
+
} else if (outcome) {
|
|
137
|
+
right = outcome.text
|
|
138
|
+
if (diff) {
|
|
139
|
+
const stats = `+${diff.added} −${diff.removed}`
|
|
140
|
+
right = right ? `${stats} · ${right}` : stats
|
|
141
|
+
}
|
|
99
142
|
}
|
|
100
|
-
|
|
101
|
-
|
|
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]
|
|
102
165
|
},
|
|
103
166
|
invalidate() {},
|
|
104
167
|
}
|
|
105
168
|
}
|
|
106
169
|
|
|
107
|
-
|
|
108
|
-
name: string
|
|
109
|
-
label: string
|
|
110
|
-
description: string
|
|
111
|
-
parameters: Parameters<ExtensionAPI["registerTool"]>[0]["parameters"]
|
|
112
|
-
execute: Parameters<ExtensionAPI["registerTool"]>[0]["execute"]
|
|
113
|
-
}
|
|
170
|
+
const TOOL_KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
|
|
114
171
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
const tool = factory()
|
|
118
|
-
pi.registerTool({
|
|
119
|
-
...tool,
|
|
120
|
-
renderShell: "self",
|
|
121
|
-
renderCall(args, theme, context) {
|
|
122
|
-
return activityBlock(kind, args as ToolArgs, theme, context as RenderContext)
|
|
123
|
-
},
|
|
124
|
-
renderResult(result, options, _theme, context) {
|
|
125
|
-
const renderContext = context as RenderContext
|
|
126
|
-
if (!renderContext.isPartial) {
|
|
127
|
-
renderContext.state.done = true
|
|
128
|
-
renderContext.state.running = false
|
|
129
|
-
live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
|
|
130
|
-
}
|
|
131
|
-
return expandedContent(result as ToolResult, options)
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
} catch {
|
|
135
|
-
// A missing built-in tool leaves the rest of the compact renderers available.
|
|
136
|
-
}
|
|
172
|
+
export function isToolKind(name: string): name is ToolKind {
|
|
173
|
+
return TOOL_KINDS.has(name)
|
|
137
174
|
}
|
|
138
175
|
|
|
139
|
-
export function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
176
|
+
export function compactRenderers(kind: ToolKind): ToolRenderers {
|
|
177
|
+
return {
|
|
178
|
+
renderShell: "self",
|
|
179
|
+
renderCall(args, theme, context) {
|
|
180
|
+
return activityRow(kind, args as ToolArgs, theme, context as RenderContext)
|
|
181
|
+
},
|
|
182
|
+
renderResult(result, options, theme, context) {
|
|
183
|
+
const renderContext = context as RenderContext
|
|
184
|
+
const typedResult = result as ToolResultLike
|
|
185
|
+
if (!renderContext.isPartial) {
|
|
186
|
+
renderContext.state.done = true
|
|
187
|
+
renderContext.state.running = false
|
|
188
|
+
renderContext.state.outcome = formatOutcome(
|
|
189
|
+
kind,
|
|
190
|
+
renderContext.args,
|
|
191
|
+
typedResult,
|
|
192
|
+
Boolean(renderContext.isError),
|
|
193
|
+
renderContext.durationMs,
|
|
194
|
+
)
|
|
195
|
+
live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
|
|
196
|
+
}
|
|
197
|
+
const container = new Container()
|
|
198
|
+
const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
|
|
199
|
+
if (errorLine && !(options as RenderOptions).expanded) container.addChild(errorLine)
|
|
200
|
+
if ((options as RenderOptions).expanded) container.addChild(outputBlock(kind, typedResult, theme))
|
|
201
|
+
return container
|
|
202
|
+
},
|
|
152
203
|
}
|
|
204
|
+
}
|
|
153
205
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Attach the compact renderers to the built-in tools without re-registering them. Registered at
|
|
208
|
+
* extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
|
|
209
|
+
*/
|
|
210
|
+
export function installToolRenderers(pi: ExtensionAPI): void {
|
|
211
|
+
if (typeof pi.registerToolRenderer !== "function") return
|
|
212
|
+
pi.registerToolRenderer((toolName, next) => {
|
|
213
|
+
if (!isToolKind(toolName)) return next()
|
|
214
|
+
return compactRenderers(toolName)
|
|
215
|
+
})
|
|
161
216
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
|
|
3
|
+
export const THINKING_GUTTER = "┊ "
|
|
4
|
+
|
|
5
|
+
const FENCE = /^\s*(```|~~~)/
|
|
6
|
+
const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
|
|
10
|
+
* Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
|
|
11
|
+
* they keep parsing, and nothing inside a fenced code block is touched.
|
|
12
|
+
*/
|
|
13
|
+
export function gutterThinking(markdown: string): string {
|
|
14
|
+
if (!markdown) return markdown
|
|
15
|
+
const lines = markdown.split("\n")
|
|
16
|
+
const out: string[] = []
|
|
17
|
+
let inFence = false
|
|
18
|
+
let atParagraphStart = true
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
if (FENCE.test(line)) {
|
|
21
|
+
inFence = !inFence
|
|
22
|
+
out.push(line)
|
|
23
|
+
atParagraphStart = !inFence
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (inFence) {
|
|
27
|
+
out.push(line)
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
if (line.trim() === "") {
|
|
31
|
+
out.push(line)
|
|
32
|
+
atParagraphStart = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
|
|
36
|
+
out.push(`${THINKING_GUTTER}${line}`)
|
|
37
|
+
} else {
|
|
38
|
+
out.push(line)
|
|
39
|
+
}
|
|
40
|
+
atParagraphStart = false
|
|
41
|
+
}
|
|
42
|
+
return out.join("\n")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function installTranscript(pi: ExtensionAPI): void {
|
|
46
|
+
if (typeof pi.registerMarkdownTransformer !== "function") return
|
|
47
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
48
|
+
if (context.messageType !== "assistant-thinking") return markdown
|
|
49
|
+
return gutterThinking(markdown)
|
|
50
|
+
})
|
|
51
|
+
}
|