pi-better-subagents 0.1.17 → 0.1.19

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/finalization.ts CHANGED
@@ -62,6 +62,13 @@ export function finalizeRun(
62
62
  if (outcome.incomplete) meta.failureReason = "incomplete-stream";
63
63
  meta.exitCode = code;
64
64
  meta.endedAt = Date.now();
65
+ const callback = meta.callback !== false;
66
+ if (callback
67
+ && meta.completionCallbackPendingAt === undefined
68
+ && meta.completionCallbackSentAt === undefined
69
+ && meta.completionCallbackSuppressedAt === undefined) {
70
+ meta.completionCallbackPendingAt = meta.endedAt;
71
+ }
65
72
  writeMeta(meta);
66
73
 
67
74
  const label = meta.name ? `${meta.name} (${id})` : id;
@@ -84,10 +91,9 @@ export function finalizeRun(
84
91
  /* ignore */
85
92
  }
86
93
 
87
- const callback = meta.callback !== false; // default: trigger completion
88
- // buildCompletionDelivery is the single place sendMessage content/options are
89
- // assembled. resultText is accepted here so callers/tests can pass it without
90
- // breaking, but it is NEVER put into content — the result lives in subagent_result.
94
+ // buildCompletionDelivery remains the compatibility formatter for callers and
95
+ // direct finalizer tests. Production callback:true delivery is coalesced by the
96
+ // host wrapper; callback:false never invokes a model-message hook.
91
97
  const delivery = buildCompletionDelivery({
92
98
  id,
93
99
  label,
@@ -98,10 +104,12 @@ export function finalizeRun(
98
104
  lifecycleClassification: outcome.classification,
99
105
  resultText: r.finalText || r.lastActivity || "",
100
106
  });
101
- hooks.sendMessage?.(
102
- { customType: "subagent-complete", content: delivery.content, display: true },
103
- delivery.options,
104
- );
107
+ if (callback) {
108
+ hooks.sendMessage?.(
109
+ { customType: "subagent-complete", content: delivery.content, display: true },
110
+ delivery.options,
111
+ );
112
+ }
105
113
 
106
114
  return {
107
115
  applied: true,
package/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  import { execSync } from "node:child_process";
15
15
  import { writeFileSync, mkdirSync, statSync } from "node:fs";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
18
+ import * as PiTui from "@earendil-works/pi-tui";
17
19
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
18
20
  import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
19
21
  import { Type } from "@earendil-works/pi-ai";
@@ -30,7 +32,7 @@ import {
30
32
  type BackgroundWorkRow,
31
33
  } from "./shared-navigator.ts";
32
34
  import { spawnDetached, type SpawnResult } from "./spawn.ts";
33
- import { parseRun, resetParseRunCursor, tailLog, type Usage } from "./parse.ts";
35
+ import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./parse.ts";
34
36
  import { finalizeRun as finalizeRunCore } from "./finalization.ts";
35
37
  import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
36
38
  import { resolveExtensions, extensionArgs } from "./extensions.ts";
@@ -84,6 +86,7 @@ import {
84
86
  getSharedCapacityGate,
85
87
  } from "./capacity.mjs";
86
88
  import { buildHealthCallbackDelivery } from "./completion.ts";
89
+ import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
87
90
  import {
88
91
  text,
89
92
  subagentListTool,
@@ -237,12 +240,58 @@ function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
237
240
 
238
241
  function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
239
242
  const meta = readMeta(id);
240
- if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
243
+ if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
241
244
  meta.completionCallbackSuppressedAt = now;
242
245
  meta.completionCallbackSuppressedReason = reason;
243
246
  writeMeta(meta);
244
247
  }
245
248
 
249
+ function markCompletionCallbackSent(id: string, now: number): void {
250
+ const meta = readMeta(id);
251
+ if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
252
+ meta.completionCallbackSentAt = now;
253
+ writeMeta(meta);
254
+ }
255
+
256
+ /** Queue one durable ordinary terminal event on the host-shared batch. */
257
+ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
258
+ const meta = readMeta(id);
259
+ if (!meta
260
+ || meta.callback === false
261
+ || meta.completionCallbackPendingAt === undefined
262
+ || meta.completionCallbackSentAt !== undefined
263
+ || meta.completionCallbackSuppressedAt !== undefined) return;
264
+ const label = meta.name ? `${meta.name} (${id})` : id;
265
+ getCallbackBatcher(pi).enqueue({
266
+ source: "subagent",
267
+ id,
268
+ label,
269
+ status: meta.status,
270
+ detailTool: "subagent_result",
271
+ callback: true,
272
+ isDelivered: () => {
273
+ const current = readMeta(id);
274
+ return current?.completionCallbackSentAt !== undefined
275
+ || current?.completionCallbackSuppressedAt !== undefined;
276
+ },
277
+ getSuppressionReason: () => {
278
+ const current = readMeta(id);
279
+ if (!current) return "subagent metadata is unavailable";
280
+ return callbackSuppressionReason(current);
281
+ },
282
+ onDelivered: (at) => markCompletionCallbackSent(id, at),
283
+ onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
284
+ });
285
+ }
286
+
287
+ /** Recover only records explicitly marked pending; legacy terminal runs never replay. */
288
+ function recoverCompletionCallbacks(pi: ExtensionAPI): void {
289
+ for (const meta of listMetas()) {
290
+ if (!ownedByThisParent(meta)) continue;
291
+ enqueueCompletionCallback(pi, meta.id);
292
+ }
293
+ }
294
+
246
295
  function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
247
296
  if (status === "orphaned") {
248
297
  if (meta.orphanedCallbackSuppressedAt !== undefined) return;
@@ -409,12 +458,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
409
458
  if (!pi) return;
410
459
  if (isHealthCallbackHandled(meta, status)) return;
411
460
 
412
- const suppressionReason = callbackSuppressionReason(meta);
413
- if (suppressionReason) {
414
- markHealthCallbackSuppressed(meta, status, suppressionReason, now);
415
- return;
416
- }
417
-
418
461
  const callback = meta.callback !== false;
419
462
  const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
420
463
  const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
@@ -426,20 +469,35 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
426
469
  writeMeta(meta);
427
470
  return;
428
471
  }
429
- try {
430
- pi.sendMessage(
431
- { customType: "subagent-health", content: delivery.content, display: true },
432
- delivery.options,
433
- );
434
- } catch {
435
- // Handoff failed — leave marker unset so a later tick/reload can retry.
436
- // Never let a delivery failure break the health ticker.
437
- return;
438
- }
439
- // Marker = successful handoff (sendMessage returned), not mere attempt.
440
- if (status === "orphaned") meta.orphanedCallbackSentAt = now;
441
- else meta.lostCallbackSentAt = now;
442
- writeMeta(meta);
472
+ void getCallbackBatcher(pi).deliverUrgent({
473
+ source: "subagent",
474
+ id: meta.id,
475
+ label,
476
+ status,
477
+ customType: "subagent-health",
478
+ content: delivery.content,
479
+ isDelivered: () => {
480
+ const current = readMeta(meta.id);
481
+ return current ? isHealthCallbackHandled(current, status) : true;
482
+ },
483
+ getSuppressionReason: () => {
484
+ const current = readMeta(meta.id);
485
+ if (!current) return "subagent metadata is unavailable";
486
+ return callbackSuppressionReason(current);
487
+ },
488
+ onDelivered: (at) => {
489
+ const current = readMeta(meta.id);
490
+ if (!current || isHealthCallbackHandled(current, status)) return;
491
+ if (status === "orphaned") current.orphanedCallbackSentAt = at;
492
+ else current.lostCallbackSentAt = at;
493
+ writeMeta(current);
494
+ },
495
+ onSuppressed: (reason, at) => {
496
+ const current = readMeta(meta.id);
497
+ if (!current || isHealthCallbackHandled(current, status)) return;
498
+ markHealthCallbackSuppressed(current, status, reason, at);
499
+ },
500
+ });
443
501
  }
444
502
 
445
503
  /** One reconciliation + durable health-callback recovery pass. */
@@ -780,7 +838,8 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
780
838
  function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
781
839
  const detail = navigatorDetail(id, now);
782
840
  if (!detail) return null;
783
- const logTailLines = options?.logTailLines ?? 10;
841
+ void options;
842
+ const transcript = readRunTranscript(id);
784
843
  const metadata = [
785
844
  { label: "provider", value: "Subagents" },
786
845
  { label: "model", value: detail.effort ? `${detail.model} · effort ${detail.effort}` : detail.model },
@@ -798,15 +857,65 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
798
857
  statusTone: statusTone(detail.status),
799
858
  subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
800
859
  metadata,
801
- // The shared navigator refreshes this provider on a coarse deadline while the
802
- // detail overlay is open. Read the selected logical tail rows each
803
- // time so the evidence behaves like `tail -f`, not a single parsed
804
- // activity/result snapshot.
805
- evidence: { label: "log tail", text: tailLog(id, logTailLines) },
860
+ evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
861
+ transcript: transcript.entries,
862
+ transcriptDiagnostic: transcript.diagnostic,
806
863
  footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
807
864
  };
808
865
  }
