@os-eco/overstory-cli 0.7.2 → 0.7.3

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 (56) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agents/hooks-deployer.test.ts +6 -5
  4. package/src/agents/identity.test.ts +3 -2
  5. package/src/agents/manifest.test.ts +4 -3
  6. package/src/agents/overlay.test.ts +3 -2
  7. package/src/commands/agents.test.ts +5 -4
  8. package/src/commands/completions.test.ts +8 -5
  9. package/src/commands/completions.ts +37 -1
  10. package/src/commands/costs.test.ts +4 -3
  11. package/src/commands/dashboard.test.ts +265 -6
  12. package/src/commands/dashboard.ts +367 -64
  13. package/src/commands/doctor.test.ts +3 -2
  14. package/src/commands/errors.test.ts +3 -2
  15. package/src/commands/feed.test.ts +3 -2
  16. package/src/commands/feed.ts +2 -29
  17. package/src/commands/inspect.test.ts +3 -2
  18. package/src/commands/log.test.ts +248 -8
  19. package/src/commands/log.ts +193 -110
  20. package/src/commands/logs.test.ts +3 -2
  21. package/src/commands/mail.test.ts +3 -2
  22. package/src/commands/metrics.test.ts +4 -3
  23. package/src/commands/nudge.test.ts +3 -2
  24. package/src/commands/prime.test.ts +2 -2
  25. package/src/commands/replay.test.ts +3 -2
  26. package/src/commands/run.test.ts +2 -1
  27. package/src/commands/sling.test.ts +127 -0
  28. package/src/commands/sling.ts +101 -3
  29. package/src/commands/status.test.ts +8 -8
  30. package/src/commands/trace.test.ts +3 -2
  31. package/src/commands/watch.test.ts +3 -2
  32. package/src/config.test.ts +3 -3
  33. package/src/doctor/agents.test.ts +3 -2
  34. package/src/doctor/logs.test.ts +3 -2
  35. package/src/doctor/structure.test.ts +3 -2
  36. package/src/index.ts +3 -1
  37. package/src/logging/color.ts +1 -1
  38. package/src/logging/format.test.ts +110 -0
  39. package/src/logging/format.ts +42 -1
  40. package/src/logging/logger.test.ts +3 -2
  41. package/src/mail/client.test.ts +3 -2
  42. package/src/mail/store.test.ts +3 -2
  43. package/src/merge/queue.test.ts +3 -2
  44. package/src/merge/resolver.test.ts +39 -0
  45. package/src/merge/resolver.ts +1 -1
  46. package/src/mulch/client.test.ts +63 -2
  47. package/src/mulch/client.ts +62 -1
  48. package/src/runtimes/claude.test.ts +4 -3
  49. package/src/runtimes/pi-guards.test.ts +26 -2
  50. package/src/runtimes/pi-guards.ts +3 -3
  51. package/src/schema-consistency.test.ts +4 -2
  52. package/src/sessions/compat.test.ts +3 -2
  53. package/src/sessions/store.test.ts +3 -2
  54. package/src/test-helpers.ts +20 -1
  55. package/src/watchdog/daemon.test.ts +4 -3
  56. package/src/watchdog/triage.test.ts +3 -2
@@ -3,7 +3,13 @@
3
3
  *
4
4
  * Rich terminal dashboard using raw ANSI escape codes (zero runtime deps).
5
5
  * Polls existing data sources and renders multi-panel layout with agent status,
6
- * mail activity, merge queue, and metrics.
6
+ * mail activity, merge queue, metrics, tasks, and recent event feed.
7
+ *
8
+ * Layout:
9
+ * Row 1-2: Header
10
+ * Row 3-N: Agents (60% width, dynamic height) | Tasks (upper-right 40%) + Feed (lower-right 40%)
11
+ * Row N+1: Mail (50%) | Merge Queue (50%)
12
+ * Row M: Metrics
7
13
  *
8
14
  * By default, all panels are scoped to the current run (current-run.txt).
9
15
  * Use --all to show data across all runs.
@@ -14,11 +20,15 @@ import { join, resolve } from "node:path";
14
20
  import { Command } from "commander";
