@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.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. 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, '&amp;')
102
+ .replace(/</g, '&lt;')
103
+ .replace(/>/g, '&gt;')
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
+ }