pi-better-background-tasks 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/runtime.ts CHANGED
@@ -4,6 +4,7 @@ import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from
4
4
  import { evaluateCondition } from "./conditions.js";
5
5
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
6
6
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
7
+ import { getCallbackBatcher } from "./shared-callback-batcher.js";
7
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
8
9
  import { isTerminalStatus } from "./types.js";
9
10
 
@@ -304,24 +305,37 @@ async function notifyTerminal(
304
305
  if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
305
306
  const latest = readMeta(meta.id) ?? meta;
306
307
  if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
307
- const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
308
- if (suppressionReason) {
309
- latest.callbackSuppressedAt = Date.now();
310
- latest.callbackSuppressedReason = suppressionReason;
311
- writeMeta(latest);
312
- return;
313
- }
314
308
  const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
315
- try {
316
- await pi.sendUserMessage(
317
- `Background task ${label} reached terminal status ${latest.status}. Inspect the compact result with bg_task_status id=${latest.id}; call bg_task_log only if the status summary is insufficient.`,
318
- { deliverAs: "followUp" },
319
- );
320
- latest.callbackSentAt = Date.now();
321
- writeMeta(latest);
322
- } catch {
323
- // Leave callbackSentAt unset so the originating session can attempt delivery once.
324
- }
309
+ getCallbackBatcher(pi).enqueue({
310
+ source: "background-task",
311
+ id: latest.id,
312
+ label,
313
+ status: latest.status,
314
+ detailTool: "bg_task_status",
315
+ callback: true,
316
+ isDelivered: () => {
317
+ const current = readMeta(latest.id);
318
+ return current?.callbackSentAt !== undefined || current?.callbackSuppressedAt !== undefined;
319
+ },
320
+ getSuppressionReason: () => {
321
+ const current = readMeta(latest.id);
322
+ if (!current) return "background task metadata is unavailable";
323
+ return getCallbackSuppressionReason(current, getActiveSession?.());
324
+ },
325
+ onDelivered: (at) => {
326
+ const current = readMeta(latest.id);
327
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
328
+ current.callbackSentAt = at;
329
+ writeMeta(current);
330
+ },
331
+ onSuppressed: (reason, at) => {
332
+ const current = readMeta(latest.id);
333
+ if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
334
+ current.callbackSuppressedAt = at;
335
+ current.callbackSuppressedReason = reason;
336
+ writeMeta(current);
337
+ },
338
+ });
325
339
  }
326
340
 
