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/turn-rule.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
3
|
+
import * as chrome from "./chrome.js"
|
|
4
|
+
import { formatElapsed } from "./working-status.js"
|
|
5
|
+
|
|
6
|
+
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
7
|
+
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export const TURN_ENTRY_TYPE = "pi-minimal.turn"
|
|
11
|
+
|
|
12
|
+
export type TurnSummary = {
|
|
13
|
+
/** Epoch ms when the run finished. */
|
|
14
|
+
endedAt: number
|
|
15
|
+
/** Wall-clock duration of the run. */
|
|
16
|
+
elapsedMs: number
|
|
17
|
+
/** Number of tool executions during the run. */
|
|
18
|
+
tools: number
|
|
19
|
+
/** Whether the run ended with an error result or abort. */
|
|
20
|
+
outcome: "completed" | "aborted" | "error"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatClock(epochMs: number): string {
|
|
24
|
+
const d = new Date(epochMs)
|
|
25
|
+
const hh = String(d.getHours()).padStart(2, "0")
|
|
26
|
+
const mm = String(d.getMinutes()).padStart(2, "0")
|
|
27
|
+
return `${hh}:${mm}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function turnLabel(summary: TurnSummary): string {
|
|
31
|
+
const parts = [formatClock(summary.endedAt), formatElapsed(summary.elapsedMs)]
|
|
32
|
+
if (summary.tools > 0) parts.push(`${summary.tools} tool${summary.tools === 1 ? "" : "s"}`)
|
|
33
|
+
if (summary.outcome === "aborted") parts.push("aborted")
|
|
34
|
+
if (summary.outcome === "error") parts.push("error")
|
|
35
|
+
return parts.join(" · ")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `── label ─────────` filling `width`; label is dropped when the row is too narrow. */
|
|
39
|
+
export function ruleLine(width: number, label: string): string {
|
|
40
|
+
const lead = "── "
|
|
41
|
+
const tail = " "
|
|
42
|
+
const minRule = 4
|
|
43
|
+
if (width <= 0) return ""
|
|
44
|
+
if (visibleWidth(lead) + visibleWidth(label) + visibleWidth(tail) + minRule > width) {
|
|
45
|
+
return "─".repeat(width)
|
|
46
|
+
}
|
|
47
|
+
const shown = truncateToWidth(label, width - lead.length - tail.length - minRule, "…")
|
|
48
|
+
const rest = Math.max(minRule, width - visibleWidth(lead) - visibleWidth(shown) - visibleWidth(tail))
|
|
49
|
+
return `${lead}${shown}${tail}${"─".repeat(rest)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function runOutcome(messages: Array<{ role?: string; stopReason?: string }> | undefined): TurnSummary["outcome"] {
|
|
53
|
+
if (!Array.isArray(messages)) return "completed"
|
|
54
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
55
|
+
const message = messages[i]
|
|
56
|
+
if (message?.role !== "assistant") continue
|
|
57
|
+
if (message.stopReason === "aborted") return "aborted"
|
|
58
|
+
if (message.stopReason === "error") return "error"
|
|
59
|
+
return "completed"
|
|
60
|
+
}
|
|
61
|
+
return "completed"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isTurnSummary(data: unknown): data is TurnSummary {
|
|
65
|
+
if (!data || typeof data !== "object") return false
|
|
66
|
+
const d = data as Record<string, unknown>
|
|
67
|
+
return (
|
|
68
|
+
typeof d.endedAt === "number" &&
|
|
69
|
+
typeof d.elapsedMs === "number" &&
|
|
70
|
+
typeof d.tools === "number" &&
|
|
71
|
+
(d.outcome === "completed" || d.outcome === "aborted" || d.outcome === "error")
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installTurnRule(pi: ExtensionAPI): () => void {
|
|
76
|
+
let ctx: ExtensionContext | undefined
|
|
77
|
+
let startedAt = 0
|
|
78
|
+
let tools = 0
|
|
79
|
+
|
|
80
|
+
if (typeof pi.registerEntryRenderer !== "function" || typeof pi.appendEntry !== "function") {
|
|
81
|
+
return () => {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pi.registerEntryRenderer<TurnSummary>(TURN_ENTRY_TYPE, (entry, _options, theme) => {
|
|
85
|
+
if (!isTurnSummary(entry.data)) return undefined
|
|
86
|
+
const label = turnLabel(entry.data)
|
|
87
|
+
const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error"
|
|
88
|
+
return {
|
|
89
|
+
render(width: number): string[] {
|
|
90
|
+
const inner = Math.max(0, width - margin() * 2)
|
|
91
|
+
const rule = ruleLine(inner, label)
|
|
92
|
+
const colored = rule.replace(label, theme.fg("dim", label))
|
|
93
|
+
return ["", `${" ".repeat(margin())}${theme.fg(color, colored)}`]
|
|
94
|
+
},
|
|
95
|
+
invalidate() {},
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
pi.on("session_start", (_event, next) => {
|
|
100
|
+
if (next.mode !== "tui") return
|
|
101
|
+
ctx = next
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
pi.on("agent_start", () => {
|
|
105
|
+
startedAt = Date.now()
|
|
106
|
+
tools = 0
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
pi.on("tool_execution_end", () => {
|
|
110
|
+
tools += 1
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
pi.on("agent_end", (event) => {
|
|
114
|
+
if (!ctx || ctx.mode !== "tui" || !startedAt) return
|
|
115
|
+
const ev = event as { messages?: Array<{ role?: string; stopReason?: string }> }
|
|
116
|
+
const summary: TurnSummary = {
|
|
117
|
+
endedAt: Date.now(),
|
|
118
|
+
elapsedMs: Date.now() - startedAt,
|
|
119
|
+
tools,
|
|
120
|
+
outcome: runOutcome(ev.messages),
|
|
121
|
+
}
|
|
122
|
+
startedAt = 0
|
|
123
|
+
try {
|
|
124
|
+
pi.appendEntry(TURN_ENTRY_TYPE, summary)
|
|
125
|
+
} catch {
|
|
126
|
+
// A session that cannot accept entries just goes without the separator.
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
pi.on("session_shutdown", () => {
|
|
131
|
+
ctx = undefined
|
|
132
|
+
startedAt = 0
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
ctx = undefined
|
|
137
|
+
startedAt = 0
|
|
138
|
+
}
|
|
139
|
+
}
|
package/test/activity.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import test from "node:test"
|
|
3
|
-
import {
|
|
3
|
+
import { rowIsRunning } from "../src/activity.js"
|
|
4
4
|
|
|
5
5
|
test("rowIsRunning is false when isPartial is false", () => {
|
|
6
6
|
assert.equal(rowIsRunning({ isPartial: false }), false)
|
|
@@ -10,18 +10,7 @@ test("rowIsRunning is true when isPartial is true", () => {
|
|
|
10
10
|
assert.equal(rowIsRunning({ isPartial: true }), true)
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
test("
|
|
14
|
-
assert.equal(
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test("activityBullet(false, 1) returns ◆", () => {
|
|
18
|
-
assert.equal(activityBullet(false, 1), "◆")
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
test("activityBullet(true, 0) returns ◆", () => {
|
|
22
|
-
assert.equal(activityBullet(true, 0), "◆")
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test("activityBullet(true, 1) returns ◇", () => {
|
|
26
|
-
assert.equal(activityBullet(true, 1), "◇")
|
|
13
|
+
test("rowIsRunning is false once the row is done or errored", () => {
|
|
14
|
+
assert.equal(rowIsRunning({ isPartial: true, state: { done: true } }), false)
|
|
15
|
+
assert.equal(rowIsRunning({ isPartial: true, isError: true }), false)
|
|
27
16
|
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { Markdown } from "@earendil-works/pi-tui"
|
|
4
|
+
import { frameCodeLines, installCodeBox } from "../src/code-box.js"
|
|
5
|
+
|
|
6
|
+
const theme = { codeBlockBorder: (s: string) => s }
|
|
7
|
+
const ESC = String.fromCharCode(27)
|
|
8
|
+
const stripAnsi = (s: string) => s.split(ESC).map((p, i) => (i === 0 ? p : p.replace(/^\[[0-9;]*m/, ""))).join("")
|
|
9
|
+
|
|
10
|
+
test("frameCodeLines replaces fences with a closed box and keeps the trailer", () => {
|
|
11
|
+
const out = frameCodeLines(["```json", ' {"a":1}', " ", "```", ""], "json", 20, theme)
|
|
12
|
+
assert.equal(out.length, 5)
|
|
13
|
+
assert.ok(out[0].startsWith("╭─ json ─") && out[0].endsWith("╮"))
|
|
14
|
+
assert.equal(out[1], '│ {"a":1} │')
|
|
15
|
+
assert.equal(out[2], "│ │")
|
|
16
|
+
assert.ok(out[3].startsWith("╰─") && out[3].endsWith("╯"))
|
|
17
|
+
for (const l of out.slice(0, 4)) assert.equal(l.length, 20, l)
|
|
18
|
+
assert.equal(out[4], "")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("frameCodeLines wraps long lines inside the box", () => {
|
|
22
|
+
const out = frameCodeLines(["```", " " + "x".repeat(30), "```"], undefined, 20, theme)
|
|
23
|
+
assert.equal(out.length, 4, out.join("\n"))
|
|
24
|
+
assert.equal(out[1], `│ ${"x".repeat(16)} │`)
|
|
25
|
+
assert.equal(out[2], `│ ${"x".repeat(14)} │`)
|
|
26
|
+
for (const l of out) assert.equal(l.length, 20, l)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("frameCodeLines handles empty blocks and missing language", () => {
|
|
30
|
+
const out = frameCodeLines(["```", "```"], undefined, 10, theme)
|
|
31
|
+
assert.deepEqual(out.map((l) => l[0]), ["╭", "│", "╰"])
|
|
32
|
+
assert.deepEqual(out.map((l) => l[l.length - 1]), ["╮", "│", "╯"])
|
|
33
|
+
assert.ok(!out[0].includes(" "), "no language label")
|
|
34
|
+
assert.deepEqual(frameCodeLines(["x"], undefined, 10, theme), ["x"])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("installCodeBox patches pi-tui's Markdown renderer idempotently", () => {
|
|
38
|
+
assert.equal(installCodeBox(), true)
|
|
39
|
+
assert.equal(installCodeBox(), true)
|
|
40
|
+
// A reload re-points the installed wrapper at the current framer.
|
|
41
|
+
const slot = Symbol.for("pi-minimal.code-box.frame")
|
|
42
|
+
const slots = globalThis as unknown as Record<symbol, unknown>
|
|
43
|
+
assert.equal(slots[slot], frameCodeLines)
|
|
44
|
+
const id = (s: string) => s
|
|
45
|
+
const mdTheme = {
|
|
46
|
+
heading: id, link: id, linkUrl: id, code: id, codeBlock: id, codeBlockBorder: id, quote: id, quoteBorder: id,
|
|
47
|
+
hr: id, listBullet: id, bold: id, italic: id, underline: id, strikethrough: id,
|
|
48
|
+
}
|
|
49
|
+
const md = new Markdown("before\n\n```ts\nconst x = 1\n```\n\nafter", 0, 0, mdTheme as never)
|
|
50
|
+
const lines = md.render(40).map(stripAnsi)
|
|
51
|
+
assert.ok(lines.some((l) => l.startsWith("╭─ ts ") && l.endsWith("╮")), lines.join("\n"))
|
|
52
|
+
assert.ok(lines.some((l) => l.startsWith("│ const x = 1") && l.endsWith("│")), lines.join("\n"))
|
|
53
|
+
assert.ok(lines.some((l) => l.startsWith("╰─") && l.endsWith("╯")), lines.join("\n"))
|
|
54
|
+
assert.ok(!lines.some((l) => l.includes("```")), "no literal fences remain")
|
|
55
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
4
|
+
import { footerHints } from "../src/footer.js"
|
|
5
|
+
import { contextBand, meter, splitRow } from "../src/cwd.js"
|
|
6
|
+
|
|
7
|
+
const hints = ["enter send", "shift+tab thinking"]
|
|
8
|
+
|
|
9
|
+
test("footerHints show only when idle with an empty editor", () => {
|
|
10
|
+
assert.deepEqual(footerHints({ busy: () => false, editorText: () => "", contextPct: () => 10 }, hints), hints)
|
|
11
|
+
assert.deepEqual(footerHints({ busy: () => true, editorText: () => "", contextPct: () => 10 }, hints), [])
|
|
12
|
+
assert.deepEqual(footerHints({ busy: () => false, editorText: () => "hi", contextPct: () => 10 }, hints), [])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("footerHints suggest /compact under context pressure", () => {
|
|
16
|
+
assert.deepEqual(footerHints({ busy: () => false, editorText: () => "", contextPct: () => 72 }, hints), [...hints, "/compact"])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("contextBand thresholds", () => {
|
|
20
|
+
assert.equal(contextBand(0), "accent")
|
|
21
|
+
assert.equal(contextBand(69), "accent")
|
|
22
|
+
assert.equal(contextBand(70), "warning")
|
|
23
|
+
assert.equal(contextBand(90), "error")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("meter fills proportionally", () => {
|
|
27
|
+
assert.equal(meter(0, "#", "."), "........")
|
|
28
|
+
assert.equal(meter(50, "#", "."), "####....")
|
|
29
|
+
assert.equal(meter(100, "#", "."), "########")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("splitRow drops right-hand segments from the end before truncating the left", () => {
|
|
33
|
+
assert.equal(splitRow("left", ["a", "b"], 12), "left a b")
|
|
34
|
+
assert.equal(splitRow("left", ["aaaa", "bbbb"], 12), "left aaaa")
|
|
35
|
+
const squeezed = splitRow("a-very-long-left", ["x"], 10)
|
|
36
|
+
assert.equal(visibleWidth(squeezed), 10)
|
|
37
|
+
assert.ok(!squeezed.includes("x"))
|
|
38
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { createGitState, formatGitState, parseBranch, parsePorcelain } from "../src/git-state.js"
|
|
5
|
+
|
|
6
|
+
test("parseBranch returns the branch and treats detached HEAD as unknown", () => {
|
|
7
|
+
assert.equal(parseBranch("main\n"), "main")
|
|
8
|
+
assert.equal(parseBranch("HEAD\n"), undefined)
|
|
9
|
+
assert.equal(parseBranch(""), undefined)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test("parsePorcelain counts changed paths", () => {
|
|
13
|
+
assert.equal(parsePorcelain(""), 0)
|
|
14
|
+
assert.equal(parsePorcelain(" M a.ts\n?? b.ts\n"), 2)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("formatGitState shows ±N only when dirty", () => {
|
|
18
|
+
assert.deepEqual(formatGitState(undefined), { branch: "", dirty: "" })
|
|
19
|
+
assert.deepEqual(formatGitState({ branch: "main", dirty: 0 }), { branch: "main", dirty: "" })
|
|
20
|
+
assert.deepEqual(formatGitState({ branch: "main", dirty: 3 }), { branch: "main", dirty: "±3" })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("createGitState polls git, throttles, and notifies listeners", async () => {
|
|
24
|
+
const calls: string[][] = []
|
|
25
|
+
const pi = {
|
|
26
|
+
async exec(_cmd: string, args: string[]) {
|
|
27
|
+
calls.push(args)
|
|
28
|
+
if (args[0] === "rev-parse") return { stdout: "feature\n", stderr: "", code: 0, killed: false }
|
|
29
|
+
return { stdout: " M x\n", stderr: "", code: 0, killed: false }
|
|
30
|
+
},
|
|
31
|
+
} as unknown as ExtensionAPI
|
|
32
|
+
const git = createGitState(pi, () => "/tmp", 60_000)
|
|
33
|
+
let notified = 0
|
|
34
|
+
git.onChange(() => (notified += 1))
|
|
35
|
+
assert.equal(git.get(), undefined)
|
|
36
|
+
await git.refresh(true)
|
|
37
|
+
assert.deepEqual(git.get(), { branch: "feature", dirty: 1 })
|
|
38
|
+
assert.equal(notified, 1)
|
|
39
|
+
await git.refresh()
|
|
40
|
+
assert.equal(calls.length, 2, "throttled refresh does not re-run git")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("createGitState clears state outside a repository", async () => {
|
|
44
|
+
const pi = {
|
|
45
|
+
async exec() {
|
|
46
|
+
return { stdout: "", stderr: "fatal: not a git repository", code: 128, killed: false }
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ExtensionAPI
|
|
49
|
+
const git = createGitState(pi, () => "/tmp")
|
|
50
|
+
await git.refresh(true)
|
|
51
|
+
assert.equal(git.get(), undefined)
|
|
52
|
+
})
|
package/test/index.test.ts
CHANGED
|
@@ -90,6 +90,18 @@ function createFake(options: {
|
|
|
90
90
|
registerTool(...args: unknown[]) {
|
|
91
91
|
record(registerCalls, "registerTool", args)
|
|
92
92
|
},
|
|
93
|
+
registerToolRenderer(...args: unknown[]) {
|
|
94
|
+
record(registerCalls, "registerToolRenderer", args)
|
|
95
|
+
},
|
|
96
|
+
registerMarkdownTransformer(...args: unknown[]) {
|
|
97
|
+
record(registerCalls, "registerMarkdownTransformer", args)
|
|
98
|
+
},
|
|
99
|
+
registerEntryRenderer(...args: unknown[]) {
|
|
100
|
+
record(registerCalls, "registerEntryRenderer", args)
|
|
101
|
+
},
|
|
102
|
+
appendEntry(...args: unknown[]) {
|
|
103
|
+
record(registerCalls, "appendEntry", args)
|
|
104
|
+
},
|
|
93
105
|
} as unknown as ExtensionAPI
|
|
94
106
|
|
|
95
107
|
const emit = async (event: string, payload: unknown = {}) => {
|
|
@@ -148,7 +160,7 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
148
160
|
)
|
|
149
161
|
})
|
|
150
162
|
|
|
151
|
-
test("TUI session_start installs chrome
|
|
163
|
+
test("TUI session_start installs chrome before the first reclaim and tool renderers at load", async (t) => {
|
|
152
164
|
const { agentDir, cwd } = setupDirs(t)
|
|
153
165
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
154
166
|
delete process.env.HERDR_ENV
|
|
@@ -164,18 +176,20 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
164
176
|
const firstCwd = allCalls.findIndex(
|
|
165
177
|
(call) => call.method === "setWidget" && call.args[0] === "tw-cwd" && typeof call.args[1] === "function",
|
|
166
178
|
)
|
|
167
|
-
const firstWrap = allCalls.findIndex((call) => call.method === "
|
|
179
|
+
const firstWrap = allCalls.findIndex((call) => call.method === "registerToolRenderer")
|
|
180
|
+
const firstSessionStart = allCalls.findIndex((call) => call.method === "setHeader")
|
|
168
181
|
const firstReclaim = allCalls.findIndex((call) => call.method === "setEditorComponent")
|
|
169
182
|
|
|
170
183
|
assert.ok(firstHeader >= 0, "header installed")
|
|
171
184
|
assert.ok(firstFooter >= 0, "footer installed")
|
|
172
185
|
assert.ok(firstCwd >= 0, "cwd installed")
|
|
173
|
-
assert.ok(firstWrap >= 0, "
|
|
186
|
+
assert.ok(firstWrap >= 0, "tool renderers registered")
|
|
187
|
+
assert.ok(firstWrap < firstSessionStart, "tool renderers registered before session_start chrome (resume-safe)")
|
|
174
188
|
assert.ok(firstReclaim >= 0, "reclaim ran")
|
|
175
189
|
assert.ok(firstHeader < firstReclaim, "header before reclaim")
|
|
176
190
|
assert.ok(firstFooter < firstReclaim, "footer before reclaim")
|
|
177
191
|
assert.ok(firstCwd < firstReclaim, "cwd before reclaim")
|
|
178
|
-
assert.
|
|
192
|
+
assert.equal(allCalls.some((call) => call.method === "registerTool"), false, "built-in tools are not re-registered")
|
|
179
193
|
})
|
|
180
194
|
|
|
181
195
|
test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
|
|
@@ -260,3 +274,25 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
260
274
|
)
|
|
261
275
|
})
|
|
262
276
|
})
|
|
277
|
+
|
|
278
|
+
test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
279
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
280
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
281
|
+
delete process.env.HERDR_ENV
|
|
282
|
+
t.after(restoreEnv)
|
|
283
|
+
const { pi, registerCalls, emit } = createFake({ cwd })
|
|
284
|
+
install(pi)
|
|
285
|
+
t.after(() => emit("session_shutdown"))
|
|
286
|
+
await emit("session_start")
|
|
287
|
+
await emit("agent_start")
|
|
288
|
+
await emit("tool_execution_end", { toolCallId: "a" })
|
|
289
|
+
await emit("tool_execution_end", { toolCallId: "b" })
|
|
290
|
+
await emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop" }] })
|
|
291
|
+
const appended = registerCalls.filter((c) => c.method === "appendEntry")
|
|
292
|
+
assert.equal(appended.length, 1)
|
|
293
|
+
assert.equal(appended[0].args[0], "pi-minimal.turn")
|
|
294
|
+
const data = appended[0].args[1] as { tools: number; outcome: string }
|
|
295
|
+
assert.equal(data.tools, 2)
|
|
296
|
+
assert.equal(data.outcome, "completed")
|
|
297
|
+
assert.ok(registerCalls.some((c) => c.method === "registerEntryRenderer" && c.args[0] === "pi-minimal.turn"))
|
|
298
|
+
})
|
package/test/package.test.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { readFileSync } from "node:fs"
|
|
|
5
5
|
test("package manifest exposes the committed extension", () => {
|
|
6
6
|
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
|
|
7
7
|
assert.equal(pkg.name, "pi-minimal")
|
|
8
|
-
assert.equal(pkg.version, "0.
|
|
8
|
+
assert.equal(pkg.version, "0.2.2")
|
|
9
9
|
assert.equal(pkg.license, "MIT")
|
|
10
10
|
assert.equal(pkg.type, "module")
|
|
11
11
|
assert.deepEqual(pkg.engines, { node: ">=22.19.0" })
|
|
12
12
|
assert.deepEqual(pkg.keywords, ["pi-package"])
|
|
13
|
-
assert.deepEqual(pkg.
|
|
13
|
+
assert.deepEqual(pkg.repository, { type: "git", url: "git+https://github.com/raviy06/pi-minimal.git" })
|
|
14
|
+
assert.deepEqual(pkg.pi, { extensions: ["./dist/index.js"], themes: ["./themes/minimal-dark.json"] })
|
|
14
15
|
assert.deepEqual(pkg.peerDependencies, {
|
|
15
16
|
"@earendil-works/pi-coding-agent": "*",
|
|
16
17
|
"@earendil-works/pi-tui": "*",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import test from "node:test"
|
|
5
|
+
|
|
6
|
+
function builtinDarkTheme(): { colors: Record<string, string> } {
|
|
7
|
+
const path = join("node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json")
|
|
8
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const theme = JSON.parse(readFileSync("themes/minimal-dark.json", "utf8")) as {
|
|
12
|
+
name: string
|
|
13
|
+
appearance: string
|
|
14
|
+
vars: Record<string, string>
|
|
15
|
+
colors: Record<string, string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("minimal-dark is named after its file and targets dark terminals", () => {
|
|
19
|
+
assert.equal(theme.name, "minimal-dark")
|
|
20
|
+
assert.equal(theme.appearance, "dark")
|
|
21
|
+
assert.ok(dirname("themes/minimal-dark.json").endsWith("themes"))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("minimal-dark defines every color role the built-in dark theme defines", () => {
|
|
25
|
+
const required = Object.keys(builtinDarkTheme().colors).sort()
|
|
26
|
+
const provided = Object.keys(theme.colors).sort()
|
|
27
|
+
assert.deepEqual(provided, required)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("minimal-dark variable references resolve", () => {
|
|
31
|
+
const isLiteral = (v: string) => v === "" || v.startsWith("#") || v.startsWith("okhsl(") || v.startsWith("oklch(")
|
|
32
|
+
for (const [name, value] of Object.entries(theme.vars)) {
|
|
33
|
+
if (!isLiteral(value)) assert.ok(value in theme.vars, `var ${name} -> ${value}`)
|
|
34
|
+
}
|
|
35
|
+
for (const [role, value] of Object.entries(theme.colors)) {
|
|
36
|
+
if (!isLiteral(value)) assert.ok(value in theme.vars, `color ${role} -> ${value}`)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("minimal-dark frames code quietly and neutralises tool backgrounds", () => {
|
|
41
|
+
assert.equal(theme.colors.mdCodeBlockBorder, "faint")
|
|
42
|
+
assert.equal(theme.colors.mdCodeBlock, "text")
|
|
43
|
+
assert.equal(theme.colors.toolSuccessBg, theme.colors.toolPendingBg)
|
|
44
|
+
assert.notEqual(theme.colors.mdHeading, theme.colors.warning)
|
|
45
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
4
|
+
import {
|
|
5
|
+
bashBody,
|
|
6
|
+
diffStats,
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatOutcome,
|
|
9
|
+
layoutRow,
|
|
10
|
+
rowGlyph,
|
|
11
|
+
shortName,
|
|
12
|
+
toolItem,
|
|
13
|
+
} from "../src/tool-rows.js"
|
|
14
|
+
|
|
15
|
+
const text = (s: string) => ({ content: [{ type: "text", text: s }] })
|
|
16
|
+
|
|
17
|
+
test("toolItem picks the first command line for bash and a two-segment path otherwise", () => {
|
|
18
|
+
assert.equal(toolItem("bash", { command: "git status\ngit diff" }), "git status")
|
|
19
|
+
assert.equal(toolItem("read", { path: "/a/b/c/file.ts" }), "c/file.ts")
|
|
20
|
+
assert.equal(toolItem("grep", { pattern: "foo" }), "foo")
|
|
21
|
+
assert.equal(toolItem("ls", {}), "ls")
|
|
22
|
+
assert.equal(shortName("file.ts"), "file.ts")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("rowGlyph encodes status and blinks only while running", () => {
|
|
26
|
+
assert.equal(rowGlyph("running", 0), "◐")
|
|
27
|
+
assert.equal(rowGlyph("running", 1), "◓")
|
|
28
|
+
assert.equal(rowGlyph("ok", 1), "✓")
|
|
29
|
+
assert.equal(rowGlyph("error", 1), "✗")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("formatDuration rounds to tenths and switches to minutes", () => {
|
|
33
|
+
assert.equal(formatDuration(undefined), "")
|
|
34
|
+
assert.equal(formatDuration(40), "0s")
|
|
35
|
+
assert.equal(formatDuration(1040), "1s")
|
|
36
|
+
assert.equal(formatDuration(1250), "1.3s")
|
|
37
|
+
assert.equal(formatDuration(65_000), "1m 5s")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("bashBody strips the exit status trailer and truncation note", () => {
|
|
41
|
+
const { body, exitCode } = bashBody("hello\nworld\n\nCommand exited with code 2")
|
|
42
|
+
assert.equal(body, "hello\nworld")
|
|
43
|
+
assert.equal(exitCode, 2)
|
|
44
|
+
const ok = bashBody("x\n\n[Showing lines 1-3 of 9. Full output: /tmp/x]")
|
|
45
|
+
assert.equal(ok.body, "x")
|
|
46
|
+
assert.equal(ok.exitCode, undefined)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("diffStats counts hunk lines and ignores file headers", () => {
|
|
50
|
+
assert.deepEqual(diffStats("--- a\n+++ b\n@@\n-x\n+y\n+z\n ctx"), { added: 2, removed: 1 })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("formatOutcome: bash success shows line count and duration", () => {
|
|
54
|
+
const out = formatOutcome("bash", {}, text("a\nb\nc"), false, 1040)
|
|
55
|
+
assert.equal(out.text, "3 lines · 1s")
|
|
56
|
+
assert.equal(out.errorLine, undefined)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("formatOutcome: bash with no output says ok", () => {
|
|
60
|
+
assert.equal(formatOutcome("bash", {}, text("(no output)"), false, 200).text, "ok · 0.2s")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("formatOutcome: bash failure exposes exit code and first output line", () => {
|
|
64
|
+
const out = formatOutcome("bash", {}, text("boom: bad thing\nmore\n\nCommand exited with code 1"), true, 300)
|
|
65
|
+
assert.equal(out.text, "exit 1 · 0.3s")
|
|
66
|
+
assert.equal(out.errorLine, "boom: bad thing")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("formatOutcome: bash failure with no output has no error line", () => {
|
|
70
|
+
const out = formatOutcome("bash", {}, text("(no output)\n\nCommand exited with code 1"), true, 10)
|
|
71
|
+
assert.equal(out.text, "exit 1 · 0s")
|
|
72
|
+
assert.equal(out.errorLine, undefined)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("formatOutcome: edit reports diff stats, write reports bytes", () => {
|
|
76
|
+
const edit = formatOutcome("edit", {}, { content: [], details: { diff: "-a\n+b\n+c" } }, false, undefined)
|
|
77
|
+
assert.deepEqual(edit.diff, { added: 2, removed: 1 })
|
|
78
|
+
assert.equal(edit.text, "")
|
|
79
|
+
const write = formatOutcome("write", { content: "x".repeat(2048) }, text("ok"), false, undefined)
|
|
80
|
+
assert.equal(write.text, "2 KB")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("formatOutcome: read, grep, find summaries", () => {
|
|
84
|
+
assert.equal(formatOutcome("read", {}, text("1\n2"), false, undefined).text, "2 lines")
|
|
85
|
+
assert.equal(formatOutcome("grep", {}, text("No matches found"), false, undefined).text, "0 matches")
|
|
86
|
+
assert.equal(formatOutcome("grep", {}, text("a:1:x"), false, undefined).text, "1 match")
|
|
87
|
+
assert.equal(formatOutcome("find", {}, text("a\nb\nc"), false, undefined).text, "3 entries")
|
|
88
|
+
assert.equal(formatOutcome("ls", {}, text("a"), false, undefined).text, "1 entry")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("formatOutcome: generic error carries the first line", () => {
|
|
92
|
+
const out = formatOutcome("read", {}, text("ENOENT: no such file"), true, undefined)
|
|
93
|
+
assert.equal(out.text, "failed")
|
|
94
|
+
assert.equal(out.errorLine, "ENOENT: no such file")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("layoutRow keeps the outcome visible by truncating the item", () => {
|
|
98
|
+
const row = layoutRow(40, "bash", "a very long command that goes on and on forever", "34 lines · 1.0s")
|
|
99
|
+
assert.equal(row.verb, "bash ")
|
|
100
|
+
assert.equal(row.outcome, "34 lines · 1.0s")
|
|
101
|
+
assert.ok(row.item.includes("…"))
|
|
102
|
+
assert.equal(2 + 5 + 1 + visibleWidth(row.item) + row.gap + row.outcome.length, 40)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("layoutRow drops the outcome when the row is too narrow", () => {
|
|
106
|
+
const row = layoutRow(20, "bash", "short", "34 lines · 1.0s")
|
|
107
|
+
assert.equal(row.outcome, "")
|
|
108
|
+
assert.equal(row.gap, 0)
|
|
109
|
+
})
|
package/test/tools.test.ts
CHANGED
|
@@ -1,48 +1,55 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import test, { type TestContext } from "node:test"
|
|
6
|
-
import {
|
|
7
|
-
SettingsManager,
|
|
8
|
-
type ExtensionAPI,
|
|
9
|
-
type ExtensionContext,
|
|
10
|
-
} from "@earendil-works/pi-coding-agent"
|
|
11
|
-
import { wrapBuiltinTools } from "../src/tools.js"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { compactRenderers, installToolRenderers, isToolKind } from "../src/tools.js"
|
|
12
5
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
else process.env[AGENT_DIR_ENV] = originalAgentDir
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function setupDirs(t: TestContext) {
|
|
22
|
-
const root = mkdtempSync(join(tmpdir(), "pi-minimal-tools-"))
|
|
23
|
-
t.after(() => rmSync(root, { recursive: true, force: true }))
|
|
24
|
-
const agentDir = join(root, "agent")
|
|
25
|
-
const cwd = join(root, "project")
|
|
26
|
-
mkdirSync(agentDir)
|
|
27
|
-
mkdirSync(cwd)
|
|
28
|
-
return { agentDir, cwd }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
test("untrusted project does not receive projectTrusted true", (t) => {
|
|
32
|
-
const { agentDir, cwd } = setupDirs(t)
|
|
33
|
-
process.env[AGENT_DIR_ENV] = agentDir
|
|
34
|
-
t.after(restoreEnv)
|
|
6
|
+
test("isToolKind recognises the built-in tools only", () => {
|
|
7
|
+
for (const name of ["bash", "read", "edit", "write", "grep", "find", "ls"]) assert.equal(isToolKind(name), true)
|
|
8
|
+
assert.equal(isToolKind("codemode"), false)
|
|
9
|
+
assert.equal(isToolKind("mcp__foo"), false)
|
|
10
|
+
})
|
|
35
11
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
12
|
+
test("installToolRenderers registers a resolver at load time that only claims built-in tools", () => {
|
|
13
|
+
let resolver: ((name: string, next: () => unknown) => unknown) | undefined
|
|
14
|
+
const pi = {
|
|
15
|
+
registerToolRenderer(fn: typeof resolver) {
|
|
16
|
+
resolver = fn
|
|
17
|
+
},
|
|
18
|
+
} as unknown as ExtensionAPI
|
|
19
|
+
installToolRenderers(pi)
|
|
20
|
+
assert.ok(resolver, "resolver registered synchronously, before session_start")
|
|
21
|
+
const fallback = { renderShell: "default" }
|
|
22
|
+
assert.equal(resolver!("codemode", () => fallback), fallback)
|
|
23
|
+
const mine = resolver!("bash", () => fallback) as { renderShell?: string }
|
|
24
|
+
assert.equal(mine.renderShell, "self")
|
|
25
|
+
assert.equal(typeof (mine as { renderCall?: unknown }).renderCall, "function")
|
|
26
|
+
})
|
|
42
27
|
|
|
43
|
-
|
|
28
|
+
test("installToolRenderers is a no-op on hosts without registerToolRenderer", () => {
|
|
29
|
+
assert.doesNotThrow(() => installToolRenderers({} as ExtensionAPI))
|
|
30
|
+
})
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
32
|
+
test("compactRenderers render a one-line row with the outcome once the result lands", () => {
|
|
33
|
+
const renderers = compactRenderers("bash")
|
|
34
|
+
const theme = { fg: (_c: string, s: string) => s } as never
|
|
35
|
+
const state: Record<string, unknown> = {}
|
|
36
|
+
const context = {
|
|
37
|
+
args: { command: "ls" },
|
|
38
|
+
toolCallId: "t1",
|
|
39
|
+
invalidate() {},
|
|
40
|
+
state,
|
|
41
|
+
isPartial: false,
|
|
42
|
+
isError: false,
|
|
43
|
+
durationMs: 1200,
|
|
44
|
+
} as never
|
|
45
|
+
const result = renderers.renderResult!(
|
|
46
|
+
{ content: [{ type: "text", text: "a\nb" }] } as never,
|
|
47
|
+
{ expanded: false, isPartial: false } as never,
|
|
48
|
+
theme,
|
|
49
|
+
context,
|
|
50
|
+
)
|
|
51
|
+
assert.ok(result)
|
|
52
|
+
const row = renderers.renderCall!({ command: "ls" } as never, theme, context)
|
|
53
|
+
const [line] = row.render(60)
|
|
54
|
+
assert.match(line, /✓ bash\s+ls\s+2 lines · 1.2s$/)
|
|
48
55
|
})
|