pi-crew 0.11.0 → 0.11.2

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 (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -1,28 +1,72 @@
1
1
  /**
2
- * Tool renderer registry — visually rich rendering for team & agent tools.
2
+ * Tool renderer registry — RAIL design language (R3, 2026-09-16).
3
3
  *
4
- * Uses box-drawing chars, progress bars, colored badges, and structured
5
- * layouts to create a distinctly different TUI appearance.
6
- * Uses visibleWidth() for ANSI-aware padding so borders align correctly.
4
+ * Visual grammar (a full redesign; the previous rounded-box + `◀ BADGE ▶`
5
+ * frame language is gone):
6
+ *
7
+ * ┏ ┃ ┗ a vertical RAIL down the left edge marks one tool call.
8
+ * `┏` opens the card (call half), `┃` continues it (streaming /
9
+ * expanded rows), `┗` closes it with the outcome summary and the
10
+ * end cap. The rail colour carries status: neutral while idle,
11
+ * accent while streaming, green on success, red on failure.
12
+ *
13
+ * `NAME ▸ SUBJECT` identity canopy: `CREW ▸ implementation`,
14
+ * `AGENT ▸ explorer`, `STATUS ▸ _ui_demo`.
15
+ *
16
+ * `······` dot leaders connect the left and right segments of a
17
+ * line (metrics ↔ elapsed/expand-chord hint), and shrink to a
18
+ * two-space gap when the column is too narrow.
19
+ *
20
+ * `▕████▎░░▏` gauge bar with EIGHTH-BLOCK sub-cell precision, so a
21
+ * narrow column still shows meaningful progress.
22
+ *
23
+ * Cards are width-deferred through AdaptiveCard: the builder receives the
24
+ * real render width, so nothing is "baked" at an assumed terminal size and
25
+ * no line ever wraps into a torn frame.
7
26
  */
8
27
 
9
28
  import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
10
29
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
11
- import { truncateToWidth } from "../../utils/visual.ts";
30
+ import { AdaptiveCard } from "../adaptive-card.ts";
12
31
  import type { CrewComponent } from "../component.ts";
13
- import { formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
14
- import { spinnerFrame } from "../spinner.ts";
32
+ import { formatCount, formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
33
+ import {
34
+ ACTIVE,
35
+ dedupeAgentLabel,
36
+ gaugeBar,
37
+ PI_EXPAND_CHORD,
38
+ RAIL,
39
+ type RailSlot,
40
+ railLeaders,
41
+ railLine,
42
+ scanGauge,
43
+ shortId,
44
+ statusBadge,
45
+ statusIcon,
46
+ statusSlot,
47
+ truncVisual,
48
+ } from "../rail.ts";
49
+ import { spinnerClockNow, spinnerFrame } from "../spinner.ts";
15
50
  import type { CrewTheme } from "../theme-adapter.ts";
51
+ import { parseCompactToolProgress } from "../tool-progress-formatter.ts";
16
52
  import { briefToolResult, isBrief } from "./brief-mode.ts";
17
53
 
54
+ // statusIcon stays part of this module's public API (team-tool.ts imports it
55
+ // from here); the implementation moved to the shared RAIL module.
56
+ export { statusIcon };
57
+
18
58
  // ── Types ──────────────────────────────────────────────────────────────
19
59
 
20
60
  export interface ToolRenderContext {
21
61
  expanded: boolean;
22
62
  lastComponent?: Container;
63
+ /** Pi's ToolRenderContext carries no width — the render width is real. */
23
64
  width?: number;
24
65
  isPartial?: boolean;
25
66
  isError?: boolean;
67
+ /** Pi signals (available since R1 audit): args may still be streaming in. */
68
+ argsComplete?: boolean;
69
+ executionStarted?: boolean;
26
70
  }
27
71
 
28
72
  export interface ToolRenderer {
@@ -30,7 +74,7 @@ export interface ToolRenderer {
30
74
  renderResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): Component;
31
75
  }
32
76
 
33
- export type Component = Container | Text;
77
+ export type Component = Container | Text | AdaptiveCard;
34
78
 
35
79
  // PR-G3 (UI-3): `Container | Text` structurally satisfies the shared
36
80
  // CrewComponent contract — both come from @earendil-works/pi-tui and each
@@ -39,433 +83,404 @@ export type Component = Container | Text;
39
83
  // the exported union type or any behavior.
40
84
  type _AssertComponentIsCrewComponent = Container extends CrewComponent ? (Text extends CrewComponent ? true : never) : never;
41
85
 
42
- // ── ANSI-aware padding ─────────────────────────────────────────────────
43
-
44
- /** Pad a string (which may contain ANSI codes) to a target VISUAL width. */
45
- function padVisual(str: string, targetWidth: number): string {
46
- const vw = visibleWidth(str);
47
- if (vw >= targetWidth) return str;
48
- return str + " ".repeat(targetWidth - vw);
49
- }
86
+ // ── RAIL primitives ────────────────────────────────────────────────────
87
+ //
88
+ // Rail glyphs, leaders, gauge, status glyphs and the width helpers now live in
89
+ // the shared `src/ui/rail.ts` (RAIL design system, 2026-09-16) so every
90
+ // pi-crew surface draws from ONE source instead of each re-declaring `┏ ┃ ┗`.
91
+ // The tool-card-specific pieces stay here: the canopy composition (CREW /
92
+ // AGENT / STATUS) and the status→slot choice for the OPEN half of a card.
50
93
 
51
- /** Truncate a string (which may contain ANSI codes) to a target VISUAL width. */
52
- function truncVisual(str: string, maxWidth: number): string {
53
- if (visibleWidth(str) <= maxWidth) return str;
54
- // Round 23 (BUG 3): previously used String.slice(0, maxWidth) which counts
55
- // UTF-16 code units — for CJK that overflows the card by up to 2x, and for
56
- // emoji it splits a surrogate pair (U+FFFD). Use the grapheme/ANSI-aware
57
- // truncateToWidth with empty ellipsis (the caller appends its own '…').
58
- return truncateToWidth(str, maxWidth, "");
59
- }
60
-
61
- // ── Visual primitives ──────────────────────────────────────────────────
62
-
63
- /** Status icon with color */
64
- function statusBadge(status: string, theme: CrewTheme): string {
65
- switch (status) {
66
- case "completed":
67
- return theme.fg("success", "●");
68
- case "failed":
69
- case "cancelled":
70
- return theme.fg("error", "✖");
71
- case "running":
72
- return theme.fg("warning", "◉");
73
- default:
74
- return theme.fg("dim", "○");
75
- }
94
+ /** Rail colour for the OPEN half — the call was made; outcome unknown yet. */
95
+ function railOpenSlot(ctx: ToolRenderContext): RailSlot {
96
+ if (ctx.isError) return "error";
97
+ if (ctx.isPartial) return "borderAccent";
98
+ return "border";
76
99
  }
77
100
 
78
- /** Status icon — compact */
79
- export function statusIcon(status: string, theme: CrewTheme): string {
80
- switch (status) {
81
- case "completed":
82
- return theme.fg("success", "✓");
83
- case "failed":
84
- case "cancelled":
85
- return theme.fg("error", "✗");
86
- case "running":
87
- return theme.fg("warning", "⟳");
88
- default:
89
- return theme.fg("dim", "○");
90
- }
101
+ /** Rail colour for the CLOSE half — the outcome is known here. */
102
+ function railCloseSlot(status: string, ctx: ToolRenderContext): RailSlot {
103
+ if (ctx.isError) return "error";
104
+ return statusSlot(status);
91
105
  }
92
106
 
93
- /** Short run ID */
94
- function shortId(id: string | undefined): string {
95
- return id ? id.slice(-8) : "????????";
96
- }
107
+ // ── Identity canopy ────────────────────────────────────────────────────
97
108
 
98
- /** Border color based on status */
99
- function borderColorForStatus(status: string): "success" | "error" | "border" {
100
- switch (status) {
101
- case "completed":
102
- return "success";
103
- case "failed":
104
- case "cancelled":
105
- return "error";
106
- default:
107
- return "border";
108
- }
109
+ /**
110
+ * `┏ CREW ▸ implementation` / `┏ STATUS ▸ _ui_demo` / `┏ AGENT ▸ explorer`.
111
+ * `action === "run"` promotes the subject (team) and the canopy word becomes
112
+ * the brand; for any other action the action IS the identity.
113
+ */
114
+ function canopy(args: {
115
+ action?: string;
116
+ team?: string;
117
+ agentName?: string;
118
+ runId?: string;
119
+ theme: CrewTheme;
120
+ ctx: ToolRenderContext;
121
+ budget: number;
122
+ }): string {
123
+ const { action = "", team, agentName, runId, theme, ctx, budget } = args;
124
+ const isRun = !action || action === "run";
125
+ const word = agentName !== undefined ? "AGENT" : isRun ? "CREW" : (action || "…").toUpperCase();
126
+ const subject = agentName !== undefined ? agentName : isRun ? (team ?? "") : (team ?? shortRun(runId));
127
+ // Subject rules: a real subject always prints; while args are still
128
+ // streaming a `▸ …` placeholder signals "not parsed yet"; a COMPLETE call
129
+ // with nothing to name (e.g. `team action=list`) prints the word alone —
130
+ // a dangling chevron there would read as a bug.
131
+ const streamingArgs = ctx.argsComplete === false;
132
+ const tail = subject
133
+ ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("toolTitle", theme.bold(subject))}`
134
+ : streamingArgs
135
+ ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("toolTitle", theme.bold("…"))}`
136
+ : "";
137
+ const label = `${theme.fg("accent", theme.bold(word))}${tail}`;
138
+ return railLine(RAIL.open, railOpenSlot(ctx), label, theme, budget);
109
139
  }
110
140
 
111
- /** Build a visual progress bar: ██████░░░░░░ 60% */
112
- function progressBar(ratio: number, barWidth: number, theme: CrewTheme): string {
113
- const clamped = Math.max(0, Math.min(1, ratio));
114
- const filled = Math.round(clamped * barWidth);
115
- const empty = barWidth - filled;
116
- const bar = theme.fg("success", "█".repeat(filled)) + theme.fg("dim", "░".repeat(empty));
117
- const pct = theme.fg("text", ` ${Math.round(clamped * 100)}%`.padStart(5));
118
- return bar + pct;
141
+ function shortRun(runId: string | undefined): string {
142
+ return runId ? shortId(runId) : "";
119
143
  }
120
144
 
121
- // ── Frame builder ──────────────────────────────────────────────────────
145
+ // ── Shared line builders ───────────────────────────────────────────────
122
146
 
123
- /** Create a rounded-corner framed card.
124
- * With renderShell="self", Pi no longer wraps in Box(1,1).
125
- * Frame uses full totalWidth.
126
- */
127
- import { deriveCardBackground, padWithBackground } from "../card-colors.ts";
128
-
129
- /** Create a rounded-corner framed card.
130
- * With renderShell="self", Pi no longer wraps in Box(1,1).
131
- * Frame uses full totalWidth.
132
- * When bgSlot is provided, the interior is filled with a subtle status-tinted
133
- * background derived from the theme (mixBg at 8% intensity).
134
- */
135
- function buildFrame(
136
- contentLines: string[],
137
- totalWidth: number,
138
- theme: CrewTheme,
139
- borderSlot: "success" | "error" | "border" | "borderAccent" = "border",
140
- bgSlot?: "success" | "error" | "border" | "borderAccent",
141
- ): string {
142
- const frameW = totalWidth - 2; // available after Box(1,1) padding
143
- const innerW = frameW - 2; // │ chars
144
- const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
145
- const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
146
- const v = theme.fg(borderSlot, "│");
147
- const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
148
-
149
- const lines: string[] = [top];
150
- for (const line of contentLines) {
151
- const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
152
- lines.push(v + padded + v);
153
- }
154
- lines.push(bottom);
155
- return lines.join("\n");
147
+ /** Dim preview line (goals, prompts, output snippets) with visual truncation. */
148
+ function previewLine(glyph: string, slot: RailSlot, text: string, theme: CrewTheme, budget: number, shorten = false): string {
149
+ const flat = (shorten ? shortenPath(text) : text).replace(/\n/g, " ");
150
+ const maxLen = budget - 2;
151
+ const body = visibleWidth(flat) > maxLen ? `${truncVisual(flat, maxLen - 1)}…` : flat;
152
+ return railLine(glyph, slot, theme.fg("dim", body), theme, budget);
156
153
  }
157
154
 
158
- /** Build frame TOP: top border + content lines, NO bottom border.
159
- * Pairs with buildFrameBottom() so renderCall + renderResult merge into ONE frame. */
160
- function buildFrameTop(
161
- contentLines: string[],
162
- totalWidth: number,
163
- theme: CrewTheme,
164
- borderSlot: "success" | "error" | "border" | "borderAccent" = "border",
165
- bgSlot?: "success" | "error" | "border" | "borderAccent",
166
- ): string {
167
- const frameW = totalWidth - 2;
168
- const innerW = frameW - 2;
169
- const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
170
- const v = theme.fg(borderSlot, "│");
171
- const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
172
- const lines: string[] = [top];
173
- for (const line of contentLines) {
174
- const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
175
- lines.push(v + padded + v);
176
- }
177
- return lines.join("\n");
178
- }
179
-
180
- /** Build frame BOTTOM: content lines + bottom border, NO top border.
181
- * Pairs with buildFrameTop() so renderCall + renderResult merge into ONE frame. */
182
- function buildFrameBottom(
183
- contentLines: string[],
184
- totalWidth: number,
185
- theme: CrewTheme,
186
- borderSlot: "success" | "error" | "border" | "borderAccent" = "border",
187
- bgSlot?: "success" | "error" | "border" | "borderAccent",
188
- ): string {
189
- const frameW = totalWidth - 2;
190
- const innerW = frameW - 2;
191
- const v = theme.fg(borderSlot, "│");
192
- const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
193
- const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
194
- const lines: string[] = [];
195
- for (const line of contentLines) {
196
- const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
197
- lines.push(v + padded + v);
198
- }
199
- lines.push(bottom);
200
- return lines.join("\n");
201
- }
202
-
203
- /** Derive border color from context so renderCall's top matches renderResult's bottom. */
204
- function borderFromContext(ctx: ToolRenderContext): "success" | "error" | "borderAccent" {
205
- if (ctx.isPartial) return "borderAccent";
206
- if (ctx.isError) return "error";
207
- return "success";
155
+ /** `n/m · pct%` tally shared by streaming and expanded gauges. */
156
+ function tallyText(completed: number, total: number, theme: CrewTheme): string {
157
+ const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
158
+ return theme.fg("muted", `${completed}/${total} · ${pct}%`);
208
159
  }
209
160
 
210
161
  // ── Team Tool Renderer ─────────────────────────────────────────────────
211
162
 
212
163
  export const teamToolRenderer: ToolRenderer = {
213
164
  renderCall(args, theme, ctx) {
214
- const action = (args.action as string) ?? "";
215
- const goal = (args.goal as string) ?? "";
216
- const team = args.team as string | undefined;
217
- const w = ctx.width || process.stdout.columns || 116;
218
- const innerW = w - 4;
219
-
220
- const contentLines: string[] = [];
221
-
222
- // Header: action badge + team name
223
- const actionBadge = theme.fg("accent", `◀ ${action.toUpperCase()} ▶`);
224
- const teamLabel = team ? ` ${theme.fg("dim", `via ${team}`)}` : "";
225
- const header = ` ${actionBadge}${teamLabel}`;
226
- contentLines.push(padVisual(header, innerW));
227
-
228
- // Goal preview (P5: shorten paths)
229
- if (goal) {
230
- const maxLen = innerW - 2;
231
- const preview = shortenPath(goal.replace(/\n/g, " "));
232
- const previewText = visibleWidth(preview) > maxLen ? truncVisual(preview, maxLen - 1) + "…" : preview;
233
- contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
234
- }
235
-
236
- return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx), borderFromContext(ctx)), 0, 0);
165
+ return new AdaptiveCard(
166
+ (w) => {
167
+ const budget = w - 2;
168
+ const action = (args.action as string) ?? "";
169
+ const goal = (args.goal as string) ?? "";
170
+ const team = args.team as string | undefined;
171
+ const lines = [canopy({ action, team, runId: (args.runId as string) ?? (args.run as string), theme, ctx, budget })];
172
+ if (goal) lines.push(previewLine(RAIL.body, "border", goal, theme, budget, true));
173
+ return lines.join("\n");
174
+ },
175
+ (text) => theme.fg("error", text),
176
+ );
237
177
  },
238
178
 
239
179
  renderResult(result, _options, theme, ctx) {
240
180
  try {
241
- const text = renderTeamResult(result, _options, theme, ctx);
242
- return new Text(text, 0, 0);
243
- } catch {
244
- return new Text(statusIcon("completed", theme) + " done", 0, 0);
181
+ return new AdaptiveCard(
182
+ (w) => renderTeamResult(result, _options, theme, ctx, w),
183
+ (text) => theme.fg("error", text),
184
+ );
185
+ } catch (e) {
186
+ // FAIL-VISIBLE: a render bug or an unexpected payload shape must
187
+ // never masquerade as a successful card.
188
+ const msg = e instanceof Error ? e.message : String(e);
189
+ return new Text(theme.fg("error", `✖ crew render error: ${truncLine(msg, 56)}`), 0, 0);
245
190
  }
246
191
  },
247
192
  };
248
193
 
249
- function renderTeamResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): string {
194
+ function renderTeamResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext, w: number): string {
250
195
  const d = (result.details ?? result) as Record<string, unknown>;
251
196
  const records = (d.agentRecords ?? d.results) as CrewAgentRecord[] | undefined;
252
197
  const action = typeof d.action === "string" ? d.action : "";
253
198
  const status = typeof d.status === "string" ? d.status : "";
254
199
  const runId = typeof d.runId === "string" ? d.runId : "";
255
- const w = ctx.width || process.stdout.columns || 116;
256
- const innerW = w - 4;
257
- const bColor = borderColorForStatus(status);
258
- const contentLines: string[] = [];
200
+ const teamName = typeof d.team === "string" ? d.team : "";
201
+ const budget = w - 2;
202
+ const closeSlot = railCloseSlot(status, ctx);
259
203
 
260
- // isPartial = tool still streaming — show LIVE progress
204
+ // ── Streaming (partial result, collapsed view) ──
261
205
  const isPartial = (options as Record<string, unknown>)?.isPartial === true;
262
206
  if (isPartial && !ctx.expanded) {
263
- const content = extractContentText(result?.content);
264
- const parsed = parseStreamingProgress(content);
265
-
207
+ const parsed = parseStreamingProgress(extractContentText(result?.content));
266
208
  if (parsed) {
267
- const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
268
- const frameIdx = Math.floor(Date.now() / 80) % spinnerFrames.length;
269
- const spinner = theme.fg("accent", spinnerFrames[frameIdx]!);
270
- const elapsed = formatDuration(parsed.elapsedMs);
209
+ const spinner = theme.fg("accent", spinnerFrame(String(spinnerClockNow())));
210
+ const elapsed = theme.fg("dim", formatDuration(parsed.elapsedMs));
211
+ const lines: string[] = [];
271
212
 
272
213
  if (parsed.completed != null && parsed.total != null && parsed.total > 0) {
273
- // ── Real task progress ──
274
214
  const ratio = parsed.completed / parsed.total;
275
- const barW = Math.min(innerW - 22, 30);
276
- const bar = progressBar(ratio, barW, theme);
277
- const count = theme.fg("muted", ` ${parsed.completed}/${parsed.total}`);
278
- contentLines.push(
279
- padVisual(` ${spinner} ${theme.fg("toolTitle", theme.bold("crew run"))} ${theme.fg("dim", elapsed)}`, innerW),
280
- );
281
- contentLines.push(padVisual(` ${bar}${count}`, innerW));
282
- if (parsed.activeAgent) contentLines.push(padVisual(` ${theme.fg("dim", parsed.activeAgent)}`, innerW));
215
+ const barW = Math.min(Math.max(8, budget - 26), 32);
216
+ const left = `${spinner} ${tallyText(parsed.completed, parsed.total, theme)} ${gaugeBar(ratio, barW, theme)}`;
217
+ lines.push(railLine(RAIL.body, "borderAccent", railLeaders(left, elapsed, budget, theme), theme, budget));
218
+ if (parsed.activeAgent) {
219
+ const dot = theme.fg("accent", spinnerFrame(parsed.activeAgent));
220
+ const label = dedupeAgentLabel(parsed.activeAgent);
221
+ lines.push(railLine(RAIL.body, "border", ` ${dot} ${theme.fg("dim", label)}`, theme, budget));
222
+ }
283
223
  } else {
284
- // ── Starting phase — animated indeterminate bar ──
285
- const barW = Math.min(innerW - 20, 30);
286
- const scanBar = renderScanBar(barW, parsed.elapsedMs, theme);
287
- contentLines.push(padVisual(` ${spinner} ${theme.fg("muted", "crew starting")} ${theme.fg("dim", elapsed)}`, innerW));
288
- contentLines.push(padVisual(` ${scanBar}`, innerW));
224
+ const barW = Math.min(Math.max(8, budget - 24), 32);
225
+ const left = `${spinner} ${theme.fg("muted", "starting")} ${scanGauge(barW, parsed.elapsedMs, theme)}`;
226
+ lines.push(railLine(RAIL.body, "borderAccent", railLeaders(left, elapsed, budget, theme), theme, budget));
289
227
  }
290
- } else if (content) {
291
- const preview = truncLine(content.split("\n").filter(Boolean).pop() ?? "", innerW - 4);
292
- contentLines.push(padVisual(` ${theme.fg("warning", "◉")} ${theme.fg("dim", preview)}`, innerW));
228
+ return lines.join("\n");
293
229
  }
294
-
295
- if (contentLines.length > 0) {
296
- return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
230
+ const fallback = extractContentText(result?.content).split("\n").filter(Boolean).pop();
231
+ if (fallback) {
232
+ return railLine(RAIL.body, "borderAccent", `${theme.fg("warning", "◉")} ${theme.fg("dim", fallback)}`, theme, budget);
297
233
  }
298
234
  }
299
235
 
300
- // Brief mode: compact summary (still framed)
301
- // But NOT for action=run — those always get progress bar + metrics
236
+ // ── Brief mode: single end-cap line (never for action=run) ──
302
237
  if (isBrief() && !ctx.expanded && action !== "run") {
303
238
  const briefText = briefToolResult("team", result as { content?: unknown[] }, theme);
304
- contentLines.push(padVisual(` ${briefText}`, innerW));
305
- return buildFrameBottom(contentLines, w, theme, bColor, bColor);
239
+ return railLine(RAIL.close, closeSlot, `${briefText}`, theme, budget);
306
240
  }
307
241
 
242
+ // ── Closed card ──
308
243
  if (!ctx.expanded) {
309
- // ── Collapsed: compact framed card ──
310
244
  if (action === "run" && records?.length) {
311
- appendRunCard(contentLines, records, status, runId, theme, innerW);
312
- } else if (action === "run") {
313
- const m = d.metrics as Metrics | undefined;
314
- if (m) {
315
- appendMetricsCard(contentLines, m, status, runId, theme, innerW);
316
- } else {
317
- appendSimpleCard(contentLines, status, runId, theme, innerW);
318
- }
319
- } else {
320
- appendSimpleCard(contentLines, status, runId, theme, innerW);
245
+ return railLine(RAIL.close, closeSlot, `${runCap(records, status, teamName, theme, budget)}`, theme, budget);
321
246
  }
322
- } else {
323
- // ── Expanded: detailed card ──
324
- if (action === "run" && records?.length) {
325
- appendExpandedRun(contentLines, records, status, runId, theme, w, innerW);
326
- } else if (action === "run") {
247
+ if (action === "run") {
327
248
  const m = d.metrics as Metrics | undefined;
328
- appendExpandedMetrics(contentLines, m, status, runId, theme, w, innerW);
329
- } else {
330
- const text = extractContentText(result?.content);
331
- contentLines.push(padVisual(` ${text.slice(0, innerW - 2)}`, innerW));
249
+ return railLine(RAIL.close, closeSlot, `${metricsCap(m, status, theme)}`, theme, budget);
332
250
  }
251
+ const badge = statusBadge(status, theme);
252
+ const label = [status || "done", runId ? shortId(runId) : ""].filter(Boolean).join(" · ");
253
+ return railLine(RAIL.close, closeSlot, `${badge} ${theme.fg("text", label)}`, theme, budget);
333
254
  }
334
255
 
335
- return buildFrameBottom(contentLines, w, theme, bColor, bColor);
256
+ // ── Expanded card ──
257
+ const lines: string[] = [];
258
+ if (action === "run" && records?.length) {
259
+ const completed = records.filter((r) => r.status === "completed").length;
260
+ const ratio = records.length > 0 ? completed / records.length : 0;
261
+ const barW = Math.min(Math.max(8, budget - 26), 40);
262
+ lines.push(
263
+ railLine(RAIL.body, "border", `${gaugeBar(ratio, barW, theme)} ${tallyText(completed, records.length, theme)}`, theme, budget),
264
+ );
265
+ for (const r of records) {
266
+ lines.push(railLine(RAIL.body, "border", agentRow(r, theme, budget), theme, budget));
267
+ const usage = usageRow(r, theme, budget);
268
+ if (usage) lines.push(railLine(RAIL.body, "border", usage, theme, budget));
269
+ }
270
+ const duration = computeTotalDuration(records);
271
+ const tokens = computeTotalTokens(records);
272
+ const cost = computeTotalCost(records);
273
+ const tail = [teamName, formatDuration(duration), `${formatTokens(tokens)} tok`, cost > 0 ? `$${cost.toFixed(3)}` : ""]
274
+ .filter(Boolean)
275
+ .join(" · ");
276
+ lines.push(
277
+ railLine(
278
+ RAIL.close,
279
+ closeSlot,
280
+ railLeaders(theme.fg("muted", tail), theme.fg("dim", PI_EXPAND_CHORD), budget, theme),
281
+ theme,
282
+ budget,
283
+ ),
284
+ );
285
+ return lines.join("\n");
286
+ }
287
+ if (action === "run") {
288
+ const m = d.metrics as Metrics | undefined;
289
+ if (m?.taskCount) {
290
+ const ratio = (m.completedCount ?? 0) / m.taskCount;
291
+ const barW = Math.min(Math.max(8, budget - 26), 40);
292
+ lines.push(
293
+ railLine(
294
+ RAIL.body,
295
+ "border",
296
+ `${gaugeBar(ratio, barW, theme)} ${tallyText(m.completedCount ?? 0, m.taskCount, theme)}`,
297
+ theme,
298
+ budget,
299
+ ),
300
+ );
301
+ }
302
+ const parts: string[] = [];
303
+ if (m?.durationMs) parts.push(formatDuration(m.durationMs));
304
+ if (m?.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
305
+ lines.push(railLine(RAIL.close, closeSlot, `${theme.fg("muted", parts.join(" · ") || "done")}`, theme, budget));
306
+ return lines.join("\n");
307
+ }
308
+ // Non-run action, expanded: the raw payload.
309
+ lines.push(previewLine(RAIL.body, "border", extractContentText(result?.content), theme, budget));
310
+ lines.push(railLine(RAIL.close, closeSlot, "", theme, budget));
311
+ return lines.join("\n");
312
+ }
313
+
314
+ /** End-cap for a finished run: `● 5/5 · implementation · 24m4s · 103k tok · $0.204 ···· ctrl+o`. */
315
+ function runCap(records: CrewAgentRecord[], status: string, teamName: string, theme: CrewTheme, budget: number): string {
316
+ const completed = records.filter((r) => r.status === "completed").length;
317
+ const duration = computeTotalDuration(records);
318
+ const tokens = computeTotalTokens(records);
319
+ const cost = computeTotalCost(records);
320
+ const badge = statusBadge(status, theme);
321
+ const parts = [
322
+ `${completed}/${records.length}`,
323
+ teamName,
324
+ formatDuration(duration),
325
+ `${formatTokens(tokens)} tok`,
326
+ cost > 0 ? `$${cost.toFixed(3)}` : "",
327
+ ].filter(Boolean);
328
+ return railLeaders(`${badge} ${theme.fg("text", parts.join(" · "))}`, theme.fg("dim", PI_EXPAND_CHORD), budget, theme);
329
+ }
330
+
331
+ function metricsCap(m: Metrics | undefined, status: string, theme: CrewTheme): string {
332
+ const badge = statusBadge(status, theme);
333
+ const parts: string[] = [];
334
+ if (m?.completedCount != null && m.taskCount) parts.push(`${m.completedCount}/${m.taskCount}`);
335
+ if (m?.durationMs) parts.push(formatDuration(m.durationMs));
336
+ if (m?.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
337
+ if (m?.totalCost) parts.push(`$${m.totalCost.toFixed(3)}`);
338
+ return `${badge} ${theme.fg("text", parts.join(" · ") || status || "done")}`;
339
+ }
340
+
341
+ /** `✓ explorer (gpt-5) · 2m1s` */
342
+ function agentRow(r: CrewAgentRecord, theme: CrewTheme, budget: number): string {
343
+ const icon = statusIcon(r.status, theme);
344
+ const role = theme.fg("toolTitle", theme.bold(r.role || r.agent || "agent"));
345
+ const model = r.model ? theme.fg("dim", ` (${r.model.split("/").at(-1)})`) : "";
346
+ const tools = formatCount(r.toolUses ?? r.progress?.toolCount ?? 0, "tool");
347
+ const dur = r.startedAt ? formatDuration(computeRecordDuration(r)) : "";
348
+ return `${icon} ${role}${model} ${theme.fg("dim", [tools, dur].filter(Boolean).join(" · "))}`;
349
+ }
350
+
351
+ /** ` ↑12k ↓4.1k $0.031` (omitted when the agent reported no usage). */
352
+ function usageRow(r: CrewAgentRecord, theme: CrewTheme, budget: number): string | undefined {
353
+ const usage = r.usage;
354
+ const parts: string[] = [];
355
+ if (usage?.input) parts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
356
+ if (usage?.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
357
+ if (usage?.cost) parts.push(theme.fg("dim", `$${usage.cost.toFixed(3)}`));
358
+ return parts.length ? ` ${parts.join(" ")}` : undefined;
336
359
  }
337
360
 
338
361
  // ── Agent Tool Renderer ────────────────────────────────────────────────
339
362
 
340
363
  export const agentToolRenderer: ToolRenderer = {
341
364
  renderCall(args, theme, ctx) {
342
- const agentName = (args.agent as string) ?? (args.subagent_type as string) ?? "";
343
- const prompt = (args.prompt ?? args.task ?? "") as string;
344
- const w = ctx.width || process.stdout.columns || 116;
345
- const innerW = w - 4;
346
-
347
- const contentLines: string[] = [];
348
- const badge = theme.fg("accent", `◀ AGENT ▶`);
349
- const nameTag = theme.fg("toolTitle", theme.bold(agentName));
350
- contentLines.push(padVisual(` ${badge} ${nameTag}`, innerW));
351
-
352
- if (prompt) {
353
- const maxLen = innerW - 2;
354
- const preview = prompt.replace(/\n/g, " ");
355
- const previewText = visibleWidth(preview) > maxLen ? truncVisual(preview, maxLen - 1) + "…" : preview;
356
- contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
357
- }
358
-
359
- return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx), borderFromContext(ctx)), 0, 0);
365
+ return new AdaptiveCard(
366
+ (w) => {
367
+ const budget = w - 2;
368
+ const agentName = (args.agent as string) ?? (args.subagent_type as string) ?? "";
369
+ const prompt = (args.prompt ?? args.task ?? "") as string;
370
+ const lines = [canopy({ agentName, theme, ctx, budget })];
371
+ if (prompt) lines.push(previewLine(RAIL.body, "border", prompt, theme, budget));
372
+ return lines.join("\n");
373
+ },
374
+ (text) => theme.fg("error", text),
375
+ );
360
376
  },
361
377
 
362
378
  renderResult(result, _options, theme, ctx) {
363
379
  try {
364
- const text = renderAgentResult(result, _options, theme, ctx);
365
- return new Text(text, 0, 0);
366
- } catch {
367
- return new Text(statusIcon("completed", theme) + " agent done", 0, 0);
380
+ return new AdaptiveCard(
381
+ (w) => renderAgentResult(result, _options, theme, ctx, w),
382
+ (text) => theme.fg("error", text),
383
+ );
384
+ } catch (e) {
385
+ // FAIL-VISIBLE — mirror the team renderer's error card.
386
+ const msg = e instanceof Error ? e.message : String(e);
387
+ return new Text(theme.fg("error", `✖ agent render error: ${truncLine(msg, 56)}`), 0, 0);
368
388
  }
369
389
  },
370
390
  };
371
391
 
372
- function renderAgentResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext): string {
392
+ function renderAgentResult(result: Record<string, unknown>, options: unknown, theme: CrewTheme, ctx: ToolRenderContext, w: number): string {
373
393
  const d = (result.details ?? result) as Record<string, unknown>;
374
394
  const results = d.results as Array<Record<string, unknown>> | undefined;
375
- const w = ctx.width || process.stdout.columns || 116;
376
- const innerW = w - 4;
395
+ const budget = w - 2;
377
396
  const status = ((d.status ?? (results?.[0] as Record<string, unknown>)?.status ?? "") as string) || "completed";
378
- const bColor: "success" | "error" | "border" = status === "completed" ? "success" : status === "failed" ? "error" : "border";
379
-
380
- const contentLines: string[] = [];
397
+ const closeSlot = railCloseSlot(status, ctx);
381
398
 
382
- // isPartial = agent still running
399
+ // ── Streaming ──
383
400
  const isPartial = (options as Record<string, unknown>)?.isPartial === true;
384
401
  if (isPartial && !ctx.expanded) {
385
- const spinner = theme.fg("accent", spinnerFrame(d.agentId as string));
386
- const parts: string[] = [theme.fg("muted", "working")];
387
-
388
- // Extract progress from onUpdate content (formatCompactToolProgress output)
402
+ const name = (d.agentName as string) ?? (d.agentId as string) ?? "agent";
403
+ const spinner = theme.fg("accent", spinnerFrame(String(d.agentId ?? "")));
404
+ const meta: string[] = [];
389
405
  const progressText = extractContentText(result?.content);
390
406
  if (progressText) {
391
- const elapsedMatch = progressText.match(/elapsed=(\d+)s/);
392
- const tokenMatch = progressText.match(/tokens=(\d+)/);
393
- const toolMatch = progressText.match(/tool:\s+(\S+)/);
394
- const elapsedSec = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) : 0;
395
- const tokens = tokenMatch ? parseInt(tokenMatch[1]!, 10) : 0;
407
+ const parsed = parseCompactToolProgress(progressText);
408
+ const elapsedSec = parsed ? Math.round(parsed.elapsedMs / 1000) : 0;
409
+ const tokens = parsed?.tokens ?? 0;
396
410
  const tps = elapsedSec > 0 ? Math.round(tokens / elapsedSec) : 0;
397
-
398
- if (elapsedSec > 0) parts.push(theme.fg("dim", formatDuration(elapsedSec * 1000)));
399
- if (tps > 0) parts.push(theme.fg("dim", `${formatTokens(tps)} tok/s`));
400
- if (toolMatch) parts.push(theme.fg("dim", `· ${toolMatch[1]}`));
411
+ if (elapsedSec > 0) meta.push(formatDuration(elapsedSec * 1000));
412
+ if (tps > 0) meta.push(`${formatTokens(tps)} tok/s`);
413
+ if (parsed?.currentTool) meta.push(parsed.currentTool);
401
414
  }
402
-
403
- contentLines.push(padVisual(` ${spinner} ${parts.join(" ")}`, innerW));
404
- return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
415
+ const left = `${spinner} ${theme.fg("toolTitle", theme.bold(name))}`;
416
+ return railLine(RAIL.body, "borderAccent", railLeaders(left, theme.fg("dim", meta.join(" · ")), budget, theme), theme, budget);
405
417
  }
406
418
 
407
- // Brief mode: non-agent results get brief treatment
419
+ // ── Brief / nameless results ──
408
420
  if (!results?.length && !d.agentId) {
409
421
  const briefText = briefToolResult("agent", result as { content?: unknown[] }, theme);
410
- contentLines.push(padVisual(` ${briefText}`, innerW));
411
- return buildFrameBottom(contentLines, w, theme, bColor, bColor);
422
+ return railLine(RAIL.close, closeSlot, `${briefText}`, theme, budget);
412
423
  }
413
424
 
425
+ const label =
426
+ (d.agentName as string) ?? (d.agentId as string) ?? ((results?.[0] as Record<string, unknown>)?.agentId as string) ?? "agent";
427
+
428
+ // ── Closed card ──
414
429
  if (!ctx.expanded) {
415
- // Collapsed: compact card
416
430
  const badge = statusBadge(status, theme);
417
- const agentId = (d.agentId as string) ?? ((results?.[0] as Record<string, unknown>)?.agentId as string) ?? "agent";
418
- const nameTag = theme.fg("toolTitle", theme.bold(agentId));
419
- contentLines.push(padVisual(` ${badge} ${nameTag}`, innerW));
420
-
421
- // Error or output preview
431
+ const head = `${badge} ${theme.fg("toolTitle", theme.bold(label))}`;
422
432
  if (d.error) {
423
- contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(d.error), innerW - 4))}`, innerW));
424
- } else if (results?.length) {
425
- const output = (results[0] as Record<string, unknown>).output as string | undefined;
426
- if (output) {
427
- const preview = truncLine(output.split("\n")[0] ?? "", innerW - 4);
428
- contentLines.push(padVisual(` ${theme.fg("muted", preview)}`, innerW));
429
- }
433
+ return railLine(RAIL.close, "error", `${head} ${theme.fg("error", truncLine(String(d.error), budget - 24))}`, theme, budget);
430
434
  }
431
- } else {
432
- // Expanded: detailed rows
433
- if (results?.length) {
434
- for (let i = 0; i < results.length; i++) {
435
- const item = results[i]!;
436
- const icon = statusIcon((item.status as string) ?? "", theme);
437
- const label = theme.fg("toolTitle", theme.bold((item.agentId as string) ?? "agent"));
438
- contentLines.push(padVisual(` ${icon} ${label}`, innerW));
439
-
440
- if (item.error) {
441
- contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(item.error), innerW - 6))}`, innerW));
442
- } else if (item.output) {
443
- const outputLines = String(item.output).split("\n").slice(0, 5);
444
- for (const line of outputLines) {
445
- contentLines.push(padVisual(` ${theme.fg("dim", truncLine(line, innerW - 6))}`, innerW));
446
- }
447
- }
448
- // Separator between agents
449
- if (i < results.length - 1) {
450
- contentLines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
435
+ const output = results?.length ? ((results[0] as Record<string, unknown>).output as string | undefined) : undefined;
436
+ const preview = output ? output.split("\n").find((l) => l.trim()) : undefined;
437
+ const right = theme.fg("dim", PI_EXPAND_CHORD);
438
+ if (preview) {
439
+ const left = `${head} ${theme.fg("muted", truncLine(preview, Math.max(8, budget - visibleWidth(head) - 8)))}`;
440
+ return railLine(RAIL.close, closeSlot, railLeaders(left, right, budget, theme), theme, budget);
441
+ }
442
+ // Live 2026-09-16: with no output preview the cap printed a bare
443
+ // `┗ ● explorer`, so the expand affordance vanished exactly when the card
444
+ // is hardest to read (an agent run whose result carries no output line).
445
+ // The team card always shows the chord; this one now does too.
446
+ return railLine(RAIL.close, closeSlot, railLeaders(head, right, budget, theme), theme, budget);
447
+ }
448
+
449
+ // ── Expanded card ──
450
+ const lines: string[] = [];
451
+ if (results?.length) {
452
+ for (const item of results) {
453
+ const icon = statusIcon((item.status as string) ?? "", theme);
454
+ lines.push(
455
+ railLine(
456
+ RAIL.body,
457
+ "border",
458
+ `${icon} ${theme.fg("toolTitle", theme.bold((item.agentId as string) ?? "agent"))}`,
459
+ theme,
460
+ budget,
461
+ ),
462
+ );
463
+ if (item.error) {
464
+ lines.push(previewLine(RAIL.body, "error", String(item.error), theme, budget));
465
+ } else if (item.output) {
466
+ for (const line of String(item.output).split("\n").slice(0, 5)) {
467
+ lines.push(previewLine(RAIL.body, "border", line, theme, budget));
451
468
  }
452
469
  }
453
- } else if (d.agentId) {
454
- const icon = statusIcon((d.status as string) ?? "", theme);
455
- contentLines.push(padVisual(` ${icon} ${theme.fg("toolTitle", theme.bold(d.agentId as string))}`, innerW));
456
- if (d.error) {
457
- contentLines.push(padVisual(` ${theme.fg("error", truncLine(String(d.error), innerW - 6))}`, innerW));
458
- }
459
- } else {
460
- const text = extractContentText(result?.content);
461
- if (text) contentLines.push(padVisual(` ${theme.fg("dim", truncLine(text, innerW - 4))}`, innerW));
462
470
  }
471
+ } else if (d.agentId) {
472
+ lines.push(
473
+ railLine(RAIL.body, "border", `${statusIcon(status, theme)} ${theme.fg("toolTitle", theme.bold(label))}`, theme, budget),
474
+ );
475
+ if (d.error) lines.push(previewLine(RAIL.body, "error", String(d.error), theme, budget));
476
+ } else {
477
+ lines.push(previewLine(RAIL.body, "border", extractContentText(result?.content), theme, budget));
463
478
  }
464
-
465
- return buildFrameBottom(contentLines, w, theme, bColor, bColor);
479
+ lines.push(railLine(RAIL.close, closeSlot, `${theme.fg("muted", label)} ${theme.fg("dim", PI_EXPAND_CHORD)}`, theme, budget));
480
+ return lines.join("\n");
466
481
  }
467
482
 
468
- // ── Card builders ──────────────────────────────────────────────────────
483
+ // ── Metrics ────────────────────────────────────────────────────────────
469
484
 
470
485
  interface Metrics {
471
486
  taskCount?: number;
@@ -475,134 +490,6 @@ interface Metrics {
475
490
  durationMs?: number;
476
491
  }
477
492
 
478
- function appendRunCard(lines: string[], records: CrewAgentRecord[], status: string, runId: string, theme: CrewTheme, innerW: number): void {
479
- const completed = records.filter((r) => r.status === "completed").length;
480
- const total = records.length;
481
- const duration = computeTotalDuration(records);
482
- const tokens = computeTotalTokens(records);
483
- const cost = computeTotalCost(records);
484
-
485
- // Line 1: badge + summary + expand hint (P2)
486
- const badge = statusBadge(status, theme);
487
- const title = theme.fg("toolTitle", theme.bold("crew run"));
488
- const idTag = theme.fg("dim", shortId(runId));
489
- const hint = theme.fg("dim", "⌘E");
490
- const left = ` ${badge} ${title} ${idTag}`;
491
- const right = `${hint}`;
492
- const gap = innerW - visibleWidth(left) - visibleWidth(right);
493
- lines.push(padVisual(left + (gap > 1 ? " ".repeat(gap) : " ") + right, innerW));
494
-
495
- // Line 2: compact metrics + cost (P1)
496
- const parts: string[] = [];
497
- parts.push(`${completed}/${total} done`);
498
- if (duration > 0) parts.push(formatDuration(duration));
499
- if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
500
- if (cost > 0) parts.push(`$${cost.toFixed(3)}`);
501
- lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
502
- }
503
-
504
- function appendMetricsCard(lines: string[], m: Metrics, status: string, runId: string, theme: CrewTheme, innerW: number): void {
505
- // Line 1: badge + title + expand hint (P2)
506
- const badge = statusBadge(status, theme);
507
- const title = theme.fg("toolTitle", theme.bold("crew run"));
508
- const idTag = theme.fg("dim", shortId(runId));
509
- const hint = theme.fg("dim", "⌘E");
510
- const left = ` ${badge} ${title} ${idTag}`;
511
- const right = `${hint}`;
512
- const gap = innerW - visibleWidth(left) - visibleWidth(right);
513
- lines.push(padVisual(left + (gap > 1 ? " ".repeat(gap) : " ") + right, innerW));
514
-
515
- // Line 2: compact metrics + cost (P1)
516
- const parts: string[] = [];
517
- if (m.completedCount != null && m.taskCount) parts.push(`${m.completedCount}/${m.taskCount} done`);
518
- if (m.durationMs) parts.push(formatDuration(m.durationMs));
519
- if (m.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
520
- if (m.totalCost) parts.push(`$${m.totalCost.toFixed(3)}`);
521
- if (parts.length) lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
522
- }
523
-
524
- function appendSimpleCard(lines: string[], status: string, runId: string, theme: CrewTheme, innerW: number): void {
525
- const badge = statusBadge(status, theme);
526
- const parts: string[] = [status];
527
- if (runId) parts.push(shortId(runId));
528
- lines.push(padVisual(` ${badge} ${theme.fg("text", parts.join(" · ") || "done")}`, innerW));
529
- }
530
-
531
- function appendExpandedRun(
532
- lines: string[],
533
- records: CrewAgentRecord[],
534
- status: string,
535
- runId: string,
536
- theme: CrewTheme,
537
- w: number,
538
- innerW: number,
539
- ): void {
540
- const completed = records.filter((r) => r.status === "completed").length;
541
- const total = records.length;
542
- const ratio = total > 0 ? completed / total : 0;
543
- const duration = computeTotalDuration(records);
544
- const tokens = computeTotalTokens(records);
545
-
546
- // Header
547
- lines.push(padVisual(` ${theme.fg("toolTitle", theme.bold("CREW RUN RESULT"))} ${theme.fg("dim", shortId(runId))}`, innerW));
548
- lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
549
-
550
- // Progress bar
551
- const barW = Math.min(innerW - 22, 40);
552
- const bar = progressBar(ratio, barW, theme);
553
- const count = theme.fg("muted", ` ${completed}/${total}`);
554
- lines.push(padVisual(` ${theme.fg("muted", "Progress")} ${bar}${count}`, innerW));
555
-
556
- // Metrics
557
- const metricLine = [theme.fg("muted", formatDuration(duration)), theme.fg("muted", `${formatTokens(tokens)} tok`)].join(
558
- theme.fg("dim", " · "),
559
- );
560
- lines.push(padVisual(` ${metricLine}`, innerW));
561
- lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
562
-
563
- // Agent rows
564
- for (const r of records) {
565
- const icon = statusIcon(r.status, theme);
566
- const role = theme.fg("toolTitle", theme.bold(r.role || r.agent || "agent"));
567
- const model = r.model ? theme.fg("dim", ` (${r.model.split("/").at(-1)})`) : "";
568
- const dur = r.startedAt ? formatDuration(computeRecordDuration(r)) : "";
569
- const toolCount = `${r.toolUses ?? r.progress?.toolCount ?? 0} tools`;
570
- lines.push(padVisual(` ${icon} ${role}${model} ${theme.fg("dim", `${toolCount} · ${dur}`)}`, innerW));
571
-
572
- // Usage
573
- const usage = r.usage;
574
- const usageParts: string[] = [];
575
- if (usage?.input) usageParts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
576
- if (usage?.output) usageParts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
577
- if (usage?.cost) usageParts.push(theme.fg("dim", `$${usage.cost.toFixed(3)}`));
578
- if (usageParts.length) lines.push(padVisual(` ${usageParts.join(" ")}`, innerW));
579
- }
580
- }
581
-
582
- function appendExpandedMetrics(
583
- lines: string[],
584
- m: Metrics | undefined,
585
- status: string,
586
- runId: string,
587
- theme: CrewTheme,
588
- w: number,
589
- innerW: number,
590
- ): void {
591
- lines.push(padVisual(` ${theme.fg("toolTitle", theme.bold("CREW RUN RESULT"))} ${theme.fg("dim", shortId(runId))}`, innerW));
592
- lines.push(padVisual(theme.fg("borderMuted", "─".repeat(innerW - 2)), innerW));
593
- if (m) {
594
- const ratio = m.taskCount ? (m.completedCount ?? 0) / m.taskCount : 0;
595
- const barW = Math.min(innerW - 22, 40);
596
- const bar = progressBar(ratio, barW, theme);
597
- const count = theme.fg("muted", ` ${m.completedCount ?? 0}/${m.taskCount ?? 0}`);
598
- lines.push(padVisual(` ${bar}${count}`, innerW));
599
- const parts: string[] = [];
600
- if (m.durationMs) parts.push(formatDuration(m.durationMs));
601
- if (m.totalTokens) parts.push(`${formatTokens(m.totalTokens)} tok`);
602
- if (parts.length) lines.push(padVisual(` ${theme.fg("dim", parts.join(" · "))}`, innerW));
603
- }
604
- }
605
-
606
493
  // ── Helpers ────────────────────────────────────────────────────────────
607
494
 
608
495
  function extractContentText(content: unknown): string {
@@ -615,7 +502,11 @@ function extractContentText(content: unknown): string {
615
502
  return String((texts[texts.length - 1]! as Record<string, unknown>).text ?? "");
616
503
  }
617
504
 
618
- /** Parse streaming progress text from team-tool progress binder. */
505
+ /** Parse streaming progress text from the team-tool progress binder.
506
+ * The format is a shared contract — `parseCompactToolProgress`
507
+ * (../tool-progress-formatter.ts) owns the tokens; this adapter only maps them
508
+ * onto the card's display model (task counts win over the bare status row).
509
+ */
619
510
  interface StreamingProgress {
620
511
  elapsedMs: number;
621
512
  completed: number | null;
@@ -625,71 +516,31 @@ interface StreamingProgress {
625
516
  }
626
517
 
627
518
  function parseStreamingProgress(text: string): StreamingProgress | null {
628
- if (!text) return null;
629
-
630
- // Format: "team status=starting elapsed=11s"
631
- const elapsedMatch = text.match(/elapsed=(\d+)s/);
632
- const elapsedMs = elapsedMatch ? parseInt(elapsedMatch[1]!, 10) * 1000 : 0;
633
-
634
- // Format from formatCompactToolProgress: "tasks completed=2 running=1"
635
- const taskMatch = text.match(/tasks[^\n]*(?:completed|done)=(\d+)[^\n]*(?:running|waiting|queued)=(\d+)/);
636
- const doneMatch = text.match(/(\d+)\/(\d+)\s+done/);
637
-
638
- // Active agent: " explorer->explorer turn=5 tokens=1234"
639
- const agentMatch = text.match(/\s+(\w+)->(\w+)\s+turn=/);
640
- const activeAgent = agentMatch ? `${agentMatch[1]}/${agentMatch[2]}` : null;
641
-
642
- // Current tool: " tool: bash (#3)"
643
- const toolMatch = text.match(/tool:\s+(\S+)/);
644
- const toolInfo = toolMatch ? ` · ${toolMatch[1]}` : "";
519
+ const parsed = parseCompactToolProgress(text);
520
+ if (!parsed) return null;
645
521
 
646
- if (doneMatch) {
522
+ const toolInfo = parsed.currentTool ? ` · ${parsed.currentTool}` : "";
523
+ if (parsed.completed != null && parsed.total != null) {
647
524
  return {
648
- elapsedMs,
649
- completed: parseInt(doneMatch[1]!, 10),
650
- total: parseInt(doneMatch[2]!, 10),
525
+ elapsedMs: parsed.elapsedMs,
526
+ completed: parsed.completed,
527
+ total: parsed.total,
651
528
  status: null,
652
- activeAgent: (activeAgent ?? "") + toolInfo,
529
+ activeAgent: (parsed.activeAgent ?? "") + toolInfo,
653
530
  };
654
531
  }
655
- if (taskMatch) {
656
- const done = parseInt(taskMatch[1]!, 10);
657
- const running = parseInt(taskMatch[2]!, 10);
658
- const total = done + running;
532
+ if (parsed.elapsedMs > 0) {
659
533
  return {
660
- elapsedMs,
661
- completed: done,
662
- total,
663
- status: null,
664
- activeAgent: (activeAgent ?? "") + toolInfo,
665
- };
666
- }
667
- if (elapsedMs > 0) {
668
- const statusMatch = text.match(/status=(\w+)/);
669
- return {
670
- elapsedMs,
534
+ elapsedMs: parsed.elapsedMs,
671
535
  completed: null,
672
536
  total: null,
673
- status: statusMatch?.[1] ?? null,
674
- activeAgent,
537
+ status: parsed.status,
538
+ activeAgent: parsed.activeAgent,
675
539
  };
676
540
  }
677
-
678
541
  return null;
679
542
  }
680
543
 
681
- /** Animated scanning bar for indeterminate progress. */
682
- function renderScanBar(barWidth: number, elapsedMs: number, theme: CrewTheme): string {
683
- const pos = Math.floor((elapsedMs / 400) % (barWidth + 6)) - 3; // bounce range
684
- const segW = Math.max(3, Math.floor(barWidth * 0.3));
685
- let bar = "";
686
- for (let i = 0; i < barWidth; i++) {
687
- const inSeg = i >= pos && i < pos + segW;
688
- bar += inSeg ? theme.fg("accent", "█") : theme.fg("dim", "░");
689
- }
690
- return bar;
691
- }
692
-
693
544
  function computeTotalDuration(records: CrewAgentRecord[]): number {
694
545
  let total = 0;
695
546
  for (const r of records) total += computeRecordDuration(r);
@@ -699,7 +550,7 @@ function computeTotalDuration(records: CrewAgentRecord[]): number {
699
550
  function computeRecordDuration(r: CrewAgentRecord): number {
700
551
  if (!r.startedAt) return 0;
701
552
  const start = new Date(r.startedAt).getTime();
702
- const end = r.completedAt ? new Date(r.completedAt).getTime() : Date.now();
553
+ const end = r.completedAt ? new Date(r.completedAt).getTime() : spinnerClockNow();
703
554
  if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
704
555
  return Math.max(0, end - start);
705
556
  }
@@ -720,14 +571,14 @@ function computeTotalTokens(records: CrewAgentRecord[]): number {
720
571
  return total;
721
572
  }
722
573
 
723
- /** P5: Shorten file path by replacing $HOME with ~ */
574
+ /** Shorten file path by replacing $HOME with ~ */
724
575
  function shortenPath(p: string): string {
725
576
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
726
577
  if (home && p.startsWith(home)) return "~" + p.slice(home.length);
727
578
  return p;
728
579
  }
729
580
 
730
- /** P8: Create clickable file hyperlink via OSC 8.
581
+ /** Create clickable file hyperlink via OSC 8.
731
582
  * Removed (M-13 fix, code-review 2026-06-23): this function was dead code (no
732
583
  * callers) and interpolated a path into an OSC-8 escape without sanitizing
733
584
  * control chars (\x07 BEL / \x1b\\ ST), a potential terminal-injection sink.