pi-minimal 0.5.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 +35 -1
- package/CONTEXT.md +27 -3
- package/README.md +6 -3
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +8 -0
- package/dist/cwd.js +2 -2
- package/dist/footer.js +15 -3
- package/dist/herdr.js +78 -0
- package/dist/index.js +62 -1
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/rex.js +289 -0
- package/dist/session.js +72 -59
- package/dist/siblings.js +14 -0
- package/package.json +5 -3
- package/release-notes.md +29 -42
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +8 -0
- package/src/cwd.ts +5 -4
- package/src/footer.ts +23 -3
- package/src/herdr.ts +78 -5
- package/src/index.ts +63 -2
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/rex.ts +304 -0
- package/src/session.ts +76 -50
- package/src/siblings.ts +38 -0
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +23 -10
- package/test/footer-composition.test.ts +61 -0
- package/test/index.test.ts +157 -1
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
|
@@ -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
|
@@ -30,7 +30,7 @@ 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: false, editDiff: "expanded" })
|
|
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
|
+
})
|
package/test/index.test.ts
CHANGED
|
@@ -17,11 +17,17 @@ type SessionMode = "tui" | "rpc" | "json" | "print"
|
|
|
17
17
|
|
|
18
18
|
const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
|
|
19
19
|
const originalHerdrEnv = process.env.HERDR_ENV
|
|
20
|
+
const originalRexSession = process.env.REX_SESSION
|
|
21
|
+
const originalRexBlock = process.env.REX_BLOCK
|
|
20
22
|
const originalAgentDir = process.env[AGENT_DIR_ENV]
|
|
21
23
|
|
|
22
24
|
function restoreEnv() {
|
|
23
25
|
if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV
|
|
24
26
|
else process.env.HERDR_ENV = originalHerdrEnv
|
|
27
|
+
if (originalRexSession === undefined) delete process.env.REX_SESSION
|
|
28
|
+
else process.env.REX_SESSION = originalRexSession
|
|
29
|
+
if (originalRexBlock === undefined) delete process.env.REX_BLOCK
|
|
30
|
+
else process.env.REX_BLOCK = originalRexBlock
|
|
25
31
|
if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
|
|
26
32
|
else process.env[AGENT_DIR_ENV] = originalAgentDir
|
|
27
33
|
}
|
|
@@ -90,6 +96,9 @@ function createFake(options: {
|
|
|
90
96
|
if (command === "herdr" && args[0] === "agent") {
|
|
91
97
|
return { stdout: JSON.stringify({ result: { agents: [] } }), stderr: "", code: 0, killed: false }
|
|
92
98
|
}
|
|
99
|
+
if (command === "gh") {
|
|
100
|
+
return { stdout: "", stderr: "not a github repo", code: 1, killed: false }
|
|
101
|
+
}
|
|
93
102
|
if (command === "herdr" && args[0] === "tab") {
|
|
94
103
|
return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: "", code: 0, killed: false }
|
|
95
104
|
}
|
|
@@ -98,6 +107,12 @@ function createFake(options: {
|
|
|
98
107
|
registerTool(...args: unknown[]) {
|
|
99
108
|
record(registerCalls, "registerTool", args)
|
|
100
109
|
},
|
|
110
|
+
registerCommand(...args: unknown[]) {
|
|
111
|
+
record(registerCalls, "registerCommand", args)
|
|
112
|
+
},
|
|
113
|
+
registerShortcut(...args: unknown[]) {
|
|
114
|
+
record(registerCalls, "registerShortcut", args)
|
|
115
|
+
},
|
|
101
116
|
registerToolRenderer(...args: unknown[]) {
|
|
102
117
|
record(registerCalls, "registerToolRenderer", args)
|
|
103
118
|
},
|
|
@@ -136,6 +151,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
136
151
|
const { agentDir, cwd } = setupDirs(t)
|
|
137
152
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
138
153
|
delete process.env.HERDR_ENV
|
|
154
|
+
delete process.env.REX_SESSION
|
|
155
|
+
delete process.env.REX_BLOCK
|
|
139
156
|
t.after(restoreEnv)
|
|
140
157
|
|
|
141
158
|
const hideThinking = t.mock.method(SettingsManager.prototype, "setHideThinkingBlock")
|
|
@@ -172,6 +189,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
172
189
|
const { agentDir, cwd } = setupDirs(t)
|
|
173
190
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
174
191
|
delete process.env.HERDR_ENV
|
|
192
|
+
delete process.env.REX_SESSION
|
|
193
|
+
delete process.env.REX_BLOCK
|
|
175
194
|
t.after(restoreEnv)
|
|
176
195
|
|
|
177
196
|
const { pi, allCalls, emit } = createFake({ cwd })
|
|
@@ -197,7 +216,7 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
197
216
|
assert.ok(firstHeader < firstReclaim, "header before reclaim")
|
|
198
217
|
assert.ok(firstFooter < firstReclaim, "footer before reclaim")
|
|
199
218
|
assert.ok(firstCwd < firstReclaim, "cwd before reclaim")
|
|
200
|
-
assert.
|
|
219
|
+
assert.deepEqual(allCalls.filter((call) => call.method === "registerTool").map((call) => (call.args[0] as { name: string }).name), ["list_open_prs"], "only pi-minimal's own tool is registered")
|
|
201
220
|
})
|
|
202
221
|
|
|
203
222
|
test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
|
|
@@ -205,6 +224,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
205
224
|
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
|
|
206
225
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
207
226
|
delete process.env.HERDR_ENV
|
|
227
|
+
delete process.env.REX_SESSION
|
|
228
|
+
delete process.env.REX_BLOCK
|
|
208
229
|
t.after(restoreEnv)
|
|
209
230
|
|
|
210
231
|
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
@@ -225,6 +246,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
225
246
|
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
|
|
226
247
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
227
248
|
delete process.env.HERDR_ENV
|
|
249
|
+
delete process.env.REX_SESSION
|
|
250
|
+
delete process.env.REX_BLOCK
|
|
228
251
|
t.after(restoreEnv)
|
|
229
252
|
|
|
230
253
|
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
@@ -243,6 +266,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
243
266
|
const { agentDir, cwd } = setupDirs(t)
|
|
244
267
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
245
268
|
delete process.env.HERDR_ENV
|
|
269
|
+
delete process.env.REX_SESSION
|
|
270
|
+
delete process.env.REX_BLOCK
|
|
246
271
|
t.after(restoreEnv)
|
|
247
272
|
|
|
248
273
|
const { pi, execCalls, emit } = createFake({ cwd })
|
|
@@ -261,6 +286,46 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
261
286
|
const { agentDir, cwd } = setupDirs(t)
|
|
262
287
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
263
288
|
process.env.HERDR_ENV = "1"
|
|
289
|
+
delete process.env.REX_SESSION
|
|
290
|
+
delete process.env.REX_BLOCK
|
|
291
|
+
t.after(restoreEnv)
|
|
292
|
+
|
|
293
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
294
|
+
install(pi)
|
|
295
|
+
t.after(() => emit("session_shutdown"))
|
|
296
|
+
await emit("session_start")
|
|
297
|
+
|
|
298
|
+
assert.equal(herdrProbes(execCalls).length, 1)
|
|
299
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test("inside Rex (REX_SESSION + REX_BLOCK) it reads the Rex session instead of herdr", async (t) => {
|
|
303
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
304
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
305
|
+
delete process.env.HERDR_ENV
|
|
306
|
+
process.env.REX_SESSION = "session:test"
|
|
307
|
+
process.env.REX_BLOCK = "block:self"
|
|
308
|
+
t.after(restoreEnv)
|
|
309
|
+
|
|
310
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
311
|
+
install(pi)
|
|
312
|
+
t.after(() => emit("session_shutdown"))
|
|
313
|
+
await emit("session_start")
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
315
|
+
|
|
316
|
+
assert.equal(herdrProbes(execCalls).length, 0)
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
execCalls.filter((call) => call.args[0] === "rex").map((call) => call.args[1]),
|
|
319
|
+
[["window", "ls", "-s", "session:test", "--json"]],
|
|
320
|
+
)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test("Herdr wins when both Herdr and Rex are present", async (t) => {
|
|
324
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
325
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
326
|
+
process.env.HERDR_ENV = "1"
|
|
327
|
+
process.env.REX_SESSION = "session:test"
|
|
328
|
+
process.env.REX_BLOCK = "block:self"
|
|
264
329
|
t.after(restoreEnv)
|
|
265
330
|
|
|
266
331
|
const { pi, execCalls, emit } = createFake({ cwd })
|
|
@@ -269,12 +334,97 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
269
334
|
await emit("session_start")
|
|
270
335
|
|
|
271
336
|
assert.equal(herdrProbes(execCalls).length, 1)
|
|
337
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test("registers /bg and the background shortcut once, honouring backgroundShortcut", async (t) => {
|
|
341
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
342
|
+
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ backgroundShortcut: "ctrl+g" }))
|
|
343
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
344
|
+
delete process.env.HERDR_ENV
|
|
345
|
+
delete process.env.REX_SESSION
|
|
346
|
+
delete process.env.REX_BLOCK
|
|
347
|
+
t.after(restoreEnv)
|
|
348
|
+
|
|
349
|
+
const { pi, registerCalls, emit } = createFake({ cwd })
|
|
350
|
+
install(pi)
|
|
351
|
+
t.after(() => emit("session_shutdown"))
|
|
352
|
+
assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "bg"))
|
|
353
|
+
assert.equal(registerCalls.filter((call) => call.method === "registerShortcut").length, 0, "shortcut waits for session config")
|
|
354
|
+
await emit("session_start")
|
|
355
|
+
await emit("session_start")
|
|
356
|
+
const shortcuts = registerCalls.filter((call) => call.method === "registerShortcut")
|
|
357
|
+
assert.deepEqual(shortcuts.map((call) => call.args[0]), ["ctrl+g"])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test("a TUI session asks gh for the repo once and registers /prs", async (t) => {
|
|
361
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
362
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
363
|
+
delete process.env.HERDR_ENV
|
|
364
|
+
delete process.env.REX_SESSION
|
|
365
|
+
delete process.env.REX_BLOCK
|
|
366
|
+
t.after(restoreEnv)
|
|
367
|
+
|
|
368
|
+
const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
|
|
369
|
+
install(pi)
|
|
370
|
+
t.after(() => emit("session_shutdown"))
|
|
371
|
+
assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "prs"))
|
|
372
|
+
await emit("session_start")
|
|
373
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
374
|
+
assert.deepEqual(execCalls.filter((call) => call.args[0] === "gh").map((call) => call.args[1]), [["repo", "view", "--json", "nameWithOwner,url"]])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test("a bash call running gh pr create records the PR URL as a session entry", async (t) => {
|
|
378
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
379
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
380
|
+
delete process.env.HERDR_ENV
|
|
381
|
+
delete process.env.REX_SESSION
|
|
382
|
+
delete process.env.REX_BLOCK
|
|
383
|
+
t.after(restoreEnv)
|
|
384
|
+
|
|
385
|
+
const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
|
|
386
|
+
install(pi)
|
|
387
|
+
t.after(() => emit("session_shutdown"))
|
|
388
|
+
await emit("session_start")
|
|
389
|
+
await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash", args: { command: "gh pr create --fill" } })
|
|
390
|
+
await emit("tool_execution_end", {
|
|
391
|
+
toolCallId: "b1", toolName: "bash", isError: false,
|
|
392
|
+
result: { content: [{ type: "text", text: "Creating pull request…\nhttps://github.com/raviy06/pi-minimal/pull/24\n" }] },
|
|
393
|
+
})
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
395
|
+
const entries = registerCalls.filter((call) => call.method === "appendEntry")
|
|
396
|
+
assert.deepEqual(entries.map((call) => call.args), [["pi-minimal.pr", { url: "https://github.com/raviy06/pi-minimal/pull/24" }]])
|
|
397
|
+
assert.ok(execCalls.some((call) => call.args[0] === "gh" && (call.args[1] as string[])[1] === "view"), "the PR is fetched by URL")
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test("bash tool calls are inspected with ps; other tools are not", async (t) => {
|
|
401
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
402
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
403
|
+
delete process.env.HERDR_ENV
|
|
404
|
+
delete process.env.REX_SESSION
|
|
405
|
+
delete process.env.REX_BLOCK
|
|
406
|
+
t.after(restoreEnv)
|
|
407
|
+
|
|
408
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
409
|
+
install(pi)
|
|
410
|
+
t.after(() => emit("session_shutdown"))
|
|
411
|
+
await emit("session_start")
|
|
412
|
+
await emit("tool_execution_start", { toolCallId: "r1", toolName: "read" })
|
|
413
|
+
await emit("tool_execution_end", { toolCallId: "r1", toolName: "read" })
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
415
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 0)
|
|
416
|
+
await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash" })
|
|
417
|
+
await emit("tool_execution_end", { toolCallId: "b1", toolName: "bash" })
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
419
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 2)
|
|
272
420
|
})
|
|
273
421
|
|
|
274
422
|
test("non-TUI modes install no TUI components", async (t) => {
|
|
275
423
|
const { agentDir, cwd } = setupDirs(t)
|
|
276
424
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
277
425
|
delete process.env.HERDR_ENV
|
|
426
|
+
delete process.env.REX_SESSION
|
|
427
|
+
delete process.env.REX_BLOCK
|
|
278
428
|
t.after(restoreEnv)
|
|
279
429
|
|
|
280
430
|
const { pi, uiCalls, execCalls, emit } = createFake({ cwd, mode: "print" })
|
|
@@ -307,6 +457,8 @@ test("footer registration survives chrome reinstalls and becomes inert on shutdo
|
|
|
307
457
|
const { agentDir, cwd } = setupDirs(t)
|
|
308
458
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
309
459
|
delete process.env.HERDR_ENV
|
|
460
|
+
delete process.env.REX_SESSION
|
|
461
|
+
delete process.env.REX_BLOCK
|
|
310
462
|
t.after(restoreEnv)
|
|
311
463
|
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
312
464
|
install(pi)
|
|
@@ -332,6 +484,8 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
|
332
484
|
const { agentDir, cwd } = setupDirs(t)
|
|
333
485
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
334
486
|
delete process.env.HERDR_ENV
|
|
487
|
+
delete process.env.REX_SESSION
|
|
488
|
+
delete process.env.REX_BLOCK
|
|
335
489
|
t.after(restoreEnv)
|
|
336
490
|
const { pi, registerCalls, emit } = createFake({ cwd })
|
|
337
491
|
install(pi)
|
|
@@ -354,6 +508,8 @@ test("a second session_start disposes the first session's chrome and the stale o
|
|
|
354
508
|
const { agentDir, cwd } = setupDirs(t)
|
|
355
509
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
356
510
|
delete process.env.HERDR_ENV
|
|
511
|
+
delete process.env.REX_SESSION
|
|
512
|
+
delete process.env.REX_BLOCK
|
|
357
513
|
t.after(restoreEnv)
|
|
358
514
|
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
359
515
|
install(pi)
|