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,73 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent"
2
+ import { diffWords } from "diff"
3
+ import type { ToolResultLike } from "./tool-rows.js"
4
+
5
+ /** The edit tool's `details.diff`, when present and non-empty. */
6
+ export function editDiffText(result: ToolResultLike | undefined): string | undefined {
7
+ const details = result?.details as { diff?: unknown } | undefined
8
+ return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
9
+ }
10
+
11
+ type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
12
+
13
+ /** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
14
+ function parseDiffLine(line: string): DiffLine | undefined {
15
+ const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
16
+ if (!match) return undefined
17
+ return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
18
+ }
19
+
20
+ /** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
21
+ function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
22
+ let out = { removed: "", added: "" }
23
+ let firstRemoved = true
24
+ let firstAdded = true
25
+ for (const part of diffWords(removed, added)) {
26
+ if (part.removed || part.added) {
27
+ const key = part.removed ? "removed" : "added"
28
+ let value = part.value
29
+ if (part.removed ? firstRemoved : firstAdded) {
30
+ const ws = /^(\s*)/.exec(value)?.[1] ?? ""
31
+ value = value.slice(ws.length)
32
+ out = { ...out, [key]: out[key] + ws }
33
+ if (part.removed) firstRemoved = false
34
+ else firstAdded = false
35
+ }
36
+ if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
37
+ } else {
38
+ out = { removed: out.removed + part.value, added: out.added + part.value }
39
+ }
40
+ }
41
+ return out
42
+ }
43
+
44
+ /** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
45
+ export function colorDiffLines(lines: string[], theme: Theme): string[] {
46
+ const out: string[] = []
47
+ for (let i = 0; i < lines.length; ) {
48
+ const parsed = parseDiffLine(lines[i]!)
49
+ if (!parsed) {
50
+ out.push(theme.fg("toolDiffContext", lines[i]!))
51
+ i++
52
+ continue
53
+ }
54
+ if (parsed.prefix === " ") {
55
+ out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`))
56
+ i++
57
+ continue
58
+ }
59
+ const removed: DiffLine[] = []
60
+ const added: DiffLine[] = []
61
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
62
+ for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
63
+ if (removed.length === 1 && added.length === 1) {
64
+ const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
65
+ out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
66
+ out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
67
+ continue
68
+ }
69
+ for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
70
+ for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
71
+ }
72
+ return out
73
+ }
@@ -5,8 +5,11 @@ export const FOOTER_EVENTS = {
5
5
  ready: "pi-minimal:footer:v1:ready",
6
6
  unavailable: "pi-minimal:footer:v1:unavailable",
7
7
  } as const
8
+ /** Slot names on the wire. `summary` is the legacy name of the plan slot and stays accepted. */
9
+ export const FOOTER_SLOTS = ["activity", "plan", "summary"] as const
10
+ export type FooterSlot = typeof FOOTER_SLOTS[number]
8
11
  export type RegisterRequest = {
9
- version: 1; sessionId: string; owner: string; slot: "activity" | "summary"
12
+ version: 1; sessionId: string; owner: string; slot: FooterSlot
10
13
  result?: RegistrationResult | { ok: false; code: "not-tui" }
11
14
  }
12
15
  type Events = {
@@ -22,11 +25,11 @@ export function bindFooterContributionEvents(events: Events, registry: FooterCon
22
25
  const request = value as Partial<RegisterRequest>
23
26
  // A different host may have answered first. Never overwrite its result.
24
27
  if (request.result !== undefined) return
25
- if (request.version !== 1 || (request.slot !== "activity" && request.slot !== "summary")) request.result = { ok: false, code: "invalid" }
28
+ if (request.version !== 1 || !FOOTER_SLOTS.includes(request.slot as FooterSlot)) request.result = { ok: false, code: "invalid" }
26
29
  else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
27
- else request.result = request.slot === "summary" ? registry.registerPlan(request) : registry.register(request)
30
+ else request.result = request.slot === "activity" ? registry.register(request) : registry.registerPlan(request)
28
31
  })
