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
@@ -3,16 +3,47 @@
3
3
  *
4
4
  * R8: Subscribes to session events for real-time streaming updates.
5
5
  * Falls back to polling LiveAgentHandle.activity when subscribe is unavailable.
6
+ *
7
+ * Frame (RAIL design language, M4/E1 2026-09-16):
8
+ *
9
+ * ┏ LIVE ▸ explorer
10
+ * ┃ ◉ building feature · turn 2/10 · 3 tools · 5.1s · ▕██░░░░░░▏ 24% ctx · sonnet
11
+ * ┃ <streamed transcript rows…>
12
+ * ┗ ↑/↓/PgUp/PgDn/G scroll · A pause · Esc/Q close ···· auto-scroll
13
+ *
14
+ * The rounded box (`╭─╮│╰─╯` + its two `│ ──── │` rules) is retired: the canopy
15
+ * opens the surface, every body row is a `┃` rail line, the `┗` close cap
16
+ * carries the hint (`formatHint`, close LAST) on the left and the scroll STATE
17
+ * (`auto-scroll` / `manual 12-30/31`) on the dot-led right segment, so the state
18
+ * is never the part that gets truncated. Chrome therefore drops from 6 rows to 3
19
+ * (canopy + meta + cap) and the viewport gains the three freed rows — keys,
20
+ * scroll rules and clamping are unchanged.
21
+ *
22
+ * Keyboard (P1-3: the overlay captures focus while open — see
23
+ * `handleInput` below for the full key set):
24
+ * esc / q close, back to the main conversation
25
+ * pgup / pgdn / ↑ ↓ / j k / g G scroll the transcript
26
+ * a toggle auto-scroll
6
27
  */
28
+ import { matchesKey } from "@earendil-works/pi-tui";
7
29
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
8
- import { pad, truncate } from "../utils/visual.ts";
30
+ import { truncate } from "../utils/visual.ts";
31
+ import { formatCount } from "./format-helpers.ts";
9
32
  import { computeLiveDurationMs } from "./live-duration.ts";
33
+ import { canopyLine, formatHint, gaugeBar, RAIL, railLeaders, railLine, statusSlot } from "./rail.ts";
10
34
  import { spinnerFrame } from "./spinner.ts";
11
35
  import { iconForStatus } from "./status-colors.ts";
12
- import type { CrewTheme } from "./theme-adapter.ts";
36
+ import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
13
37
 
14
- const CHROME_LINES = 6;
38
+ /** Chrome rows: canopy + meta row + close cap. Every other row is transcript. */
39
+ const CHROME_LINES = 3;
15
40
  const MIN_VIEWPORT = 3;
41
+ /** Lines moved by one ↑/k or ↓/j press. */
42
+ const SCROLL_LINE_STEP = 1;
43
+ /** Lines moved by one PgUp/PgDn press (same page size as the agent view overlay). */
44
+ const SCROLL_PAGE_STEP = 10;
45
+ /** Bar width of the `% ctx` gauge in the meta row. */
46
+ const CTX_GAUGE_WIDTH = 8;
16
47
 
