@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +6 -5
- package/src/agents/identity.test.ts +3 -2
- package/src/agents/manifest.test.ts +4 -3
- package/src/agents/overlay.test.ts +3 -2
- package/src/commands/agents.test.ts +5 -4
- package/src/commands/completions.test.ts +8 -5
- package/src/commands/completions.ts +37 -1
- package/src/commands/costs.test.ts +4 -3
- package/src/commands/dashboard.test.ts +265 -6
- package/src/commands/dashboard.ts +367 -64
- package/src/commands/doctor.test.ts +3 -2
- package/src/commands/errors.test.ts +3 -2
- package/src/commands/feed.test.ts +3 -2
- package/src/commands/feed.ts +2 -29
- package/src/commands/inspect.test.ts +3 -2
- package/src/commands/log.test.ts +248 -8
- package/src/commands/log.ts +193 -110
- package/src/commands/logs.test.ts +3 -2
- package/src/commands/mail.test.ts +3 -2
- package/src/commands/metrics.test.ts +4 -3
- package/src/commands/nudge.test.ts +3 -2
- package/src/commands/prime.test.ts +2 -2
- package/src/commands/replay.test.ts +3 -2
- package/src/commands/run.test.ts +2 -1
- package/src/commands/sling.test.ts +127 -0
- package/src/commands/sling.ts +101 -3
- package/src/commands/status.test.ts +8 -8
- package/src/commands/trace.test.ts +3 -2
- package/src/commands/watch.test.ts +3 -2
- package/src/config.test.ts +3 -3
- package/src/doctor/agents.test.ts +3 -2
- package/src/doctor/logs.test.ts +3 -2
- package/src/doctor/structure.test.ts +3 -2
- package/src/index.ts +3 -1
- package/src/logging/color.ts +1 -1
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +42 -1
- package/src/logging/logger.test.ts +3 -2
- package/src/mail/client.test.ts +3 -2
- package/src/mail/store.test.ts +3 -2
- package/src/merge/queue.test.ts +3 -2
- package/src/merge/resolver.test.ts +39 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.test.ts +63 -2
- package/src/mulch/client.ts +62 -1
- package/src/runtimes/claude.test.ts +4 -3
- package/src/runtimes/pi-guards.test.ts +26 -2
- package/src/runtimes/pi-guards.ts +3 -3
- package/src/schema-consistency.test.ts +4 -2
- package/src/sessions/compat.test.ts +3 -2
- package/src/sessions/store.test.ts +3 -2
- package/src/test-helpers.ts +20 -1
- package/src/watchdog/daemon.test.ts +4 -3
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
491
|
+
* Render the agent panel (left 60%, dynamic height).
|
|
393
492
|
*/
|
|
394
|
-
function renderAgentPanel(
|
|
493
|
+
export function renderAgentPanel(
|
|
395
494
|
data: DashboardData,
|
|
396
|
-
|
|
397
|
-
|
|
495
|
+
fullWidth: number,
|
|
496
|
+
panelHeight: number,
|
|
398
497
|
startRow: number,
|
|
399
498
|
): string {
|
|
400
|
-
const
|
|
499
|
+
const leftWidth = Math.floor(fullWidth * 0.6);
|
|
401
500
|
let output = "";
|
|
402
501
|
|
|
403
502
|
// Panel header
|
|
404
|
-
const headerLine = `${
|
|
405
|
-
const headerPadding = " ".repeat(
|
|
406
|
-
|
|
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
|
|
410
|
-
|
|
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 =
|
|
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
|
|
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
|
|
450
|
-
|
|
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 = `${
|
|
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 =
|
|
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
|
|
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.
|
|
741
|
+
const panelWidth = Math.floor(width * 0.5);
|
|
477
742
|
let output = "";
|
|
478
743
|
|
|
479
744
|
const unreadCount = data.status.unreadMailCount;
|
|
480
|
-
const headerLine = `${
|
|
481
|
-
const headerPadding = " ".repeat(
|
|
482
|
-
|
|
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 =
|
|
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
|
|
503
|
-
const padding = " ".repeat(
|
|
504
|
-
|
|
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 = `${
|
|
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 (
|
|
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 = `${
|
|
531
|
-
const headerPadding = " ".repeat(
|
|
532
|
-
|
|
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 =
|
|
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
|
|
550
|
-
const padding = " ".repeat(
|
|
551
|
-
|
|
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 = `${
|
|
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 =
|
|
847
|
+
const separator = dimHorizontalLine(width, dimBox.tee, dimBox.teeRight);
|
|
575
848
|
output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
|
|
576
849
|
|
|
577
|
-
const headerLine = `${
|
|
578
|
-
const headerPadding = " ".repeat(
|
|
579
|
-
|
|
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
|
|
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 = `${
|
|
588
|
-
const metricsPadding = " ".repeat(
|
|
589
|
-
|
|
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 =
|
|
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
|
|
886
|
+
// Agent panel start row
|
|
610
887
|
const agentPanelStart = 3;
|
|
611
|
-
output += renderAgentPanel(data, width, height, agentPanelStart);
|
|
612
888
|
|
|
613
|
-
//
|
|
614
|
-
const
|
|
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
|
|
920
|
+
// Mail panel (left 50%)
|
|
618
921
|
output += renderMailPanel(data, width, height, middlePanelStart);
|
|
619
922
|
|
|
620
|
-
// Merge queue panel (right
|
|
621
|
-
const mergeQueueCol = Math.floor(width * 0.
|
|
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)
|