pi-better-subagents 0.1.18 → 0.1.20

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/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";
@@ -836,7 +838,8 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
836
838
  function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
837
839
  const detail = navigatorDetail(id, now);
838
840
  if (!detail) return null;
839
- const logTailLines = options?.logTailLines ?? 10;
841
+ void options;
842
+ const transcript = readRunTranscript(id);
840
843
  const metadata = [
841
844
  { label: "provider", value: "Subagents" },
842
845
  { label: "model", value: detail.effort ? `${detail.model} · effort ${detail.effort}` : detail.model },
@@ -854,15 +857,65 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
854
857
  statusTone: statusTone(detail.status),
855
858
  subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
856
859
  metadata,
857
- // The shared navigator refreshes this provider on a coarse deadline while the
858
- // detail overlay is open. Read the selected logical tail rows each
859
- // time so the evidence behaves like `tail -f`, not a single parsed
860
- // activity/result snapshot.
861
- 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,
862
863
  footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
863
864
  };
864
865
  }
865
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
+
866
919
  function ensureSubagentProvider(): void {
867
920
  if (unregisterSubagentProvider) return;
868
921
  const provider: BackgroundWorkProvider = {
@@ -870,6 +923,7 @@ function ensureSubagentProvider(): void {
870
923
  label: "Subagents",
871
924
  priority: 10,
872
925
  visibleCount: () => navigatorRunningCount(),
926
+ showSection: (rows) => rows.some((row) => row.status === "running"),
873
927
  parentRow: (now) => mainAgentWorkRow(now),
874
928
  listRows: (now) => subagentWorkRows(now),
875
929
  detail: (id, now, options) => subagentWorkDetail(id, now, options),
@@ -961,6 +1015,7 @@ function ensureNavigator(ctx: ExtensionContext): void {
961
1015
  isOpenTrigger: (data: string) => matchesKey(data, Key.left),
962
1016
  matchKey: (data: string, keyId: string) => matchesKey(data, keyId),
963
1017
  truncate: truncateToWidth,
1018
+ createTranscriptComponent: createSubagentTranscriptComponent,
964
1019
  });
965
1020
  } catch { /* ignore */ }
966
1021
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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
@@ -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 = {
@@ -76,6 +83,7 @@ type NavigatorState = {
76
83
  deps?: HostDeps;
77
84
  lastHint?: string | null;
78
85
  lastMainListLines?: string[];
86
+ lastMainListSignature?: string;
79
87
  mainListWidgetInstalled?: boolean;
80
88
  mainListRequestRender?: () => void;
81
89
  mainListSelectedId?: string;
@@ -83,6 +91,7 @@ type NavigatorState = {
83
91
  mainListCloseArm?: { id: string; armedAt: number };
84
92
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
85
93
  mainListDeadlineScheduler?: RenderScheduler;
94
+ editorComponent?: Component;
86
95
  detailOverlayRows?: number;
87
96
  dispose?: () => void;
88
97
  };
@@ -99,11 +108,9 @@ export const CLOSE_ARM_MS = 3000;
99
108
  export const DEFAULT_LOG_TAIL_ROWS = 10;
100
109
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
101
110
  const MAIN_LIST_FALLBACK_WIDTH = 100;
102
- const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
103
- const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
111
+ const DETAIL_OVERLAY_FOOTER_ROWS = 3;
104
112
  const EVIDENCE_SECTION_ID = "__evidence__";
105
113
  const RUNNING_DOT_GLYPH = "●";
106
- const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
107
114
 
108
115
  function state(): NavigatorState {
109
116
  const g = globalThis as typeof globalThis & { [GLOBAL_KEY]?: NavigatorState };
@@ -192,6 +199,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
192
199
  }
193
200
  s.lastHint = undefined;
194
201
  s.lastMainListLines = undefined;
202
+ s.lastMainListSignature = undefined;
195
203
  s.mainListDeadlineScheduler?.dispose();
196
204
  s.mainListDeadlineScheduler = undefined;
197
205
  s.mainListWidgetInstalled = false;
@@ -256,6 +264,12 @@ function refreshMainListWidget(): void {
256
264
  s.mainListDeadlineScheduler?.schedule(nextExpiry - now);
257
265
  }
258
266
  syncMainListSelection(rows);
267
+ const nextSignature = rows.length ? mainListSignature(rows, {
268
+ selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
269
+ focused: s.mainListFocused === true,
270
+ }) : undefined;
271
+ const changed = nextSignature !== s.lastMainListSignature;
272
+ s.lastMainListSignature = nextSignature;
259
273
  s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
260
274
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
261
275
  focused: s.mainListFocused === true,
@@ -265,6 +279,7 @@ function refreshMainListWidget(): void {
265
279
  try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
266
280
  s.mainListWidgetInstalled = false;
267
281
  s.mainListRequestRender = undefined;
282
+ s.lastMainListSignature = undefined;
268
283
  return;
269
284
  }
270
285
  if (!s.mainListWidgetInstalled) {
@@ -273,7 +288,9 @@ function refreshMainListWidget(): void {
273
288
  s.mainListWidgetInstalled = true;
274
289
  } catch { /* ignore */ }
275
290
  }
276
- try { s.mainListRequestRender?.(); } catch { /* ignore */ }
291
+ if (changed) {
292
+ try { s.mainListRequestRender?.(); } catch { /* ignore */ }
293
+ }
277
294
  }
278
295
 
279
296
  function themeFg(ctx: ExtensionContext): (color: string, value: string) => string {
@@ -315,7 +332,7 @@ function renderWidth(width: number): number {
315
332
  return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
316
333
  }
317
334
 
318
- type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
335
+ type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string; parentRow?: boolean };
319
336
 
320
337
  function rowKey(providerId: string, id: string): string {
321
338
  return `${providerId}:${id}`;
@@ -333,6 +350,19 @@ function listRows(now = Date.now()): InternalRow[] {
333
350
  let providerRows: BackgroundWorkRow[] = [];
334
351
  try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
335
352
  const orderedProviderRows = [...providerRows].sort((a, b) => b.sortStartedAt - a.sortStartedAt || rowDisplayName(a).localeCompare(rowDisplayName(b)));
353
+ let showSection = orderedProviderRows.length > 0 || provider.parentRow !== undefined;
354
+ try { showSection = provider.showSection?.(orderedProviderRows, now) ?? showSection; } catch { showSection = false; }
355
+ if (!showSection) continue;
356
+ let parentRow: BackgroundWorkRow | null = null;
357
+ try { parentRow = provider.parentRow?.(now) ?? null; } catch { parentRow = null; }
358
+ if (parentRow) {
359
+ rows.push({
360
+ ...parentRow,
361
+ navigatorId: rowKey(parentRow.providerId, parentRow.id),
362
+ providerLabel: provider.label,
363
+ parentRow: true,
364
+ });
365
+ }
336
366
  for (const row of orderedProviderRows) {
337
367
  rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
338
368
  }
@@ -378,7 +408,7 @@ function focusMainList(): void {
378
408
  const rows = listRows();
379
409
  if (rows.length === 0) return;
380
410
  const s = state();
381
- syncMainListSelection(rows);
411
+ s.mainListSelectedId = rows.find((row) => row.parentRow && row.id === "main")?.navigatorId ?? rows[0]!.navigatorId;
382
412
  s.mainListFocused = true;
383
413
  refreshMainListWidget();
384
414
  }
@@ -412,16 +442,6 @@ function buildMainListLines(
412
442
  const group = grouped.get(label)!;
413
443
  if (i > 0) lines.push("");
414
444
  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
445
  for (const row of group) {
426
446
  const selected = options.focused && row.navigatorId === options.selectedId;
427
447
  lines.push(formatMainListRow(row, selected === true, fg, width));
@@ -433,13 +453,13 @@ function buildMainListLines(
433
453
  }
434
454
 
435
455
  function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
436
- const keys = focused ? "↑↓ select · Enter detail · x stop · Esc unfocus" : "← to navigate";
456
+ const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
437
457
  return dim(keys, fg);
438
458
  }
439
459
 
440
460
  function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
441
461
  const normalized = singleLine(label).toLowerCase();
442
- return `${dim("▸", fg)} ${fg("warning", normalized)}`;
462
+ return fg("warning", normalized);
443
463
  }
444
464
 
445
465
  function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
@@ -471,9 +491,40 @@ function statusGlyph(row: InternalRow): string {
471
491
  }
472
492
 
473
493
  function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
494
+ void now;
474
495
  if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
475
- const frame = Math.floor(now / DETAIL_TICK_MS) % RUNNING_DOT_FRAMES.length;
476
- return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
496
+ return { glyph: RUNNING_DOT_GLYPH, color: "accent" };
497
+ }
498
+
499
+ function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean } = {}): string {
500
+ return JSON.stringify({
501
+ focused: options.focused === true,
502
+ selectedId: options.focused === true ? options.selectedId ?? null : null,
503
+ rows: rows.map((row) => ({
504
+ navigatorId: row.navigatorId,
505
+ providerLabel: row.providerLabel,
506
+ parentRow: row.parentRow === true,
507
+ providerId: row.providerId,
508
+ id: row.id,
509
+ name: row.name ?? null,
510
+ model: row.model ?? null,
511
+ effort: row.effort ?? null,
512
+ tool: row.tool ?? null,
513
+ tokens: row.tokens ?? null,
514
+ command: row.command ?? null,
515
+ status: row.status,
516
+ statusTone: row.statusTone,
517
+ kind: row.kind,
518
+ primary: row.primary,
519
+ secondary: row.secondary ?? null,
520
+ facts: stableFacts(row.facts),
521
+ sortStartedAt: row.sortStartedAt,
522
+ })),
523
+ });
524
+ }
525
+
526
+ function stableFacts(facts: string[] | undefined): string[] {
527
+ return (facts ?? []).filter((fact) => !/\bleft$/.test(singleLine(fact)));
477
528
  }
478
529
 
479
530
  function rowSummary(row: InternalRow): string {
@@ -527,13 +578,14 @@ function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLin
527
578
  }
528
579
 
529
580
  function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
581
+ if (row.parentRow) return { action: "not-closable", providerId: row.providerId, id: row.id };
530
582
  const provider = state().providers.get(row.providerId);
531
583
  if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
532
584
  try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
533
585
  }