17
48
  export class LiveConversationOverlay {
18
49
  private scrollOffset = 0;
@@ -31,11 +62,14 @@ export class LiveConversationOverlay {
31
62
  private handle: LiveAgentHandle;
32
63
  private theme: CrewTheme;
33
64
 
34
- constructor(handle: LiveAgentHandle, theme: CrewTheme, columns = 80, rows = 24) {
65
+ private readonly nowMs: () => number;
66
+
67
+ constructor(handle: LiveAgentHandle, theme: CrewTheme, columns = 80, rows = 24, nowMs?: () => number) {
35
68
  this.handle = handle;
36
69
  this.theme = theme;
37
70
  this.columns = columns;
38
71
  this.rows = rows;
72
+ this.nowMs = nowMs ?? Date.now;
39
73
  // R8: Subscribe to real session events if available
40
74
  const session = handle.session as Record<string, unknown>;
41
75
  if (typeof session.subscribe === "function") {
@@ -46,7 +80,7 @@ export class LiveConversationOverlay {
46
80
  const text = typeof obj.text === "string" ? obj.text : typeof obj.content === "string" ? obj.content : "";
47
81
  if (text.trim()) {
48
82
  this.pushLine(text);
49
- if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
83
+ if (this.autoScroll) this.scrollOffset = this.maxScrollOffset();
50
84
  }
51
85
  });
52
86
  } catch {
@@ -86,83 +120,182 @@ export class LiveConversationOverlay {
86
120
 
87
121
  private refreshSummary(): void {
88
122
  const act = this.handle.activity;
89
- const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${act.turnCount} turns · ${act.toolUses} tools · ${(computeLiveDurationMs(act) / 1000).toFixed(1)}s]`;
123
+ const summary = `${LiveConversationOverlay.SUMMARY_PREFIX}[${formatCount(act.turnCount ?? 0, "turn")} · ${formatCount(act.toolUses ?? 0, "tool")} · ${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s]`;
90
124
  const lastLine = this.cachedLines[this.cachedLines.length - 1];
91
125
  if (lastLine?.startsWith(LiveConversationOverlay.SUMMARY_PREFIX)) {
92
126
  this.cachedLines[this.cachedLines.length - 1] = summary;
93
127
  } else {
94
128
  this.pushLine(summary);
95
129
  }
96
- if (this.autoScroll) this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
130
+ if (this.autoScroll) this.scrollOffset = this.maxScrollOffset();
97
131
  }
98
132
 
99
133
  private viewportHeight(): number {
100
134
  return Math.max(MIN_VIEWPORT, this.rows - CHROME_LINES);
101
135
  }
102
136
 
137
+ /** Largest offset that still shows the newest line in the viewport. */
138
+ private maxScrollOffset(): number {
139
+ return Math.max(0, this.cachedLines.length - this.viewportHeight());
140
+ }
141
+
142
+ /**
143
+ * autoScroll rule (M1-6):
144
+ * - a manual scroll always recomputes autoScroll from the position — the
145
+ * viewport tails the stream only while it sits at the newest line;
146
+ * - scrolling DOWN to (or past) the newest line therefore restores
147
+ * autoScroll, and scrolling UP away from it pauses it so incoming lines
148
+ * cannot yank the viewport back;
149
+ * - `a` toggles explicitly: turning it on jumps to the newest line,
150
+ * turning it off pauses wherever the viewport currently is.
151
+ */
152
+ private scrollBy(delta: number): void {
153
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset(), this.scrollOffset + delta));
154
+ this.autoScroll = this.scrollOffset >= this.maxScrollOffset();
155
+ }
156
+
157
+ /** Jump to the oldest buffered line. */
158
+ private scrollHome(): void {
159
+ this.scrollBy(-this.cachedLines.length);
160
+ }
161
+
162
+ /** Jump to the newest line (re-enables auto-scroll). */
163
+ private scrollEnd(): void {
164
+ this.scrollBy(this.cachedLines.length);
165
+ }
166
+
167
+ /** Toggle auto-scroll; turning it on jumps to the newest line. */
168
+ private toggleAutoScroll(): void {
169
+ if (this.autoScroll) this.autoScroll = false;
170
+ else this.scrollEnd();
171
+ }
172
+
173
+ /**
174
+ * Handle a keypress while the overlay has focus. Mirrors the key set of
175
+ * src/ui/inline-panel/agent-view-overlay.ts:160-182:
176
+ *
177
+ * esc / q close the overlay
178
+ * ↓ / j scroll down one line
179
+ * ↑ / k scroll up one line
180
+ * PgDn / PgUp scroll one page
181
+ * g (home) jump to the oldest buffered line
182
+ * G (end) jump to the newest line (re-enables auto-scroll)
183
+ * a toggle auto-scroll
184
+ *
185
+ * The host component in src/extension/registration/viewers.ts intercepts
186
+ * esc/q itself (it owns the `done()` callback) and forwards every other key
187
+ * here; the esc/q branch below keeps this class self-contained for direct
188
+ * callers and for the host contract. Unrecognised keys are ignored — the
189
+ * overlay never leaks keys into the editor underneath.
190
+ */
191
+ handleInput(data: string): void {
192
+ if (this.closed) return;
193
+ if (matchesKey(data, "escape") || data === "q") {
194
+ this.close();
195
+ return;
196
+ }
197
+ if (matchesKey(data, "down") || data === "j") {
198
+ this.scrollBy(SCROLL_LINE_STEP);
199
+ return;
200
+ }
201
+ if (matchesKey(data, "up") || data === "k") {
202
+ this.scrollBy(-SCROLL_LINE_STEP);
203
+ return;
204
+ }
205
+ if (matchesKey(data, "pageDown")) {
206
+ this.scrollBy(SCROLL_PAGE_STEP);
207
+ return;
208
+ }
209
+ if (matchesKey(data, "pageUp")) {
210
+ this.scrollBy(-SCROLL_PAGE_STEP);
211
+ return;
212
+ }
213
+ if (matchesKey(data, "home") || data === "g") {
214
+ this.scrollHome();
215
+ return;
216
+ }
217
+ if (matchesKey(data, "end") || data === "G") {
218
+ this.scrollEnd();
219
+ return;
220
+ }
221
+ if (data === "a") {
222
+ this.toggleAutoScroll();
223
+ }
224
+ }
225
+
226
+ /** Left half of the close cap: the key legend, through `formatHint`. */
227
+ private hintText(): string {
228
+ return formatHint([
229
+ [["up", "down", "pageup", "pagedown", "g", "G"], "scroll"],
230
+ ["a", this.autoScroll ? "pause" : "resume"],
231
+ [["escape", "q"], "close"],
232
+ ]);
233
+ }
234
+
235
+ /**
236
+ * Right half of the close cap: the scroll STATE (dot-led so it can never be
237
+ * the segment that gets truncated). Every key advertised is handled by
238
+ * `handleInput` in BOTH states (P1-3: the old `↑/k ↓/j G/g` hint lived in an
239
+ * autoScroll === false branch that could never be reached, because nothing
240
+ * ever turned autoScroll off).
241
+ */
242
+ private stateText(): string {
243
+ if (this.autoScroll) return "auto-scroll";
244
+ const total = this.cachedLines.length;
245
+ const from = total === 0 ? 0 : this.scrollOffset + 1;
246
+ const to = Math.min(total, this.scrollOffset + this.viewportHeight());
247
+ return `manual ${from}-${to}/${total}`;
248
+ }
249
+
103
250
  render(width?: number): string[] {
104
251
  const w = width ?? this.columns;
105
252
  if (w < 6) return [];
106
253
  const th = this.theme;
107
- const innerW = w - 4;
108
- const row = (content: string) => th.fg("border", "│") + " " + pad(truncate(content, innerW), innerW) + " " + th.fg("border", "│");
109
- const hrTop = th.fg("border", `╭${"─".repeat(w - 2)}╮`);
110
- const hrBot = th.fg("border", `╰${"─".repeat(w - 2)}╯`);
111
- const hrMid = row(th.fg("dim", "─".repeat(innerW)));
254
+ const budget = w - 2;
255
+ const act = this.handle.activity;
256
+ const slot = statusSlot(this.handle.status);
112
257
 
113
258
  const lines: string[] = [];
114
- lines.push(hrTop);
259
+ // Canopy: ┏ LIVE ▸ <agent> (every identity field guarded — the handle is
260
+ // assembled at runtime and a missing name must never print `undefined`).
261
+ lines.push(canopyLine({ word: "LIVE", subject: this.handle.agent ?? this.handle.taskId ?? "?", theme: th, budget }));
115
262
 
116
- // Header
263
+ // Meta row: status glyph, description, then the counters.
117
264
  const statusIcon =
118
265
  this.handle.status === "running"
119
- ? th.fg("accent", spinnerFrame(this.handle.taskId ?? this.handle.agentId))
266
+ ? th.fg("accent", spinnerFrame(this.handle.taskId ?? this.handle.agentId ?? ""))
120
267
  : iconForStatus(this.handle.status);
121
- const name = this.handle.agent ?? this.handle.taskId;
122
- const act = this.handle.activity;
123
- const elapsed = `${(computeLiveDurationMs(act) / 1000).toFixed(1)}s`;
124
- const headerParts: string[] = [];
125
- if (act.maxTurns != null) headerParts.push(`turn ${act.turnCount}/${act.maxTurns}`);
126
- else if (act.turnCount > 0) headerParts.push(`turn ${act.turnCount}`);
127
- if (act.toolUses > 0) headerParts.push(`${act.toolUses} tools`);
128
- headerParts.push(elapsed);
129
-
130
- // Context % + compaction
268
+ const desc = this.handle.description ?? this.handle.role ?? "";
269
+ const parts: string[] = [];
270
+ if (act.maxTurns != null) parts.push(`turn ${act.turnCount ?? 0}/${act.maxTurns}`);
271
+ else if ((act.turnCount ?? 0) > 0) parts.push(`turn ${act.turnCount}`);
272
+ if ((act.toolUses ?? 0) > 0) parts.push(formatCount(act.toolUses ?? 0, "tool"));
273
+ parts.push(`${(computeLiveDurationMs(act, this.nowMs()) / 1000).toFixed(1)}s`);
131
274
  try {
132
275
  const ctxPct = this.handle.session.getSessionStats?.()?.contextUsage?.percent;
133
276
  if (ctxPct != null) {
134
- const color = ctxPct >= 85 ? "error" : ctxPct >= 70 ? "warning" : "dim";
135
- headerParts.push(th.fg(color, `${Math.round(ctxPct)}% ctx`));
277
+ const color: CrewThemeColor = ctxPct >= 85 ? "error" : ctxPct >= 70 ? "warning" : "dim";
278
+ parts.push(th.fg(color, `${gaugeBar(ctxPct / 100, CTX_GAUGE_WIDTH, th, color)} ${Math.round(ctxPct)}% ctx`));
136
279
  }
137
280
  } catch {
138
281
  /* ignore */
139
282
  }
140
- if (act.compactionCount > 0) headerParts.push(th.fg("dim", `↻${act.compactionCount}`));
141
- // Model name
142
- if (this.handle.modelName) headerParts.push(th.fg("muted", this.handle.modelName));
283
+ if ((act.compactionCount ?? 0) > 0) parts.push(th.fg("dim", `↻${act.compactionCount}`));
284
+ if (this.handle.modelName) parts.push(th.fg("muted", this.handle.modelName));
285
+ const describe = desc ? `${th.fg("muted", desc)} ${th.fg("dim", "·")} ` : "";
286
+ lines.push(railLine(RAIL.body, slot, truncate(`${statusIcon} ${describe}${th.fg("dim", parts.join(" · "))}`, budget), th, budget));
143
287
 
144
- const desc = this.handle.description ?? this.handle.role ?? "";
145
- lines.push(
146
- row(
147
- `${statusIcon} ${th.fg("accent", name)} ${th.fg("muted", desc)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
148
- ),
149
- );
150
- lines.push(hrMid);
151
-
152
- // Content
288
+ // Transcript body.
153
289
  const vh = this.viewportHeight();
154
290
  const visible = this.cachedLines.slice(this.scrollOffset, this.scrollOffset + vh);
155
291
  for (const line of visible) {
156
- lines.push(row(th.fg("dim", line)));
292
+ lines.push(railLine(RAIL.body, "border", th.fg("dim", truncate(line, budget)), th, budget));
157
293
  }
158
294
 
159
- // Footer
160
- lines.push(hrMid);
161
- const footerText = this.autoScroll
162
- ? "auto-scroll · esc/q close"
163
- : `line ${this.scrollOffset + 1}/${this.cachedLines.length} · ↑/k ↓/j G/g · esc/q close`;
164
- lines.push(row(th.fg("dim", footerText)));
165
- lines.push(hrBot);
295
+ // Close cap: hint (left) ···· scroll state (right).
296
+ lines.push(
297
+ railLine(RAIL.close, slot, railLeaders(th.fg("dim", this.hintText()), th.fg("dim", this.stateText()), budget, th), th, budget),
298
+ );
166
299
  return lines;
167
300
  }
168
301
 
@@ -5,9 +5,12 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import { formatTaskGraphLines, waitingReason } from "../runtime/task-display.ts";
6
6
  import { loadRunManifestById } from "../state/stores/state-store.ts";
7
7
  import type { TeamTaskState } from "../state/types.ts";
8
- import { aggregateUsage, formatUsage } from "../state/usage.ts";
8
+ import { aggregateUsage, formatTokens } from "../state/usage.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
- import { pad, truncate } from "../utils/visual.ts";
10
+ import { truncate } from "../utils/visual.ts";
11
+ import { formatCount, teamWorkflowLabel } from "./format-helpers.ts";
12
+ import { DASHBOARD_KEYS } from "./keybinding-map.ts";
13
+ import { ACTIVE, canopyLine, formatHint, RAIL, type RailSlot, railLine, sectionLine, shortId, statusSlot } from "./rail.ts";
11
14
  import type { OverlaySchedulerHandle } from "./shared-overlay-scheduler.ts";
12
15
  import { registerOverlayScheduler } from "./shared-overlay-scheduler.ts";
13
16
  import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
@@ -21,13 +24,11 @@ const TASK_READ_TTL_MS = 200;
21
24
 
22
25
  type Done = (value: undefined) => void;
23
26
 
24
- function line(text: string, width: number): string {
25
- return `│ ${pad(truncate(text, width - 4), width - 4)} │`;
26
- }
27
-
28
- function border(left: string, fill: string, right: string, width: number): string {
29
- return `${left}${fill.repeat(Math.max(0, width - 2))}${right}`;
30
- }
27
+ /** The ONE footer hint of the sidebar: keys label pairs, close LAST (RAIL §1). */
28
+ const SIDEBAR_HINT: ReadonlyArray<readonly [string, string]> = [
29
+ ["/team-dashboard", "details"],
30
+ [DASHBOARD_KEYS.close[0] ?? "q", "close"],
31
+ ];
31
32
 
32
33
  function readTasks(path: string): TeamTaskState[] {
33
34
  const parse = () => {
@@ -43,7 +44,25 @@ function readTasks(path: string): TeamTaskState[] {
43
44
 
44
45
  function shortUsage(tasks: TeamTaskState[]): string {
45
46
  const usage = aggregateUsage(tasks);
46
- return usage ? formatUsage(usage) : "usage=(none)";
47
+ return usage ? compactUsage(usage) : "usage=(none)";
48
+ }
49
+
50
+ /**
51
+ * TUI form of a usage record: `↑2.8k ↓3.7k $0.000`.
52
+ *
53
+ * `formatUsage` (state/usage.ts) is the `key=value` form used by CLI/status
54
+ * output (`input=2780, output=3715, cacheRead=57216, cost=0.000000, turns=0`) —
55
+ * correct for a log line, but on a 118-column rail row it buries the numbers
56
+ * the eye actually wants. The card's usage row has always used this compact
57
+ * form; the sidebar now matches it.
58
+ */
59
+ function compactUsage(usage: ReturnType<typeof aggregateUsage>): string {
60
+ if (!usage) return "usage=(none)";
61
+ const parts: string[] = [];
62
+ if (usage.input !== undefined) parts.push(`↑${formatTokens(usage.input)}`);
63
+ if (usage.output !== undefined) parts.push(`↓${formatTokens(usage.output)}`);
64
+ if (usage.cost !== undefined && Number.isFinite(usage.cost) && usage.cost > 0) parts.push(`$${usage.cost.toFixed(3)}`);
65
+ return parts.length > 0 ? parts.join(" ") : "usage=(none)";
47
66
  }
48
67
 
49
68
  export class LiveRunSidebar {
@@ -147,13 +166,14 @@ export class LiveRunSidebar {
147
166
 
148
167
  render(width: number): string[] {
149
168
  const w = Math.max(36, width);
169
+ const budget = w - 2;
150
170
 
151
171
  // P0-6: render from snapshots only — never read disk on every render tick.
152
172
  // Production wires a snapshotCache (extension/registration/ui.ts). When
153
- // the cache hasn't populated yet we paint a single "(loading…)" line so
154
- // the pre-load frame is well-formed instead of an empty panel. When the
155
- // cache is undefined (tests/dev) we fall back to direct disk reads so
156
- // existing unit tests keep working.
173
+ // the cache hasn't populated yet we paint a well-formed two-row frame so
174
+ // the pre-load surface still wears the rail; when the cache is undefined
175
+ // (tests/dev) we fall back to direct disk reads so existing unit tests
176
+ // keep working.
157
177
  let run: import("../state/types.ts").TeamRunManifest;
158
178
  let tasks: TeamTaskState[];
159
179
  let rawAgents: ReturnType<typeof readCrewAgents>;
@@ -165,7 +185,16 @@ export class LiveRunSidebar {
165
185
  snapshot = undefined;
166
186
  }
167
187
  if (!snapshot) {
168
- return ["(loading…)"];
188
+ // A well-formed pre-load frame: same canopy/cap grammar as the loaded
189
+ // one, with a single dim status row.
190
+ return this.renderFrame(
191
+ [
192
+ canopyLine({ word: "LIVE", subject: shortId(this.runId), theme: this.theme, budget }),
193
+ railLine(RAIL.body, "border", this.theme.fg("dim", "loading…"), this.theme, budget),
194
+ ],
195
+ budget,
196
+ "border",
197
+ );
169
198
  }
170
199
  run = snapshot.manifest;
171
200
  tasks = snapshot.tasks;
@@ -173,14 +202,13 @@ export class LiveRunSidebar {
173
202
  } else {
174
203
  const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
175
204
  if (!loaded) {
176
- return renderLines(
205
+ return this.renderFrame(
177
206
  [
178
- border("╭", "─", "╮", w),
179
- line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
180
- line("run not found", w),
181
- border("╰", "─", "╯", w),
207
+ canopyLine({ word: "LIVE", subject: shortId(this.runId), theme: this.theme, budget }),
208
+ railLine(RAIL.body, "border", this.theme.fg("muted", "run not found"), this.theme, budget),
182
209
  ],
183
- w,
210
+ budget,
211
+ "border",
184
212
  );
185
213
  }
186
214
  run = loaded.manifest;
@@ -194,6 +222,10 @@ export class LiveRunSidebar {
194
222
  const waiting = tasks.filter((task) => task.status === "queued");
195
223
  const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
196
224
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
225
+ // RAIL (§2.E): the rounded `╭─╮│╰─╯` box is retired — the surface is a
226
+ // `┏ LIVE ▸ <run8>` canopy, `┃` body rows, `┣ SECTION` headers and a
227
+ // `┗ <hint>` cap. The rail colour carries the run's state.
228
+ const slot: RailSlot = statusSlot(run.status);
197
229
  // L-2: surface the cancellation/failure reason for terminal runs so the
198
230
  // user sees *why* a run ended without having to switch panes. The reason
199
231
  // is already computed on the consumed snapshot (cancellationReason).
@@ -203,59 +235,98 @@ export class LiveRunSidebar {
203
235
  ? ` · ${truncate(snapshot.cancellationReason, 40)}`
204
236
  : "";
205
237
  const lines: string[] = [
206
- border("╭", "─", "╮", w),
207
- line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
208
- line(`${run.runId.slice(-12)} · ${run.status}${reasonSuffix} · right default`, w),
209
- line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
210
- border("├", "─", "┤", w),
211
- line(`Active agents (${active.length})`, w),
238
+ canopyLine({ word: "LIVE", subject: shortId(run.runId), theme: this.theme, budget, slot }),
239
+ railLine(
240
+ RAIL.body,
241
+ "border",
242
+ this.theme.fg(
243
+ "muted",
244
+ `${shortId(run.runId)} · ${run.status ?? "?"}${reasonSuffix} · ${run.workspaceMode ?? "single"}`,
245
+ ),
246
+ this.theme,
247
+ budget,
248
+ ),
249
+ railLine(
250
+ RAIL.body,
251
+ "border",
252
+ this.theme.fg("muted", `${teamWorkflowLabel(run.team, run.workflow)} · ${shortUsage(tasks)}`),
253
+ this.theme,
254
+ budget,
255
+ ),
256
+ sectionLine({ name: "active", subject: formatCount(active.length, "agent"), theme: this.theme, budget, slot }),
212
257
  ];
213
258
  for (const agent of active.slice(0, 8)) {
214
259
  const status = iconForStatus(agent.status, {
215
260
  runningGlyph: spinnerFrame(agent.taskId),
216
261
  });
217
262
  const usage = agent.usage
218
- ? formatUsage(agent.usage)
263
+ ? compactUsage(agent.usage)
219
264
  : agent.progress?.tokens
220
265
  ? `tokens=${agent.progress.tokens}`
221
266
  : "usage=pending";
222
- lines.push(line(`${status} ${agent.taskId} ${agent.role}->${agent.agent}`, w));
267
+ // The record comes from unvalidated `agents.json` — every field is
268
+ // guarded (`?? "?"`) and the legacy `role->agent` separator is `▸`.
223
269
  lines.push(
224
- line(
225
- ` ${agent.routing ? `model ${agent.routing.requested ? `${agent.routing.requested} → ` : ""}${agent.routing.resolved}` : agent.model ? `model ${agent.model}` : "model pending"}`,
226
- w,
270
+ railLine(
271
+ RAIL.body,
272
+ "border",
273
+ `${status} ${agent.taskId ?? "?"} ${agent.role ?? "?"} ${ACTIVE} ${agent.agent ?? "?"}`,
274
+ this.theme,
275
+ budget,
227
276
  ),
228
277
  );
229
278
  lines.push(
230
- line(
231
- ` ${agent.progress?.currentTool ? `tool ${agent.progress.currentTool} · ` : ""}${agent.toolUses ?? 0} tools · ${usage}`,
232
- w,
279
+ railLine(
280
+ RAIL.body,
281
+ "border",
282
+ this.theme.fg(
283
+ "muted",
284
+ ` ${agent.routing ? `model ${agent.routing.requested ? `${agent.routing.requested} → ` : ""}${agent.routing.resolved ?? "pending"}` : agent.model ? `model ${agent.model}` : "model pending"}`,
285
+ ),
286
+ this.theme,
287
+ budget,
288
+ ),
289
+ );
290
+ lines.push(
291
+ railLine(
292
+ RAIL.body,
293
+ "border",
294
+ this.theme.fg(
295
+ "muted",
296
+ ` ${agent.progress?.currentTool ? `tool ${agent.progress.currentTool} · ` : ""}${formatCount(agent.toolUses ?? 0, "tool")} · ${usage}`,
297
+ ),
298
+ this.theme,
299
+ budget,
233
300
  ),
234
301
  );
235
302
  }
236
- if (!active.length) lines.push(line("- none", w));
237
- lines.push(border("├", "─", "┤", w), line(`Waiting tasks (${waiting.length})`, w));
303
+ if (!active.length) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
304
+ lines.push(sectionLine({ name: "waiting", subject: formatCount(waiting.length, "task"), theme: this.theme, budget, slot }));
238
305
  for (const task of waiting.slice(0, 8)) {
239
306
  const status = iconForStatus("queued");
240
- lines.push(line(`${status} ${task.id} ${waitingReason(task, tasks) ?? "waiting"}`, w));
307
+ lines.push(this.bodyRow(`${status} ${task.id ?? "?"} ${waitingReason(task, tasks) ?? "waiting"}`, budget));
241
308
  }
242
- if (waiting.length === 0) lines.push(line("- none", w));
243
- lines.push(border("├", "─", "┤", w), line(`Completed agents (${completed.length})`, w));
309
+ if (waiting.length === 0) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
310
+ lines.push(sectionLine({ name: "done", subject: formatCount(completed.length, "agent"), theme: this.theme, budget, slot }));
244
311
  for (const agent of completed) {
245
312
  const status = iconForStatus(agent.status === "running" ? "stopped" : agent.status);
246
313
  lines.push(
247
- line(
248
- `${status} ${agent.taskId} ${agent.model ? `· ${agent.model}` : ""}${agent.usage ? ` · ${formatUsage(agent.usage)}` : ""}`,
249
- w,
314
+ this.bodyRow(
315
+ `${status} ${agent.taskId ?? "?"} ${agent.model ? `· ${agent.model}` : ""}${agent.usage ? ` · ${compactUsage(agent.usage)}` : ""}`,
316
+ budget,
250
317
  ),
251
318
  );
252
319
  }
253
- if (completed.length === 0) lines.push(line("- none", w));
254
- lines.push(border("├", "─", "┤", w));
255
- for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
256
- lines.push(line("q close · /team-dashboard details", w));
257
- // F-6: compute the auto-close countdown BEFORE pushing the bottom border
258
- // so the countdown renders inside the bordered box rather than below it.
320
+ if (completed.length === 0) lines.push(this.bodyRow(this.theme.fg("dim", "none"), budget));
321
+ lines.push(sectionLine({ name: "tasks", subject: formatCount(tasks.length, "task"), theme: this.theme, budget, slot }));
322
+ for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) {
323
+ // `formatTaskGraphLines` (src/runtime/task-display.ts) still emits the
324
+ // legacy `role->agent` separator; normalize it at the render boundary
325
+ // so no `->` can reach the TUI (§4 consistency fix).
326
+ lines.push(this.bodyRow(this.theme.fg("muted", entry.replace(/->/g, ACTIVE)), budget));
327
+ }
328
+ // F-6: compute the auto-close countdown BEFORE the cap so the countdown
329
+ // renders inside the frame rather than below it.
259
330
  // Auto-close logic: if run is terminal and no active agents, close after delay
260
331
  const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
261
332
  const hasActiveAgents = agents.some((a) => a.status === "running");
@@ -268,7 +339,7 @@ export class LiveRunSidebar {
268
339
  this.done(undefined);
269
340
  }, autoCloseMs);
270
341
  this.autoCloseTimeout?.unref();
271
- lines.push(line(`auto-close in ${Math.round(autoCloseMs / 1000)}s…`, w));
342
+ lines.push(this.bodyRow(this.theme.fg("dim", `auto-close in ${Math.round(autoCloseMs / 1000)}s…`), budget));
272
343
  }
273
344
  }
274
345
  // Clear timeout if conditions change
@@ -276,17 +347,25 @@ export class LiveRunSidebar {
276
347
  clearTimeout(this.autoCloseTimeout);
277
348
  this.autoCloseTimeout = undefined;
278
349
  }
279
- lines.push(border("╰", "─", "╯", w));
280
- this.cachedLines = renderLines(
281
- lines.map((entry) => this.colorLine(entry)),
282
- w,
283
- );
350
+ this.cachedLines = renderLines(this.renderFrame(lines, budget, slot), w);
284
351
  this.cachedSignature = signature;
285
352
  this.cachedWidth = w;
286
353
  }
287
354
  return this.cachedLines;
288
355
  }
289
356
 
357
+ /** One `┃` body row (glyph + colour owned by the rail helper). */
358
+ private bodyRow(content: string, budget: number): string {
359
+ return railLine(RAIL.body, "border", content, this.theme, budget);
360
+ }
361
+
362
+ /** Append the `┗ <hint>` cap and colorize the glyphs (F-1 / V-3). */
363
+ private renderFrame(lines: string[], budget: number, slot: RailSlot): string[] {
364
+ const hint = this.theme.fg("dim", formatHint(SIDEBAR_HINT));
365
+ const framed = [...lines, railLine(RAIL.close, slot, hint, this.theme, budget)];
366
+ return framed.map((entry) => this.colorLine(entry));
367
+ }
368
+
290
369
  handleInput(data: string): void {
291
370
  if (data === "q" || data === "\u001b") this.done(undefined);
292
371
  }