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
@@ -0,0 +1,249 @@
1
+ import assert from "node:assert/strict"
2
+ import { EventEmitter } from "node:events"
3
+ import { PassThrough } from "node:stream"
4
+ import test from "node:test"
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
6
+ import {
7
+ applyRexEvent,
8
+ createRexSiblings,
9
+ createRexState,
10
+ listRexSiblings,
11
+ parseProgramStatus,
12
+ parseWindowList,
13
+ type RexSpawn,
14
+ } from "../src/rex.js"
15
+
16
+ const SELF = "block:self"
17
+ const OTHER = "block:other"
18
+ const WIN_SELF = "window:self"
19
+ const WIN_OTHER = "window:other"
20
+
21
+ // Shapes captured from `rex window ls --json`, `rex block call … program_status` and `rex events --json`.
22
+ const windowList = JSON.stringify({
23
+ revision: 14,
24
+ session_id: "session:s",
25
+ label: "pi-dev",
26
+ active_window_id: WIN_SELF,
27
+ windows: [
28
+ {
29
+ window_id: WIN_SELF,
30
+ label: "Window 395a96cb",
31
+ focused_block_id: SELF,
32
+ layers: [{ kind: "tiled", blocks: [{ block_id: SELF, label: "shell", creator_name: "com.superlogical.terminal" }] }],
33
+ },
34
+ {
35
+ window_id: WIN_OTHER,
36
+ label: "ry-fix-login",
37
+ focused_block_id: OTHER,
38
+ layers: [
39
+ {
40
+ kind: "tiled",
41
+ blocks: [
42
+ { block_id: OTHER, label: "shell", creator_name: "com.superlogical.terminal" },
43
+ { block_id: "block:browser", label: "web", creator_name: "com.superlogical.browser" },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ })
50
+
51
+ function sessionEvent(event: unknown): string {
52
+ return JSON.stringify({ type: "session_event", payload: { session_id: "session:s", event } })
53
+ }
54
+
55
+ function statusChanged(blockId: string, record: Record<string, unknown>, id = ""): string {
56
+ return sessionEvent({
57
+ type: "block_event",
58
+ payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_changed", payload: { id, record, reason: "report" } },
59
+ })
60
+ }
61
+
62
+ function statusRemoved(blockId: string, ids: string[], reason = "child_exited"): string {
63
+ return sessionEvent({
64
+ type: "block_event",
65
+ payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_removed", payload: { ids, reason } },
66
+ })
67
+ }
68
+
69
+ test.describe("rex parsing", () => {
70
+ test("parseWindowList maps terminal blocks to tabs and skips non-terminal blocks", () => {
71
+ const layout = parseWindowList(windowList)
72
+ assert.deepEqual([...layout.tabs], [[WIN_SELF, "Window 395a96cb"], [WIN_OTHER, "ry-fix-login"]])
73
+ assert.deepEqual([...layout.blocks], [[SELF, WIN_SELF], [OTHER, WIN_OTHER]])
74
+ })
75
+
76
+ test("parseWindowList rejects output without windows", () => {
77
+ assert.throws(() => parseWindowList("{}"))
78
+ })
79
+
80
+ test("parseProgramStatus returns the root record and prefers effective_app", () => {
81
+ const out = JSON.stringify({
82
+ records: [
83
+ { state: "working", app: "pi", effective_app: "pi", updated_at: "2026-10-28T12:58:11Z", owner: { pid: 1, name: "node" } },
84
+ { id: "build", state: "blocked", updated_at: "2026-10-28T12:58:11Z" },
85
+ ],
86
+ })
87
+ assert.deepEqual(parseProgramStatus(out), { state: "working", app: "pi" })
88
+ assert.equal(parseProgramStatus(JSON.stringify({ records: [] })), undefined)
89
+ assert.equal(parseProgramStatus(JSON.stringify({ records: [{ id: "child", state: "working", updated_at: "x" }] })), undefined)
90
+ })
91
+ })
92
+
93
+ test.describe("rex events", () => {
94
+ test("root status reports create, replace and remove records", () => {
95
+ const state = createRexState()
96
+ state.layout = parseWindowList(windowList)
97
+
98
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", effective_app: "pi", updated_at: "x" })), "status")
99
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "working" }])
100
+
101
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "blocked", kind: "question", app: "pi", msg: "Approve edit?", updated_at: "x" })), "status")
102
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "blocked" }])
103
+
104
+ assert.equal(applyRexEvent(state, statusRemoved(OTHER, [""])), "status")
105
+ assert.equal(listRexSiblings(state, SELF), undefined)
106
+ })
107
+
108
+ test("child records, other ids and non-session lines are ignored", () => {
109
+ const state = createRexState()
110
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", updated_at: "x" }, "build/test")), undefined)
111
+ assert.equal(applyRexEvent(state, statusRemoved(OTHER, ["build"])), undefined)
112
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "session_view_changed", payload: { revision: 3 } })), undefined)
113
+ assert.equal(applyRexEvent(state, JSON.stringify({ type: "server_event" })), undefined)
114
+ assert.equal(applyRexEvent(state, "not json"), undefined)
115
+ assert.equal(state.status.size, 0)
116
+ })
117
+
118
+ test("block_closed drops the record; label and block changes request a layout refresh", () => {
119
+ const state = createRexState()
120
+ state.layout = parseWindowList(windowList)
121
+ applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }))
122
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "block_closed", payload: { block_id: OTHER } })), "layout")
123
+ assert.equal(state.status.has(OTHER), false)
124
+ assert.equal(state.layout.blocks.has(OTHER), false)
125
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "window_label_changed", payload: { window_id: WIN_OTHER, label: "x" } })), "layout")
126
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "block_created", payload: { block_id: "block:new" } })), "layout")
127
+ })
128
+ })
129
+
130
+ test.describe("listRexSiblings", () => {
131
+ test("excludes self, falls back from auto labels to the app, and needs a self block", () => {
132
+ const state = createRexState()
133
+ state.layout = parseWindowList(windowList)
134
+ state.status.set(SELF, { state: "working", app: "pi" })
135
+ state.status.set(OTHER, { state: "idle", app: "pi" })
136
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "idle" }])
137
+ // From the other pane's point of view, our auto-labelled tab is named after the program.
138
+ assert.deepEqual(listRexSiblings(state, OTHER), [{ name: "pi", status: "working" }])
139
+ assert.equal(listRexSiblings(state, undefined), undefined)
140
+ })
141
+
142
+ test("a pane in the same tab as self is named after its program, not the shared tab", () => {
143
+ const state = createRexState()
144
+ state.layout = parseWindowList(windowList)
145
+ state.layout.blocks.set("block:split", WIN_OTHER)
146
+ state.status.set("block:split", { state: "blocked", app: "terraform" })
147
+ state.status.set(SELF, { state: "working", app: "pi" })
148
+ assert.deepEqual(listRexSiblings(state, OTHER), [
149
+ { name: "terraform", status: "blocked" },
150
+ { name: "pi", status: "working" },
151
+ ])
152
+ })
153
+
154
+ test("a record with neither a real label nor an app is skipped", () => {
155
+ const state = createRexState()
156
+ state.layout = parseWindowList(windowList)
157
+ state.status.set(SELF, { state: "working" })
158
+ assert.equal(listRexSiblings(state, OTHER), undefined)
159
+ })
160
+ })
161
+
162
+ type FakeChild = EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: () => boolean; killed: boolean }
163
+
164
+ function fakeSpawn() {
165
+ const children: FakeChild[] = []
166
+ const calls: string[][] = []
167
+ const spawn: RexSpawn = (command, args) => {
168
+ calls.push([command, ...args])
169
+ const child = new EventEmitter() as FakeChild
170
+ child.stdout = new PassThrough()
171
+ child.stderr = new PassThrough()
172
+ child.killed = false
173
+ child.kill = () => { child.killed = true; return true }
174
+ children.push(child)
175
+ return child as unknown as ReturnType<RexSpawn>
176
+ }
177
+ return { spawn, children, calls }
178
+ }
179
+
180
+ function fakePi(statusByBlock: Record<string, string>) {
181
+ const execCalls: string[][] = []
182
+ const pi = {
183
+ async exec(command: string, args: string[]) {
184
+ execCalls.push([command, ...args])
185
+ if (command === "rex" && args[0] === "window") return { stdout: windowList, stderr: "", code: 0, killed: false }
186
+ if (command === "rex" && args[0] === "block") {
187
+ const block = args[args.indexOf("-b") + 1]
188
+ const stdout = statusByBlock[block] ?? JSON.stringify({ records: [] })
189
+ return { stdout, stderr: "", code: 0, killed: false }
190
+ }
191
+ return { stdout: "", stderr: "", code: 1, killed: false }
192
+ },
193
+ } as unknown as ExtensionAPI
194
+ return { pi, execCalls }
195
+ }
196
+
197
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
198
+
199
+ test.describe("createRexSiblings", () => {
200
+ test("seeds from window ls + program_status (skipping self), then follows the event stream", async () => {
201
+ const { spawn, children, calls } = fakeSpawn()
202
+ const { pi, execCalls } = fakePi({
203
+ [OTHER]: JSON.stringify({ records: [{ state: "working", app: "pi", effective_app: "pi", updated_at: "x" }] }),
204
+ })
205
+ const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
206
+ let changes = 0
207
+ source.onChange(() => { changes += 1 })
208
+
209
+ assert.deepEqual(calls, [["rex", "events", "-s", "session:s", "--json"]])
210
+ for (let i = 0; i < 5; i++) await tick()
211
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
212
+ assert.equal(changes, 1)
213
+ assert.ok(execCalls.some((c) => c[1] === "window"))
214
+ assert.ok(execCalls.some((c) => c[1] === "block" && c.includes(OTHER)))
215
+ assert.equal(execCalls.some((c) => c[1] === "block" && c.includes(SELF)), false, "self is not probed")
216
+
217
+ children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "y" }) + "\n")
218
+ for (let i = 0; i < 3; i++) await tick()
219
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "blocked" }])
220
+ assert.equal(changes, 2)
221
+
222
+ // Same state again: no spurious notification.
223
+ children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "z" }) + "\n")
224
+ for (let i = 0; i < 3; i++) await tick()
225
+ assert.equal(changes, 2)
226
+
227
+ source.dispose()
228
+ assert.equal(children[0].killed, true)
229
+ assert.equal(source.get(), undefined)
230
+ })
231
+
232
+ test("a stream exit clears siblings and a disposed source ignores late events", async () => {
233
+ const { spawn, children } = fakeSpawn()
234
+ const { pi } = fakePi({})
235
+ const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
236
+ for (let i = 0; i < 5; i++) await tick()
237
+ children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
238
+ for (let i = 0; i < 3; i++) await tick()
239
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
240
+
241
+ children[0].emit("exit", 1)
242
+ assert.equal(source.get(), undefined)
243
+
244
+ source.dispose()
245
+ children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
246
+ for (let i = 0; i < 3; i++) await tick()
247
+ assert.equal(source.get(), undefined)
248
+ })
249
+ })
@@ -192,10 +192,15 @@ function editRows(expanded: boolean, result: unknown = editResult, isError = fal
192
192
  return block.render(80).map((line) => line.trimEnd())
193
193
  }
