pi-minimal 0.4.0 → 0.6.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 +81 -1
- package/CONTEXT.md +107 -0
- package/README.md +9 -6
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +10 -1
- package/dist/cwd.js +2 -2
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/footer.js +15 -3
- package/dist/git-state.js +31 -11
- package/dist/herdr.js +78 -0
- package/dist/index.js +79 -202
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/reclaim.js +0 -3
- package/dist/rex.js +289 -0
- package/dist/session.js +168 -0
- package/dist/siblings.js +14 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +5 -3
- package/release-notes.md +33 -12
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +10 -1
- package/src/cwd.ts +5 -4
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/footer.ts +23 -3
- package/src/git-state.ts +29 -10
- package/src/herdr.ts +78 -5
- package/src/index.ts +81 -195
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/reclaim.ts +0 -4
- package/src/rex.ts +304 -0
- package/src/session.ts +185 -0
- package/src/siblings.ts +38 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +24 -11
- package/test/footer-composition.test.ts +61 -0
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +202 -4
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/src/tracker.ts
CHANGED
|
@@ -3,18 +3,24 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
|
3
3
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const
|
|
4
4
|
type PhaseName = (typeof PHASES)[number]
|
|
5
5
|
type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped"
|
|
6
|
-
|
|
6
|
+
/** pi-gauntlet's task statuses; distinct from the footer contribution API's `ActivityTask.status`. */
|
|
7
|
+
type GauntletTaskStatus = "pending" | "in_progress" | "complete" | "failed"
|
|
7
8
|
|
|
8
9
|
type PhaseMap = Record<PhaseName, { status: PhaseStatus; substep?: string | null }>
|
|
9
10
|
|
|
10
|
-
type Task = { name: string; status:
|
|
11
|
+
type Task = { name: string; status: GauntletTaskStatus }
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* What pi-gauntlet last reported on this session branch, read from its `phase_tracker` and
|
|
15
|
+
* `plan_tracker` tool results. A projection of another extension's history, not a contribution:
|
|
16
|
+
* nothing is registered or versioned, so it is only as stable as those tools' `details` shapes.
|
|
17
|
+
*/
|
|
18
|
+
export type GauntletProjection = {
|
|
13
19
|
phases?: PhaseMap
|
|
14
20
|
tasks?: Task[]
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
function
|
|
23
|
+
export function readGauntletProjection(ctx: ExtensionContext): GauntletProjection {
|
|
18
24
|
let phases: PhaseMap | undefined
|
|
19
25
|
let tasks: Task[] | undefined
|
|
20
26
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -47,7 +53,7 @@ export function formatTrackerLines(
|
|
|
47
53
|
ctx: ExtensionContext,
|
|
48
54
|
theme: { fg: (name: string, s: string) => string },
|
|
49
55
|
): string[] {
|
|
50
|
-
const { phases, tasks } =
|
|
56
|
+
const { phases, tasks } = readGauntletProjection(ctx)
|
|
51
57
|
const lines: string[] = []
|
|
52
58
|
if (phases && phaseActive(phases)) {
|
|
53
59
|
const parts = PHASES.map((p) => {
|
package/src/working-status.ts
CHANGED
|
@@ -206,8 +206,9 @@ export function installWorkingStatus(pi: ExtensionAPI): () => void {
|
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
pi.on("session_shutdown", () => {
|
|
209
|
-
ctx
|
|
209
|
+
// Clear the message while ctx is still known; dropping ctx first left it on screen.
|
|
210
210
|
stop()
|
|
211
|
+
ctx = undefined
|
|
211
212
|
})
|
|
212
213
|
|
|
213
214
|
return stop
|
|
@@ -0,0 +1,198 @@
|
|
|
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 {
|
|
5
|
+
applyPoll,
|
|
6
|
+
createBackgroundState,
|
|
7
|
+
createBackgroundTracker,
|
|
8
|
+
findOrphanGroups,
|
|
9
|
+
parseEtime,
|
|
10
|
+
parsePs,
|
|
11
|
+
PS_ARGS,
|
|
12
|
+
snapshot,
|
|
13
|
+
} from "../src/background.js"
|
|
14
|
+
import { backgroundRows, backgroundSegment, renderBackgroundLines } from "../src/background-overlay.js"
|
|
15
|
+
|
|
16
|
+
// `ps -ax -o pid=,ppid=,pgid=,etime=,command=` as macOS prints it (right-aligned numbers).
|
|
17
|
+
const PI = 76737
|
|
18
|
+
const SHELL = 81443
|
|
19
|
+
const psBefore = `
|
|
20
|
+
${PI} 78317 ${PI} 04:11 pi
|
|
21
|
+
81000 1 81000 01-02:03:04 /usr/libexec/trustd --agent
|
|
22
|
+
`
|
|
23
|
+
const psDuring = `${psBefore}
|
|
24
|
+
${SHELL} ${PI} ${SHELL} 00:00 /bin/bash -c (./watch.sh &); sleep 0.3
|
|
25
|
+
`
|
|
26
|
+
const psAfter = `${psBefore}
|
|
27
|
+
81446 1 ${SHELL} 00:01 /bin/bash ./watch.sh
|
|
28
|
+
81450 1 ${SHELL} 00:01 node server.js
|
|
29
|
+
81460 1 81460 00:00 /usr/libexec/other-daemon
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
test.describe("ps parsing", () => {
|
|
33
|
+
test("parseEtime handles every format", () => {
|
|
34
|
+
assert.equal(parseEtime("05"), 5)
|
|
35
|
+
assert.equal(parseEtime("04:11"), 251)
|
|
36
|
+
assert.equal(parseEtime("01:02:03"), 3723)
|
|
37
|
+
assert.equal(parseEtime("01-02:03:04"), 86400 + 7384)
|
|
38
|
+
assert.equal(parseEtime("garbage"), 0)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("parsePs reads pid, ppid, pgid, etime and the full command", () => {
|
|
42
|
+
const entries = parsePs(psAfter)
|
|
43
|
+
assert.deepEqual(entries.get(81446), { pid: 81446, ppid: 1, pgid: SHELL, elapsedSec: 1, command: "/bin/bash ./watch.sh" })
|
|
44
|
+
assert.equal(entries.get(81000)?.elapsedSec, 86400 + 7384)
|
|
45
|
+
assert.equal(entries.size, 5)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test.describe("findOrphanGroups", () => {
|
|
50
|
+
test("finds the dead tool shell's group and ignores unrelated orphans and pre-existing groups", () => {
|
|
51
|
+
const groups = findOrphanGroups(parsePs(psBefore), parsePs(psAfter))
|
|
52
|
+
assert.deepEqual([...groups], [SHELL])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("a group whose leader is still alive is a foreground call, not background", () => {
|
|
56
|
+
const groups = findOrphanGroups(parsePs(psBefore), parsePs(`${psDuring}\n 81446 1 ${SHELL} 00:01 node server.js`))
|
|
57
|
+
assert.equal(groups.size, 0)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("a group that existed before the call belongs to someone else", () => {
|
|
61
|
+
const before = parsePs(`${psBefore}\n 81446 1 ${SHELL} 00:10 node server.js`)
|
|
62
|
+
assert.equal(findOrphanGroups(before, parsePs(psAfter)).size, 0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test.describe("applyPoll", () => {
|
|
67
|
+
test("tracks members, marks them ended when gone, forgets them after the TTL and drops empty groups", () => {
|
|
68
|
+
const state = createBackgroundState()
|
|
69
|
+
state.groups.add(SHELL)
|
|
70
|
+
let now = 100_000
|
|
71
|
+
assert.equal(applyPoll(state, parsePs(psAfter), now), true)
|
|
72
|
+
let snap = snapshot(state)
|
|
73
|
+
assert.deepEqual(snap.running.map((p) => [p.pid, p.command, p.startedAt]), [
|
|
74
|
+
[81446, "/bin/bash ./watch.sh", now - 1000],
|
|
75
|
+
[81450, "node server.js", now - 1000],
|
|
76
|
+
])
|
|
77
|
+
assert.equal(snap.ended.length, 0)
|
|
78
|
+
|
|
79
|
+
// Nothing changed: no notification.
|
|
80
|
+
assert.equal(applyPoll(state, parsePs(psAfter), now + 2000), false)
|
|
81
|
+
|
|
82
|
+
// server.js exits.
|
|
83
|
+
now += 4000
|
|
84
|
+
const onlyWatch = psAfter.split("\n").filter((line) => !line.includes("server.js")).join("\n")
|
|
85
|
+
assert.equal(applyPoll(state, parsePs(onlyWatch), now), true)
|
|
86
|
+
snap = snapshot(state)
|
|
87
|
+
assert.deepEqual(snap.running.map((p) => p.pid), [81446])
|
|
88
|
+
assert.deepEqual(snap.ended.map((p) => [p.pid, p.endedAt]), [[81450, now]])
|
|
89
|
+
|
|
90
|
+
// Everything exits; the ended entries linger for a minute, then the group is dropped.
|
|
91
|
+
now += 2000
|
|
92
|
+
assert.equal(applyPoll(state, parsePs(psBefore), now), true)
|
|
93
|
+
assert.equal(snapshot(state).running.length, 0)
|
|
94
|
+
assert.equal(snapshot(state).ended.length, 2)
|
|
95
|
+
assert.equal(state.groups.has(SHELL), true)
|
|
96
|
+
now += 61_000
|
|
97
|
+
assert.equal(applyPoll(state, parsePs(psBefore), now), true)
|
|
98
|
+
assert.equal(snapshot(state).ended.length, 0)
|
|
99
|
+
assert.equal(state.groups.size, 0)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test.describe("overlay rendering", () => {
|
|
104
|
+
const theme = { fg: (_c: string, t: string) => t }
|
|
105
|
+
|
|
106
|
+
test("segment shows the running count only", () => {
|
|
107
|
+
assert.equal(backgroundSegment({ running: [], ended: [] }), undefined)
|
|
108
|
+
const proc = { pid: 1, pgid: 1, command: "x", startedAt: 0 }
|
|
109
|
+
assert.equal(backgroundSegment({ running: [proc, proc], ended: [proc] }), "⚙ 2 bg")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("rows carry status and duration; lines fit the width and mark the selection", () => {
|
|
113
|
+
const now = 1_000_000
|
|
114
|
+
const rows = backgroundRows(
|
|
115
|
+
{
|
|
116
|
+
running: [{ pid: 10, pgid: 1, command: "npm run dev", startedAt: now - 252_000 }],
|
|
117
|
+
ended: [{ pid: 11, pgid: 1, command: "tail -f app.log", startedAt: now - 40_000, endedAt: now - 8000 }],
|
|
118
|
+
},
|
|
119
|
+
now,
|
|
120
|
+
)
|
|
121
|
+
assert.deepEqual(rows, [
|
|
122
|
+
{ pid: 10, command: "npm run dev", status: "running", durationMs: 252_000 },
|
|
123
|
+
{ pid: 11, command: "tail -f app.log", status: "ended", durationMs: 32_000 },
|
|
124
|
+
])
|
|
125
|
+
const lines = renderBackgroundLines(rows, 1, 60, theme, "ctrl+b")
|
|
126
|
+
assert.equal(lines.length, 4)
|
|
127
|
+
assert.match(lines[1] ?? "", /^ {2}npm run dev\s+running\s+4m 12s$/)
|
|
128
|
+
assert.match(lines[2] ?? "", /^› tail -f app.log\s+ended\s+ran 32s$/)
|
|
129
|
+
assert.ok(lines.every((line) => line.length <= 60))
|
|
130
|
+
assert.match(lines[3] ?? "", /ctrl\+b/)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("empty list says so", () => {
|
|
134
|
+
const lines = renderBackgroundLines([], 0, 40, theme)
|
|
135
|
+
assert.match(lines[1] ?? "", /Nothing running/)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test.describe("createBackgroundTracker", () => {
|
|
140
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
141
|
+
const settle = async () => { for (let i = 0; i < 6; i++) await tick() }
|
|
142
|
+
|
|
143
|
+
function fakePi(listings: string[]) {
|
|
144
|
+
const execCalls: string[][] = []
|
|
145
|
+
const pi = {
|
|
146
|
+
async exec(command: string, args: string[]) {
|
|
147
|
+
execCalls.push([command, ...args])
|
|
148
|
+
const stdout = listings.length > 1 ? (listings.shift() as string) : (listings[0] ?? "")
|
|
149
|
+
return { stdout, stderr: "", code: 0, killed: false }
|
|
150
|
+
},
|
|
151
|
+
} as unknown as ExtensionAPI
|
|
152
|
+
return { pi, execCalls }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("a bash call that leaves processes behind is tracked; other tools are ignored", async () => {
|
|
156
|
+
const { pi, execCalls } = fakePi([psBefore, psAfter, psAfter])
|
|
157
|
+
const killed: Array<[number, string]> = []
|
|
158
|
+
let now = 500_000
|
|
159
|
+
const tracker = createBackgroundTracker({ pi, now: () => now, killProcess: (pid, signal) => { killed.push([pid, signal]) } })
|
|
160
|
+
let changes = 0
|
|
161
|
+
tracker.onChange(() => { changes += 1 })
|
|
162
|
+
|
|
163
|
+
tracker.toolStart("c1", "read")
|
|
164
|
+
tracker.toolEnd("c1", "read")
|
|
165
|
+
await settle()
|
|
166
|
+
assert.equal(execCalls.length, 0)
|
|
167
|
+
|
|
168
|
+
tracker.toolStart("c2", "bash")
|
|
169
|
+
await settle()
|
|
170
|
+
tracker.toolEnd("c2", "bash")
|
|
171
|
+
await settle()
|
|
172
|
+
assert.deepEqual(execCalls.slice(0, 2), [["ps", ...PS_ARGS], ["ps", ...PS_ARGS]])
|
|
173
|
+
assert.equal(changes, 1)
|
|
174
|
+
assert.deepEqual(tracker.get().running.map((p) => p.pid), [81446, 81450])
|
|
175
|
+
|
|
176
|
+
assert.equal(tracker.kill(81450), true)
|
|
177
|
+
assert.equal(tracker.kill(81446, { group: true, signal: "SIGKILL" }), true)
|
|
178
|
+
assert.equal(tracker.kill(99999), false)
|
|
179
|
+
assert.deepEqual(killed, [[81450, "SIGTERM"], [-SHELL, "SIGKILL"]])
|
|
180
|
+
|
|
181
|
+
now += 3000
|
|
182
|
+
tracker.dispose()
|
|
183
|
+
assert.deepEqual(tracker.get().running.map((p) => p.pid), [81446, 81450], "get() still answers after dispose")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("a clean bash call tracks nothing and starts no poller", async () => {
|
|
187
|
+
const { pi, execCalls } = fakePi([psBefore, psBefore])
|
|
188
|
+
const tracker = createBackgroundTracker({ pi })
|
|
189
|
+
tracker.toolStart("c1", "bash")
|
|
190
|
+
tracker.toolEnd("c1", "bash")
|
|
191
|
+
await settle()
|
|
192
|
+
assert.equal(execCalls.length, 2)
|
|
193
|
+
assert.equal(tracker.get().running.length, 0)
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
195
|
+
assert.equal(execCalls.length, 2, "no poll scheduled")
|
|
196
|
+
tracker.dispose()
|
|
197
|
+
})
|
|
198
|
+
})
|
package/test/config.test.ts
CHANGED
|
@@ -26,11 +26,11 @@ function writeProject(cwd: string, contents: string) {
|
|
|
26
26
|
writeFileSync(join(dir, "pi-minimal.json"), contents)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
test("defaults hideGauntletWidgets to
|
|
29
|
+
test("defaults hideGauntletWidgets to false", (t) => {
|
|
30
30
|
const { agentDir, cwd } = setup(t)
|
|
31
31
|
const error = t.mock.method(console, "error")
|
|
32
32
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
33
|
-
assert.deepEqual(config, { hideGauntletWidgets:
|
|
33
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
34
34
|
assert.equal(error.mock.callCount(), 0)
|
|
35
35
|
})
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ test("global false overrides default", (t) => {
|
|
|
39
39
|
writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
40
40
|
const error = t.mock.method(console, "error")
|
|
41
41
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
|
|
42
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
42
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
43
43
|
assert.equal(error.mock.callCount(), 0)
|
|
44
44
|
})
|
|
45
45
|
|
|
@@ -49,7 +49,7 @@ test("trusted project true overrides global false", (t) => {
|
|
|
49
49
|
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: true, editDiff: "expanded" }))
|
|
50
50
|
const error = t.mock.method(console, "error")
|
|
51
51
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
52
|
-
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
|
|
52
|
+
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
53
53
|
assert.equal(error.mock.callCount(), 0)
|
|
54
54
|
})
|
|
55
55
|
|
|
@@ -59,7 +59,7 @@ test("untrusted project false does not override global true", (t) => {
|
|
|
59
59
|
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
|
|
60
60
|
const error = t.mock.method(console, "error")
|
|
61
61
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
|
|
62
|
-
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
|
|
62
|
+
assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
63
63
|
assert.equal(error.mock.callCount(), 0)
|
|
64
64
|
})
|
|
65
65
|
|
|
@@ -69,7 +69,7 @@ test("omitted keys do not override", (t) => {
|
|
|
69
69
|
writeProject(cwd, JSON.stringify({}))
|
|
70
70
|
const error = t.mock.method(console, "error")
|
|
71
71
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
72
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
72
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
73
73
|
assert.equal(error.mock.callCount(), 0)
|
|
74
74
|
})
|
|
75
75
|
|
|
@@ -79,7 +79,7 @@ test("unknown keys do not override", (t) => {
|
|
|
79
79
|
writeProject(cwd, JSON.stringify({ extra: true }))
|
|
80
80
|
const error = t.mock.method(console, "error")
|
|
81
81
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
82
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
82
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
83
83
|
assert.equal(error.mock.callCount(), 0)
|
|
84
84
|
})
|
|
85
85
|
|
|
@@ -89,7 +89,7 @@ test("invalid JSON calls console.error and leaves the lower-precedence value unc
|
|
|
89
89
|
writeProject(cwd, "{")
|
|
90
90
|
const error = t.mock.method(console, "error")
|
|
91
91
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
92
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
92
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
93
93
|
assert.ok(error.mock.callCount() >= 1)
|
|
94
94
|
})
|
|
95
95
|
|
|
@@ -99,7 +99,7 @@ test("non-object root calls console.error and leaves the lower-precedence value
|
|
|
99
99
|
writeProject(cwd, JSON.stringify(["hideGauntletWidgets", false]))
|
|
100
100
|
const error = t.mock.method(console, "error")
|
|
101
101
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
102
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
102
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
103
103
|
assert.ok(error.mock.callCount() >= 1)
|
|
104
104
|
})
|
|
105
105
|
|
|
@@ -109,7 +109,7 @@ test("non-boolean hideGauntletWidgets calls console.error and leaves the lower-p
|
|
|
109
109
|
writeProject(cwd, JSON.stringify({ hideGauntletWidgets: "yes" }))
|
|
110
110
|
const error = t.mock.method(console, "error")
|
|
111
111
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
112
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
112
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
|
|
113
113
|
assert.ok(error.mock.callCount() >= 1)
|
|
114
114
|
})
|
|
115
115
|
|
|
@@ -129,7 +129,20 @@ test("an invalid editDiff value is reported and does not override", (t) => {
|
|
|
129
129
|
writeProject(cwd, JSON.stringify({ editDiff: "sometimes" }))
|
|
130
130
|
const error = t.mock.method(console, "error")
|
|
131
131
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
132
|
-
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "always" })
|
|
132
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "always", backgroundShortcut: "ctrl+b" })
|
|
133
133
|
assert.equal(error.mock.callCount(), 1)
|
|
134
134
|
assert.match(String(error.mock.calls[0]!.arguments[0]), /editDiff must be one of "always", "never", "expanded"/)
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
test("backgroundShortcut accepts a key name or null and rejects other values", (t) => {
|
|
138
|
+
const { agentDir, cwd } = setup(t)
|
|
139
|
+
writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: "ctrl+g" }))
|
|
140
|
+
assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, "ctrl+g")
|
|
141
|
+
writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: null }))
|
|
142
|
+
assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, null)
|
|
143
|
+
const errors: string[] = []
|
|
144
|
+
t.mock.method(console, "error", (message: string) => { errors.push(message) })
|
|
145
|
+
writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: 7 }))
|
|
146
|
+
assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, "ctrl+b")
|
|
147
|
+
assert.match(errors.join("\n"), /backgroundShortcut/)
|
|
148
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import test from "node:test"
|
|
3
3
|
import { initTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { visibleWidth } from "@earendil-works/pi-tui"
|
|
4
5
|
import { installFooter } from "../src/footer.js"
|
|
5
6
|
import { createFooterContributions, type ActivitySnapshot } from "../src/footer-contributions.js"
|
|
6
7
|
|
|
@@ -23,6 +24,8 @@ const data = (revision: number, motion: "spinner" | "static" = "spinner"): Activ
|
|
|
23
24
|
revision, rows: [{ key: "1", agent: "worker", state: "running", text: "Last: tests" }], omitted: 0, motion, format: "icons",
|
|
24
25
|
})
|
|
25
26
|
const sources = { busy: () => false, editorText: () => "", contextPct: () => 10 }
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
|
|
26
29
|
|
|
27
30
|
test("no contributor preserves existing footer; activity is last even while typing/busy", () => {
|
|
28
31
|
const f = fixture(), registry = createFooterContributions("S1")
|
|
@@ -127,3 +130,61 @@ test("legacy task summary precedes activity", () => {
|
|
|
127
130
|
assert.ok(lines.at(-1)?.includes("worker"))
|
|
128
131
|
dispose()
|
|
129
132
|
})
|
|
133
|
+
|
|
134
|
+
test("background count follows the hints and survives hiding them", () => {
|
|
135
|
+
const f = fixture()
|
|
136
|
+
let bg: string | undefined = "⚙ 2 bg"
|
|
137
|
+
let listener: (() => void) | undefined
|
|
138
|
+
const bgSources = {
|
|
139
|
+
...sources,
|
|
140
|
+
background: () => bg,
|
|
141
|
+
onBackgroundChange: (l: () => void) => { listener = l; return () => { listener = undefined } },
|
|
142
|
+
prs: () => "#24",
|
|
143
|
+
}
|
|
144
|
+
const stop = installFooter(f.ctx, bgSources)
|
|
145
|
+
const component = f.mount()
|
|
146
|
+
assert.match(stripAnsi(component.render(80)[0] ?? ""), /^ enter send.*expand tools {2}· {2}⚙ 2 bg\s+#24$/)
|
|
147
|
+
stop(); component.dispose?.()
|
|
148
|
+
const typingStop = installFooter(f.ctx, { ...bgSources, busy: () => true })
|
|
149
|
+
const typing = f.mount()
|
|
150
|
+
assert.match(typing.render(80)[0] ?? "", /^ ⚙ 2 bg\s+#24$/)
|
|
151
|
+
const before = f.renders
|
|
152
|
+
bg = undefined
|
|
153
|
+
listener?.()
|
|
154
|
+
assert.equal(f.renders, before + 1)
|
|
155
|
+
assert.deepEqual(typing.render(80).map((line) => line.trim()), ["#24"])
|
|
156
|
+
typingStop(); typing.dispose?.()
|
|
157
|
+
assert.equal(listener, undefined)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("PR chips sit right-aligned on the hints line, stay when hints hide, and re-render on change", () => {
|
|
161
|
+
const f = fixture()
|
|
162
|
+
let chips: string | undefined = "#23 #22"
|
|
163
|
+
let listener: (() => void) | undefined
|
|
164
|
+
const prsSources = {
|
|
165
|
+
...sources,
|
|
166
|
+
prs: () => chips,
|
|
167
|
+
onPrsChange: (l: () => void) => { listener = l; return () => { listener = undefined } },
|
|
168
|
+
}
|
|
169
|
+
const stop = installFooter(f.ctx, prsSources)
|
|
170
|
+
const component = f.mount()
|
|
171
|
+
const idle = component.render(80)
|
|
172
|
+
assert.equal(idle.length, 1)
|
|
173
|
+
assert.match(idle[0] ?? "", /^ \S.*send.*#23 #22$/)
|
|
174
|
+
assert.equal(visibleWidth(idle[0] ?? ""), 1 + 78, "chips are flush right within the margin")
|
|
175
|
+
|
|
176
|
+
// Typing hides the hints; the chips keep the line.
|
|
177
|
+
stop(); component.dispose?.()
|
|
178
|
+
const typingStop = installFooter(f.ctx, { ...prsSources, editorText: () => "draft" })
|
|
179
|
+
const typing = f.mount()
|
|
180
|
+
assert.deepEqual(typing.render(80).map((line) => line.trim()), ["#23 #22"])
|
|
181
|
+
|
|
182
|
+
// A change notification triggers a render; no chips, no line.
|
|
183
|
+
const before = f.renders
|
|
184
|
+
chips = undefined
|
|
185
|
+
listener?.()
|
|
186
|
+
assert.equal(f.renders, before + 1)
|
|
187
|
+
assert.deepEqual(typing.render(80), [])
|
|
188
|
+
typingStop(); typing.dispose?.()
|
|
189
|
+
assert.equal(listener, undefined, "unsubscribed on dispose")
|
|
190
|
+
})
|
|
@@ -65,3 +65,18 @@ test("shutdown removes listeners, invalidates handles and emits unavailable once
|
|
|
65
65
|
assert.equal(fresh.result, undefined)
|
|
66
66
|
assert.deepEqual(handle.publish({}), { ok: false, code: "disposed" })
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
test("the plan slot accepts both its name and the legacy alias, and advertises all three", () => {
|
|
70
|
+
const events = bus(), registry = createFooterContributions()
|
|
71
|
+
let ready: { slots: string[] } | undefined
|
|
72
|
+
events.on(FOOTER_EVENTS.ready, (value) => { ready = value as { slots: string[] } })
|
|
73
|
+
bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
74
|
+
assert.ok(ready)
|
|
75
|
+
assert.deepEqual(ready!.slots, ["activity", "plan", "summary"])
|
|
76
|
+
const viaPlan: RegisterRequest = { ...request(), slot: "plan", owner: "a" }
|
|
77
|
+
events.emit(FOOTER_EVENTS.register, viaPlan)
|
|
78
|
+
assert.ok(viaPlan.result?.ok)
|
|
79
|
+
const viaSummary: RegisterRequest = { ...request(), slot: "summary", owner: "b" }
|
|
80
|
+
events.emit(FOOTER_EVENTS.register, viaSummary)
|
|
81
|
+
assert.deepEqual(viaSummary.result, { ok: false, code: "occupied" }, "both names address the same slot")
|
|
82
|
+
})
|
package/test/git-state.test.ts
CHANGED
|
@@ -50,3 +50,38 @@ test("createGitState clears state outside a repository", async () => {
|
|
|
50
50
|
await git.refresh(true)
|
|
51
51
|
assert.equal(git.get(), undefined)
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
test("createGitState reads cwd once per refresh, drops results after dispose, and isolates listener errors", async () => {
|
|
55
|
+
const cwds: string[] = []
|
|
56
|
+
let resolveStatus!: (value: unknown) => void
|
|
57
|
+
const pi = {
|
|
58
|
+
async exec(_cmd: string, args: string[], opts: { cwd: string }) {
|
|
59
|
+
cwds.push(opts.cwd)
|
|
60
|
+
if (args[0] === "rev-parse") return { stdout: "main\n", stderr: "", code: 0, killed: false }
|
|
61
|
+
return new Promise(resolve => { resolveStatus = resolve })
|
|
62
|
+
},
|
|
63
|
+
} as unknown as ExtensionAPI
|
|
64
|
+
let dir = "/a"
|
|
65
|
+
const git = createGitState(pi, () => dir)
|
|
66
|
+
let notified = 0
|
|
67
|
+
git.onChange(() => { throw new Error("boom") })
|
|
68
|
+
git.onChange(() => { notified++ })
|
|
69
|
+
const pending = git.refresh(true)
|
|
70
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
71
|
+
dir = "/b"
|
|
72
|
+
resolveStatus({ stdout: " M x\n", stderr: "", code: 0, killed: false })
|
|
73
|
+
await pending
|
|
74
|
+
assert.deepEqual(cwds, ["/a", "/a"], "both commands use the directory read at refresh start")
|
|
75
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 })
|
|
76
|
+
assert.equal(notified, 1, "a throwing listener does not block the next one")
|
|
77
|
+
|
|
78
|
+
const late = git.refresh(true)
|
|
79
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
80
|
+
git.dispose()
|
|
81
|
+
resolveStatus({ stdout: "", stderr: "", code: 0, killed: false })
|
|
82
|
+
await late
|
|
83
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 }, "a result landing after dispose is ignored")
|
|
84
|
+
assert.equal(notified, 1)
|
|
85
|
+
await git.refresh(true)
|
|
86
|
+
assert.deepEqual(cwds.length, 4, "no new commands after dispose")
|
|
87
|
+
})
|