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 +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 +256 -28
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 } = {}) {
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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;
|