15
21
  import { loadConfig } from "../config.ts";
16
22
  import { ValidationError } from "../errors.ts";
23
+ import { createEventStore } from "../events/store.ts";
17
24
  import { accent, brand, color, visibleLength } from "../logging/color.ts";
18
25
  import {
26
+ buildAgentColorMap,
19
27
  formatDuration,
28
+ formatEventLine,
20
29
  formatRelativeTime,
21
30
  mergeStatusColor,
31
+ numericPriorityColor,
22
32
  priorityColor,
23
33
  } from "../logging/format.ts";
24
34
  import { stateColor, stateIcon } from "../logging/theme.ts";
@@ -27,7 +37,9 @@ import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
27
37
  import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
28
38
  import { openSessionStore } from "../sessions/compat.ts";
29
39
  import type { SessionStore } from "../sessions/store.ts";
30
- import type { MailMessage } from "../types.ts";
40
+ import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
41
+ import type { TrackerIssue } from "../tracker/types.ts";
42
+ import type { EventStore, MailMessage, StoredEvent } from "../types.ts";
31
43
  import { evaluateHealth } from "../watchdog/health.ts";
32
44
  import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
33
45
 
@@ -46,7 +58,8 @@ const CURSOR = {
46
58
  } as const;
47
59
 
48
60
  /**
49
- * Box drawing characters for panel borders.
61
+ * Box drawing characters for panel borders (plain — not used for rendering,
62
+ * kept for backward compat with tests and horizontalLine helper).
50
63
  */
51
64
  const BOX = {
52
65
  topLeft: "┌",
@@ -60,6 +73,22 @@ const BOX = {
60
73
  cross: "┼",
61
74
  };
62
75
 
76
+ /**
77
+ * Dimmed version of BOX characters — for subdued borders that do not
78
+ * compete visually with panel content.
79
+ */
80
+ export const dimBox = {
81
+ topLeft: color.dim("┌"),
82
+ topRight: color.dim("┐"),
83
+ bottomLeft: color.dim("└"),
84
+ bottomRight: color.dim("┘"),
85
+ horizontal: color.dim("─"),
86
+ vertical: color.dim("│"),
87
+ tee: color.dim("├"),
88
+ teeRight: color.dim("┤"),
89
+ cross: color.dim("┼"),
90
+ } as const;
91
+
63
92
  /**
64
93
  * Truncate a string to fit within maxLen characters, adding ellipsis if needed.
65
94
  */
@@ -79,14 +108,32 @@ function pad(str: string, width: number): string {
79
108
  }
80
109
 
81
110
  /**
82
- * Draw a horizontal line with left/right/middle connectors.
111
+ * Draw a horizontal line with left/right connectors using plain BOX chars.
112
+ * Exported for backward compat in tests.
83
113
  */
84
114
  function horizontalLine(width: number, left: string, _middle: string, right: string): string {
85
115
  return left + BOX.horizontal.repeat(Math.max(0, width - 2)) + right;
86
116
  }
87
117
 
118
+ /**
119
+ * Draw a horizontal line using dimmed border characters.
120
+ * ANSI-aware: uses visibleLength() for padding calculations.
121
+ */
122
+ function dimHorizontalLine(width: number, left: string, right: string): string {
123
+ const fillCount = Math.max(0, width - visibleLength(left) - visibleLength(right));
124
+ return left + dimBox.horizontal.repeat(fillCount) + right;
125
+ }
126
+
88
127
  export { pad, truncate, horizontalLine };
89
128
 
129
+ /**
130
+ * Compute agent panel height from screen height and agent count.
131
+ * min 8 rows, max floor(height * 0.5), grows with agent count (+4 for chrome).
132
+ */
133
+ export function computeAgentPanelHeight(height: number, agentCount: number): number {
134
+ return Math.max(8, Math.min(Math.floor(height * 0.5), agentCount + 4));
135
+ }
136
+
90
137
  /**
91
138
  * Filter agents by run ID. When run-scoped, also includes sessions with null
92
139
  * runId (e.g. coordinator) because SQL WHERE run_id = ? never matches NULL.
@@ -109,6 +156,7 @@ export interface DashboardStores {
109
156
  mailStore: MailStore | null;
110
157
  mergeQueue: MergeQueue | null;
111
158
  metricsStore: MetricsStore | null;
159
+ eventStore: EventStore | null;
112
160
  }
113
161
 
114
162
  /**
@@ -149,7 +197,17 @@ export function openDashboardStores(root: string): DashboardStores {
149
197
  // metrics db might not be openable
150
198
  }
151
199
 
152
- return { sessionStore, mailStore, mergeQueue, metricsStore };
200
+ let eventStore: EventStore | null = null;
201
+ try {
202
+ const eventsDbPath = join(overstoryDir, "events.db");
203
+ if (existsSync(eventsDbPath)) {
204
+ eventStore = createEventStore(eventsDbPath);
205
+ }
206
+ } catch {
207
+ // events db might not be openable
208
+ }
209
+
210
+ return { sessionStore, mailStore, mergeQueue, metricsStore, eventStore };
153
211
  }
154
212
 
155
213
  /**
@@ -176,8 +234,23 @@ export function closeDashboardStores(stores: DashboardStores): void {
176
234
  } catch {
177
235
  /* best effort */
178
236
  }
