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
package/tui/app.mjs CHANGED
@@ -1,12 +1,14 @@
1
- import React, { useState, useEffect, useCallback } from "react";
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import htm from "htm";
3
3
  import { Box, Text, useInput } from "ink";
4
4
 
5
5
  import wsBridge from "./lib/ws-bridge.mjs";
6
6
  import StatusHeader from "./components/status-header.mjs";
7
+ import { readTuiHeaderConfig } from "./lib/header-config.mjs";
7
8
  import TasksScreen from "./screens/tasks.mjs";
8
9
  import AgentsScreen from "./screens/agents.mjs";
9
10
  import StatusScreen from "./screens/status.mjs";
11
+ import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js";
10
12
 
11
13
  const html = htm.bind(React.createElement);
12
14
 
@@ -16,51 +18,112 @@ const SCREENS = {
16
18
  agents: AgentsScreen,
17
19
  };
18
20
 
21
+ function ScreenTabs({ screen }) {
22
+ const navItems = [
23
+ { key: "status", num: "1", label: "Status" },
24
+ { key: "tasks", num: "2", label: "Tasks" },
25
+ { key: "agents", num: "3", label: "Agents" },
26
+ ];
27
+
28
+ return html`
29
+ <${Box} paddingX=${1} borderStyle="single">
30
+ ${navItems.map((item, index) => html`
31
+ <${React.Fragment} key=${item.key}>
32
+ <${Text} inverse=${screen === item.key} color=${screen === item.key ? undefined : "cyan"}>
33
+ [${item.num}] ${item.label}
34
+ <//>
35
+ ${index < navItems.length - 1 ? html`<${Text} dimColor> <//>` : null}
36
+ <//>
37
+ `)}
38
+ <${Text} dimColor> [q] Quit<//>
39
+ <//>
40
+ `;
41
+ }
42
+
43
+ function upsertById(items, nextItem) {
44
+ const index = items.findIndex((item) => item.id === nextItem.id);
45
+ if (index === -1) return [nextItem, ...items];
46
+ const next = [...items];
47
+ next[index] = { ...next[index], ...nextItem };
48
+ return next;
49
+ }
50
+
19
51
  export default function App({ host, port, connectOnly, initialScreen, refreshMs }) {
20
52
  const [screen, setScreen] = useState(initialScreen || "status");
21
53
  const [connected, setConnected] = useState(false);
54
+ const [connectionState, setConnectionState] = useState("offline");
22
55
  const [stats, setStats] = useState(null);
23
56
  const [sessions, setSessions] = useState([]);
24
57
  const [tasks, setTasks] = useState([]);
25
58
  const [error, setError] = useState(null);
59
+ const [refreshCountdownSec, setRefreshCountdownSec] = useState(
60
+ Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)),
61
+ );
62
+ const [screenInputLocked, setScreenInputLocked] = useState(false);
63
+ const [headerConfig, setHeaderConfig] = useState(() => readTuiHeaderConfig());
64
+
65
+ const bridge = useMemo(
66
+ () => (typeof wsBridge === "function" ? wsBridge({ host, port, refreshMs }) : wsBridge),
67
+ [host, port, refreshMs],
68
+ );
69
+ const bridgeRef = useRef(bridge);
26
70
 
27
71
  useEffect(() => {
28
- const bridge = wsBridge({ host, port });
72
+ let active = true;
73
+ bridgeRef.current = bridge;
74
+ setHeaderConfig(readTuiHeaderConfig(bridge?.configDir));
75
+
76
+ const resetRefreshCountdown = () => {
77
+ setRefreshCountdownSec(Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)));
78
+ };
79
+ const refreshTasks = async () => {
80
+ try {
81
+ const nextTasks = await listTasksFromApi();
82
+ if (active) setTasks(nextTasks);
83
+ } catch (err) {
84
+ if (active) setError(String(err?.message || err || "Failed to load tasks"));
85
+ }
86
+ };
87
+
88
+ const unsubscribes = [];
89
+ const on = (eventName, handler) => {
90
+ const unsubscribe = bridge.on(eventName, handler);
91
+ unsubscribes.push(unsubscribe);
92
+ };
29
93
 
