@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/README.md +151 -0
- package/SECURITY.md +11 -0
- package/component-registry.js +345 -0
- package/config/default.json +33 -0
- package/design-tokens-registry.js +42 -0
- package/index.js +993 -0
- package/mcp-server.js +364 -0
- package/package.json +82 -0
- package/scripts/postinstall.js +99 -0
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());
|