327
341
  function getCallbackSuppressionReason(
@@ -0,0 +1,320 @@
1
+ // Generated from packages/callback-batcher/index.ts. Do not edit directly.
2
+ export type CallbackSource = "subagent" | "background-task";
3
+ export type CallbackDetailTool = "subagent_result" | "bg_task_status";
4
+
5
+ export interface CallbackBatchHost {
6
+ sendMessage(
7
+ message: { customType: string; content: string; display: boolean },
8
+ options: Record<string, unknown>,
9
+ ): unknown;
10
+ }
11
+
12
+ export interface CallbackBatchEvent {
13
+ source: CallbackSource;
14
+ id: string;
15
+ label: string;
16
+ status: string;
17
+ detailTool: CallbackDetailTool;
18
+ callback?: boolean;
19
+ isDelivered?: () => boolean;
20
+ getSuppressionReason?: () => string | undefined;
21
+ onDelivered?: (at: number) => void;
22
+ onSuppressed?: (reason: string, at: number) => void;
23
+ }
24
+
25
+ export interface UrgentCallbackEvent {
26
+ source: CallbackSource;
27
+ id: string;
28
+ label: string;
29
+ status: "orphaned" | "lost" | string;
30
+ customType: string;
31
+ content: string;
32
+ isDelivered?: () => boolean;
33
+ getSuppressionReason?: () => string | undefined;
34
+ onDelivered?: (at: number) => void;
35
+ onSuppressed?: (reason: string, at: number) => void;
36
+ }
37
+
38
+ export interface CallbackBatcherOptions {
39
+ windowMs?: number;
40
+ retryMs?: number;
41
+ }
42
+
43
+ export interface CallbackBatcher {
44
+ enqueue(event: CallbackBatchEvent): boolean;
45
+ flush(): Promise<boolean>;
46
+ deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
47
+ cancel(): void;
48
+ pendingCount(): number;
49
+ }
50
+
51
+ interface PendingEvent {
52
+ event: CallbackBatchEvent;
53
+ sequence: number;
54
+ }
55
+
56
+ interface SharedCallbackBatcherState {
57
+ byHost: WeakMap<object, CallbackBatcher>;
58
+ }
59
+
60
+ const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
61
+ const DEFAULT_WINDOW_MS = 100;
62
+ const DEFAULT_RETRY_MS = 1_000;
63
+ const MAX_LABEL_CHARS = 160;
64
+ const MAX_ID_CHARS = 200;
65
+ const MAX_STATUS_CHARS = 80;
66
+
67
+ export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
68
+ export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
69
+
70
+ export function resolveCallbackBatchWindowMs(
71
+ value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
72
+ ): number {
73
+ if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
74
+ const parsed = Number(value);
75
+ if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
76
+ return Math.max(0, Math.min(5_000, Math.floor(parsed)));
77
+ }
78
+
79
+ export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
80
+ const count = events.length;
81
+ const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
82
+ const rows = events.map((event) => {
83
+ const source = boundedField(event.source, 40);
84
+ const id = boundedField(event.id, MAX_ID_CHARS);
85
+ const label = boundedField(event.label, MAX_LABEL_CHARS);
86
+ const status = boundedField(event.status, MAX_STATUS_CHARS);
87
+ const detail = event.detailTool === "bg_task_status"
88
+ ? `bg_task_status id=${id}`
89
+ : `subagent_result id=${JSON.stringify(id)}`;
90
+ return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
91
+ });
92
+ return [
93
+ heading,
94
+ ...rows,
95
+ "Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
96
+ ].join("\n");
97
+ }
98
+
99
+ export function createCallbackBatcher(
100
+ host: CallbackBatchHost,
101
+ options: CallbackBatcherOptions = {},
102
+ ): CallbackBatcher {
103
+ const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
104
+ const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
105
+ const pending = new Map<string, PendingEvent>();
106
+ const inFlight = new Set<string>();
107
+ const urgentInFlight = new Set<string>();
108
+ let sequence = 0;
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ let flushPromise: Promise<boolean> | undefined;
111
+
112
+ const cancelTimer = (): void => {
113
+ if (timer) clearTimeout(timer);
114
+ timer = undefined;
115
+ };
116
+
117
+ const schedule = (delayMs: number): void => {
118
+ if (timer || pending.size === 0) return;
119
+ timer = setTimeout(() => {
120
+ timer = undefined;
121
+ void api.flush();
122
+ }, Math.max(0, delayMs));
123
+ timer.unref?.();
124
+ };
125
+
126
+ const enqueue = (event: CallbackBatchEvent): boolean => {
127
+ if (event.callback === false) return false;
128
+ const key = eventKey(event);
129
+ if (pending.has(key) || inFlight.has(key)) return false;
130
+ pending.set(key, { event, sequence: sequence++ });
131
+ schedule(windowMs);
132
+ return true;
133
+ };
134
+
135
+ const performFlush = async (): Promise<boolean> => {
136
+ cancelTimer();
137
+ const snapshot = [...pending.entries()]
138
+ .sort((a, b) => a[1].sequence - b[1].sequence);
139
+ pending.clear();
140
+ for (const [key] of snapshot) inFlight.add(key);
141
+
142
+ const deliverable: Array<[string, PendingEvent]> = [];
143
+ for (const item of snapshot) {
144
+ const [key, pendingEvent] = item;
145
+ const disposition = eventDisposition(pendingEvent.event);
146
+ if (disposition.kind === "delivered") {
147
+ inFlight.delete(key);
148
+ continue;
149
+ }
150
+ if (disposition.kind === "suppressed") {
151
+ invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
152
+ inFlight.delete(key);
153
+ continue;
154
+ }
155
+ deliverable.push(item);
156
+ }
157
+
158
+ if (deliverable.length === 0) {
159
+ if (pending.size > 0) schedule(windowMs);
160
+ return true;
161
+ }
162
+
163
+ try {
164
+ await host.sendMessage(
165
+ {
166
+ customType: "background-completion-batch",
167
+ content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
168
+ display: true,
169
+ },
170
+ { deliverAs: "followUp", triggerTurn: true },
171
+ );
172
+ } catch {
173
+ for (const [key] of deliverable) inFlight.delete(key);
174
+ const retryItems = [...deliverable, ...pending.entries()]
175
+ .sort((a, b) => a[1].sequence - b[1].sequence);
176
+ pending.clear();
177
+ for (const [key, item] of retryItems) {
178
+ if (!pending.has(key)) pending.set(key, item);
179
+ }
180
+ schedule(retryMs);
181
+ return false;
182
+ }
183
+
184
+ const deliveredAt = Date.now();
185
+ for (const [key, item] of deliverable) {
186
+ invokeDelivered(item.event, deliveredAt);
187
+ inFlight.delete(key);
188
+ }
189
+ if (pending.size > 0) schedule(windowMs);
190
+ return true;
191
+ };
192
+
193
+ const flush = (): Promise<boolean> => {
194
+ if (flushPromise) return flushPromise;
195
+ flushPromise = performFlush().finally(() => {
196
+ flushPromise = undefined;
197
+ });
198
+ return flushPromise;
199
+ };
200
+
201
+ const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
202
+ const key = eventKey(event);
203
+ if (urgentInFlight.has(key)) return false;
204
+ const disposition = eventDisposition(event);
205
+ if (disposition.kind === "delivered") return true;
206
+ if (disposition.kind === "suppressed") {
207
+ invokeSuppressed(event, disposition.reason, Date.now());
208
+ return true;
209
+ }
210
+
211
+ urgentInFlight.add(key);
212
+ try {
213
+ const handoff = host.sendMessage(
214
+ { customType: event.customType, content: event.content, display: true },
215
+ { deliverAs: "followUp", triggerTurn: true },
216
+ );
217
+ if (isPromiseLike(handoff)) {
218
+ return Promise.resolve(handoff).then(
219
+ () => {
220
+ invokeDelivered(event, Date.now());
221
+ return true;
222
+ },
223
+ () => false,
224
+ ).finally(() => urgentInFlight.delete(key));
225
+ }
226
+ invokeDelivered(event, Date.now());
227
+ urgentInFlight.delete(key);
228
+ return true;
229
+ } catch {
230
+ urgentInFlight.delete(key);
231
+ return false;
232
+ }
233
+ };
234
+
235
+ const api: CallbackBatcher = {
236
+ enqueue,
237
+ flush,
238
+ deliverUrgent,
239
+ cancel() {
240
+ cancelTimer();
241
+ pending.clear();
242
+ },
243
+ pendingCount() {
244
+ return pending.size;
245
+ },
246
+ };
247
+ return api;
248
+ }
249
+
250
+ export function getCallbackBatcher(
251
+ host: CallbackBatchHost,
252
+ options: CallbackBatcherOptions = {},
253
+ ): CallbackBatcher {
254
+ const state = globalState();
255
+ const key = host as object;
256
+ const existing = state.byHost.get(key);
257
+ if (existing) return existing;
258
+ const created = createCallbackBatcher(host, options);
259
+ state.byHost.set(key, created);
260
+ return created;
261
+ }
262
+
263
+ export function cancelCallbackBatch(host: CallbackBatchHost): void {
264
+ globalState().byHost.get(host as object)?.cancel();
265
+ }
266
+
267
+ function globalState(): SharedCallbackBatcherState {
268
+ const root = globalThis as typeof globalThis & {
269
+ [GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
270
+ };
271
+ root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
272
+ return root[GLOBAL_STATE_KEY];
273
+ }
274
+
275
+ function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
276
+ return `${event.source}\u0000${event.id}\u0000${event.status}`;
277
+ }
278
+
279
+ function eventDisposition(
280
+ event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
281
+ ): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
282
+ try {
283
+ if (event.isDelivered?.()) return { kind: "delivered" };
284
+ } catch {
285
+ return { kind: "suppressed", reason: "durable delivery state could not be verified" };
286
+ }
287
+ try {
288
+ const reason = event.getSuppressionReason?.();
289
+ return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
290
+ } catch {
291
+ return { kind: "suppressed", reason: "callback ownership could not be verified" };
292
+ }
293
+ }
294
+
295
+ function invokeDelivered(
296
+ event: Pick<CallbackBatchEvent, "onDelivered">,
297
+ at: number,
298
+ ): void {
299
+ try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
300
+ }
301
+
302
+ function invokeSuppressed(
303
+ event: Pick<CallbackBatchEvent, "onSuppressed">,
304
+ reason: string,
305
+ at: number,
306
+ ): void {
307
+ try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
308
+ }
309
+
310
+ function boundedField(value: unknown, maxChars: number): string {
311
+ const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
312
+ if (oneLine.length <= maxChars) return oneLine;
313
+ return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
314
+ }
315
+
316
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
317
+ return (typeof value === "object" || typeof value === "function")
318
+ && value !== null
319
+ && typeof (value as PromiseLike<unknown>).then === "function";
320
+ }
@@ -39,6 +39,11 @@ export type BackgroundWorkDetail = {
39
39
  metadata: Array<{ label: string; value: string }>;
40
40
  foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string; expandedByDefault?: boolean }>;
