@polderlabs/bizar-dash 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli/dashboard-tui.mjs
|
|
4
|
+
*
|
|
5
|
+
* v2.7.0 — Blessed-based terminal dashboard for Bizar.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the React web dashboard with 8 tabs:
|
|
8
|
+
* 1 Overview 2 Chat 3 Agents 4 Plans
|
|
9
|
+
* 5 Projects 6 Tasks 7 Config 8 Settings
|
|
10
|
+
*
|
|
11
|
+
* Connects to the same Express + WebSocket server used by the web UI
|
|
12
|
+
* (cli/dashboard/server.mjs). The caller (cli/bin.mjs) is responsible
|
|
13
|
+
* for starting the server before invoking launchTui().
|
|
14
|
+
*
|
|
15
|
+
* Layout:
|
|
16
|
+
*
|
|
17
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
18
|
+
* │ 🪩 Bizar Dashboard — v2.7.0 │ header (3 lines)
|
|
19
|
+
* │ [1]Overview [2]Chat [3]Agents [4]Plans [5]Projects … │
|
|
20
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
21
|
+
* │ │
|
|
22
|
+
* │ (rendered tab content here) │ content
|
|
23
|
+
* │ │
|
|
24
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
25
|
+
* │ ● connected | 4321 | 13 agents | 4 plans | 8 projects │ status bar (1 line)
|
|
26
|
+
* └─────────────────────────────────────────────────────────┘
|
|
27
|
+
*
|
|
28
|
+
* Keyboard:
|
|
29
|
+
* 1-8 jump to tab
|
|
30
|
+
* Tab / S-Tab cycle tabs
|
|
31
|
+
* r reload snapshot
|
|
32
|
+
* / search (basic, current tab)
|
|
33
|
+
* q / C-c quit
|
|
34
|
+
* c open chat composer
|
|
35
|
+
* n new item (plan / task, tab-dependent)
|
|
36
|
+
*
|
|
37
|
+
* v2.7.0 design notes:
|
|
38
|
+
* - blessed tags are enabled (`{bold}`, `{color-fg}`, etc.) on every box.
|
|
39
|
+
* - The same blessed.textbox instance is reused for prompts to keep the
|
|
40
|
+
* code small. Prompts yield a Promise so callers can `await` the answer.
|
|
41
|
+
* - State is always loaded twice — once via /api/snapshot on connect and
|
|
42
|
+
* again every time a `change` arrives over WS. Belt-and-suspenders.
|
|
43
|
+
* - WebSocket auto-reconnects with backoff so a server restart doesn't kill
|
|
44
|
+
* the TUI.
|
|
45
|
+
*/
|
|
46
|
+
import blessed from 'blessed';
|
|
47
|
+
import { WebSocket } from 'ws';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { dirname, join } from 'node:path';
|
|
50
|
+
import { homedir } from 'node:os';
|
|
51
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
52
|
+
|
|
53
|
+
// blessed expects to be the entry point, so we set process.title.
|
|
54
|
+
process.title = 'bizar-tui';
|
|
55
|
+
|
|
56
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
57
|
+
const __dirname = dirname(__filename);
|
|
58
|
+
const HOME = homedir();
|
|
59
|
+
|
|
60
|
+
const VERSION = '2.7.0';
|
|
61
|
+
|
|
62
|
+
// ── Tab definitions ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const TABS = [
|
|
65
|
+
{ id: 'overview', label: 'Overview', key: '1' },
|
|
66
|
+
{ id: 'chat', label: 'Chat', key: '2' },
|
|
67
|
+
{ id: 'agents', label: 'Agents', key: '3' },
|
|
68
|
+
{ id: 'plans', label: 'Plans', key: '4' },
|
|
69
|
+
{ id: 'projects', label: 'Projects', key: '5' },
|
|
70
|
+
{ id: 'tasks', label: 'Tasks', key: '6' },
|
|
71
|
+
{ id: 'config', label: 'Config', key: '7' },
|
|
72
|
+
{ id: 'settings', label: 'Settings', key: '8' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** Truncate string to n cols, accounting for ANSI / blessed tags. */
|
|
78
|
+
function truncate(str, n) {
|
|
79
|
+
if (str == null) return '';
|
|
80
|
+
// Strip blessed tags {color-fg}...{/} and ANSI escape sequences for length
|
|
81
|
+
// measurement only.
|
|
82
|
+
const clean = String(str).replace(/\{[^}]+\}/g, '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
83
|
+
if (clean.length <= n) return str;
|
|
84
|
+
const cut = clean.slice(0, Math.max(0, n - 1)) + '…';
|
|
85
|
+
return cut;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pad a string to n cols (counting display cols, not tag chars). */
|
|
89
|
+
function pad(str, n) {
|
|
90
|
+
const clean = String(str).replace(/\{[^}]+\}/g, '');
|
|
91
|
+
const len = clean.length;
|
|
92
|
+
if (len >= n) return str;
|
|
93
|
+
return str + ' '.repeat(n - len);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pretty-print a JSON value with syntax colors for the TUI. */
|
|
97
|
+
function jsonToTags(value, indent = 2) {
|
|
98
|
+
const json = JSON.stringify(value, null, indent);
|
|
99
|
+
if (json === undefined) return '';
|
|
100
|
+
return json
|
|
101
|
+
.replace(/&/g, '&')
|
|
102
|
+
.replace(/</g, '<')
|
|
103
|
+
.replace(/>/g, '>')
|
|
104
|
+
.replace(/"([^"]+)"(?=\s*:)/g, '{cyan-fg}"$1"{/}')
|
|
105
|
+
.replace(/:\s*"([^"]*)"/g, ': {green-fg}"$1"{/}')
|
|
106
|
+
.replace(/\b(true|false|null)\b/g, '{yellow-fg}$1{/}')
|
|
107
|
+
.replace(/\b(-?\d+(?:\.\d+)?)\b/g, '{magenta-fg}$1{/}');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Format an ISO timestamp as HH:MM:SS (or HH:MM). */
|
|
111
|
+
function shortTime(iso) {
|
|
112
|
+
if (!iso) return '--:--';
|
|
113
|
+
const d = new Date(iso);
|
|
114
|
+
if (Number.isNaN(d.getTime())) return '--:--';
|
|
115
|
+
return d.toTimeString().slice(0, 8);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Format an ISO timestamp as a relative "5 min ago" string. */
|
|
119
|
+
function relativeTime(iso) {
|
|
120
|
+
if (!iso) return 'never';
|
|
121
|
+
const d = new Date(iso);
|
|
122
|
+
if (Number.isNaN(d.getTime())) return 'never';
|
|
123
|
+
const diff = Date.now() - d.getTime();
|
|
124
|
+
if (diff < 60_000) return 'just now';
|
|
125
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
126
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
127
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read a key from the prompt, returns Promise<string>. */
|
|
131
|
+
function prompt(screen, label, { secret = false, defaultValue = '' } = {}) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const box = blessed.prompt({
|
|
134
|
+
parent: screen,
|
|
135
|
+
top: 'center',
|
|
136
|
+
left: 'center',
|
|
137
|
+
width: '60%',
|
|
138
|
+
height: 5,
|
|
139
|
+
border: 'line',
|
|
140
|
+
tags: true,
|
|
141
|
+
keys: true,
|
|
142
|
+
hidden: false,
|
|
143
|
+
label: ` ${label} `,
|
|
144
|
+
style: {
|
|
145
|
+
fg: 'white',
|
|
146
|
+
bg: 'black',
|
|
147
|
+
border: { fg: 'magenta' },
|
|
148
|
+
focus: { border: { fg: 'cyan' } },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
box.readInput('', defaultValue, (err, value) => {
|
|
152
|
+
box.destroy();
|
|
153
|
+
screen.render();
|
|
154
|
+
if (err) resolve(null);
|
|
155
|
+
else resolve(value);
|
|
156
|
+
});
|
|
157
|
+
box.focus();
|
|
158
|
+
screen.render();
|
|
159
|
+
if (secret) box.censor = true;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Show a confirmation modal. */
|
|
164
|
+
function confirm(screen, label) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const box = blessed.question({
|
|
167
|
+
parent: screen,
|
|
168
|
+
top: 'center',
|
|
169
|
+
left: 'center',
|
|
170
|
+
width: '60%',
|
|
171
|
+
height: 5,
|
|
172
|
+
border: 'line',
|
|
173
|
+
tags: true,
|
|
174
|
+
keys: true,
|
|
175
|
+
label: ` ${label} (y/N) `,
|
|
176
|
+
style: {
|
|
177
|
+
fg: 'white',
|
|
178
|
+
bg: 'black',
|
|
179
|
+
border: { fg: 'yellow' },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
box.readInput('', '', (err, value) => {
|
|
183
|
+
box.destroy();
|
|
184
|
+
screen.render();
|
|
185
|
+
const yes = typeof value === 'string' && /^y(es)?$/i.test(value.trim());
|
|
186
|
+
resolve(yes);
|
|
187
|
+
});
|
|
188
|
+
box.focus();
|
|
189
|
+
screen.render();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Show a transient info banner; auto-dismiss after ms. */
|
|
194
|
+
function toast(screen, message, { color = 'cyan', ms = 2500 } = {}) {
|
|
195
|
+
const t = blessed.message({
|
|
196
|
+
parent: screen,
|
|
197
|
+
top: 'center',
|
|
198
|
+
left: 'center',
|
|
199
|
+
width: '60%',
|
|
200
|
+
height: 3,
|
|
201
|
+
border: 'line',
|
|
202
|
+
tags: true,
|
|
203
|
+
label: ' Bizar ',
|
|
204
|
+
style: {
|
|
205
|
+
fg: 'white',
|
|
206
|
+
bg: 'black',
|
|
207
|
+
border: { fg: color },
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
t.display(`{${color}-fg}${message}{/}`, ms);
|
|
211
|
+
screen.render();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── REST client ─────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function makeApiClient(port) {
|
|
217
|
+
const base = `http://127.0.0.1:${port}`;
|
|
218
|
+
async function req(method, path, body) {
|
|
219
|
+
const r = await fetch(`${base}${path}`, {
|
|
220
|
+
method,
|
|
221
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
222
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
223
|
+
});
|
|
224
|
+
if (!r.ok) {
|
|
225
|
+
const text = await r.text().catch(() => '');
|
|
226
|
+
throw new Error(`${method} ${path} → ${r.status} ${text.slice(0, 200)}`);
|
|
227
|
+
}
|
|
228
|
+
if (r.status === 204) return null;
|
|
229
|
+
return r.json();
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
get: (p) => req('GET', p),
|
|
233
|
+
post: (p, b) => req('POST', p, b),
|
|
234
|
+
put: (p, b) => req('PUT', p, b),
|
|
235
|
+
patch: (p, b) => req('PATCH', p, b),
|
|
236
|
+
del: (p) => req('DELETE', p),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Renderers ───────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function renderOverview(state) {
|
|
243
|
+
const { overview = null } = state;
|
|
244
|
+
if (!overview) {
|
|
245
|
+
return '{gray-fg}Loading overview…{/}';
|
|
246
|
+
}
|
|
247
|
+
const c = overview.counts || {};
|
|
248
|
+
const lines = [];
|
|
249
|
+
lines.push('{bold}Counts{/bold}');
|
|
250
|
+
lines.push(` Agents {cyan-fg}${c.agents ?? 0}{/}`);
|
|
251
|
+
lines.push(` Plans {cyan-fg}${c.plans ?? 0}{/}`);
|
|
252
|
+
lines.push(` Projects {cyan-fg}${c.projects ?? 0}{/}`);
|
|
253
|
+
lines.push(` Sessions {cyan-fg}${c.sessions ?? 0}{/}`);
|
|
254
|
+
lines.push('');
|
|
255
|
+
|
|
256
|
+
lines.push('{bold}Versions{/bold}');
|
|
257
|
+
const v = overview.versions || {};
|
|
258
|
+
lines.push(` node {gray-fg}${v.node || '?'}{/}`);
|
|
259
|
+
lines.push(` platform {gray-fg}${v.platform || '?'}{/}`);
|
|
260
|
+
lines.push(` project {gray-fg}${truncate(v.projectRoot || '?', 60)}{/}`);
|
|
261
|
+
lines.push(` bizarRoot {gray-fg}${truncate(v.bizarRoot || '?', 60)}{/}`);
|
|
262
|
+
lines.push('');
|
|
263
|
+
|
|
264
|
+
const activity = overview.recentActivity || [];
|
|
265
|
+
lines.push(`{bold}Recent activity{/bold} {gray-fg}(last ${Math.min(activity.length, 15)}){/}`);
|
|
266
|
+
for (const ev of activity.slice(0, 15)) {
|
|
267
|
+
const t = shortTime(ev.ts);
|
|
268
|
+
const kind = ev.kind || 'event';
|
|
269
|
+
let detail = '';
|
|
270
|
+
if (typeof ev.message === 'string') detail = ev.message;
|
|
271
|
+
else if (ev.agent) detail = `@${ev.agent}`;
|
|
272
|
+
else if (ev.slug) detail = ev.slug;
|
|
273
|
+
else if (ev.name) detail = ev.name;
|
|
274
|
+
else if (ev.path) detail = truncate(ev.path, 40);
|
|
275
|
+
lines.push(` {gray-fg}${t}{/} ${pad(kind, 22)} ${truncate(detail, 60)}`);
|
|
276
|
+
}
|
|
277
|
+
if (activity.length === 0) {
|
|
278
|
+
lines.push(' {gray-fg}(no activity recorded){/}');
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderChat(state) {
|
|
284
|
+
const { chat = [] } = state;
|
|
285
|
+
const lines = [];
|
|
286
|
+
lines.push('{bold}Recent messages{/bold} {gray-fg}(newest first; press c to compose){/}');
|
|
287
|
+
if (!chat.length) {
|
|
288
|
+
lines.push(' {gray-fg}(no messages yet){/}');
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
|
291
|
+
const recent = chat.slice(0, 20);
|
|
292
|
+
for (const m of recent) {
|
|
293
|
+
const t = shortTime(m.ts || m.timestamp);
|
|
294
|
+
const role = m.role || m.from || 'user';
|
|
295
|
+
const text = (m.message || m.content || '').toString().split(/\r?\n/)[0];
|
|
296
|
+
lines.push(` {gray-fg}${t}{/} {bold}${pad(role, 8)}{/} ${truncate(text, 80)}`);
|
|
297
|
+
}
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderAgents(state) {
|
|
302
|
+
const { agents = [] } = state;
|
|
303
|
+
const lines = [];
|
|
304
|
+
lines.push(`{bold}Agents{/bold} {gray-fg}(press Enter for details, r to reload){/}`);
|
|
305
|
+
if (!agents.length) {
|
|
306
|
+
lines.push(' {gray-fg}(no agents installed){/}');
|
|
307
|
+
return lines.join('\n');
|
|
308
|
+
}
|
|
309
|
+
const width = Math.max(20, Math.floor((process.stdout.columns || 100) / 2) - 4);
|
|
310
|
+
for (let i = 0; i < agents.length; i += 2) {
|
|
311
|
+
const left = agents[i];
|
|
312
|
+
const right = agents[i + 1];
|
|
313
|
+
lines.push(card(left, width) + ' ' + (right ? card(right, width) : ''));
|
|
314
|
+
}
|
|
315
|
+
return lines.join('\n');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function card(agent, width) {
|
|
319
|
+
const lines = [];
|
|
320
|
+
lines.push('┌─ ' + agent.name + ' ' + '─'.repeat(Math.max(0, width - agent.name.length - 4)) + '┐');
|
|
321
|
+
const model = agent.model ? `model: ${agent.model}` : 'model: ?';
|
|
322
|
+
const mode = agent.mode ? `mode: ${agent.mode}` : '';
|
|
323
|
+
lines.push('│ ' + pad(model, width - 4) + ' │');
|
|
324
|
+
if (mode) lines.push('│ ' + pad(mode, width - 4) + ' │');
|
|
325
|
+
const desc = truncate(agent.description || '', width - 4);
|
|
326
|
+
if (desc) lines.push('│ ' + pad(desc, width - 4) + ' │');
|
|
327
|
+
lines.push('└' + '─'.repeat(width - 2) + '┘');
|
|
328
|
+
return lines.join('\n');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function renderPlans(state) {
|
|
332
|
+
const { plans = [] } = state;
|
|
333
|
+
const lines = [];
|
|
334
|
+
lines.push(`{bold}Plans{/bold} {gray-fg}(press n to create){/}`);
|
|
335
|
+
if (!plans.length) {
|
|
336
|
+
lines.push(' {gray-fg}(no plans){/}');
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
for (const p of plans) {
|
|
340
|
+
const status = p.status || 'draft';
|
|
341
|
+
const elements = p.elementCount != null ? `${p.elementCount} el` : '';
|
|
342
|
+
const comments = p.commentCount != null ? `${p.commentCount} cm` : '';
|
|
343
|
+
const meta = [elements, comments].filter(Boolean).join(', ');
|
|
344
|
+
const src = p.source === 'global' ? '{gray-fg}(global){/}' : '';
|
|
345
|
+
lines.push(
|
|
346
|
+
` • {bold}${pad(p.title || p.slug, 28)}{/} {cyan-fg}${pad(status, 10)}{/} ${pad(meta, 14)} ${relativeTime(new Date(p.mtime).toISOString())} ${src}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return lines.join('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderProjects(state) {
|
|
353
|
+
const { projects = [] } = state;
|
|
354
|
+
const lines = [];
|
|
355
|
+
lines.push('{bold}Projects{/bold}');
|
|
356
|
+
if (!projects.length) {
|
|
357
|
+
lines.push(' {gray-fg}(no projects discovered){/}');
|
|
358
|
+
return lines.join('\n');
|
|
359
|
+
}
|
|
360
|
+
for (const p of projects) {
|
|
361
|
+
const tag = p.active ? '{green-fg}[active]{/}' : ' ';
|
|
362
|
+
lines.push(` ${tag} {bold}${pad(p.name, 24)}{/} ${truncate(p.path, 70)}`);
|
|
363
|
+
}
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderTasks(state) {
|
|
368
|
+
const { tasks = [] } = state;
|
|
369
|
+
const cols = { queued: [], doing: [], done: [] };
|
|
370
|
+
for (const t of tasks) {
|
|
371
|
+
const k = cols[t.status] ? t.status : 'queued';
|
|
372
|
+
cols[k].push(t);
|
|
373
|
+
}
|
|
374
|
+
const lines = [];
|
|
375
|
+
lines.push('{bold}Tasks{/bold} {gray-fg}(press n to add, e to edit status){/}');
|
|
376
|
+
const colWidth = Math.max(20, Math.floor((process.stdout.columns || 100) / 3) - 2);
|
|
377
|
+
const header = ['QUEUED', 'DOING', 'DONE']
|
|
378
|
+
.map((label) => `{bold}${pad(label, colWidth - 2)}{/}`)
|
|
379
|
+
.join(' ');
|
|
380
|
+
lines.push(header);
|
|
381
|
+
lines.push(
|
|
382
|
+
['─'.repeat(colWidth - 2), '─'.repeat(colWidth - 2), '─'.repeat(colWidth - 2)]
|
|
383
|
+
.map((h) => `{gray-fg}${h}{/}`)
|
|
384
|
+
.join(' '),
|
|
385
|
+
);
|
|
386
|
+
const maxRows = Math.max(cols.queued.length, cols.doing.length, cols.done.length);
|
|
387
|
+
for (let r = 0; r < maxRows; r++) {
|
|
388
|
+
const row = ['queued', 'doing', 'done']
|
|
389
|
+
.map((k) => {
|
|
390
|
+
const t = cols[k][r];
|
|
391
|
+
if (!t) return ' '.repeat(colWidth - 2);
|
|
392
|
+
const mark = t.status === 'done' ? '{green-fg}✔{/}' : '{gray-fg}•{/}';
|
|
393
|
+
const title = truncate(t.title || '(untitled)', colWidth - 6);
|
|
394
|
+
return pad(`${mark} ${title}`, colWidth - 2);
|
|
395
|
+
})
|
|
396
|
+
.join(' ');
|
|
397
|
+
lines.push(row);
|
|
398
|
+
}
|
|
399
|
+
if (maxRows === 0) {
|
|
400
|
+
lines.push('{gray-fg}(no tasks yet — press n to add one){/}');
|
|
401
|
+
}
|
|
402
|
+
return lines.join('\n');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderConfig(state) {
|
|
406
|
+
const { config = null } = state;
|
|
407
|
+
if (!config) {
|
|
408
|
+
return '{gray-fg}Loading config…{/}';
|
|
409
|
+
}
|
|
410
|
+
const lines = [];
|
|
411
|
+
lines.push(`{bold}opencode.json{/bold} {gray-fg}${truncate(config.path || '', 80)}{/}`);
|
|
412
|
+
if (!config.exists) {
|
|
413
|
+
lines.push(' {yellow-fg}(file does not exist yet){/}');
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const data = config.data ?? JSON.parse(config.raw || 'null');
|
|
418
|
+
lines.push(jsonToTags(data, 2));
|
|
419
|
+
} catch (err) {
|
|
420
|
+
lines.push(`{red-fg}failed to parse: ${err.message}{/}`);
|
|
421
|
+
lines.push(truncate(config.raw || '', 2000));
|
|
422
|
+
}
|
|
423
|
+
return lines.join('\n');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderSettings(state) {
|
|
427
|
+
const { settings = null } = state;
|
|
428
|
+
if (!settings) {
|
|
429
|
+
return '{gray-fg}Loading settings…{/}';
|
|
430
|
+
}
|
|
431
|
+
const s = settings.data || {};
|
|
432
|
+
const notif = s.notifications || {};
|
|
433
|
+
const dash = s.dashboard || {};
|
|
434
|
+
const about = s.about || {};
|
|
435
|
+
const lines = [];
|
|
436
|
+
lines.push('{bold}Settings{/bold} {gray-fg}(press Enter to edit){/}');
|
|
437
|
+
lines.push('');
|
|
438
|
+
lines.push(` {cyan-fg}theme{/} ${pad(s.theme || 'dark', 10)} (dark | light | system)`);
|
|
439
|
+
lines.push(` {cyan-fg}defaultAgent{/} ${s.defaultAgent || 'odin'}`);
|
|
440
|
+
lines.push(` {cyan-fg}defaultModel{/} ${s.defaultModel || '(none)'}`);
|
|
441
|
+
lines.push('');
|
|
442
|
+
lines.push('{bold}Dashboard{/bold}');
|
|
443
|
+
lines.push(` {cyan-fg}dashboard.autoLaunchWeb{/} ${dash.autoLaunchWeb === false ? '{yellow-fg}false{/}' : '{green-fg}true{/}'} (toggle via PUT /api/settings)`);
|
|
444
|
+
lines.push('');
|
|
445
|
+
lines.push('{bold}Notifications{/bold}');
|
|
446
|
+
lines.push(` {cyan-fg}onAgentComplete{/} ${notif.onAgentComplete ? 'on' : 'off'}`);
|
|
447
|
+
lines.push(` {cyan-fg}onPlanApproval{/} ${notif.onPlanApproval ? 'on' : 'off'}`);
|
|
448
|
+
lines.push('');
|
|
449
|
+
lines.push('{bold}About{/bold}');
|
|
450
|
+
lines.push(` version ${about.version || '?'}`);
|
|
451
|
+
lines.push(` homepage ${truncate(about.homepage || '?', 60)}`);
|
|
452
|
+
lines.push(` license ${about.license || '?'}`);
|
|
453
|
+
lines.push('');
|
|
454
|
+
lines.push('{gray-fg}Settings file: ' + (settings.path || '?') + '{/}');
|
|
455
|
+
return lines.join('\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const RENDERERS = {
|
|
459
|
+
overview: renderOverview,
|
|
460
|
+
chat: renderChat,
|
|
461
|
+
agents: renderAgents,
|
|
462
|
+
plans: renderPlans,
|
|
463
|
+
projects: renderProjects,
|
|
464
|
+
tasks: renderTasks,
|
|
465
|
+
config: renderConfig,
|
|
466
|
+
settings: renderSettings,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// ── WebSocket helper ────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
class DashboardSocket {
|
|
472
|
+
constructor(port, onMessage) {
|
|
473
|
+
this.port = port;
|
|
474
|
+
this.onMessage = onMessage;
|
|
475
|
+
this.ws = null;
|
|
476
|
+
this.backoffMs = 500;
|
|
477
|
+
this.stopped = false;
|
|
478
|
+
this.connect();
|
|
479
|
+
}
|
|
480
|
+
connect() {
|
|
481
|
+
if (this.stopped) return;
|
|
482
|
+
try {
|
|
483
|
+
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}/ws`);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
this.scheduleReconnect();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.ws.on('open', () => {
|
|
489
|
+
this.backoffMs = 500;
|
|
490
|
+
this.onMessage({ type: '_status', status: 'connected' });
|
|
491
|
+
});
|
|
492
|
+
this.ws.on('message', (data) => {
|
|
493
|
+
try {
|
|
494
|
+
const msg = JSON.parse(data.toString());
|
|
495
|
+
this.onMessage(msg);
|
|
496
|
+
} catch {
|
|
497
|
+
/* ignore */
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
this.ws.on('close', () => {
|
|
501
|
+
this.onMessage({ type: '_status', status: 'disconnected' });
|
|
502
|
+
this.scheduleReconnect();
|
|
503
|
+
});
|
|
504
|
+
this.ws.on('error', () => {
|
|
505
|
+
/* close will follow */
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
scheduleReconnect() {
|
|
509
|
+
if (this.stopped) return;
|
|
510
|
+
const wait = this.backoffMs;
|
|
511
|
+
this.backoffMs = Math.min(this.backoffMs * 2, 15_000);
|
|
512
|
+
setTimeout(() => this.connect(), wait);
|
|
513
|
+
}
|
|
514
|
+
close() {
|
|
515
|
+
this.stopped = true;
|
|
516
|
+
try {
|
|
517
|
+
this.ws?.close();
|
|
518
|
+
} catch {
|
|
519
|
+
/* ignore */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Main launch function ────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Launch the TUI. Caller must already have the dashboard server listening on
|
|
528
|
+
* `port`. Returns once the user quits the TUI.
|
|
529
|
+
*
|
|
530
|
+
* @param {object} opts
|
|
531
|
+
* @param {number} opts.port
|
|
532
|
+
* @param {string} [opts.projectRoot]
|
|
533
|
+
* @param {string} [opts.opencodeConfigDir]
|
|
534
|
+
* @param {string} [opts.bizarRoot]
|
|
535
|
+
*/
|
|
536
|
+
export async function launchTui(opts = {}) {
|
|
537
|
+
const port = opts.port ?? 4321;
|
|
538
|
+
const api = makeApiClient(port);
|
|
539
|
+
|
|
540
|
+
// ── State ─────────────────────────────────────────────────────────────────
|
|
541
|
+
const state = {
|
|
542
|
+
overview: null,
|
|
543
|
+
chat: [],
|
|
544
|
+
agents: [],
|
|
545
|
+
plans: [],
|
|
546
|
+
projects: [],
|
|
547
|
+
config: null,
|
|
548
|
+
settings: null,
|
|
549
|
+
tasks: [],
|
|
550
|
+
activeTab: 'overview',
|
|
551
|
+
connected: false,
|
|
552
|
+
serverPort: port,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// ── Screen + layout ───────────────────────────────────────────────────────
|
|
556
|
+
const screen = blessed.screen({
|
|
557
|
+
smartCSR: true,
|
|
558
|
+
title: 'Bizar Dashboard',
|
|
559
|
+
fullUnicode: true,
|
|
560
|
+
autoPadding: true,
|
|
561
|
+
warnings: false,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const header = blessed.box({
|
|
565
|
+
parent: screen,
|
|
566
|
+
top: 0,
|
|
567
|
+
left: 0,
|
|
568
|
+
right: 0,
|
|
569
|
+
height: 3,
|
|
570
|
+
tags: true,
|
|
571
|
+
border: { type: 'line' },
|
|
572
|
+
style: { border: { fg: 'magenta' } },
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const content = blessed.box({
|
|
576
|
+
parent: screen,
|
|
577
|
+
top: 3,
|
|
578
|
+
left: 0,
|
|
579
|
+
right: 0,
|
|
580
|
+
bottom: 1,
|
|
581
|
+
tags: true,
|
|
582
|
+
border: { type: 'line' },
|
|
583
|
+
scrollable: true,
|
|
584
|
+
alwaysScroll: true,
|
|
585
|
+
keys: true,
|
|
586
|
+
mouse: true,
|
|
587
|
+
scrollbar: { ch: ' ', style: { bg: 'magenta' } },
|
|
588
|
+
style: { border: { fg: 'gray' } },
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const statusBar = blessed.box({
|
|
592
|
+
parent: screen,
|
|
593
|
+
bottom: 0,
|
|
594
|
+
left: 0,
|
|
595
|
+
right: 0,
|
|
596
|
+
height: 1,
|
|
597
|
+
tags: true,
|
|
598
|
+
style: { bg: 'magenta', fg: 'white' },
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
function refreshHeader() {
|
|
602
|
+
const tabs = TABS.map((t) => {
|
|
603
|
+
const active = t.id === state.activeTab;
|
|
604
|
+
const label = `${t.key}${t.label}`;
|
|
605
|
+
if (active) return `{inverse} ${label} {/inverse}`;
|
|
606
|
+
return `{gray-fg}${label}{/gray-fg}`;
|
|
607
|
+
}).join(' ');
|
|
608
|
+
header.setContent(
|
|
609
|
+
`{center}{bold}🪩 Bizar Dashboard — v${VERSION}{/bold}{/center}\n{center}${tabs} {gray-fg}| q:quit r:reload ?:help{/center}`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function refreshStatus() {
|
|
614
|
+
const dot = state.connected ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}';
|
|
615
|
+
const c = state.overview?.counts || {};
|
|
616
|
+
const counts = `agents:${c.agents ?? 0} plans:${c.plans ?? 0} projects:${c.projects ?? 0} sessions:${c.sessions ?? 0}`;
|
|
617
|
+
statusBar.setContent(
|
|
618
|
+
` ${dot} ${state.connected ? 'connected' : 'disconnected'} ${state.serverPort} ${counts} `,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function refreshContent() {
|
|
623
|
+
const fn = RENDERERS[state.activeTab] || renderOverview;
|
|
624
|
+
content.setContent(fn(state));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function render() {
|
|
628
|
+
refreshHeader();
|
|
629
|
+
refreshContent();
|
|
630
|
+
refreshStatus();
|
|
631
|
+
screen.render();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function setTab(id) {
|
|
635
|
+
state.activeTab = id;
|
|
636
|
+
content.setContent((RENDERERS[id] || renderOverview)(state));
|
|
637
|
+
render();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── Data loaders ──────────────────────────────────────────────────────────
|
|
641
|
+
async function loadSnapshot() {
|
|
642
|
+
try {
|
|
643
|
+
const snap = await api.get('/api/snapshot');
|
|
644
|
+
Object.assign(state, snap);
|
|
645
|
+
state.connected = true;
|
|
646
|
+
render();
|
|
647
|
+
} catch (err) {
|
|
648
|
+
state.connected = false;
|
|
649
|
+
statusBar.setContent(` {red-fg}●{/red-fg} load failed: ${err.message} `);
|
|
650
|
+
screen.render();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── WebSocket lifecycle ───────────────────────────────────────────────────
|
|
655
|
+
const sock = new DashboardSocket(port, (msg) => {
|
|
656
|
+
if (msg.type === '_status') {
|
|
657
|
+
state.connected = msg.status === 'connected';
|
|
658
|
+
render();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (msg.type === 'snapshot') {
|
|
662
|
+
Object.assign(state, msg.data || {});
|
|
663
|
+
render();
|
|
664
|
+
} else if (msg.type === 'change' || msg.type === 'tasks:change') {
|
|
665
|
+
loadSnapshot();
|
|
666
|
+
} else if (msg.type === 'settings:change') {
|
|
667
|
+
state.settings = msg.settings;
|
|
668
|
+
render();
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// ── Action handlers ───────────────────────────────────────────────────────
|
|
673
|
+
async function actionComposeChat() {
|
|
674
|
+
setTab('chat');
|
|
675
|
+
const agent = state.settings?.data?.defaultAgent || 'odin';
|
|
676
|
+
const text = await prompt(screen, ' Message (agent: ' + agent + ') ');
|
|
677
|
+
if (!text || !text.trim()) return;
|
|
678
|
+
try {
|
|
679
|
+
await api.post('/api/chat', { message: text, agent });
|
|
680
|
+
toast(screen, 'Message queued.', { color: 'green' });
|
|
681
|
+
// Refresh chat after a moment
|
|
682
|
+
setTimeout(loadSnapshot, 400);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
toast(screen, `Send failed: ${err.message}`, { color: 'red' });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function actionNewPlan() {
|
|
689
|
+
setTab('plans');
|
|
690
|
+
const slug = (await prompt(screen, ' New plan slug (a-z, 0-9, dashes) ')) || '';
|
|
691
|
+
if (!slug.trim()) return;
|
|
692
|
+
const title = (await prompt(screen, ' Plan title ')) || slug;
|
|
693
|
+
try {
|
|
694
|
+
await api.post('/api/plans', { slug: slug.trim(), title: title.trim() });
|
|
695
|
+
toast(screen, `Plan "${slug}" created.`, { color: 'green' });
|
|
696
|
+
loadSnapshot();
|
|
697
|
+
} catch (err) {
|
|
698
|
+
toast(screen, `Create failed: ${err.message}`, { color: 'red' });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function actionNewTask() {
|
|
703
|
+
setTab('tasks');
|
|
704
|
+
const title = (await prompt(screen, ' New task title ')) || '';
|
|
705
|
+
if (!title.trim()) return;
|
|
706
|
+
try {
|
|
707
|
+
await api.post('/api/tasks', { title: title.trim(), status: 'queued' });
|
|
708
|
+
toast(screen, 'Task created.', { color: 'green' });
|
|
709
|
+
loadSnapshot();
|
|
710
|
+
} catch (err) {
|
|
711
|
+
toast(screen, `Create failed: ${err.message}`, { color: 'red' });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function actionToggleAutoLaunch() {
|
|
716
|
+
const cur = state.settings?.data?.dashboard?.autoLaunchWeb;
|
|
717
|
+
const next = cur === false ? true : false;
|
|
718
|
+
const updated = {
|
|
719
|
+
...(state.settings?.data || {}),
|
|
720
|
+
dashboard: {
|
|
721
|
+
...((state.settings?.data?.dashboard) || {}),
|
|
722
|
+
autoLaunchWeb: next,
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
try {
|
|
726
|
+
const r = await api.put('/api/settings', updated);
|
|
727
|
+
state.settings = r;
|
|
728
|
+
toast(
|
|
729
|
+
screen,
|
|
730
|
+
`dashboard.autoLaunchWeb → ${next}`,
|
|
731
|
+
{ color: next ? 'green' : 'yellow' },
|
|
732
|
+
);
|
|
733
|
+
render();
|
|
734
|
+
} catch (err) {
|
|
735
|
+
toast(screen, `Save failed: ${err.message}`, { color: 'red' });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function actionHelp() {
|
|
740
|
+
toast(
|
|
741
|
+
screen,
|
|
742
|
+
'1-8 tab · Tab cycle · r reload · c chat · n new · t toggle web · q quit',
|
|
743
|
+
{ color: 'cyan', ms: 5000 },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ── Keyboard ──────────────────────────────────────────────────────────────
|
|
748
|
+
for (const tab of TABS) {
|
|
749
|
+
screen.key([tab.key], () => setTab(tab.id));
|
|
750
|
+
}
|
|
751
|
+
screen.key(['tab'], () => {
|
|
752
|
+
const idx = TABS.findIndex((t) => t.id === state.activeTab);
|
|
753
|
+
setTab(TABS[(idx + 1) % TABS.length].id);
|
|
754
|
+
});
|
|
755
|
+
screen.key(['S-tab'], () => {
|
|
756
|
+
const idx = TABS.findIndex((t) => t.id === state.activeTab);
|
|
757
|
+
setTab(TABS[(idx - 1 + TABS.length) % TABS.length].id);
|
|
758
|
+
});
|
|
759
|
+
screen.key(['r'], () => {
|
|
760
|
+
loadSnapshot();
|
|
761
|
+
toast(screen, 'Reloaded.', { color: 'cyan', ms: 1000 });
|
|
762
|
+
});
|
|
763
|
+
screen.key(['c'], () => {
|
|
764
|
+
if (state.activeTab === 'chat') actionComposeChat();
|
|
765
|
+
else setTab('chat');
|
|
766
|
+
});
|
|
767
|
+
screen.key(['n'], () => {
|
|
768
|
+
if (state.activeTab === 'plans') actionNewPlan();
|
|
769
|
+
else if (state.activeTab === 'tasks') actionNewTask();
|
|
770
|
+
else toast(screen, 'Nothing to create on this tab.', { color: 'yellow' });
|
|
771
|
+
});
|
|
772
|
+
screen.key(['t'], () => actionToggleAutoLaunch());
|
|
773
|
+
screen.key(['?'], () => actionHelp());
|
|
774
|
+
screen.key(['q', 'C-c'], () => shutdown());
|
|
775
|
+
screen.key(['escape'], () => content.focus());
|
|
776
|
+
|
|
777
|
+
// Scroll inside content
|
|
778
|
+
content.key(['up'], () => content.scroll(-1));
|
|
779
|
+
content.key(['down'], () => content.scroll(1));
|
|
780
|
+
content.key(['pageup'], () => content.scroll(-(screen.height || 24)));
|
|
781
|
+
content.key(['pagedown'], () => content.scroll(screen.height || 24));
|
|
782
|
+
|
|
783
|
+
// ── Shutdown ──────────────────────────────────────────────────────────────
|
|
784
|
+
let exiting = false;
|
|
785
|
+
function shutdown() {
|
|
786
|
+
if (exiting) return;
|
|
787
|
+
exiting = true;
|
|
788
|
+
try {
|
|
789
|
+
sock.close();
|
|
790
|
+
} catch {
|
|
791
|
+
/* ignore */
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
screen.destroy();
|
|
795
|
+
} catch {
|
|
796
|
+
/* ignore */
|
|
797
|
+
}
|
|
798
|
+
process.exit(0);
|
|
799
|
+
}
|
|
800
|
+
process.on('SIGINT', shutdown);
|
|
801
|
+
process.on('SIGTERM', shutdown);
|
|
802
|
+
|
|
803
|
+
// ── Initial paint ─────────────────────────────────────────────────────────
|
|
804
|
+
refreshHeader();
|
|
805
|
+
refreshStatus();
|
|
806
|
+
screen.render();
|
|
807
|
+
|
|
808
|
+
await loadSnapshot();
|
|
809
|
+
screen.render();
|
|
810
|
+
|
|
811
|
+
// Keep the process alive until the user quits.
|
|
812
|
+
await new Promise(() => {});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── Direct entry point ──────────────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
818
|
+
if (isMain) {
|
|
819
|
+
// Allow running the file directly: `node cli/dashboard-tui.mjs [port]`
|
|
820
|
+
const port = parseInt(process.argv[2] || '4321', 10);
|
|
821
|
+
if (!Number.isFinite(port)) {
|
|
822
|
+
console.error('Usage: node cli/dashboard-tui.mjs [port]');
|
|
823
|
+
process.exit(2);
|
|
824
|
+
}
|
|
825
|
+
// Note: when run directly, the caller is responsible for starting the
|
|
826
|
+
// server. We import it lazily so the module is usable as a library too.
|
|
827
|
+
const { createServer } = await import('./server.mjs');
|
|
828
|
+
const { server, close } = await createServer({
|
|
829
|
+
port,
|
|
830
|
+
projectRoot: process.cwd(),
|
|
831
|
+
opencodeConfigDir: join(HOME, '.config', 'opencode'),
|
|
832
|
+
bizarRoot: dirname(__dirname),
|
|
833
|
+
});
|
|
834
|
+
await new Promise((resolve, reject) => {
|
|
835
|
+
server.once('error', reject);
|
|
836
|
+
server.listen(port, '127.0.0.1', () => {
|
|
837
|
+
server.off('error', reject);
|
|
838
|
+
resolve();
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
await launchTui({ port });
|
|
842
|
+
// launchTui() resolves when the user quits; tear down the server.
|
|
843
|
+
close();
|
|
844
|
+
}
|