809
866
 
867
+ function createSubagentTranscriptComponent(detail: BackgroundWorkDetail, theme: unknown) {
868
+ const ContainerComponent = (PiTui as any).Container;
869
+ const AssistantComponent = (PiCodingAgent as any).AssistantMessageComponent;
870
+ const ToolComponent = (PiCodingAgent as any).ToolExecutionComponent;
871
+ const markdownTheme = typeof (PiCodingAgent as any).getMarkdownTheme === "function"
872
+ ? (PiCodingAgent as any).getMarkdownTheme()
873
+ : {};
874
+ if (!ContainerComponent || !AssistantComponent || !ToolComponent) {
875
+ return {
876
+ render: () => (detail.transcript ?? []).flatMap((entry) => entry.type === "assistant"
877
+ ? entry.content.filter((block) => block.type === "text").flatMap((block) => String(block.text ?? "").split("\n"))
878
+ : [`[${entry.state}] ${entry.name}`]),
879
+ invalidate() {},
880
+ };
881
+ }
882
+ const container = new ContainerComponent();
883
+ const ui = { requestRender: () => { try { (uiCtx?.ui as any)?.requestRender?.(); } catch { /* ignore */ } } };
884
+ for (const entry of detail.transcript ?? []) {
885
+ if (entry.type === "assistant") {
886
+ const message = {
887
+ role: "assistant" as const,
888
+ content: entry.content,
889
+ stopReason: entry.streaming ? undefined : "stop",
890
+ timestamp: Date.now(),
891
+ };
892
+ const component = new AssistantComponent(message as any, true, markdownTheme, "Thinking...", 1);
893
+ component.updateContent(message as any, entry.streaming);
894
+ container.addChild(component);
895
+ continue;
896
+ }
897
+ const component = new ToolComponent(
898
+ entry.name,
899
+ entry.id ?? `transcript-${entry.name}`,
900
+ entry.args ?? {},
901
+ { showImages: false },
902
+ undefined,
903
+ ui as any,
904
+ uiCtx?.cwd ?? process.cwd(),
905
+ );
906
+ component.markExecutionStarted();
907
+ component.setArgsComplete();
908
+ if (entry.state === "completed") {
909
+ const result = entry.result && typeof entry.result === "object"
910
+ ? entry.result as any
911
+ : { content: entry.result == null ? [] : [{ type: "text", text: String(entry.result) }] };
912
+ component.updateResult({ ...result, isError: entry.isError });
913
+ }
914
+ container.addChild(component);
915
+ }
916
+ return container;
917
+ }
918
+
810
919
  function ensureSubagentProvider(): void {
811
920
  if (unregisterSubagentProvider) return;
812
921
  const provider: BackgroundWorkProvider = {
@@ -814,6 +923,7 @@ function ensureSubagentProvider(): void {
814
923
  label: "Subagents",
815
924
  priority: 10,
816
925
  visibleCount: () => navigatorRunningCount(),
926
+ showSection: (rows) => rows.some((row) => row.status === "running"),
817
927
  parentRow: (now) => mainAgentWorkRow(now),
818
928
  listRows: (now) => subagentWorkRows(now),
819
929
  detail: (id, now, options) => subagentWorkDetail(id, now, options),
@@ -905,6 +1015,7 @@ function ensureNavigator(ctx: ExtensionContext): void {
905
1015
  isOpenTrigger: (data: string) => matchesKey(data, Key.left),
906
1016
  matchKey: (data: string, keyId: string) => matchesKey(data, keyId),
907
1017
  truncate: truncateToWidth,
1018
+ createTranscriptComponent: createSubagentTranscriptComponent,
908
1019
  });
909
1020
  } catch { /* ignore */ }
910
1021
  }
