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 +21 -0
- package/package.json +1 -1
- package/src/cli.js +7 -1
- package/src/help.js +9 -5
- package/src/workflow/cli.js +11 -4
- package/src/workflow/dashboard.js +233 -25
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
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
|
-
|
|
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
|
|
685
|
-
+ 'live-agent, and technical drill-down views
|
|
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: '
|
|
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.',
|
package/src/workflow/cli.js
CHANGED
|
@@ -81,11 +81,18 @@ function discover() {
|
|
|
81
81
|
return found;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export async function cmdWorkflow(args
|
|
85
|
-
|
|
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(
|
|
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(
|
|
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) =>
|
|
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({
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|