29
- if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: ["activity", "summary"] })
32
+ if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [...FOOTER_SLOTS] })
30
33
  let disposed = false
31
34
  return () => {
32
35
  if (disposed) return
@@ -77,7 +77,8 @@ function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
77
77
 
78
78
  function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
79
79
  let sessionId = initialSessionId
80
- let owner: symbol | undefined
80
+ // The registrant's `owner` string is a display identity; this symbol is the actual authority to publish.
81
+ let ownerToken: symbol | undefined
81
82
  let snapshot: T | undefined
82
83
  const observers = new Set<() => void>()
83
84
  const notify = () => {
@@ -89,17 +90,17 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
89
90
  read: () => snapshot,
90
91
  subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
91
92
  reset(nextSessionId: string | null) {
92
- sessionId = nextSessionId; owner = undefined; snapshot = undefined; notify()
93
+ sessionId = nextSessionId; ownerToken = undefined; snapshot = undefined; notify()
93
94
  },
94
95
  register(input: unknown): RegistrationResult {
95
96
  if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
96
97
  if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
97
- if (owner) return { ok: false, code: "occupied" }
98
+ if (ownerToken) return { ok: false, code: "occupied" }
98
99
  const token = Symbol(input.owner)
99
- owner = token
100
+ ownerToken = token
100
101
  return { ok: true, registration: {
101
102
  publish(input: unknown) {
102
- if (owner !== token) return { ok: false, code: "disposed" }
103
+ if (ownerToken !== token) return { ok: false, code: "disposed" }
103
104
  const next = normalize(input)
104
105
  if (!next) return { ok: false, code: "invalid" }
105
106
  if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
@@ -107,8 +108,8 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
107
108
  return { ok: true }
108
109
  },
109
110
  dispose() {
110
- if (owner !== token) return
111
- owner = undefined; snapshot = undefined; notify()
111
+ if (ownerToken !== token) return
112
+ ownerToken = undefined; snapshot = undefined; notify()
112
113
  },
113
114
  } }
114
115
  },
@@ -116,17 +117,18 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
116
117
  }
117
118
  export function createFooterContributions(initialSessionId: string | null = null) {
118
119
  const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
119
- const summary = createFooterSlot(normalizePlanSnapshot, initialSessionId)
120
+ // The plan slot; "summary" is its legacy wire name.
121
+ const plan = createFooterSlot(normalizePlanSnapshot, initialSessionId)
120
122
  return {
121
123
  read: activity.read,
122
- readPlan: summary.read,
124
+ readPlan: plan.read,
123
125
  register: activity.register,
124
- registerPlan: summary.register,
126
+ registerPlan: plan.register,
125
127
  subscribe(observer: () => void) {
126
- const offActivity = activity.subscribe(observer), offSummary = summary.subscribe(observer)
127
- return () => { offActivity(); offSummary() }
128
+ const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer)
129
+ return () => { offActivity(); offPlan() }
128
130
  },
129
- reset(sessionId: string | null) { activity.reset(sessionId); summary.reset(sessionId) },
131
+ reset(sessionId: string | null) { activity.reset(sessionId); plan.reset(sessionId) },
130
132
  }
131
133
  }
132
134
  export type FooterContributions = ReturnType<typeof createFooterContributions>
package/src/footer.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent"
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui"
4
4
  import * as chrome from "./chrome.js"
5
+ import { splitRow } from "./cwd.js"
5
6
  import { formatTrackerLines } from "./tracker.js"
6
7
  import type { FooterContributions } from "./footer-contributions.js"
7
8
  import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js"