41
41
  evidence: { label: string; text: string };
42
+ transcript?: Array<
43
+ | { type: "assistant"; content: Array<{ type: string; text?: string; thinking?: string }>; streaming: boolean }
44
+ | { type: "tool"; id?: string; name: string; args?: unknown; result?: unknown; isError: boolean; state: "running" | "completed" }
45
+ >;
46
+ transcriptDiagnostic?: string;
42
47
  footerActions?: string[];
43
48
  };
44
49
 
@@ -54,6 +59,8 @@ export type BackgroundWorkProvider = {
54
59
  label: string;
55
60
  priority: number;
56
61
  visibleCount(): number;
62
+ showSection?(rows: BackgroundWorkRow[], now: number): boolean;
63
+ parentRow?(now: number): BackgroundWorkRow | null;
57
64
  listRows(now: number): BackgroundWorkRow[];
58
65
  detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
59
66
  armCloseLabel(row: BackgroundWorkRow): string;
@@ -66,6 +73,7 @@ type HostDeps = {
66
73
  isOpenTrigger: (data: string) => boolean;
67
74
  matchKey: (data: string, keyId: string) => boolean;
68
75
  truncate: (s: string, width: number) => string;
76
+ createTranscriptComponent?: (detail: BackgroundWorkDetail, theme: unknown) => Component;
69
77
  };
70
78
 
71
79
  type NavigatorState = {
@@ -82,6 +90,7 @@ type NavigatorState = {
82
90
  mainListCloseArm?: { id: string; armedAt: number };
83
91
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
84
92
  mainListDeadlineScheduler?: RenderScheduler;
93
+ editorComponent?: Component;
85
94
  detailOverlayRows?: number;
86
95
  dispose?: () => void;
87
96
  };
@@ -98,8 +107,7 @@ export const CLOSE_ARM_MS = 3000;
98
107
  export const DEFAULT_LOG_TAIL_ROWS = 10;
99
108
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
100
109
  const MAIN_LIST_FALLBACK_WIDTH = 100;
101
- const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
102
- const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
110
+ const DETAIL_OVERLAY_FOOTER_ROWS = 3;
103
111
  const EVIDENCE_SECTION_ID = "__evidence__";
104
112
  const RUNNING_DOT_GLYPH = "●";
105
113
  const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
@@ -314,7 +322,7 @@ function renderWidth(width: number): number {
314
322
  return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
315
323
  }
316
324
 
317
- type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
325
+ type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string; parentRow?: boolean };
318
326
 
319
327
  function rowKey(providerId: string, id: string): string {
320
328
  return `${providerId}:${id}`;
@@ -332,6 +340,19 @@ function listRows(now = Date.now()): InternalRow[] {
332
340
  let providerRows: BackgroundWorkRow[] = [];
333
341
  try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
334
342
  const orderedProviderRows = [...providerRows].sort((a, b) => b.sortStartedAt - a.sortStartedAt || rowDisplayName(a).localeCompare(rowDisplayName(b)));
343
+ let showSection = orderedProviderRows.length > 0 || provider.parentRow !== undefined;
344
+ try { showSection = provider.showSection?.(orderedProviderRows, now) ?? showSection; } catch { showSection = false; }
345
+ if (!showSection) continue;
346
+ let parentRow: BackgroundWorkRow | null = null;
347
+ try { parentRow = provider.parentRow?.(now) ?? null; } catch { parentRow = null; }
348
+ if (parentRow) {
349
+ rows.push({
350
+ ...parentRow,
351
+ navigatorId: rowKey(parentRow.providerId, parentRow.id),
352
+ providerLabel: provider.label,
353
+ parentRow: true,
354
+ });
355
+ }
335
356
  for (const row of orderedProviderRows) {
336
357
  rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
337
358
  }
@@ -377,7 +398,7 @@ function focusMainList(): void {
377
398
  const rows = listRows();
378
399
  if (rows.length === 0) return;
379
400
  const s = state();
380
- syncMainListSelection(rows);
401
+ s.mainListSelectedId = rows.find((row) => row.parentRow && row.id === "main")?.navigatorId ?? rows[0]!.navigatorId;
381
402
  s.mainListFocused = true;
382
403
  refreshMainListWidget();
383
404
  }
@@ -422,13 +443,13 @@ function buildMainListLines(
422
443
  }
423
444
 
424
445
  function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
425
- const keys = focused ? "↑↓ select · Enter detail · x stop · Esc unfocus" : "← to navigate";
446
+ const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
426
447
  return dim(keys, fg);
427
448
  }
428
449
 
429
450
  function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
430
451
  const normalized = singleLine(label).toLowerCase();
431
- return `${dim("▸", fg)} ${fg("warning", normalized)}`;
452
+ return fg("warning", normalized);
432
453
  }
433
454
 
434
455
  function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
@@ -516,13 +537,14 @@ function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLin
516
537
  }