194
194
 
195
- test("editDiff=expanded (default) shows the diff only behind ctrl+o, in the theme's diff colours", (t) => {
195
+ const longDiff = (n: number) => ({ content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: n }, (_, i) => `+${i + 1} line ${i}`).join("\n") } })
196
+
197
+ test("editDiff=expanded (default) shows small diffs inline and larger ones only behind ctrl+o", (t) => {
196
198
  t.after(() => setEditDiffMode("expanded"))
197
199
  setEditDiffMode("expanded")
198
- assert.deepEqual(editRows(false), [])
200
+ assert.equal(editRows(false).length, 4, "a 4-line diff is shown without ctrl+o")
201
+ assert.equal(editRows(false, longDiff(8)).length, 8, "8 lines is still small")
202
+ assert.deepEqual(editRows(false, longDiff(9)), [], "9 lines waits for ctrl+o")
203
+ assert.equal(editRows(true, longDiff(9)).length, 9)
199
204
  const rows = editRows(true)
200
205
  assert.equal(rows.length, 4)
201
206
  assert.match(rows[0]!, /│ <context> 1 context$/)
@@ -227,7 +232,7 @@ test("editDiff=always shows the diff inline and editDiff=never only on request f
227
232
  test("editDiff=always truncates a long inline diff and leaves the full diff to ctrl+o", (t) => {
228
233
  t.after(() => setEditDiffMode("expanded"))
229
234
  setEditDiffMode("always")
230
- const long = { content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: 60 }, (_, i) => `+${i + 1} line ${i}`).join("\n") } }
235
+ const long = longDiff(60)
231
236
  const inline = editRows(false, long)
232
237
  assert.equal(inline.length, 41)
233
238
  assert.match(inline[40]!, /… 20 more lines \(ctrl\+o\)$/)
@@ -241,3 +246,31 @@ test("edit failures and results without a diff never render a diff block", (t) =
241
246
  const failed = editRows(false, { content: [{ type: "text", text: "oldText not found" }] }, true)
242
247
  assert.ok(failed.every((row) => !row.includes("<removed>")))
243
248
  })
249
+
250
+ test("write rows show the written content on ctrl+o and nothing when collapsed", () => {
251
+ const renderers = compactRenderers("write")
252
+ const content = Array.from({ length: 450 }, (_, i) => `line ${i}`).join("\n")
253
+ const context = { args: { path: "a.ts", content }, toolCallId: "w1", invalidate() {}, state: {}, isPartial: false, isError: false, durationMs: 100 } as never
254
+ const result = { content: [{ type: "text", text: "Successfully wrote to a.ts" }] } as never
255
+ const collapsed = renderers.renderResult!(result, { expanded: false } as never, plainTheme, context).render(80)
256
+ assert.deepEqual(collapsed, [])
257
+ const expanded = renderers.renderResult!(result, { expanded: true } as never, plainTheme, context).render(80).map((l) => l.trimEnd())
258
+ assert.equal(expanded.length, 401)
259
+ assert.match(expanded[0]!, /│ line 0$/)
260
+ assert.match(expanded[400]!, /… 50 more lines$/)
261
+ const failed = { args: { path: "a.ts", content }, toolCallId: "w2", invalidate() {}, state: {}, isPartial: false, isError: true, durationMs: 100 } as never
262
+ const err = renderers.renderResult!({ content: [{ type: "text", text: "EACCES" }] } as never, { expanded: true } as never, plainTheme, failed).render(80)
263
+ assert.ok(err.every((l) => !l.includes("line 0")), "a failed write shows its error, not the content")
264
+ })
265
+
266
+ test("createRowRenderers keeps one live key per row across call and result", () => {
267
+ const renderers = compactRenderers("bash")
268
+ const state: Record<string, unknown> = {}
269
+ const call = { args: { command: "sleep 1" }, invalidate() {}, state, isPartial: true }
270
+ renderers.renderCall!({ command: "sleep 1" } as never, plainTheme, call as never)
271
+ assert.equal(state.liveId, "bash:sleep 1")
272
+ renderers.renderResult!({ content: [{ type: "text", text: "" }] } as never, { expanded: false } as never, plainTheme, { ...call, isPartial: false } as never)
273
+ assert.equal(state.liveId, "bash:sleep 1")
274
+ assert.equal((state as { running?: boolean }).running, false)
275
+ stopLiveWatch()
276
+ })
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
4
+ import { installWorkingStatus } from "../src/working-status.js"
5
+
6
+ function fixture() {
7
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>()
8
+ const messages: Array<string | undefined> = []
9
+ const ctx = { mode: "tui", ui: { setWorkingMessage: (text?: string) => { messages.push(text) } } } as unknown as ExtensionContext
10
+ const pi = { on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => { handlers.set(name, handler) } } as unknown as ExtensionAPI
11
+ const stop = installWorkingStatus(pi)
12
+ const emit = (name: string, event: unknown = {}) => handlers.get(name)!(event, ctx)
13
+ return { emit, messages, stop }
14
+ }
15
+
16
+ test("working status walks thinking → tool → thinking → writing and clears when the agent settles", (t) => {
17
+ t.mock.timers.enable({ apis: ["setInterval", "Date"] })
18
+ const f = fixture()
19
+ f.emit("session_start")
20
+ f.emit("agent_start")
21
+ assert.match(f.messages.at(-1)!, /^Thinking · /)
22
+ f.emit("tool_execution_start", { toolCallId: "t1", toolName: "bash", args: { command: "ls -la" } })
23
+ assert.match(f.messages.at(-1)!, /^Running ls -la/)
24
+ f.emit("tool_execution_end", { toolCallId: "t1" })
25
+ assert.match(f.messages.at(-1)!, /^Thinking · /)
26
+ f.emit("message_update", { message: { role: "assistant", content: [] }, assistantMessageEvent: { type: "text_delta" } })
27
+ assert.match(f.messages.at(-1)!, /^Writing/)
28
+ f.emit("agent_settled")
29
+ assert.equal(f.messages.at(-1), undefined, "settling clears the message")
30
+ f.stop()
31
+ })
32
+
33
+ test("events before session_start or after settle are ignored", () => {
34
+ const f = fixture()
35
+ f.emit("agent_start")
36
+ assert.deepEqual(f.messages, [])
37
+ f.emit("session_start")
38
+ f.emit("tool_execution_start", { toolCallId: "x", toolName: "read" })
39
+ assert.deepEqual(f.messages, [], "a tool without an active run does not paint")
40
+ f.stop()
41
+ })
42
+
43
+ test("session_shutdown clears the working message while the context is still known", (t) => {
44
+ t.mock.timers.enable({ apis: ["setInterval", "Date"] })
45
+ const f = fixture()
46
+ f.emit("session_start")
47
+ f.emit("agent_start")
48
+ assert.ok(f.messages.at(-1))
49
+ f.emit("session_shutdown")
50
+ assert.equal(f.messages.at(-1), undefined, "shutdown must call setWorkingMessage() to clear")
51
+ f.emit("agent_start")
52
+ assert.equal(f.messages.at(-1), undefined, "no painting after shutdown dropped the context")
53
+ })