@tt-a1i/openpi 0.3.1 → 0.5.0

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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -5,8 +5,8 @@
5
5
  * name 5/5 agents · 31m18s · done
6
6
  * description
7
7
  * ╭ Phases ────────────╮ ╭ Gather · 3 agents ──────────────────────────────╮
8
- * │ ❯ Gather 3/3 │ │ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
9
- * │ Verify 1/1 │ │ Other bot feedback gpt-5 · 9%/372k 4m43s│
8
+ * │ ❯ Gather 3/3 │ │ CodeRabbit feedback gpt-5 · 7%/372k 5m37s│
9
+ * │ Verify 1/1 │ │ Other bot feedback gpt-5 · 9%/372k 4m43s│
10
10
  * ╰────────────────────╯ ╰─────────────────────────────────────────────────╯
11
11
  * up/down select · right enter · left back · s save report
12
12
  */
@@ -14,56 +14,59 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import {
17
- getAgentDir,
18
17
  type ExtensionContext,
18
+ getAgentDir,
19
19
  type KeybindingsManager,
20
20
  } from "@earendil-works/pi-coding-agent";
21
+ import { type TUI, truncateToWidth } from "@earendil-works/pi-tui";
22
+ import { AgentSessionPage } from "../shared/agent-session-page.ts";
23
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
24
+ import { contextPercent } from "../shared/context-utilization.ts";
21
25
  import {
22
- Key,
23
- matchesKey,
24
- truncateToWidth,
25
- visibleWidth,
26
- wrapTextWithAnsi,
27
- type TUI,
28
- } from "@earendil-works/pi-tui";
26
+ panelFrame,
27
+ type ScreenHint,
28
+ screenTitleLine,
29
+ hintLine as sharedHintLine,
30
+ } from "../shared/screen-chrome.ts";
31
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
32
+ import { sanitizeTerminalText } from "../shared/terminal-text.ts";
29
33
  import { isAcceptanceLedger } from "./acceptance.ts";
34
+ import { projectWorkflowGraph } from "./graph-projection.ts";
35
+ import {
36
+ classifyInterruptedInvocation,
37
+ decodeInvocationRecord,
38
+ } from "./invocation-ledger.ts";
30
39
  import {
40
+ type AgentRecord,
41
+ type AgentUsage,
31
42
  agentContext,
43
+ aggregateUsage,
32
44
  countStates,
33
45
  formatElapsed,
34
46
  formatUsage,
35
- aggregateUsage,
36
47
  isWorkflowRunId,
37
48
  MAX_LOG_TEXT,
49
+ type PhaseGroup,
38
50
  phaseGroups,
39
- resultJson,
40
51
  resolveWorkflowRunTarget,
52
+ resultJson,
41
53
  sanitizeLine,
42
54
  shortenHome,
43
- stateSquare,
55
+ stateGlyph,
44
56
  statusColor,
57
+ statusGlyph,
45
58
  statusWord,
46
- SQUARE,
47
59
  type Theme,
48
- type AgentRecord,
49
- type AgentUsage,
50
- type PhaseGroup,
51
60
  type TranscriptEntry,
52
61
  type WorkflowDetails,
53
62
  type WorkflowLogEntry,
54
63
  workflowGraphRecords,
55
64
  } from "./model.ts";
56
- import { sanitizeTerminalText } from "../shared/terminal-text.ts";
57
- import { projectWorkflowGraph } from "./graph-projection.ts";
58
- import {
59
- classifyInterruptedInvocation,
60
- decodeInvocationRecord,
61
- } from "./invocation-ledger.ts";
62
65
  import { writeFileAtomic } from "./serialization.ts";
66
+ import { WorkflowTranscriptAdapter } from "./transcript.ts";
63
67
 
64
68
  const NOTICE_TTL_MS = 4000;
65
69
  const MIN_HEIGHT = 10;
66
- const TRANSCRIPT_SCROLL_STEP = 20;
67
70
 