237
+ try {
238
+ stores.eventStore?.close();
239
+ } catch {
240
+ /* best effort */
241
+ }
242
+ }
243
+
244
+ /** Tracker data cached between dashboard ticks (10s TTL). */
245
+ interface TrackerCache {
246
+ tasks: TrackerIssue[];
247
+ fetchedAt: number; // Date.now() ms
179
248
  }
180
249
 
250
+ /** Module-level tracker cache (persists across poll ticks). */
251
+ let trackerCache: TrackerCache | null = null;
252
+ const TRACKER_CACHE_TTL_MS = 10_000; // 10 seconds
253
+
181
254
  interface DashboardData {
182
255
  currentRunId?: string | null;
183
256
  status: StatusData;
@@ -188,6 +261,8 @@ interface DashboardData {
188
261
  avgDuration: number;
189
262
  byCapability: Record<string, number>;
190
263
  };
264
+ tasks: TrackerIssue[];
265
+ recentEvents: StoredEvent[];
191
266
  }
192
267
 
193
268
  /**
@@ -223,9 +298,6 @@ async function loadDashboardData(
223
298
  const tmuxSessions = await getCachedTmuxSessions();
224
299
 
225
300
  // Evaluate health for active agents using the same logic as the watchdog.
226
- // This handles two key cases:
227
- // 1. tmux dead -> zombie (previously the only reconciliation)
228
- // 2. persistent capabilities (coordinator, monitor) booting -> working when tmux alive
229
301
  const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
230
302
  const healthThresholds = thresholds ?? { staleMs: 300_000, zombieMs: 600_000 };
231
303
  for (const session of allSessions) {
@@ -243,7 +315,6 @@ async function loadDashboardData(
243
315
  }
244
316
 
245
317
  // If run-scoped, filter agents to only those belonging to the current run.
246
- // Also includes null-runId sessions (e.g. coordinator) per filterAgentsByRun logic.
247
318
  const filteredAgents = filterAgentsByRun(allSessions, runId);
248
319
 
249
320
  // Count unread mail
@@ -293,7 +364,6 @@ async function loadDashboardData(
293
364
  try {
294
365
  if (runId && filteredAgents.length > 0) {
295
366
  const agentNames = new Set(filteredAgents.map((a) => a.agentName));
296
- // Fetch a small batch to filter from; can't push agent-set filter into SQL
297
367
  const allMail = stores.mailStore.getAll({ limit: 50 });
298
368
  recentMail = allMail
299
369
  .filter((m) => agentNames.has(m.from) || agentNames.has(m.to))
@@ -332,7 +402,6 @@ async function loadDashboardData(
332
402
  if (stores.metricsStore) {
333
403
  try {
334
404
  if (runId && filteredAgents.length > 0) {
335
- // Run-scoped: filter sessions by agent names, compute all values from the filtered set
336
405
  const agentNames = new Set(filteredAgents.map((a) => a.agentName));
337
406
  const sessions = stores.metricsStore.getRecentSessions(100);
338
407
  const filtered = sessions.filter((s) => agentNames.has(s.agentName));
@@ -350,7 +419,6 @@ async function loadDashboardData(
350
419
  byCapability[cap] = (byCapability[cap] ?? 0) + 1;
351
420
  }
352
421
  } else {
353
- // All-runs view: use countSessions() to get accurate total (not capped at 100)
354
422
  totalSessions = stores.metricsStore.countSessions();
355
423
  avgDuration = stores.metricsStore.getAverageDuration();
356
424
 
@@ -365,12 +433,43 @@ async function loadDashboardData(
365
433
  }
366
434
  }
367
435
 
436
+ // Load tasks from tracker with cache
437
+ let tasks: TrackerIssue[] = [];
438
+ const now2 = Date.now();
439
+ if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
440
+ try {
441
+ const backend = await resolveBackend("auto", root);
442
+ const tracker = createTrackerClient(backend, root);
443
+ tasks = await tracker.list({ limit: 10 });
444
+ trackerCache = { tasks, fetchedAt: now2 };
445
+ } catch {
446
+ // tracker unavailable — graceful degradation
447
+ tasks = trackerCache?.tasks ?? [];
448
+ }
449
+ } else {
450
+ tasks = trackerCache.tasks;
451
+ }
452
+
453
+ // Load recent events from event store
454
+ let recentEvents: StoredEvent[] = [];
455
+ if (stores.eventStore) {
456
+ try {
457
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
458
+ const events = stores.eventStore.getTimeline({ since: fiveMinAgo, limit: 20 });
459
+ recentEvents = [...events].reverse(); // most recent first
460
+ } catch {
461
+ /* best effort */
462
+ }
463
+ }
464
+
368
465
  return {
369
466
  currentRunId: runId,
370
467
  status,
371
468
  recentMail,
372
469
  mergeQueue: mergeQueueEntries,
373
470
  metrics: { totalSessions, avgDuration, byCapability },
471
+ tasks,
472
+ recentEvents,
374
473
  };
