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.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/CONTEXT.md +107 -0
  3. package/README.md +9 -6
  4. package/dist/background-overlay.js +95 -0
  5. package/dist/background.js +206 -0
  6. package/dist/config.js +10 -1
  7. package/dist/cwd.js +2 -2
  8. package/dist/edit-diff.js +74 -0
  9. package/dist/footer-contribution-events.js +5 -3
  10. package/dist/footer-contributions.js +15 -13
  11. package/dist/footer.js +15 -3
  12. package/dist/git-state.js +31 -11
  13. package/dist/herdr.js +78 -0
  14. package/dist/index.js +79 -202
  15. package/dist/listeners.js +23 -0
  16. package/dist/prs-command.js +76 -0
  17. package/dist/prs.js +270 -0
  18. package/dist/reclaim.js +0 -3
  19. package/dist/rex.js +289 -0
  20. package/dist/session.js +168 -0
  21. package/dist/siblings.js +14 -0
  22. package/dist/tools.js +213 -374
  23. package/dist/tracker.js +2 -2
  24. package/dist/working-status.js +2 -1
  25. package/doc/archive/README.md +3 -0
  26. package/doc/footer-contributions.md +7 -7
  27. package/package.json +5 -3
  28. package/release-notes.md +33 -12
  29. package/src/background-overlay.ts +103 -0
  30. package/src/background.ts +250 -0
  31. package/src/config.ts +10 -1
  32. package/src/cwd.ts +5 -4
  33. package/src/edit-diff.ts +73 -0
  34. package/src/footer-contribution-events.ts +7 -4
  35. package/src/footer-contributions.ts +15 -13
  36. package/src/footer.ts +23 -3
  37. package/src/git-state.ts +29 -10
  38. package/src/herdr.ts +78 -5
  39. package/src/index.ts +81 -195
  40. package/src/listeners.ts +22 -0
  41. package/src/prs-command.ts +81 -0
  42. package/src/prs.ts +348 -0
  43. package/src/reclaim.ts +0 -4
  44. package/src/rex.ts +304 -0
  45. package/src/session.ts +185 -0
  46. package/src/siblings.ts +38 -0
  47. package/src/tools.ts +261 -389
  48. package/src/tracker.ts +11 -5
  49. package/src/working-status.ts +2 -1
  50. package/test/background.test.ts +198 -0
  51. package/test/config.test.ts +24 -11
  52. package/test/footer-composition.test.ts +61 -0
  53. package/test/footer-contribution-events.test.ts +15 -0
  54. package/test/git-state.test.ts +35 -0
  55. package/test/index.test.ts +202 -4
  56. package/test/prs.test.ts +272 -0
  57. package/test/rex.test.ts +249 -0
  58. package/test/tools.test.ts +36 -3
  59. package/test/working-status.test.ts +53 -0
  60. /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
  61. /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
- type TaskStatus = "pending" | "in_progress" | "complete" | "failed"
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: TaskStatus }
11
+ type Task = { name: string; status: GauntletTaskStatus }
11
12
 
12
- export type TrackerSnapshot = {
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 readSnapshot(ctx: ExtensionContext): TrackerSnapshot {
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 } = readSnapshot(ctx)
56
+ const { phases, tasks } = readGauntletProjection(ctx)
51
57
  const lines: string[] = []
52
58
  if (phases && phaseActive(phases)) {
53
59
  const parts = PHASES.map((p) => {
@@ -206,8 +206,9 @@ export function installWorkingStatus(pi: ExtensionAPI): () => void {
206
206
  })
207
207
 
208
208
  pi.on("session_shutdown", () => {
209
- ctx = undefined
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
+ })
@@ -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 true", (t) => {
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: true, 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
+ })
@@ -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
+ })
@@ -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
+ })