pi-minimal 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/CONTEXT.md +27 -3
- package/README.md +6 -3
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +8 -0
- package/dist/cwd.js +2 -2
- package/dist/footer.js +15 -3
- package/dist/herdr.js +78 -0
- package/dist/index.js +62 -1
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/rex.js +289 -0
- package/dist/session.js +72 -59
- package/dist/siblings.js +14 -0
- package/package.json +5 -3
- package/release-notes.md +29 -42
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +8 -0
- package/src/cwd.ts +5 -4
- package/src/footer.ts +23 -3
- package/src/herdr.ts +78 -5
- package/src/index.ts +63 -2
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/rex.ts +304 -0
- package/src/session.ts +76 -50
- package/src/siblings.ts +38 -0
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +23 -10
- package/test/footer-composition.test.ts +61 -0
- package/test/index.test.ts +157 -1
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
3
|
+
import type { BackgroundProcess, BackgroundSnapshot, BackgroundTracker } from "./background.js"
|
|
4
|
+
import { formatElapsed } from "./working-status.js"
|
|
5
|
+
|
|
6
|
+
export type BackgroundRow = {
|
|
7
|
+
pid: number
|
|
8
|
+
command: string
|
|
9
|
+
status: "running" | "ended"
|
|
10
|
+
/** Running: time since start. Ended: how long it ran. */
|
|
11
|
+
durationMs: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function backgroundRows(snap: BackgroundSnapshot, now: number): BackgroundRow[] {
|
|
15
|
+
const row = (proc: BackgroundProcess): BackgroundRow => ({
|
|
16
|
+
pid: proc.pid,
|
|
17
|
+
command: proc.command,
|
|
18
|
+
status: proc.endedAt === undefined ? "running" : "ended",
|
|
19
|
+
durationMs: (proc.endedAt ?? now) - proc.startedAt,
|
|
20
|
+
})
|
|
21
|
+
return [...snap.running.map(row), ...snap.ended.map(row)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Status-line segment: `⚙ 2 bg`; undefined when nothing is running. */
|
|
25
|
+
export function backgroundSegment(snap: BackgroundSnapshot): string | undefined {
|
|
26
|
+
if (snap.running.length === 0) return undefined
|
|
27
|
+
return `⚙ ${snap.running.length} bg`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Theme = { fg(color: string, text: string): string }
|
|
31
|
+
|
|
32
|
+
/** Lines of the overlay body for a given width; pure so it can be tested without a TUI. */
|
|
33
|
+
export function renderBackgroundLines(rows: BackgroundRow[], selected: number, width: number, theme: Theme, shortcut?: string): string[] {
|
|
34
|
+
const title = theme.fg("accent", "Background processes")
|
|
35
|
+
const hint = theme.fg("dim", shortcut ? `↑↓ select · k kill · K kill group · esc close (${shortcut})` : "↑↓ select · k kill · K kill group · esc close")
|
|
36
|
+
if (rows.length === 0) {
|
|
37
|
+
return [title, theme.fg("muted", "Nothing running in the background."), hint].map((line) => truncateToWidth(line, width))
|
|
38
|
+
}
|
|
39
|
+
const statusWidth = 7
|
|
40
|
+
const durations = rows.map((row) => (row.status === "running" ? formatElapsed(row.durationMs) : `ran ${formatElapsed(row.durationMs)}`))
|
|
41
|
+
const durationWidth = Math.max(...durations.map((text) => text.length))
|
|
42
|
+
const commandWidth = Math.max(8, width - 2 - 1 - statusWidth - 2 - durationWidth)
|
|
43
|
+
const lines = rows.map((row, index) => {
|
|
44
|
+
const marker = index === selected ? theme.fg("accent", "› ") : " "
|
|
45
|
+
const command = truncateToWidth(row.command, commandWidth, "…").padEnd(commandWidth)
|
|
46
|
+
const status = row.status === "running" ? theme.fg("success", "running") : theme.fg("muted", "ended ")
|
|
47
|
+
const duration = theme.fg("dim", (durations[index] ?? "").padStart(durationWidth))
|
|
48
|
+
const text = `${marker}${index === selected ? theme.fg("text", command) : theme.fg("muted", command)} ${status} ${duration}`
|
|
49
|
+
return visibleWidth(text) > width ? truncateToWidth(text, width) : text
|
|
50
|
+
})
|
|
51
|
+
return [title, ...lines, hint]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shows the overlay until the user closes it. Resolves when closed. */
|
|
55
|
+
export async function showBackgroundOverlay(ctx: ExtensionContext, tracker: BackgroundTracker, shortcut?: string): Promise<void> {
|
|
56
|
+
if (ctx.mode !== "tui") {
|
|
57
|
+
const rows = backgroundRows(tracker.get(), Date.now())
|
|
58
|
+
ctx.ui.notify(rows.length === 0 ? "Nothing running in the background." : rows.map((row) => `${row.pid} ${row.status} ${formatElapsed(row.durationMs)} ${row.command}`).join("\n"), "info")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
await ctx.ui.custom<void>(
|
|
62
|
+
(tui, theme, keybindings, done) => {
|
|
63
|
+
let selected = 0
|
|
64
|
+
let cached: { width: number; lines: string[] } | undefined
|
|
65
|
+
const invalidate = () => { cached = undefined }
|
|
66
|
+
const redraw = () => { invalidate(); tui.requestRender() }
|
|
67
|
+
const unsubscribe = tracker.onChange(redraw)
|
|
68
|
+
const ticker = setInterval(redraw, 1000)
|
|
69
|
+
const rows = () => backgroundRows(tracker.get(), Date.now())
|
|
70
|
+
const finish = () => {
|
|
71
|
+
clearInterval(ticker)
|
|
72
|
+
unsubscribe()
|
|
73
|
+
done()
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
render(width: number): string[] {
|
|
77
|
+
if (cached && cached.width === width) return cached.lines
|
|
78
|
+
const current = rows()
|
|
79
|
+
if (selected >= current.length) selected = Math.max(0, current.length - 1)
|
|
80
|
+
cached = { width, lines: renderBackgroundLines(current, selected, width, theme, shortcut) }
|
|
81
|
+
return cached.lines
|
|
82
|
+
},
|
|
83
|
+
handleInput(data: string): void {
|
|
84
|
+
const current = rows()
|
|
85
|
+
if (keybindings.matches(data, "tui.select.cancel")) { finish(); return }
|
|
86
|
+
if (keybindings.matches(data, "tui.select.up")) selected = current.length === 0 ? 0 : (selected - 1 + current.length) % current.length
|
|
87
|
+
else if (keybindings.matches(data, "tui.select.down")) selected = current.length === 0 ? 0 : (selected + 1) % current.length
|
|
88
|
+
else if (data === "k" || data === "K") {
|
|
89
|
+
const row = current[selected]
|
|
90
|
+
if (row?.status === "running") tracker.kill(row.pid, { group: data === "K" })
|
|
91
|
+
} else return
|
|
92
|
+
redraw()
|
|
93
|
+
},
|
|
94
|
+
invalidate,
|
|
95
|
+
dispose() {
|
|
96
|
+
clearInterval(ticker)
|
|
97
|
+
unsubscribe()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{ overlay: true, overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" } },
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { createListeners } from "./listeners.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Background processes: programs a `bash` tool call left running after it returned (`npm run dev &`,
|
|
6
|
+
* `nohup ./watch.sh`). Pi spawns every bash call detached in its own process group, so whatever the
|
|
7
|
+
* shell leaves behind keeps that group's id after the shell exits and is reparented to pid 1.
|
|
8
|
+
*
|
|
9
|
+
* Attribution needs no command rewriting: snapshot `ps` when the call starts and when it ends; a
|
|
10
|
+
* process that is new, orphaned (ppid 1) and belongs to a group whose leader was born and died inside
|
|
11
|
+
* the call came from that call's shell. Tracked groups are then polled until they are empty.
|
|
12
|
+
* Pi kills these groups when it exits, so "background" means "alive while this pi is".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PsEntry = {
|
|
16
|
+
pid: number
|
|
17
|
+
ppid: number
|
|
18
|
+
pgid: number
|
|
19
|
+
/** Seconds since the process started, from `etime`. */
|
|
20
|
+
elapsedSec: number
|
|
21
|
+
command: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type BackgroundProcess = {
|
|
25
|
+
pid: number
|
|
26
|
+
pgid: number
|
|
27
|
+
command: string
|
|
28
|
+
/** Wall-clock ms when the process started (derived from etime at first sight). */
|
|
29
|
+
startedAt: number
|
|
30
|
+
/** Wall-clock ms when it was first seen gone; undefined while running. */
|
|
31
|
+
endedAt?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type BackgroundSnapshot = {
|
|
35
|
+
running: BackgroundProcess[]
|
|
36
|
+
/** Recently ended, newest first; kept for ENDED_TTL_MS. */
|
|
37
|
+
ended: BackgroundProcess[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const PS_ARGS = ["-ax", "-o", "pid=,ppid=,pgid=,etime=,command="]
|
|
41
|
+
const ENDED_TTL_MS = 60_000
|
|
42
|
+
const POLL_MS = 2000
|
|
43
|
+
const PS_TIMEOUT_MS = 3000
|
|
44
|
+
|
|
45
|
+
/** Parses `etime` (`SS`, `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`) into seconds. */
|
|
46
|
+
export function parseEtime(text: string): number {
|
|
47
|
+
const [daysPart, clock] = text.includes("-") ? text.split("-", 2) : [undefined, text]
|
|
48
|
+
const parts = (clock ?? "").split(":").map((part) => Number.parseInt(part, 10))
|
|
49
|
+
if (parts.some((part) => !Number.isFinite(part))) return 0
|
|
50
|
+
let seconds = 0
|
|
51
|
+
for (const part of parts) seconds = seconds * 60 + part
|
|
52
|
+
const days = daysPart === undefined ? 0 : Number.parseInt(daysPart, 10)
|
|
53
|
+
return seconds + (Number.isFinite(days) ? days : 0) * 86400
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parses `ps -ax -o pid=,ppid=,pgid=,etime=,command=` output. */
|
|
57
|
+
export function parsePs(stdout: string): Map<number, PsEntry> {
|
|
58
|
+
const entries = new Map<number, PsEntry>()
|
|
59
|
+
for (const line of stdout.split("\n")) {
|
|
60
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)
|
|
61
|
+
if (!match) continue
|
|
62
|
+
const pid = Number(match[1])
|
|
63
|
+
entries.set(pid, {
|
|
64
|
+
pid,
|
|
65
|
+
ppid: Number(match[2]),
|
|
66
|
+
pgid: Number(match[3]),
|
|
67
|
+
elapsedSec: parseEtime(match[4] ?? ""),
|
|
68
|
+
command: (match[5] ?? "").trim(),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process groups a finished tool call left behind: processes present only in `after`, orphaned
|
|
76
|
+
* (ppid 1), whose group leader is a pid that did not exist in `before` and is gone in `after`.
|
|
77
|
+
* Any group that was alive before the call, or whose leader still runs, belongs to someone else.
|
|
78
|
+
*/
|
|
79
|
+
export function findOrphanGroups(before: Map<number, PsEntry>, after: Map<number, PsEntry>): Set<number> {
|
|
80
|
+
const groupsBefore = new Set<number>()
|
|
81
|
+
for (const entry of before.values()) groupsBefore.add(entry.pgid)
|
|
82
|
+
const groups = new Set<number>()
|
|
83
|
+
for (const [pid, entry] of after) {
|
|
84
|
+
if (before.has(pid) || entry.ppid !== 1) continue
|
|
85
|
+
if (groupsBefore.has(entry.pgid) || after.has(entry.pgid)) continue
|
|
86
|
+
groups.add(entry.pgid)
|
|
87
|
+
}
|
|
88
|
+
return groups
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type BackgroundState = {
|
|
92
|
+
groups: Set<number>
|
|
93
|
+
processes: Map<number, BackgroundProcess>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createBackgroundState(): BackgroundState {
|
|
97
|
+
return { groups: new Set(), processes: new Map() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reconciles tracked processes with a fresh `ps` listing: records new members of tracked groups,
|
|
102
|
+
* marks vanished ones ended, forgets ended ones after their TTL, and drops empty groups.
|
|
103
|
+
* Returns whether anything changed.
|
|
104
|
+
*/
|
|
105
|
+
export function applyPoll(state: BackgroundState, ps: Map<number, PsEntry>, now: number): boolean {
|
|
106
|
+
let changed = false
|
|
107
|
+
const seen = new Set<number>()
|
|
108
|
+
for (const entry of ps.values()) {
|
|
109
|
+
if (!state.groups.has(entry.pgid) || entry.pid === entry.pgid) continue
|
|
110
|
+
seen.add(entry.pid)
|
|
111
|
+
const known = state.processes.get(entry.pid)
|
|
112
|
+
if (known && known.endedAt === undefined) continue
|
|
113
|
+
state.processes.set(entry.pid, {
|
|
114
|
+
pid: entry.pid,
|
|
115
|
+
pgid: entry.pgid,
|
|
116
|
+
command: entry.command,
|
|
117
|
+
startedAt: now - entry.elapsedSec * 1000,
|
|
118
|
+
})
|
|
119
|
+
changed = true
|
|
120
|
+
}
|
|
121
|
+
for (const [pid, proc] of state.processes) {
|
|
122
|
+
if (seen.has(pid)) continue
|
|
123
|
+
if (proc.endedAt === undefined) {
|
|
124
|
+
proc.endedAt = now
|
|
125
|
+
changed = true
|
|
126
|
+
} else if (now - proc.endedAt > ENDED_TTL_MS) {
|
|
127
|
+
state.processes.delete(pid)
|
|
128
|
+
changed = true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const pgid of state.groups) {
|
|
132
|
+
const live = [...state.processes.values()].some((proc) => proc.pgid === pgid)
|
|
133
|
+
if (!live) {
|
|
134
|
+
state.groups.delete(pgid)
|
|
135
|
+
changed = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return changed
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function snapshot(state: BackgroundState): BackgroundSnapshot {
|
|
142
|
+
const running: BackgroundProcess[] = []
|
|
143
|
+
const ended: BackgroundProcess[] = []
|
|
144
|
+
for (const proc of state.processes.values()) (proc.endedAt === undefined ? running : ended).push(proc)
|
|
145
|
+
running.sort((a, b) => a.startedAt - b.startedAt)
|
|
146
|
+
ended.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
|
|
147
|
+
return { running, ended }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type BackgroundTracker = {
|
|
151
|
+
get(): BackgroundSnapshot
|
|
152
|
+
onChange(listener: () => void): () => void
|
|
153
|
+
toolStart(toolCallId: string, toolName: string): void
|
|
154
|
+
toolEnd(toolCallId: string, toolName: string): void
|
|
155
|
+
/** SIGTERM one process (or its whole group with `group: true`). */
|
|
156
|
+
kill(pid: number, options?: { group?: boolean; signal?: NodeJS.Signals }): boolean
|
|
157
|
+
dispose(): void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type BackgroundTrackerDeps = {
|
|
161
|
+
pi: ExtensionAPI
|
|
162
|
+
now?: () => number
|
|
163
|
+
killProcess?: (pid: number, signal: NodeJS.Signals) => void
|
|
164
|
+
/** Tool names whose calls are inspected; defaults to pi's shell tools. */
|
|
165
|
+
tools?: readonly string[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createBackgroundTracker(deps: BackgroundTrackerDeps): BackgroundTracker {
|
|
169
|
+
const now = deps.now ?? Date.now
|
|
170
|
+
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal))
|
|
171
|
+
const tools = new Set(deps.tools ?? ["bash", "powershell"])
|
|
172
|
+
const state = createBackgroundState()
|
|
173
|
+
const listeners = createListeners()
|
|
174
|
+
const starts = new Map<string, Promise<Map<number, PsEntry> | undefined>>()
|
|
175
|
+
let alive = true
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
177
|
+
let polling = false
|
|
178
|
+
|
|
179
|
+
const listProcesses = async (): Promise<Map<number, PsEntry> | undefined> => {
|
|
180
|
+
try {
|
|
181
|
+
const run = await deps.pi.exec("ps", PS_ARGS, { timeout: PS_TIMEOUT_MS })
|
|
182
|
+
if (run.code !== 0 || run.killed) return undefined
|
|
183
|
+
return parsePs(run.stdout)
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const schedule = () => {
|
|
190
|
+
if (!alive || timer || state.groups.size === 0) return
|
|
191
|
+
timer = setTimeout(() => {
|
|
192
|
+
timer = undefined
|
|
193
|
+
void poll()
|
|
194
|
+
}, POLL_MS)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const poll = async () => {
|
|
198
|
+
if (!alive || polling) return
|
|
199
|
+
polling = true
|
|
200
|
+
try {
|
|
201
|
+
const ps = await listProcesses()
|
|
202
|
+
if (!alive) return
|
|
203
|
+
if (ps && applyPoll(state, ps, now())) listeners.notify()
|
|
204
|
+
} finally {
|
|
205
|
+
polling = false
|
|
206
|
+
schedule()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
get: () => snapshot(state),
|
|
212
|
+
onChange: listeners.add,
|
|
213
|
+
toolStart(toolCallId, toolName) {
|
|
214
|
+
if (!alive || !tools.has(toolName)) return
|
|
215
|
+
starts.set(toolCallId, listProcesses())
|
|
216
|
+
},
|
|
217
|
+
toolEnd(toolCallId, toolName) {
|
|
218
|
+
const before = starts.get(toolCallId)
|
|
219
|
+
starts.delete(toolCallId)
|
|
220
|
+
if (!alive || !before || !tools.has(toolName)) return
|
|
221
|
+
void (async () => {
|
|
222
|
+
const [beforePs, afterPs] = await Promise.all([before, listProcesses()])
|
|
223
|
+
if (!alive || !beforePs || !afterPs) return
|
|
224
|
+
const groups = findOrphanGroups(beforePs, afterPs)
|
|
225
|
+
if (groups.size === 0) return
|
|
226
|
+
for (const pgid of groups) state.groups.add(pgid)
|
|
227
|
+
if (applyPoll(state, afterPs, now())) listeners.notify()
|
|
228
|
+
schedule()
|
|
229
|
+
})()
|
|
230
|
+
},
|
|
231
|
+
kill(pid, options = {}) {
|
|
232
|
+
const proc = state.processes.get(pid)
|
|
233
|
+
if (!proc) return false
|
|
234
|
+
try {
|
|
235
|
+
killProcess(options.group ? -proc.pgid : pid, options.signal ?? "SIGTERM")
|
|
236
|
+
return true
|
|
237
|
+
} catch {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
dispose() {
|
|
242
|
+
if (!alive) return
|
|
243
|
+
alive = false
|
|
244
|
+
if (timer) clearTimeout(timer)
|
|
245
|
+
timer = undefined
|
|
246
|
+
starts.clear()
|
|
247
|
+
listeners.clear()
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -9,12 +9,15 @@ export const EDIT_DIFF_MODES: readonly EditDiffMode[] = ["always", "never", "exp
|
|
|
9
9
|
export type PiMinimalConfig = {
|
|
10
10
|
hideGauntletWidgets: boolean
|
|
11
11
|
editDiff: EditDiffMode
|
|
12
|
+
/** Key that opens the background-process overlay; `null` disables the shortcut (`/bg` still works). */
|
|
13
|
+
backgroundShortcut: string | null
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const DEFAULTS: PiMinimalConfig = {
|
|
15
17
|
// Hiding another extension's widgets is opt-in.
|
|
16
18
|
hideGauntletWidgets: false,
|
|
17
19
|
editDiff: "expanded",
|
|
20
|
+
backgroundShortcut: "ctrl+b",
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function loadPiMinimalConfig(options: {
|
|
@@ -58,6 +61,11 @@ function applyLayer(config: PiMinimalConfig, path: string): void {
|
|
|
58
61
|
if (typeof value === "boolean") config.hideGauntletWidgets = value
|
|
59
62
|
else console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
|
|
60
63
|
}
|
|
64
|
+
if (Object.hasOwn(record, "backgroundShortcut")) {
|
|
65
|
+
const value = record.backgroundShortcut
|
|
66
|
+
if (value === null || (typeof value === "string" && value.trim().length > 0)) config.backgroundShortcut = value === null ? null : value.trim()
|
|
67
|
+
else console.error(`pi-minimal: backgroundShortcut must be a key name such as "ctrl+b" or null in ${path}`)
|
|
68
|
+
}
|
|
61
69
|
if (Object.hasOwn(record, "editDiff")) {
|
|
62
70
|
const value = record.editDiff
|
|
63
71
|
if (isEditDiffMode(value)) config.editDiff = value
|
package/src/cwd.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import * as chrome from "./chrome.js"
|
|
5
5
|
import { formatGitState, type GitState } from "./git-state.js"
|
|
6
|
-
import type { Sibling } from "./
|
|
6
|
+
import type { Sibling } from "./siblings.js"
|
|
7
7
|
|
|
8
8
|
/** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
|
|
9
9
|
const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
|
|
@@ -88,21 +88,22 @@ export function splitRow(left: string, rightSegments: string[], width: number, s
|
|
|
88
88
|
function siblingColor(status: string): "success" | "warning" | "error" | "accent" | "muted" {
|
|
89
89
|
if (status === "idle" || status === "done") return "success"
|
|
90
90
|
if (status === "working") return "accent"
|
|
91
|
-
if (status === "blocked") return "error"
|
|
91
|
+
if (status === "blocked" || status === "error") return "error"
|
|
92
92
|
return "muted"
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
export type StatusLineSources = {
|
|
96
96
|
gitState: () => GitState | undefined
|
|
97
97
|
siblings: () => Sibling[] | undefined
|
|
98
|
-
|
|
98
|
+
/** Fires when git state or the sibling list may have changed; the widget re-renders. */
|
|
99
|
+
onChange: (listener: () => void) => () => void
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): void {
|
|
102
103
|
ctx.ui.setWidget(
|
|
103
104
|
"tw-cwd",
|
|
104
105
|
(tui, theme) => {
|
|
105
|
-
const unsub = sources.
|
|
106
|
+
const unsub = sources.onChange(() => tui.requestRender())
|
|
106
107
|
return {
|
|
107
108
|
dispose: unsub,
|
|
108
109
|
invalidate() {},
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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/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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai"
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
3
|
+
import { showBackgroundOverlay } from "./background-overlay.js"
|
|
2
4
|
import { installCodeBox } from "./code-box.js"
|
|
5
|
+
import { isPrCreateCommand } from "./prs.js"
|
|
6
|
+
import { parsePrFilter, prsToolResult, runPrsCommand, type PrFilter } from "./prs-command.js"
|
|
3
7
|
import { createSession, type Session } from "./session.js"
|
|
4
8
|
import { installToolRenderers, stopLiveWatch } from "./tools.js"
|
|
5
9
|
import { installTranscript } from "./transcript.js"
|
|
@@ -21,9 +25,61 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
21
25
|
let active: Session | undefined
|
|
22
26
|
let busy = false
|
|
23
27
|
|
|
28
|
+
// Background processes: `/bg` and a shortcut (default ctrl+b, see `backgroundShortcut`). The
|
|
29
|
+
// shortcut is registered once per key at load, since pi has no unregister; it reads the active
|
|
30
|
+
// session's config when pressed.
|
|
31
|
+
const openBackground = async (ctx: ExtensionContext) => {
|
|
32
|
+
if (!active?.background) return
|
|
33
|
+
await showBackgroundOverlay(ctx, active.background, active.backgroundShortcut ?? undefined)
|
|
34
|
+
}
|
|
35
|
+
pi.registerCommand("bg", { description: "Show processes left running by bash tool calls", handler: (_args, ctx) => openBackground(ctx) })
|
|
36
|
+
// Open PRs for the repo in cwd: `/prs [mine|review|all]` and the `list_open_prs` tool.
|
|
37
|
+
pi.registerCommand("prs", {
|
|
38
|
+
description: "Open PRs in this repo that I authored or that need my review",
|
|
39
|
+
handler: (args, ctx) => runPrsCommand(args, ctx, pi, active?.prs),
|
|
40
|
+
})
|
|
41
|
+
pi.registerTool({
|
|
42
|
+
name: "list_open_prs",
|
|
43
|
+
label: "List Open PRs",
|
|
44
|
+
description: "List open pull requests in the current repository that the user authored or has been asked to review (via the gh CLI).",
|
|
45
|
+
parameters: Type.Object({
|
|
46
|
+
filter: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("mine"), Type.Literal("review")], { description: "all (default), mine, or review (waiting on my review)" })),
|
|
47
|
+
}),
|
|
48
|
+
async execute(_toolCallId, params) {
|
|
49
|
+
await active?.prs?.refresh()
|
|
50
|
+
const result = prsToolResult(active?.prs, (params.filter as PrFilter | undefined) ?? parsePrFilter(""))
|
|
51
|
+
const text = result.prs.length === 0
|
|
52
|
+
? `No open PRs${result.repo ? ` in ${result.repo}` : ""}${result.error ? ` (${result.error})` : ""}.`
|
|
53
|
+
: result.prs.map((pr) => `#${pr.number} [${pr.kind}${pr.isDraft ? ", draft" : ""}${pr.ciFailing ? ", CI failing" : ""}] ${pr.title} — ${pr.url}`).join("\n")
|
|
54
|
+
return { content: [{ type: "text", text }], details: result }
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
const registeredShortcuts = new Set<string>()
|
|
58
|
+
const ensureShortcut = () => {
|
|
59
|
+
const key = active?.backgroundShortcut
|
|
60
|
+
if (!key || registeredShortcuts.has(key) || typeof pi.registerShortcut !== "function") return
|
|
61
|
+
registeredShortcuts.add(key)
|
|
62
|
+
pi.registerShortcut(key as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
63
|
+
description: "Show background processes",
|
|
64
|
+
handler: (ctx) => (active?.backgroundShortcut === key ? openBackground(ctx) : undefined),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
24
68
|
pi.on("session_start", (_event, ctx) => {
|
|
25
69
|
active?.dispose()
|
|
26
70
|
active = createSession(pi, ctx, { busy: () => busy })
|
|
71
|
+
ensureShortcut()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Commands that create a PR: remember the URL they print so the chips follow the session
|
|
75
|
+
// even when cwd is another repo (worktrees) or no repo at all.
|
|
76
|
+
const prCreateCalls = new Map<string, true>()
|
|
77
|
+
pi.on("tool_execution_start", (event) => {
|
|
78
|
+
active?.background?.toolStart(event.toolCallId, event.toolName)
|
|
79
|
+
const command = (event.args as { command?: unknown } | undefined)?.command
|
|
80
|
+
if ((event.toolName === "bash" || event.toolName === "powershell") && typeof command === "string" && isPrCreateCommand(command)) {
|
|
81
|
+
prCreateCalls.set(event.toolCallId, true)
|
|
82
|
+
}
|
|
27
83
|
})
|
|
28
84
|
|
|
29
85
|
pi.on("agent_start", () => {
|
|
@@ -37,7 +93,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
37
93
|
active?.pin("turn_end")
|
|
38
94
|
active?.refreshGit()
|
|
39
95
|
})
|
|
40
|
-
pi.on("tool_execution_end", () => {
|
|
96
|
+
pi.on("tool_execution_end", (event) => {
|
|
97
|
+
active?.background?.toolEnd(event.toolCallId, event.toolName)
|
|
98
|
+
if (prCreateCalls.delete(event.toolCallId) && !event.isError) {
|
|
99
|
+
const content = (event.result as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content ?? []
|
|
100
|
+
active?.notePrUrls(content.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : "")).join("\n"))
|
|
101
|
+
}
|
|
41
102
|
active?.pin("tool_execution_end")
|
|
42
103
|
active?.refreshGit()
|
|
43
104
|
})
|
package/src/listeners.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** A tiny change-notification set shared by the git and sibling sources. */
|
|
2
|
+
export function createListeners() {
|
|
3
|
+
const listeners = new Set<() => void>()
|
|
4
|
+
return {
|
|
5
|
+
notify() {
|
|
6
|
+
for (const listener of listeners) {
|
|
7
|
+
try {
|
|
8
|
+
listener()
|
|
9
|
+
} catch {
|
|
10
|
+
// One listener's failure must not starve the others.
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
add(listener: () => void) {
|
|
15
|
+
listeners.add(listener)
|
|
16
|
+
return () => listeners.delete(listener)
|
|
17
|
+
},
|
|
18
|
+
clear() {
|
|
19
|
+
listeners.clear()
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|