pi-jar 0.1.6 → 0.1.7
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/README.md +17 -5
- package/docs/DIFF-REVIEW.md +11 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +98 -26
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +72 -57
- package/src/delegate.ts +284 -87
- package/src/diff-view.ts +141 -30
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
package/src/diff-view.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { coarseLineDiff, diffHunkOffsets, diffLineCount, diffRowCount, diffRowsWindow, lineDiff, MAX_REVIEW_DIFF_LINES, type ChangeTracker, type DiffOp, type DiffRow, type FileChange } from "./changes.ts";
|
|
4
4
|
import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
|
|
5
5
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
6
6
|
|
|
@@ -12,20 +12,77 @@ const ACTIONS = [
|
|
|
12
12
|
] as const;
|
|
13
13
|
|
|
14
14
|
const STATUS_MARK = { added: "A", modified: "M", deleted: "D" } as const;
|
|
15
|
+
const PREVIEW_WINDOW = 1500;
|
|
16
|
+
const SUMMARY_AFTER_BYTES = 256 * 1024;
|
|
17
|
+
const SEARCH_HINT = "/ filter · Enter apply · Esc clear";
|
|
18
|
+
|
|
19
|
+
function wrapPath(path: string, width: number): string[] {
|
|
20
|
+
const points = Array.from(path);
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
let line = "";
|
|
23
|
+
for (const point of points) {
|
|
24
|
+
if (line && visibleWidth(line + point) > width) { lines.push(line); line = ""; }
|
|
25
|
+
line += point;
|
|
26
|
+
}
|
|
27
|
+
if (line || !lines.length) lines.push(line);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiffSearchResult { files: FileChange[]; selected: number }
|
|
32
|
+
export function filterChanges(changes: readonly FileChange[], query: string): DiffSearchResult {
|
|
33
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
34
|
+
if (!needle) return { files: [...changes], selected: 0 };
|
|
35
|
+
return { files: changes.filter(change => `${change.path}\n${change.rel}\n${change.status}`.toLocaleLowerCase().includes(needle)), selected: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fullReviewRequired(change: FileChange): boolean {
|
|
39
|
+
return change.diff === null || Buffer.byteLength(change.before) + Buffer.byteLength(change.after) > SUMMARY_AFTER_BYTES
|
|
40
|
+
|| diffLineCount(change.before) + diffLineCount(change.after) > PREVIEW_WINDOW;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fullReviewSupported(change: FileChange): boolean {
|
|
44
|
+
return diffLineCount(change.before) + diffLineCount(change.after) <= MAX_REVIEW_DIFF_LINES;
|
|
45
|
+
}
|
|
15
46
|
|
|
16
47
|
/** Keep indentation but drop escape sequences and control characters that would break layout. */
|
|
17
48
|
export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, " ")
|
|
18
49
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, "·");
|
|
19
50
|
|
|
20
51
|
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
21
|
-
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string,
|
|
53
|
+
options: { fullReview?: boolean; scroll?: number; rows?: number; unified?: boolean; ops?: readonly DiffOp[]; coarse?: boolean } = {}): string[] {
|
|
54
|
+
const large = fullReviewRequired(change);
|
|
55
|
+
if (large && (!options.fullReview || !fullReviewSupported(change))) return [
|
|
56
|
+
fg("warning", `Summary preview · +${change.added} −${change.removed} · ${(Buffer.byteLength(change.before) + Buffer.byteLength(change.after)) / 1024 | 0} KiB`),
|
|
57
|
+
fg("dim", fullReviewSupported(change) ? "Press v for a windowed full review, or keep this summary." : "Full review unavailable: preview is bounded to 100,000 combined lines."),
|
|
58
|
+
fg("dim", "Older/binary/over-1 MiB files are not tracked for safe revert.")
|
|
59
|
+
];
|
|
60
|
+
const exact = options.ops ? change.diff : (change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after)));
|
|
61
|
+
const ops = options.ops ?? exact ?? coarseLineDiff(change.before, change.after);
|
|
62
|
+
const rows = Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW);
|
|
63
|
+
const scroll = Math.max(0, options.scroll ?? 0);
|
|
64
|
+
const visible = diffRowsWindow(ops, 3, scroll, rows);
|
|
65
|
+
if (!visible.length) return [fg("dim", scroll ? "End of diff." : "No textual changes.")];
|
|
66
|
+
const bounded = options.coarse ?? (change.diff === null || (!options.ops && exact === undefined));
|
|
67
|
+
const gutter = Math.max(3, String(Math.max(...visible.map((row) => Math.max(row.oldLine ?? 0, row.newLine ?? 0)))).length);
|
|
27
68
|
const num = (value?: number) => (value ? String(value) : "").padStart(gutter);
|
|
28
|
-
|
|
69
|
+
if (options.unified === false) {
|
|
70
|
+
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
71
|
+
return [
|
|
72
|
+
truncateToWidth(fg("accent", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"),
|
|
73
|
+
...(bounded ? [fg("warning", "Coarse preview: replacement groups are approximate.")] : []),
|
|
74
|
+
...visible.map(row => {
|
|
75
|
+
if (row.kind === "hunk") return fg("accent", row.text);
|
|
76
|
+
const oldText = row.kind === "add" ? "" : `${num(row.oldLine)} ${row.kind === "remove" ? "-" : " "}${safeLine(row.text)}`;
|
|
77
|
+
const newText = row.kind === "remove" ? "" : `${num(row.newLine)} ${row.kind === "add" ? "+" : " "}${safeLine(row.text)}`;
|
|
78
|
+
const left = truncateToWidth(oldText, column, "…", true);
|
|
79
|
+
const right = truncateToWidth(newText, column, "…", true);
|
|
80
|
+
return (row.kind === "remove" ? fg("error", left) : fg("muted", left)) + fg("dim", " │ ")
|
|
81
|
+
+ (row.kind === "add" ? fg("success", right) : fg("muted", right));
|
|
82
|
+
})
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
const rendered = visible.map((row) => {
|
|
29
86
|
if (row.kind === "hunk") return fg("accent", row.text);
|
|
30
87
|
const text = truncateToWidth(safeLine(row.text), Math.max(1, width - gutter * 2 - 4));
|
|
31
88
|
const prefix = fg("dim", `${num(row.oldLine)} ${num(row.newLine)} `);
|
|
@@ -33,6 +90,9 @@ export function renderDiff(change: FileChange, width: number, fg: (color: string
|
|
|
33
90
|
if (row.kind === "remove") return prefix + fg("error", "-" + text);
|
|
34
91
|
return prefix + fg("muted", " " + text);
|
|
35
92
|
});
|
|
93
|
+
if (bounded) rendered.unshift(fg("warning", "Coarse preview: LCS budget exceeded; full review preserves file order but replacement groups are approximate."));
|
|
94
|
+
if (change.added + change.removed > rows) rendered.push(fg("dim", `Windowed preview at row ${scroll + 1} · PgUp/PgDn or j/k`));
|
|
95
|
+
return rendered;
|
|
36
96
|
}
|
|
37
97
|
|
|
38
98
|
/**
|
|
@@ -47,17 +107,24 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
47
107
|
let selected = 0;
|
|
48
108
|
let scroll = 0;
|
|
49
109
|
let listScroll = 0;
|
|
110
|
+
let query = "";
|
|
111
|
+
let searchMode = false;
|
|
112
|
+
let fullReview = false;
|
|
113
|
+
let unified = true;
|
|
50
114
|
let bodyRows = 0;
|
|
115
|
+
let viewportRows = 1;
|
|
51
116
|
let width = 80;
|
|
52
117
|
let armed: "one" | "all" | undefined;
|
|
53
118
|
let message = "";
|
|
54
119
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
55
120
|
let cache: { key: string; lines: string[] } | undefined;
|
|
121
|
+
let opCache: { key: string; ops: readonly DiffOp[]; hunks: number[]; rows: number; coarse: boolean } | undefined;
|
|
56
122
|
const fg = (color: string, text: string) => theme.fg(color as never, text);
|
|
57
123
|
const refresh = () => {
|
|
58
124
|
changes = tracker.changes();
|
|
59
125
|
cache = undefined;
|
|
60
|
-
|
|
126
|
+
opCache = undefined;
|
|
127
|
+
selected = Math.min(selected, Math.max(0, visibleChanges().length - 1));
|
|
61
128
|
scroll = 0;
|
|
62
129
|
if (!changes.length) done();
|
|
63
130
|
};
|
|
@@ -66,12 +133,26 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
66
133
|
catch (error) { message = "failed: " + (error instanceof Error ? error.message : String(error)); ctx.ui.notify("pi-jar: " + message, "error"); }
|
|
67
134
|
refresh();
|
|
68
135
|
};
|
|
69
|
-
const
|
|
70
|
-
const
|
|
136
|
+
const visibleChanges = () => filterChanges(changes, query).files;
|
|
137
|
+
const currentChange = () => visibleChanges()[selected];
|
|
138
|
+
const select = (index: number) => { selected = Math.max(0, Math.min(visibleChanges().length - 1, index)); scroll = 0; fullReview = false; cache = undefined; opCache = undefined; };
|
|
139
|
+
const scrollBody = (delta: number) => {
|
|
140
|
+
const max = Math.max(0, (opCache?.rows ?? 0) - viewportRows);
|
|
141
|
+
scroll = Math.max(0, Math.min(max, scroll + delta)); cache = undefined;
|
|
142
|
+
};
|
|
71
143
|
const component = {
|
|
72
144
|
invalidate() { cache = undefined; },
|
|
73
145
|
handleInput(data: string) {
|
|
74
|
-
|
|
146
|
+
if (searchMode) {
|
|
147
|
+
if (matchesKey(data, Key.escape)) { searchMode = false; query = ""; selected = 0; listScroll = 0; }
|
|
148
|
+
else if (matchesKey(data, Key.ctrl("u"))) query = "";
|
|
149
|
+
else if (matchesKey(data, Key.enter)) searchMode = false;
|
|
150
|
+
else if (matchesKey(data, Key.backspace)) query = query.slice(0, -1);
|
|
151
|
+
else if (data.length === 1 && data >= " " && data <= "~") query += data;
|
|
152
|
+
selected = Math.min(selected, Math.max(0, visibleChanges().length - 1));
|
|
153
|
+
listScroll = 0; scroll = 0; cache = undefined; opCache = undefined; tui.requestRender(); return;
|
|
154
|
+
}
|
|
155
|
+
const current = currentChange();
|
|
75
156
|
if (armed && (data === "y" || (armed === "one" && data === "r") || (armed === "all" && data === "R"))) {
|
|
76
157
|
const scope = armed; armed = undefined;
|
|
77
158
|
if (scope === "all") run(`reverted ${changes.length} file(s)`, () => { for (const change of changes) tracker.revert(change.path); });
|
|
@@ -79,15 +160,32 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
79
160
|
tui.requestRender(); return;
|
|
80
161
|
}
|
|
81
162
|
armed = undefined;
|
|
163
|
+
if (matchesKey(data, Key.escape) && query) {
|
|
164
|
+
query = ""; selected = 0; listScroll = 0; scroll = 0; fullReview = false; cache = undefined; opCache = undefined;
|
|
165
|
+
tui.requestRender(); return;
|
|
166
|
+
}
|
|
82
167
|
if (matchesKey(data, Key.escape) || data === "q") return done();
|
|
83
|
-
if (data === "
|
|
84
|
-
else if (data === "
|
|
168
|
+
if (data === "/") { searchMode = true; query = ""; }
|
|
169
|
+
else if (data === "n" && query) select(selected + 1);
|
|
170
|
+
else if (data === "N" && query) select(selected - 1);
|
|
171
|
+
else if (data === "t") { unified = !unified; cache = undefined; message = unified ? "Unified diff" : "Side-by-side diff"; }
|
|
172
|
+
else if (data === "v" && current && fullReviewRequired(current) && fullReviewSupported(current)) {
|
|
173
|
+
fullReview = !fullReview; scroll = 0; cache = undefined; opCache = undefined; message = fullReview ? "Full windowed review · LCS budget may label a coarse replacement" : "Summary preview";
|
|
174
|
+
}
|
|
175
|
+
else if (data === "a" && current) run(`accepted ${current.rel}`, () => tracker.accept(current.path));
|
|
176
|
+
else if (data === "A" && current) run(`accepted ${changes.length} file(s)`, () => tracker.acceptAll());
|
|
85
177
|
else if (data === "r" && current) { armed = "one"; message = `revert ${current.rel}? press r or y to confirm`; }
|
|
86
|
-
else if (data === "R") { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
178
|
+
else if (data === "R" && current) { armed = "all"; message = `revert all ${changes.length} files? press R or y to confirm`; }
|
|
87
179
|
else if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
|
|
88
180
|
else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
|
|
89
181
|
else if (matchesKey(data, Key.pageDown) || data === " ") scrollBody(Math.max(1, bodyRows - 2));
|
|
90
182
|
else if (matchesKey(data, Key.pageUp)) scrollBody(-Math.max(1, bodyRows - 2));
|
|
183
|
+
else if (data === "n" && !query && opCache?.hunks.length) {
|
|
184
|
+
scroll = opCache.hunks.find(offset => offset > scroll) ?? opCache.hunks[0]!; cache = undefined;
|
|
185
|
+
}
|
|
186
|
+
else if (data === "p" && opCache?.hunks.length) {
|
|
187
|
+
scroll = [...opCache.hunks].reverse().find(offset => offset < scroll) ?? opCache.hunks.at(-1)!; cache = undefined;
|
|
188
|
+
}
|
|
91
189
|
else if (data === "g") scrollBody(-Infinity);
|
|
92
190
|
else if (data === "G") scrollBody(Infinity);
|
|
93
191
|
tui.requestRender();
|
|
@@ -102,37 +200,50 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
102
200
|
}
|
|
103
201
|
if (event.type !== "click" || event.button !== "left") return;
|
|
104
202
|
if (event.y === 0 && event.x >= width - 3) { done(); return { handled: true }; }
|
|
105
|
-
if (inContent && inList && listScroll + row <
|
|
203
|
+
if (inContent && inList && listScroll + row < visibleChanges().length) { select(listScroll + row); tui.requestRender(); return { handled: true, focus: true }; }
|
|
106
204
|
const action = ACTIONS[event.y - layout.footerTop];
|
|
107
205
|
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
108
206
|
},
|
|
109
207
|
render(available: number): string[] {
|
|
110
208
|
width = Math.max(24, available);
|
|
111
|
-
bodyRows = contentRows(
|
|
209
|
+
bodyRows = contentRows(7 + ACTIONS.length, 6);
|
|
112
210
|
const listWidth = sidebarWidth(width, 20, 36);
|
|
113
211
|
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
212
|
+
const filtered = visibleChanges();
|
|
213
|
+
const current = filtered[selected];
|
|
214
|
+
if (!current) return [fg("dim", `No files match /${safeLine(query)}`), fg("dim", "Press / to edit the filter or Esc to clear it.")];
|
|
215
|
+
const opKey = `${current.path}:${current.before.length}:${current.after.length}`;
|
|
216
|
+
if (opCache?.key !== opKey && (!fullReviewRequired(current) || fullReview)) {
|
|
217
|
+
const exact = current.diff === null ? undefined : (current.diff ?? lineDiff(current.before, current.after));
|
|
218
|
+
const coarse = exact === undefined;
|
|
219
|
+
const ops = exact ?? coarseLineDiff(current.before, current.after);
|
|
220
|
+
opCache = { key: opKey, ops, hunks: diffHunkOffsets(ops), rows: diffRowCount(ops), coarse };
|
|
221
|
+
}
|
|
222
|
+
const pathLines = wrapPath(current.path.replace(/[\x00-\x1f\x7f-\x9f]/g, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`), bodyWidth);
|
|
223
|
+
// Reserve wrapped path, metadata, split/coarse headers and the optional window footer.
|
|
224
|
+
viewportRows = Math.max(1, bodyRows - pathLines.length - 1 - (unified ? 0 : 1) - (opCache?.coarse ? 1 : 0) - 1);
|
|
225
|
+
scroll = Math.min(scroll, Math.max(0, (opCache?.rows ?? 0) - viewportRows));
|
|
226
|
+
const key = `${current.path}:${bodyWidth}:${bodyRows}:${viewportRows}:${unified}:${fullReview}:${scroll}`;
|
|
227
|
+
if (cache?.key !== key) cache = { key, lines: renderDiff(current, bodyWidth, fg, { fullReview, scroll, rows: viewportRows, ops: opCache?.ops, coarse: opCache?.coarse, unified }) };
|
|
228
|
+
// The absolute path is intentionally the first row of the right pane, even in split mode.
|
|
229
|
+
const content = [...pathLines.map(path => fg("accent", path)),
|
|
230
|
+
fg("dim", `${selected + 1}/${filtered.length} · ${current.status} · +${current.added} −${current.removed}`), ...cache.lines];
|
|
121
231
|
if (selected < listScroll) listScroll = selected;
|
|
122
232
|
if (selected >= listScroll + bodyRows) listScroll = selected - bodyRows + 1;
|
|
123
|
-
const list =
|
|
233
|
+
const list = filtered.slice(listScroll, listScroll + bodyRows).map((change, index) => {
|
|
124
234
|
const active = listScroll + index === selected;
|
|
125
235
|
const counts = fg("success", `+${change.added}`) + fg("error", ` −${change.removed}`);
|
|
126
236
|
const name = truncateToWidth((active ? "▌" : " ") + STATUS_MARK[change.status] + " " + change.rel, Math.max(4, listWidth - String(change.added).length - String(change.removed).length - 4));
|
|
127
237
|
return fg(active ? "accent" : "muted", name) + " " + counts;
|
|
128
238
|
});
|
|
129
|
-
const totals =
|
|
130
|
-
const title = `± CHANGES · ${
|
|
239
|
+
const totals = filtered.reduce((sum, change) => [sum[0]! + change.added, sum[1]! + change.removed], [0, 0]);
|
|
240
|
+
const title = `± CHANGES · ${filtered.length}/${changes.length} files · +${totals[0]} −${totals[1]}`;
|
|
131
241
|
const footer = [
|
|
132
242
|
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
133
|
-
|
|
243
|
+
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear` : query ? `/${query} · n/N next match · / new search` : `${SEARCH_HINT} · n/p hunk · t split/unified · v full preview`),
|
|
244
|
+
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn scroll · a accept · r revert · Esc close")
|
|
134
245
|
];
|
|
135
|
-
const split = splitFrame(theme, width, title, list, content.slice(
|
|
246
|
+
const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
|
|
136
247
|
layout = split.layout;
|
|
137
248
|
return split.lines;
|
|
138
249
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { cleanText } from "./status.ts";
|
|
4
|
+
import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
|
|
5
|
+
|
|
6
|
+
export interface ShellNotificationDetails {
|
|
7
|
+
summary: string;
|
|
8
|
+
ids: string[];
|
|
9
|
+
events: Array<{ id: string; name: string; kind: ShellEvent["kind"]; status: string; exitCode?: number | null }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A compact transport payload; logs are fetched explicitly, never copied into wake-up messages. */
|
|
13
|
+
export function shellNotification(events: readonly ShellEvent[]): CustomMessageEntryDraft {
|
|
14
|
+
const failed = events.filter(({ kind, job }) => kind === "exit" && (job.status === "failed" || (job.status === "exited" && job.exitCode !== 0))).length;
|
|
15
|
+
const ready = events.filter(event => event.kind === "match").length;
|
|
16
|
+
const completed = events.length - ready;
|
|
17
|
+
const summary = `Background shells · ${completed} completed${failed ? ` · ${failed} failed` : ""}${ready ? ` · ${ready} ready` : ""}`;
|
|
18
|
+
const details: ShellNotificationDetails = {
|
|
19
|
+
summary, ids: events.map(event => event.job.id),
|
|
20
|
+
events: events.map(({ kind, job }) => ({ id: job.id, name: job.name, kind, status: job.status, exitCode: job.exitCode }))
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
type: "custom_message", customType: SHELL_MESSAGE, display: true, details,
|
|
24
|
+
content: [summary, ...events.map(({ kind, job }) => kind === "match" ? `${job.id} · ${job.name} · ready: ${cleanText(job.matched ?? "", 120)}` : describe(job)),
|
|
25
|
+
"Use jar_shell output for relevant diagnostics only; observed completions do not replay."].join("\n")
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep events in our own queue while the model runs, not Pi's irrevocable follow-up queue.
|
|
30
|
+
* Boundary entries are consumed before final settlement; output/peek/wait can acknowledge events
|
|
31
|
+
* before delivery. Idle events wake once after a debounce, and session replacement drops old work. */
|
|
32
|
+
export function registerShellNotifications(pi: ExtensionAPI, manager: () => ShellManager | undefined): { notify(): void; dispose(): void } {
|
|
33
|
+
let active = false;
|
|
34
|
+
let interrupted = false;
|
|
35
|
+
let deliveryFailures = 0;
|
|
36
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
const warned = new Set<string>();
|
|
38
|
+
const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
|
|
39
|
+
const take = () => {
|
|
40
|
+
cancelTimer();
|
|
41
|
+
const events = manager()?.takeNotifications() ?? [];
|
|
42
|
+
return events.length ? shellNotification(events) : undefined;
|
|
43
|
+
};
|
|
44
|
+
const notify = () => {
|
|
45
|
+
if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
|
|
46
|
+
const owner = manager();
|
|
47
|
+
timer = setTimeout(() => {
|
|
48
|
+
timer = undefined;
|
|
49
|
+
if (active || interrupted || owner !== manager()) return;
|
|
50
|
+
const events = owner?.takeNotifications() ?? [];
|
|
51
|
+
if (!events.length) return;
|
|
52
|
+
const { type: _type, ...message } = shellNotification(events);
|
|
53
|
+
try { pi.sendMessage(message, { triggerTurn: true, deliverAs: "nextTurn" }); deliveryFailures = 0; }
|
|
54
|
+
catch (error) {
|
|
55
|
+
owner?.restoreNotifications(events);
|
|
56
|
+
console.error("pi-jar: could not deliver shell summary", error);
|
|
57
|
+
if (++deliveryFailures < 3) notify();
|
|
58
|
+
}
|
|
59
|
+
}, 300);
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
};
|
|
62
|
+
const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
|
|
63
|
+
|
|
64
|
+
pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
|
|
65
|
+
const text = typeof message.content === "string" ? message.content : "Background shells";
|
|
66
|
+
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
|
|
67
|
+
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
|
|
68
|
+
});
|
|
69
|
+
pi.on?.("session_start", reset);
|
|
70
|
+
pi.on?.("session_shutdown", reset);
|
|
71
|
+
pi.on?.("before_agent_start", () => {
|
|
72
|
+
active = true;
|
|
73
|
+
interrupted = false;
|
|
74
|
+
const entry = take();
|
|
75
|
+
if (!entry) return;
|
|
76
|
+
const { type: _type, ...message } = entry;
|
|
77
|
+
return { message };
|
|
78
|
+
});
|
|
79
|
+
pi.on?.("agent_start", () => { active = true; cancelTimer(); });
|
|
80
|
+
pi.on?.("turn_end", event => {
|
|
81
|
+
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
82
|
+
const entry = take();
|
|
83
|
+
if (entry) return { entries: [entry], continue: true };
|
|
84
|
+
});
|
|
85
|
+
pi.on?.("agent_before_settle", event => {
|
|
86
|
+
interrupted = event.outcome !== "completed";
|
|
87
|
+
if (interrupted || event.context?.canContinue === false) return;
|
|
88
|
+
const entry = take();
|
|
89
|
+
if (entry) return { entries: [entry], continue: true };
|
|
90
|
+
// One reminder per outstanding finite job, never a self-repeating continuation loop.
|
|
91
|
+
const owner = manager();
|
|
92
|
+
const known = new Set(owner?.summaries().map(job => job.id) ?? []);
|
|
93
|
+
for (const id of warned) if (!known.has(id)) warned.delete(id);
|
|
94
|
+
const pending = (owner?.verificationPending() ?? []).filter(job => !warned.has(job.id));
|
|
95
|
+
if (!pending.length) return;
|
|
96
|
+
for (const job of pending) warned.add(job.id);
|
|
97
|
+
return { entries: [{ type: "custom_message" as const, customType: SHELL_MESSAGE, display: true,
|
|
98
|
+
content: `Verification still pending: ${pending.map(job => `${job.id} (${job.name})`).join(", ")}. Use jar_shell wait/peek for these ids before claiming success, or clearly report them as pending. Do not wait for long-lived services.`,
|
|
99
|
+
details: { summary: `${pending.length} background check(s) still pending`, ids: pending.map(job => job.id) }
|
|
100
|
+
}], continue: true };
|
|
101
|
+
});
|
|
102
|
+
pi.on?.("agent_settled", () => { active = false; notify(); });
|
|
103
|
+
return { notify, dispose: reset };
|
|
104
|
+
}
|