@@ -935,16 +1046,7 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
935
1046
  notify: (message, level) => {
936
1047
  try { ctx.ui.notify(message, level); } catch { /* ignore */ }
937
1048
  },
938
- sendMessage: (message, options) => {
939
- const meta = readMeta(id);
940
- if (!meta) return;
941
- const suppressionReason = callbackSuppressionReason(meta);
942
- if (suppressionReason) {
943
- markCompletionCallbackSuppressed(id, suppressionReason);
944
- return;
945
- }
946
- pi.sendMessage(message, options);
947
- },
1049
+ sendMessage: () => enqueueCompletionCallback(pi, id),
948
1050
  });
949
1051
  }
950
1052
 
@@ -1401,6 +1503,7 @@ export default function (pi: ExtensionAPI) {
1401
1503
  catch { mainAgentStartedAt = undefined; }
1402
1504
  mainAgentTools.clear();
1403
1505
  activeCallbackOrigin = callbackOriginFromContext(ctx);
1506
+ recoverCompletionCallbacks(pi);
1404
1507
  // Reload / session switch hardening (#48):
1405
1508
  // - Drop any leftover overlay timers/confirm state from a prior session
1406
1509
  // (defensive if the host skipped session_shutdown before re-start).
@@ -1432,6 +1535,7 @@ export default function (pi: ExtensionAPI) {
1432
1535
 
1433
1536
  pi.on("session_before_switch", () => {
1434
1537
  activeCallbackOrigin = undefined;
1538
+ cancelCallbackBatch(pi);
1435
1539
  mainAgentStartedAt = undefined;
1436
1540
  mainAgentTools.clear();
1437
1541
  disposeBackgroundWorkNavigator();
@@ -1444,6 +1548,7 @@ export default function (pi: ExtensionAPI) {
1444
1548
  // cannot become live process groups with no coordinator.
1445
1549
  stopCurrentSessionSubagents(ctx);
1446
1550
  activeCallbackOrigin = undefined;
1551
+ cancelCallbackBatch(pi);
1447
1552
  mainAgentStartedAt = undefined;
1448
1553
  mainAgentTools.clear();
1449
1554
  stopTicker();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/parse.ts CHANGED
@@ -30,7 +30,7 @@ import { readBoundedTail, tailTerminalDisplay } from "./shared-log-utils.ts";
30
30
  import { readAppendedLines, type LogCursor } from "./log-cursor.ts";
31
31
  import { logPathFor } from "./registry.ts";
32
32
 
33
- interface ContentBlock { type: string; text?: string; name?: string }
33
+ interface ContentBlock { type: string; text?: string; thinking?: string; name?: string }
34
34
  interface Cost { total?: number }
35
35
  interface MsgUsage { input?: number; output?: number; cacheRead?: number; cost?: Cost }
36
36
  interface Msg { role?: string; content?: string | ContentBlock[]; usage?: MsgUsage }
@@ -130,6 +130,121 @@ export interface ParsedRun {
130
130
  diagnostics: string[];
131
131
  }
132
132
 
133
+ export type TranscriptEntry =
134
+ | { type: "assistant"; content: ContentBlock[]; streaming: boolean }
135
+ | {
136
+ type: "tool";
137
+ id?: string;
138
+ name: string;
139
+ args?: unknown;
140
+ result?: unknown;
141
+ isError: boolean;
142
+ state: "running" | "completed";
143
+ };
144
+
145
+ export interface RunTranscript {
146
+ entries: TranscriptEntry[];
147
+ truncated: boolean;
148
+ diagnostic?: string;
149
+ }
150
+
151
+ const DEFAULT_TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
152
+ const DEFAULT_TRANSCRIPT_ENTRIES = 40;
153
+
154
+ function transcriptContent(msg: Msg | undefined): ContentBlock[] {
155
+ if (!msg) return [];
156
+ if (typeof msg.content === "string") {
157
+ return msg.content.trim() ? [{ type: "text", text: msg.content }] : [];
158
+ }
159
+ if (!Array.isArray(msg.content)) return [];
160
+ return msg.content.filter((block) =>
161
+ block && (
162
+ (block.type === "text" && typeof block.text === "string" && block.text.trim()) ||
163
+ (block.type === "thinking" && typeof (block as { thinking?: unknown }).thinking === "string")
164
+ ),
165
+ );
166
+ }
167
+
168
+ /** Bounded, display-oriented transcript fold for the subagent detail view. */
169
+ export function readRunTranscript(
170
+ id: string,
171
+ options: { maxBytes?: number; maxEntries?: number } = {},
172
+ ): RunTranscript {
173
+ const maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES);
174
+ const maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_TRANSCRIPT_ENTRIES);
175
+ const tail = readTail(logPathFor(id), maxBytes);
176
+ if (tail.error) return { entries: [], truncated: false, diagnostic: `Log unreadable: ${tail.error}` };
177
+
178
+ const lines = tail.text.split(/\r?\n/);
179
+ if (tail.truncated) lines.shift();
180
+ const entries: TranscriptEntry[] = [];
181
+ const tools = new Map<string, Extract<TranscriptEntry, { type: "tool" }>>();
182
+ let anonymousTool = 0;
183
+ let liveAssistant: Extract<TranscriptEntry, { type: "assistant" }> | undefined;
184
+
185
+ for (const line of lines) {
186
+ const event = tryParse(line.trim());
187
+ if (!event) continue;
188
+ const type = event.type;
189
+ if (type === "message_end") {
190
+ const message = event.message as Msg | undefined;
191
+ if (message?.role !== "assistant") continue;
192
+ const content = transcriptContent(message);
193
+ if (content.length) entries.push({ type: "assistant", content, streaming: false });
194
+ liveAssistant = undefined;
195
+ continue;
196
+ }
197
+ if (type === "message_update") {
198
+ const message = event.message as Msg | undefined;
199
+ if (message?.role !== "assistant") continue;
200
+ const content = transcriptContent(message);
201
+ if (content.length) liveAssistant = { type: "assistant", content, streaming: true };
202
+ continue;
203
+ }
204
+ if (type === "tool_execution_start") {
205
+ const idValue = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
206
+ const key = idValue ?? `anonymous:${anonymousTool++}`;
207
+ const tool: Extract<TranscriptEntry, { type: "tool" }> = {
208
+ type: "tool",
209
+ id: idValue,
210
+ name: typeof event.toolName === "string" ? event.toolName : "unknown",
211
+ args: event.args,
212
+ isError: false,
213
+ state: "running",
214
+ };
215
+ entries.push(tool);
216
+ tools.set(key, tool);
217
+ continue;
218
+ }
219
+ if (type === "tool_execution_end") {
220
+ const idValue = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
221
+ let tool = idValue ? tools.get(idValue) : undefined;
222
+ if (!tool && typeof event.toolName === "string") {
223
+ tool = [...tools.values()].reverse().find((candidate) => candidate.name === event.toolName && candidate.state === "running");
224
+ }
225
+ if (!tool) {
226
+ tool = {
227
+ type: "tool",
228
+ id: idValue,
229
+ name: typeof event.toolName === "string" ? event.toolName : "unknown",
230
+ isError: event.isError === true,
231
+ state: "completed",
232
+ };
233
+ entries.push(tool);
234
+ }
235
+ tool.result = event.result;
236
+ tool.isError = event.isError === true;
237
+ tool.state = "completed";
238
+ }
239
+ }
240
+ if (liveAssistant) entries.push(liveAssistant);
241
+ return {
242
+ entries: entries.slice(-maxEntries),
243
+ truncated: tail.truncated || entries.length > maxEntries,
244
+ diagnostic: tail.truncated ? `Showing the latest ${fmtBytes(maxBytes)} of the transcript.` : undefined,
245
+ };
246
+ }
247
+
133
248
  /**
134
249
  * Authoritative lifecycle evidence scanned from the complete NDJSON stream.
135
250
  * Kept separate from parseRun()'s bounded tail so large-log result parsing stays
package/registry.ts CHANGED
@@ -102,6 +102,9 @@ export interface RunMeta {
102
102
  sessionId: string;
103
103
  /** Foreground session that is allowed to receive unsolicited callbacks. */
104
104
  callbackOrigin?: RunCallbackOrigin;
105
+ /** Durable ordinary-completion callback recovery and successful-handoff markers. */
106
+ completionCallbackPendingAt?: number;
107
+ completionCallbackSentAt?: number;
105
108
  completionCallbackSuppressedAt?: number;
106
109
  completionCallbackSuppressedReason?: string;
107
110
  orphanedCallbackSuppressedAt?: number;
@@ -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,7 @@ export type BackgroundWorkProvider = {
54
59
  label: string;
55
60
  priority: number;
56
61
  visibleCount(): number;
62
+ showSection?(rows: BackgroundWorkRow[], now: number): boolean;
57
63
  parentRow?(now: number): BackgroundWorkRow | null;
58
64
  listRows(now: number): BackgroundWorkRow[];
59
65
  detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
@@ -67,6 +73,7 @@ type HostDeps = {
67
73
  isOpenTrigger: (data: string) => boolean;
68
74
  matchKey: (data: string, keyId: string) => boolean;
69
75
  truncate: (s: string, width: number) => string;
76
+ createTranscriptComponent?: (detail: BackgroundWorkDetail, theme: unknown) => Component;
70
77
  };
71
78
 
72
79
  type NavigatorState = {
@@ -83,6 +90,7 @@ type NavigatorState = {
83
90
  mainListCloseArm?: { id: string; armedAt: number };
84
91
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
85
92
  mainListDeadlineScheduler?: RenderScheduler;
93
+ editorComponent?: Component;
86
94
  detailOverlayRows?: number;
87
95
  dispose?: () => void;
88
96
  };
@@ -99,8 +107,7 @@ export const CLOSE_ARM_MS = 3000;
99
107
  export const DEFAULT_LOG_TAIL_ROWS = 10;
100
108
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
101
109
  const MAIN_LIST_FALLBACK_WIDTH = 100;
102
- const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
103
- const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
110
+ const DETAIL_OVERLAY_FOOTER_ROWS = 3;
104
111
  const EVIDENCE_SECTION_ID = "__evidence__";
105
112
  const RUNNING_DOT_GLYPH = "●";
106
113
  const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
@@ -315,7 +322,7 @@ function renderWidth(width: number): number {
315
322
  return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
316
323
  }
317
324
 
318
- type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
325
+ type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string; parentRow?: boolean };
319
326
 
320
327
  function rowKey(providerId: string, id: string): string {
321
328
  return `${providerId}:${id}`;
@@ -333,6 +340,19 @@ function listRows(now = Date.now()): InternalRow[] {
333
340
  let providerRows: BackgroundWorkRow[] = [];
334
341
  try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
335
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
+ }
336
356
  for (const row of orderedProviderRows) {
337
357
  rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
338
358
  }
@@ -378,7 +398,7 @@ function focusMainList(): void {
378
398
  const rows = listRows();
379
399
  if (rows.length === 0) return;
380
400
  const s = state();
381
- syncMainListSelection(rows);
401
+ s.mainListSelectedId = rows.find((row) => row.parentRow && row.id === "main")?.navigatorId ?? rows[0]!.navigatorId;
382
402
  s.mainListFocused = true;
383
403
  refreshMainListWidget();
384
404
  }
@@ -412,16 +432,6 @@ function buildMainListLines(
412
432
  const group = grouped.get(label)!;
413
433
  if (i > 0) lines.push("");
414
434
  lines.push(providerGroupLabel(label, fg));
415
- const provider = state().providers.get(group[0]!.providerId);
416
- let parentRow: BackgroundWorkRow | null = null;
417
- try { parentRow = provider?.parentRow?.(Date.now()) ?? null; } catch { parentRow = null; }
418
- if (parentRow) {
419
- lines.push(formatMainListRow({
420
- ...parentRow,
421
- navigatorId: rowKey(parentRow.providerId, parentRow.id),
422
- providerLabel: label,
423
- }, false, fg, width));
424
- }
425
435
  for (const row of group) {
426
436
  const selected = options.focused && row.navigatorId === options.selectedId;
427
437
  lines.push(formatMainListRow(row, selected === true, fg, width));
@@ -433,13 +443,13 @@ function buildMainListLines(
433
443
  }
434
444
 
435
445
  function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
436
- 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";
437
447
  return dim(keys, fg);
438
448
  }
439
449
 
440
450
  function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
441
451
  const normalized = singleLine(label).toLowerCase();
442
- return `${dim("▸", fg)} ${fg("warning", normalized)}`;
452
+ return fg("warning", normalized);
443
453
  }
444
454
 
445
455
  function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
@@ -527,13 +537,14 @@ function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLin
527
537
  }
528
538
 
529
539
  function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
540
+ if (row.parentRow) return { action: "not-closable", providerId: row.providerId, id: row.id };
530
541
  const provider = state().providers.get(row.providerId);
531
542
  if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
532
543
  try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
533
544
  }
534
545
 
535
546
  function closeHintFor(row: InternalRow | undefined): string | null {
536
- if (!row) return null;
547
+ if (!row || row.parentRow) return null;
537
548
  const provider = state().providers.get(row.providerId);
538
549
  if (!provider) return null;
539
550
  try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
@@ -558,6 +569,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
558
569
  }
559
570
 
560
571
  function wrapEditor(inner: any, deps: HostDeps): unknown {
572
+ if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
561
573
  return new Proxy(inner, {
562
574
  get(target, prop) {
563
575
  if (prop === "handleInput") {
@@ -588,16 +600,24 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
588
600
  }
589
601
  if (deps.matchKey(data, "up")) {
590
602
  clearMainListCloseArm();
591
- if (moveMainListSelection(-1)) refreshMainListWidget();
603
+ if (moveMainListSelection(-1)) {
604
+ refreshMainListWidget();
605
+ if (!selectedMainListRow()?.parentRow) openNavigator();
606
+ }
592
607
  return true;
593
608
  }
594
609
  if (deps.matchKey(data, "down")) {
595
610
  clearMainListCloseArm();
596
- if (moveMainListSelection(1)) refreshMainListWidget();
611
+ if (moveMainListSelection(1)) {
612
+ refreshMainListWidget();
613
+ if (!selectedMainListRow()?.parentRow) openNavigator();
614
+ }
597
615
  return true;
598
616
  }
599
617
  if (deps.matchKey(data, "enter")) {
600
- openNavigator();
618
+ const selected = selectedMainListRow();
619
+ if (selected?.parentRow) unfocusMainList();
620
+ else openNavigator();
601
621
  return true;
602
622
  }
603
623
  if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
@@ -613,7 +633,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
613
633
 
614
634
  function handleMainListCloseKey(): void {
615
635
  const row = selectedMainListRow();
616
- if (!row) return;
636
+ if (!row || row.parentRow) return;
617
637
  const s = state();
618
638
  const now = Date.now();
619
639
  const arm = s.mainListCloseArm;
@@ -689,6 +709,8 @@ function createOverlayComponent(
689
709
  let closeArm: { id: string; armedAt: number } | undefined;
690
710
  let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
691
711
  let closed = false;
712
+ let transcriptDetail: BackgroundWorkDetail | null = null;
713
+ let transcriptComponent: Component | null = null;
692
714
 
693
715
  const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
694
716
 
@@ -704,12 +726,24 @@ function createOverlayComponent(
704
726
  });
705
727
 
706
728
  function refreshRows(): void {
707
- const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
729
+ const selectedId = state().mainListSelectedId ?? overlayState.rows[overlayState.selected]?.navigatorId;
708
730
  overlayState.rows = listRows();
709
731
  const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
710
732
  overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
711
733
  }
712
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
+
713
747
  function clearCloseArm(): void {
714
748
  if (closeArmTimer) clearTimeout(closeArmTimer);
715
749
  closeArmTimer = undefined;
@@ -731,13 +765,16 @@ function createOverlayComponent(
731
765
  }
732
766
 
733
767
  function selectedRow(): InternalRow | undefined {
734
- if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
735
768
  return overlayState.rows[overlayState.selected];
736
769
  }
737
770
 
738
771
  function openDetail(): void {
739
772
  const row = selectedRow();
740
773
  if (!row) return;
774
+ if (row.parentRow) {
775
+ close();
776
+ return;
777
+ }
741
778
  clearCloseArm();
742
779
  detailId = row.navigatorId;
743
780
  expandedSections.clear();
@@ -808,10 +845,43 @@ function createOverlayComponent(
808
845
 
809
846
  return {
810
847
  render(width: number) {
811
- const lines = mode === "detail"
812
- ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: state().detailOverlayRows })
813
- : buildListLines(overlayState, width, deps.truncate, fg);
814
- 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];
815
885
  },
816
886
  handleInput(data: string) {
817
887
  if (closed) return;
@@ -820,10 +890,25 @@ function createOverlayComponent(
820
890
  return;
821
891
  }
822
892
  if (mode === "detail") {
823
- 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
+ }
824
906
  else if (deps.matchKey(data, "enter")) {
825
- const sectionId = firstToggleableSectionId(detail);
826
- 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;
827
912
  if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
828
913
  else expandedSections.add(sectionId);
829
914
  requestRender();
@@ -834,7 +919,10 @@ function createOverlayComponent(
834
919
  if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
835
920
  requestRender();
836
921
  }
837
- else if (deps.matchKey(data, "escape")) close();
922
+ else if (deps.matchKey(data, "escape")) {
923
+ unfocusMainList();
924
+ close();
925
+ }
838
926
  return;
839
927
  }
840
928
  if (deps.matchKey(data, "up")) {
@@ -851,7 +939,7 @@ function createOverlayComponent(
851
939
  close();
852
940
  }
853
941
  },
854
- invalidate() {},
942
+ invalidate() { transcriptComponent?.invalidate(); },
855
943
  dispose() {
856
944
  clearCloseArm();
857
945
  detailScheduler.dispose();
@@ -859,26 +947,61 @@ function createOverlayComponent(
859
947
  };
860
948
  }
861
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
+
862
977
  function detailOverlayOptions() {
863
- const navigatorRows = state().lastMainListLines?.length ?? 0;
864
- const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
978
+ const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
865
979
  return {
866
980
  anchor: "top-left" as const,
867
981
  width: "100%" as const,
868
982
  maxHeight: "100%" as const,
869
983
  margin: {
870
- top: DETAIL_OVERLAY_HEADER_MARGIN_ROWS,
984
+ top: 0,
871
985
  right: 0,
872
986
  bottom: marginBottom,
873
987
  left: 0,
874
988
  },
875
989
  visible: (_termWidth: number, termHeight: number) => {
876
- state().detailOverlayRows = Math.max(1, termHeight - DETAIL_OVERLAY_HEADER_MARGIN_ROWS - marginBottom);
990
+ state().detailOverlayRows = Math.max(1, termHeight - marginBottom);
877
991
  return true;
878
992
  },
879
993
  };
880
994
  }
881
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
+
882
1005
  function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
883
1006
  return {
884
1007
  providerId: row.providerId,