bullswarm 0.38.1 → 0.38.2

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.
Files changed (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +10 -0
  3. package/package.json +1 -1
  4. package/src/workflow/dashboard-agent-detail.js +117 -0
  5. package/src/workflow/dashboard-ansi.js +83 -0
  6. package/src/workflow/dashboard-cancel.js +37 -0
  7. package/src/workflow/dashboard-clamp.js +9 -0
  8. package/src/workflow/dashboard-clipboard.js +22 -0
  9. package/src/workflow/dashboard-details.js +60 -0
  10. package/src/workflow/dashboard-frame.js +134 -0
  11. package/src/workflow/dashboard-integration.js +80 -0
  12. package/src/workflow/dashboard-keys.js +49 -0
  13. package/src/workflow/dashboard-licence.js +108 -0
  14. package/src/workflow/dashboard-nav.js +164 -0
  15. package/src/workflow/dashboard-page-budget.js +22 -0
  16. package/src/workflow/dashboard-page-fleet.js +23 -0
  17. package/src/workflow/dashboard-page-help.js +80 -0
  18. package/src/workflow/dashboard-page-history.js +14 -0
  19. package/src/workflow/dashboard-page-stats.js +36 -0
  20. package/src/workflow/dashboard-pages.js +36 -0
  21. package/src/workflow/dashboard-panels.js +62 -0
  22. package/src/workflow/dashboard-render.js +194 -0
  23. package/src/workflow/dashboard-rows.js +127 -0
  24. package/src/workflow/dashboard-run-state.js +27 -0
  25. package/src/workflow/dashboard-stats-legend.js +186 -0
  26. package/src/workflow/dashboard-status.js +45 -0
  27. package/src/workflow/dashboard-value-text.js +122 -0
  28. package/src/workflow/dashboard.js +35 -1646
  29. package/src/workflow/dispatch-attempt-files.js +79 -0
  30. package/src/workflow/dispatch-deliverable.js +141 -0
  31. package/src/workflow/dispatch-handoff.js +86 -0
  32. package/src/workflow/dispatch-no-pool.js +94 -0
  33. package/src/workflow/dispatch-pools.js +103 -0
  34. package/src/workflow/dispatch-restart.js +122 -0
  35. package/src/workflow/dispatch-silence.js +47 -0
  36. package/src/workflow/dispatch-snapshot.js +245 -0
  37. package/src/workflow/retry-handoff.js +1 -1
  38. package/src/workflow/step-model-activity.js +232 -0
  39. package/src/workflow/step-model-basis-words.js +87 -0
  40. package/src/workflow/step-model-cost.js +178 -0
  41. package/src/workflow/step-model-counts.js +61 -0
  42. package/src/workflow/step-model-duration.js +29 -0
  43. package/src/workflow/step-model-events.js +110 -0
  44. package/src/workflow/step-model-files.js +67 -0
  45. package/src/workflow/step-model-pairs.js +162 -0
  46. package/src/workflow/step-model-presentation.js +233 -0
  47. package/src/workflow/step-model-report.js +62 -0
  48. package/src/workflow/step-model-stream.js +150 -0
  49. package/src/workflow/step-model-text.js +94 -0
  50. package/src/workflow/step-model-tool-kinds.js +56 -0
  51. package/src/workflow/step-model-tool-text.js +94 -0
  52. package/src/workflow/step-model-turn-rows.js +179 -0
  53. package/src/workflow/step-model-turns.js +179 -0
  54. package/src/workflow/step-model-usage.js +195 -0
  55. package/src/workflow/step-model-values.js +45 -0
  56. package/src/workflow/step-model.js +21 -1973
  57. package/src/workflow/v2-dispatch.js +27 -875
package/AGENTS.md CHANGED
@@ -61,7 +61,7 @@ before doing anything. Their readers and stored formats are unchanged.
61
61
  5. Workflow dispatches honor the same guarantees as single runs, in
62
62
  `src/workflow/v2-dispatch.js`: `BULLSWARM_DEPTH` is checked and propagated
63
63
  (`assertDepthAllowed` and `childDepthEnv` in `dispatchV2Action`), pools at
64
- a spent window are excluded (`preparePools`), and a sign-in failure is
64
+ a spent window are excluded (`preparePools`, in `dispatch-pools.js`), and a sign-in failure is
65
65
  failure kind `auth`: the step's retry skips every pool in the dead
66
66
  credential's group (`upstreamGroupOf`), a choice held for that dispatch
67
67
  only and never stored. A step's `route`, the run's pin and the step's
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.38.2 — Three large files split into one-concept modules
6
+
7
+ - internal: the three largest workflow files are split into one-concept
8
+ modules, with no behaviour change. `src/workflow/dashboard.js` goes from
9
+ 3,613 lines to 2,002 plus 24 `dashboard-*.js` modules (1,837 lines);
10
+ `step-model.js` from 2,540 to 588 plus 18 `step-model-*.js` modules (2,213
11
+ lines); `v2-dispatch.js` from 2,039 to 1,191 plus 8 `dispatch-*.js` modules
12
+ (917 lines). Each old file still exports the names the product reads from
13
+ it; names only tests read are now imported from the module that holds them.
14
+
5
15
  ## 0.38.1 — Fewer wasted retries, honest no-pool reasons
6
16
 
7
17
  - no eligible pool: when a pool lacks a tier only because free models are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.38.1",
3
+ "version": "0.38.2",
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": {
@@ -0,0 +1,117 @@
1
+ // The agent detail pane: a selected attempt's route, prompt, activity,
2
+ // outcome and artifacts, with the task and outcome previews it reads.
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { glyphs } from '../lib/glyphs.js';
5
+ import { stepStatusLabel, actionRoleLabel, statusIcon } from './dashboard-status.js';
6
+ import { compactUsage, reasoningText } from './dashboard-value-text.js';
7
+
8
+ export function agentDetailLines(model, width, spinnerFrame) {
9
+ const agent = model.selectedAgent;
10
+ if (!agent) {
11
+ const lines = ['No agent selected.', '', 'Planned steps in this phase:'];
12
+ for (const action of model.selectedPhase.actions) {
13
+ const blocked = (model.selectedPhase.blockedActions ?? []).find((entry) => entry.id === action.id);
14
+ lines.push(blocked
15
+ ? `${glyphs().blocked} ${action.id} · ${actionRoleLabel(action)} · never dispatched`
16
+ : `${statusIcon(action.status, spinnerFrame)} ${action.id} · ${actionRoleLabel(action)} · ${stepStatusLabel(action)}`);
17
+ if (blocked) {
18
+ lines.push(` blocked by ${blocked.blockedBy.length ? blocked.blockedBy.join(', ') : 'a failed dependency'}`);
19
+ }
20
+ }
21
+ if (!model.selectedPhase.actions.length) lines.push('· waiting for the orchestrator to add work');
22
+ return wrapLines(lines, width);
23
+ }
24
+ const { action, attempt, active } = agent;
25
+ const liveActions = active?.lastActions ?? attempt?.lastActions ?? [];
26
+ const routing = attempt?.routing;
27
+ const reasoning = reasoningText(attempt) || reasoningText(active);
28
+ const lines = [
29
+ `${statusIcon(agent.status, spinnerFrame)} ${agent.status} · ${agent.model}${reasoning ? ` · ${reasoning}` : ''}`,
30
+ // V1 stores the tier on the attempt; V2 stores it under routing. Reading
31
+ // only the V1 shape made every V2 attempt render `effort auto`, right
32
+ // beside the reasoning level it was resolved against.
33
+ `${agent.pool} · attempt ${attempt?.attemptNumber ?? active?.attempt ?? 1} · effort ${attempt?.effort ?? routing?.effort ?? active?.effort ?? 'auto'}${reasoning ? ` · reasoning ${reasoning}` : ''}`,
34
+ '',
35
+ `Step · ${action.id} · ${actionRoleLabel(action)}`,
36
+ ];
37
+ if (routing?.reason) lines.push(`Route: ${routing.reason}`);
38
+ if (attempt?.startedAt ?? active?.startedAt) lines.push(`Started: ${attempt?.startedAt ?? active.startedAt}`);
39
+ if (attempt?.finishedAt) lines.push(`Finished: ${attempt.finishedAt}`);
40
+ if (active?.lastActivityAt) lines.push(`Last activity: ${active.lastActivityAt} · ${active.outputBytesObserved ?? 0} bytes`);
41
+ if (active?.stall?.status === 'suspected_stalled') {
42
+ lines.push(`${glyphs().warn} Suspected stalled: ${active.stall.silentForSec}s without evidence; never auto-killed`);
43
+ }
44
+ if (attempt?.failureReason) lines.push(`Failure: ${attempt.failureReason}`);
45
+ const taskFile = attempt?.taskFile ?? active?.taskFile;
46
+ const prompt = taskPreview(taskFile);
47
+ lines.push('', `Prompt${prompt.length ? ` · ${prompt.length} lines shown` : ''}`);
48
+ if (prompt.length) lines.push(...prompt.map((line) => ` ${line}`));
49
+ else lines.push(' unavailable');
50
+ const historicalActionIds = new Set(model.events
51
+ .filter((event) => event.type === 'attempt.agent_action'
52
+ && event.payload?.actionId === action.id
53
+ && (attempt?.attemptNumber == null || event.payload?.attemptNumber === attempt.attemptNumber))
54
+ .map((event) => event.payload?.agentAction?.id)
55
+ .filter(Boolean));
56
+ const totalActions = active?.actionCount ?? attempt?.actionCount
57
+ ?? Math.max(liveActions.length, historicalActionIds.size);
58
+ const activityLabel = totalActions > liveActions.length
59
+ ? `Activity · last ${liveActions.length} of ${totalActions}`
60
+ : 'Activity';
61
+ lines.push('', compactUsage(attempt?.usage), '', activityLabel);
62
+ if (!liveActions.length) lines.push('· waiting for semantic action events');
63
+ const firstVisibleActionNumber = Math.max(1, totalActions - liveActions.length + 1);
64
+ for (const [index, step] of liveActions.entries()) {
65
+ lines.push(`#${firstVisibleActionNumber + index} ${statusIcon(step.status, spinnerFrame)} ${step.kind} · ${step.status}`);
66
+ if (step.summary) lines.push(` ${step.summary}`);
67
+ }
68
+ const output = model.state.outputs?.[action.id];
69
+ const outcome = outcomePreview(attempt?.outFile ?? active?.outFile ?? output?.outFile, output);
70
+ if (outcome.length) lines.push('', 'Outcome', ...outcome.map((line) => ` ${line}`));
71
+ lines.push('', 'Artifacts:');
72
+ lines.push(`task: ${attempt?.taskFile ?? active?.taskFile ?? '—'}`);
73
+ lines.push(`output: ${attempt?.outFile ?? active?.outFile ?? '—'}`);
74
+ return wrapLines(lines, width);
75
+ }
76
+
77
+ export function taskPreview(path, limit = 6) {
78
+ if (!path || !existsSync(path)) return [];
79
+ try {
80
+ const all = readFileSync(path, 'utf8').split(/\r?\n/).filter((line) => line.trim());
81
+ const shown = all.slice(0, limit);
82
+ if (all.length > limit) shown.push(`… ${all.length - limit} more lines`);
83
+ return shown;
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ export function outcomePreview(path, output, maxChars = 64 * 1024) {
90
+ let text = '';
91
+ try {
92
+ if (path && existsSync(path)) text = readFileSync(path, 'utf8');
93
+ } catch { /* fall through to the durable state preview */ }
94
+ if (!text && typeof output?.outputText === 'string') text = output.outputText;
95
+ if (!text && output?.verify) text = JSON.stringify(output.verify, null, 2);
96
+ if (!text.trim()) return [];
97
+ const truncated = text.length > maxChars;
98
+ const lines = text.slice(0, maxChars).split(/\r?\n/);
99
+ if (truncated) lines.push('… outcome truncated in TUI; open the artifact for the complete result');
100
+ return lines;
101
+ }
102
+
103
+ export function wrapLines(lines, width) {
104
+ const out = [];
105
+ for (const line of lines) {
106
+ if (!line) { out.push(''); continue; }
107
+ let rest = String(line);
108
+ while (rest.length > width) {
109
+ let split = rest.lastIndexOf(' ', width);
110
+ if (split < Math.floor(width * 0.5)) split = width;
111
+ out.push(rest.slice(0, split));
112
+ rest = rest.slice(split).trimStart();
113
+ }
114
+ out.push(rest);
115
+ }
116
+ return out;
117
+ }
@@ -0,0 +1,83 @@
1
+ // The ANSI text primitives every dashboard page paints with: the escape
2
+ // introducer, visible length, truncation, dim, the palette tint, bold and
3
+ // inverse, and whether colour meters are on (meterAnsi, blank).
4
+ import { asciiGlyphsPreferred } from '../lib/glyphs.js';
5
+ import { METER_COLORS } from './usage-view.js';
6
+
7
+ const ESC = '\x1b[';
8
+
9
+ export function dimText(value, width) {
10
+ return `\x1b[2m${truncate(value, width)}\x1b[0m`;
11
+ }
12
+
13
+ // ------------------------------------------------- the palette on the page
14
+ //
15
+ // The pages name a palette role — or the stable hex returned by dash-kit for a
16
+ // pool/model series — and never invent a colour. Every value comes from
17
+ // METER_COLORS in usage-view.js, the one palette the product has; this file
18
+ // adds none. Colour is off wherever the meters are off, so an ascii terminal
19
+ // and a plain-text capture read the same words with no escapes in them.
20
+
21
+ const SGR_RESET = '\x1b[0m';
22
+ const SGR_BOLD = '\x1b[1m';
23
+ const SGR_NO_BOLD = '\x1b[22m';
24
+
25
+ const rgbOf = (hex) => {
26
+ const value = Number.parseInt(String(hex).slice(1), 16);
27
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
28
+ };
29
+
30
+ /** `text` in the palette's `role`, or untouched where colour is off. */
31
+ export function tint(text, role) {
32
+ const body = String(text ?? '');
33
+ if (!body || !meterAnsi()) return body;
34
+ const hex = typeof role === 'string' && role.startsWith('#') ? role : METER_COLORS[role];
35
+ if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return body;
36
+ return `\x1b[38;2;${rgbOf(hex).join(';')}m${body}${SGR_RESET}`;
37
+ }
38
+
39
+ /** A key figure or a key name, bold — ASCII mode keeps bold, dim and inverse. */
40
+ function strong(text) {
41
+ const body = String(text ?? '');
42
+ if (!body) return body;
43
+ return `${SGR_BOLD}${body}${SGR_NO_BOLD}`;
44
+ }
45
+
46
+ /**
47
+ * The cursor row, inverse as the tab row: `\x1b[7m … \x1b[27m`. A painted row
48
+ * resets its own cells, so reverse video is re-armed after each reset and the
49
+ * whole row stays inverse. SGR only — the row keeps its text and its width —
50
+ * and kept in ASCII mode, which drops colour but keeps bold, dim and inverse.
51
+ */
52
+ function inverseText(text) {
53
+ const body = String(text ?? '');
54
+ if (!body) return body;
55
+ return `\x1b[7m${body.replace(/\x1b\[0m/g, '\x1b[0m\x1b[7m')}\x1b[27m`;
56
+ }
57
+
58
+ export function truncate(value, width) {
59
+ const text = String(value ?? '');
60
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
61
+ }
62
+
63
+ const ANSI_SGR = /\x1b\[[0-9;?]*[A-Za-z]/g;
64
+
65
+ /** How many columns a painted line really occupies. */
66
+ export const visibleLength = (value) => String(value ?? '').replace(ANSI_SGR, '').length;
67
+
68
+ /** Meters are background-coloured cells; an ascii terminal gets the plain bar. */
69
+ export function meterAnsi() {
70
+ return !asciiGlyphsPreferred();
71
+ }
72
+
73
+ /** The blank a figure with no measurable source is painted as. */
74
+ export function blank() {
75
+ return asciiGlyphsPreferred() ? '-' : '—';
76
+ }
77
+
78
+ export {
79
+ ESC,
80
+ strong,
81
+ inverseText,
82
+ ANSI_SGR,
83
+ };
@@ -0,0 +1,37 @@
1
+ // Asking a run to stop: the operator's cancellation request, written for the
2
+ // kernel to honour (requestCancel).
3
+ import { V2_TERMINAL_STATUSES } from './status.js';
4
+ import { existsSync } from 'node:fs';
5
+ import { readJsonForUpdate, writeJsonAtomic } from '../lib/fsjson.js';
6
+ import { join } from 'node:path';
7
+ import { resolveRunId, isLegacyRunState } from './short-id.js';
8
+ import { appendEvent } from './events.js';
9
+
10
+ export function requestCancel(bullswarmDir, token, { source = 'api', requesterPid = process.pid } = {}) {
11
+ const resolved = resolveRunId(bullswarmDir, token);
12
+ if (!resolved) throw new Error(`no run found for "${token}"`);
13
+ const statePath = join(resolved.runDir, 'state.json');
14
+ if (!existsSync(statePath)) throw new Error(`run "${token}" has no state.json`);
15
+ const state = readJsonForUpdate(statePath, 'workflow state');
16
+ // A legacy run has no kernel to ask, so nothing is written and nothing is
17
+ // claimed: the caller is told what it is and left alone.
18
+ if (isLegacyRunState(state)) return { ...resolved, legacy: true, alreadyFinished: true };
19
+ if (V2_TERMINAL_STATUSES.has(state.lifecycle.status)) return { ...resolved, state, alreadyFinished: true };
20
+ const requestedAt = new Date().toISOString();
21
+ state.cancellation = {
22
+ requested: true,
23
+ requestedAt,
24
+ reason: 'operator requested stop',
25
+ source,
26
+ requesterPid,
27
+ };
28
+ appendEvent(resolved.runDir, state, 'workflow.cancellation_requested', {
29
+ requestedAt,
30
+ reason: state.cancellation.reason,
31
+ source,
32
+ requesterPid,
33
+ });
34
+ // The operator owns this intent file; only the kernel owns state.json.
35
+ writeJsonAtomic(join(resolved.runDir, 'cancellation.json'), state.cancellation);
36
+ return { ...resolved, state, alreadyFinished: false };
37
+ }
@@ -0,0 +1,9 @@
1
+ // The one number helper the dashboard's windows and cursors share (clamp).
2
+
3
+ function clamp(value, min, max) {
4
+ return Math.min(max, Math.max(min, Number(value) || 0));
5
+ }
6
+
7
+ export {
8
+ clamp,
9
+ };
@@ -0,0 +1,22 @@
1
+ // The local clipboard a copied screen falls back to when a terminal will not
2
+ // take OSC 52 (writeClipboard).
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ /**
6
+ * The local clipboard, for a terminal that will not take OSC 52: pbcopy on
7
+ * macOS, wl-copy under Wayland. Returns which tool carried it, or why none
8
+ * did — the message on screen names one or the other, never both.
9
+ */
10
+ export function writeClipboard(text, { platform = process.platform, env = process.env, run = spawnSync } = {}) {
11
+ const tools = [];
12
+ if (platform === 'darwin') tools.push('pbcopy');
13
+ if (env.WAYLAND_DISPLAY) tools.push('wl-copy');
14
+ if (env.DISPLAY) tools.push('xclip');
15
+ for (const tool of tools) {
16
+ try {
17
+ const result = run(tool, tool === 'xclip' ? ['-selection', 'clipboard'] : [], { input: text });
18
+ if (!result?.error && (result?.status === 0 || result?.status == null)) return { ok: true, tool };
19
+ } catch { /* try the next one */ }
20
+ }
21
+ return { ok: false, tool: null, reason: tools.length ? `${tools.join(' and ')} failed` : 'no pbcopy, wl-copy or xclip on this machine' };
22
+ }
@@ -0,0 +1,60 @@
1
+ // The static details pane for one run: the legacy line, or a v2 run's stages,
2
+ // planner, requirements and recent events (renderDetails).
3
+ import { isLegacyRunState, legacyRunLine } from './short-id.js';
4
+ import { presentationStageStatus, projectV2DependencyStages } from './v2-presentation.js';
5
+ import { isProgramWorkflow } from './execution-policy.js';
6
+ import { ESC } from './dashboard-ansi.js';
7
+ import { keyHint } from './dashboard-keys.js';
8
+ import { stepStatusLabel, statusIcon } from './dashboard-status.js';
9
+
10
+ export function renderDetails(row, { interactive = true } = {}) {
11
+ const state = row?.state ?? {};
12
+ // A legacy run has no readable graph left, so the pane says exactly what the
13
+ // CLI says and stops there.
14
+ if (row?.legacy || isLegacyRunState(state)) {
15
+ return [
16
+ `${ESC}2J${ESC}H`,
17
+ legacyRunLine({ shortId: row?.shortId, runId: row?.runId, runDir: row?.runDir }),
18
+ ...(interactive ? ['', ` ${keyHint('out')} · r refresh · ${keyHint('detach')}`] : []),
19
+ ].join('\n');
20
+ }
21
+ return renderV2Details(row, { interactive });
22
+ }
23
+
24
+ function renderV2Details(row, { interactive = true } = {}) {
25
+ const state = row.state;
26
+ const lines = [
27
+ `${ESC}2J${ESC}H`,
28
+ ` bullswarm · ${row.shortId ?? state.shortId ?? state.runId}`,
29
+ '',
30
+ ` status: ${state.lifecycle.status}`,
31
+ ...(isProgramWorkflow(state) ? [` mode: program in ${state.config.settings.workspaceMode ?? 'shared'} workspace; requirement evidence reported separately`] : []),
32
+ ` goal: ${state.intent.goal}`,
33
+ ` dir: ${row.runDir ?? '—'}`,
34
+ '',
35
+ ' presentation stages:',
36
+ ];
37
+ for (const stage of isProgramWorkflow(state) ? projectV2DependencyStages(state) : state.presentation.stages) {
38
+ const progress = presentationStageStatus(stage, state.actions);
39
+ const status = stage.completedAt ? (progress.successful ? 'completed' : 'completed with gaps') : stage.startedAt ? 'running' : 'not started';
40
+ lines.push(` ${statusIcon(status)} ${stage.label} · ${progress.completed}/${progress.total} · ${status}`);
41
+ for (const id of stage.actionIds) {
42
+ const action = state.actions.find((entry) => entry.id === id);
43
+ lines.push(` ${statusIcon(action?.status)} ${id} · ${stepStatusLabel(action)}`);
44
+ }
45
+ }
46
+ if (!state.presentation.stages.length) lines.push(' planning has not created the first program yet');
47
+ lines.push('', ' workflow planner:');
48
+ lines.push(` ${statusIcon(state.planner.status)} ${state.planner.status} · ${state.planner.turns} checkpoint${state.planner.turns === 1 ? '' : 's'}`);
49
+ lines.push(` latest: ${state.planner.lastDecision?.summary ?? 'not available'}`);
50
+ lines.push('', ' requirements:');
51
+ for (const requirement of Object.values(state.ledger.requirements)) {
52
+ lines.push(` ${statusIcon(requirement.status)} ${requirement.id} · ${requirement.status}`);
53
+ }
54
+ lines.push('', ' recent events:');
55
+ for (const event of (row.events ?? []).slice(-12)) lines.push(` #${event.sequence} ${event.type}`);
56
+ if (!(row.events ?? []).length) lines.push(' none');
57
+ if (row.kernelStderrTail?.length) lines.push('', ' kernel log: available');
58
+ if (interactive) lines.push('', ` ${keyHint('out')} · c stop · r refresh · ${keyHint('detach')}`);
59
+ return lines.join('\n');
60
+ }
@@ -0,0 +1,134 @@
1
+ // The frame and window builder: lines that remember where each clickable part
2
+ // was painted, a view module's regions shifted into place, and the window a
3
+ // body is drawn through.
4
+ import { columns } from './dash-kit.js';
5
+ import { visibleLength } from './dashboard-ansi.js';
6
+ import { clamp } from './dashboard-clamp.js';
7
+
8
+ /**
9
+ * A `columns()` band painted into the body at `indent`, each column clickable
10
+ * over its own rows. `cells` carry an optional `action`; a cell columns()
11
+ * dropped for want of room simply has no region.
12
+ */
13
+ function pushColumns(body, cells, { width, gap = 2, indent = 1 } = {}) {
14
+ const lines = columns(cells, { width, gap });
15
+ const base = body.lines.length;
16
+ for (const line of lines) body.push(`${' '.repeat(indent)}${line}`);
17
+ for (const [index, column] of (lines.meta?.columns ?? []).entries()) {
18
+ const action = cells[index]?.action;
19
+ if (!action) continue;
20
+ // A cell may name the rows it reacts to — a tile is one click on its
21
+ // number, not three on a number, a label and a caption.
22
+ const only = cells[index]?.actionRows ?? null;
23
+ for (let row = 0; row < column.rows; row += 1) {
24
+ if (only && !only.includes(row)) continue;
25
+ const painted = visibleLength(body.lines[base + row] ?? '');
26
+ const x1 = column.x + indent;
27
+ const x2 = Math.min(painted, x1 + Math.max(1, column.width) - 1);
28
+ if (x2 >= x1) body.regions.push({ x1, x2, y: base + row + 1, action });
29
+ }
30
+ }
31
+ return lines;
32
+ }
33
+
34
+ /** A line builder that remembers where each clickable part was painted. */
35
+ function frameBuilder() {
36
+ const builder = {
37
+ lines: [],
38
+ regions: [],
39
+ push(text = '') { builder.lines.push(text); return builder; },
40
+ /** A row that reacts to a click anywhere on it. */
41
+ row(text = '', action = null) {
42
+ builder.lines.push(text);
43
+ if (action) {
44
+ builder.regions.push({ x1: 1, x2: Math.max(1, visibleLength(text)), y: builder.lines.length, action });
45
+ }
46
+ return builder;
47
+ },
48
+ /** One line from parts: `{ text, action }` makes that text clickable. */
49
+ parts(parts) {
50
+ let column = 1;
51
+ let text = '';
52
+ for (const part of parts) {
53
+ const span = visibleLength(part.text);
54
+ if (part.action) {
55
+ builder.regions.push({ x1: column, x2: Math.max(column, column + span - 1), y: builder.lines.length + 1, action: part.action });
56
+ }
57
+ column += span;
58
+ text += part.text;
59
+ }
60
+ builder.lines.push(text);
61
+ return builder;
62
+ },
63
+ /** A dash-kit `{ text, regions }` row, its regions shifted by `indent`. */
64
+ kit({ text = '', regions = [] } = {}, indent = 0) {
65
+ builder.lines.push(`${' '.repeat(indent)}${text}`);
66
+ for (const region of regions) {
67
+ builder.regions.push({
68
+ x1: region.x + indent,
69
+ x2: region.x + indent + Math.max(1, region.width) - 1,
70
+ y: builder.lines.length,
71
+ action: region.action,
72
+ });
73
+ }
74
+ return builder;
75
+ },
76
+ };
77
+ return builder;
78
+ }
79
+
80
+ /**
81
+ * A view module's lines and its regions, appended to a builder.
82
+ *
83
+ * budget-view, fleet-view, stats-view and history-view each return regions as
84
+ * `{ x, y, width, action }`, where `y` is the 1-based row of the view's own
85
+ * lines the region was painted on. The shell only has to shift that row by
86
+ * where the view landed in the body, so a click always reaches the row it was
87
+ * drawn on instead of the shell searching the text for it. A region without
88
+ * a usable row is dropped rather than made to fire the wrong action.
89
+ */
90
+ function pushView(builder, view) {
91
+ const lines = Array.isArray(view) ? view : (view?.lines ?? []);
92
+ const regions = Array.isArray(view) ? [] : (view?.regions ?? []);
93
+ const base = builder.lines.length;
94
+ for (const line of lines) builder.lines.push(line);
95
+ for (const region of regions) {
96
+ if (!region?.action || !(region.width > 0)) continue;
97
+ const row = Number(region.y);
98
+ if (!Number.isInteger(row) || row < 1 || row > lines.length) continue;
99
+ builder.regions.push({
100
+ x1: region.x, x2: region.x + region.width - 1, y: base + row, action: region.action,
101
+ });
102
+ }
103
+ return builder;
104
+ }
105
+
106
+ /** The window a body is drawn through, and its `first–last/total` label. */
107
+ function windowOf(body, { height, scroll = 0 } = {}) {
108
+ const total = body.lines.length;
109
+ const capacity = Math.max(1, Number(height) || 1);
110
+ const offset = clamp(scroll, 0, Math.max(0, total - capacity));
111
+ const end = Math.min(total, offset + capacity);
112
+ const scrolled = offset > 0 || total > end;
113
+ return { offset, end, total, position: scrolled ? ` · ${offset + 1}–${end}/${total}` : '' };
114
+ }
115
+
116
+ /** Copies a windowed body into the frame, moving its hit regions down with it. */
117
+ function drawWindow(frame, body, window) {
118
+ const base = frame.lines.length;
119
+ for (let index = window.offset; index < window.end; index += 1) frame.lines.push(body.lines[index]);
120
+ for (const region of body.regions) {
121
+ if (region.y > window.offset && region.y <= window.end) {
122
+ frame.regions.push({ ...region, y: base + (region.y - window.offset) });
123
+ }
124
+ }
125
+ return frame;
126
+ }
127
+
128
+ export {
129
+ pushColumns,
130
+ frameBuilder,
131
+ pushView,
132
+ windowOf,
133
+ drawWindow,
134
+ };
@@ -0,0 +1,80 @@
1
+ // The agent integration block: each agent's skill, awareness and mod state,
2
+ // what an install changed, and the commands that operate the product.
3
+ import { glyphs } from '../lib/glyphs.js';
4
+ import { rule } from './dash-kit.js';
5
+ import { dimText, truncate, visibleLength } from './dashboard-ansi.js';
6
+
7
+ /** The one-line commands that operate the product, as Runs lists them. */
8
+ const DASHBOARD_COMMANDS = Object.freeze([
9
+ 'bullswarm run',
10
+ 'bullswarm workflow goal "<goal>"',
11
+ 'bullswarm workflow watch <id> --next',
12
+ 'bullswarm workflow reindex',
13
+ 'bullswarm setup',
14
+ 'bullswarm doctor',
15
+ ]);
16
+
17
+ /** `skill ✓ · awareness ✓`, plus the mod link and hooks flag for Claude. */
18
+ function integrationAgentLine(entry) {
19
+ const ok = glyphs().ok;
20
+ const mark = (installed) => (installed ? ok : '—');
21
+ const parts = [
22
+ `skill ${mark(entry.skill?.status === 'installed')}`,
23
+ `awareness ${mark(entry.awareness === true)}`,
24
+ ];
25
+ if (entry.mod !== undefined) {
26
+ parts.push(`mod ${mark(entry.mod?.status === 'installed')}`, `hooks ${mark(entry.hooksFlag === true)}`);
27
+ }
28
+ return `${String(entry.agent ?? '?').padEnd(8)} ${parts.join(' · ')}`;
29
+ }
30
+
31
+ /** What `installIntegration` changed, agent by agent. */
32
+ function installResultLines(result) {
33
+ const lines = [' install results'];
34
+ for (const change of result?.changes ?? []) {
35
+ const parts = [
36
+ `skill ${change.skill?.changed ? 'installed' : 'already installed'}`,
37
+ `awareness ${change.awareness?.reason ?? 'unchanged'}`,
38
+ ];
39
+ if (change.mod) parts.push(`mod ${change.mod.changed ? 'linked' : 'already linked'}`);
40
+ if (change.hooksFlag) parts.push(`hooks flag ${change.hooksFlag.reason ?? 'unchanged'}`);
41
+ lines.push(` ${change.agent} · ${parts.join(' · ')}`);
42
+ }
43
+ if (lines.length === 1) lines.push(' nothing to change');
44
+ return lines;
45
+ }
46
+
47
+ function integrationLines(model, opts, body) {
48
+ const { width } = opts;
49
+ body.push('');
50
+ body.push(rule('agents', null, width));
51
+ const installed = model.integration?.ok === true;
52
+ const button = installed ? `[installed ${glyphs().ok}]` : '[install]';
53
+ const prefix = ' agent integration ';
54
+ const suffix = installed ? ' · every agent already has it' : ' · i installs the skill and the awareness block for every agent';
55
+ const fits = visibleLength(prefix) + visibleLength(button) + visibleLength(suffix) <= width;
56
+ body.parts([
57
+ { text: prefix },
58
+ { text: button, action: installed ? null : { kind: 'install' } },
59
+ { text: fits ? dimText(suffix, Math.max(0, width - visibleLength(prefix) - visibleLength(button))) : '' },
60
+ ]);
61
+ if (!model.integration) {
62
+ body.push(dimText(' reading the agent integration…', width));
63
+ } else {
64
+ for (const entry of model.integration.agents ?? []) {
65
+ body.push(dimText(` ${integrationAgentLine(entry)}`, width));
66
+ }
67
+ if (model.installResult) {
68
+ for (const line of installResultLines(model.installResult)) body.push(dimText(truncate(line, width), width));
69
+ }
70
+ }
71
+
72
+ body.push('');
73
+ body.push(rule('run it', null, width));
74
+ for (const command of DASHBOARD_COMMANDS) body.push(dimText(` ${command}`, width));
75
+ return ' bullswarm · runs';
76
+ }
77
+
78
+ export {
79
+ integrationLines,
80
+ };
@@ -0,0 +1,49 @@
1
+ // The dashboard's keys: every binding with the words its hint uses
2
+ // (DASHBOARD_KEYS), and the hint and key-press tests built on it.
3
+
4
+ // Keep navigation wording and bindings in one place. Rendering and input use
5
+ // the same vocabulary so a hint never describes a different action.
6
+ const keyRow = (keys, label, bindings) => Object.freeze({ keys, label, bindings: Object.freeze(bindings) });
7
+
8
+ // 0.33.0 rebinds three keys the 0.32 viewer used: `r` was refresh (the 1 s
9
+ // timer already refreshes, so nothing is lost), `b` was move out (Esc and the
10
+ // left arrow still are), and `Tab` was the next workflow (Shift+Tab still
11
+ // cycles workflows, and Tab now walks a page's sub-tabs). The changelog
12
+ // records the change; the Help page names every key below.
13
+ export const DASHBOARD_KEYS = Object.freeze({
14
+ home: keyRow('h', 'Home', ['h']),
15
+ runs: keyRow('r', 'Runs', ['r']),
16
+ budget: keyRow('b', 'Budget', ['b']),
17
+ stats: keyRow('s', 'Stats', ['s']),
18
+ history: keyRow('y', 'History', ['y']),
19
+ fleet: keyRow('f', 'Fleet', ['f']),
20
+ help: keyRow('?', 'Help', ['?']),
21
+ openRun: keyRow('1\u20139', 'open that run', []),
22
+ nextTab: keyRow('Tab', 'next sub-tab', ['\t']),
23
+ cycleWorkflow: keyRow('Shift+Tab', 'cycle workflows', ['\x1b[Z']),
24
+ period: keyRow('p', 'cycle the period', ['p']),
25
+ up: keyRow('\u2191/k', 'move up', ['\x1b[A', 'k']),
26
+ down: keyRow('\u2193/j', 'move down', ['\x1b[B', 'j']),
27
+ in: keyRow('Enter/\u2192/l', 'open', ['\r', '\n', '\x1b[C', 'l']),
28
+ out: keyRow('Esc/\u2190', 'move out', ['\x1b', '\x1b[D']),
29
+ pageUp: keyRow('PgUp', 'scroll up a screen', ['\x1b[5~']),
30
+ pageDown: keyRow('PgDn', 'scroll down a screen', ['\x1b[6~']),
31
+ top: keyRow('Home', 'top', ['\x1b[H', '\x1b[1~', '\x1bOH']),
32
+ end: keyRow('End', 'bottom', ['\x1b[F', '\x1b[4~', '\x1bOF']),
33
+ copy: keyRow('ctrl+s', 'copy the screen', ['\x13']),
34
+ detach: keyRow('q', 'quit', ['q', '\x03']),
35
+ });
36
+
37
+ function keyHint(name) {
38
+ const action = DASHBOARD_KEYS[name];
39
+ return `${action.keys.split('/')[0]} ${action.label}`;
40
+ }
41
+
42
+ function keyPressed(name, key) {
43
+ return DASHBOARD_KEYS[name].bindings?.includes(key) ?? false;
44
+ }
45
+
46
+ export {
47
+ keyHint,
48
+ keyPressed,
49
+ };