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
@@ -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
  },
@@ -132,10 +147,12 @@ function herdrProbes(execCalls: Call[]): Call[] {
132
147
  }
133
148
 
134
149
  test.describe("session chrome", { concurrency: 1 }, () => {
135
- test("TUI session_start installs chrome and hides Gauntlet widgets by default", async (t) => {
150
+ test("TUI session_start installs chrome and leaves Gauntlet widgets alone by default", async (t) => {
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")
@@ -155,8 +172,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
155
172
  (call) => typeof call.args[1] === "function" || (call.args[1] !== undefined && call.args[1] !== null),
156
173
  ),
157
174
  )
158
- assert.ok(widgetCalls(uiCalls, "phase_tracker").some((call) => call.args[1] === undefined))
159
- assert.ok(widgetCalls(uiCalls, "plan_tracker").some((call) => call.args[1] === undefined))
175
+ assert.equal(widgetCalls(uiCalls, "phase_tracker").length, 0)
176
+ assert.equal(widgetCalls(uiCalls, "plan_tracker").length, 0)
160
177
  assert.equal(
161
178
  allCalls.some((call) => call.method === "setHideThinkingBlock"),
162
179
  false,
@@ -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,29 @@ 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.equal(allCalls.some((call) => call.method === "registerTool"), false, "built-in tools are not re-registered")
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")
220
+ })
221
+
222
+ test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
223
+ const { agentDir, cwd } = setupDirs(t)
224
+ writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
225
+ process.env[AGENT_DIR_ENV] = agentDir
226
+ delete process.env.HERDR_ENV
227
+ delete process.env.REX_SESSION
228
+ delete process.env.REX_BLOCK
229
+ t.after(restoreEnv)
230
+
231
+ const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
232
+ install(pi)
233
+ t.after(() => emit("session_shutdown"))
234
+ await emit("session_start")
235
+ await new Promise(resolve => setTimeout(resolve, 60))
236
+ const afterSettle = widgetCalls(uiCalls, "phase_tracker").length
237
+ assert.ok(afterSettle >= 1, "hidden at session_start")
238
+ await new Promise(resolve => setTimeout(resolve, 450))
239
+ assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle, "no steal interval")
240
+ await emit("turn_end")
241
+ assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle + 1, "hidden again on turn_end")
201
242
  })
202
243
 
203
244
  test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
@@ -205,6 +246,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
205
246
  writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
206
247
  process.env[AGENT_DIR_ENV] = agentDir
207
248
  delete process.env.HERDR_ENV
249
+ delete process.env.REX_SESSION
250
+ delete process.env.REX_BLOCK
208
251
  t.after(restoreEnv)
209
252
 
210
253
  const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
@@ -223,6 +266,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
223
266
  const { agentDir, cwd } = setupDirs(t)
224
267
  process.env[AGENT_DIR_ENV] = agentDir
225
268
  delete process.env.HERDR_ENV
269
+ delete process.env.REX_SESSION
270
+ delete process.env.REX_BLOCK
226
271
  t.after(restoreEnv)
227
272
 
228
273
  const { pi, execCalls, emit } = createFake({ cwd })
@@ -241,6 +286,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
241
286
  const { agentDir, cwd } = setupDirs(t)
242
287
  process.env[AGENT_DIR_ENV] = agentDir
243
288
  process.env.HERDR_ENV = "1"
289
+ delete process.env.REX_SESSION
290
+ delete process.env.REX_BLOCK
244
291
  t.after(restoreEnv)
245
292
 
246
293
  const { pi, execCalls, emit } = createFake({ cwd })
@@ -249,12 +296,135 @@ test.describe("session chrome", { concurrency: 1 }, () => {
249
296
  await emit("session_start")
250
297
 
251
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"
329
+ t.after(restoreEnv)
330
+
331
+ const { pi, execCalls, emit } = createFake({ cwd })
332
+ install(pi)
333
+ t.after(() => emit("session_shutdown"))
334
+ await emit("session_start")
335
+
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)
252
420
  })
253
421
 
