bullswarm 0.18.4 → 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.4",
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 } = {}) {
@@ -1268,7 +1403,8 @@ function detailRow(bullswarmDir, token) {
1268
1403
  }
1269
1404
 
1270
1405
  export async function runDashboard(bullswarmDir, {
1271
- input = process.stdin, output = process.stdout, refreshMs = 1000, token = null,
1406
+ input = process.stdin, output = process.stdout, refreshMs = 1000,
1407
+ spinnerMs = 400, token = null,
1272
1408
  } = {}) {
1273
1409
  if ((!input.isTTY || !output.isTTY) && !token) throw new Error('workflow dashboard requires a TTY, or pass a run ID for a static text tree');
1274
1410
  if ((!input.isTTY || !output.isTTY) && token) {
@@ -1280,8 +1416,13 @@ export async function runDashboard(bullswarmDir, {
1280
1416
  let detail = Boolean(token);
1281
1417
  let message = null;
1282
1418
  let lastGoodRow = null;
1283
- 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);
1284
1424
  let selectedRunId = token ? detailRow(bullswarmDir, token).runId : (rows[selected]?.runId ?? null);
1425
+ let lastPaintedFrame = null;
1285
1426
  const ui = {
1286
1427
  focus: 0,
1287
1428
  phaseIndex: null,
@@ -1297,6 +1438,25 @@ export async function runDashboard(bullswarmDir, {
1297
1438
  mobileTimeline: true,
1298
1439
  spinnerFrame: 0,
1299
1440
  };
1441
+ // Clearing the entire alternate screen for every spinner frame produces a
1442
+ // visible blank flash on slower terminals, especially mobile SSH sessions.
1443
+ // Enter/clear the alternate screen once, then repaint each row in place.
1444
+ // Padding to the terminal height also removes remnants when switching from
1445
+ // a taller detail view to a shorter picker view.
1446
+ const writeFrame = (text) => {
1447
+ const clearPrefix = `${ESC}2J${ESC}H`;
1448
+ let source = String(text ?? '').startsWith(clearPrefix)
1449
+ ? String(text ?? '').slice(clearPrefix.length)
1450
+ : String(text ?? '');
1451
+ if (source.startsWith('\n')) source = source.slice(1);
1452
+ const height = Math.max(1, Number(output.rows) || source.split('\n').length);
1453
+ const lines = source.split('\n').slice(0, height);
1454
+ while (lines.length < height) lines.push('');
1455
+ const frame = `${ESC}H${lines.map((line) => `${line}${ESC}K`).join('\n')}`;
1456
+ if (frame === lastPaintedFrame) return;
1457
+ lastPaintedFrame = frame;
1458
+ output.write(frame);
1459
+ };
1300
1460
  const paintUnsafe = () => {
1301
1461
  if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
1302
1462
  if (detail && selectedRunId) {
@@ -1311,7 +1471,7 @@ export async function runDashboard(bullswarmDir, {
1311
1471
  });
1312
1472
  ui.phaseIndex = model.phaseIndex;
1313
1473
  ui.agentIndex = model.agentIndex;
1314
- output.write(renderWorkflowTui(row, {
1474
+ writeFrame(renderWorkflowTui(row, {
1315
1475
  width: output.columns,
1316
1476
  height: output.rows,
1317
1477
  ...ui,
@@ -1319,7 +1479,27 @@ export async function runDashboard(bullswarmDir, {
1319
1479
  }));
1320
1480
  return;
1321
1481
  }
1322
- output.write(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
+ }));
1323
1503
  };
1324
1504
  // A render error must never kill the TUI or strand the terminal in
1325
1505
  // alt-screen raw mode (crash observed 2026-08-29 at detailRow via the
@@ -1327,25 +1507,35 @@ export async function runDashboard(bullswarmDir, {
1327
1507
  const paint = () => {
1328
1508
  try { paintUnsafe(); } catch (err) {
1329
1509
  message = `display error: ${err.message}`;
1330
- try { output.write(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 */ }
1331
1516
  }
1332
1517
  };
1333
1518
  const refresh = () => {
1334
1519
  try {
1335
- 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));
1336
1526
  } catch (err) { message = `display error: ${err.message}`; }
1337
- if (!selectedRunId) selectedRunId = rows[selected]?.runId ?? null;
1527
+ selectedRunId = rows[selected]?.runId ?? null;
1338
1528
  paint();
1339
1529
  };
1340
1530
  input.setRawMode?.(true);
1341
1531
  input.resume();
1342
- output.write(`${ESC}?1049h${ESC}?25l`);
1532
+ output.write(`${ESC}?1049h${ESC}?25l${ESC}2J${ESC}H`);
1343
1533
  paint();
1344
1534
  const timer = setInterval(refresh, refreshMs);
1345
1535
  const spinnerTimer = setInterval(() => {
1346
1536
  ui.spinnerFrame = (ui.spinnerFrame + 1) % SPINNER_FRAMES.length;
1347
- if (detail) paint();
1348
- }, 160);
1537
+ if (detail || rows[selected]?.ongoing) paint();
1538
+ }, Math.max(50, Number(spinnerMs) || 400));
1349
1539
  return new Promise((resolve) => {
1350
1540
  const finish = () => {
1351
1541
  clearInterval(timer);
@@ -1413,6 +1603,28 @@ export async function runDashboard(bullswarmDir, {
1413
1603
  };
1414
1604
  const onDataUnsafe = (buf) => {
1415
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
+ }
1416
1628
  if (ui.confirmCancel) {
1417
1629
  if (key === 'y' || key === 'Y') return requestSelectedCancel();
1418
1630
  if (key === 'n' || key === 'N' || key === '\u001b' || key === '\u0003') {
@@ -1424,6 +1636,16 @@ export async function runDashboard(bullswarmDir, {
1424
1636
  }
1425
1637
  if (key === 'q' || key === '\u0003') return finish();
1426
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
+ }
1427
1649
  if (key === '\u001b' || key === 'b') {
1428
1650
  if (ui.orchestratorDetail) {
1429
1651
  ui.orchestratorDetail = false;
@@ -1486,6 +1708,12 @@ export async function runDashboard(bullswarmDir, {
1486
1708
  if (key === '\r' || key === '\n') {
1487
1709
  if (!detail) {
1488
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
+ }
1489
1717
  detail = true;
1490
1718
  ui.followActivePhase = true;
1491
1719
  ui.followActiveAgent = true;