@@ -19,6 +20,14 @@ export type FooterSources = {
19
20
  editorText: () => string
20
21
  /** Context usage percentage, when known. */
21
22
  contextPct: () => number | undefined
23
+ /** Open PR chips (`#1675 #1670 +3`), themed and linked, shown right-aligned on the hints line. */
24
+ prs?: (theme: { fg(color: string, text: string): string }) => string | undefined
25
+ /** Fires when the PR chips may have changed. */
26
+ onPrsChange?: (listener: () => void) => () => void
27
+ /** `⚙ 2 bg` while bash-tool processes run in the background; shown after the hints. */
28
+ background?: () => string | undefined
29
+ /** Fires when the background count may have changed. */
30
+ onBackgroundChange?: (listener: () => void) => () => void
22
31
  }
23
32
 
24
33
  /**
@@ -52,11 +61,15 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
52
61
  const update = () => { syncAnimation(); redraw() }
53
62
  const unsub = footerData.onBranchChange(redraw)
54
63
  const unsubscribeActivity = contributions?.subscribe(update)
64
+ const unsubscribePrs = sources.onPrsChange?.(redraw)
65
+ const unsubscribeBackground = sources.onBackgroundChange?.(redraw)
55
66
  stopMount = () => {
56
67
  if (disposed) return
57
68
  disposed = true
58
69
  unsub()
59
70
  unsubscribeActivity?.()
71
+ unsubscribePrs?.()
72
+ unsubscribeBackground?.()
60
73
  live.stop()
61
74
  }
62
75
  update()
@@ -71,9 +84,16 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
71
84
  keyHint("app.thinking.cycle", "thinking"),
72
85
  keyHint("app.tools.expand", "expand tools"),
73
86
  ].filter(Boolean))
74
- if (hints.length > 0) {
75
- const line = theme.fg("dim", hints.join(" · "))
76
- out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
87
+ // Left: hints (idle only) then the background count (always). Right: PR chips whenever
88
+ // there are any. The chips are dropped before the left side when the line is too narrow.
89
+ const background = sources.background?.()
90
+ const left = [hints.length > 0 ? theme.fg("dim", hints.join(" · ")) : "", background ? theme.fg("warning", background) : ""]
91
+ .filter(Boolean)
92
+ .join(theme.fg("dim", " · "))
93
+ const prs = sources.prs?.(theme)
94
+ if (left || prs) {
95
+ const line = prs ? splitRow(left, [prs], inner) : truncateToWidth(left, inner, "")
96
+ out.push(`${" ".repeat(margin())}${line}`)
77
97
  }
78
98
  const activityWidth = Math.max(0, width - margin() * 2)
79
99
  const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme)
package/src/git-state.ts CHANGED
@@ -30,30 +30,45 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
30
30
  let state: GitState | undefined
31
31
  let lastRun = 0
32
32
  let inFlight: Promise<void> | undefined
33
+ let disposed = false
33
34
  const listeners = new Set<() => void>()
34
35
 
36
+ const notify = () => {
37
+ for (const listener of listeners) {
38
+ try {
39
+ listener()
40
+ } catch {
41
+ // One listener's failure must not starve the others.
42
+ }
43
+ }
44
+ }
45
+
35
46
  const refresh = (force = false): Promise<void> => {
47
+ if (disposed) return Promise.resolve()
36
48
  const now = Date.now()
37
49
  if (inFlight) return inFlight
38
50
  if (!force && now - lastRun < minIntervalMs) return Promise.resolve()
39
51
  lastRun = now
52
+ // Read the directory once so both commands describe the same tree even if cwd changes mid-refresh.
53
+ const dir = cwd()
40
54
  inFlight = (async () => {
55
+ let next: GitState | undefined
41
56
  try {
42
- const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 })
43
- if (branchRun.code !== 0) {
44
- state = undefined
45
- return
57
+ const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 2000 })
58
+ if (branchRun.code === 0) {
59
+ const branch = parseBranch(branchRun.stdout)
60
+ const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
61
+ next = { branch, dirty: statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined }
46
62
  }
47
- const branch = parseBranch(branchRun.stdout)
48
- const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 })
49
- const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined
50
- state = { branch, dirty }
51
63
  } catch {
52
- state = undefined
64
+ next = undefined
53
65
  } finally {
54
66
  inFlight = undefined
55
- for (const listener of listeners) listener()
56
67
  }
68
+ // A result that lands after dispose belongs to a session that no longer exists.
69
+ if (disposed) return
70
+ state = next
71
+ notify()
57
72
  })()
58
73
  return inFlight
59
74
  }
@@ -65,6 +80,10 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
65
80
  listeners.add(listener)
66
81
  return () => listeners.delete(listener)
67
82
  },
83
+ dispose() {
84
+ disposed = true
85
+ listeners.clear()
86
+ },
68
87
  }
69
88
  }
70
89
 
package/src/herdr.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { createListeners } from "./listeners.js"
3
+ import type { Sibling, SiblingSource } from "./siblings.js"
4
+
5
+ export type { Sibling } from "./siblings.js"
6
+
1
7
  export type HerdrAgent = {
2
8
  agent: string
3
9
  agent_status: string
@@ -40,11 +46,6 @@ export function parseTabList(stdout: string): HerdrTab[] {
40
46
  }))
41
47
  }
42
48
 
43
- export type Sibling = {
44
- name: string
45
- status: string
46
- }
47
-
48
49
  export function listSiblings(
49
50
  agents: HerdrAgent[],
50
51
  tabs: HerdrTab[],
@@ -64,3 +65,75 @@ export function listSiblings(
64
65
  if (parts.length === 0) return undefined
65
66
  return parts
66
67
  }
68
+
69
+ const HERDR_FAST_MS = 2000
70
+ const HERDR_SLOW_MS = 10000
71
+ const HERDR_EXEC_TIMEOUT_MS = 3000
72
+
73
+ /**
74
+ * Polls `herdr agent list` + `herdr tab list`: every 2 s while siblings are visible, every 10 s
75
+ * otherwise or after a failure. Probes once immediately.
76
+ */
77
+ export function createHerdrSiblings(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env): SiblingSource {
78
+ let alive = true
79
+ let siblings: Sibling[] | undefined
80
+ let timer: ReturnType<typeof setInterval> | undefined
81
+ let period = 0
82
+ let inFlight: AbortController | undefined
83
+ const listeners = createListeners()
84
+
85
+ const set = (next: Sibling[] | undefined) => {
86
+ const changed = JSON.stringify(next) !== JSON.stringify(siblings)
87
+ siblings = next
88
+ if (changed) listeners.notify()
89
+ }
90
+
91
+ const schedule = (periodMs: number) => {
92
+ if (!alive || period === periodMs) return
93
+ if (timer) clearInterval(timer)
94
+ period = periodMs
95
+ timer = setInterval(() => { void probe() }, periodMs)
96
+ }
97
+
98
+ const probe = async () => {
99
+ if (!alive || inFlight) return
100
+ const ac = new AbortController()
101
+ inFlight = ac
102
+ try {
103
+ const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
104
+ if (!alive) return
105
+ const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
106
+ if (!alive) return
107
+ if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
108
+ set(undefined)
109
+ schedule(HERDR_SLOW_MS)
110
+ return
111
+ }
112
+ set(listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), env.HERDR_PANE_ID, env.HERDR_TAB_ID))
113
+ schedule(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS)
114
+ } catch {
115
+ if (!alive) return
116
+ set(undefined)
117
+ schedule(HERDR_SLOW_MS)
118
+ } finally {
119
+ if (inFlight === ac) inFlight = undefined
120
+ }
121
+ }
122
+
123
+ void probe()
124
+
125
+ return {
126
+ get: () => siblings,
127
+ onChange: listeners.add,
128
+ dispose() {
129
+ if (!alive) return
130
+ alive = false
131
+ if (timer) clearInterval(timer)
132
+ timer = undefined
133
+ inFlight?.abort()
134
+ inFlight = undefined
135
+ siblings = undefined
136
+ listeners.clear()
137
+ },
138
+ }
139
+ }