254
422
  test("non-TUI modes install no TUI components", async (t) => {
255
423
  const { agentDir, cwd } = setupDirs(t)
256
424
  process.env[AGENT_DIR_ENV] = agentDir
257
425
  delete process.env.HERDR_ENV
426
+ delete process.env.REX_SESSION
427
+ delete process.env.REX_BLOCK
258
428
  t.after(restoreEnv)
259
429
 
260
430
  const { pi, uiCalls, execCalls, emit } = createFake({ cwd, mode: "print" })
@@ -287,6 +457,8 @@ test("footer registration survives chrome reinstalls and becomes inert on shutdo
287
457
  const { agentDir, cwd } = setupDirs(t)
288
458
  process.env[AGENT_DIR_ENV] = agentDir
289
459
  delete process.env.HERDR_ENV
460
+ delete process.env.REX_SESSION
461
+ delete process.env.REX_BLOCK
290
462
  t.after(restoreEnv)
291
463
  const { pi, emit, events, uiCalls } = createFake({ cwd })
292
464
  install(pi)
@@ -312,6 +484,8 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
312
484
  const { agentDir, cwd } = setupDirs(t)
313
485
  process.env[AGENT_DIR_ENV] = agentDir
314
486
  delete process.env.HERDR_ENV
487
+ delete process.env.REX_SESSION
488
+ delete process.env.REX_BLOCK
315
489
  t.after(restoreEnv)
316
490
  const { pi, registerCalls, emit } = createFake({ cwd })
317
491
  install(pi)
@@ -329,3 +503,27 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
329
503
  assert.equal(data.outcome, "completed")
330
504
  assert.ok(registerCalls.some((c) => c.method === "registerEntryRenderer" && c.args[0] === "pi-minimal.turn"))
331
505
  })
506
+
507
+ test("a second session_start disposes the first session's chrome and the stale one cannot draw", async (t) => {
508
+ const { agentDir, cwd } = setupDirs(t)
509
+ process.env[AGENT_DIR_ENV] = agentDir
510
+ delete process.env.HERDR_ENV
511
+ delete process.env.REX_SESSION
512
+ delete process.env.REX_BLOCK
513
+ t.after(restoreEnv)
514
+ const { pi, emit, events, uiCalls } = createFake({ cwd })
515
+ install(pi)
516
+ t.after(() => emit("session_shutdown"))
517
+ await emit("session_start")
518
+ const first: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
519
+ events.emit(FOOTER_EVENTS.register, first)
520
+ assert.ok(first.result?.ok)
521
+ await emit("session_start")
522
+ assert.equal(first.result.registration.publish({ revision: 1, rows: [], omitted: 0, motion: "static", format: "icons" }).ok, false, "old registration is revoked")
523
+ const second: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
524
+ events.emit(FOOTER_EVENTS.register, second)
525
+ assert.ok(second.result?.ok, "the new session accepts a fresh registration")
526
+ const count = uiCalls.length
527
+ await new Promise(resolve => setTimeout(resolve, 70))
528
+ assert.ok(uiCalls.length >= count, "settle callbacks of the live session still run")
529
+ })
@@ -0,0 +1,272 @@
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 { stripTerminalSequences } from "@earendil-works/pi-tui"
5
+ import { filterPrs, parsePrFilter, prsToolResult } from "../src/prs-command.js"
6
+ import {
7
+ ciFailing,
8
+ createPrSource,
9
+ describePr,
10
+ findPrUrls,
11
+ isPrCreateCommand,
12
+ parsePrList,
13
+ parsePrView,
14
+ parseRepoView,
15
+ PR_LIST_FIELDS,
16
+ prChipLabel,
17
+ prColor,
18
+ prSegment,
19
+ prUrlRepo,
20
+ type PrRepo,
21
+ type PullRequest,
22
+ } from "../src/prs.js"
23
+
24
+ const repo: PrRepo = { nameWithOwner: "raviy06/pi-minimal", host: "github.com", url: "https://github.com/raviy06/pi-minimal" }
25
+
26
+ // Shapes from `gh pr list --json …` on github.com and on a GitHub Enterprise host.
27
+ const ghList = JSON.stringify([
28
+ {
29
+ number: 23, title: "feat: background processes", url: "https://github.com/raviy06/pi-minimal/pull/23", isDraft: false,
30
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
31
+ statusCheckRollup: [{ state: null, conclusion: "", status: "IN_PROGRESS" }], updatedAt: "2026-10-28T13:40:00Z",
32
+ },
33
+ {
34
+ number: 22, title: "feat: Rex siblings", url: "https://github.com/raviy06/pi-minimal/pull/22", isDraft: true,
35
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
36
+ statusCheckRollup: [{ state: null, conclusion: "SUCCESS", status: "COMPLETED" }], updatedAt: "2026-10-28T13:30:00Z",
37
+ },
38
+ {
39
+ number: 21, title: "docs: glossary", url: "https://github.com/raviy06/pi-minimal/pull/21", isDraft: false,
40
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "CHANGES_REQUESTED",
41
+ statusCheckRollup: [], updatedAt: "2026-10-27T10:00:00Z",
42
+ },
43
+ {
44
+ number: 20, title: "fix: review me", url: "https://github.com/raviy06/pi-minimal/pull/20", isDraft: false,
45
+ author: { login: "someone" }, reviewRequests: [{ __typename: "User", login: "RaviY06" }], reviewDecision: "REVIEW_REQUIRED",
46
+ statusCheckRollup: [{ state: "PENDING", conclusion: null, status: null }, { state: "FAILURE", conclusion: null, status: null }], updatedAt: "2026-10-28T09:00:00Z",
47
+ },
48
+ {
49
+ number: 19, title: "team review", url: "https://github.com/raviy06/pi-minimal/pull/19", isDraft: false,
50
+ author: { login: "someone" }, reviewRequests: [{ __typename: "Team", name: "HotelTonight", slug: "hoteltonight" }], reviewDecision: "",
51
+ statusCheckRollup: [], updatedAt: "2026-10-28T08:00:00Z",
52
+ },
53
+ {
54
+ number: 18, title: "just mentioned", url: "https://github.com/raviy06/pi-minimal/pull/18", isDraft: false,
55
+ author: { login: "someone" }, reviewRequests: [], reviewDecision: "", statusCheckRollup: [], updatedAt: "2026-10-28T07:00:00Z",
56
+ },
57
+ ])
58
+
59
+ test.describe("prs parsing", () => {
60
+ test("parseRepoView derives the host from the URL", () => {
61
+ assert.deepEqual(parseRepoView(JSON.stringify({ nameWithOwner: "airbnb/ht-mainapp", url: "https://git.musta.ch/airbnb/ht-mainapp" })), {
62
+ nameWithOwner: "airbnb/ht-mainapp", host: "git.musta.ch", url: "https://git.musta.ch/airbnb/ht-mainapp",
63
+ })
64
+ assert.throws(() => parseRepoView("{}"))
65
+ })
66
+
67
+ test("ciFailing reads both status contexts and check runs", () => {
68
+ assert.equal(ciFailing([{ state: "SUCCESS" }, { conclusion: "SUCCESS", status: "COMPLETED" }]), false)
69
+ assert.equal(ciFailing([{ state: "PENDING" }]), false)
70
+ assert.equal(ciFailing([{ state: "FAILURE" }]), true)
71
+ assert.equal(ciFailing([{ conclusion: "TIMED_OUT", status: "COMPLETED" }]), true)
72
+ assert.equal(ciFailing(null), false)
73
+ })
74
+
75
+ test("parsePrList keeps mine and review-requested (case-insensitive), drops mentions, sorts reviews first", () => {
76
+ const prs = parsePrList(ghList, repo, "raviy06")
77
+ assert.deepEqual(prs.map((pr) => [pr.number, pr.kind]), [[20, "review"], [23, "mine"], [22, "mine"], [21, "mine"]])
78
+ assert.equal(prs.find((pr) => pr.number === 20)?.ciFailing, true)
79
+ assert.equal(prs.find((pr) => pr.number === 22)?.isDraft, true)
80
+ assert.equal(prs[0]?.repo, "github.com/raviy06/pi-minimal")
81
+ })
82
+
83
+ test("team review requests count when my teams are known", () => {
84
+ const prs = parsePrList(ghList, repo, "raviy06", ["HotelTonight"])
85
+ assert.ok(prs.some((pr) => pr.number === 19 && pr.kind === "review"))
86
+ })
87
+ })
88
+
89
+ test.describe("prs display", () => {
90
+ const theme = { fg: (c: string, t: string) => `[${c}]${t}[/${c}]` }
91
+ const prs = parsePrList(ghList, repo, "raviy06")
92
+ const by = (n: number) => prs.find((pr) => pr.number === n) as PullRequest
93
+
94
+ test("prColor: trouble > review > draft > mine", () => {
95
+ assert.equal(prColor(by(20)), "error", "failing CI on a review request")
96
+ assert.equal(prColor(by(21)), "error", "changes requested")
97
+ assert.equal(prColor({ ...by(20), ciFailing: false }), "warning")
98
+ assert.equal(prColor(by(22)), "dim")
99
+ assert.equal(prColor(by(23)), "accent")
100
+ })
101
+
102
+ test("prSegment links each chip, colours it, and collapses the overflow", () => {
103
+ const segment = prSegment(prs, theme, 2, "github.com/raviy06/pi-minimal") as string
104
+ const esc = "\u001b"
105
+ assert.ok(segment.includes(`[error]${esc}]8;;https://github.com/raviy06/pi-minimal/pull/20${esc}\\#20${esc}]8;;${esc}\\[/error]`), "chip #20 is a red OSC 8 link")
106
+ assert.match(segment, /\[accent\].*#23.*\[\/accent\]/)
107
+ assert.match(segment, /\[dim\]\+2\[\/dim\]$/)
108
+ assert.equal(stripTerminalSequences(segment.replace(/\[\/?[a-z]+\]/g, "")), "#20 #23 +2")
109
+ assert.equal(prSegment([], theme), undefined)
110
+ assert.equal(stripTerminalSequences((prSegment(prs.slice(0, 1), theme, 4) as string).replace(/\[\/?[a-z]+\]/g, "")), "pi-minimal#20", "outside any repo the chip names its repo")
111
+ })
112
+
113
+ test("describePr summarises kind and trouble", () => {
114
+ assert.equal(describePr(by(20), "github.com/raviy06/pi-minimal"), "#20 fix: review me · review requested · CI failing · @someone")
115
+ assert.equal(describePr(by(22), "github.com/raviy06/pi-minimal"), "#22 feat: Rex siblings · draft")
116
+ assert.equal(describePr(by(21), "github.com/raviy06/pi-minimal"), "#21 docs: glossary · mine · changes requested")
117
+ })
118
+
119
+ test("filter parsing and filtering", () => {
120
+ assert.equal(parsePrFilter(""), "all")
121
+ assert.equal(parsePrFilter("mine"), "mine")
122
+ assert.equal(parsePrFilter("Review extra"), "review")
123
+ assert.deepEqual(filterPrs(prs, "review").map((pr) => pr.number), [20])
124
+ assert.deepEqual(filterPrs(prs, "mine").map((pr) => pr.number), [23, 22, 21])
125
+ })
126
+ })
127
+
128
+ test.describe("createPrSource", () => {
129
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
130
+ const settle = async () => { for (let i = 0; i < 12; i++) await tick() }
131
+
132
+ const sessionPrUrl = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
133
+ const ghView = (state = "OPEN") => JSON.stringify({
134
+ number: 22741, title: "Make the Rails session cookie host-only", url: sessionPrUrl, isDraft: false, state,
135
+ author: { login: "ravi-yadav" }, reviewRequests: [], reviewDecision: "REVIEW_REQUIRED",
136
+ statusCheckRollup: [{ state: "FAILURE" }], updatedAt: "2026-10-28T12:00:00Z",
137
+ })
138
+
139
+ function fakePi(options: { repoOk?: boolean; login?: string; viewState?: string } = {}) {
140
+ const execCalls: string[][] = []
141
+ const pi = {
142
+ async exec(command: string, args: string[]) {
143
+ execCalls.push([command, ...args])
144
+ if (command !== "gh") return { stdout: "", stderr: "", code: 1, killed: false }
145
+ if (args[0] === "pr" && args[1] === "view") return { stdout: ghView(options.viewState), stderr: "", code: 0, killed: false }
146
+ if (args[0] === "repo") {
147
+ return options.repoOk === false
148
+ ? { stdout: "", stderr: "none of the git remotes configured for this repository point to a known GitHub host", code: 1, killed: false }
149
+ : { stdout: JSON.stringify({ nameWithOwner: repo.nameWithOwner, url: repo.url }), stderr: "", code: 0, killed: false }
150
+ }
151
+ if (args[0] === "api") return { stdout: `${options.login ?? "raviy06"}\n`, stderr: "", code: 0, killed: false }
152
+ if (args[0] === "pr") return { stdout: ghList, stderr: "", code: 0, killed: false }
153
+ return { stdout: "", stderr: "", code: 1, killed: false }
154
+ },
155
+ } as unknown as ExtensionAPI
156
+ return { pi, execCalls }
157
+ }
158
+
159
+ test("fetches repo, viewer login (per host) and the PR list once; later refreshes reuse the repo and login", async (t) => {
160
+ const { pi, execCalls } = fakePi()
161
+ let now = 1_000_000
162
+ const source = createPrSource({ pi, cwd: () => "/repo", now: () => now, minIntervalMs: 60_000, pollMs: 10 * 60_000 })
163
+ t.after(() => source.dispose())
164
+ let changes = 0
165
+ source.onChange(() => { changes += 1 })
166
+ await settle()
167
+ assert.deepEqual(execCalls.map((c) => c.slice(0, 3)), [
168
+ ["gh", "repo", "view"],
169
+ ["gh", "api", "--hostname"],
170
+ ["gh", "pr", "list"],
171
+ ])
172
+ const hostnameIdx = execCalls[1]?.indexOf("--hostname") ?? -1
173
+ assert.ok(hostnameIdx >= 0)
174
+ assert.equal(execCalls[1]?.[hostnameIdx + 1], "github.com")
175
+ assert.ok(execCalls[2]?.includes("github.com/raviy06/pi-minimal") && execCalls[2]?.includes(PR_LIST_FIELDS))
176
+ assert.equal(changes, 1)
177
+ assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
178
+ assert.equal(source.get().fetchedAt, now)
179
+
180
+ // Throttled: an event-driven refresh inside the window does nothing.
181
+ await source.refresh()
182
+ assert.equal(execCalls.length, 3)
183
+ // Forced: only the list is fetched again; nothing changed so no notification.
184
+ now += 1000
185
+ await source.refresh(true)
186
+ assert.equal(execCalls.length, 4)
187
+ assert.equal(execCalls[3]?.[1], "pr")
188
+ assert.equal(changes, 1)
189
+
190
+ const tool = prsToolResult(source, "review")
191
+ assert.equal(tool.repo, "raviy06/pi-minimal")
192
+ assert.deepEqual(tool.prs.map((pr) => pr.number), [20])
193
+ })
194
+
195
+ test("outside a GitHub repo the snapshot is empty with the error, and no PR list is requested", async (t) => {
196
+ const { pi, execCalls } = fakePi({ repoOk: false })
197
+ const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
198
+ t.after(() => source.dispose())
199
+ await settle()
200
+ assert.equal(execCalls.length, 1)
201
+ assert.equal(source.get().repo, undefined)
202
+ assert.deepEqual(source.get().prs, [])
203
+ assert.match(source.get().error ?? "", /known GitHub host/)
204
+ })
205
+
206
+ test("a PR created in this session shows up even outside any repo, is persisted via sessionPrs(), and is forgotten once merged", async (t) => {
207
+ const { pi, execCalls } = fakePi({ repoOk: false })
208
+ const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
209
+ t.after(() => source.dispose())
210
+ await settle()
211
+ assert.deepEqual(source.get().prs, [])
212
+
213
+ source.addSessionPr("Creating pull request…\nhttps://git.musta.ch/airbnb/ht-mainapp/pull/22741\n")
214
+ await settle()
215
+ assert.deepEqual(source.sessionPrs(), [sessionPrUrl])
216
+ assert.ok(execCalls.some((c) => c[1] === "pr" && c[2] === "view" && c[3] === sessionPrUrl))
217
+ assert.deepEqual(source.get().prs.map((pr) => [pr.number, pr.kind, pr.repo, pr.ciFailing]), [[22741, "mine", "git.musta.ch/airbnb/ht-mainapp", true]])
218
+ assert.equal(source.get().repo, undefined, "still not a repo here")
219
+
220
+ // Same URL again is a no-op.
221
+ const calls = execCalls.length
222
+ source.addSessionPr(sessionPrUrl)
223
+ await settle()
224
+ assert.equal(execCalls.length, calls)
225
+
226
+ // A restored session starts with the URL and fetches it on the first refresh.
227
+ const restored = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl] })
228
+ t.after(() => restored.dispose())
229
+ await settle()
230
+ assert.deepEqual(restored.get().prs.map((pr) => pr.number), [22741])
231
+ })
232
+
233
+ test("a session PR in the cwd repo is not listed twice; a merged one disappears", async (t) => {
234
+ const merged = fakePi({ viewState: "MERGED" })
235
+ const source = createPrSource({ pi: merged.pi, cwd: () => "/repo", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl, "https://github.com/raviy06/pi-minimal/pull/23"] })
236
+ t.after(() => source.dispose())
237
+ await settle()
238
+ // #23 comes from the list (so no `pr view` for it); the GHE one was merged, so it is dropped and forgotten.
239
+ assert.equal(merged.execCalls.filter((c) => c[1] === "pr" && c[2] === "view").length, 1)
240
+ assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
241
+ assert.deepEqual(source.sessionPrs(), ["https://github.com/raviy06/pi-minimal/pull/23"])
242
+ })
243
+ })
244
+
245
+ test.describe("session PRs", () => {
246
+ test("PR URLs are found in gh pr create output and mapped to their repo", () => {
247
+ const out = "Creating pull request for ry-prs-status into main in raviy06/pi-minimal\n\nhttps://github.com/raviy06/pi-minimal/pull/24\n"
248
+ assert.deepEqual(findPrUrls(out), ["https://github.com/raviy06/pi-minimal/pull/24"])
249
+ assert.deepEqual(findPrUrls("see https://git.musta.ch/airbnb/ht-mainapp/pull/22741 and https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), ["https://git.musta.ch/airbnb/ht-mainapp/pull/22741"])
250
+ assert.deepEqual(findPrUrls("https://github.com/raviy06/pi-minimal/issues/5"), [])
251
+ assert.equal(prUrlRepo("https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), "git.musta.ch/airbnb/ht-mainapp")
252
+ assert.equal(isPrCreateCommand("cd x && gh pr create --fill"), true)
253
+ assert.equal(isPrCreateCommand("gt submit --stack"), true)
254
+ assert.equal(isPrCreateCommand("gh pr view 24"), false)
255
+ })
256
+
257
+ test("parsePrView keeps open PRs as mine and drops merged/closed ones", () => {
258
+ const url = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
259
+ const raw = { number: 22741, title: "t", url, isDraft: false, author: { login: "x" }, statusCheckRollup: [], state: "OPEN" }
260
+ assert.equal(parsePrView(JSON.stringify(raw), url)?.kind, "mine")
261
+ assert.equal(parsePrView(JSON.stringify(raw), url)?.repo, "git.musta.ch/airbnb/ht-mainapp")
262
+ assert.equal(parsePrView(JSON.stringify({ ...raw, state: "MERGED" }), url), undefined)
263
+ })
264
+
265
+ test("chips outside the cwd repo carry the repo name", () => {
266
+ const pr = parsePrList(ghList, repo, "raviy06")[0] as PullRequest
267
+ assert.equal(prChipLabel(pr, "github.com/raviy06/pi-minimal"), "#20")
268
+ assert.equal(prChipLabel(pr, "git.musta.ch/airbnb/ht-mainapp"), "pi-minimal#20")
269
+ assert.equal(prChipLabel(pr, undefined), "pi-minimal#20", "no cwd repo: name the repo")
270
+ assert.match(describePr(pr, "git.musta.ch/airbnb/other"), /^pi-minimal#20 {2}/)
271
+ })
272
+ })