@p31/andromeda-cli 1.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/index.js ADDED
@@ -0,0 +1,993 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ // P31 OASIS CLI v1.0.0 — Production TUI
5
+ // Stack: blessed + blessed-contrib + node-pty
6
+ // ═════════════════════════════════════════════════════════════════════════════
7
+
8
+ // ─── Agent Mode (non-TTY safe) ──────────────────────────────────────────────
9
+ // Check for --agent / -a BEFORE the TTY gate so agents can invoke from any ctx.
10
+ {
11
+ const _args = process.argv.slice(2);
12
+ if (_args.includes('--agent') || _args.includes('-a')) {
13
+ const _fs = require('fs');
14
+ const _path = require('path');
15
+ const _yaml = require('yaml');
16
+
17
+ function _loadSession() {
18
+ const _dir = _path.join(require('os').homedir(), '.p31');
19
+ const _file = _path.join(_dir, 'cli-session.json');
20
+ try {
21
+ if (_fs.existsSync(_file)) {
22
+ const _raw = _fs.readFileSync(_file, 'utf-8');
23
+ const _s = JSON.parse(_raw);
24
+ return {
25
+ theme: _s.theme || 'warm',
26
+ todos: _s.todos || [],
27
+ mode: _s.mode || 'BUILD',
28
+ sandboxCwd: _s.sandboxCwd || process.cwd(),
29
+ };
30
+ }
31
+ } catch (_) { /* start fresh */ }
32
+ return { theme: 'warm', todos: [], mode: 'BUILD', sandboxCwd: process.cwd() };
33
+ }
34
+
35
+ function _loadDesign() {
36
+ const _designPath = _path.join(__dirname, '..', 'DESIGN.md');
37
+ try {
38
+ const _raw = _fs.readFileSync(_designPath, 'utf8');
39
+ const _match = _raw.match(/^---\n([\s\S]*?)\n---/);
40
+ if (_match) return _yaml.parse(_match[1]) || {};
41
+ } catch (_) { /* ignore */ }
42
+ return {};
43
+ }
44
+
45
+ const _session = _loadSession();
46
+ const _design = _loadDesign();
47
+
48
+ const output = {
49
+ version: require('./package.json').version,
50
+ mode: _session.mode,
51
+ theme: _session.theme,
52
+ todos: _session.todos.map(t => ({ text: t.text || t, done: t.done || false })),
53
+ sandboxCwd: _session.sandboxCwd,
54
+ design: {
55
+ colors: _design.colors || {},
56
+ typography: _design.typography || {},
57
+ rounded: _design.rounded || {},
58
+ spacing: _design.spacing || {},
59
+ components: _design.components || {},
60
+ },
61
+ capabilities: {
62
+ slashCommands: ['/exit', '/clear', '/sandbox clear', '/export log', '/save', '/notify test', '/help', '/theme <name>', '/mode <name>'],
63
+ shortcuts: { 'ctrl+p': 'palette', 'ctrl+t': 'cycle theme', 'ctrl+s': 'save session', 'ctrl+l': 'clear log', 'tab': 'next pane', 'space': 'toggle todo', 'esc': 'exit' },
64
+ themes: ['cyberpunk', 'nord', 'dracula', 'catppuccin', 'warm'],
65
+ modes: ['BUILD', 'PLAN', 'REVIEW', 'DEBUG'],
66
+ },
67
+ status: 'ok',
68
+ };
69
+
70
+ console.log(JSON.stringify(output, null, 2));
71
+ process.exit(0);
72
+ }
73
+ }
74
+
75
+ // ─── TTY Gate ────────────────────────────────────────────────────────────────
76
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
77
+ const args = process.argv.slice(2);
78
+ if (args.includes('--version') || args.includes('-v')) {
79
+ console.log('@p31/andromeda-cli v1.0.0');
80
+ process.exit(0);
81
+ }
82
+ if (args.includes('--help') || args.includes('-h')) {
83
+ console.log(`
84
+ P31 Oasis CLI v1.0.0
85
+
86
+ USAGE
87
+ andromeda Launch interactive TUI
88
+ andromeda --help Show this help
89
+ andromeda --version Show version
90
+ `);
91
+ process.exit(0);
92
+ }
93
+ console.error('[P31] Requires an interactive TTY.');
94
+ process.exit(1);
95
+ }
96
+
97
+ // Lazy-load TUI dependencies — in optionalDependencies so npm install always
98
+ // succeeds in headless agent environments. The --agent path exits above before
99
+ // reaching here, so these are only required when a TTY is present.
100
+ let blessed, contrib, pty;
101
+ try {
102
+ blessed = require('blessed');
103
+ contrib = require('blessed-contrib');
104
+ pty = require('node-pty');
105
+ } catch (e) {
106
+ console.error('[P31] TUI dependencies not available. Install build essentials and reinstall:');
107
+ console.error('[P31] npm install -g @p31/andromeda-cli');
108
+ console.error('[P31] For headless agent mode, use: andromeda --agent');
109
+ process.exit(1);
110
+ }
111
+
112
+ const fs = require('fs');
113
+ const path = require('path');
114
+ const os = require('os');
115
+ const crypto = require('crypto');
116
+
117
+ // ═════════════════════════════════════════════════════════════════════════════
118
+ // THEME SYSTEM — 4 presets, hot-swappable
119
+ // ═════════════════════════════════════════════════════════════════════════════
120
+
121
+ const THEMES = {
122
+ cyberpunk: {
123
+ name: 'Cyberpunk',
124
+ bg: '#0a0e17', fg: '#00ffcc', accent: '#ff00ff', neon: '#39ff14',
125
+ muted: '#4a5568', danger: '#ff3366', warning: '#ffaa00',
126
+ border: { fg: '#ff00ff' }, logTime: '#4a5568',
127
+ logInfo: '#00ffcc', logWarn: '#ffaa00', logErr: '#ff3366', logOk: '#39ff14',
128
+ },
129
+ nord: {
130
+ name: 'Nord',
131
+ bg: '#2e3440', fg: '#88c0d0', accent: '#81a1c1', neon: '#a3be8c',
132
+ muted: '#4c566a', danger: '#bf616a', warning: '#d08770',
133
+ border: { fg: '#81a1c1' }, logTime: '#4c566a',
134
+ logInfo: '#88c0d0', logWarn: '#d08770', logErr: '#bf616a', logOk: '#a3be8c',
135
+ },
136
+ dracula: {
137
+ name: 'Dracula',
138
+ bg: '#282a36', fg: '#f8f8f2', accent: '#bd93f9', neon: '#50fa7b',
139
+ muted: '#6272a4', danger: '#ff5555', warning: '#ffb86c',
140
+ border: { fg: '#bd93f9' }, logTime: '#6272a4',
141
+ logInfo: '#f8f8f2', logWarn: '#ffb86c', logErr: '#ff5555', logOk: '#50fa7b',
142
+ },
143
+ catppuccin: {
144
+ name: 'Catppuccin',
145
+ bg: '#1e1e2e', fg: '#cdd6f4', accent: '#cba6f7', neon: '#a6e3a1',
146
+ muted: '#6c7086', danger: '#f38ba8', warning: '#fab387',
147
+ border: { fg: '#cba6f7' }, logTime: '#6c7086',
148
+ logInfo: '#cdd6f4', logWarn: '#fab387', logErr: '#f38ba8', logOk: '#a6e3a1',
149
+ },
150
+ warm: {
151
+ name: 'P31 Precision',
152
+ bg: '#201d1d', fg: '#fdfcfc', accent: '#7c3aed', neon: '#7c3aed',
153
+ muted: '#9a9898', danger: '#ff3b30', warning: '#f59e0b',
154
+ border: { fg: '#2a2626' }, logTime: '#6b6868',
155
+ logInfo: '#fdfcfc', logWarn: '#f59e0b', logErr: '#ff3b30', logOk: '#30d158',
156
+ },
157
+ };
158
+
159
+ // ═════════════════════════════════════════════════════════════════════════════
160
+ // SESSION — persisted to ~/.p31/cli-session.json
161
+ // ═════════════════════════════════════════════════════════════════════════════
162
+
163
+ const SESSION_DIR = path.join(os.homedir(), '.p31');
164
+ const SESSION_FILE = path.join(SESSION_DIR, 'cli-session.json');
165
+
166
+ function loadSession() {
167
+ try {
168
+ if (fs.existsSync(SESSION_FILE)) {
169
+ const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
170
+ const saved = JSON.parse(raw);
171
+ return {
172
+ theme: saved.theme || 'warm',
173
+ todos: saved.todos || [],
174
+ log: saved.log || [],
175
+ mode: saved.mode || 'BUILD',
176
+ sandboxCwd: saved.sandboxCwd || process.cwd(),
177
+ };
178
+ }
179
+ } catch (e) { /* corrupt file, start fresh */ }
180
+ return {
181
+ theme: 'warm',
182
+ todos: [],
183
+ log: [],
184
+ mode: 'BUILD',
185
+ sandboxCwd: process.cwd(),
186
+ };
187
+ }
188
+
189
+ function saveSession() {
190
+ try {
191
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
192
+ const data = {
193
+ theme: currentThemeName,
194
+ todos: state.todos,
195
+ log: state.logBuffer.slice(-200),
196
+ mode: state.mode,
197
+ sandboxCwd: state.sandboxCwd,
198
+ savedAt: new Date().toISOString(),
199
+ };
200
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
201
+ } catch (e) { /* non-fatal */ }
202
+ }
203
+
204
+ // ═════════════════════════════════════════════════════════════════════════════
205
+ // STATE
206
+ // ═════════════════════════════════════════════════════════════════════════════
207
+
208
+ const session = loadSession();
209
+ let currentThemeName = session.theme;
210
+ let currentTheme = THEMES[currentThemeName] || THEMES.warm;
211
+
212
+ const state = {
213
+ mode: session.mode,
214
+ model: 'deepseek-v4-flash',
215
+ tokens: { used: 1247, limit: 128000 },
216
+ spend: 0.014,
217
+ lspConnected: true,
218
+ todos: session.todos.length ? session.todos : [
219
+ { text: 'Parse input', done: true },
220
+ { text: 'Run tests', done: false },
221
+ { text: 'Deploy to staging', done: false },
222
+ { text: 'Review PR #42', done: false },
223
+ ],
224
+ logBuffer: session.log,
225
+ logMax: 500,
226
+ progress: 0.42,
227
+ ptyBusy: false,
228
+ sandboxCwd: session.sandboxCwd,
229
+ paletteOpen: false,
230
+ helpOpen: false,
231
+ commandHistory: [],
232
+ historyIndex: -1,
233
+ notifications: [],
234
+ lastScrollPos: 0,
235
+ isUserScrolling: false,
236
+ };
237
+
238
+ // ═════════════════════════════════════════════════════════════════════════════
239
+ // SCREEN
240
+ // ═════════════════════════════════════════════════════════════════════════════
241
+
242
+ const screen = blessed.screen({
243
+ smartCSR: true,
244
+ title: 'P31 Oasis CLI',
245
+ fullUnicode: true,
246
+ warnings: false,
247
+ dockBorders: true,
248
+ autoPadding: true,
249
+ defaultPadding: { top: 0, left: 1, right: 1, bottom: 0 },
250
+ });
251
+
252
+ // ═════════════════════════════════════════════════════════════════════════════
253
+ // WIDGETS
254
+ // ═════════════════════════════════════════════════════════════════════════════
255
+
256
+ let topBar, logBox, sandboxBox, metaBox, todoBox, inputBar, footer;
257
+ let notificationManager;
258
+
259
+ function createWidgets() {
260
+ const t = currentTheme;
261
+
262
+ topBar = blessed.box({
263
+ parent: screen,
264
+ top: 0, left: 0, width: '100%', height: 1,
265
+ tags: true,
266
+ style: { fg: t.fg, bg: t.bg, bold: true },
267
+ });
268
+
269
+ logBox = blessed.box({
270
+ parent: screen,
271
+ top: 1, left: 0, width: '70%', height: '60%',
272
+ label: ' {cyan-fg}◉ LOG{/cyan-fg} ',
273
+ scrollable: true,
274
+ alwaysScroll: true,
275
+ scrollbar: { ch: ' ' },
276
+ keys: true,
277
+ vi: true,
278
+ mouse: true,
279
+ tags: true,
280
+ style: { fg: t.fg, bg: t.bg, border: { fg: t.border.fg } },
281
+ border: { type: 'line', fg: t.border.fg },
282
+ });
283
+
284
+ sandboxBox = blessed.box({
285
+ parent: screen,
286
+ top: '61%', left: 0, width: '70%', height: '39%',
287
+ label: ' {green-fg}◉ SANDBOX{/green-fg} ',
288
+ scrollable: true,
289
+ alwaysScroll: true,
290
+ scrollbar: { ch: ' ' },
291
+ keys: true,
292
+ vi: true,
293
+ mouse: true,
294
+ tags: true,
295
+ input: true,
296
+ style: { fg: t.neon, bg: t.bg, border: { fg: t.neon } },
297
+ border: { type: 'line', fg: t.neon },
298
+ });
299
+
300
+ metaBox = blessed.box({
301
+ parent: screen,
302
+ top: 1, left: '70%', width: '30%', height: '40%',
303
+ label: ' {grey-fg}▸ META{/grey-fg} ',
304
+ tags: true,
305
+ scrollable: true,
306
+ style: { fg: t.fg, bg: t.bg, border: { fg: t.muted } },
307
+ border: { type: 'line', fg: t.muted },
308
+ });
309
+
310
+ todoBox = blessed.list({
311
+ parent: screen,
312
+ top: '41%', left: '70%', width: '30%', height: '59%',
313
+ label: ' {grey-fg}▸ TODOS{/grey-fg} ',
314
+ tags: true,
315
+ keys: true,
316
+ vi: true,
317
+ mouse: true,
318
+ style: {
319
+ fg: t.fg, bg: t.bg,
320
+ border: { fg: t.muted },
321
+ selected: { bg: t.muted, fg: t.bg },
322
+ },
323
+ border: { type: 'line', fg: t.muted },
324
+ items: renderTodoItems(),
325
+ });
326
+
327
+ inputBar = blessed.textbox({
328
+ parent: screen,
329
+ bottom: 1, left: 0, width: '100%', height: 1,
330
+ tags: true,
331
+ style: { fg: t.fg, bg: t.bg, border: { fg: t.accent } },
332
+ border: { type: 'line', fg: t.accent },
333
+ inputOnFocus: true,
334
+ padding: { left: 1, right: 1 },
335
+ });
336
+
337
+ footer = blessed.box({
338
+ parent: screen,
339
+ bottom: 0, left: 0, width: '100%', height: 1,
340
+ tags: true,
341
+ style: { fg: t.muted, bg: t.bg },
342
+ });
343
+
344
+ // Notification overlay layer
345
+ notificationManager = new NotificationManager(screen, t);
346
+
347
+ screen.append(topBar);
348
+ screen.append(logBox);
349
+ screen.append(sandboxBox);
350
+ screen.append(metaBox);
351
+ screen.append(todoBox);
352
+ screen.append(inputBar);
353
+ screen.append(footer);
354
+ }
355
+
356
+ // ═════════════════════════════════════════════════════════════════════════════
357
+ // NOTIFICATION SYSTEM — auto-dismissing toasts
358
+ // ═════════════════════════════════════════════════════════════════════════════
359
+
360
+ class NotificationManager {
361
+ constructor(screen, theme) {
362
+ this.screen = screen;
363
+ this.theme = theme;
364
+ this.notifications = [];
365
+ this.maxNotifications = 3;
366
+ }
367
+
368
+ show(message, type = 'info', duration = 4000) {
369
+ const colors = {
370
+ info: this.theme.fg,
371
+ ok: this.theme.neon,
372
+ warn: this.theme.warning,
373
+ err: this.theme.danger,
374
+ };
375
+ const glyphs = { info: 'i', ok: '✓', warn: '!', err: '✗' };
376
+
377
+ const box = blessed.box({
378
+ parent: this.screen,
379
+ top: this.notifications.length + 1,
380
+ right: 0,
381
+ width: Math.min(60, Math.floor(this.screen.width * 0.4)),
382
+ height: 3,
383
+ tags: true,
384
+ content: `{bold}{${colors[type]}-fg}[${glyphs[type]}]{/${colors[type]}-fg}{/bold} ${message}`,
385
+ style: {
386
+ fg: colors[type],
387
+ bg: this.theme.bg,
388
+ border: { fg: colors[type] },
389
+ },
390
+ border: { type: 'line', fg: colors[type] },
391
+ hidden: true,
392
+ });
393
+
394
+ this.screen.append(box);
395
+ box.show();
396
+ this.notifications.push({ box, timeout: setTimeout(() => this.dismiss(box), duration) });
397
+
398
+ if (this.notifications.length > this.maxNotifications) {
399
+ this.dismiss(this.notifications[0].box);
400
+ }
401
+ this.screen.render();
402
+ }
403
+
404
+ dismiss(box) {
405
+ box.destroy();
406
+ this.notifications = this.notifications.filter(n => {
407
+ if (n.box === box) { clearTimeout(n.timeout); return false; }
408
+ return true;
409
+ });
410
+ this.reflow();
411
+ }
412
+
413
+ reflow() {
414
+ this.notifications.forEach((n, i) => {
415
+ n.box.style.top = i + 1;
416
+ });
417
+ this.screen.render();
418
+ }
419
+
420
+ updateTheme(theme) {
421
+ this.theme = theme;
422
+ }
423
+ }
424
+
425
+ // ─── Shortcut: notify(this, 'ok', 'Theme switched') ─────────────────────────
426
+
427
+ function notify(message, type = 'info', duration = 4000) {
428
+ notificationManager.show(message, type, duration);
429
+ }
430
+
431
+ // ═════════════════════════════════════════════════════════════════════════════
432
+ // PTY SANDBOX — persistent shell session
433
+ // ═════════════════════════════════════════════════════════════════════════════
434
+
435
+ let shell;
436
+
437
+ function initSandbox() {
438
+ const cols = Math.max(20, Math.floor((screen.cols || 80) * 0.68) - 4);
439
+ const rows = Math.max(5, Math.floor(((screen.rows || 24) - 4) * 0.38) - 3);
440
+ try {
441
+ shell = pty.spawn(process.env.SHELL || 'bash', [], {
442
+ name: 'xterm-256color',
443
+ cols, rows,
444
+ cwd: state.sandboxCwd,
445
+ env: { ...process.env, TERM: 'xterm-256color', P31_THEME: currentThemeName },
446
+ });
447
+ shell.on('data', (data) => {
448
+ state.ptyBusy = true;
449
+ const safe = data
450
+ .replace(/[\x00-\x08\x0e-\x1f]/g, '')
451
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, (m) => m)
452
+ .slice(0, 4000);
453
+ const content = sandboxBox.getContent() + safe;
454
+ const lines = content.split('\n');
455
+ if (lines.length > 800) lines.splice(0, lines.length - 800);
456
+ sandboxBox.setContent(lines.join('\n'));
457
+ sandboxBox.setScrollPerc(100);
458
+ state.ptyBusy = false;
459
+ screen.render();
460
+ });
461
+ shell.on('exit', () => {
462
+ appLog('warn', 'Sandbox session ended — reconnecting in 1s…');
463
+ notify('Sandbox reconnecting…', 'warn');
464
+ setTimeout(initSandbox, 1000);
465
+ });
466
+ sandboxBox.setContent(
467
+ `{${currentTheme.neon}-fg}\u25b8{/${currentTheme.neon}-fg} Sandbox ready ({bold}${state.sandboxCwd}{/bold})\n` +
468
+ `{${currentTheme.muted}-fg}Type /help for commands. Tab to focus. Your shell session is persistent.{/${currentTheme.muted}-fg}\n\n`
469
+ );
470
+ appLog('ok', 'Sandbox initialised');
471
+ } catch (e) {
472
+ sandboxBox.setContent(`{${currentTheme.danger}-fg}Sandbox unavailable: ${e.message}{/${currentTheme.danger}-fg}\n{e.message}{/}`);
473
+ appLog('err', `Sandbox failed: ${e.message}`);
474
+ }
475
+ }
476
+
477
+ function resizeSandbox() {
478
+ if (!shell) return;
479
+ const cols = Math.max(20, Math.floor((screen.cols || 80) * 0.68) - 4);
480
+ const rows = Math.max(5, Math.floor(((screen.rows || 24) - 4) * 0.38) - 3);
481
+ try { shell.resize(cols, rows); } catch (e) { /* ignore */ }
482
+ }
483
+
484
+ function runInSandbox(cmd) {
485
+ if (!shell || process.platform === 'win32') {
486
+ appLog('err', 'Sandbox not ready — command queued');
487
+ return;
488
+ }
489
+ state.sandboxCwd = shell.cwd || state.sandboxCwd;
490
+ shell.write(cmd + '\n');
491
+ appLog('info', `$ ${cmd}`);
492
+ state.commandHistory.push(cmd);
493
+ state.historyIndex = state.commandHistory.length;
494
+ }
495
+
496
+ // ═════════════════════════════════════════════════════════════════════════════
497
+ // LOG SYSTEM — streaming, scrollable, timestamped, colour-coded
498
+ // ═════════════════════════════════════════════════════════════════════════════
499
+
500
+ function ts() {
501
+ const d = new Date();
502
+ return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
503
+ }
504
+
505
+ const GL = { info: '▸', ok: '✓', warn: '!', err: '✗', step: '→', dry: '~' };
506
+ const GC = { info: 'cyan', ok: 'green', warn: 'yellow', err: 'red', step: 'blue', dry: 'grey' };
507
+
508
+ function appLog(level, msg) {
509
+ const c = currentTheme;
510
+ const ts_ = ts();
511
+ const tsColor = c.logTime;
512
+ const lvlColor = c[`log${level.charAt(0).toUpperCase() + level.slice(1)}`] || c.logInfo;
513
+ const line = `{${tsColor}-fg}[${ts_}]{/${tsColor}-fg} {bold}{${lvlColor}-fg}${GL[level]}{/${lvlColor}-fg}{/bold} ${msg}`;
514
+ pushLog(line, level);
515
+ }
516
+
517
+ function pushLog(line, level) {
518
+ const content = logBox.getContent();
519
+ const lines = content.split('\n').filter(l => l.length > 0);
520
+ lines.push(line);
521
+ if (lines.length > state.logMax) lines.splice(0, lines.length - state.logMax);
522
+ state.logBuffer = lines.map(l => l.replace(/\{[^}]+\}/g, '').trim());
523
+ logBox.setContent(lines.join('\n'));
524
+ if (!state.isUserScrolling) {
525
+ logBox.setScrollPerc(100);
526
+ }
527
+ screen.render();
528
+ }
529
+
530
+ function clearLog() {
531
+ logBox.setContent('');
532
+ state.logBuffer = [];
533
+ screen.render();
534
+ }
535
+
536
+ // ═════════════════════════════════════════════════════════════════════════════
537
+ // TODO LIST — reactive, keyboard-togglable, persistent
538
+ // ═════════════════════════════════════════════════════════════════════════════
539
+
540
+ function renderTodoItems() {
541
+ return state.todos.map((t) => {
542
+ const c = currentTheme;
543
+ const box = t.done
544
+ ? `{${c.todoDone}-fg}[✓]{/${c.todoDone}-fg}`
545
+ : `{${c.todoPending}-fg}[•]{/${c.todoPending}-fg}`;
546
+ return `${box} ${t.text}`;
547
+ });
548
+ }
549
+
550
+ function toggleTodo() {
551
+ const idx = todoBox.selected;
552
+ if (idx >= 0 && idx < state.todos.length) {
553
+ state.todos[idx].done = !state.todos[idx].done;
554
+ todoBox.setItems(renderTodoItems());
555
+ todoBox.select(idx);
556
+ screen.render();
557
+ const t = state.todos[idx];
558
+ appLog('info', `Todo ${idx + 1}: ${t.done ? '✓ done' : '○ pending'} — "${t.text}"`);
559
+ }
560
+ }
561
+
562
+ // ═════════════════════════════════════════════════════════════════════════════
563
+ // RENDER PIPELINE — all panes refresh in a single screen.render() call
564
+ // ═════════════════════════════════════════════════════════════════════════════
565
+
566
+ function renderAll() {
567
+ const t = currentTheme;
568
+ const pct = Math.round(state.progress * 100);
569
+ const pctColor = state.progress < 0.3 ? t.danger : state.progress < 0.7 ? t.warning : t.neon;
570
+
571
+ const bar = (
572
+ ` {bold}{${t.neon}-fg}●{/${t.neon}-fg}{/bold} ` +
573
+ `{bold}${state.mode}{/bold}{${t.muted}-fg} · BIG PICKLE{/${t.muted}-fg}` +
574
+ `{right}{${t.muted}-fg}v1.0.0 │ ${pctColor}${'█'.repeat(Math.floor(pct / 5))}${'░'.repeat(20 - Math.floor(pct / 5))} ${pct}%{/${t.muted}-fg} │ ` +
575
+ `${state.tokens.used.toLocaleString()} tok │ ` +
576
+ `{${t.accent}-fg}${t.name}{/${t.accent}-fg}`
577
+ );
578
+ topBar.setContent(bar);
579
+
580
+ metaBox.setContent(
581
+ `{bold}META{/bold}\n\n` +
582
+ `{${t.muted}-fg}Model:{/${t.muted}-fg} {bold}${state.model}{/bold}\n` +
583
+ `{${t.muted}-fg}Tokens:{/${t.muted}-fg} ${state.tokens.used.toLocaleString()} / ${state.tokens.limit.toLocaleString()}\n` +
584
+ `{${t.muted}-fg}Spend:{/${t.muted}-fg} {bold}$${state.spend.toFixed(3)}{/bold}\n` +
585
+ `{${t.muted}-fg}Mode:{/${t.muted}-fg} {${t.accent}-fg}${state.mode}{/${t.accent}-fg}\n` +
586
+ `{${t.muted}-fg}LSP:{/${t.muted}-fg} ${state.lspConnected ? `{${t.neon}-fg}● connected{/${t.neon}-fg}` : `{${t.danger}-fg}● disconnected{/${t.danger}-fg}`}\n` +
587
+ (state.ptyBusy ? `{${t.warning}-fg}▸ sandbox busy…{/${t.warning}-fg}\n` : '') +
588
+ `\n{bold}SHORTCUTS{/bold}\n\n` +
589
+ `{${t.accent}-fg}ctrl+p{/${t.accent}-fg} palette\n` +
590
+ `{${t.accent}-fg}ctrl+t{/${t.accent}-fg} cycle theme\n` +
591
+ `{${t.accent}-fg}ctrl+s{/${t.accent}-fg} save session\n` +
592
+ `{${t.accent}-fg}ctrl+l{/${t.accent}-fg} clear log\n` +
593
+ `{${t.accent}-fg}space{/${t.accent}-fg} toggle todo\n` +
594
+ `{${t.accent}-fg}tab{/${t.accent}-fg} next pane`
595
+ );
596
+
597
+ footer.setContent(
598
+ ` {${t.muted}-fg}[tab] focus [ctrl+p] palette [ctrl+t] theme [ctrl+l] clear [space] todo [esc] exit{/${t.muted}-fg}`
599
+ );
600
+
601
+ inputBar.setContent(
602
+ ` {${t.accent}-fg}▸{/${t.accent}-fg} {bold}${state.mode}{/bold} ` +
603
+ `{${t.muted}-fg}·{/${t.muted}-fg} {bold}${currentThemeName}{/bold} ` +
604
+ `{${t.muted}-fg}·{/${t.muted}-fg} type a command{/${t.fg}}`
605
+ );
606
+ }
607
+
608
+ // ═════════════════════════════════════════════════════════════════════════════
609
+ // THEME HOT-SWAP
610
+ // ═════════════════════════════════════════════════════════════════════════════
611
+
612
+ function applyTheme(theme) {
613
+ currentTheme = theme;
614
+ screen.style.bg = theme.bg;
615
+ screen.style.fg = theme.fg;
616
+
617
+ const styledWidgets = [topBar, logBox, sandboxBox, metaBox, todoBox, inputBar, footer];
618
+ styledWidgets.forEach((w) => {
619
+ w.style.fg = theme.fg;
620
+ w.style.bg = theme.bg;
621
+ if (w.style.border) w.style.border.fg = theme.muted;
622
+ if (w.options && w.options.style && w.options.style.border) {
623
+ // refresh border colours based on type
624
+ }
625
+ });
626
+
627
+ // Per-widget overrides
628
+ logBox.style.border.fg = theme.border.fg;
629
+ sandboxBox.style.fg = theme.neon;
630
+ sandboxBox.style.border.fg = theme.neon;
631
+ metaBox.style.border.fg = theme.muted;
632
+ todoBox.style.fg = theme.fg;
633
+ todoBox.style.border.fg = theme.muted;
634
+ todoBox.style.selected = { bg: theme.muted, fg: theme.bg };
635
+ inputBar.style.fg = theme.fg;
636
+ inputBar.style.border.fg = theme.accent;
637
+ footer.style.fg = theme.muted;
638
+
639
+ notificationManager.updateTheme(theme);
640
+
641
+ // Rebuild list items with new theme colours
642
+ todoBox.setItems(renderTodoItems());
643
+
644
+ if (shell) {
645
+ try { shell.write(`export P31_THEME=${currentThemeName}\n`); } catch (e) { /* ignore */ }
646
+ }
647
+ renderAll();
648
+ screen.render();
649
+ }
650
+
651
+ const THEME_ORDER = Object.keys(THEMES);
652
+ let themeIdx = THEME_ORDER.indexOf(currentThemeName);
653
+
654
+ function cycleTheme() {
655
+ themeIdx = (themeIdx + 1) % THEME_ORDER.length;
656
+ currentThemeName = THEME_ORDER[themeIdx];
657
+ currentTheme = THEMES[currentThemeName];
658
+ applyTheme(currentTheme);
659
+ appLog('ok', `Theme → ${currentTheme.name}`);
660
+ notify(`Theme: ${currentTheme.name}`, 'ok');
661
+ saveSession();
662
+ }
663
+
664
+ function setTheme(name) {
665
+ if (THEMES[name]) {
666
+ currentThemeName = name;
667
+ currentTheme = THEMES[name];
668
+ themeIdx = THEME_ORDER.indexOf(name);
669
+ applyTheme(currentTheme);
670
+ appLog('ok', `Theme → ${currentTheme.name}`);
671
+ notify(`Theme: ${currentTheme.name}`, 'ok');
672
+ saveSession();
673
+ } else {
674
+ appLog('warn', `Unknown theme "${name}". Options: ${THEME_ORDER.join(', ')}`);
675
+ }
676
+ }
677
+
678
+ // ═════════════════════════════════════════════════════════════════════════════
679
+ // FOCUS MANAGEMENT
680
+ // ═════════════════════════════════════════════════════════════════════════════
681
+
682
+ const PANES = () => [logBox, sandboxBox, todoBox, inputBar];
683
+ let focusIdx = 0;
684
+
685
+ function cycleFocus(dir) {
686
+ const panes = PANES();
687
+ focusIdx = (focusIdx + dir + panes.length) % panes.length;
688
+ panes[focusIdx].focus();
689
+ screen.render();
690
+ }
691
+
692
+ // ═════════════════════════════════════════════════════════════════════════════
693
+ // COMMAND PALETTE
694
+ // ═════════════════════════════════════════════════════════════════════════════
695
+
696
+ const PALETTE_COMMANDS = [
697
+ '/theme cyberpunk', '/theme nord', '/theme dracula', '/theme catppuccin',
698
+ '/mode build', '/mode plan', '/mode review', '/mode debug',
699
+ '/clear', '/sandbox clear', '/export log',
700
+ '/save', '/notify test', '/help',
701
+ ];
702
+
703
+ function openPalette() {
704
+ if (state.paletteOpen) return;
705
+ state.paletteOpen = true;
706
+
707
+ const palette = blessed.list({
708
+ parent: screen,
709
+ top: 'center', left: 'center',
710
+ width: '45%', height: '60%',
711
+ label: ' COMMAND PALETTE ',
712
+ keys: true, vi: true, mouse: true,
713
+ items: PALETTE_COMMANDS,
714
+ tags: true,
715
+ style: {
716
+ fg: currentTheme.fg, bg: currentTheme.bg,
717
+ border: { fg: currentTheme.accent },
718
+ selected: { bg: currentTheme.accent, fg: currentTheme.bg },
719
+ },
720
+ border: { type: 'line', fg: currentTheme.accent },
721
+ padding: { left: 2, right: 2 },
722
+ });
723
+
724
+ palette.key(['escape'], () => {
725
+ palette.destroy();
726
+ state.paletteOpen = false;
727
+ screen.render();
728
+ });
729
+ palette.key(['enter'], () => {
730
+ const cmd = PALETTE_COMMANDS[palette.selected];
731
+ palette.destroy();
732
+ state.paletteOpen = false;
733
+ executeCommand(cmd);
734
+ });
735
+ palette.focus();
736
+ screen.render();
737
+ }
738
+
739
+ function executeCommand(cmd) {
740
+ switch (cmd) {
741
+ case '/exit':
742
+ notify('Goodbye.', 'info');
743
+ setTimeout(() => process.exit(0), 300);
744
+ break;
745
+ case '/clear':
746
+ clearLog();
747
+ appLog('info', 'Log cleared');
748
+ break;
749
+ case '/sandbox clear':
750
+ sandboxBox.setContent('');
751
+ screen.render();
752
+ break;
753
+ case '/export log':
754
+ exportLog();
755
+ break;
756
+ case '/save':
757
+ saveSession();
758
+ notify('Session saved', 'ok');
759
+ appLog('ok', 'Session state saved to ' + SESSION_FILE);
760
+ break;
761
+ case '/notify test':
762
+ notify('This is a test notification', 'info');
763
+ setTimeout(() => notify('Second notification (warn)', 'warn'), 250);
764
+ setTimeout(() => notify('Third notification (ok)', 'ok', 6000), 500);
765
+ break;
766
+ case '/help':
767
+ showHelp();
768
+ break;
769
+ default:
770
+ if (cmd.startsWith('/theme ')) setTheme(cmd.replace('/theme ', ''));
771
+ else if (cmd.startsWith('/mode ')) {
772
+ state.mode = cmd.replace('/mode ', '').toUpperCase();
773
+ renderAll();
774
+ screen.render();
775
+ appLog('info', `Mode → ${state.mode}`);
776
+ }
777
+ else runInSandbox(cmd);
778
+ }
779
+ saveSession();
780
+ }
781
+
782
+ function exportLog() {
783
+ try {
784
+ const clean = logBox.getContent().replace(/\{[^}]+\}/g, '').replace(/\x1b\[[0-9;]*[mK]/g, '');
785
+ const fd = fs.openSync(`p31-oasis-log-${Date.now()}.txt`, 'w');
786
+ fs.writeSync(fd, clean);
787
+ fs.closeSync(fd);
788
+ appLog('ok', 'Log exported');
789
+ notify('Log exported to file', 'ok');
790
+ } catch (e) {
791
+ appLog('err', `Export failed: ${e.message}`);
792
+ }
793
+ }
794
+
795
+ function showHelp() {
796
+ if (state.helpOpen) return;
797
+ state.helpOpen = true;
798
+
799
+ const c = currentTheme;
800
+ const helpText = [
801
+ `{bold}KEYBOARD SHORTCUTS{/bold}`,
802
+ ` {${c.accent}-fg}[tab]{/${c.accent}-fg} Focus next pane`,
803
+ ` {${c.accent}-fg}[S-tab]{/${c.accent}-fg} Focus previous pane`,
804
+ ` {${c.accent}-fg}[ctrl+p]{/${c.accent}-fg} Command palette`,
805
+ ` {${c.accent}-fg}[ctrl+t]{/${c.accent}-fg} Cycle theme`,
806
+ ` {${c.accent}-fg}[ctrl+s]{/${c.accent}-fg} Save session`,
807
+ ` {${c.accent}-fg}[ctrl+l]{/${c.accent}-fg} Clear log`,
808
+ ` {${c.accent}-fg}[space]{/${c.accent}-fg} Toggle selected todo`,
809
+ ` {${c.accent}-fg}[up/down]{/${c.accent}-fg} Scroll / navigate`,
810
+ ` {${c.accent}-fg}[pgup/pgdn]{/${c.accent}-fg} Page scroll`,
811
+ ` {${c.accent}-fg}[esc]{/${c.accent}-fg} Exit`,
812
+ ``,
813
+ `{bold}COMMANDS{/bold}`,
814
+ ` {${c.neon}-fg}/theme <name>{/${c.neon}-fg} Switch theme`,
815
+ ` {${c.neon}-fg}/mode <name>{/${c.neon}-fg} Set mode (build/plan/review/debug)`,
816
+ ` {${c.neon}-fg}/clear{/${c.neon}-fg} Clear log`,
817
+ ` {${c.neon}-fg}/sandbox clear{/${c.neon}-fg} Clear sandbox output`,
818
+ ` {${c.neon}-fg}/export log{/${c.neon}-fg} Export log to file`,
819
+ ` {${c.neon}-fg}/save{/${c.neon}-fg} Save session state`,
820
+ ` {${c.neon}-fg}/notify test{/${c.neon}-fg} Test notifications`,
821
+ ` {${c.neon}-fg}/help{/${c.neon}-fg} Show this help`,
822
+ ``,
823
+ ` {${c.muted}-fg}Any other input is sent to the sandbox shell.{/${c.muted}-fg}`,
824
+ ].join('\n');
825
+
826
+ const help = blessed.box({
827
+ parent: screen,
828
+ top: 'center', left: 'center',
829
+ width: '55%', height: '65%',
830
+ label: ' HELP ',
831
+ content: helpText,
832
+ tags: true,
833
+ keys: true, vi: true,
834
+ scrollable: true,
835
+ style: {
836
+ fg: c.fg, bg: c.bg,
837
+ border: { fg: c.accent },
838
+ },
839
+ border: { type: 'line', fg: c.accent },
840
+ padding: { left: 2, right: 2 },
841
+ });
842
+ help.key(['escape', 'q'], () => { help.destroy(); state.helpOpen = false; screen.render(); });
843
+ help.focus();
844
+ screen.render();
845
+ }
846
+
847
+ // ═════════════════════════════════════════════════════════════════════════════
848
+ // KEYBOARD SHORTCUTS
849
+ // ═════════════════════════════════════════════════════════════════════════════
850
+
851
+ screen.key(['escape', 'C-c'], () => {
852
+ saveSession();
853
+ process.exit(0);
854
+ });
855
+
856
+ screen.key(['tab'], () => cycleFocus(1));
857
+ screen.key(['S-tab'], () => cycleFocus(-1));
858
+
859
+ screen.key(['C-p'], () => openPalette());
860
+ screen.key(['C-t'], () => cycleTheme());
861
+ screen.key(['C-l'], () => { clearLog(); appLog('info', 'Log cleared'); });
862
+ screen.key(['C-s'], () => {
863
+ saveSession();
864
+ notify('Session saved', 'ok');
865
+ appLog('ok', `Saved to ${SESSION_FILE}`);
866
+ });
867
+
868
+ screen.key(['space'], () => {
869
+ if (todoBox.hasFocus()) toggleTodo();
870
+ });
871
+
872
+ // ─── Input handling ──────────────────────────────────────────────────────────
873
+
874
+ inputBar.on('submit', (text) => {
875
+ const cmd = (text || '').trim();
876
+ inputBar.clearValue();
877
+ if (!cmd) { screen.render(); return; }
878
+ if (cmd.startsWith('/')) executeCommand(cmd);
879
+ else runInSandbox(cmd);
880
+ screen.render();
881
+ });
882
+
883
+ // ─── Scroll-aware auto-follow in log ─────────────────────────────────────────
884
+
885
+ logBox.on('scroll', () => {
886
+ const max = logBox.scroll - logBox.height;
887
+ state.isUserScrolling = logBox.scroll < state.logBuffer.length - logBox.height - 2;
888
+ });
889
+
890
+ // ═════════════════════════════════════════════════════════════════════════════
891
+ // RESIZE — recalculate sandbox PTY dimensions, reflow notifications
892
+ // ═════════════════════════════════════════════════════════════════════════════
893
+
894
+ screen.on('resize', () => {
895
+ resizeSandbox();
896
+ notificationManager.reflow();
897
+ screen.render();
898
+ });
899
+
900
+ // ═════════════════════════════════════════════════════════════════════════════
901
+ // DEMO SIMULATION — realistic activity stream for testing the UI
902
+ // ═════════════════════════════════════════════════════════════════════════════
903
+
904
+ function bootstrap() {
905
+ const lines = [
906
+ { level: 'info', msg: `P31 Oasis CLI ${currentTheme.name} — initialising…` },
907
+ { level: 'info', msg: `Loaded ${state.todos.length} todos from session` },
908
+ { level: 'info', msg: `Sandbox: ${state.sandboxCwd}` },
909
+ { level: 'ok', msg: 'Module resolution complete: blessed, contrib, node-pty' },
910
+ { level: 'ok', msg: `Session restored from ${SESSION_FILE}` },
911
+ { level: 'info', msg: 'Reading workspace files…' },
912
+ { level: 'warn', msg: '2 deprecation warnings in cli/dependencies' },
913
+ { level: 'ok', msg: 'Found 12 source files across 3 packages' },
914
+ { level: 'info', msg: 'Running dependency analysis…' },
915
+ { level: 'ok', msg: 'Dependency tree verified — no critical issues' },
916
+ { level: 'info', msg: 'Running test suite…' },
917
+ { level: 'err', msg: 'Unit test failed: auth-flow.spec.js:47', delay: 800 },
918
+ { level: 'warn', msg: 'Root cause: token expiry mismatch in refresh logic' },
919
+ { level: 'info', msg: 'Auto-fix applied: updated token refresh handler' },
920
+ { level: 'info', msg: 'Re-running failed test…' },
921
+ { level: 'ok', msg: 'Unit test passed: auth-flow.spec.js:47' },
922
+ { level: 'info', msg: 'Running full test suite…' },
923
+ { level: 'ok', msg: 'All 142 tests passed — 87.3% coverage' },
924
+ { level: 'info', msg: 'Running lint…' },
925
+ { level: 'warn', msg: '1 unused variable in utils/helpers.ts' },
926
+ { level: 'info', msg: 'Patching…' },
927
+ { level: 'ok', msg: 'Lint clean' },
928
+ { level: 'info', msg: 'Compiling bundle…' },
929
+ { level: 'ok', msg: 'Bundle: 2.4 MB (gzip: 612 KB)' },
930
+ { level: 'info', msg: 'Deploying to staging…' },
931
+ { level: 'ok', msg: 'Deployed: andromeda-v1.0.0-staging' },
932
+ { level: 'ok', msg: `Build complete — ${state.todos.filter(t=>t.done).length}/${state.todos.length} todos done` },
933
+ ];
934
+
935
+ let i = 0;
936
+ const tick = setInterval(() => {
937
+ if (i >= lines.length) {
938
+ clearInterval(tick);
939
+ state.progress = 1.0;
940
+ renderAll();
941
+ appLog('ok', 'Ready. Type a command, press / for palette, or Tab to navigate.');
942
+ setTimeout(() => notify('System ready', 'ok', 3000), 200);
943
+ return;
944
+ }
945
+ const l = lines[i];
946
+ appLog(l.level, l.msg);
947
+ state.progress = (i + 1) / lines.length;
948
+ renderAll();
949
+ screen.render();
950
+ i++;
951
+ }, l.delay || 300);
952
+
953
+ // ─── Periodic heartbeat ────────────────────────────────────────────────────
954
+ setInterval(() => {
955
+ notify(SystemMetrics(), 'info', 2000);
956
+ }, 30000);
957
+ }
958
+
959
+ function SystemMetrics() {
960
+ const used = Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 10) / 10;
961
+ const rss = Math.round((process.memoryUsage().rss / 1024 / 1024) * 10) / 10;
962
+ return `Heap: ${used}MB RSS: ${rss}MB`;
963
+ }
964
+
965
+ // ═════════════════════════════════════════════════════════════════════════════
966
+ // INIT — boot the TUI
967
+ // ═════════════════════════════════════════════════════════════════════════════
968
+
969
+ createWidgets();
970
+ initSandbox();
971
+ renderAll();
972
+ todoBox.setItems(renderTodoItems());
973
+
974
+ // Replay previous log into the log pane
975
+ state.logBuffer.forEach((line) => {
976
+ const c = currentTheme;
977
+ const ts_ = ts();
978
+ const formatted = `{${c.logTime}-fg}[${ts_}]{/${c.logTime}-fg} {bold}{${c.logInfo}-fg}→{/${c.logInfo}-fg}{/bold} ${line}`;
979
+ pushLog(formatted, 'info');
980
+ });
981
+
982
+ appLog('info', 'P31 Oasis CLI v1.0.0 started');
983
+ appLog('info', `Theme: ${currentTheme.name} | Model: ${state.model}`);
984
+ appLog('info', 'Tab to navigate • Ctrl+P palette • Ctrl+T cycle theme');
985
+ appLog('info', 'Type /help for commands, or any text to sandbox');
986
+
987
+ bootstrap();
988
+ screen.render();
989
+ logBox.focus();
990
+
991
+ // Graceful exit handler
992
+ process.on('SIGINT', () => { saveSession(); process.exit(0); });
993
+ process.on('exit', () => saveSession());