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.
Files changed (49) hide show
  1. package/.env.example +9 -0
  2. package/agent/agent-event-bus.mjs +10 -0
  3. package/agent/agent-supervisor.mjs +20 -0
  4. package/bosun-tui.mjs +107 -105
  5. package/cli.mjs +10 -0
  6. package/config/config.mjs +25 -0
  7. package/config/executor-config.mjs +124 -1
  8. package/infra/container-runner.mjs +565 -1
  9. package/infra/monitor.mjs +18 -0
  10. package/infra/tracing.mjs +544 -240
  11. package/infra/tui-bridge.mjs +13 -1
  12. package/kanban/kanban-adapter.mjs +128 -4
  13. package/lib/repo-map.mjs +114 -3
  14. package/package.json +11 -4
  15. package/server/ui-server.mjs +3 -0
  16. package/task/task-archiver.mjs +18 -6
  17. package/task/task-attachments.mjs +14 -10
  18. package/task/task-cli.mjs +24 -4
  19. package/task/task-executor.mjs +19 -0
  20. package/task/task-store.mjs +194 -37
  21. package/telegram/telegram-bot.mjs +4 -1
  22. package/tui/app.mjs +131 -171
  23. package/tui/components/status-header.mjs +178 -75
  24. package/tui/lib/header-config.mjs +68 -0
  25. package/tui/lib/ws-bridge.mjs +61 -9
  26. package/tui/screens/agents.mjs +127 -0
  27. package/tui/screens/tasks.mjs +1 -48
  28. package/ui/app.js +8 -5
  29. package/ui/components/kanban-board.js +65 -3
  30. package/ui/components/session-list.js +18 -32
  31. package/ui/demo-defaults.js +52 -2
  32. package/ui/modules/session-api.js +100 -0
  33. package/ui/modules/state.js +71 -15
  34. package/ui/tabs/workflows.js +25 -1
  35. package/ui/tui/App.js +298 -0
  36. package/ui/tui/TasksScreen.js +564 -0
  37. package/ui/tui/constants.js +55 -0
  38. package/ui/tui/tasks-screen-helpers.js +301 -0
  39. package/ui/tui/useTasks.js +61 -0
  40. package/ui/tui/useWebSocket.js +166 -0
  41. package/ui/tui/useWorkflows.js +30 -0
  42. package/workflow/workflow-engine.mjs +412 -7
  43. package/workflow/workflow-nodes.mjs +616 -75
  44. package/workflow-templates/agents.mjs +3 -0
  45. package/workflow-templates/planning.mjs +7 -0
  46. package/workflow-templates/sub-workflows.mjs +5 -0
  47. package/workflow-templates/task-execution.mjs +3 -0
  48. package/workspace/command-diagnostics.mjs +1 -1
  49. package/workspace/context-cache.mjs +182 -9
@@ -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 title = sanitizeTaskText(task.title || "");
128
- const rawDescription = sanitizeTaskText(task.description || "");
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(task.diagnostics);
131
- const meta = task.meta && typeof task.meta === "object"
185
+ const diagnostics = normalizeTaskDiagnosticsForUi(hydratedTask.diagnostics);
186
+ const meta = hydratedTask.meta && typeof hydratedTask.meta === "object"
132
187
  ? {
133
- ...task.meta,
134
- title: task.meta.title != null ? sanitizeTaskText(task.meta.title) : task.meta.title,
188
+ ...hydratedTask.meta,
189
+ title: hydratedTask.meta.title != null ? sanitizeTaskText(hydratedTask.meta.title) : hydratedTask.meta.title,
135
190
  description:
136
- task.meta.description != null
137
- ? (isPlaceholderTaskDescription(task.meta.description)
191
+ hydratedTask.meta.description != null
192
+ ? (isPlaceholderTaskDescription(hydratedTask.meta.description)
138
193
  ? ""
139
- : sanitizeTaskText(task.meta.description))
140
- : task.meta.description,
141
- diagnostics: normalizeTaskDiagnosticsForUi(task.meta.diagnostics),
194
+ : sanitizeTaskText(hydratedTask.meta.description))
195
+ : hydratedTask.meta.description,
196
+ diagnostics: normalizeTaskDiagnosticsForUi(hydratedTask.meta.diagnostics),
142
197
  }
143
- : task.meta;
198
+ : hydratedTask.meta;
144
199
  return {
145
- ...task,
200
+ ...hydratedTask,
146
201
  title,
147
- description: description || synthesizeTaskDescription({ ...task, title }),
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)] = { ...merged[indexById.get(key)], ...task };
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
+
@@ -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
+ }