534
586
 
535
587
  function closeHintFor(row: InternalRow | undefined): string | null {
536
- if (!row) return null;
588
+ if (!row || row.parentRow) return null;
537
589
  const provider = state().providers.get(row.providerId);
538
590
  if (!provider) return null;
539
591
  try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
@@ -558,6 +610,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
558
610
  }
559
611
 
560
612
  function wrapEditor(inner: any, deps: HostDeps): unknown {
613
+ if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
561
614
  return new Proxy(inner, {
562
615
  get(target, prop) {
563
616
  if (prop === "handleInput") {
@@ -588,16 +641,24 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
588
641
  }
589
642
  if (deps.matchKey(data, "up")) {
590
643
  clearMainListCloseArm();
591
- if (moveMainListSelection(-1)) refreshMainListWidget();
644
+ if (moveMainListSelection(-1)) {
645
+ refreshMainListWidget();
646
+ if (!selectedMainListRow()?.parentRow) openNavigator();
647
+ }
592
648
  return true;
593
649
  }
594
650
  if (deps.matchKey(data, "down")) {
595
651
  clearMainListCloseArm();
596
- if (moveMainListSelection(1)) refreshMainListWidget();
652
+ if (moveMainListSelection(1)) {
653
+ refreshMainListWidget();
654
+ if (!selectedMainListRow()?.parentRow) openNavigator();
655
+ }
597
656
  return true;
598
657
  }
599
658
  if (deps.matchKey(data, "enter")) {
600
- openNavigator();
659
+ const selected = selectedMainListRow();
660
+ if (selected?.parentRow) unfocusMainList();
661
+ else openNavigator();
601
662
  return true;
602
663
  }
603
664
  if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
@@ -613,7 +674,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
613
674
 
614
675
  function handleMainListCloseKey(): void {
615
676
  const row = selectedMainListRow();
616
- if (!row) return;
677
+ if (!row || row.parentRow) return;
617
678
  const s = state();
618
679
  const now = Date.now();
619
680
  const arm = s.mainListCloseArm;
@@ -689,6 +750,8 @@ function createOverlayComponent(
689
750
  let closeArm: { id: string; armedAt: number } | undefined;
690
751
  let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
691
752
  let closed = false;
753
+ let transcriptDetail: BackgroundWorkDetail | null = null;
754
+ let transcriptComponent: Component | null = null;
692
755
 
693
756
  const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
694
757
 
@@ -704,12 +767,24 @@ function createOverlayComponent(
704
767
  });
705
768
 
706
769
  function refreshRows(): void {
707
- const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
770
+ const selectedId = state().mainListSelectedId ?? overlayState.rows[overlayState.selected]?.navigatorId;
708
771
  overlayState.rows = listRows();
709
772
  const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
710
773
  overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
711
774
  }
712
775
 
776
+ function selectOverlayRow(next: number): void {
777
+ overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), Math.max(0, next));
778
+ state().mainListSelectedId = overlayState.rows[overlayState.selected]?.navigatorId;
779
+ clearCloseArm();
780
+ refreshMainListWidget();
781
+ }
782
+
783
+ function activateSelectedRow(): void {
784
+ if (selectedRow()?.parentRow) close();
785
+ else openDetail();
786
+ }
787
+
713
788
  function clearCloseArm(): void {
714
789
  if (closeArmTimer) clearTimeout(closeArmTimer);
715
790
  closeArmTimer = undefined;
@@ -731,13 +806,16 @@ function createOverlayComponent(
731
806
  }
732
807
 
733
808
  function selectedRow(): InternalRow | undefined {
734
- if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
735
809
  return overlayState.rows[overlayState.selected];
736
810
  }
737
811
 
738
812
  function openDetail(): void {
739
813
  const row = selectedRow();
740
814
  if (!row) return;
815
+ if (row.parentRow) {
816
+ close();
817
+ return;
818
+ }
741
819
  clearCloseArm();
742
820
  detailId = row.navigatorId;
743
821
  expandedSections.clear();
@@ -808,10 +886,43 @@ function createOverlayComponent(
808
886
 
809
887
  return {
810
888
  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;
889
+ refreshRows();
890
+ const railLines = mode === "detail"
891
+ ? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
892
+ selectedId: selectedRow()?.navigatorId,
893
+ focused: true,
894
+ })
895
+ : [];
896
+ const editorLines = mode === "detail" ? renderEditorLines(width) : [];
897
+ const bottomLines = [...railLines, ...editorLines];
898
+ const overlayRows = state().detailOverlayRows;
899
+ const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
900
+ let contentLines: string[];
901
+ if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
902
+ if (transcriptDetail !== detail || !transcriptComponent) {
903
+ transcriptDetail = detail;
904
+ transcriptComponent = deps.createTranscriptComponent(detail, theme);
905
+ }
906
+ contentLines = buildTranscriptDetailLines(
907
+ detail,
908
+ transcriptComponent.render(width),
909
+ width,
910
+ deps.truncate,
911
+ fg,
912
+ { minRows: detailRows },
913
+ );
914
+ } else {
915
+ transcriptDetail = null;
916
+ transcriptComponent = null;
917
+ contentLines = mode === "detail"
918
+ ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
919
+ : buildListLines(overlayState, width, deps.truncate, fg);
920
+ }
921
+ if (mode !== "detail") return contentLines;
922
+ if (detailRows === undefined) return [...contentLines, ...bottomLines];
923
+ const fittedContent = contentLines.slice(0, detailRows);
924
+ while (fittedContent.length < detailRows) fittedContent.push("");
925
+ return [...fittedContent, ...bottomLines];
815
926
  },
816
927
  handleInput(data: string) {
817
928
  if (closed) return;
@@ -820,10 +931,25 @@ function createOverlayComponent(
820
931
  return;
821
932
  }
822
933
  if (mode === "detail") {
823
- if (deps.matchKey(data, "left")) close();
934
+ if (deps.matchKey(data, "up")) {
935
+ selectOverlayRow(overlayState.selected - 1);
936
+ activateSelectedRow();
937
+ }
938
+ else if (deps.matchKey(data, "down")) {
939
+ selectOverlayRow(overlayState.selected + 1);
940
+ activateSelectedRow();
941
+ }
942
+ else if (deps.matchKey(data, "left")) {
943
+ const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
944
+ if (mainIdx >= 0) selectOverlayRow(mainIdx);
945
+ close();
946
+ }
824
947
  else if (deps.matchKey(data, "enter")) {
825
- const sectionId = firstToggleableSectionId(detail);
826
- if (sectionId) {
948
+ const row = selectedRow();
949
+ if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
950
+ else {
951
+ const sectionId = firstToggleableSectionId(detail);
952
+ if (!sectionId) return;
827
953
  if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
828
954
  else expandedSections.add(sectionId);
829
955
  requestRender();
@@ -834,7 +960,10 @@ function createOverlayComponent(
834
960
  if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
835
961
  requestRender();
836
962
  }
837
- else if (deps.matchKey(data, "escape")) close();
963
+ else if (deps.matchKey(data, "escape")) {
964
+ unfocusMainList();
965
+ close();
966
+ }
838
967
  return;
839
968
  }
840
969
  if (deps.matchKey(data, "up")) {
@@ -851,7 +980,7 @@ function createOverlayComponent(
851
980
  close();
852
981
  }
853
982
  },
854
- invalidate() {},
983
+ invalidate() { transcriptComponent?.invalidate(); },
855
984
  dispose() {
856
985
  clearCloseArm();
857
986
  detailScheduler.dispose();
@@ -859,26 +988,61 @@ function createOverlayComponent(
859
988
  };
860
989
  }
861
990
 
991
+ function buildTranscriptDetailLines(
992
+ detail: BackgroundWorkDetail,
993
+ transcriptLines: string[],
994
+ width: number,
995
+ truncate: (s: string, width: number) => string,
996
+ fg: (color: string, value: string) => string,
997
+ options: { minRows?: number } = {},
998
+ ): string[] {
999
+ const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
1000
+ const lines: string[] = [
1001
+ fg("accent", rule(detail.title, width)),
1002
+ dim(` ← main · ${actions}`, fg),
1003
+ "",
1004
+ ` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
1005
+ ];
1006
+ if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
1007
+ for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
1008
+ lines.push("", dim(section("transcript", width), fg));
1009
+ if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1010
+ lines.push(...(transcriptLines.length ? transcriptLines : [" (no transcript yet)"]));
1011
+ lines.push("");
1012
+ const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
1013
+ padBeforeFooter(lines, footerLines.length, options.minRows);
1014
+ lines.push(...footerLines);
1015
+ return lines.map((line) => safeTruncate(line, width, truncate));
1016
+ }
1017
+
862
1018
  function detailOverlayOptions() {
863
- const navigatorRows = state().lastMainListLines?.length ?? 0;
864
- const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
1019
+ const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
865
1020
  return {
866
1021
  anchor: "top-left" as const,
867
1022
  width: "100%" as const,
868
1023
  maxHeight: "100%" as const,
869
1024
  margin: {
870
- top: DETAIL_OVERLAY_HEADER_MARGIN_ROWS,
1025
+ top: 0,
871
1026
  right: 0,
872
1027
  bottom: marginBottom,
873
1028
  left: 0,
874
1029
  },
875
1030
  visible: (_termWidth: number, termHeight: number) => {
876
- state().detailOverlayRows = Math.max(1, termHeight - DETAIL_OVERLAY_HEADER_MARGIN_ROWS - marginBottom);
1031
+ state().detailOverlayRows = Math.max(1, termHeight - marginBottom);
877
1032
  return true;
878
1033
  },
879
1034
  };
880
1035
  }
881
1036
 
1037
+ function renderEditorLines(width: number): string[] {
1038
+ try {
1039
+ const lines = state().editorComponent?.render(width);
1040
+ if (lines?.length) return lines;
1041
+ } catch { /* use an empty editor-shaped fallback */ }
1042
+ const border = "─".repeat(Math.max(1, width));
1043
+ return [border, "", border];
1044
+ }
1045
+
882
1046
  function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
883
1047
  return {
884
1048
  providerId: row.providerId,