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/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 { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
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): string[] {
22
- const ops = change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after));
23
- if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
24
- const rows: DiffRow[] = diffRows(ops);
25
- if (!rows.length) return [fg("dim", "No textual changes.")];
26
- const gutter = Math.max(3, String(Math.max(...rows.map((row) => Math.max(row.oldLine ?? 0, row.newLine ?? 0)))).length);
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
- return rows.map((row) => {
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
- selected = Math.min(selected, Math.max(0, changes.length - 1));
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 select = (index: number) => { selected = Math.max(0, Math.min(changes.length - 1, index)); scroll = 0; };
70
- const scrollBody = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, (cache?.lines.length ?? 0) - bodyRows), scroll + delta)); };
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
- const current = changes[selected];
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 === "a" && current) run(`accepted ${current.rel}`, () => tracker.accept(current.path));
84
- else if (data === "A") run(`accepted ${changes.length} file(s)`, () => tracker.acceptAll());
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 < changes.length) { select(listScroll + row); tui.requestRender(); return { handled: true, focus: true }; }
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(6 + ACTIONS.length, 6);
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 current = changes[selected];
115
- if (!current) return [];
116
- const key = `${selected}:${bodyWidth}:${current.after.length}`;
117
- if (cache?.key !== key) cache = { key, lines: renderDiff(current, bodyWidth, fg) };
118
- const header = listWidth ? [] : [fg("accent", `‹ ${selected + 1}/${changes.length} ${current.rel} ›`), ""];
119
- const content = [...header, ...cache.lines];
120
- scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - bodyRows)));
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 = changes.slice(listScroll, listScroll + bodyRows).map((change, index) => {
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 = changes.reduce((sum, change) => [sum[0]! + change.added, sum[1]! + change.removed], [0, 0]);
130
- const title = `± CHANGES · ${changes.length} file${changes.length === 1 ? "" : "s"} · +${totals[0]} −${totals[1]}`;
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
- message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn scroll · press a key or click an action · Esc close")
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(scroll, scroll + bodyRows), footer, bodyRows, listWidth);
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
+ }