bosun 0.42.2 → 0.42.4
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/.env.example +9 -0
- package/agent/agent-event-bus.mjs +10 -0
- package/agent/agent-supervisor.mjs +20 -0
- package/bosun-tui.mjs +107 -105
- package/cli.mjs +10 -0
- package/config/config.mjs +25 -0
- package/config/executor-config.mjs +124 -1
- package/infra/container-runner.mjs +565 -1
- package/infra/monitor.mjs +18 -0
- package/infra/tracing.mjs +544 -240
- package/infra/tui-bridge.mjs +13 -1
- package/kanban/kanban-adapter.mjs +128 -4
- package/lib/repo-map.mjs +114 -3
- package/package.json +11 -4
- package/server/ui-server.mjs +3 -0
- package/task/task-archiver.mjs +18 -6
- package/task/task-attachments.mjs +14 -10
- package/task/task-cli.mjs +24 -4
- package/task/task-executor.mjs +19 -0
- package/task/task-store.mjs +194 -37
- package/telegram/telegram-bot.mjs +4 -1
- package/tui/app.mjs +131 -171
- package/tui/components/status-header.mjs +178 -75
- package/tui/lib/header-config.mjs +68 -0
- package/tui/lib/ws-bridge.mjs +61 -9
- package/tui/screens/agents.mjs +127 -0
- package/tui/screens/tasks.mjs +1 -48
- package/ui/app.js +8 -5
- package/ui/components/kanban-board.js +65 -3
- package/ui/components/session-list.js +18 -32
- package/ui/demo-defaults.js +52 -2
- package/ui/modules/session-api.js +100 -0
- package/ui/modules/state.js +71 -15
- package/ui/tabs/workflows.js +25 -1
- package/ui/tui/App.js +298 -0
- package/ui/tui/TasksScreen.js +564 -0
- package/ui/tui/constants.js +55 -0
- package/ui/tui/tasks-screen-helpers.js +301 -0
- package/ui/tui/useTasks.js +61 -0
- package/ui/tui/useWebSocket.js +166 -0
- package/ui/tui/useWorkflows.js +30 -0
- package/workflow/workflow-engine.mjs +412 -7
- package/workflow/workflow-nodes.mjs +616 -75
- package/workflow-templates/agents.mjs +3 -0
- package/workflow-templates/planning.mjs +7 -0
- package/workflow-templates/sub-workflows.mjs +5 -0
- package/workflow-templates/task-execution.mjs +3 -0
- package/workspace/command-diagnostics.mjs +1 -1
- package/workspace/context-cache.mjs +182 -9
package/ui/modules/state.js
CHANGED
|
@@ -64,6 +64,60 @@ function _cacheFresh(url, group) {
|
|
|
64
64
|
const e = _apiCache.get(url);
|
|
65
65
|
return e ? (Date.now() - e.fetchedAt) < (CACHE_TTL[group] || 10000) : false;
|
|
66
66
|
}
|
|
67
|
+
function mergeTaskLinkageRecords(...sources) {
|
|
68
|
+
const merged = [];
|
|
69
|
+
const indexByKey = new Map();
|
|
70
|
+
const keyFor = (record) => {
|
|
71
|
+
if (!record || typeof record !== "object") return "";
|
|
72
|
+
const branchName = String(record.branchName || "").trim().toLowerCase();
|
|
73
|
+
const prUrl = String(record.prUrl || "").trim().toLowerCase();
|
|
74
|
+
const prNumber = Number.parseInt(String(record.prNumber ?? ""), 10);
|
|
75
|
+
return [branchName, Number.isFinite(prNumber) && prNumber > 0 ? prNumber : "", prUrl].join("|");
|
|
76
|
+
};
|
|
77
|
+
for (const source of sources) {
|
|
78
|
+
const records = Array.isArray(source) ? source : [];
|
|
79
|
+
for (const record of records) {
|
|
80
|
+
if (!record || typeof record !== "object") continue;
|
|
81
|
+
const normalized = { ...record };
|
|
82
|
+
const key = keyFor(normalized);
|
|
83
|
+
if (!key) continue;
|
|
84
|
+
if (indexByKey.has(key)) {
|
|
85
|
+
const idx = indexByKey.get(key);
|
|
86
|
+
merged[idx] = { ...merged[idx], ...normalized };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
indexByKey.set(key, merged.length);
|
|
90
|
+
merged.push(normalized);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return merged;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function mergeTaskRecords(existingTask, incomingTask) {
|
|
97
|
+
const merged = { ...(existingTask || {}), ...(incomingTask || {}) };
|
|
98
|
+
const existingMeta = existingTask?.meta && typeof existingTask.meta === "object" ? existingTask.meta : {};
|
|
99
|
+
const incomingMeta = incomingTask?.meta && typeof incomingTask.meta === "object" ? incomingTask.meta : {};
|
|
100
|
+
merged.meta = { ...existingMeta, ...incomingMeta };
|
|
101
|
+
const linkage = mergeTaskLinkageRecords(
|
|
102
|
+
existingTask?.prLinkage,
|
|
103
|
+
existingMeta?.prLinkage,
|
|
104
|
+
incomingTask?.prLinkage,
|
|
105
|
+
incomingMeta?.prLinkage,
|
|
106
|
+
);
|
|
107
|
+
if (linkage.length > 0) {
|
|
108
|
+
merged.prLinkage = linkage;
|
|
109
|
+
merged.meta.prLinkage = linkage;
|
|
110
|
+
const primaryLinkage = linkage[0] || null;
|
|
111
|
+
if (primaryLinkage?.branchName) merged.branchName = primaryLinkage.branchName;
|
|
112
|
+
if (primaryLinkage?.prUrl) merged.prUrl = primaryLinkage.prUrl;
|
|
113
|
+
if (Number.isFinite(primaryLinkage?.prNumber) && primaryLinkage.prNumber > 0) merged.prNumber = primaryLinkage.prNumber;
|
|
114
|
+
merged.meta.prLinkageSource = primaryLinkage?.source || incomingMeta?.prLinkageSource || existingMeta?.prLinkageSource || null;
|
|
115
|
+
merged.meta.prLinkageFreshness = primaryLinkage?.freshness || incomingMeta?.prLinkageFreshness || existingMeta?.prLinkageFreshness || null;
|
|
116
|
+
merged.meta.prLinkageUpdatedAt = primaryLinkage?.updatedAt || incomingMeta?.prLinkageUpdatedAt || existingMeta?.prLinkageUpdatedAt || null;
|
|
117
|
+
}
|
|
118
|
+
return merged;
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
function _cacheClearGroup(group) {
|
|
68
122
|
for (const k of _apiCache.keys()) {
|
|
69
123
|
if (k.includes(group) || group === '*') _apiCache.delete(k);
|
|
@@ -124,27 +178,28 @@ function normalizeTaskDiagnosticsForUi(diagnostics) {
|
|
|
124
178
|
|
|
125
179
|
function normalizeTaskForUi(task) {
|
|
126
180
|
if (!task || typeof task !== "object") return task;
|
|
127
|
-
const
|
|
128
|
-
const
|
|
181
|
+
const hydratedTask = mergeTaskRecords(null, task);
|
|
182
|
+
const title = sanitizeTaskText(hydratedTask.title || "");
|
|
183
|
+
const rawDescription = sanitizeTaskText(hydratedTask.description || "");
|
|
129
184
|
const description = isPlaceholderTaskDescription(rawDescription) ? "" : rawDescription;
|
|
130
|
-
const diagnostics = normalizeTaskDiagnosticsForUi(
|
|
131
|
-
const meta =
|
|
185
|
+
const diagnostics = normalizeTaskDiagnosticsForUi(hydratedTask.diagnostics);
|
|
186
|
+
const meta = hydratedTask.meta && typeof hydratedTask.meta === "object"
|
|
132
187
|
? {
|
|
133
|
-
...
|
|
134
|
-
title:
|
|
188
|
+
...hydratedTask.meta,
|
|
189
|
+
title: hydratedTask.meta.title != null ? sanitizeTaskText(hydratedTask.meta.title) : hydratedTask.meta.title,
|
|
135
190
|
description:
|
|
136
|
-
|
|
137
|
-
? (isPlaceholderTaskDescription(
|
|
191
|
+
hydratedTask.meta.description != null
|
|
192
|
+
? (isPlaceholderTaskDescription(hydratedTask.meta.description)
|
|
138
193
|
? ""
|
|
139
|
-
: sanitizeTaskText(
|
|
140
|
-
:
|
|
141
|
-
diagnostics: normalizeTaskDiagnosticsForUi(
|
|
194
|
+
: sanitizeTaskText(hydratedTask.meta.description))
|
|
195
|
+
: hydratedTask.meta.description,
|
|
196
|
+
diagnostics: normalizeTaskDiagnosticsForUi(hydratedTask.meta.diagnostics),
|
|
142
197
|
}
|
|
143
|
-
:
|
|
198
|
+
: hydratedTask.meta;
|
|
144
199
|
return {
|
|
145
|
-
...
|
|
200
|
+
...hydratedTask,
|
|
146
201
|
title,
|
|
147
|
-
description: description || synthesizeTaskDescription({ ...
|
|
202
|
+
description: description || synthesizeTaskDescription({ ...hydratedTask, title }),
|
|
148
203
|
diagnostics,
|
|
149
204
|
meta,
|
|
150
205
|
};
|
|
@@ -164,7 +219,7 @@ function mergeTaskPages(existingTasks = [], incomingTasks = []) {
|
|
|
164
219
|
if (key) indexById.set(key, merged.length - 1);
|
|
165
220
|
continue;
|
|
166
221
|
}
|
|
167
|
-
merged[indexById.get(key)] =
|
|
222
|
+
merged[indexById.get(key)] = mergeTaskRecords(merged[indexById.get(key)], task);
|
|
168
223
|
}
|
|
169
224
|
return merged;
|
|
170
225
|
}
|
|
@@ -1070,3 +1125,4 @@ export function initWsInvalidationListener() {
|
|
|
1070
1125
|
});
|
|
1071
1126
|
});
|
|
1072
1127
|
}
|
|
1128
|
+
|
package/ui/tabs/workflows.js
CHANGED
|
@@ -336,6 +336,9 @@ function formatRetryDecisionReason(reason) {
|
|
|
336
336
|
function formatIssueAdvisorAction(action) {
|
|
337
337
|
const normalized = String(action || "").trim().toLowerCase();
|
|
338
338
|
if (normalized === "replan_from_failed") return "Replan from failed node";
|
|
339
|
+
if (normalized === "replan_subgraph") return "Replan downstream subgraph";
|
|
340
|
+
if (normalized === "rerun_same_step") return "Rerun same step";
|
|
341
|
+
if (normalized === "spawn_fix_step") return "Spawn targeted fix step";
|
|
339
342
|
if (normalized === "resume_remaining") return "Resume remaining work";
|
|
340
343
|
if (normalized === "inspect_failure") return "Inspect failure first";
|
|
341
344
|
if (normalized === "continue") return "Continue";
|
|
@@ -5400,10 +5403,11 @@ function RunHistoryView() {
|
|
|
5400
5403
|
? selectedRun.detail.issueAdvisor
|
|
5401
5404
|
: null;
|
|
5402
5405
|
const dagCounts = getRunDagCounts(selectedRun);
|
|
5406
|
+
const dagRevisions = Array.isArray(selectedRun?.detail?.dagState?.revisions) ? selectedRun.detail.dagState.revisions : [];
|
|
5403
5407
|
const ledgerEvents = Array.isArray(selectedRun?.ledger?.events) ? selectedRun.ledger.events : [];
|
|
5404
5408
|
const recommendedRetryMode =
|
|
5405
5409
|
selectedRun?.status === "failed"
|
|
5406
|
-
? (selectedRun?.issueAdvisorRecommendation === "replan_from_failed" ? "from_scratch" : "from_failed")
|
|
5410
|
+
? ((selectedRun?.issueAdvisorRecommendation === "replan_from_failed" || selectedRun?.issueAdvisorRecommendation === "replan_subgraph") ? "from_scratch" : "from_failed")
|
|
5407
5411
|
: "";
|
|
5408
5412
|
const recommendedRetryLabel = formatRetryModeLabel(recommendedRetryMode);
|
|
5409
5413
|
|
|
@@ -5522,7 +5526,9 @@ function RunHistoryView() {
|
|
|
5522
5526
|
<div><b>Completed:</b> ${dagCounts.completed}/${dagCounts.nodeCount}</div>
|
|
5523
5527
|
<div><b>Failed:</b> ${dagCounts.failed} · <b>Skipped:</b> ${dagCounts.skipped} · <b>Active:</b> ${dagCounts.active}</div>
|
|
5524
5528
|
<div><b>Recommendation:</b> ${formatIssueAdvisorAction(issueAdvisor?.recommendedAction)}</div>
|
|
5529
|
+
<div><b>Decision Class:</b> ${formatIssueAdvisorAction(issueAdvisor?.retryDecisionClass || issueAdvisor?.recommendedAction)}</div>
|
|
5525
5530
|
<div style="margin-top: 6px; color: #e5e7eb;">${issueAdvisor?.summary || "No issue-advisor summary recorded for this run."}</div>
|
|
5531
|
+
${issueAdvisor?.nextStepGuidance && html`<div style="margin-top: 6px; color: #cbd5e1;">${issueAdvisor.nextStepGuidance}</div>`}
|
|
5526
5532
|
</div>
|
|
5527
5533
|
</div>
|
|
5528
5534
|
|
|
@@ -5552,6 +5558,24 @@ function RunHistoryView() {
|
|
|
5552
5558
|
</div>
|
|
5553
5559
|
</div>
|
|
5554
5560
|
|
|
5561
|
+
<details style="background: var(--color-bg-secondary, #1a1f2e); border: 1px solid var(--color-border, #2a3040); border-radius: 8px; padding: 10px 12px; margin-bottom: 12px;">
|
|
5562
|
+
<summary style="cursor: pointer; font-weight: 600; font-size: 13px;">DAG Revisions (${dagRevisions.length})</summary>
|
|
5563
|
+
<div style="display:flex; flex-direction:column; gap:8px; margin-top:8px;">
|
|
5564
|
+
${dagRevisions.length === 0 && html`<div style="font-size:12px; opacity:0.6;">No DAG revision history recorded.</div>`}
|
|
5565
|
+
${dagRevisions.map((revision) => html`
|
|
5566
|
+
<div style="border:1px solid #334155; border-radius:6px; padding:8px; background:#0f172a; font-size:12px; color:#cbd5e1;">
|
|
5567
|
+
<div><b>Revision ${revision.index}:</b> ${revision.reason || "update"}</div>
|
|
5568
|
+
<div><b>Recorded:</b> ${formatDate(revision.recordedAt)}</div>
|
|
5569
|
+
<div><b>Counts:</b> completed=${Number(revision?.counts?.completed || 0)}, failed=${Number(revision?.counts?.failed || 0)}, pending=${Number(revision?.counts?.pending || 0)}</div>
|
|
5570
|
+
<div><b>Preserved:</b> ${(Array.isArray(revision?.preservedCompletedNodeIds) && revision.preservedCompletedNodeIds.length) ? revision.preservedCompletedNodeIds.join(", ") : "—"}</div>
|
|
5571
|
+
<div><b>Focus:</b> ${(Array.isArray(revision?.focusNodeIds) && revision.focusNodeIds.length) ? revision.focusNodeIds.join(", ") : "—"}</div>
|
|
5572
|
+
<div><b>Graph Before:</b> nodes=${Array.isArray(revision?.graphBefore?.nodes) ? revision.graphBefore.nodes.length : 0}, edges=${Array.isArray(revision?.graphBefore?.edges) ? revision.graphBefore.edges.length : 0}</div>
|
|
5573
|
+
<div><b>Graph After:</b> nodes=${Array.isArray(revision?.graphAfter?.nodes) ? revision.graphAfter.nodes.length : 0}, edges=${Array.isArray(revision?.graphAfter?.edges) ? revision.graphAfter.edges.length : 0}</div>
|
|
5574
|
+
</div>
|
|
5575
|
+
`)}
|
|
5576
|
+
</div>
|
|
5577
|
+
</details>
|
|
5578
|
+
|
|
5555
5579
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
|
5556
5580
|
<h3 style="margin: 0; font-size: 14px; color: var(--color-text-secondary, #8b95a5);">Node Execution</h3>
|
|
5557
5581
|
${nodeIds.length === 0 && html`<div style="font-size: 12px; opacity: 0.6;">No node execution data recorded.</div>`}
|
package/ui/tui/App.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import htm from "htm";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ANSI_COLORS,
|
|
7
|
+
COLUMN_WIDTHS,
|
|
8
|
+
GLYPHS,
|
|
9
|
+
KEY_BINDINGS,
|
|
10
|
+
MIN_TERMINAL_SIZE,
|
|
11
|
+
TAB_ORDER,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
import { useWebSocket } from "./useWebSocket.js";
|
|
14
|
+
import { useTasks } from "./useTasks.js";
|
|
15
|
+
import { useWorkflows } from "./useWorkflows.js";
|
|
16
|
+
|
|
17
|
+
const html = htm.bind(React.createElement);
|
|
18
|
+
|
|
19
|
+
function clip(value, width) {
|
|
20
|
+
const text = String(value ?? "");
|
|
21
|
+
return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatWhen(value) {
|
|
25
|
+
if (!value) return "-";
|
|
26
|
+
try {
|
|
27
|
+
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
28
|
+
} catch {
|
|
29
|
+
return "-";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mergeTasks(baseTasks, liveTasks) {
|
|
34
|
+
const map = new Map();
|
|
35
|
+
for (const task of Array.isArray(baseTasks) ? baseTasks : []) {
|
|
36
|
+
if (task?.id) map.set(task.id, { ...task });
|
|
37
|
+
}
|
|
38
|
+
for (const task of Array.isArray(liveTasks) ? liveTasks : []) {
|
|
39
|
+
if (task?.id) map.set(task.id, { ...(map.get(task.id) || {}), ...task });
|
|
40
|
+
}
|
|
41
|
+
return Array.from(map.values());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderRow(columns, key, color = undefined, bold = false) {
|
|
45
|
+
return html`
|
|
46
|
+
<${Box} key=${key}>
|
|
47
|
+
${columns.map((column, index) => html`
|
|
48
|
+
<${Text}
|
|
49
|
+
key=${String(index)}
|
|
50
|
+
color=${color}
|
|
51
|
+
bold=${bold}
|
|
52
|
+
>
|
|
53
|
+
${String(column)}
|
|
54
|
+
<//>
|
|
55
|
+
`)}
|
|
56
|
+
<//>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderTable(data) {
|
|
61
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
62
|
+
return html`<${Text} color=${ANSI_COLORS.muted}>No data yet.<//>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const headers = Object.keys(data[0] || {});
|
|
66
|
+
const widths = headers.map((header) => {
|
|
67
|
+
const contentWidth = data.reduce(
|
|
68
|
+
(max, row) => Math.max(max, String(row?.[header] ?? "").length),
|
|
69
|
+
String(header).length,
|
|
70
|
+
);
|
|
71
|
+
return Math.min(Math.max(contentWidth, String(header).length), COLUMN_WIDTHS[header] || contentWidth);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const formatCell = (value, width) => clip(value, width).padEnd(width + 2, " ");
|
|
75
|
+
const separator = widths.map((width) => `${"-".repeat(width)} `);
|
|
76
|
+
|
|
77
|
+
return html`
|
|
78
|
+
<${Box} flexDirection="column">
|
|
79
|
+
${renderRow(headers.map((header, index) => formatCell(header.toUpperCase(), widths[index])), "header", ANSI_COLORS.accent, true)}
|
|
80
|
+
${renderRow(separator, "separator", ANSI_COLORS.muted)}
|
|
81
|
+
${data.map((row, rowIndex) => renderRow(
|
|
82
|
+
headers.map((header, index) => formatCell(row?.[header] ?? "", widths[index])),
|
|
83
|
+
`row-${rowIndex}`,
|
|
84
|
+
))}
|
|
85
|
+
<//>
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function StatusHeader({ activeTab, connectionStatus, reconnectPulse, host, port, stats, terminalSize }) {
|
|
90
|
+
const isConnected = connectionStatus === "connected";
|
|
91
|
+
const isReconnecting = connectionStatus === "reconnecting";
|
|
92
|
+
const indicator = isConnected
|
|
93
|
+
? GLYPHS.connected
|
|
94
|
+
: isReconnecting
|
|
95
|
+
? (reconnectPulse ? GLYPHS.reconnectingOn : GLYPHS.reconnectingOff)
|
|
96
|
+
: GLYPHS.disconnected;
|
|
97
|
+
const indicatorColor = isConnected
|
|
98
|
+
? ANSI_COLORS.connected
|
|
99
|
+
: isReconnecting
|
|
100
|
+
? ANSI_COLORS.reconnecting
|
|
101
|
+
: ANSI_COLORS.disconnected;
|
|
102
|
+
|
|
103
|
+
return html`
|
|
104
|
+
<${Box} flexDirection="column" marginBottom=${1}>
|
|
105
|
+
<${Box} justifyContent="space-between">
|
|
106
|
+
<${Text} bold>Bosun TUI<//>
|
|
107
|
+
<${Text} color=${indicatorColor}>${indicator} ${connectionStatus.toUpperCase()}<//>
|
|
108
|
+
<//>
|
|
109
|
+
<${Box} justifyContent="space-between">
|
|
110
|
+
<${Text} color=${ANSI_COLORS.muted}>WS ${host}:${port} · ${terminalSize.columns}x${terminalSize.rows}<//>
|
|
111
|
+
<${Text} color=${ANSI_COLORS.muted}>
|
|
112
|
+
Agents ${stats?.activeAgents ?? 0}/${stats?.maxAgents ?? 0} · Tokens ${stats?.tokensTotal ?? 0}
|
|
113
|
+
<//>
|
|
114
|
+
<//>
|
|
115
|
+
<${Box} marginTop=${1}>
|
|
116
|
+
${TAB_ORDER.map((tab) => html`
|
|
117
|
+
<${Box} key=${tab.id} marginRight=${2}>
|
|
118
|
+
<${Text} inverse=${tab.id === activeTab} color=${tab.id === activeTab ? undefined : ANSI_COLORS.accent}>
|
|
119
|
+
[${tab.shortcut.toUpperCase()}]${tab.label.toLowerCase().startsWith(tab.shortcut) ? tab.label.slice(1) : tab.label}
|
|
120
|
+
<//>
|
|
121
|
+
<//>
|
|
122
|
+
`)}
|
|
123
|
+
<//>
|
|
124
|
+
<//>
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ScreenFrame({ title, subtitle, children }) {
|
|
129
|
+
return html`
|
|
130
|
+
<${Box} flexDirection="column">
|
|
131
|
+
<${Text} bold>${title}<//>
|
|
132
|
+
<${Text} color=${ANSI_COLORS.muted}>${subtitle}<//>
|
|
133
|
+
<${Box} marginTop=${1}>${children}<//>
|
|
134
|
+
<//>
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default function App({ config, configDir, host, port, protocol = "ws", initialScreen = "agents", terminalSize }) {
|
|
139
|
+
const { exit } = useApp();
|
|
140
|
+
const [activeTab, setActiveTab] = useState(initialScreen);
|
|
141
|
+
const wsState = useWebSocket({ host, port, configDir, protocol });
|
|
142
|
+
const taskState = useTasks();
|
|
143
|
+
const workflowState = useWorkflows(config);
|
|
144
|
+
|
|
145
|
+
const combinedTasks = useMemo(
|
|
146
|
+
() => mergeTasks(taskState.tasks, wsState.tasks),
|
|
147
|
+
[taskState.tasks, wsState.tasks],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
useInput((input, key) => {
|
|
151
|
+
if (input === KEY_BINDINGS.q) {
|
|
152
|
+
exit();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (key.tab) {
|
|
157
|
+
const index = TAB_ORDER.findIndex((tab) => tab.id === activeTab);
|
|
158
|
+
const delta = key.shift ? -1 : 1;
|
|
159
|
+
const nextIndex = (index + delta + TAB_ORDER.length) % TAB_ORDER.length;
|
|
160
|
+
setActiveTab(TAB_ORDER[nextIndex].id);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const nextTab = KEY_BINDINGS[String(input || "").toLowerCase()];
|
|
165
|
+
if (nextTab && TAB_ORDER.some((tab) => tab.id === nextTab)) {
|
|
166
|
+
setActiveTab(nextTab);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const tooSmall = terminalSize.columns < MIN_TERMINAL_SIZE.columns || terminalSize.rows < MIN_TERMINAL_SIZE.rows;
|
|
171
|
+
|
|
172
|
+
const agentsRows = useMemo(() => wsState.sessions.slice(0, 10).map((session) => ({
|
|
173
|
+
id: clip(session.id, COLUMN_WIDTHS.id),
|
|
174
|
+
status: clip(session.status, COLUMN_WIDTHS.status),
|
|
175
|
+
title: clip(session.title || session.taskId || "-", COLUMN_WIDTHS.title),
|
|
176
|
+
turns: String(session.turnCount ?? 0),
|
|
177
|
+
updated: formatWhen(session.lastActiveAt),
|
|
178
|
+
})), [wsState.sessions]);
|
|
179
|
+
|
|
180
|
+
const taskRows = useMemo(() => combinedTasks.slice(0, 12).map((task) => ({
|
|
181
|
+
id: clip(task.id, COLUMN_WIDTHS.id),
|
|
182
|
+
status: clip(task.status || "todo", COLUMN_WIDTHS.status),
|
|
183
|
+
priority: clip(task.priority || "medium", COLUMN_WIDTHS.priority),
|
|
184
|
+
title: clip(task.title || "Untitled task", COLUMN_WIDTHS.title),
|
|
185
|
+
})), [combinedTasks]);
|
|
186
|
+
|
|
187
|
+
const workflowRows = useMemo(() => (workflowState.workflows || []).slice(0, 12).map((workflow) => ({
|
|
188
|
+
workflow: clip(workflow.name || workflow.id || "workflow", COLUMN_WIDTHS.workflow),
|
|
189
|
+
source: clip(workflow.source || workflow.file || "configured", 24),
|
|
190
|
+
enabled: workflow.enabled === false ? "no" : "yes",
|
|
191
|
+
})), [workflowState.workflows]);
|
|
192
|
+
|
|
193
|
+
let body = null;
|
|
194
|
+
if (tooSmall) {
|
|
195
|
+
body = html`
|
|
196
|
+
<${ScreenFrame}
|
|
197
|
+
title="Terminal too small"
|
|
198
|
+
subtitle="The Bosun TUI works best in a 120x30 or larger terminal."
|
|
199
|
+
>
|
|
200
|
+
<${Text} color=${ANSI_COLORS.warning}>
|
|
201
|
+
${GLYPHS.warning} Current size is ${terminalSize.columns}x${terminalSize.rows}. Resize the terminal to continue.
|
|
202
|
+
<//>
|
|
203
|
+
<//>
|
|
204
|
+
`;
|
|
205
|
+
} else if (activeTab === "agents") {
|
|
206
|
+
body = html`
|
|
207
|
+
<${ScreenFrame}
|
|
208
|
+
title="Agents"
|
|
209
|
+
subtitle="Live sessions from the Bosun WebSocket bus."
|
|
210
|
+
>
|
|
211
|
+
${renderTable(agentsRows)}
|
|
212
|
+
<//>
|
|
213
|
+
`;
|
|
214
|
+
} else if (activeTab === "tasks") {
|
|
215
|
+
body = html`
|
|
216
|
+
<${ScreenFrame}
|
|
217
|
+
title="Tasks"
|
|
218
|
+
subtitle=${taskState.loading ? "Loading task store…" : `Showing ${combinedTasks.length} task(s).`}
|
|
219
|
+
>
|
|
220
|
+
${taskState.error ? html`<${Text} color=${ANSI_COLORS.danger}>${taskState.error}<//>` : renderTable(taskRows)}
|
|
221
|
+
<//>
|
|
222
|
+
`;
|
|
223
|
+
} else if (activeTab === "logs") {
|
|
224
|
+
body = html`
|
|
225
|
+
<${ScreenFrame}
|
|
226
|
+
title="Logs"
|
|
227
|
+
subtitle="Latest streamed lines from the Bosun bus."
|
|
228
|
+
>
|
|
229
|
+
${wsState.logs.length
|
|
230
|
+
? html`${wsState.logs.slice(0, 12).map((entry, index) => html`
|
|
231
|
+
<${Text} key=${String(index)}>
|
|
232
|
+
${clip(entry?.timestamp || "--:--", 8)} ${clip((entry?.level || "info").toUpperCase(), 5)} ${clip(entry?.line || entry?.raw || "", 100)}
|
|
233
|
+
<//>
|
|
234
|
+
`)}`
|
|
235
|
+
: html`<${Text} color=${ANSI_COLORS.muted}>No log lines streamed yet.<//>`}
|
|
236
|
+
<//>
|
|
237
|
+
`;
|
|
238
|
+
} else if (activeTab === "workflows") {
|
|
239
|
+
body = html`
|
|
240
|
+
<${ScreenFrame}
|
|
241
|
+
title="Workflows"
|
|
242
|
+
subtitle=${workflowState.loading ? "Loading configured workflows…" : `Loaded ${workflowState.workflows.length} workflow(s).`}
|
|
243
|
+
>
|
|
244
|
+
${workflowState.error ? html`<${Text} color=${ANSI_COLORS.danger}>${workflowState.error}<//>` : renderTable(workflowRows)}
|
|
245
|
+
<//>
|
|
246
|
+
`;
|
|
247
|
+
} else if (activeTab === "telemetry") {
|
|
248
|
+
body = html`
|
|
249
|
+
<${ScreenFrame}
|
|
250
|
+
title="Telemetry"
|
|
251
|
+
subtitle="Live monitor counters and reconnect health."
|
|
252
|
+
>
|
|
253
|
+
<${Text}>Connection: ${wsState.connectionStatus}<//>
|
|
254
|
+
<${Text}>Reconnects: ${wsState.reconnectCount}<//>
|
|
255
|
+
<${Text}>Tokens In/Out: ${wsState.stats?.tokensIn ?? 0}/${wsState.stats?.tokensOut ?? 0}<//>
|
|
256
|
+
<${Text}>Throughput TPS: ${wsState.stats?.throughputTps ?? 0}<//>
|
|
257
|
+
<//>
|
|
258
|
+
`;
|
|
259
|
+
} else if (activeTab === "settings") {
|
|
260
|
+
body = html`
|
|
261
|
+
<${ScreenFrame}
|
|
262
|
+
title="Settings"
|
|
263
|
+
subtitle="Resolved local Bosun runtime settings."
|
|
264
|
+
>
|
|
265
|
+
<${Text}>Config Dir: ${configDir}<//>
|
|
266
|
+
<${Text}>WS Host: ${host}<//>
|
|
267
|
+
<${Text}>WS Port: ${port}<//>
|
|
268
|
+
<${Text}>Workspace: ${config?.activeWorkspace || "-"}<//>
|
|
269
|
+
<//>
|
|
270
|
+
`;
|
|
271
|
+
} else {
|
|
272
|
+
body = html`
|
|
273
|
+
<${ScreenFrame}
|
|
274
|
+
title="Help"
|
|
275
|
+
subtitle="Keyboard shortcuts for the Bosun TUI."
|
|
276
|
+
>
|
|
277
|
+
<${Text}>A/T/L/W/X/S/? switch screens.<//>
|
|
278
|
+
<${Text}>Tab and Shift+Tab cycle screens.<//>
|
|
279
|
+
<${Text}>Q quits the TUI.<//>
|
|
280
|
+
<//>
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return html`
|
|
285
|
+
<${Box} flexDirection="column">
|
|
286
|
+
<${StatusHeader}
|
|
287
|
+
activeTab=${activeTab}
|
|
288
|
+
connectionStatus=${wsState.connectionStatus}
|
|
289
|
+
reconnectPulse=${wsState.reconnectPulse}
|
|
290
|
+
host=${host}
|
|
291
|
+
port=${port}
|
|
292
|
+
stats=${wsState.stats}
|
|
293
|
+
terminalSize=${terminalSize}
|
|
294
|
+
/>
|
|
295
|
+
${body}
|
|
296
|
+
<//>
|
|
297
|
+
`;
|
|
298
|
+
}
|