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
package/dist/rex.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { createListeners } from "./listeners.js";
|
|
4
|
+
export function createRexState() {
|
|
5
|
+
return { layout: { tabs: new Map(), blocks: new Map() }, status: new Map() };
|
|
6
|
+
}
|
|
7
|
+
const TERMINAL_CREATOR = "com.superlogical.terminal";
|
|
8
|
+
/** Parses `rex window ls -s SESSION --json`. */
|
|
9
|
+
export function parseWindowList(stdout) {
|
|
10
|
+
const data = JSON.parse(stdout);
|
|
11
|
+
if (!Array.isArray(data.windows))
|
|
12
|
+
throw new Error("invalid rex window list");
|
|
13
|
+
const layout = { tabs: new Map(), blocks: new Map() };
|
|
14
|
+
for (const window of data.windows) {
|
|
15
|
+
const windowId = typeof window.window_id === "string" ? window.window_id : undefined;
|
|
16
|
+
if (!windowId)
|
|
17
|
+
continue;
|
|
18
|
+
layout.tabs.set(windowId, typeof window.label === "string" ? window.label : "");
|
|
19
|
+
for (const layer of window.layers ?? []) {
|
|
20
|
+
for (const block of layer.blocks ?? []) {
|
|
21
|
+
if (typeof block.block_id !== "string")
|
|
22
|
+
continue;
|
|
23
|
+
if (block.creator_name !== undefined && block.creator_name !== TERMINAL_CREATOR)
|
|
24
|
+
continue;
|
|
25
|
+
layout.blocks.set(block.block_id, windowId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return layout;
|
|
30
|
+
}
|
|
31
|
+
/** Parses `rex block call -b BLOCK program_status`; returns the root record, if any. */
|
|
32
|
+
export function parseProgramStatus(stdout) {
|
|
33
|
+
const data = JSON.parse(stdout);
|
|
34
|
+
if (!Array.isArray(data.records))
|
|
35
|
+
throw new Error("invalid rex program status");
|
|
36
|
+
const root = data.records.find((record) => record.id === undefined || record.id === "");
|
|
37
|
+
return root ? toRecord(root) : undefined;
|
|
38
|
+
}
|
|
39
|
+
function toRecord(raw) {
|
|
40
|
+
if (typeof raw.state !== "string")
|
|
41
|
+
return undefined;
|
|
42
|
+
const record = { state: raw.state };
|
|
43
|
+
const app = typeof raw.effective_app === "string" ? raw.effective_app : typeof raw.app === "string" ? raw.app : undefined;
|
|
44
|
+
if (app)
|
|
45
|
+
record.app = app;
|
|
46
|
+
if (typeof raw.msg === "string")
|
|
47
|
+
record.msg = raw.msg;
|
|
48
|
+
return record;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Applies one line of `rex events --json` to the state. Returns what changed: `status` (a pane's
|
|
52
|
+
* record) or `layout` (the caller should re-read the window list). Unknown events are ignored.
|
|
53
|
+
*/
|
|
54
|
+
export function applyRexEvent(state, line) {
|
|
55
|
+
// Shape: {"type":"session_event","payload":{"session_id":…,"event":{"type":…,"payload":{…}}}}
|
|
56
|
+
let outer;
|
|
57
|
+
try {
|
|
58
|
+
outer = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if (outer.type !== "session_event")
|
|
64
|
+
return undefined;
|
|
65
|
+
const event = outer.payload?.event;
|
|
66
|
+
const payload = event?.payload ?? {};
|
|
67
|
+
switch (event?.type) {
|
|
68
|
+
case "block_event": {
|
|
69
|
+
const blockId = payload.block_id;
|
|
70
|
+
if (typeof blockId !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
const inner = (payload.payload ?? {});
|
|
73
|
+
if (payload.name === "program_status_changed") {
|
|
74
|
+
if (inner.id !== "" && inner.id !== undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
const record = toRecord((inner.record ?? {}));
|
|
77
|
+
if (!record)
|
|
78
|
+
return undefined;
|
|
79
|
+
state.status.set(blockId, record);
|
|
80
|
+
return "status";
|
|
81
|
+
}
|
|
82
|
+
if (payload.name === "program_status_removed") {
|
|
83
|
+
const ids = Array.isArray(inner.ids) ? inner.ids : [];
|
|
84
|
+
if (!ids.includes(""))
|
|
85
|
+
return undefined;
|
|
86
|
+
return state.status.delete(blockId) ? "status" : undefined;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
case "block_closed": {
|
|
91
|
+
const blockId = payload.block_id;
|
|
92
|
+
if (typeof blockId !== "string")
|
|
93
|
+
return undefined;
|
|
94
|
+
state.status.delete(blockId);
|
|
95
|
+
state.layout.blocks.delete(blockId);
|
|
96
|
+
return "layout";
|
|
97
|
+
}
|
|
98
|
+
case "block_created":
|
|
99
|
+
case "window_label_changed":
|
|
100
|
+
case "window_created":
|
|
101
|
+
case "window_closed":
|
|
102
|
+
case "session_label_changed":
|
|
103
|
+
return "layout";
|
|
104
|
+
default:
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** A Rex auto-label such as `Window 395a96cb` says nothing about the work; prefer the program. */
|
|
109
|
+
const AUTO_LABEL = /^Window [0-9a-f]+$/i;
|
|
110
|
+
/**
|
|
111
|
+
* Siblings are named after their tab, like Herdr siblings. A pane in the same tab as this pi
|
|
112
|
+
* would share our tab's name, so it is named after its program instead.
|
|
113
|
+
*/
|
|
114
|
+
export function listRexSiblings(state, selfBlock) {
|
|
115
|
+
if (!selfBlock)
|
|
116
|
+
return undefined;
|
|
117
|
+
const selfWindow = state.layout.blocks.get(selfBlock);
|
|
118
|
+
const parts = [];
|
|
119
|
+
for (const [blockId, record] of state.status) {
|
|
120
|
+
if (blockId === selfBlock)
|
|
121
|
+
continue;
|
|
122
|
+
const windowId = state.layout.blocks.get(blockId);
|
|
123
|
+
const label = windowId && windowId !== selfWindow ? state.layout.tabs.get(windowId) : undefined;
|
|
124
|
+
const name = label && !AUTO_LABEL.test(label) ? label : record.app;
|
|
125
|
+
if (!name)
|
|
126
|
+
continue;
|
|
127
|
+
parts.push({ name, status: record.state });
|
|
128
|
+
}
|
|
129
|
+
if (parts.length === 0)
|
|
130
|
+
return undefined;
|
|
131
|
+
return parts;
|
|
132
|
+
}
|
|
133
|
+
const REX_EXEC_TIMEOUT_MS = 3000;
|
|
134
|
+
const REX_RESTART_MS = 10000;
|
|
135
|
+
const REX_LAYOUT_DEBOUNCE_MS = 150;
|
|
136
|
+
export function createRexSiblings(deps) {
|
|
137
|
+
const env = deps.env ?? process.env;
|
|
138
|
+
const spawn = deps.spawn ?? ((command, args) => nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }));
|
|
139
|
+
const session = env.REX_SESSION ?? "";
|
|
140
|
+
const selfBlock = env.REX_BLOCK;
|
|
141
|
+
let alive = true;
|
|
142
|
+
let child;
|
|
143
|
+
let restartTimer;
|
|
144
|
+
let layoutTimer;
|
|
145
|
+
let layoutInFlight = false;
|
|
146
|
+
let layoutDirty = false;
|
|
147
|
+
let siblings;
|
|
148
|
+
const state = createRexState();
|
|
149
|
+
const listeners = createListeners();
|
|
150
|
+
const recompute = () => {
|
|
151
|
+
if (!alive)
|
|
152
|
+
return;
|
|
153
|
+
const next = listRexSiblings(state, selfBlock);
|
|
154
|
+
const changed = JSON.stringify(next) !== JSON.stringify(siblings);
|
|
155
|
+
siblings = next;
|
|
156
|
+
if (changed)
|
|
157
|
+
listeners.notify();
|
|
158
|
+
};
|
|
159
|
+
const refreshLayout = async (seedStatus) => {
|
|
160
|
+
if (!alive)
|
|
161
|
+
return;
|
|
162
|
+
if (layoutInFlight) {
|
|
163
|
+
layoutDirty = true;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
layoutInFlight = true;
|
|
167
|
+
try {
|
|
168
|
+
const run = await deps.pi.exec("rex", ["window", "ls", "-s", session, "--json"], { timeout: REX_EXEC_TIMEOUT_MS });
|
|
169
|
+
if (!alive)
|
|
170
|
+
return;
|
|
171
|
+
if (run.code !== 0 || run.killed)
|
|
172
|
+
return;
|
|
173
|
+
const layout = parseWindowList(run.stdout);
|
|
174
|
+
state.layout = layout;
|
|
175
|
+
for (const blockId of [...state.status.keys()]) {
|
|
176
|
+
if (!layout.blocks.has(blockId))
|
|
177
|
+
state.status.delete(blockId);
|
|
178
|
+
}
|
|
179
|
+
if (seedStatus) {
|
|
180
|
+
await Promise.all([...layout.blocks.keys()]
|
|
181
|
+
.filter((blockId) => blockId !== selfBlock)
|
|
182
|
+
.map(async (blockId) => {
|
|
183
|
+
try {
|
|
184
|
+
const status = await deps.pi.exec("rex", ["block", "call", "-s", session, "-b", blockId, "program_status"], { timeout: REX_EXEC_TIMEOUT_MS });
|
|
185
|
+
if (!alive || status.code !== 0 || status.killed)
|
|
186
|
+
return;
|
|
187
|
+
const record = parseProgramStatus(status.stdout);
|
|
188
|
+
if (record)
|
|
189
|
+
state.status.set(blockId, record);
|
|
190
|
+
else
|
|
191
|
+
state.status.delete(blockId);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A pane that vanished mid-seed shows up via events later.
|
|
195
|
+
}
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
recompute();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Keep the last known layout; the next layout event retries.
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
layoutInFlight = false;
|
|
205
|
+
if (layoutDirty && alive) {
|
|
206
|
+
layoutDirty = false;
|
|
207
|
+
void refreshLayout(false);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const scheduleLayoutRefresh = () => {
|
|
212
|
+
if (!alive || layoutTimer)
|
|
213
|
+
return;
|
|
214
|
+
layoutTimer = setTimeout(() => {
|
|
215
|
+
layoutTimer = undefined;
|
|
216
|
+
void refreshLayout(false);
|
|
217
|
+
}, REX_LAYOUT_DEBOUNCE_MS);
|
|
218
|
+
};
|
|
219
|
+
const start = () => {
|
|
220
|
+
if (!alive)
|
|
221
|
+
return;
|
|
222
|
+
restartTimer = undefined;
|
|
223
|
+
let proc;
|
|
224
|
+
try {
|
|
225
|
+
proc = spawn("rex", ["events", "-s", session, "--json"]);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
scheduleRestart();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
child = proc;
|
|
232
|
+
if (proc.stdout) {
|
|
233
|
+
const lines = createInterface({ input: proc.stdout });
|
|
234
|
+
lines.on("line", (line) => {
|
|
235
|
+
if (!alive || child !== proc)
|
|
236
|
+
return;
|
|
237
|
+
const effect = applyRexEvent(state, line);
|
|
238
|
+
if (effect === "status")
|
|
239
|
+
recompute();
|
|
240
|
+
else if (effect === "layout") {
|
|
241
|
+
recompute();
|
|
242
|
+
scheduleLayoutRefresh();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
proc.on("error", () => { if (child === proc)
|
|
247
|
+
onExit(); });
|
|
248
|
+
proc.on("exit", () => { if (child === proc)
|
|
249
|
+
onExit(); });
|
|
250
|
+
void refreshLayout(true);
|
|
251
|
+
};
|
|
252
|
+
const onExit = () => {
|
|
253
|
+
child = undefined;
|
|
254
|
+
if (!alive)
|
|
255
|
+
return;
|
|
256
|
+
state.status.clear();
|
|
257
|
+
recompute();
|
|
258
|
+
scheduleRestart();
|
|
259
|
+
};
|
|
260
|
+
const scheduleRestart = () => {
|
|
261
|
+
if (!alive || restartTimer)
|
|
262
|
+
return;
|
|
263
|
+
restartTimer = setTimeout(start, REX_RESTART_MS);
|
|
264
|
+
};
|
|
265
|
+
start();
|
|
266
|
+
return {
|
|
267
|
+
get: () => siblings,
|
|
268
|
+
onChange: listeners.add,
|
|
269
|
+
dispose() {
|
|
270
|
+
if (!alive)
|
|
271
|
+
return;
|
|
272
|
+
alive = false;
|
|
273
|
+
if (restartTimer)
|
|
274
|
+
clearTimeout(restartTimer);
|
|
275
|
+
if (layoutTimer)
|
|
276
|
+
clearTimeout(layoutTimer);
|
|
277
|
+
restartTimer = undefined;
|
|
278
|
+
layoutTimer = undefined;
|
|
279
|
+
const proc = child;
|
|
280
|
+
child = undefined;
|
|
281
|
+
try {
|
|
282
|
+
proc?.kill();
|
|
283
|
+
}
|
|
284
|
+
catch { /* already gone */ }
|
|
285
|
+
siblings = undefined;
|
|
286
|
+
listeners.clear();
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -7,17 +7,35 @@ import { bindFooterContributionEvents } from "./footer-contribution-events.js";
|
|
|
7
7
|
import { createFooterContributions } from "./footer-contributions.js";
|
|
8
8
|
import { createGitState } from "./git-state.js";
|
|
9
9
|
import { installHeader } from "./header.js";
|
|
10
|
-
import {
|
|
10
|
+
import { createBackgroundTracker } from "./background.js";
|
|
11
|
+
import { backgroundSegment } from "./background-overlay.js";
|
|
12
|
+
import { createPrSource, findPrUrls, prSegment } from "./prs.js";
|
|
11
13
|
import { applyReclaim, reclaimPolicy } from "./reclaim.js";
|
|
14
|
+
import { createSiblingSource } from "./siblings.js";
|
|
12
15
|
import { setSessionCwd } from "./tool-rows.js";
|
|
13
16
|
import { setEditDiffMode } from "./tools.js";
|
|
14
17
|
import { hideGauntletWidgets } from "./tracker.js";
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
+
/** Custom session entry remembering a PR this session created, so a resumed session shows it. */
|
|
19
|
+
export const PR_ENTRY_TYPE = "pi-minimal.pr";
|
|
20
|
+
function readSessionPrs(ctx) {
|
|
21
|
+
const urls = [];
|
|
22
|
+
try {
|
|
23
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
24
|
+
if (entry.type !== "custom" || entry.customType !== PR_ENTRY_TYPE)
|
|
25
|
+
continue;
|
|
26
|
+
const url = entry.data?.url;
|
|
27
|
+
if (typeof url === "string")
|
|
28
|
+
urls.push(url);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// No branch available (tests, odd hosts): start empty.
|
|
33
|
+
}
|
|
34
|
+
return urls;
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
|
-
* Everything pi-minimal owns for one session: config, cwd, the contribution registry, the git
|
|
20
|
-
* Herdr
|
|
37
|
+
* Everything pi-minimal owns for one session: config, cwd, the contribution registry, the git
|
|
38
|
+
* poller, the sibling source (Herdr or Rex), and the header/footer/status-line chrome. Disposal stops every timer and makes
|
|
21
39
|
* late async results inert, so two sessions in one process never share or overwrite each other's
|
|
22
40
|
* state. Non-TUI sessions get a session too, but it only binds the contribution protocol (which
|
|
23
41
|
* replies `not-tui`) and installs no chrome.
|
|
@@ -28,7 +46,7 @@ export function createSession(pi, ctx, deps) {
|
|
|
28
46
|
const contributions = createFooterContributions();
|
|
29
47
|
const stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, id, ctx.mode);
|
|
30
48
|
if (ctx.mode !== "tui") {
|
|
31
|
-
return { id, pin() { }, refreshGit() { }, dispose() { if (!alive)
|
|
49
|
+
return { id, pin() { }, refreshGit() { }, notePrUrls() { }, dispose() { if (!alive)
|
|
32
50
|
return; alive = false; stopContributionEvents(); } };
|
|
33
51
|
}
|
|
34
52
|
const config = loadPiMinimalConfig({ agentDir: getAgentDir(), cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
@@ -44,9 +62,8 @@ export function createSession(pi, ctx, deps) {
|
|
|
44
62
|
const gitState = createGitState(pi, () => ctx.cwd);
|
|
45
63
|
let stopFooter = () => { };
|
|
46
64
|
let siblings;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let herdrInFlight;
|
|
65
|
+
const background = createBackgroundTracker({ pi });
|
|
66
|
+
const prs = createPrSource({ pi, cwd: () => ctx.cwd, sessionPrs: readSessionPrs(ctx) });
|
|
50
67
|
const settleTimers = [];
|
|
51
68
|
const contextPct = () => {
|
|
52
69
|
const usage = ctx.getContextUsage?.();
|
|
@@ -61,10 +78,25 @@ export function createSession(pi, ctx, deps) {
|
|
|
61
78
|
busy: deps.busy,
|
|
62
79
|
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
63
80
|
contextPct,
|
|
81
|
+
prs: (theme) => {
|
|
82
|
+
const snap = prs.get();
|
|
83
|
+
return prSegment(snap.prs, theme, 4, snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined);
|
|
84
|
+
},
|
|
85
|
+
onPrsChange: prs.onChange,
|
|
86
|
+
background: () => backgroundSegment(background.get()),
|
|
87
|
+
onBackgroundChange: background.onChange,
|
|
64
88
|
}, contributions);
|
|
65
89
|
};
|
|
66
90
|
const installStatusLine = () => {
|
|
67
|
-
installCwd(ctx, {
|
|
91
|
+
installCwd(ctx, {
|
|
92
|
+
gitState: gitState.get,
|
|
93
|
+
siblings: () => siblings?.get(),
|
|
94
|
+
onChange(listener) {
|
|
95
|
+
const offGit = gitState.onChange(listener);
|
|
96
|
+
const offSiblings = siblings?.onChange(listener) ?? (() => { });
|
|
97
|
+
return () => { offGit(); offSiblings(); };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
68
100
|
};
|
|
69
101
|
const pin = (reason) => {
|
|
70
102
|
if (!alive)
|
|
@@ -79,45 +111,9 @@ export function createSession(pi, ctx, deps) {
|
|
|
79
111
|
ctx.ui.setWidget("tw-cwd", undefined);
|
|
80
112
|
installStatusLine();
|
|
81
113
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (herdrTimer)
|
|
86
|
-
clearInterval(herdrTimer);
|
|
87
|
-
herdrPeriod = periodMs;
|
|
88
|
-
herdrTimer = setInterval(() => { void probeHerdr(); }, periodMs);
|
|
89
|
-
};
|
|
90
|
-
const probeHerdr = async () => {
|
|
91
|
-
if (!alive || herdrInFlight)
|
|
92
|
-
return;
|
|
93
|
-
const ac = new AbortController();
|
|
94
|
-
herdrInFlight = ac;
|
|
95
|
-
try {
|
|
96
|
-
const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal });
|
|
97
|
-
if (!alive)
|
|
98
|
-
return;
|
|
99
|
-
const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal });
|
|
100
|
-
if (!alive)
|
|
101
|
-
return;
|
|
102
|
-
if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
|
|
103
|
-
siblings = undefined;
|
|
104
|
-
scheduleHerdr(HERDR_SLOW_MS);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
siblings = listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), process.env.HERDR_PANE_ID, process.env.HERDR_TAB_ID);
|
|
108
|
-
scheduleHerdr(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS);
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
if (!alive)
|
|
112
|
-
return;
|
|
113
|
-
siblings = undefined;
|
|
114
|
-
scheduleHerdr(HERDR_SLOW_MS);
|
|
115
|
-
}
|
|
116
|
-
finally {
|
|
117
|
-
if (herdrInFlight === ac)
|
|
118
|
-
herdrInFlight = undefined;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
114
|
+
// The source is chosen from the environment (HERDR_ENV, else REX_SESSION/REX_BLOCK); start it
|
|
115
|
+
// before the status line so its onChange subscription is live.
|
|
116
|
+
siblings = createSiblingSource({ pi, spawn: deps.spawn });
|
|
121
117
|
// Install once, then re-assert shortly after: other extensions' session_start handlers may draw after ours.
|
|
122
118
|
installHeader(ctx);
|
|
123
119
|
installChromeFooter();
|
|
@@ -128,24 +124,41 @@ export function createSession(pi, ctx, deps) {
|
|
|
128
124
|
ctx.ui.setWorkingIndicator();
|
|
129
125
|
for (const delay of [0, 50])
|
|
130
126
|
settleTimers.push(setTimeout(() => pin("session_settle"), delay));
|
|
131
|
-
if (process.env.HERDR_ENV === "1")
|
|
132
|
-
void probeHerdr();
|
|
133
127
|
return {
|
|
134
128
|
id,
|
|
129
|
+
background,
|
|
130
|
+
backgroundShortcut: config.backgroundShortcut,
|
|
131
|
+
prs,
|
|
132
|
+
notePrUrls(text) {
|
|
133
|
+
if (!alive)
|
|
134
|
+
return;
|
|
135
|
+
const known = new Set(prs.sessionPrs());
|
|
136
|
+
for (const url of findPrUrls(text)) {
|
|
137
|
+
if (known.has(url))
|
|
138
|
+
continue;
|
|
139
|
+
prs.addSessionPr(url);
|
|
140
|
+
try {
|
|
141
|
+
pi.appendEntry?.(PR_ENTRY_TYPE, { url });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// A session that cannot accept entries just forgets the PR on restart.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
135
148
|
pin,
|
|
136
|
-
refreshGit(force) { if (alive)
|
|
137
|
-
void gitState.refresh(force);
|
|
149
|
+
refreshGit(force) { if (alive) {
|
|
150
|
+
void gitState.refresh(force);
|
|
151
|
+
void prs.refresh();
|
|
152
|
+
} },
|
|
138
153
|
dispose() {
|
|
139
154
|
if (!alive)
|
|
140
155
|
return;
|
|
141
156
|
alive = false;
|
|
142
157
|
for (const timer of settleTimers.splice(0))
|
|
143
158
|
clearTimeout(timer);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
herdrInFlight?.abort();
|
|
148
|
-
herdrInFlight = undefined;
|
|
159
|
+
background.dispose();
|
|
160
|
+
prs.dispose();
|
|
161
|
+
siblings?.dispose();
|
|
149
162
|
siblings = undefined;
|
|
150
163
|
gitState.dispose();
|
|
151
164
|
stopFooter();
|
package/dist/siblings.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHerdrSiblings } from "./herdr.js";
|
|
2
|
+
import { createRexSiblings } from "./rex.js";
|
|
3
|
+
/**
|
|
4
|
+
* Picks the host the session runs in. Herdr wins when both are present (Rex inside a Herdr pane):
|
|
5
|
+
* Herdr knows every agent it launched, Rex only the programs that report status.
|
|
6
|
+
*/
|
|
7
|
+
export function createSiblingSource(deps) {
|
|
8
|
+
const env = deps.env ?? process.env;
|
|
9
|
+
if (env.HERDR_ENV === "1")
|
|
10
|
+
return createHerdrSiblings(deps.pi, env);
|
|
11
|
+
if (env.REX_SESSION && env.REX_BLOCK)
|
|
12
|
+
return createRexSiblings({ pi: deps.pi, env, spawn: deps.spawn });
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
-
"@earendil-works/pi-tui": "*"
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
32
|
+
"@earendil-works/pi-ai": "*"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
"@types/node": "*",
|
|
38
39
|
"eslint": "^10.11.0",
|
|
39
40
|
"typescript": "*",
|
|
40
|
-
"typescript-eslint": "^8.70.1"
|
|
41
|
+
"typescript-eslint": "^8.70.1",
|
|
42
|
+
"@earendil-works/pi-ai": "^1.4.0"
|
|
41
43
|
},
|
|
42
44
|
"scripts": {
|
|
43
45
|
"build": "tsc -p tsconfig.json",
|
package/release-notes.md
CHANGED
|
@@ -1,57 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
Rex support and a footer hints line that carries live state: sibling agents from Rex as well as
|
|
2
|
+
Herdr, background processes left by bash calls, and the repo's open PRs as clickable chips.
|
|
3
3
|
|
|
4
4
|
### Added
|
|
5
5
|
|
|
6
|
-
- `
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
- Open PRs: the footer shows `#1675 #1670 +3` chips, right-aligned on the hints line below the editor, for open PRs in the repo of cwd that you
|
|
7
|
+
authored or were asked to review, each an OSC 8 hyperlink to the PR, coloured accent (mine), dim
|
|
8
|
+
(my draft), amber (needs my review) or red (failing CI / changes requested). `/prs [mine|review|all]`
|
|
9
|
+
lists them with open / paste URL / checkout actions; `list_open_prs` tool for the model. Uses the
|
|
10
|
+
`gh` CLI; works on GitHub.com and GitHub Enterprise. PRs created by the session (`gh pr create`,
|
|
11
|
+
`gt submit`) are remembered as session entries and shown from any directory, as `repo#n` when
|
|
12
|
+
outside the cwd repo, until merged. New peer dependency `@earendil-works/pi-ai` (supplied by pi)
|
|
13
|
+
for the tool schema.
|
|
14
|
+
- Background processes: `⚙ N bg` after the key hints in the footer while processes left behind by `bash` tool
|
|
15
|
+
calls are running; `ctrl+b` / `/bg` opens an overlay listing each one with running/ended state and
|
|
16
|
+
duration, `k` kills the selection, `K` its process group. Attribution diffs `ps` around each bash
|
|
17
|
+
call using pi's detached process groups, so no command is rewritten. New `backgroundShortcut`
|
|
18
|
+
setting (`"ctrl+b"`; `null` disables the key).
|
|
19
|
+
|
|
20
|
+
- Rex siblings: inside a Rex session (`REX_SESSION` + `REX_BLOCK`) the status line shows other
|
|
21
|
+
panes' agent state, read from Rex's Program Status Protocol (OSC 7501) over one
|
|
22
|
+
`rex events --json` stream instead of polling. Needs a reporter in each pi (an extension that
|
|
23
|
+
writes OSC 7501 on `agent_start` / `agent_settled`); programs such as claude-code or terraform
|
|
24
|
+
that already speak the protocol appear as well. `error` renders like `blocked`.
|
|
14
25
|
|
|
15
26
|
### Changed
|
|
16
27
|
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end, tool end). The 200 ms interval that cleared pi-gauntlet's widgets five times a
|
|
21
|
-
second is gone; it never reinstalled the header or footer anyway.
|
|
22
|
-
- All session-scoped state (config, cwd, the footer contribution registry, the git and
|
|
23
|
-
Herdr pollers, the chrome) now lives on one `Session` object created at session start
|
|
24
|
-
and disposed at shutdown or when the next session starts. Late async results from a
|
|
25
|
-
disposed session are dropped instead of drawing into the new one.
|
|
26
|
-
- The compact, generic and codemode tool rows are now three small strategies over one
|
|
27
|
-
row lifecycle (`createRowRenderers`): running state, live spinner, status colours,
|
|
28
|
-
the error line and expansion are implemented once. Edit-diff colouring moved to
|
|
29
|
-
`src/edit-diff.ts`. No visible change beyond the two items above.
|
|
30
|
-
- Internal renames for clarity: `TrackerSnapshot` → `GauntletProjection`,
|
|
31
|
-
`readSnapshot` → `readGauntletProjection`, the tracker's `TaskStatus` →
|
|
32
|
-
`GauntletTaskStatus`, and the slot's authority symbol is `ownerToken`. No wire change.
|
|
33
|
-
- `doc/footer-contributions.md` now states precisely when the legacy projection is replaced
|
|
34
|
-
(only while a plan line renders), how control characters are sanitised (VT stripped,
|
|
35
|
-
C0/C1/bidi replaced with spaces), and that `inReview`/`blocked` are bounded individually.
|
|
36
|
-
|
|
37
|
-
### Fixed
|
|
38
|
-
|
|
39
|
-
- The working message is cleared on session shutdown; it used to drop the context
|
|
40
|
-
before clearing and leave the last status on screen.
|
|
41
|
-
- Git state reads the working directory once per refresh, so branch and dirty count
|
|
42
|
-
always describe the same tree, and one throwing change listener no longer blocks
|
|
43
|
-
the others.
|
|
28
|
+
- Sibling status now comes from a `SiblingSource` (`src/siblings.ts`) picked from the environment;
|
|
29
|
+
the Herdr poller moved from `session.ts` into `herdr.ts` unchanged. Herdr wins when both hosts
|
|
30
|
+
are present. The status line re-renders when the sibling list changes, not only on git changes.
|
|
44
31
|
|
|
45
32
|
## What's Changed
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
33
|
+
* feat: Rex siblings on the status line via OSC 7501 event stream by @raviy06 in https://github.com/raviy06/pi-minimal/pull/22
|
|
34
|
+
* feat: background processes on the status line with a ctrl+b overlay by @raviy06 in https://github.com/raviy06/pi-minimal/pull/23
|
|
35
|
+
* feat: open PR chips and background count on the footer hints line by @raviy06 in https://github.com/raviy06/pi-minimal/pull/24
|
|
49
36
|
|
|
50
37
|
|
|
51
|
-
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.
|
|
38
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.5.0...v0.6.0
|
|
52
39
|
|
|
53
40
|
---
|
|
54
41
|
**Install / upgrade**
|
|
55
42
|
```bash
|
|
56
|
-
pi install npm:pi-minimal@0.
|
|
43
|
+
pi install npm:pi-minimal@0.6.0 # or: pi update --extensions
|
|
57
44
|
```
|