bullswarm 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.19.0 — unified workflow dashboard
4
+
5
+ - Running `bullswarm workflow` on an interactive terminal now opens one
6
+ full-screen home for active and recent runs, with filtering, safe detach,
7
+ and direct navigation into each workflow's timeline, planner, phases,
8
+ agents, and activity.
9
+ - Wide terminals pair the run list with a live selected-run preview. Narrow
10
+ phone and SSH terminals use a full-width stacked list before opening the
11
+ selected workflow, preserving readable navigation without duplicating the
12
+ desktop layout.
13
+ - Non-interactive callers still receive side-effect-free help, existing JSON
14
+ contracts remain stable, and `workflow tui` remains a compatibility alias.
15
+ - Exact-height mobile repainting now preserves the footer while retaining the
16
+ no-flash in-place refresh behavior.
17
+
18
+ ## 0.18.5 — no-flash workflow TUI
19
+
20
+ - Full-screen workflow views repaint rows in place instead of repeatedly
21
+ clearing the terminal, removing the visible flash on slower mobile and SSH
22
+ sessions.
23
+
3
24
  ## 0.18.4 — reliable auto-follow tail
4
25
 
5
26
  - Timestamp-aligned mobile auto-follow now preserves the newest workflow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -430,7 +430,13 @@ async function cmdDoctor(opts) {
430
430
  // --- main ---------------------------------------------------------------------
431
431
 
432
432
  export async function main(argv) {
433
- const help = helpForArgs(argv);
433
+ // Bare `bullswarm workflow` is the human workflow home on a terminal.
434
+ // Non-TTY callers still receive side-effect-free help, exactly as before.
435
+ const bareWorkflowDashboard = argv.length === 1
436
+ && argv[0] === 'workflow'
437
+ && process.stdin.isTTY
438
+ && process.stdout.isTTY;
439
+ const help = bareWorkflowDashboard ? null : helpForArgs(argv);
434
440
  if (help) {
435
441
  console.log(help);
436
442
  return 0;
package/src/help.js CHANGED
@@ -528,10 +528,11 @@ const strategyAutoOffText = rich({
528
528
  // them branch on opts.json.
529
529
 
530
530
  const workflowText = rich({
531
- usage: 'bullswarm workflow <command> [options]',
531
+ usage: 'bullswarm workflow [<command>] [options]',
532
532
  purpose: 'Create, execute, observe, and audit durable multi-agent workflows: autonomous '
533
533
  + 'goals, fixed workflow files, and incrementally-built drafts all run through the same '
534
- + 'durable, resumable execution engine.',
534
+ + 'durable, resumable execution engine. With no command on a TTY, opens the unified '
535
+ + 'full-screen workflow dashboard.',
535
536
  argsTitle: 'Commands',
536
537
  args: [
537
538
  { name: 'goal "<goal>"', desc: 'autonomously plan, execute, verify, and replan a goal' },
@@ -551,6 +552,8 @@ const workflowText = rich({
551
552
  ],
552
553
  options: [],
553
554
  safety: [
555
+ 'bare bullswarm workflow opens a read-only active/recent run dashboard only when stdin and '
556
+ + 'stdout are TTYs; non-interactive callers receive this help text instead',
554
557
  'reconcileInterruptedRuns() runs before every workflow subcommand dispatch — including a '
555
558
  + 'mistyped bare "help" argument, since only --help/-h/leading "help" bypass dispatch '
556
559
  + 'entirely — and can rewrite state.json for any run whose heartbeat looks stale',
@@ -560,6 +563,7 @@ const workflowText = rich({
560
563
  + 'their own --help)',
561
564
  ],
562
565
  examples: [
566
+ { cmd: 'bullswarm workflow', note: 'open the human workflow home: runs, live preview, timeline, agents, and activity' },
563
567
  { cmd: 'bullswarm workflow goal "Audit this repository for TODOs" --cwd .', note: 'autonomous goal, launched independently' },
564
568
  { cmd: 'bullswarm workflow runs --all --since 7d', note: 'list every run started in the last week' },
565
569
  ],
@@ -681,8 +685,8 @@ const workflowInspectText = rich({
681
685
 
682
686
  const workflowTuiText = rich({
683
687
  usage: 'bullswarm workflow tui [<runId>] [--json] [--all] [--show <runId>] [--cancel <runId>]',
684
- purpose: 'Open the interactive full-screen workflow timeline with Workflow Planner, phase, '
685
- + 'live-agent, and technical drill-down views for ongoing and historical runs, or print a '
688
+ purpose: 'Open the interactive full-screen workflow dashboard with an active/recent run list, '
689
+ + 'selected-run preview, Workflow Planner, phase, live-agent, and technical drill-down views, or print a '
686
690
  + 'static/JSON snapshot for a non-interactive caller.',
687
691
  args: [{ name: '[<runId>]', desc: 'shortId or runId to open directly in detail view; omit to see the run picker' }],
688
692
  options: [
@@ -699,7 +703,7 @@ const workflowTuiText = rich({
699
703
  'below 100 columns the timeline remains full-width; press t to toggle Timeline and Phases, then Enter/Esc to drill into agents and activity',
700
704
  ],
701
705
  examples: [
702
- { cmd: 'bullswarm workflow tui', note: 'interactive run picker' },
706
+ { cmd: 'bullswarm workflow tui', note: 'compatibility alias for the bare workflow dashboard' },
703
707
  { cmd: 'bullswarm workflow tui --json --all' },
704
708
  ],
705
709
  next: 'bullswarm workflow watch <runId> for a low-noise non-interactive follow, or bullswarm workflow approval approve <runId> if it is waiting on a decision gate.',
@@ -81,11 +81,18 @@ function discover() {
81
81
  return found;
82
82
  }
83
83
 
84
- export async function cmdWorkflow(args) {
85
- reconcileInterruptedRuns(BULLSWARM_DIR());
84
+ export async function cmdWorkflow(args, {
85
+ bullswarmDir = BULLSWARM_DIR(), input = process.stdin, output = process.stdout,
86
+ } = {}) {
87
+ reconcileInterruptedRuns(bullswarmDir);
86
88
  const [sub, ...rest] = args;
87
89
  const opts = parseFlags(rest);
88
90
 
91
+ if (!sub && input.isTTY && output.isTTY) {
92
+ try { return await runDashboard(bullswarmDir, { input, output }); }
93
+ catch (err) { console.error(`✗ ${err.message}`); return 1; }
94
+ }
95
+
89
96
  switch (sub) {
90
97
  case 'goal':
91
98
  return wfGoal(opts);
@@ -107,7 +114,7 @@ export async function cmdWorkflow(args) {
107
114
  try {
108
115
  if (opts.json || opts.cancel || opts.show || opts.all) {
109
116
  const token = opts.rest[0] ?? opts.show;
110
- const result = dashboardJson(BULLSWARM_DIR(), {
117
+ const result = dashboardJson(bullswarmDir, {
111
118
  all: opts.all,
112
119
  token,
113
120
  cancel: opts.cancel,
@@ -115,7 +122,7 @@ export async function cmdWorkflow(args) {
115
122
  console.log(JSON.stringify(result, null, 2));
116
123
  return 0;
117
124
  }
118
- return await runDashboard(BULLSWARM_DIR(), { token: opts.rest[0] ?? null });
125
+ return await runDashboard(bullswarmDir, { token: opts.rest[0] ?? null, input, output });
119
126
  }
120
127
  catch (err) { console.error(`✗ ${err.message}`); return 1; }
121
128
  case 'events':
@@ -58,10 +58,14 @@ export function requestCancel(bullswarmDir, token) {
58
58
  return { ...resolved, state, alreadyFinished: false };
59
59
  }
60
60
 
61
- export function dashboardRows(bullswarmDir) {
61
+ export function dashboardRows(bullswarmDir, { all = false } = {}) {
62
62
  return listRuns(bullswarmDir)
63
- .filter((r) => r.ongoing)
64
- .sort((a, b) => String(b.state?.startedAt ?? '').localeCompare(String(a.state?.startedAt ?? '')))
63
+ .filter((r) => all || r.ongoing)
64
+ .sort((a, b) => {
65
+ if (a.ongoing !== b.ongoing) return a.ongoing ? -1 : 1;
66
+ return String(b.state?.startedAt ?? b.report?.startedAt ?? '')
67
+ .localeCompare(String(a.state?.startedAt ?? a.report?.startedAt ?? ''));
68
+ })
65
69
  .map((r) => {
66
70
  const state = r.state ?? {};
67
71
  const steps = state.steps ?? [];
@@ -83,23 +87,154 @@ export function dashboardRows(bullswarmDir) {
83
87
  });
84
88
  }
85
89
 
86
- export function renderDashboard({ rows, selected = 0, message = null } = {}) {
87
- const out = [`${ESC}2J${ESC}H`, ' bullswarm · workflows', ''];
88
- if (!rows?.length) {
89
- out.push(' No ongoing workflows.', '', ' Press r to refresh · q to quit');
90
- return out.join('\n');
90
+ export function renderDashboard({
91
+ rows, allRows = rows, selected = 0, message = null, width = 120, height = 36,
92
+ filter = 'active', query = '', filterEditing = false, spinnerFrame = 0,
93
+ previewRow = null,
94
+ } = {}) {
95
+ width = Math.max(20, Number(width) || 120);
96
+ height = Math.max(12, Number(height) || 36);
97
+ rows = rows ?? [];
98
+ allRows = allRows ?? rows;
99
+ const narrow = width < 100;
100
+ const active = allRows.filter((row) => row.ongoing).length;
101
+ const waiting = allRows.filter((row) => isWaitingWorkflow(row.state)).length;
102
+ const historical = Math.max(0, allRows.length - active);
103
+ const selectedRow = previewRow ?? rows[selected] ?? null;
104
+ const summary = `${active} active · ${waiting} waiting · ${historical} recent`;
105
+ const header = [
106
+ truncate(` bullswarm workflows · ${summary}`, width),
107
+ selectedRow
108
+ ? truncate(` ${selectedRow.shortId ?? '------'} · ${workflowRunLabel(selectedRow)}`, width)
109
+ : truncate(` ${filter === 'active' ? 'Active workflows' : 'All workflows'}`, width),
110
+ ];
111
+ const footer = filterEditing
112
+ ? truncate(` Filter: ${query}█ · Enter apply · Esc clear`, width)
113
+ : narrow
114
+ ? truncate(' ↑/↓ select · Enter open · / filter · a active/all · q exit', width)
115
+ : truncate(' ↑/↓ select · Enter open · / filter · a active/all · r refresh · c stop · q exit', width);
116
+ const messageLine = message
117
+ ? truncate(` ${message}`, width)
118
+ : truncate(` Runs · ${filter}${query ? ` · filter “${query}”` : ''} · workflow continues after dashboard exit`, width);
119
+ const bodyHeight = Math.max(6, height - header.length - 2);
120
+ const listLines = dashboardRunLines(rows, selected, narrow, width);
121
+
122
+ let body;
123
+ if (narrow) {
124
+ body = renderPanel(`Runs · ${filter}`, listWindow(listLines, selected, bodyHeight - 2, true), width, bodyHeight);
125
+ } else {
126
+ const leftWidth = Math.max(32, Math.min(46, Math.floor(width * 0.36)));
127
+ const rightWidth = width - leftWidth;
128
+ const left = renderPanel(
129
+ `Runs · ${filter}`,
130
+ listWindow(listLines, selected, bodyHeight - 2, false),
131
+ leftWidth,
132
+ bodyHeight,
133
+ );
134
+ let right;
135
+ if (selectedRow?.state) {
136
+ const model = workflowPanelModel(selectedRow);
137
+ right = renderWorkflowOverviewPanel(model, rightWidth, bodyHeight, spinnerFrame, 0);
138
+ } else {
139
+ const hint = allRows.length && filter === 'active'
140
+ ? ['No active workflows.', '', 'Press a to browse recent runs.']
141
+ : ['No workflow runs yet.', '', 'Start one with:', 'bullswarm workflow goal "…"'];
142
+ right = renderPanel('Selected workflow', hint, rightWidth, bodyHeight);
143
+ }
144
+ body = joinPanels(left, right);
91
145
  }
92
- out.push(' Select a run with j/k or arrows · Enter details · c stop · r refresh · q quit', '');
93
- out.push(' ID WORKFLOW STATUS PHASE PROGRESS');
94
- out.push(' ' + '-'.repeat(82));
95
- rows.forEach((r, i) => {
96
- const mark = i === selected ? '>' : ' ';
97
- const progress = `${r.stepsOk}/${r.stepsTotal} steps` + (r.fanout.total ? ` · ${r.fanout.ok}/${r.fanout.total} items` : '');
98
- out.push(`${mark} ${(r.shortId ?? '------').padEnd(8)} ${(r.state?.workflow ?? '?').slice(0, 24).padEnd(24)} ${(r.status ?? 'running').padEnd(10)} ${(r.phase ?? 'starting').slice(0, 20).padEnd(20)} ${progress}`);
146
+ return [`${ESC}2J${ESC}H`, ...header, ...body, messageLine, footer].join('\n');
147
+ }
148
+
149
+ function isWaitingWorkflow(state) {
150
+ const value = String(state?.status ?? state?.stage ?? '').toLowerCase();
151
+ return value.includes('waiting') || value === 'paused';
152
+ }
153
+
154
+ function workflowRunLabel(row) {
155
+ const state = row?.state ?? {};
156
+ const workflow = String(state.workflow ?? '').trim();
157
+ if (workflow && !workflow.startsWith('goal-')) return workflow;
158
+ return String(state.intent?.goal ?? state.intent?.description ?? workflow ?? row?.runId ?? 'workflow')
159
+ .split('\n')[0]
160
+ .trim();
161
+ }
162
+
163
+ function workflowConcernCount(row) {
164
+ const concerns = row?.state?.outcome?.concerns ?? row?.report?.concerns ?? [];
165
+ return Array.isArray(concerns) ? concerns.length : 0;
166
+ }
167
+
168
+ function dashboardRunLines(rows, selected, narrow, width) {
169
+ if (!rows.length) return [{ selected: false, lines: ['No workflows in this view.'] }];
170
+ return rows.map((row, index) => {
171
+ const state = row.state ?? {};
172
+ const selectedRow = index === selected;
173
+ const icon = statusIcon(row.ongoing ? (state.status ?? 'running') : (state.status ?? 'completed'));
174
+ const elapsed = durationText(state.startedAt ?? row.report?.startedAt, state.finishedAt ?? row.report?.finishedAt);
175
+ const workerAttempts = (state.attempts ?? []).filter((attempt) =>
176
+ attempt.actionId !== state.orchestration?.actionId && attempt.actionId !== 'orchestrator');
177
+ const finished = workerAttempts.filter((attempt) => TERMINAL_ACTIONS.has(attempt.status)).length;
178
+ let progress = workerAttempts.length
179
+ ? `${finished}/${workerAttempts.length} workers`
180
+ : `${row.stepsOk ?? 0}/${row.stepsTotal ?? 0} actions`;
181
+ if (row.fanout?.total) progress += ` · ${row.fanout.ok}/${row.fanout.total} items`;
182
+ const concerns = workflowConcernCount(row);
183
+ const status = concerns ? `${concerns} concern${concerns === 1 ? '' : 's'}` : humanWorkflowStatus(state.status, row.ongoing);
184
+ const name = workflowRunLabel(row);
185
+ const phase = humanPhaseName(row.phase ?? state.stage ?? 'starting');
186
+ if (narrow) {
187
+ const inner = Math.max(1, width - 4);
188
+ return {
189
+ selected: selectedRow,
190
+ lines: [
191
+ selectLine(`${icon} ${row.shortId ?? '------'} · ${name}`, selectedRow, true, inner),
192
+ selectLine(` ${progress} · ${elapsed}`, selectedRow, false, inner),
193
+ selectLine(` ${phase} · ${status}`, selectedRow, false, inner),
194
+ '',
195
+ ],
196
+ };
197
+ }
198
+ return {
199
+ selected: selectedRow,
200
+ lines: [
201
+ selectLine(`${icon} ${row.shortId ?? '------'} · ${name}`, selectedRow, true, 44),
202
+ selectLine(` ${progress} · ${elapsed} · ${status}`, selectedRow, false, 44),
203
+ '',
204
+ ],
205
+ };
206
+ });
207
+ }
208
+
209
+ function listWindow(groups, selected, height, narrow) {
210
+ const linesPerGroup = narrow ? 4 : 3;
211
+ const capacity = Math.max(1, Math.floor(height / linesPerGroup));
212
+ const start = clamp(selected - Math.floor(capacity / 2), 0, Math.max(0, groups.length - capacity));
213
+ return groups.slice(start, start + capacity).flatMap((group) => group.lines).slice(0, height);
214
+ }
215
+
216
+ function humanWorkflowStatus(status, ongoing) {
217
+ const value = String(status ?? '').replaceAll('_', ' ');
218
+ if (ongoing && (!value || value === 'running')) return 'running';
219
+ if (value === 'completed') return 'finished';
220
+ if (value === 'completed with concerns') return 'finished with concerns';
221
+ return value || (ongoing ? 'running' : 'finished');
222
+ }
223
+
224
+ function humanPhaseName(value) {
225
+ return String(value ?? 'starting').replaceAll('-', ' ').replaceAll(':', ' › ');
226
+ }
227
+
228
+ function filterDashboardRows(rows, filter, query) {
229
+ const needle = String(query ?? '').trim().toLowerCase();
230
+ return (rows ?? []).filter((row) => {
231
+ if (filter === 'active' && !row.ongoing) return false;
232
+ if (!needle) return true;
233
+ const state = row.state ?? {};
234
+ return [row.shortId, row.runId, state.workflow, state.intent?.goal, row.phase, state.status]
235
+ .filter(Boolean)
236
+ .some((value) => String(value).toLowerCase().includes(needle));
99
237
  });
100
- if (message) out.push('', ` ${message}`);
101
- out.push('', ' Press Enter for details · q to quit');
102
- return out.join('\n');
103
238
  }
104
239
 
105
240
  export function renderDetails(row, { interactive = true } = {}) {
@@ -1281,7 +1416,11 @@ export async function runDashboard(bullswarmDir, {
1281
1416
  let detail = Boolean(token);
1282
1417
  let message = null;
1283
1418
  let lastGoodRow = null;
1284
- let rows = dashboardRows(bullswarmDir);
1419
+ let dashboardFilter = 'active';
1420
+ let query = '';
1421
+ let filterEditing = false;
1422
+ let allRows = dashboardRows(bullswarmDir, { all: true });
1423
+ let rows = filterDashboardRows(allRows, dashboardFilter, query);
1285
1424
  let selectedRunId = token ? detailRow(bullswarmDir, token).runId : (rows[selected]?.runId ?? null);
1286
1425
  let lastPaintedFrame = null;
1287
1426
  const ui = {
@@ -1306,9 +1445,10 @@ export async function runDashboard(bullswarmDir, {
1306
1445
  // a taller detail view to a shorter picker view.
1307
1446
  const writeFrame = (text) => {
1308
1447
  const clearPrefix = `${ESC}2J${ESC}H`;
1309
- const source = String(text ?? '').startsWith(clearPrefix)
1448
+ let source = String(text ?? '').startsWith(clearPrefix)
1310
1449
  ? String(text ?? '').slice(clearPrefix.length)
1311
1450
  : String(text ?? '');
1451
+ if (source.startsWith('\n')) source = source.slice(1);
1312
1452
  const height = Math.max(1, Number(output.rows) || source.split('\n').length);
1313
1453
  const lines = source.split('\n').slice(0, height);
1314
1454
  while (lines.length < height) lines.push('');
@@ -1339,7 +1479,27 @@ export async function runDashboard(bullswarmDir, {
1339
1479
  }));
1340
1480
  return;
1341
1481
  }
1342
- writeFrame(renderDashboard({ rows, selected, message }));
1482
+ let previewRow = null;
1483
+ if (selectedRunId) {
1484
+ const fresh = detailRow(bullswarmDir, selectedRunId);
1485
+ previewRow = (fresh.state || lastGoodRow?.runId !== fresh.runId) ? fresh : lastGoodRow;
1486
+ if (previewRow === fresh) lastGoodRow = fresh;
1487
+ const selectedListRow = rows[selected];
1488
+ if (previewRow && selectedListRow) previewRow.ongoing = selectedListRow.ongoing;
1489
+ }
1490
+ writeFrame(renderDashboard({
1491
+ rows,
1492
+ allRows,
1493
+ selected,
1494
+ message,
1495
+ width: output.columns,
1496
+ height: output.rows,
1497
+ filter: dashboardFilter,
1498
+ query,
1499
+ filterEditing,
1500
+ spinnerFrame: ui.spinnerFrame,
1501
+ previewRow,
1502
+ }));
1343
1503
  };
1344
1504
  // A render error must never kill the TUI or strand the terminal in
1345
1505
  // alt-screen raw mode (crash observed 2026-08-29 at detailRow via the
@@ -1347,14 +1507,24 @@ export async function runDashboard(bullswarmDir, {
1347
1507
  const paint = () => {
1348
1508
  try { paintUnsafe(); } catch (err) {
1349
1509
  message = `display error: ${err.message}`;
1350
- try { writeFrame(renderDashboard({ rows, selected, message })); } catch { /* keep the loop alive */ }
1510
+ try {
1511
+ writeFrame(renderDashboard({
1512
+ rows, allRows, selected, message, width: output.columns, height: output.rows,
1513
+ filter: dashboardFilter, query, filterEditing, spinnerFrame: ui.spinnerFrame,
1514
+ }));
1515
+ } catch { /* keep the loop alive */ }
1351
1516
  }
1352
1517
  };
1353
1518
  const refresh = () => {
1354
1519
  try {
1355
- rows = dashboardRows(bullswarmDir);
1520
+ const previousRunId = selectedRunId;
1521
+ allRows = dashboardRows(bullswarmDir, { all: true });
1522
+ rows = filterDashboardRows(allRows, dashboardFilter, query);
1523
+ const preserved = rows.findIndex((row) => row.runId === previousRunId);
1524
+ if (preserved >= 0) selected = preserved;
1525
+ else selected = clamp(selected, 0, Math.max(0, rows.length - 1));
1356
1526
  } catch (err) { message = `display error: ${err.message}`; }
1357
- if (!selectedRunId) selectedRunId = rows[selected]?.runId ?? null;
1527
+ selectedRunId = rows[selected]?.runId ?? null;
1358
1528
  paint();
1359
1529
  };
1360
1530
  input.setRawMode?.(true);
@@ -1364,7 +1534,7 @@ export async function runDashboard(bullswarmDir, {
1364
1534
  const timer = setInterval(refresh, refreshMs);
1365
1535
  const spinnerTimer = setInterval(() => {
1366
1536
  ui.spinnerFrame = (ui.spinnerFrame + 1) % SPINNER_FRAMES.length;
1367
- if (detail) paint();
1537
+ if (detail || rows[selected]?.ongoing) paint();
1368
1538
  }, Math.max(50, Number(spinnerMs) || 400));
1369
1539
  return new Promise((resolve) => {
1370
1540
  const finish = () => {
@@ -1433,6 +1603,28 @@ export async function runDashboard(bullswarmDir, {
1433
1603
  };
1434
1604
  const onDataUnsafe = (buf) => {
1435
1605
  const key = String(buf);
1606
+ if (filterEditing) {
1607
+ if (key === '\r' || key === '\n') {
1608
+ filterEditing = false;
1609
+ message = query ? `Showing workflows matching “${query}”.` : null;
1610
+ return refresh();
1611
+ }
1612
+ if (key === '\u001b' || key === '\u0003') {
1613
+ filterEditing = false;
1614
+ query = '';
1615
+ message = null;
1616
+ return refresh();
1617
+ }
1618
+ if (key === '\u007f' || key === '\b') {
1619
+ query = query.slice(0, -1);
1620
+ return refresh();
1621
+ }
1622
+ if (/^[ -~]+$/.test(key)) {
1623
+ query += key;
1624
+ return refresh();
1625
+ }
1626
+ return;
1627
+ }
1436
1628
  if (ui.confirmCancel) {
1437
1629
  if (key === 'y' || key === 'Y') return requestSelectedCancel();
1438
1630
  if (key === 'n' || key === 'N' || key === '\u001b' || key === '\u0003') {
@@ -1444,6 +1636,16 @@ export async function runDashboard(bullswarmDir, {
1444
1636
  }
1445
1637
  if (key === 'q' || key === '\u0003') return finish();
1446
1638
  if (key === 'r') { message = null; return refresh(); }
1639
+ if (!detail && key === '/') {
1640
+ filterEditing = true;
1641
+ message = null;
1642
+ return paint();
1643
+ }
1644
+ if (!detail && key === 'a') {
1645
+ dashboardFilter = dashboardFilter === 'active' ? 'all' : 'active';
1646
+ message = dashboardFilter === 'active' ? 'Showing active workflows.' : 'Showing active and recent workflows.';
1647
+ return refresh();
1648
+ }
1447
1649
  if (key === '\u001b' || key === 'b') {
1448
1650
  if (ui.orchestratorDetail) {
1449
1651
  ui.orchestratorDetail = false;
@@ -1506,6 +1708,12 @@ export async function runDashboard(bullswarmDir, {
1506
1708
  if (key === '\r' || key === '\n') {
1507
1709
  if (!detail) {
1508
1710
  selectedRunId = rows[selected]?.runId ?? selectedRunId;
1711
+ if (!selectedRunId) {
1712
+ message = dashboardFilter === 'active'
1713
+ ? 'No active workflow selected · press a to browse recent runs.'
1714
+ : 'No workflow selected.';
1715
+ return paint();
1716
+ }
1509
1717
  detail = true;
1510
1718
  ui.followActivePhase = true;
1511
1719
  ui.followActiveAgent = true;