375
474
  }
376
475
 
@@ -389,31 +488,36 @@ function renderHeader(width: number, interval: number, currentRunId?: string | n
389
488
  }
390
489
 
391
490
  /**
392
- * Render the agent panel (top ~40% of screen).
491
+ * Render the agent panel (left 60%, dynamic height).
393
492
  */
394
- function renderAgentPanel(
493
+ export function renderAgentPanel(
395
494
  data: DashboardData,
396
- width: number,
397
- height: number,
495
+ fullWidth: number,
496
+ panelHeight: number,
398
497
  startRow: number,
399
498
  ): string {
400
- const panelHeight = Math.floor(height * 0.4);
499
+ const leftWidth = Math.floor(fullWidth * 0.6);
401
500
  let output = "";
402
501
 
403
502
  // Panel header
404
- const headerLine = `${BOX.vertical} ${brand.bold("Agents")} (${data.status.agents.length})`;
405
- const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
406
- output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
503
+ const headerLine = `${dimBox.vertical} ${brand.bold("Agents")} (${data.status.agents.length})`;
504
+ const headerPadding = " ".repeat(
505
+ Math.max(0, leftWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
506
+ );
507
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
407
508
 
408
509
  // Column headers
409
- const colHeaders = `${BOX.vertical} St Name Capability State Task ID Duration Tmux ${BOX.vertical}`;
410
- output += `${CURSOR.cursorTo(startRow + 1, 1)}${colHeaders}\n`;
510
+ const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Tmux `;
511
+ const colPadding = " ".repeat(
512
+ Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
513
+ );
514
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${colStr}${colPadding}${dimBox.vertical}\n`;
411
515
 
412
516
  // Separator
413
- const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
517
+ const separator = dimHorizontalLine(leftWidth, dimBox.tee, dimBox.teeRight);
414
518
  output += `${CURSOR.cursorTo(startRow + 2, 1)}${separator}\n`;
415
519
 
416
- // Sort agents: active first (working, booting, stalled), then completed, then zombie
520
+ // Sort agents: active first, then completed, then zombie
417
521
  const agents = [...data.status.agents].sort((a, b) => {
418
522
  const activeStates = ["working", "booting", "stalled"];
419
523
  const aActive = activeStates.includes(a.state);
@@ -446,25 +550,186 @@ function renderAgentPanel(
446
550
  const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
447
551
  const tmuxDot = tmuxAlive ? color.green(">") : color.red("x");
448
552
 
449
- const line = `${BOX.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
450
- output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${line}\n`;
553
+ const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${tmuxDot} `;
554
+ const linePadding = " ".repeat(
555
+ Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
556
+ );
557
+ output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${lineContent}${linePadding}${dimBox.vertical}\n`;
451
558
  }
452
559
 
453
560
  // Fill remaining rows with empty lines
454
561
  for (let i = visibleAgents.length; i < maxRows; i++) {
455
- const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, width - 2))}${BOX.vertical}`;
562
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, leftWidth - 2))}${dimBox.vertical}`;
456
563
  output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${emptyLine}\n`;
457
564
  }
458
565
 
459
- // Bottom border
460
- const bottomBorder = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
566
+ // Bottom border (joins the right column)
567
+ const bottomBorder = dimHorizontalLine(leftWidth, dimBox.tee, dimBox.teeRight);
461
568
  output += `${CURSOR.cursorTo(startRow + 3 + maxRows, 1)}${bottomBorder}\n`;
462
569
 
463
570
  return output;
464
571
  }
465
572
 
466
573
  /**
467
- * Render the mail panel (middle-left ~30% height, ~60% width).
574
+ * Render the tasks panel (upper-right quadrant).
575
+ */
576
+ export function renderTasksPanel(
577
+ data: DashboardData,
578
+ startCol: number,
579
+ panelWidth: number,
580
+ panelHeight: number,
581
+ startRow: number,
582
+ ): string {
583
+ let output = "";
584
+
585
+ // Header
586
+ const headerLine = `${dimBox.vertical} ${brand.bold("Tasks")} (${data.tasks.length})`;
587
+ const headerPadding = " ".repeat(
588
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
589
+ );
590
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
591
+
592
+ // Separator
593
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.teeRight);
594
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
595
+
596
+ const maxRows = panelHeight - 2; // header + separator
597
+ const visibleTasks = data.tasks.slice(0, maxRows);
598
+
599
+ if (visibleTasks.length === 0) {
600
+ const emptyMsg = color.dim("No tracker data");
601
+ const emptyLine = `${dimBox.vertical} ${emptyMsg}`;
602
+ const emptyPadding = " ".repeat(
603
+ Math.max(0, panelWidth - visibleLength(emptyLine) - visibleLength(dimBox.vertical)),
604
+ );
605
+ output += `${CURSOR.cursorTo(startRow + 2, startCol)}${emptyLine}${emptyPadding}${dimBox.vertical}\n`;
606
+ // Fill remaining rows
607
+ for (let i = 1; i < maxRows; i++) {
608
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
609
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
610
+ }
611
+ return output;
612
+ }
613
+
614
+ for (let i = 0; i < visibleTasks.length; i++) {
615
+ const task = visibleTasks[i];
616
+ if (!task) continue;
617
+
618
+ const idStr = accent(pad(truncate(task.id, 14), 14));
619
+ const priorityStr = numericPriorityColor(task.priority)(`P${task.priority}`);
620
+ const statusStr = pad(task.status, 12);
621
+ const titleMaxLen = Math.max(4, panelWidth - 44);
622
+ const titleStr = truncate(task.title, titleMaxLen);
623
+
624
+ const lineContent = `${dimBox.vertical} ${idStr} ${titleStr} ${priorityStr} ${statusStr}`;
625
+ const linePadding = " ".repeat(
626
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
627
+ );
628
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${linePadding}${dimBox.vertical}\n`;
629
+ }
630
+
631
+ // Fill remaining rows
632
+ for (let i = visibleTasks.length; i < maxRows; i++) {
633
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
634
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
635
+ }
636
+
637
+ return output;
638
+ }
639
+
640
+ /**
641
+ * Render the feed panel (lower-right quadrant).
642
+ */
643
+ export function renderFeedPanel(
644
+ data: DashboardData,
645
+ startCol: number,
646
+ panelWidth: number,
647
+ panelHeight: number,
648
+ startRow: number,
649
+ ): string {
650
+ let output = "";
651
+
652
+ // Header
653
+ const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (last 5 min)`;
654
+ const headerPadding = " ".repeat(
655
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
656
+ );
657
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
658
+
659
+ // Separator
660
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.teeRight);
661
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
662
+
663
+ const maxRows = panelHeight - 2; // header + separator
664
+
665
+ if (data.recentEvents.length === 0) {
666
+ const emptyMsg = color.dim("No recent events");
667
+ const emptyLine = `${dimBox.vertical} ${emptyMsg}`;
668
+ const emptyPadding = " ".repeat(
669
+ Math.max(0, panelWidth - visibleLength(emptyLine) - visibleLength(dimBox.vertical)),
670
+ );
671
+ output += `${CURSOR.cursorTo(startRow + 2, startCol)}${emptyLine}${emptyPadding}${dimBox.vertical}\n`;
672
+ for (let i = 1; i < maxRows; i++) {
673
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
674
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
675
+ }
676
+ return output;
677
+ }
678
+
679
+ const colorMap = buildAgentColorMap(data.recentEvents);
680
+ const visibleEvents = data.recentEvents.slice(0, maxRows);
681
+
682
+ for (let i = 0; i < visibleEvents.length; i++) {
683
+ const event = visibleEvents[i];
684
+ if (!event) continue;
685
+
686
+ const formatted = formatEventLine(event, colorMap);
687
+ // ANSI-safe truncation: trim to panelWidth - 4 (border + space each side)
688
+ const maxLineLen = panelWidth - 4;
689
+ let displayLine = formatted;
690
+ if (visibleLength(displayLine) > maxLineLen) {
691
+ // Truncate by stripping to visible characters
692
+ let count = 0;
693
+ let end = 0;
694
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI
695
+ const ANSI = /\x1b\[[0-9;]*m/g;
696
+ let lastIndex = 0;
697
+ let match = ANSI.exec(displayLine);
698
+ while (match !== null) {
699
+ const plainSegLen = match.index - lastIndex;
700
+ if (count + plainSegLen >= maxLineLen - 1) {
701
+ end = lastIndex + (maxLineLen - 1 - count);
702
+ count = maxLineLen - 1;
703
+ break;
704
+ }
705
+ count += plainSegLen;
706
+ lastIndex = match.index + match[0].length;
707
+ end = lastIndex;
708
+ match = ANSI.exec(displayLine);
709
+ }
710
+ if (count < maxLineLen - 1) {
711
+ end = displayLine.length;
712
+ }
713
+ displayLine = `${displayLine.slice(0, end)}…`;
714
+ }
715
+
716
+ const lineContent = `${dimBox.vertical} ${displayLine}`;
717
+ const contentLen = visibleLength(lineContent) + visibleLength(dimBox.vertical);
718
+ const linePadding = " ".repeat(Math.max(0, panelWidth - contentLen));
719
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${linePadding}${dimBox.vertical}\n`;
720
+ }
721
+
722
+ // Fill remaining rows
723
+ for (let i = visibleEvents.length; i < maxRows; i++) {
724
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
725
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
726
+ }
727
+
728
+ return output;
729
+ }
730
+
731
+ /**
732
+ * Render the mail panel (bottom-left 50%).
468
733
  */