517
538
 
518
539
  function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
540
+ if (row.parentRow) return { action: "not-closable", providerId: row.providerId, id: row.id };
519
541
  const provider = state().providers.get(row.providerId);
520
542
  if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
521
543
  try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
522
544
  }
523
545
 
524
546
  function closeHintFor(row: InternalRow | undefined): string | null {
525
- if (!row) return null;
547
+ if (!row || row.parentRow) return null;
526
548
  const provider = state().providers.get(row.providerId);
527
549
  if (!provider) return null;
528
550
  try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
@@ -547,6 +569,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
547
569
  }
548
570
 
549
571
  function wrapEditor(inner: any, deps: HostDeps): unknown {
572
+ if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
550
573
  return new Proxy(inner, {
551
574
  get(target, prop) {
552
575
  if (prop === "handleInput") {
@@ -577,16 +600,24 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
577
600
  }
578
601
  if (deps.matchKey(data, "up")) {
579
602
  clearMainListCloseArm();
580
- if (moveMainListSelection(-1)) refreshMainListWidget();
603
+ if (moveMainListSelection(-1)) {
604
+ refreshMainListWidget();
605
+ if (!selectedMainListRow()?.parentRow) openNavigator();
606
+ }
581
607
  return true;
582
608
  }
583
609
  if (deps.matchKey(data, "down")) {
584
610
  clearMainListCloseArm();
585
- if (moveMainListSelection(1)) refreshMainListWidget();
611
+ if (moveMainListSelection(1)) {
612
+ refreshMainListWidget();
613
+ if (!selectedMainListRow()?.parentRow) openNavigator();
614
+ }
586
615
  return true;
587
616
  }
588
617
  if (deps.matchKey(data, "enter")) {
589
- openNavigator();
618
+ const selected = selectedMainListRow();
619
+ if (selected?.parentRow) unfocusMainList();
620
+ else openNavigator();
590
621
  return true;
591
622
  }
592
623
  if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
@@ -602,7 +633,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
602
633
 
603
634
  function handleMainListCloseKey(): void {
604
635
  const row = selectedMainListRow();
605
- if (!row) return;
636
+ if (!row || row.parentRow) return;
606
637
  const s = state();
607
638
  const now = Date.now();
608
639
  const arm = s.mainListCloseArm;
@@ -678,6 +709,8 @@ function createOverlayComponent(
678
709
  let closeArm: { id: string; armedAt: number } | undefined;
679
710
  let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
680
711
  let closed = false;
712
+ let transcriptDetail: BackgroundWorkDetail | null = null;
713
+ let transcriptComponent: Component | null = null;
681
714
 
682
715
  const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
683
716
 
@@ -693,12 +726,24 @@ function createOverlayComponent(
693
726
  });
694
727
 
695
728
  function refreshRows(): void {
696
- const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
729
+ const selectedId = state().mainListSelectedId ?? overlayState.rows[overlayState.selected]?.navigatorId;
697
730
  overlayState.rows = listRows();
698
731
  const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
699
732
  overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
700
733
  }
701
734
 
735
+ function selectOverlayRow(next: number): void {
736
+ overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), Math.max(0, next));
737
+ state().mainListSelectedId = overlayState.rows[overlayState.selected]?.navigatorId;
738
+ clearCloseArm();
739
+ refreshMainListWidget();
740
+ }
741
+
742
+ function activateSelectedRow(): void {
743
+ if (selectedRow()?.parentRow) close();
744
+ else openDetail();
745
+ }
746
+
702
747
  function clearCloseArm(): void {
703
748
  if (closeArmTimer) clearTimeout(closeArmTimer);
704
749
  closeArmTimer = undefined;
@@ -720,13 +765,16 @@ function createOverlayComponent(
720
765
  }
721
766
 
722
767
  function selectedRow(): InternalRow | undefined {
723
- if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
724
768
  return overlayState.rows[overlayState.selected];
725
769
  }
726
770
 
727
771
  function openDetail(): void {
728
772
  const row = selectedRow();
729
773
  if (!row) return;
774
+ if (row.parentRow) {
775
+ close();
776
+ return;
777
+ }
730
778
  clearCloseArm();
731
779
  detailId = row.navigatorId;
732
780
  expandedSections.clear();
@@ -797,10 +845,43 @@ function createOverlayComponent(
797
845
 
798
846
  return {
799
847
  render(width: number) {
800
- const lines = mode === "detail"
801
- ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: state().detailOverlayRows })
802
- : buildListLines(overlayState, width, deps.truncate, fg);
803
- return lines;
848
+ refreshRows();
849
+ const railLines = mode === "detail"
850
+ ? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
851
+ selectedId: selectedRow()?.navigatorId,
852
+ focused: true,
853
+ })
854
+ : [];
855
+ const editorLines = mode === "detail" ? renderEditorLines(width) : [];
856
+ const bottomLines = [...railLines, ...editorLines];
857
+ const overlayRows = state().detailOverlayRows;
858
+ const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
859
+ let contentLines: string[];
860
+ if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
861
+ if (transcriptDetail !== detail || !transcriptComponent) {
862
+ transcriptDetail = detail;
863
+ transcriptComponent = deps.createTranscriptComponent(detail, theme);
864
+ }
865
+ contentLines = buildTranscriptDetailLines(
866
+ detail,
867
+ transcriptComponent.render(width),
868
+ width,
869
+ deps.truncate,
870
+ fg,
871
+ { minRows: detailRows },
872
+ );
873
+ } else {
874
+ transcriptDetail = null;
875
+ transcriptComponent = null;
876
+ contentLines = mode === "detail"
877
+ ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
878
+ : buildListLines(overlayState, width, deps.truncate, fg);
879
+ }
880
+ if (mode !== "detail") return contentLines;
881
+ if (detailRows === undefined) return [...contentLines, ...bottomLines];
882
+ const fittedContent = contentLines.slice(0, detailRows);
883
+ while (fittedContent.length < detailRows) fittedContent.push("");
884
+ return [...fittedContent, ...bottomLines];
804
885
  },
805
886
  handleInput(data: string) {
806
887
  if (closed) return;
@@ -809,10 +890,25 @@ function createOverlayComponent(
809
890
  return;
810
891
  }
811
892
  if (mode === "detail") {
812
- if (deps.matchKey(data, "left")) close();
893
+ if (deps.matchKey(data, "up")) {
894
+ selectOverlayRow(overlayState.selected - 1);
895
+ activateSelectedRow();
896
+ }
897
+ else if (deps.matchKey(data, "down")) {
898
+ selectOverlayRow(overlayState.selected + 1);
899
+ activateSelectedRow();
900
+ }
901
+ else if (deps.matchKey(data, "left")) {
902
+ const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
903
+ if (mainIdx >= 0) selectOverlayRow(mainIdx);
904
+ close();
905
+ }
813
906
  else if (deps.matchKey(data, "enter")) {
814
- const sectionId = firstToggleableSectionId(detail);
815
- if (sectionId) {
907
+ const row = selectedRow();
908
+ if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
909
+ else {
910
+ const sectionId = firstToggleableSectionId(detail);
911
+ if (!sectionId) return;
816
912
  if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
817
913
  else expandedSections.add(sectionId);
818
914
  requestRender();
@@ -823,7 +919,10 @@ function createOverlayComponent(
823
919
  if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
824
920
  requestRender();
825
921
  }
826
- else if (deps.matchKey(data, "escape")) close();
922
+ else if (deps.matchKey(data, "escape")) {
923
+ unfocusMainList();
924
+ close();
925
+ }
827
926
  return;
828
927
  }
829
928
  if (deps.matchKey(data, "up")) {
@@ -840,7 +939,7 @@ function createOverlayComponent(
840
939
  close();
841
940
  }
842
941
  },
843
- invalidate() {},
942
+ invalidate() { transcriptComponent?.invalidate(); },
844
943
  dispose() {
845
944
  clearCloseArm();
846
945
  detailScheduler.dispose();
@@ -848,26 +947,61 @@ function createOverlayComponent(
848
947
  };
849
948
  }
850
949
 
950
+ function buildTranscriptDetailLines(
951
+ detail: BackgroundWorkDetail,
952
+ transcriptLines: string[],
953
+ width: number,
954
+ truncate: (s: string, width: number) => string,
955
+ fg: (color: string, value: string) => string,
956
+ options: { minRows?: number } = {},
957
+ ): string[] {
958
+ const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
959
+ const lines: string[] = [
960
+ fg("accent", rule(detail.title, width)),
961
+ dim(` ← main · ${actions}`, fg),
962
+ "",
963
+ ` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
964
+ ];
965
+ if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
966
+ for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
967
+ lines.push("", dim(section("transcript", width), fg));
968
+ if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
969
+ lines.push(...(transcriptLines.length ? transcriptLines : [" (no transcript yet)"]));
970
+ lines.push("");
971
+ const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
972
+ padBeforeFooter(lines, footerLines.length, options.minRows);
973
+ lines.push(...footerLines);
974
+ return lines.map((line) => safeTruncate(line, width, truncate));
975
+ }
976
+
851
977
  function detailOverlayOptions() {
852
- const navigatorRows = state().lastMainListLines?.length ?? 0;
853
- const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
978
+ const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
854
979
  return {
855
980
  anchor: "top-left" as const,
856
981
  width: "100%" as const,
857
982
  maxHeight: "100%" as const,
858
983
  margin: {
859
- top: DETAIL_OVERLAY_HEADER_MARGIN_ROWS,
984
+ top: 0,
860
985
  right: 0,
861
986
  bottom: marginBottom,
862
987
  left: 0,
863
988
  },
864
989
  visible: (_termWidth: number, termHeight: number) => {
865
- state().detailOverlayRows = Math.max(1, termHeight - DETAIL_OVERLAY_HEADER_MARGIN_ROWS - marginBottom);
990
+ state().detailOverlayRows = Math.max(1, termHeight - marginBottom);
866
991
  return true;
867
992
  },
868
993
  };
869
994
  }
870
995
 
996
+ function renderEditorLines(width: number): string[] {
997
+ try {
998
+ const lines = state().editorComponent?.render(width);
999
+ if (lines?.length) return lines;
1000
+ } catch { /* use an empty editor-shaped fallback */ }
1001
+ const border = "─".repeat(Math.max(1, width));
1002
+ return [border, "", border];
1003
+ }
1004
+
871
1005
  function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
872
1006
  return {
873
1007
  providerId: row.providerId,
@@ -976,7 +1110,10 @@ function buildDetailLines(
976
1110
  } else {
977
1111
  const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
978
1112
  lines.push(dim(section(evidenceLabel, width), fg));
979
- for (const raw of body.split(/\r?\n/)) lines.push(raw ? ` ${raw}` : " ");
1113
+ const wrapped = /log/i.test(detail.evidence.label)
1114
+ ? wrapLogText(body, width - 6)
1115
+ : body.split(/\r?\n/);
1116
+ for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
980
1117
  }
981
1118
  lines.push("");
982
1119
  const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
@@ -1042,6 +1179,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
1042
1179
  return rows.length ? rows : ["(no output yet)"];
1043
1180
  }
1044
1181
 
1182
+ /**
1183
+ * Wrap terminal log rows without flattening entries together. Continuations
1184
+ * retain the source indentation, and long paths/JSON tokens are hard-wrapped
1185
+ * so the final width guard never has to discard their suffix.
1186
+ */
1187
+ export function wrapLogText(text: string, width: number): string[] {
1188
+ const max = Math.max(8, Math.floor(width));
1189
+ const rows: string[] = [];
1190
+ for (const source of String(text ?? "").split(/\r?\n/)) {
1191
+ if (!source) {
1192
+ rows.push("");
1193
+ continue;
1194
+ }
1195
+ const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
1196
+ const indent = sourceIndent.replace(/\t/g, " ");
1197
+ let remaining = source.slice(sourceIndent.length);
1198
+ const contentWidth = Math.max(1, max - visibleWidth(indent));
1199
+ if (!remaining) {
1200
+ rows.push(indent);
1201
+ continue;
1202
+ }
1203
+ while (visibleWidth(remaining) > contentWidth) {
1204
+ let [head, tail] = splitVisiblePrefix(remaining, contentWidth);
1205
+ const minSoftBreak = Math.floor(contentWidth * 0.45);
1206
+ const delimiterBreak = lastDelimiterBreak(head);
1207
+ const whitespace = head.match(/\s+\S*$/)?.index;
1208
+ if (delimiterBreak > minSoftBreak) {
1209
+ tail = head.slice(delimiterBreak) + tail;
1210
+ head = head.slice(0, delimiterBreak);
1211
+ } else if (whitespace !== undefined && whitespace > minSoftBreak) {
1212
+ tail = head.slice(whitespace).trimStart() + tail;
1213
+ head = head.slice(0, whitespace).trimEnd();
1214
+ }
1215
+ rows.push(indent + head);
1216
+ remaining = tail.trimStart();
1217
+ }
1218
+ rows.push(indent + remaining);
1219
+ }
1220
+ return rows.length ? rows : [""];
1221
+ }
1222
+
1223
+ function lastDelimiterBreak(value: string): number {
1224
+ let last = -1;
1225
+ for (const match of value.matchAll(/[\/,}:]/g)) last = match.index + 1;
1226
+ return last;
1227
+ }
1228
+
1229
+ function splitVisiblePrefix(value: string, width: number): [string, string] {
1230
+ const max = Math.max(0, Math.floor(width));
1231
+ let visible = 0;
1232
+ let index = 0;
1233
+ while (index < value.length && visible < max) {
1234
+ if (value[index] === "\u001b" || value[index] === "\u009b") {
1235
+ const match = value.slice(index).match(ANSI_RE);
1236
+ if (match?.index === 0) {
1237
+ index += match[0].length;
1238
+ continue;
1239
+ }
1240
+ }
1241
+ if (value[index] === "<") {
1242
+ const close = value.indexOf(">", index);
1243
+ if (close !== -1) {
1244
+ const tag = value.slice(index, close + 1);
1245
+ if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
1246
+ index = close + 1;
1247
+ continue;
1248
+ }
1249
+ }
1250
+ }
1251
+ const codePoint = value.codePointAt(index)!;
1252
+ index += codePoint > 0xFFFF ? 2 : 1;
1253
+ visible += 1;
1254
+ }
1255
+ return [value.slice(0, index), value.slice(index)];
1256
+ }
1257
+
1045
1258
  function cycleLogTailRows(current: number): number {
1046
1259
  const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
1047
1260
  return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
package/src/tools.ts CHANGED
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { readLog } from "./logs.js";
4
4
  import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
5
+ import { cancelCallbackBatch } from "./shared-callback-batcher.js";
5
6
  import { listMetas, readMeta, writeMeta } from "./registry.js";
6
7
  import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
7
8
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
@@ -89,9 +90,11 @@ export function registerTools(pi: ExtensionAPI): void {
89
90
  });
90
91
  pi.on("session_before_switch", () => {
91
92
  activeSession = undefined;
93
+ cancelCallbackBatch(pi);
92
94
  });
93
95
  pi.on("session_shutdown", () => {
94
96
  activeSession = undefined;
97
+ cancelCallbackBatch(pi);
95
98
  });
96
99
 
97
100
  pi.registerTool({