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.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict"
2
2
  import test from "node:test"
3
- import { activityBullet, rowIsRunning } from "../src/activity.js"
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("activityBullet(false, 0) returns ◆", () => {
14
- assert.equal(activityBullet(false, 0), "◆")
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
+ })
@@ -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 and wraps tools before the first reclaim", async (t) => {
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 === "registerTool")
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, "tools wrapped")
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.ok(firstWrap < firstReclaim, "wrap before reclaim")
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
+ })
@@ -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.1.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.pi, { extensions: ["./dist/index.js"] })
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
+ })
@@ -1,48 +1,55 @@
1
1
  import assert from "node:assert/strict"
2
- import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
3
- import { tmpdir } from "node:os"
4
- import { join } from "node:path"
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
- const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
14
- const originalAgentDir = process.env[AGENT_DIR_ENV]
15
-
16
- function restoreEnv() {
17
- if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
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
- const create = t.mock.method(SettingsManager, "create")
37
- const pi = { registerTool() {} } as unknown as ExtensionAPI
38
- const ctx = {
39
- cwd,
40
- isProjectTrusted: () => false,
41
- } as unknown as ExtensionContext
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
- wrapBuiltinTools(pi, ctx)
28
+ test("installToolRenderers is a no-op on hosts without registerToolRenderer", () => {
29
+ assert.doesNotThrow(() => installToolRenderers({} as ExtensionAPI))
30
+ })
44
31
 
45
- assert.equal(create.mock.callCount(), 1)
46
- const options = create.mock.calls[0].arguments[2] as { projectTrusted?: boolean } | undefined
47
- assert.equal(options?.projectTrusted, false)
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
  })