469
734
  function renderMailPanel(
470
735
  data: DashboardData,
@@ -473,15 +738,17 @@ function renderMailPanel(
473
738
  startRow: number,
474
739
  ): string {
475
740
  const panelHeight = Math.floor(height * 0.3);
476
- const panelWidth = Math.floor(width * 0.6);
741
+ const panelWidth = Math.floor(width * 0.5);
477
742
  let output = "";
478
743
 
479
744
  const unreadCount = data.status.unreadMailCount;
480
- const headerLine = `${BOX.vertical} ${brand.bold("Mail")} (${unreadCount} unread)`;
481
- const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
482
- output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
745
+ const headerLine = `${dimBox.vertical} ${brand.bold("Mail")} (${unreadCount} unread)`;
746
+ const headerPadding = " ".repeat(
747
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
748
+ );
749
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
483
750
 
484
- const separator = horizontalLine(panelWidth, BOX.tee, BOX.horizontal, BOX.cross);
751
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.cross);
485
752
  output += `${CURSOR.cursorTo(startRow + 1, 1)}${separator}\n`;
486
753
 
487
754
  const maxRows = panelHeight - 3; // header + separator + border
@@ -499,14 +766,16 @@ function renderMailPanel(
499
766
  const time = formatRelativeTime(msg.createdAt);
500
767
 
501
768
  const coloredPriority = priority ? priorityColorFn(priority) : "";
502
- const line = `${BOX.vertical} ${coloredPriority}${from} → ${to}: ${subject} (${time})`;
503
- const padding = " ".repeat(Math.max(0, panelWidth - visibleLength(line) - 1));
504
- output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${line}${padding}${BOX.vertical}\n`;
769
+ const lineContent = `${dimBox.vertical} ${coloredPriority}${from} → ${to}: ${subject} (${time})`;
770
+ const padding = " ".repeat(
771
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
772
+ );
773
+ output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${lineContent}${padding}${dimBox.vertical}\n`;
505
774
  }
506
775
 
507
776
  // Fill remaining rows with empty lines
508
777
  for (let i = messages.length; i < maxRows; i++) {
509
- const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${BOX.vertical}`;
778
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
510
779
  output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${emptyLine}\n`;
511
780
  }
512
781
 
@@ -514,7 +783,7 @@ function renderMailPanel(
514
783
  }
515
784
 
516
785
  /**
517
- * Render the merge queue panel (middle-right ~30% height, ~40% width).
786
+ * Render the merge queue panel (bottom-right 50%).
518
787
  */
519
788
  function renderMergeQueuePanel(
520
789
  data: DashboardData,
@@ -527,11 +796,13 @@ function renderMergeQueuePanel(
527
796
  const panelWidth = width - startCol + 1;
528
797
  let output = "";
529
798
 
530
- const headerLine = `${BOX.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
531
- const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
532
- output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${BOX.vertical}\n`;
799
+ const headerLine = `${dimBox.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
800
+ const headerPadding = " ".repeat(
801
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
802
+ );
803
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
533
804
 
534
- const separator = horizontalLine(panelWidth, BOX.cross, BOX.horizontal, BOX.teeRight);
805
+ const separator = dimHorizontalLine(panelWidth, dimBox.cross, dimBox.teeRight);
535
806
  output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
536
807
 
537
808
  const maxRows = panelHeight - 3; // header + separator + border
@@ -546,14 +817,16 @@ function renderMergeQueuePanel(
546
817
  const agent = accent(truncate(entry.agentName, 15));
547
818
  const branch = truncate(entry.branchName, panelWidth - 30);
548
819
 
549
- const line = `${BOX.vertical} ${statusColorFn(status)} ${agent} ${branch}`;
550
- const padding = " ".repeat(Math.max(0, panelWidth - visibleLength(line) - 1));
551
- output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${line}${padding}${BOX.vertical}\n`;
820
+ const lineContent = `${dimBox.vertical} ${statusColorFn(status)} ${agent} ${branch}`;
821
+ const padding = " ".repeat(
822
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
823
+ );
824
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${padding}${dimBox.vertical}\n`;
552
825
  }
553
826
 
554
827
  // Fill remaining rows with empty lines
555
828
  for (let i = entries.length; i < maxRows; i++) {
556
- const emptyLine = `${BOX.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${BOX.vertical}`;
829
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
557
830
  output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${emptyLine}\n`;
558
831
  }
559
832
 
@@ -571,24 +844,28 @@ function renderMetricsPanel(
571
844
  ): string {
572
845
  let output = "";
573
846
 
574
- const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
847
+ const separator = dimHorizontalLine(width, dimBox.tee, dimBox.teeRight);
575
848
  output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
576
849
 
577
- const headerLine = `${BOX.vertical} ${brand.bold("Metrics")}`;
578
- const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
579
- output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
850
+ const headerLine = `${dimBox.vertical} ${brand.bold("Metrics")}`;
851
+ const headerPadding = " ".repeat(
852
+ Math.max(0, width - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
853
+ );
854
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
580
855
 
581
856
  const totalSessions = data.metrics.totalSessions;
582
- const avgDuration = formatDuration(data.metrics.avgDuration);
857
+ const avgDur = formatDuration(data.metrics.avgDuration);
583
858
  const byCapability = Object.entries(data.metrics.byCapability)
584
859
  .map(([cap, count]) => `${cap}:${count}`)
585
860
  .join(", ");
586
861
 
587
- const metricsLine = `${BOX.vertical} Total sessions: ${totalSessions} | Avg duration: ${avgDuration} | By capability: ${byCapability}`;
588
- const metricsPadding = " ".repeat(Math.max(0, width - metricsLine.length - 1));
589
- output += `${CURSOR.cursorTo(startRow + 2, 1)}${metricsLine}${metricsPadding}${BOX.vertical}\n`;
862
+ const metricsLine = `${dimBox.vertical} Total sessions: ${totalSessions} | Avg duration: ${avgDur} | By capability: ${byCapability}`;
863
+ const metricsPadding = " ".repeat(
864
+ Math.max(0, width - visibleLength(metricsLine) - visibleLength(dimBox.vertical)),
865
+ );
866
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
590
867
 
591
- const bottomBorder = horizontalLine(width, BOX.bottomLeft, BOX.horizontal, BOX.bottomRight);
868
+ const bottomBorder = dimHorizontalLine(width, dimBox.bottomLeft, dimBox.bottomRight);
592
869
  output += `${CURSOR.cursorTo(startRow + 3, 1)}${bottomBorder}\n`;
593
870
 
594
871
  return output;
@@ -606,19 +883,45 @@ function renderDashboard(data: DashboardData, interval: number): void {
606
883
  // Header (rows 1-2)
607
884
  output += renderHeader(width, interval, data.currentRunId);
608
885
 
609
- // Agent panel (rows 3 to ~40% of screen)
886
+ // Agent panel start row
610
887
  const agentPanelStart = 3;
611
- output += renderAgentPanel(data, width, height, agentPanelStart);
612
888
 
613
- // Calculate middle panels start row
614
- const agentPanelHeight = Math.floor(height * 0.4);
889
+ // Dynamic agent panel height
890
+ const agentCount = data.status.agents.length;
891
+ const agentPanelHeight = computeAgentPanelHeight(height, agentCount);
892
+
893
+ // Column widths
894
+ const leftWidth = Math.floor(width * 0.6);
895
+ const rightWidth = width - leftWidth;
896
+ const rightStartCol = leftWidth + 1;
897
+
898
+ // Right column split (Tasks upper / Feed lower)
899
+ const rightHalf = Math.floor(agentPanelHeight / 2);
900
+ const feedHeight = agentPanelHeight - rightHalf;
901
+
902
+ // Render left: agents (60% wide, dynamic height)
903
+ output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
904
+
905
+ // Render right-upper: tasks
906
+ output += renderTasksPanel(data, rightStartCol, rightWidth, rightHalf, agentPanelStart);
907
+
908
+ // Render right-lower: feed
909
+ output += renderFeedPanel(
910
+ data,
911
+ rightStartCol,
912
+ rightWidth,
913
+ feedHeight,
914
+ agentPanelStart + rightHalf,
915
+ );
916
+
917
+ // Middle panels (mail/merge) start after agent block
615
918
  const middlePanelStart = agentPanelStart + agentPanelHeight + 1;
616
919
 
617
- // Mail panel (left 60%)
920
+ // Mail panel (left 50%)
618
921
  output += renderMailPanel(data, width, height, middlePanelStart);
619
922
 
620
- // Merge queue panel (right 40%)
621
- const mergeQueueCol = Math.floor(width * 0.6) + 1;
923
+ // Merge queue panel (right 50%)
924
+ const mergeQueueCol = Math.floor(width * 0.5) + 1;
622
925
  output += renderMergeQueuePanel(data, width, height, middlePanelStart, mergeQueueCol);
623
926
 
624
927
  // Metrics panel (bottom strip)