68
71
  function wrapSelection(index: number, delta: number, length: number): number {
69
72
  if (length === 0) return 0;
@@ -80,6 +83,80 @@ function runsDir(): string {
80
83
  return path.join(getAgentDir(), "workflows");
81
84
  }
82
85
 
86
+ /** Every persisted run id on disk; empty when no runs directory exists. */
87
+ export function listPersistedRunIds(): string[] {
88
+ try {
89
+ return fs.readdirSync(runsDir()).filter(isWorkflowRunId);
90
+ } catch {
91
+ // No runs yet.
92
+ }
93
+ return [];
94
+ }
95
+
96
+ /** Hydrate the result/transcript side artifacts referenced by workflow.json. */
97
+ function hydrateRunArtifacts(runId: string, details: WorkflowDetails) {
98
+ const runDir = path.join(runsDir(), runId);
99
+ if (details.resultArtifact) {
100
+ try {
101
+ details.result = JSON.parse(
102
+ fs.readFileSync(
103
+ path.join(runDir, path.basename(details.resultArtifact)),
104
+ "utf8",
105
+ ),
106
+ );
107
+ } catch {
108
+ // Keep the compact compatibility marker from workflow.json.
109
+ }
110
+ }
111
+ if (details.transcriptArtifact) {
112
+ try {
113
+ const transcripts = JSON.parse(
114
+ fs.readFileSync(
115
+ path.join(runDir, path.basename(details.transcriptArtifact)),
116
+ "utf8",
117
+ ),
118
+ ) as Record<string, unknown>;
119
+ for (const agent of details.agents) {
120
+ agent.transcript = normalizeTranscript(
121
+ transcripts[String(agent.index)],
122
+ );
123
+ }
124
+ } catch {
125
+ // Older or partially written artifacts simply lack transcripts.
126
+ }
127
+ }
128
+ }
129
+
130
+ export interface ReadPersistedRunOptions {
131
+ /** Hydrate result and transcript side artifacts referenced by workflow.json. */
132
+ hydrateArtifacts?: boolean;
133
+ }
134
+
135
+ /**
136
+ * The single read entry for a persisted workflow.json: parse, normalize
137
+ * (including runs written by older tooling), and optionally hydrate the side
138
+ * artifacts. Unreadable or invalid runs read as undefined. Stale "running"
139
+ * reconciliation stays with callers so selection filters can run on the
140
+ * recorded timestamps first (`recoverStaleWorkflowDetails`).
141
+ */
142
+ export function readPersistedWorkflowDetails(
143
+ runId: string,
144
+ options: ReadPersistedRunOptions = {},
145
+ ): WorkflowDetails | undefined {
146
+ let details: WorkflowDetails | undefined;
147
+ try {
148
+ const raw: unknown = JSON.parse(
149
+ fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
150
+ );
151
+ details = normalizePersistedWorkflowDetails(runId, raw);
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ if (!details) return undefined;
156
+ if (options.hydrateArtifacts) hydrateRunArtifacts(runId, details);
157
+ return details;
158
+ }
159
+
83
160
  function isWorktreeCleanup(
84
161
  value: unknown,
85
162
  ): value is NonNullable<AgentRecord["worktreeCleanup"]> {
@@ -117,6 +194,45 @@ function normalizeUsage(value: unknown): AgentUsage {
117
194
  };
118
195
  }
119
196
 
197
+ function normalizeDelivery(value: unknown): WorkflowDetails["delivery"] {
198
+ if (!value || typeof value !== "object") return undefined;
199
+ const record = value as Record<string, unknown>;
200
+ const state = record.state;
201
+ if (
202
+ state !== "none" &&
203
+ state !== "held-for-inline" &&
204
+ state !== "pending" &&
205
+ state !== "delivered" &&
206
+ state !== "consumed-inline"
207
+ ) {
208
+ return undefined;
209
+ }
210
+ if (typeof record.id !== "string" || record.id.length === 0) return undefined;
211
+ const attempts =
212
+ typeof record.attempts === "number" &&
213
+ Number.isSafeInteger(record.attempts) &&
214
+ record.attempts >= 0
215
+ ? record.attempts
216
+ : 0;
217
+ const updatedAt =
218
+ typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt)
219
+ ? record.updatedAt
220
+ : 0;
221
+ return {
222
+ id: sanitizeLine(record.id, 256),
223
+ state,
224
+ attempts,
225
+ updatedAt,
226
+ ...(typeof record.deliveredAt === "number" &&
227
+ Number.isFinite(record.deliveredAt)
228
+ ? { deliveredAt: record.deliveredAt }
229
+ : {}),
230
+ ...(typeof record.lastError === "string"
231
+ ? { lastError: sanitizeLine(record.lastError, 2_000) }
232
+ : {}),
233
+ };
234
+ }
235
+
120
236
  function normalizeTranscript(value: unknown): TranscriptEntry[] {
121
237
  if (!Array.isArray(value)) return [];
122
238
  const transcript: TranscriptEntry[] = [];
@@ -140,6 +256,10 @@ function normalizeTranscript(value: unknown): TranscriptEntry[] {
140
256
  typeof entry.name === "string"
141
257
  ? sanitizeLine(entry.name, 160) || undefined
142
258
  : undefined,
259
+ toolCallId:
260
+ typeof entry.toolCallId === "string"
261
+ ? sanitizeLine(entry.toolCallId, 1_024) || undefined
262
+ : undefined,
143
263
  isError: entry.isError === true,
144
264
  timestamp:
145
265
  typeof entry.timestamp === "number" ? entry.timestamp : undefined,
@@ -148,6 +268,35 @@ function normalizeTranscript(value: unknown): TranscriptEntry[] {
148
268
  return transcript;
149
269
  }
150
270
 
271
+ function normalizeAgentState(value: unknown): AgentRecord["state"] {
272
+ switch (value) {
273
+ case "done":
274
+ case "completed":
275
+ return "done";
276
+ case "error":
277
+ case "failed":
278
+ return "error";
279
+ case "running":
280
+ case "uncertain":
281
+ return value;
282
+ default:
283
+ return "uncertain";
284
+ }
285
+ }
286
+
287
+ function normalizeWorkflowStatus(value: unknown): WorkflowDetails["status"] {
288
+ switch (value) {
289
+ case "running":
290
+ case "completed":
291
+ case "failed":
292
+ case "aborted":
293
+ case "uncertain":
294
+ return value;
295
+ default:
296
+ return "uncertain";
297
+ }
298
+ }
299
+
151
300
  /** Leniently normalize a workflow.json (including runs from older tooling). */
152
301
  export function normalizePersistedWorkflowDetails(
153
302
  runId: string,
@@ -163,12 +312,7 @@ export function normalizePersistedWorkflowDetails(
163
312
  for (const item of rawAgents) {
164
313
  if (!item || typeof item !== "object") continue;
165
314
  const a = item as Record<string, unknown>;
166
- const state =
167
- a.state === "error" || a.state === "failed"
168
- ? "error"
169
- : a.state === "running"
170
- ? "running"
171
- : "done";
315
+ const state = normalizeAgentState(a.state);
172
316
  const index = typeof a.index === "number" ? a.index : agents.length + 1;
173
317
  const decodedInvocation = decodeInvocationRecord(a.invocation);
174
318
  const invocation =
@@ -205,6 +349,11 @@ export function normalizePersistedWorkflowDetails(
205
349
  ...(typeof a.resultRef === "string" && a.resultRef
206
350
  ? { resultRef: sanitizeLine(a.resultRef, 256) }
207
351
  : {}),
352
+ ...(typeof a.resultArtifact === "string" &&
353
+ a.resultArtifact.startsWith("agent-results/") &&
354
+ !a.resultArtifact.includes("..")
355
+ ? { resultArtifact: sanitizeLine(a.resultArtifact, 256) }
356
+ : {}),
208
357
  label:
209
358
  typeof a.label === "string"
210
359
  ? sanitizeLine(a.label, 160) || `agent-${index}`
@@ -279,15 +428,23 @@ export function normalizePersistedWorkflowDetails(
279
428
  logs.push({
280
429
  at: typeof entry.at === "number" ? entry.at : startedAt,
281
430
  text: sanitizeLine(entry.text, MAX_LOG_TEXT),
431
+ ...(entry.kind === "pipeline-drop"
432
+ ? { kind: "pipeline-drop" as const }
433
+ : {}),
282
434
  });
283
435
  }
284
436
 
285
- const status =
286
- record.status === "running" ||
287
- record.status === "failed" ||
288
- record.status === "aborted"
289
- ? record.status
290
- : "completed";
437
+ let status = normalizeWorkflowStatus(record.status);
438
+ if (status !== "running") {
439
+ for (const agent of agents) {
440
+ if (agent.state !== "running" && agent.state !== "uncertain") continue;
441
+ status = "uncertain";
442
+ agent.state = "uncertain";
443
+ agent.error =
444
+ agent.error ??
445
+ "Persisted terminal workflow contained an agent without terminal evidence";
446
+ }
447
+ }
291
448
 
292
449
  return {
293
450
  runId,
@@ -306,6 +463,7 @@ export function normalizePersistedWorkflowDetails(
306
463
  ? sanitizeLine(meta.description, 2_000) || undefined
307
464
  : undefined,
308
465
  background: record.background === true,
466
+ delivery: normalizeDelivery(record.delivery),
309
467
  status,
310
468
  startedAt,
311
469
  finishedAt:
@@ -351,13 +509,36 @@ export function recoverStaleWorkflowDetails(
351
509
  recoveredAt = Date.now(),
352
510
  ): WorkflowDetails {
353
511
  if (details.status !== "running") return details;
354
- details.status = "aborted";
512
+ details.status = "uncertain";
355
513
  details.finishedAt = details.finishedAt ?? recoveredAt;
356
- details.error = details.error ?? "Recovered stale run that was not active";
514
+ details.error =
515
+ details.error ??
516
+ "Workflow owner was lost; completion and external side effects are uncertain";
517
+ if (!details.delivery) {
518
+ details.delivery = {
519
+ id: `workflow:${details.runId}`,
520
+ state: "pending",
521
+ attempts: 0,
522
+ updatedAt: recoveredAt,
523
+ lastError: "Migrated a pre-delivery run after its owner disappeared",
524
+ };
525
+ } else if (
526
+ details.delivery.state !== "delivered" &&
527
+ details.delivery.state !== "consumed-inline"
528
+ ) {
529
+ details.delivery = {
530
+ ...details.delivery,
531
+ state: "pending",
532
+ updatedAt: recoveredAt,
533
+ lastError: "Recovered after the workflow owner process ended",
534
+ };
535
+ }
357
536
  for (const agent of details.agents) {
358
537
  if (agent.state !== "running") continue;
359
- agent.state = "error";
360
- agent.error = agent.error ?? "Run ended before this agent settled";
538
+ agent.state = "uncertain";
539
+ agent.error =
540
+ agent.error ??
541
+ "Workflow owner was lost before this agent produced terminal evidence";
361
542
  agent.finishedAt = details.finishedAt;
362
543
  }
363
544
  details.graph = projectWorkflowGraph(workflowGraphRecords(details.agents));
@@ -388,70 +569,36 @@ export function loadRunEntries(
388
569
  referencedRunIds: ReadonlySet<string>,
389
570
  /** Hide runs untouched by the current request; live runs always show. */
390
571
  startedSince = 0,
572
+ /** Bounded settled projections used only if canonical disk state is unreadable. */
573
+ retained: ReadonlyMap<string, WorkflowDetails> = new Map(),
391
574
  ): RunEntry[] {
392
- let names: string[] = [];
393
- try {
394
- names = fs.readdirSync(runsDir()).filter(isWorkflowRunId);
395
- } catch {
396
- // No runs yet.
397
- }
398
575
  const entries: RunEntry[] = [];
399
- for (const runId of names) {
576
+ const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]);
577
+ for (const runId of runIds) {
400
578
  const live = active.get(runId);
401
579
  if (live) {
402
580
  entries.push({ runId, details: live, live: true });
403
581
  continue;
404
582
  }
405
- try {
406
- const raw = JSON.parse(
407
- fs.readFileSync(path.join(runsDir(), runId, "workflow.json"), "utf8"),
408
- );
409
- const details = normalizePersistedWorkflowDetails(runId, raw);
410
- const touchedAt = Math.max(
411
- details?.startedAt ?? 0,
412
- details?.finishedAt ?? 0,
413
- );
414
- if (
415
- details &&
416
- touchedAt >= startedSince &&
417
- (details.sessionId === sessionId || referencedRunIds.has(runId))
418
- ) {
419
- const runDir = path.join(runsDir(), runId);
420
- if (details.resultArtifact) {
421
- try {
422
- details.result = JSON.parse(
423
- fs.readFileSync(
424
- path.join(runDir, path.basename(details.resultArtifact)),
425
- "utf8",
426
- ),
427
- );
428
- } catch {
429
- // Keep the compact compatibility marker from workflow.json.
430
- }
431
- }
432
- if (details.transcriptArtifact) {
433
- try {
434
- const transcripts = JSON.parse(
435
- fs.readFileSync(
436
- path.join(runDir, path.basename(details.transcriptArtifact)),
437
- "utf8",
438
- ),
439
- ) as Record<string, unknown>;
440
- for (const agent of details.agents) {
441
- agent.transcript = normalizeTranscript(
442
- transcripts[String(agent.index)],
443
- );
444
- }
445
- } catch {
446
- // Older or partially written artifacts simply lack transcripts.
447
- }
448
- }
449
- recoverStaleWorkflowDetails(details);
450
- entries.push({ runId, details, live: false });
451
- }
452
- } catch {
453
- // Skip unreadable runs.
583
+ const persisted = readPersistedWorkflowDetails(runId, {
584
+ hydrateArtifacts: true,
585
+ });
586
+ const retainedDetails = retained.get(runId);
587
+ const details = persisted ?? retainedDetails;
588
+ if (!details) continue;
589
+ const fromRetention =
590
+ persisted === undefined && retainedDetails !== undefined;
591
+ const touchedAt = Math.max(details.startedAt, details.finishedAt ?? 0);
592
+ if (
593
+ touchedAt < startedSince ||
594
+ (!fromRetention &&
595
+ details.sessionId !== sessionId &&
596
+ !referencedRunIds.has(runId))
597
+ ) {
598
+ continue;
454
599
  }
600
+ recoverStaleWorkflowDetails(details);
601
+ entries.push({ runId, details, live: false });
455
602
  }
456
603
  return entries.sort((a, b) => b.details.startedAt - a.details.startedAt);
457
604
  }
@@ -472,13 +619,13 @@ export function workflowGraphSummary(
472
619
  }
473
620
 
474
621
  export function buildWorkflowReport(details: WorkflowDetails): string {
475
- const { done, failed } = countStates(details);
622
+ const { done, failed, uncertain } = countStates(details);
476
623
  const lines: string[] = [
477
624
  `# Workflow ${details.name ?? details.runId}`,
478
625
  "",
479
626
  `- Run: ${details.runId}`,
480
627
  `- Status: ${statusWord(details.status)}`,
481
- `- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}`,
628
+ `- Agents: ${done}/${details.agents.length} ok${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""}`,
482
629
  `- Elapsed: ${formatElapsed(details.startedAt, details.finishedAt)}`,
483
630
  ];
484
631
  const totals = formatUsage(aggregateUsage(details.agents));
@@ -498,7 +645,9 @@ export function buildWorkflowReport(details: WorkflowDetails): string {
498
645
  ? "ok"
499
646
  : agent.state === "error"
500
647
  ? "FAILED"
501
- : "running";
648
+ : agent.state === "uncertain"
649
+ ? "UNCERTAIN"
650
+ : "running";
502
651
  const stats = [
503
652
  agent.model,
504
653
  agentContext(agent),
@@ -565,24 +714,24 @@ export class WorkflowDashboard {
565
714
  private phaseIndex = 0;
566
715
  private agentIndex = 0;
567
716
  private detailFocus: DetailFocus = "phases";
568
- private transcriptScroll = 0;
569
- private transcriptRowCount = 0;
570
- private transcriptViewportSize = 1;
717
+ private transcriptPage?: AgentSessionPage;
571
718
  private current?: RunEntry;
572
719
  private openedDirectly = false;
573
720
  private notice?: string;
574
721
  private noticeAt = 0;
575
722
  private disposed = false;
576
- private timer: ReturnType<typeof setInterval>;
723
+ private timer?: ReturnType<typeof setInterval>;
577
724
  private tui: TUI;
578
725
  private theme: Theme;
579
726
  private keybindings: KeybindingsManager;
580
727
  private getActive: () => Map<string, WorkflowDetails>;
728
+ private getRetained: () => ReadonlyMap<string, WorkflowDetails>;
581
729
  private sessionId: string;
582
730
  private referencedRunIds: ReadonlySet<string>;
583
731
  private startedSince: number;
584
732
  private close: () => void;
585
733
  private onAbort?: (runId: string) => boolean;
734
+ private initialToolsExpanded: boolean;
586
735
 
587
736
  constructor(
588
737
  tui: TUI,
@@ -595,16 +744,20 @@ export class WorkflowDashboard {
595
744
  close: () => void,
596
745
  initialRunId?: string,
597
746
  onAbort?: (runId: string) => boolean,
747
+ getRetained: () => ReadonlyMap<string, WorkflowDetails> = () => new Map(),
748
+ initialToolsExpanded = false,
598
749
  ) {
599
750
  this.tui = tui;
600
751
  this.theme = theme;
601
752
  this.keybindings = keybindings;
602
753
  this.getActive = getActive;
754
+ this.getRetained = getRetained;
603
755
  this.sessionId = sessionId;
604
756
  this.referencedRunIds = referencedRunIds;
605
757
  this.startedSince = startedSince;
606
758
  this.close = close;
607
759
  this.onAbort = onAbort;
760
+ this.initialToolsExpanded = initialToolsExpanded;
608
761
  this.refresh();
609
762
  if (initialRunId) {
610
763
  const resolution = resolveWorkflowRunTarget(
@@ -624,25 +777,44 @@ export class WorkflowDashboard {
624
777
  this.noticeAt = Date.now();
625
778
  }
626
779
  }
780
+ this.refreshTimer();
781
+ }
782
+
783
+ private refreshTimer() {
784
+ const active = Boolean(
785
+ this.notice ||
786
+ (this.view === "list"
787
+ ? this.entries.some(
788
+ (entry) => entry.live && entry.details.status === "running",
789
+ )
790
+ : this.view === "detail"
791
+ ? this.current?.live && this.current.details.status === "running"
792
+ : this.current?.live && this.selectedAgent()?.state === "running"),
793
+ );
794
+ if (!active) {
795
+ if (this.timer) clearInterval(this.timer);
796
+ this.timer = undefined;
797
+ return;
798
+ }
799
+ if (this.timer) return;
627
800
  this.timer = setInterval(() => {
628
- if (
629
- this.entries.some((e) => e.live) ||
630
- this.current?.live ||
631
- this.notice
632
- ) {
633
- this.refresh();
634
- this.tui.requestRender();
635
- }
636
- }, 500);
801
+ this.refresh();
802
+ this.tui.requestRender();
803
+ this.refreshTimer();
804
+ }, SPINNER_INTERVAL_MS);
637
805
  }
638
806
 
639
807
  dispose() {
640
808
  if (this.disposed) return;
641
809
  this.disposed = true;
642
- clearInterval(this.timer);
810
+ if (this.timer) clearInterval(this.timer);
811
+ this.timer = undefined;
812
+ this.transcriptPage = undefined;
643
813
  }
644
814
 
645
- invalidate() {}
815
+ invalidate() {
816
+ this.transcriptPage?.invalidate();
817
+ }
646
818
 
647
819
  private refresh() {
648
820
  const selected = this.entries[this.listIndex]?.runId;
@@ -651,6 +823,7 @@ export class WorkflowDashboard {
651
823
  this.sessionId,
652
824
  this.referencedRunIds,
653
825
  this.startedSince,
826
+ this.getRetained(),
654
827
  );
655
828
  if (selected) {
656
829
  const index = this.entries.findIndex((e) => e.runId === selected);
@@ -707,11 +880,12 @@ export class WorkflowDashboard {
707
880
  const target = path.join(runsDir(), entry.runId, "report.md");
708
881
  try {
709
882
  writeFileAtomic(target, buildWorkflowReport(entry.details));
710
- this.notice = `saved ${shortenHome(target)}`;
883
+ this.setNotice(`saved ${shortenHome(target)}`);
711
884
  } catch (error) {
712
- this.notice = `save failed: ${error instanceof Error ? error.message : String(error)}`;
885
+ this.setNotice(
886
+ `save failed: ${error instanceof Error ? error.message : String(error)}`,
887
+ );
713
888
  }
714
- this.noticeAt = Date.now();
715
889
  }
716
890
 
717
891
  /** Request cancellation of a run by id, surfacing the outcome as a notice. */
@@ -730,6 +904,7 @@ export class WorkflowDashboard {
730
904
  private setNotice(text: string) {
731
905
  this.notice = text;
732
906
  this.noticeAt = Date.now();
907
+ this.refreshTimer();
733
908
  }
734
909
 
735
910
  handleInput(data: string) {
@@ -811,57 +986,27 @@ export class WorkflowDashboard {
811
986
  } else if (left || cancel) {
812
987
  this.detailFocus = "phases";
813
988
  } else if ((right || confirm) && this.selectedAgent()) {
814
- this.transcriptScroll = 0;
815
- this.view = "transcript";
989
+ this.openTranscriptPage();
816
990
  }
817
991
  }
818
992
  if (data === "s") this.saveReport();
819
993
  if (data === "x") this.abortRun(this.current);
820
994
  } else {
821
- const maxScroll = Math.max(
822
- 0,
823
- this.transcriptRowCount - this.transcriptViewportSize,
824
- );
825
- const scrollStep =
826
- data === "j" || data === "k" ? TRANSCRIPT_SCROLL_STEP : 1;
827
- const pageStep = Math.max(1, this.transcriptViewportSize - 2);
828
- if (up) {
829
- this.transcriptScroll = Math.max(0, this.transcriptScroll - scrollStep);
830
- } else if (down) {
831
- this.transcriptScroll = Math.min(
832
- maxScroll,
833
- this.transcriptScroll + scrollStep,
834
- );
835
- } else if (matchesKey(data, Key.ctrl("u"))) {
836
- this.transcriptScroll = Math.max(0, this.transcriptScroll - pageStep);
837
- } else if (matchesKey(data, Key.ctrl("d"))) {
838
- this.transcriptScroll = Math.min(
839
- maxScroll,
840
- this.transcriptScroll + pageStep,
841
- );
842
- } else if (data === "g") {
843
- this.transcriptScroll = 0;
844
- } else if (data === "G") {
845
- this.transcriptScroll = maxScroll;
846
- } else if (cancel || left) {
847
- this.view = "detail";
848
- this.detailFocus = "agents";
849
- }
995
+ this.transcriptPage?.handleInput(data);
996
+ this.refreshTimer();
997
+ return;
850
998
  }
999
+ this.refreshTimer();
851
1000
  this.tui.requestRender();
852
1001
  }
853
1002
 
854
1003
  render(width: number): string[] {
1004
+ if (this.view === "transcript" && this.transcriptPage) {
1005
+ return this.transcriptPage.render(width);
1006
+ }
855
1007
  const height = Math.max(MIN_HEIGHT, this.tui.terminal.rows - 1);
856
1008
  let lines: string[];
857
- if (this.view === "transcript" && this.current && this.selectedAgent()) {
858
- lines = this.renderTranscript(
859
- this.current.details,
860
- this.selectedAgent()!,
861
- width,
862
- height,
863
- );
864
- } else if (this.view === "detail" && this.current) {
1009
+ if (this.view === "detail" && this.current) {
865
1010
  lines = this.renderDetail(this.current.details, width, height);
866
1011
  } else {
867
1012
  lines = this.renderList(width, height);
@@ -869,15 +1014,51 @@ export class WorkflowDashboard {
869
1014
  return lines.map((line) => truncateToWidth(line, width, ""));
870
1015
  }
871
1016
 
872
- /** Compose `left ... right` within `width`, truncating left when needed. */
873
- private split(left: string, right: string, width: number): string {
874
- const rightWidth = visibleWidth(right);
875
- let text = left;
876
- if (visibleWidth(text) + rightWidth + 1 > width) {
877
- text = truncateToWidth(text, Math.max(0, width - rightWidth - 2), "…");
878
- }
879
- const pad = Math.max(1, width - visibleWidth(text) - rightWidth);
880
- return text + " ".repeat(pad) + right;
1017
+ private openTranscriptPage() {
1018
+ const transcriptAdapter = new WorkflowTranscriptAdapter();
1019
+ this.view = "transcript";
1020
+ this.transcriptPage = new AgentSessionPage(
1021
+ this.tui,
1022
+ this.theme,
1023
+ this.keybindings,
1024
+ {
1025
+ getState: () => {
1026
+ const details = this.current?.details;
1027
+ const agent = this.selectedAgent();
1028
+ if (!details || !agent) return undefined;
1029
+ return {
1030
+ id: agent.callId ?? `agent-${agent.index}`,
1031
+ title: agent.label,
1032
+ status: agent.state,
1033
+ document: transcriptAdapter.document(
1034
+ agent.transcript,
1035
+ agent.worktreePath,
1036
+ ),
1037
+ metadata: [
1038
+ `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`,
1039
+ agent.model,
1040
+ agentContext(agent),
1041
+ agent.acceptance
1042
+ ? `acceptance:${agent.acceptance.status}`
1043
+ : undefined,
1044
+ formatElapsed(agent.startedAt, agent.finishedAt),
1045
+ ],
1046
+ errorText: agent.error,
1047
+ emptyText:
1048
+ "transcript unavailable (this run predates transcript capture)",
1049
+ };
1050
+ },
1051
+ close: () => {
1052
+ this.transcriptPage = undefined;
1053
+ this.view = "detail";
1054
+ this.detailFocus = "agents";
1055
+ this.refreshTimer();
1056
+ this.tui.requestRender();
1057
+ },
1058
+ },
1059
+ { toolsExpanded: this.initialToolsExpanded },
1060
+ );
1061
+ this.tui.requestRender();
881
1062
  }
882
1063
 
883
1064
  /** Bordered panel with a title in the top border, padded to exact height. */
@@ -887,23 +1068,7 @@ export class WorkflowDashboard {
887
1068
  width: number,
888
1069
  height: number,
889
1070
  ): string[] {
890
- const theme = this.theme;
891
- const inner = Math.max(0, width - 2);
892
- const border = (s: string) => theme.fg("borderMuted", s);
893
- const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
894
- const dashes = Math.max(0, inner - visibleWidth(titleText) - 1);
895
- const lines: string[] = [
896
- border("╭─") + titleText + border("─".repeat(dashes) + "╮"),
897
- ];
898
- const bodyHeight = Math.max(0, height - 2);
899
- for (let i = 0; i < bodyHeight; i++) {
900
- const row = rows[i] ?? "";
901
- const clipped = truncateToWidth(row, inner, "…");
902
- const pad = Math.max(0, inner - visibleWidth(clipped));
903
- lines.push(border("│") + clipped + " ".repeat(pad) + border("│"));
904
- }
905
- lines.push(border("╰" + "─".repeat(inner) + "╯"));
906
- return lines;
1071
+ return panelFrame(this.theme, { label: title, rows, width, height });
907
1072
  }
908
1073
 
909
1074
  /** Scroll window keeping `selected` visible. */
@@ -924,25 +1089,21 @@ export class WorkflowDashboard {
924
1089
  return this.keybindings.getKeys(binding).join("/") || "unbound";
925
1090
  }
926
1091
 
927
- private hintLine(hint: string, width: number): string {
928
- const theme = this.theme;
929
- if (this.notice)
930
- return truncateToWidth(theme.fg("accent", ` ${this.notice}`), width);
931
- return truncateToWidth(theme.fg("dim", ` ${hint}`), width);
1092
+ private hintLine(hints: readonly ScreenHint[], width: number): string {
1093
+ return sharedHintLine(this.theme, hints, width, this.notice);
932
1094
  }
933
1095
 
934
1096
  private renderList(width: number, height: number): string[] {
935
1097
  const theme = this.theme;
936
1098
  const lines: string[] = [];
937
- const header = this.split(
938
- " " + theme.bold(theme.fg("accent", "Workflows")),
939
- theme.fg(
940
- "dim",
941
- `${this.entries.length} run${this.entries.length === 1 ? "" : "s"} `,
1099
+ lines.push(
1100
+ screenTitleLine(
1101
+ theme,
1102
+ "Workflows",
1103
+ `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}`,
1104
+ width,
942
1105
  ),
943
- width,
944
1106
  );
945
- lines.push(header);
946
1107
 
947
1108
  const panelHeight = height - 2;
948
1109
  const bodyHeight = Math.max(0, panelHeight - 2);
@@ -957,7 +1118,7 @@ export class WorkflowDashboard {
957
1118
  ),
958
1119
  );
959
1120
  lines.push(
960
- this.hintLine(`${this.keys("tui.select.cancel")} close`, width),
1121
+ this.hintLine([[this.keys("tui.select.cancel"), "close"]], width),
961
1122
  );
962
1123
  return lines;
963
1124
  }
@@ -976,22 +1137,30 @@ export class WorkflowDashboard {
976
1137
  const label = selected
977
1138
  ? theme.fg("accent", name)
978
1139
  : theme.fg("text", name);
979
- const { done, failed } = countStates(d);
1140
+ const { done, failed, uncertain } = countStates(d);
980
1141
  const settled = done + failed;
981
1142
  const right =
982
1143
  theme.fg(
983
1144
  "dim",
984
- `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
1145
+ `${settled}/${d.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""} · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
985
1146
  ) +
986
1147
  theme.fg(statusColor(d.status), statusWord(d.status)) +
987
1148
  " ";
988
- const left = ` ${marker} ${statusSquareFor(d, theme)} ${label} ${theme.fg("dim", d.runId)}`;
989
- return this.split(left, right, width - 2);
1149
+ const left = ` ${marker} ${statusGlyph(d.status, theme, Date.now())} ${label} ${theme.fg("dim", d.runId)}`;
1150
+ return fitNavigationSides(left, right, width - 2);
990
1151
  });
991
1152
  lines.push(...this.panel("Runs", rows, width, panelHeight));
992
1153
  lines.push(
993
1154
  this.hintLine(
994
- `${this.keys("tui.select.up")}/${this.keys("tui.select.down")} select · ${this.keys("tui.select.confirm")} open · x stop · ${this.keys("tui.select.cancel")} close`,
1155
+ [
1156
+ [
1157
+ `${this.keys("tui.select.up")}/${this.keys("tui.select.down")}`,
1158
+ "select",
1159
+ ],
1160
+ [this.keys("tui.select.confirm"), "open"],
1161
+ ["x", "stop"],
1162
+ [this.keys("tui.select.cancel"), "close"],
1163
+ ],
995
1164
  width,
996
1165
  ),
997
1166
  );
@@ -1006,28 +1175,37 @@ export class WorkflowDashboard {
1006
1175
  const theme = this.theme;
1007
1176
  const lines: string[] = [];
1008
1177
 
1009
- const { done, failed } = countStates(d);
1178
+ const { done, failed, uncertain } = countStates(d);
1010
1179
  const settled = done + failed;
1180
+ // Same language as the transcript card: the glyph carries the state, the
1181
+ // status word only shows for terminal states.
1011
1182
  const right =
1012
1183
  theme.fg(
1013
1184
  "dim",
1014
- `${settled}/${d.agents.length} agents · ${formatElapsed(d.startedAt, d.finishedAt)} · `,
1185
+ `${settled}/${d.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""} · ${formatElapsed(d.startedAt, d.finishedAt)}`,
1015
1186
  ) +
1016
- theme.fg(statusColor(d.status), statusWord(d.status)) +
1017
- " ";
1187
+ (d.status === "running"
1188
+ ? " "
1189
+ : theme.fg("dim", " · ") +
1190
+ theme.fg(statusColor(d.status), statusWord(d.status)) +
1191
+ " ");
1018
1192
  lines.push(
1019
- this.split(
1020
- " " + theme.bold(theme.fg("accent", d.name ?? d.runId)),
1193
+ fitNavigationSides(
1194
+ ` ${statusGlyph(d.status, theme, Date.now())} ${theme.bold(theme.fg("accent", d.name ?? d.runId))}`,
1021
1195
  right,
1022
1196
  width,
1023
1197
  ),
1024
1198
  );
1025
1199
  const totals = formatUsage(aggregateUsage(d.agents));
1026
- const graphSummary = d.graph ? workflowGraphSummary(d.graph) : undefined;
1200
+ // A graph with no edges is a flat swarm — "N nodes · 0 edges" is noise.
1201
+ const graphSummary =
1202
+ d.graph && d.graph.edges.length > 0
1203
+ ? workflowGraphSummary(d.graph)
1204
+ : undefined;
1027
1205
  const subRight = [graphSummary, totals].filter(Boolean).join(" · ");
1028
1206
  const subLeft = " " + theme.fg("muted", d.description ?? d.runId);
1029
1207
  lines.push(
1030
- this.split(
1208
+ fitNavigationSides(
1031
1209
  subLeft,
1032
1210
  subRight ? theme.fg("dim", `${subRight} `) : " ",
1033
1211
  width,
@@ -1064,7 +1242,7 @@ export class WorkflowDashboard {
1064
1242
  const groupDone = group.agents.filter(
1065
1243
  (a) => a.state !== "running",
1066
1244
  ).length;
1067
- const square = groupSquare(group, theme);
1245
+ const square = groupGlyph(group, theme);
1068
1246
  const title =
1069
1247
  selected && this.detailFocus === "phases"
1070
1248
  ? theme.fg("accent", group.title)
@@ -1073,7 +1251,11 @@ export class WorkflowDashboard {
1073
1251
  group.agents.length > 0
1074
1252
  ? theme.fg("dim", `${groupDone}/${group.agents.length} `)
1075
1253
  : theme.fg("dim", "- ");
1076
- return this.split(` ${marker} ${square} ${title}`, counts, sidebarInner);
1254
+ return fitNavigationSides(
1255
+ ` ${marker} ${square} ${title}`,
1256
+ counts,
1257
+ sidebarInner,
1258
+ );
1077
1259
  });
1078
1260
 
1079
1261
  // Right: agents in the selected phase.
@@ -1085,6 +1267,10 @@ export class WorkflowDashboard {
1085
1267
  0,
1086
1268
  ...selectedGroup.agents.map((a) => a.label.length),
1087
1269
  );
1270
+ const models = new Set(
1271
+ selectedGroup.agents.map((a) => a.model).filter(Boolean),
1272
+ );
1273
+ const mixedModels = models.size > 1;
1088
1274
  const agentWindow = this.windowed(
1089
1275
  selectedGroup.agents,
1090
1276
  this.agentIndex,
@@ -1097,9 +1283,18 @@ export class WorkflowDashboard {
1097
1283
  selected && this.detailFocus === "agents"
1098
1284
  ? theme.fg("accent", "❯")
1099
1285
  : " ";
1286
+ // The model repeats on every row when the run is homogeneous; only
1287
+ // mixed fleets earn a per-row model. Context occupancy matters while
1288
+ // an agent runs; once settled, its cost is the elapsed on the right.
1289
+ const percent = contextPercent({
1290
+ tokens: agent.usage.contextTokens,
1291
+ contextWindow: agent.contextWindow,
1292
+ });
1100
1293
  const stats = [
1101
- agent.model,
1102
- agentContext(agent),
1294
+ mixedModels ? agent.model : undefined,
1295
+ agent.state === "running" && percent !== undefined
1296
+ ? `${percent}%`
1297
+ : undefined,
1103
1298
  agent.acceptance
1104
1299
  ? `acceptance:${agent.acceptance.status}`
1105
1300
  : undefined,
@@ -1110,16 +1305,16 @@ export class WorkflowDashboard {
1110
1305
  selected && this.detailFocus === "agents"
1111
1306
  ? theme.fg("accent", agent.label.padEnd(Math.min(maxLabel, 40)))
1112
1307
  : theme.fg("text", agent.label.padEnd(Math.min(maxLabel, 40)));
1113
- const left = ` ${marker} ${stateSquare(agent.state, theme)} ${label} ${theme.fg("dim", stats)}`;
1308
+ const left = ` ${marker} ${stateGlyph(agent.state, theme, Date.now())} ${label}${stats ? ` ${theme.fg("dim", stats)}` : ""}`;
1114
1309
  const right = theme.fg(
1115
1310
  "dim",
1116
1311
  `${formatElapsed(agent.startedAt, agent.finishedAt)} `,
1117
1312
  );
1118
- agentRows.push(this.split(left, right, agentsInner));
1313
+ agentRows.push(fitNavigationSides(left, right, agentsInner));
1119
1314
  if (agent.error) {
1120
1315
  agentRows.push(
1121
1316
  truncateToWidth(
1122
- ` ${theme.fg("error", sanitizeLine(agent.error, 2_000))}`,
1317
+ ` ${theme.fg("error", displayError(agent.error))}`,
1123
1318
  agentsInner,
1124
1319
  "…",
1125
1320
  ),
@@ -1170,136 +1365,57 @@ export class WorkflowDashboard {
1170
1365
  );
1171
1366
  }
1172
1367
 
1173
- const hint =
1368
+ const hints: ScreenHint[] =
1174
1369
  this.detailFocus === "phases"
1175
- ? `j/k select phase · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} agents · ${this.keys("tui.select.cancel")} back · x stop · s save report`
1176
- : `j/k select agent · h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")} phases · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} details · x stop · s save report`;
1177
- lines.push(this.hintLine(hint, width));
1178
- return lines;
1179
- }
1180
-
1181
- private transcriptRows(agent: AgentRecord, width: number): string[] {
1182
- const theme = this.theme;
1183
- const rows: string[] = [];
1184
- if (agent.transcript.length === 0) {
1185
- return [
1186
- theme.fg(
1187
- "dim",
1188
- " transcript unavailable (this run predates transcript capture)",
1189
- ),
1190
- ];
1191
- }
1192
-
1193
- for (const entry of agent.transcript) {
1194
- const label = transcriptLabel(entry);
1195
- const color = transcriptColor(entry);
1196
- rows.push(
1197
- ` ${theme.fg(color, SQUARE)} ${theme.bold(theme.fg(color, label))}`,
1198
- );
1199
- const contentWidth = Math.max(8, width - 4);
1200
- const styled = theme.fg(
1201
- entry.role === "thinking" ? "dim" : entry.isError ? "error" : "text",
1202
- sanitizeTerminalText(entry.text),
1203
- );
1204
- for (const line of wrapTextWithAnsi(styled, contentWidth)) {
1205
- rows.push(` ${line}`);
1206
- }
1207
- rows.push("");
1208
- }
1209
- return rows;
1210
- }
1211
-
1212
- private renderTranscript(
1213
- details: WorkflowDetails,
1214
- agent: AgentRecord,
1215
- width: number,
1216
- height: number,
1217
- ): string[] {
1218
- const theme = this.theme;
1219
- const lines: string[] = [];
1220
- const right = theme.fg(
1221
- "dim",
1222
- [
1223
- agent.model,
1224
- agentContext(agent),
1225
- agent.acceptance ? `acceptance:${agent.acceptance.status}` : undefined,
1226
- formatElapsed(agent.startedAt, agent.finishedAt),
1227
- ]
1228
- .filter(Boolean)
1229
- .join(" · ") + " ",
1230
- );
1231
- lines.push(
1232
- this.split(
1233
- ` ${stateSquare(agent.state, theme)} ${theme.bold(theme.fg("accent", agent.label))}`,
1234
- right,
1235
- width,
1236
- ),
1237
- );
1238
- lines.push(
1239
- this.split(
1240
- ` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`,
1241
- theme.fg("dim", `${agent.transcript.length} entries `),
1242
- width,
1243
- ),
1244
- );
1245
-
1246
- const panelHeight = height - 3;
1247
- const bodyHeight = Math.max(1, panelHeight - 2);
1248
- const rows = this.transcriptRows(agent, width - 2);
1249
- this.transcriptRowCount = rows.length;
1250
- this.transcriptViewportSize = bodyHeight;
1251
- const maxScroll = Math.max(0, rows.length - bodyHeight);
1252
- this.transcriptScroll = Math.min(this.transcriptScroll, maxScroll);
1253
- const visible = rows.slice(
1254
- this.transcriptScroll,
1255
- this.transcriptScroll + bodyHeight,
1256
- );
1257
- const position =
1258
- rows.length > bodyHeight
1259
- ? `Transcript · ${this.transcriptScroll + 1}-${Math.min(rows.length, this.transcriptScroll + bodyHeight)}/${rows.length}`
1260
- : "Transcript";
1261
- lines.push(...this.panel(position, visible, width, panelHeight));
1262
- lines.push(
1263
- this.hintLine(
1264
- "j/k scroll · ctrl-u/d page · g/G top/bottom · h/left/esc back",
1265
- width,
1266
- ),
1267
- );
1370
+ ? [
1371
+ ["j/k", "select phase"],
1372
+ [
1373
+ `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`,
1374
+ "agents",
1375
+ ],
1376
+ [this.keys("tui.select.cancel"), "back"],
1377
+ ["x", "stop"],
1378
+ ["s", "save report"],
1379
+ ]
1380
+ : [
1381
+ ["j/k", "select agent"],
1382
+ [
1383
+ `h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")}`,
1384
+ "phases",
1385
+ ],
1386
+ [
1387
+ `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`,
1388
+ "details",
1389
+ ],
1390
+ ["x", "stop"],
1391
+ ["s", "save report"],
1392
+ ];
1393
+ lines.push(this.hintLine(hints, width));
1268
1394
  return lines;
1269
1395
  }
1270
1396
  }
1271
1397
 
1272
- function transcriptLabel(entry: TranscriptEntry): string {
1273
- if (entry.role === "user") return "USER";
1274
- if (entry.role === "assistant") return "ASSISTANT";
1275
- if (entry.role === "thinking") return "THINKING";
1276
- const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown";
1277
- if (entry.role === "tool") return `TOOL ${name}`;
1278
- return `RESULT ${name}`;
1279
- }
1280
-
1281
- function transcriptColor(
1282
- entry: TranscriptEntry,
1283
- ): "accent" | "success" | "dim" | "warning" | "error" | "muted" {
1284
- if (entry.isError) return "error";
1285
- if (entry.role === "user") return "accent";
1286
- if (entry.role === "assistant") return "success";
1287
- if (entry.role === "thinking") return "dim";
1288
- if (entry.role === "tool") return "warning";
1289
- return "muted";
1290
- }
1291
-
1292
- function statusSquareFor(details: WorkflowDetails, theme: Theme): string {
1293
- return theme.fg(statusColor(details.status), SQUARE);
1398
+ /**
1399
+ * Agent errors often arrive as an HTTP status plus a JSON body
1400
+ * (`429: {"message":"user rate limit exceeded …"}`); the panel row keeps the
1401
+ * status code and the message, dropping the braces and quotes.
1402
+ */
1403
+ function displayError(error: string) {
1404
+ const clean = sanitizeLine(error, 2_000);
1405
+ const match = clean.match(/^(\d{3})[:\s]*\{\s*"message"\s*:\s*"([^"]+)"/);
1406
+ if (match) return `${match[1]}: ${match[2]}`;
1407
+ return clean;
1294
1408
  }
1295
1409
 
1296
- function groupSquare(group: PhaseGroup, theme: Theme): string {
1297
- if (group.agents.length === 0) return theme.fg("dim", SQUARE);
1410
+ function groupGlyph(group: PhaseGroup, theme: Theme) {
1411
+ if (group.agents.length === 0) return theme.fg("dim", "○");
1298
1412
  if (group.agents.some((a) => a.state === "running"))
1299
- return theme.fg("warning", SQUARE);
1413
+ return theme.fg("warning", spinnerFrame(Date.now()));
1414
+ if (group.agents.some((a) => a.state === "uncertain"))
1415
+ return theme.fg("warning", "?");
1300
1416
  if (group.agents.some((a) => a.state === "error"))
1301
- return theme.fg("error", SQUARE);
1302
- return theme.fg("success", SQUARE);
1417
+ return theme.fg("error", "✗");
1418
+ return theme.fg("success", "✓");
1303
1419
  }
1304
1420
 
1305
1421
  /** Open the dashboard as a full-screen overlay. */
@@ -1309,10 +1425,11 @@ export async function showWorkflowDashboard(
1309
1425
  initialRunId?: string,
1310
1426
  startedSince = 0,
1311
1427
  onAbort?: (runId: string) => boolean,
1312
- ): Promise<void> {
1428
+ getRetained?: () => ReadonlyMap<string, WorkflowDetails>,
1429
+ ) {
1313
1430
  await ctx.ui.custom<void>(
1314
1431
  (tui, theme, keybindings, done) => {
1315
- const dashboard: WorkflowDashboard = new WorkflowDashboard(
1432
+ const dashboard = new WorkflowDashboard(
1316
1433
  tui,
1317
1434
  theme,
1318
1435
  keybindings,
@@ -1326,12 +1443,14 @@ export async function showWorkflowDashboard(
1326
1443
  },
1327
1444
  initialRunId,
1328
1445
  onAbort,
1446
+ getRetained,
1447
+ ctx.ui.getToolsExpanded(),
1329
1448
  );
1330
1449
  return dashboard;
1331
1450
  },
1332
1451
  {
1333
1452
  overlay: true,
1334
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
1453
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" },
1335
1454
  },
1336
1455
  );
1337
1456
  }