30
- bridge.on("connect", () => {
94
+ on("connect", () => {
31
95
  setConnected(true);
96
+ setConnectionState("connected");
32
97
  setError(null);
98
+ resetRefreshCountdown();
99
+ void refreshTasks();
33
100
  });
34
-
35
- bridge.on("disconnect", () => {
101
+ on("disconnect", () => {
36
102
  setConnected(false);
103
+ setConnectionState("reconnecting");
37
104
  });
38
-
39
- bridge.on("error", (err) => {
40
- setError(err.message);
105
+ on("reconnecting", () => {
106
+ setConnected(false);
107
+ setConnectionState("reconnecting");
41
108
  });
42
-
43
- bridge.on("stats", (data) => {
109
+ on("error", (err) => {
110
+ const message = err?.message || String(err || "Connection failed");
111
+ setError(message);
112
+ if (String(message).includes("Max reconnection attempts")) {
113
+ setConnectionState("offline");
114
+ }
115
+ });
116
+ on("stats", (data) => {
44
117
  setStats(data);
118
+ resetRefreshCountdown();
45
119
  });
46
-
47
- bridge.on("session:start", (session) => {
48
- setSessions((prev) => [...prev, session]);
120
+ on("session:start", (session) => {
121
+ setSessions((prev) => upsertById(prev, session));
49
122
  });
50
-
51
- bridge.on("session:update", (session) => {
52
- setSessions((prev) => {
53
- const existingIndex = prev.findIndex((candidate) => candidate.id === session.id);
54
- if (existingIndex >= 0) {
55
- const updated = [...prev];
56
- updated[existingIndex] = session;
57
- return updated;
58
- }
59
- return [session, ...prev];
60
- });
123
+ on("session:update", (session) => {
124
+ setSessions((prev) => upsertById(prev, session));
61
125
  });
62
-
63
- bridge.on("sessions:update", (payload) => {
126
+ on("sessions:update", (payload) => {
64
127
  const nextSessions = Array.isArray(payload?.sessions)
65
128
  ? payload.sessions
66
129
  : Array.isArray(payload)
@@ -68,175 +131,73 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs
68
131
  : [];
69
132
  setSessions(nextSessions);
70
133
  });
71
-
72
- bridge.on("session:end", (session) => {
73
- setSessions((prev) => prev.filter((candidate) => candidate.id !== session.id));
134
+ on("session:end", (session) => {
135
+ setSessions((prev) => prev.filter((item) => item.id !== session.id));
74
136
  });
75
-
76
- bridge.on("task:update", (task) => {
77
- setTasks((prev) => {
78
- const idx = prev.findIndex((candidate) => candidate.id === task.id);
79
- if (idx >= 0) {
80
- const updated = [...prev];
81
- updated[idx] = task;
82
- return updated;
83
- }
84
- return [...prev, task];
85
- });
137
+ on("tasks:update", () => {
138
+ void refreshTasks();
86
139
  });
87
-
88
- bridge.on("task:create", (task) => {
89
- setTasks((prev) => [...prev, task]);
140
+ on("task:update", (task) => {
141
+ setTasks((prev) => upsertById(prev, task));
90
142
  });
91
-
92
- bridge.on("task:delete", (taskId) => {
143
+ on("task:create", (task) => {
144
+ setTasks((prev) => upsertById(prev, task));
145
+ });
146
+ on("task:delete", (taskId) => {
93
147
  setTasks((prev) => prev.filter((task) => task.id !== taskId));
94
148
  });
95
-
96
- const applyRetryQueue = (retryData) => {
97
- setStats((prev) => ({
98
- ...(prev || {}),
99
- retryQueue: retryData,
100
- }));
101
- };
102
-
103
- const retryUnsubscribes = [];
104
-
105
- retryUnsubscribes.push(bridge.on("retry:update", applyRetryQueue));
106
- retryUnsubscribes.push(bridge.on("retry-queue-updated", applyRetryQueue));
149
+ on("retry:update", (retryQueue) => {
150
+ setStats((prev) => ({ ...(prev || {}), retryQueue }));
151
+ });
152
+ on("retry-queue-updated", (retryQueue) => {
153
+ setStats((prev) => ({ ...(prev || {}), retryQueue }));
154
+ });
107
155
 
108
156
  bridge.connect();
157
+ void refreshTasks();
109
158
 
110
159
  return () => {
111
- retryUnsubscribes.forEach((unsubscribe) => {
112
- if (typeof unsubscribe === "function") {
113
- unsubscribe();
114
- }
115
- });
116
-
117
- return () => {
118
- retryUnsubscribes.forEach((unsubscribe) => {
119
- if (typeof unsubscribe === "function") {
120
- unsubscribe();
121
- }
122
- });
123
-
124
- return () => {
125
- retryUnsubscribes.forEach((unsubscribe) => {
126
- if (typeof unsubscribe === "function") {
127
- unsubscribe();
128
- }
129
- });
130
-
131
- return () => {
132
- retryUnsubscribes.forEach((unsubscribe) => {
133
- if (typeof unsubscribe === "function") {
134
- unsubscribe();
135
- }
136
- });
137
-
138
- return () => {
139
- retryUnsubscribes.forEach((unsubscribe) => {
140
- if (typeof unsubscribe === "function") {
141
- unsubscribe();
142
- }
143
- });
144
-
145
- return () => {
146
- retryUnsubscribes.forEach((unsubscribe) => {
147
- if (typeof unsubscribe === "function") {
148
- unsubscribe();
149
- }
150
- });
151
-
152
- return () => {
153
- retryUnsubscribes.forEach((unsubscribe) => {
154
- if (typeof unsubscribe === "function") {
155
- unsubscribe();
156
- }
157
- });
158
-
159
- return () => {
160
- retryUnsubscribes.forEach((unsubscribe) => {
161
- if (typeof unsubscribe === "function") {
162
- unsubscribe();
163
- }
164
- });
165
-
166
- return () => {
167
- retryUnsubscribes.forEach((unsubscribe) => {
168
- if (typeof unsubscribe === "function") {
169
- unsubscribe();
170
- }
171
- });
172
-
173
- return () => {
174
- retryUnsubscribes.forEach((unsubscribe) => {
175
- if (typeof unsubscribe === "function") {
176
- unsubscribe();
177
- }
178
- });
179
-
180
- return () => {
181
- retryUnsubscribes.forEach((unsubscribe) => {
182
- if (typeof unsubscribe === "function") {
183
- unsubscribe();
184
- }
185
- });
186
-
187
- return () => {
188
- retryUnsubscribes.forEach((unsubscribe) => {
189
- if (typeof unsubscribe === "function") {
190
- unsubscribe();
191
- }
192
- });
193
-
194
- return () => {
195
- retryUnsubscribes.forEach((unsubscribe) => {
196
- if (typeof unsubscribe === "function") {
197
- unsubscribe();
198
- }
199
- });
200
-
201
- return () => {
202
- retryUnsubscribes.forEach((unsubscribe) => {
203
- if (typeof unsubscribe === "function") {
204
- unsubscribe();
205
- }
160
+ active = false;
161
+ unsubscribes.forEach((unsubscribe) => {
162
+ if (typeof unsubscribe === "function") unsubscribe();
206
163
  });
207
164
  bridge.disconnect();
165
+ bridgeRef.current = null;
208
166
  };
209
- }, [host, port]);
167
+ }, [bridge, refreshMs]);
168
+
169
+ useEffect(() => {
170
+ const intervalId = setInterval(() => {
171
+ setRefreshCountdownSec((prev) => Math.max(0, prev - 1));
172
+ }, 1000);
173
+ return () => clearInterval(intervalId);
174
+ }, []);
210
175
 
211
176
  const handleKeyPress = useCallback((key) => {
212
- if (key === "q") {
213
- process.exit(0);
214
- }
215
- if (key === "1") {
216
- setScreen("status");
217
- }
218
- if (key === "2") {
219
- setScreen("tasks");
220
- }
221
- if (key === "3") {
222
- setScreen("agents");
223
- }
177
+ if (key === "q") process.exit(0);
178
+ if (key === "1") setScreen("status");
179
+ if (key === "2") setScreen("tasks");
180
+ if (key === "3") setScreen("agents");
224
181
  }, []);
225
182
 
226
183
  useInput((input) => {
184
+ if (screenInputLocked) return;
227
185
  handleKeyPress(input);
228
186
  });
229
187
 
230
188
  const ScreenComponent = SCREENS[screen] || StatusScreen;
231
- const wsBridgeInstance = typeof wsBridge === "function" ? wsBridge({ host, port }) : wsBridge;
232
189
 
233
190
  return html`
234
191
  <${Box} flexDirection="column" minHeight=${0}>
235
192
  <${StatusHeader}
236
193
  stats=${stats}
237
194
  connected=${connected}
238
- screen=${screen}
195
+ connectionState=${connectionState}
196
+ projectLabel=${headerConfig.projectLabel}
197
+ configuredProviders=${headerConfig.configuredProviders}
198
+ refreshCountdownSec=${refreshCountdownSec}
239
199
  />
200
+ <${ScreenTabs} screen=${screen} />
240
201
  <${Box} flexDirection="column" flexGrow=${1}>
241
202
  ${error
242
203
  ? html`
@@ -249,16 +210,15 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs
249
210
  stats=${stats}
250
211
  sessions=${sessions}
251
212
  tasks=${tasks}
252
- wsBridge=${wsBridgeInstance}
213
+ wsBridge=${bridgeRef.current}
253
214
  host=${host}
254
215
  port=${port}
255
216
  connectOnly=${connectOnly}
256
217
  refreshMs=${refreshMs}
218
+ onTasksChange=${setTasks}
219
+ onInputCaptureChange=${setScreenInputLocked}
257
220
  />
258
221
  <//>
259
- <${Box} paddingX=${1} borderStyle="single">
260
- <${Text} dimColor>[1] Status [2] Tasks [3] Agents [q] Quit<//>
261
- <//>
262
222
  <//>
263
223
  `;
264
224
  }
@@ -4,99 +4,202 @@ import { Box, Text } from "ink";
4
4
 
5
5
  const html = htm.bind(React.createElement);
6
6
 
7
- const STATUS_COLORS = {
8
- connected: "green",
9
- disconnected: "red",
10
- active: "green",
7
+ const PROVIDER_ORDER = ["claude", "codex", "gemini", "copilot"];
8
+ const PROVIDER_ALIASES = {
9
+ anthropic: "claude",
10
+ claude: "claude",
11
+ openai: "codex",
12
+ azure: "codex",
13
+ codex: "codex",
14
+ google: "gemini",
15
+ gemini: "gemini",
16
+ copilot: "copilot",
17
+ github: "copilot",
11
18
  };
19
+ const TONE_COLORS = {
20
+ normal: undefined,
21
+ dim: undefined,
22
+ warning: "yellow",
23
+ danger: "red",
24
+ };
25
+ const CONNECTION_STATES = {
26
+ connected: { color: "green", label: "Connected" },
27
+ reconnecting: { color: "yellow", label: "Reconnecting" },
28
+ offline: { color: "red", label: "Offline" },
29
+ };
30
+
31
+ function toNumber(value, fallback = 0) {
32
+ const numeric = Number(value);
33
+ return Number.isFinite(numeric) ? numeric : fallback;
34
+ }
35
+
36
+ function padMetric(value, width = 0) {
37
+ return String(value ?? "").padStart(width, " ");
38
+ }
39
+
40
+ function formatCompactMetric(value) {
41
+ const numeric = Math.max(0, toNumber(value, 0));
42
+ if (numeric >= 1_000_000) return `${(numeric / 1_000_000).toFixed(1)}m`;
43
+ if (numeric >= 1_000) return `${(numeric / 1_000).toFixed(1)}k`;
44
+ if (numeric >= 100) return String(Math.round(numeric));
45
+ if (numeric >= 10) return Number(numeric.toFixed(1)).toString();
46
+ return Number(numeric.toFixed(2)).toString();
47
+ }
12
48
 
13
49
  function formatDuration(ms) {
14
- const safe = Math.max(0, Number(ms || 0));
15
- const seconds = Math.floor(safe / 1000);
16
- const minutes = Math.floor(seconds / 60);
17
- const hours = Math.floor(minutes / 60);
18
- const days = Math.floor(hours / 24);
19
- if (days > 0) return `${days}d ${hours % 24}h`;
20
- if (hours > 0) return `${hours}h ${minutes % 60}m`;
21
- if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
50
+ const safeMs = Math.max(0, toNumber(ms, 0));
51
+ const totalSeconds = Math.floor(safeMs / 1000);
52
+ const days = Math.floor(totalSeconds / 86400);
53
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
54
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
55
+ const seconds = totalSeconds % 60;
56
+ if (days > 0) return `${days}d ${hours}h`;
57
+ if (hours > 0) return `${hours}h ${minutes}m`;
58
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
22
59
  return `${seconds}s`;
23
60
  }
24
61
 
25
- function formatCost(usd) {
26
- const value = Number(usd || 0);
27
- return `$${Number.isFinite(value) ? value.toFixed(2) : "0.00"}`;
62
+ function normalizeProviderKey(provider) {
63
+ const normalized = String(provider || "").trim().toLowerCase();
64
+ return PROVIDER_ALIASES[normalized] || normalized;
28
65
  }
29
66
 
30
- export default function StatusHeader({ stats, connected, screen }) {
31
- const s = {
32
- uptimeMs: 0,
33
- runtimeMs: 0,
34
- totalCostUsd: 0,
35
- totalSessions: 0,
36
- activeSessions: 0,
37
- totalTasks: 0,
38
- activeTasks: 0,
39
- completedTasks: 0,
40
- failedTasks: 0,
41
- retryQueue: { count: 0 },
42
- workflows: { active: 0, total: 0 },
43
- agents: { online: 0, total: 0 },
44
- ...(stats || {}),
45
- };
67
+ function formatRateValue(value, unit = "min") {
68
+ if (value == null) return "n/a";
69
+ return `${toNumber(value, 0)}/${unit}`;
70
+ }
46
71
 
47
- const navItems = [
48
- { key: "status", num: "1", label: "Status" },
49
- { key: "tasks", num: "2", label: "Tasks" },
50
- { key: "agents", num: "3", label: "Agents" },
72
+ function resolveProviderTone(bucket = {}) {
73
+ const values = [bucket.primary, bucket.secondary, bucket.credits]
74
+ .map((value) => (value == null ? null : toNumber(value, NaN)))
75
+ .filter((value) => Number.isFinite(value));
76
+ if (values.some((value) => value <= 0)) return "danger";
77
+
78
+ const limits = [
79
+ [bucket.primary, bucket.primaryLimit],
80
+ [bucket.secondary, bucket.secondaryLimit],
81
+ [bucket.credits, bucket.creditsLimit],
51
82
  ];
83
+ if (limits.some(([remaining, limit]) => Number.isFinite(Number(remaining)) && Number.isFinite(Number(limit)) && Number(limit) > 0 && (Number(remaining) / Number(limit)) <= 0.2)) {
84
+ return "warning";
85
+ }
86
+ if (values.some((value) => value <= 20)) return "warning";
87
+ return "normal";
88
+ }
89
+
90
+ export function normalizeHeaderRateLimits(rateLimits = {}, configuredProviders = {}) {
91
+ const normalized = {};
92
+ for (const [provider, bucket] of Object.entries(rateLimits || {})) {
93
+ const key = normalizeProviderKey(provider);
94
+ if (!PROVIDER_ORDER.includes(key) || !bucket || typeof bucket !== "object") continue;
95
+ normalized[key] = {
96
+ primary: bucket.primary == null ? null : toNumber(bucket.primary, 0),
97
+ secondary: bucket.secondary == null ? null : toNumber(bucket.secondary, 0),
98
+ credits: bucket.credits == null ? null : toNumber(bucket.credits, 0),
99
+ primaryLimit: bucket.primaryLimit == null ? null : toNumber(bucket.primaryLimit, 0),
100
+ secondaryLimit: bucket.secondaryLimit == null ? null : toNumber(bucket.secondaryLimit, 0),
101
+ creditsLimit: bucket.creditsLimit == null ? null : toNumber(bucket.creditsLimit, 0),
102
+ unit: String(bucket.unit || "min").trim() || "min",
103
+ };
104
+ }
105
+
106
+ return PROVIDER_ORDER.reduce((acc, provider) => {
107
+ const configured = configuredProviders[provider] === true || Boolean(normalized[provider]);
108
+ const bucket = normalized[provider] || null;
109
+ if (!configured || !bucket) {
110
+ acc[provider] = {
111
+ provider,
112
+ configured,
113
+ tone: "dim",
114
+ label: `${provider} n/a`,
115
+ };
116
+ return acc;
117
+ }
118
+
119
+ acc[provider] = {
120
+ provider,
121
+ configured,
122
+ tone: resolveProviderTone(bucket),
123
+ label: `${provider} primary ${formatRateValue(bucket.primary, bucket.unit)} | secondary ${formatRateValue(bucket.secondary, bucket.unit)} | credits ${bucket.credits == null ? "n/a" : toNumber(bucket.credits, 0)}`,
124
+ };
125
+ return acc;
126
+ }, {});
127
+ }
128
+
129
+ export function buildStatusHeaderModel({
130
+ stats = {},
131
+ configuredProviders = {},
132
+ connectionState = "offline",
133
+ projectLabel = "No project",
134
+ refreshCountdownSec = 0,
135
+ } = {}) {
136
+ const activeAgents = Math.max(0, toNumber(stats?.activeAgents, 0));
137
+ const maxAgents = Math.max(0, toNumber(stats?.maxAgents, 0));
138
+ const throughputTps = Math.max(0, toNumber(stats?.throughputTps, 0));
139
+ const uptimeMs = Math.max(0, toNumber(stats?.uptimeMs, 0));
140
+ const tokensIn = Math.max(0, toNumber(stats?.tokensIn, 0));
141
+ const tokensOut = Math.max(0, toNumber(stats?.tokensOut, 0));
142
+ const tokensTotal = Math.max(0, toNumber(stats?.tokensTotal ?? stats?.totalTokens, tokensIn + tokensOut));
143
+ const providers = normalizeHeaderRateLimits(stats?.rateLimits || {}, configuredProviders);
144
+ const connection = CONNECTION_STATES[connectionState] || CONNECTION_STATES.offline;
145
+
146
+ return {
147
+ row1: `Agents: ${padMetric(activeAgents, 2)}/${padMetric(maxAgents, 2)} | Throughput: ${throughputTps} tps | Runtime: ${formatDuration(uptimeMs)} | Tokens: in ${formatCompactMetric(tokensIn)} | out ${formatCompactMetric(tokensOut)} | total ${formatCompactMetric(tokensTotal)}`,
148
+ row2: PROVIDER_ORDER.map((provider) => providers[provider]),
149
+ row3: {
150
+ connection,
151
+ projectLabel: String(projectLabel || "").trim() || "No project",
152
+ refreshLabel: `Next refresh: ${Math.max(0, Math.trunc(toNumber(refreshCountdownSec, 0)))}s`,
153
+ },
154
+ };
155
+ }
156
+
157
+ export default function StatusHeader({
158
+ stats,
159
+ connected,
160
+ connectionState,
161
+ projectLabel,
162
+ configuredProviders,
163
+ refreshCountdownSec,
164
+ }) {
165
+ const resolvedConnectionState = connectionState || (connected ? "connected" : "offline");
166
+ const model = buildStatusHeaderModel({
167
+ stats,
168
+ configuredProviders,
169
+ connectionState: resolvedConnectionState,
170
+ projectLabel,
171
+ refreshCountdownSec,
172
+ });
173
+ const connectionDot = resolvedConnectionState === "reconnecting" && refreshCountdownSec % 2 === 0
174
+ ? "◌"
175
+ : "●";
52
176
 
53
177
  return html`
54
- <${Box} flexDirection="column" borderStyle="round" paddingX=${1}>
55
- <${Box} justifyContent="space-between">
56
- <${Box}>
57
- <${Text} bold>Bosun TUI<//>
58
- <${Text} dimColor> | <//>
59
- <${Text}
60
- color=${connected ? STATUS_COLORS.connected : STATUS_COLORS.disconnected}
61
- bold=${!connected}
62
- >
63
- ${connected ? "Connected" : "Disconnected"}
64
- <//>
65
- <//>
66
- <${Box}>
67
- <${Text} dimColor>Uptime: <//>
68
- <${Text}>${formatDuration(s.uptimeMs)}<//>
69
- <${Text} dimColor> | Runtime: <//>
70
- <${Text}>${formatDuration(s.runtimeMs)}<//>
71
- <${Text} dimColor> | Cost: <//>
72
- <${Text}>${formatCost(s.totalCostUsd)}<//>
73
- <//>
74
- <//>
178
+ <${Box} flexDirection="column" paddingX=${1} paddingTop=${1}>
75
179
  <${Box}>
76
- <${Text} dimColor>Sessions: <//>
77
- <${Text} color=${s.activeSessions > 0 ? STATUS_COLORS.active : undefined}>
78
- ${s.activeSessions}
79
- <//>
80
- <${Text} dimColor>/${s.totalSessions} Tasks: <//>
81
- <${Text} color=${s.activeTasks > 0 ? STATUS_COLORS.active : undefined}>
82
- ${s.activeTasks}
83
- <//>
84
- <${Text} dimColor>
85
- /${s.totalTasks} Done:${s.completedTasks} Fail:${s.failedTasks}
86
- | Retry:${s.retryQueue?.count || 0}
87
- | WF:${s.workflows?.active || 0}/${s.workflows?.total || 0}
88
- | Agents:${s.agents?.online || 0}/${s.agents?.total || 0}
89
- <//>
180
+ <${Text}>${model.row1}<//>
90
181
  <//>
91
- <${Box}>
92
- ${navItems.map((item) => html`
93
- <${Box} key=${item.key} marginRight=${2}>
94
- <${Text} inverse=${screen === item.key} color=${screen === item.key ? undefined : "cyan"}>
95
- [${item.num}] ${item.label}
182
+ <${Box} marginTop=${1}>
183
+ ${model.row2.map((provider, index) => html`
184
+ <${React.Fragment} key=${provider.provider}>
185
+ <${Text}
186
+ color=${TONE_COLORS[provider.tone]}
187
+ dimColor=${provider.tone === "dim"}
188
+ >
189
+ ${provider.label}
96
190
  <//>
191
+ ${index < model.row2.length - 1 ? html`<${Text} dimColor> | <//>` : null}
97
192
  <//>
98
193
  `)}
99
194
  <//>
195
+ <${Box} marginTop=${1}>
196
+ <${Text} color=${model.row3.connection.color}>${connectionDot}<//>
197
+ <${Text}> ${model.row3.connection.label}<//>
198
+ <${Text} dimColor> | Project: <//>
199
+ <${Text}>${model.row3.projectLabel}<//>
200
+ <${Text} dimColor> | <//>
201
+ <${Text}>${model.row3.refreshLabel}<//>
202
+ <//>
100
203
  <//>
101
204
  `;
102
205
  }