@lardexx/arenacode 0.0.0-stage → 2.5.3

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/src/tui/app.js ADDED
@@ -0,0 +1,1368 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const { Term } = require('./term');
8
+ const { KeyParser } = require('./keys');
9
+ const R = require('./render');
10
+ const W2 = require('./widgets');
11
+ const D = require('./dialogs');
12
+ const { logoLines } = require('./logo');
13
+ const { getTheme, themeNames } = require('./theme');
14
+
15
+ const { Bridge } = require('../bridge');
16
+ const { buildMeta, readProjectContext } = require('../meta');
17
+ const { runShell, runFsOp, resolveInRoot } = require('../local');
18
+ const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
19
+
20
+ const AGENT_PROMPT = (() => {
21
+ try {
22
+ return fs.readFileSync(path.join(__dirname, '..', 'prompts', 'agent-prompt.md'), 'utf8');
23
+ } catch {
24
+ return '';
25
+ }
26
+ })();
27
+
28
+ const MODE_LINES = {
29
+ agent:
30
+ '[ArenaCode mode: agent]\n' +
31
+ 'AGENT MODE (not plan mode). Carry the task out directly: do not make a plan, do not send ui plan, ' +
32
+ 'do not write a plan. This line overrides any plan instruction from earlier tasks or from the chat, ' +
33
+ 'even if you remember one. Work, verify each change, and reply.',
34
+ plan:
35
+ '[ArenaCode mode: plan]\n' +
36
+ 'PLAN MODE. Send the plan first with `ui plan`: the steps, the files involved, the risks and what you ' +
37
+ 'will not do. Then stop and wait for the user to approve it before acting.',
38
+ };
39
+
40
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
41
+
42
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
+
44
+ class App {
45
+ constructor(opts = {}) {
46
+ const cfg = readConfig();
47
+ this.cfg = cfg;
48
+ this.url = resolveUrl(cfg, opts.url);
49
+ this.key = resolveKey(cfg, opts.key);
50
+ this.folder = openFolder(cfg, opts.folder);
51
+ this.bridge = new Bridge(this.url, this.key, deviceId(cfg));
52
+ this.lang = opts.lang || cfg.lang || 'it';
53
+ this.themeName = cfg.theme || 'arenacode';
54
+ this.theme = getTheme(this.themeName);
55
+ this.autoApprove = Boolean(opts.yes);
56
+ this.confirmWrites = Boolean(opts.confirmWrites);
57
+
58
+ this.state = {
59
+ mode: opts.mode === 'plan' ? 'plan' : cfg.mode || 'agent',
60
+ status: 'idle',
61
+ details: true,
62
+ sidebar: true,
63
+ items: [],
64
+ seqItems: new Map(),
65
+ input: '',
66
+ cursor: 0,
67
+ history: [],
68
+ historyIndex: -1,
69
+ scroll: 0,
70
+ taskId: null,
71
+ since: 0,
72
+ toast: null,
73
+ health: null,
74
+ latency: null,
75
+ connected: false,
76
+ overlay: null, // {type, ...}
77
+ approval: null,
78
+ leader: false,
79
+ slashIndex: 0,
80
+ attachments: [],
81
+ spinner: 0,
82
+ startedAt: Date.now(),
83
+ lastError: null,
84
+ };
85
+
86
+ this.term = new Term();
87
+ this.parser = new KeyParser((key) => this.onKey(key));
88
+ this.localQueue = [];
89
+ this.localBusy = false;
90
+ this.running = false;
91
+ this.dirty = true;
92
+ this.rendering = false;
93
+ }
94
+
95
+ // ---------------------------------------------------------------- lifecycle
96
+ async start() {
97
+ this.term.enter();
98
+ this.term.on('key', (chunk) => this.parser.feed(chunk));
99
+ this.term.on('resize', () => this.invalidate());
100
+ this.running = true;
101
+
102
+ const interval = setInterval(() => {
103
+ this.state.spinner = (this.state.spinner + 1) % SPINNER.length;
104
+ if (this.state.status === 'running' || this.state.status === 'working') this.invalidate();
105
+ this.maybeRender();
106
+ }, 120);
107
+ const healthTimer = setInterval(() => this.refreshHealth(), 12000);
108
+
109
+ await this.refreshHealth();
110
+ this.loopRender();
111
+ await this.pollLoop();
112
+
113
+ clearInterval(interval);
114
+ clearInterval(healthTimer);
115
+ this.term.exit();
116
+ }
117
+
118
+ stop() {
119
+ this.running = false;
120
+ this.term.exit();
121
+ }
122
+
123
+ invalidate() {
124
+ this.dirty = true;
125
+ }
126
+
127
+ maybeRender() {
128
+ if (this.dirty && !this.rendering) this.render();
129
+ }
130
+
131
+ async loopRender() {
132
+ while (this.running) {
133
+ if (this.dirty) this.render();
134
+ await sleep(33);
135
+ }
136
+ }
137
+
138
+ // ---------------------------------------------------------------- data
139
+ async refreshHealth() {
140
+ const started = Date.now();
141
+ const res = await this.bridge.health();
142
+ if (res.status === 200) {
143
+ this.state.health = res.json;
144
+ this.state.connected = true;
145
+ this.state.latency = Date.now() - started;
146
+ } else {
147
+ this.state.connected = false;
148
+ }
149
+ this.invalidate();
150
+ }
151
+
152
+ push(item) {
153
+ this.state.items.push(item);
154
+ this.state.scroll = 0;
155
+ this.invalidate();
156
+ return item;
157
+ }
158
+
159
+ toast(text, tone = 'info') {
160
+ this.state.toast = { text, tone, until: Date.now() + 3500 };
161
+ this.invalidate();
162
+ setTimeout(() => {
163
+ if (this.state.toast && this.state.toast.until <= Date.now()) {
164
+ this.state.toast = null;
165
+ this.invalidate();
166
+ }
167
+ }, 3600);
168
+ }
169
+
170
+ // ---------------------------------------------------------------- keys
171
+ onKey(key) {
172
+ const st = this.state;
173
+
174
+ if (key.name === 'resize') return;
175
+ if (st.prompt) return this.onPromptKey(key);
176
+ if (st.overlay) return this.onOverlayKey(key);
177
+ if (st.approval) return this.onApprovalKey(key);
178
+
179
+ // leader (ctrl+x, like opencode)
180
+ if (st.leader) {
181
+ st.leader = false;
182
+ this.invalidate();
183
+ return this.onLeaderKey(key);
184
+ }
185
+ if (key.ctrl && key.name === 'x') {
186
+ st.leader = true;
187
+ this.invalidate();
188
+ setTimeout(() => {
189
+ if (st.leader) {
190
+ st.leader = false;
191
+ this.invalidate();
192
+ }
193
+ }, 2000);
194
+ return undefined;
195
+ }
196
+
197
+ if (key.ctrl && key.name === 'p') return this.openPalette();
198
+ if (key.ctrl && key.name === 'c') {
199
+ if (st.status === 'running') return this.stopTurn();
200
+ return this.quit();
201
+ }
202
+ if (key.ctrl && key.name === 'd') return this.quit();
203
+ if (key.ctrl && key.name === 'l') {
204
+ st.items = [];
205
+ st.seqItems.clear();
206
+ this.invalidate();
207
+ return undefined;
208
+ }
209
+ if (key.name === 'escape') {
210
+ if (st.status === 'running') return this.stopTurn();
211
+ if (st.input === '') return this.quit();
212
+ st.input = '';
213
+ st.cursor = 0;
214
+ this.invalidate();
215
+ return undefined;
216
+ }
217
+ if (key.name === 'tab') {
218
+ st.mode = st.mode === 'agent' ? 'plan' : 'agent';
219
+ this.toast(`mode: ${st.mode}`);
220
+ return undefined;
221
+ }
222
+ if (key.name === 'f1') return this.openHelp();
223
+
224
+ // scrolling
225
+ const page = Math.max(1, this.mainHeight() - 2);
226
+ if (key.name === 'pageup' || (key.ctrl && key.name === 'u')) {
227
+ st.scroll += page;
228
+ this.invalidate();
229
+ return undefined;
230
+ }
231
+ if (key.name === 'pagedown' || (key.ctrl && key.name === 'j')) {
232
+ st.scroll = Math.max(0, st.scroll - page);
233
+ this.invalidate();
234
+ return undefined;
235
+ }
236
+ if (key.alt && key.name === 'up') {
237
+ st.scroll += 1;
238
+ this.invalidate();
239
+ return undefined;
240
+ }
241
+ if (key.alt && key.name === 'down') {
242
+ st.scroll = Math.max(0, st.scroll - 1);
243
+ this.invalidate();
244
+ return undefined;
245
+ }
246
+
247
+ return this.editInput(key);
248
+ }
249
+
250
+ onLeaderKey(key) {
251
+ const st = this.state;
252
+ switch (key.name) {
253
+ case 'q':
254
+ return this.quit();
255
+ case 'n':
256
+ st.items = [];
257
+ st.seqItems.clear();
258
+ st.taskId = null;
259
+ st.since = 0;
260
+ this.toast('new session');
261
+ return undefined;
262
+ case 'h':
263
+ return this.openHelp();
264
+ case 'l':
265
+ return this.openTasks();
266
+ case 't': {
267
+ const names = themeNames();
268
+ const next = names[(names.indexOf(this.themeName) + 1) % names.length];
269
+ this.themeName = next;
270
+ this.theme = getTheme(next);
271
+ patchConfig({ theme: next });
272
+ this.toast(`theme: ${next}`);
273
+ return undefined;
274
+ }
275
+ case 'd':
276
+ st.details = !st.details;
277
+ this.toast(`details ${st.details ? 'on' : 'off'}`);
278
+ return undefined;
279
+ case 'b':
280
+ st.sidebar = !st.sidebar;
281
+ this.invalidate();
282
+ return undefined;
283
+ case 's':
284
+ return this.stopTurn();
285
+ case 'p':
286
+ return this.openPalette();
287
+ default:
288
+ this.invalidate();
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ editInput(key) {
294
+ const st = this.state;
295
+ const text = st.input;
296
+
297
+ if (key.name === 'enter') {
298
+ if (key.shift || key.alt) {
299
+ st.input = text.slice(0, st.cursor) + '\n' + text.slice(st.cursor);
300
+ st.cursor += 1;
301
+ this.invalidate();
302
+ return undefined;
303
+ }
304
+ const value = st.input.trim();
305
+ if (!value) return undefined;
306
+ // a bare slash command runs the highlighted item, like opencode's autocomplete
307
+ if (value.startsWith('/')) {
308
+ const list = this.slashList();
309
+ const exact = list.find((c) => c.slash === value.split(' ')[0]);
310
+ const picked = exact || list[Math.min(st.slashIndex || 0, Math.max(0, list.length - 1))];
311
+ if (picked) {
312
+ st.input = '';
313
+ st.cursor = 0;
314
+ st.slashIndex = 0;
315
+ st.history.unshift(value);
316
+ st.historyIndex = -1;
317
+ this.invalidate();
318
+ return picked.run();
319
+ }
320
+ }
321
+ st.history.unshift(value);
322
+ st.historyIndex = -1;
323
+ st.input = '';
324
+ st.cursor = 0;
325
+ this.invalidate();
326
+ return this.submit(value);
327
+ }
328
+ if (key.name === 'backspace') {
329
+ st.slashIndex = 0;
330
+ if (st.cursor > 0) {
331
+ st.input = text.slice(0, st.cursor - 1) + text.slice(st.cursor);
332
+ st.cursor -= 1;
333
+ }
334
+ this.invalidate();
335
+ return undefined;
336
+ }
337
+ if (key.name === 'delete') {
338
+ st.input = text.slice(0, st.cursor) + text.slice(st.cursor + 1);
339
+ this.invalidate();
340
+ return undefined;
341
+ }
342
+ if (key.name === 'left') {
343
+ st.cursor = Math.max(0, st.cursor - 1);
344
+ this.invalidate();
345
+ return undefined;
346
+ }
347
+ if (key.name === 'right') {
348
+ st.cursor = Math.min(st.input.length, st.cursor + 1);
349
+ this.invalidate();
350
+ return undefined;
351
+ }
352
+ if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
353
+ st.cursor = 0;
354
+ this.invalidate();
355
+ return undefined;
356
+ }
357
+ if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
358
+ st.cursor = st.input.length;
359
+ this.invalidate();
360
+ return undefined;
361
+ }
362
+ if (text.startsWith('/') && (key.name === 'up' || key.name === 'down') && !key.alt) {
363
+ const list = this.slashList();
364
+ if (list.length) {
365
+ const dir = key.name === 'up' ? -1 : 1;
366
+ st.slashIndex = ((st.slashIndex || 0) + dir + list.length) % list.length;
367
+ this.invalidate();
368
+ return undefined;
369
+ }
370
+ }
371
+ if (key.name === 'up' && !key.alt) {
372
+ if (st.history.length) {
373
+ st.historyIndex = Math.min(st.history.length - 1, st.historyIndex + 1);
374
+ st.input = st.history[st.historyIndex];
375
+ st.cursor = st.input.length;
376
+ this.invalidate();
377
+ }
378
+ return undefined;
379
+ }
380
+ if (key.name === 'down' && !key.alt) {
381
+ if (st.historyIndex >= 0) {
382
+ st.historyIndex -= 1;
383
+ st.input = st.historyIndex < 0 ? '' : st.history[st.historyIndex];
384
+ st.cursor = st.input.length;
385
+ this.invalidate();
386
+ }
387
+ return undefined;
388
+ }
389
+ if (key.ctrl && key.name === 'w') {
390
+ const before = st.input.slice(0, st.cursor).replace(/\s*\S*$/, '');
391
+ st.input = before + st.input.slice(st.cursor);
392
+ st.cursor = before.length;
393
+ this.invalidate();
394
+ return undefined;
395
+ }
396
+ if (key.ctrl || key.alt) return undefined;
397
+ st.slashIndex = 0;
398
+ if (key.name.length === 1) {
399
+ st.input = st.input.slice(0, st.cursor) + key.name + st.input.slice(st.cursor);
400
+ st.cursor += key.name.length;
401
+ this.invalidate();
402
+ }
403
+ return undefined;
404
+ }
405
+
406
+ // ---------------------------------------------------------------- overlays
407
+ openPalette() {
408
+ this.state.overlay = { type: 'palette', query: '', index: 0 };
409
+ this.invalidate();
410
+ }
411
+
412
+ openHelp() {
413
+ this.state.overlay = { type: 'help' };
414
+ this.invalidate();
415
+ }
416
+
417
+ async openTasks() {
418
+ this.state.overlay = { type: 'tasks', loading: true, tasks: [], index: 0 };
419
+ this.invalidate();
420
+ const res = await this.bridge.listTasks('all');
421
+ if (res.status === 200) {
422
+ this.state.overlay.tasks = (res.json.tasks || []).slice(-50).reverse();
423
+ this.state.overlay.loading = false;
424
+ } else {
425
+ this.state.overlay = null;
426
+ this.toast(`tasks: ${(res.json && res.json.error) || res.status}`, 'error');
427
+ }
428
+ this.invalidate();
429
+ }
430
+
431
+ commands() {
432
+ return [
433
+ { title: 'Stop the running turn', hint: 'esc', run: () => this.stopTurn() },
434
+ { title: 'New session', hint: 'ctrl+x n', run: () => {
435
+ this.state.items = [];
436
+ this.state.seqItems.clear();
437
+ this.state.taskId = null;
438
+ this.state.since = 0;
439
+ this.toast('new session');
440
+ } },
441
+ { title: 'Task queue', hint: 'ctrl+x l', run: () => this.openTasks() },
442
+ { title: 'Toggle detail', hint: 'ctrl+x d', run: () => {
443
+ this.state.details = !this.state.details;
444
+ this.toast(`details ${this.state.details ? 'on' : 'off'}`);
445
+ } },
446
+ { title: 'Toggle sidebar', hint: 'ctrl+x b', run: () => {
447
+ this.state.sidebar = !this.state.sidebar;
448
+ } },
449
+ { title: 'Switch mode (agent / plan)', hint: 'tab', run: () => {
450
+ this.state.mode = this.state.mode === 'agent' ? 'plan' : 'agent';
451
+ this.toast(`mode: ${this.state.mode}`);
452
+ } },
453
+ { title: 'Theme', hint: 'ctrl+x t', run: () => {
454
+ const names = themeNames();
455
+ const next = names[(names.indexOf(this.themeName) + 1) % names.length];
456
+ this.themeName = next;
457
+ this.theme = getTheme(next);
458
+ this.toast(`theme: ${next}`);
459
+ } },
460
+ { title: 'Bridge status (ping)', run: async () => {
461
+ await this.refreshHealth();
462
+ const h = this.state.health;
463
+ if (h) this.push({ kind: 'system', text: `bridge v${h.version} · ${h.executor_mode} · claimable ${h.tasks.claimable}` });
464
+ else this.toast('bridge unreachable', 'error');
465
+ } },
466
+ { title: 'Bridge workspace files', run: () => this.openFiles() },
467
+ { title: 'Help', hint: 'f1', run: () => this.openHelp() },
468
+ { title: 'Quit', hint: 'ctrl+x q', run: () => this.quit() },
469
+ ].map((cmd) => ({ ...cmd, slash: '/' + cmd.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, ''), group: 'Commands' }));
470
+ }
471
+
472
+ /** `/help`, `/new`, `/tasks` … — opencode-style slash commands */
473
+ slashCommands() {
474
+ return [
475
+ { slash: '/new', title: 'Start a new session', hint: 'ctrl+x n', group: 'Session', run: () => this.commands()[1].run() },
476
+ { slash: '/tasks', title: 'Show the task queue', hint: 'ctrl+x l', group: 'Session', run: () => this.openTasks() },
477
+ { slash: '/files', title: 'Browse the workspace files', hint: '', group: 'Session', run: () => this.openFiles() },
478
+ { slash: '/mode', title: 'Switch agent / plan', hint: 'tab', group: 'Session', run: () => this.commands()[5].run() },
479
+ { slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: () => this.commands()[6].run() },
480
+ { slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: () => this.commands()[7].run() },
481
+ { slash: '/help', title: 'Every keybinding', hint: 'f1', group: 'Session', run: () => this.openHelp() },
482
+ { slash: '/quit', title: 'Leave arenacode', hint: 'ctrl+x q', group: 'Session', run: () => this.quit() },
483
+ ];
484
+ }
485
+
486
+ onOverlayKey(key) {
487
+ const ov = this.state.overlay;
488
+ if (key.name === 'escape') {
489
+ this.state.overlay = null;
490
+ this.invalidate();
491
+ return undefined;
492
+ }
493
+ if (ov.type === 'help') return undefined;
494
+
495
+ if (ov.type === 'files') {
496
+ this.state.overlay = null;
497
+ this.invalidate();
498
+ return undefined;
499
+ }
500
+
501
+ if (ov.type === 'tasks') {
502
+ if (key.name === 'up') {
503
+ ov.index = Math.max(0, ov.index - 1);
504
+ } else if (key.name === 'down') {
505
+ ov.index = Math.min(Math.max(0, ov.tasks.length - 1), ov.index + 1);
506
+ } else if (key.name === 'enter' && ov.tasks[ov.index]) {
507
+ const task = ov.tasks[ov.index];
508
+ this.state.overlay = null;
509
+ this.bridge.getTask(task.id).then((res) => {
510
+ if (res.status !== 200) return;
511
+ this.push({ kind: 'system', text: `task ${task.id} · ${res.json.state}` });
512
+ const output = res.json.output ? String(res.json.output).trim() : '';
513
+ if (output) this.push({ kind: 'text', text: output });
514
+ this.invalidate();
515
+ });
516
+ }
517
+ this.invalidate();
518
+ return undefined;
519
+ }
520
+
521
+ if (ov.type === 'palette') {
522
+ const list = this.filteredCommands();
523
+ if (key.name === 'up') {
524
+ ov.index = Math.max(0, ov.index - 1);
525
+ } else if (key.name === 'down') {
526
+ ov.index = Math.min(Math.max(0, list.length - 1), ov.index + 1);
527
+ } else if (key.name === 'enter') {
528
+ const cmd = list[ov.index];
529
+ this.state.overlay = null;
530
+ this.invalidate();
531
+ if (cmd) Promise.resolve(cmd.run()).catch(() => {});
532
+ return undefined;
533
+ } else if (key.name === 'backspace') {
534
+ ov.query = ov.query.slice(0, -1);
535
+ ov.index = 0;
536
+ } else if (!key.ctrl && !key.alt && key.name.length === 1) {
537
+ ov.query += key.name;
538
+ ov.index = 0;
539
+ }
540
+ this.invalidate();
541
+ return undefined;
542
+ }
543
+ return undefined;
544
+ }
545
+
546
+ filteredCommands() {
547
+ const ov = this.state.overlay;
548
+ const all = this.commands();
549
+ if (!ov || !ov.query) return all;
550
+ const q = ov.query.toLowerCase();
551
+ return all.filter((c) => c.title.toLowerCase().includes(q) || (c.category || '').toLowerCase().includes(q));
552
+ }
553
+
554
+ async openFiles() {
555
+ const res = await this.bridge.fs('');
556
+ if (res.status !== 200) {
557
+ this.toast('workspace unavailable', 'error');
558
+ return;
559
+ }
560
+ const listing = res.json;
561
+ this.push({ kind: 'system', text: `bridge workspace ${listing.root}/${listing.path}` });
562
+ for (const entry of listing.entries.slice(0, 20)) {
563
+ this.push({ kind: 'system', text: ` ${entry.type === 'dir' ? '▸' : ' '} ${entry.name}${entry.type === 'dir' ? '/' : ` ${entry.size} B`}` });
564
+ }
565
+ }
566
+
567
+ // ---------------------------------------------------------------- turn
568
+ async submit(text) {
569
+ const st = this.state;
570
+ if (st.status === 'running') {
571
+ this.toast('a turn is running: press esc to stop', 'warn');
572
+ return;
573
+ }
574
+ if (!this.url) {
575
+ this.toast('no bridge URL: run arenacode init', 'error');
576
+ return;
577
+ }
578
+
579
+ // !command runs locally, like opencode's shell mode
580
+ if (text.startsWith('!')) {
581
+ const cmd = text.slice(1).trim();
582
+ this.push({ kind: 'user', text: cmd, shell: true });
583
+ const item = this.push({ kind: 'tool', icon: 'terminal', label: cmd, state: 'running', detail: '' });
584
+ const { output, code } = await runShell(cmd, { cwd: this.folder, shell: undefined, quiet: true });
585
+ item.state = code === 0 ? 'done' : 'failed';
586
+ item.detail = output.trim();
587
+ item.secs = undefined;
588
+ this.invalidate();
589
+ return;
590
+ }
591
+
592
+ let instruction = text;
593
+ const attachments = [];
594
+ const refs = [...text.matchAll(/@([^\s]+)/g)].map((m) => m[1]);
595
+ for (const ref of refs) {
596
+ const abs = path.resolve(this.folder, ref);
597
+ if (!fs.existsSync(abs)) continue;
598
+ const name = `attachments/${Date.now()}-${path.basename(abs)}`;
599
+ const up = await this.bridge.upload(name, fs.readFileSync(abs));
600
+ if (up.status === 200) attachments.push(up.json.path);
601
+ }
602
+ if (attachments.length) {
603
+ instruction += `\n\n[attachments in the workspace: ${attachments.join(', ')}]`;
604
+ }
605
+
606
+ this.push({ kind: 'user', text: text.replace(/@([^\s]+)/g, '$1') });
607
+
608
+ const meta = buildMeta(this.folder, { lang: this.lang, pcAccess: this.cfg.pc_access !== false });
609
+ const projectContext = readProjectContext(this.folder);
610
+ const payload = {
611
+ instruction: `${MODE_LINES[st.mode]}\n${instruction}`,
612
+ wait: 0,
613
+ meta,
614
+ agent_prompt: AGENT_PROMPT,
615
+ };
616
+ if (projectContext) payload.project_context = projectContext;
617
+
618
+ st.status = 'running';
619
+ this.invalidate();
620
+ const res = await this.bridge.createTask(payload);
621
+ if (res.status !== 202 && res.status !== 200) {
622
+ st.status = 'idle';
623
+ this.push({ kind: 'error', text: (res.json && (res.json.error || res.json.hint)) || `HTTP ${res.status}` });
624
+ return;
625
+ }
626
+ st.taskId = res.json.task_id;
627
+ st.since = 0;
628
+ st.seqItems.clear();
629
+ }
630
+
631
+ async stopTurn() {
632
+ const st = this.state;
633
+ if (!st.taskId || st.status !== 'running') {
634
+ this.toast('nothing to stop');
635
+ return;
636
+ }
637
+ await this.bridge.cancel(st.taskId);
638
+ this.push({ kind: 'system', text: 'stop requested' });
639
+ st.status = 'idle';
640
+ this.invalidate();
641
+ }
642
+
643
+ async pollLoop() {
644
+ while (this.running) {
645
+ const st = this.state;
646
+ if (!st.taskId || st.status !== 'running') {
647
+ await sleep(200);
648
+ continue;
649
+ }
650
+ const res = await this.bridge.events(st.taskId, st.since, 25);
651
+ if (!this.running) break;
652
+ if (res.status === 0 || res.status >= 500) {
653
+ await sleep(2000);
654
+ continue;
655
+ }
656
+ if (res.status !== 200 || !res.json) {
657
+ st.status = 'idle';
658
+ this.push({ kind: 'error', text: `events: HTTP ${res.status}` });
659
+ continue;
660
+ }
661
+ const { events = [], next, done, state } = res.json;
662
+ for (const ev of events) this.handleEvent(ev);
663
+ if (typeof next === 'number') st.since = next;
664
+ if (done) {
665
+ st.status = 'idle';
666
+ if (state === 'cancelled') this.push({ kind: 'done', state: 'cancelled' });
667
+ this.invalidate();
668
+ }
669
+ }
670
+ }
671
+
672
+ handleEvent(ev) {
673
+ const st = this.state;
674
+ const key = `${ev.seq}:${ev.kind}`;
675
+
676
+ switch (ev.kind) {
677
+ case 'thought': {
678
+ const existing = st.seqItems.get(`t${ev.seq}`);
679
+ if (existing) {
680
+ existing.label = ev.label || existing.label;
681
+ } else {
682
+ const item = this.push({ kind: 'thought', label: ev.label || '…' });
683
+ st.seqItems.set(`t${ev.seq}`, item);
684
+ }
685
+ break;
686
+ }
687
+ case 'tool': {
688
+ let item = st.seqItems.get(`x${ev.seq}`);
689
+ if (!item) {
690
+ item = this.push({ kind: 'tool', icon: ev.icon || 'bolt', label: ev.label || ev.tool || 'step', detail: ev.detail || '', state: ev.state || 'running', secs: ev.secs });
691
+ st.seqItems.set(`x${ev.seq}`, item);
692
+ } else {
693
+ item.label = ev.label || item.label;
694
+ item.detail = ev.detail !== undefined ? ev.detail : item.detail;
695
+ item.state = ev.state || item.state;
696
+ item.secs = ev.secs !== undefined ? ev.secs : item.secs;
697
+ }
698
+ if (ev.local_exec) this.registerLocal('exec', ev.local_exec, item);
699
+ if (ev.local_fs) this.registerLocal('fs', ev.local_fs, item);
700
+ if (ev.local_ui) this.registerLocal('ui', ev.local_ui, item);
701
+ if (ev.local_file) this.registerLocal('file', ev.local_file, item);
702
+ break;
703
+ }
704
+ case 'text': {
705
+ let item = st.seqItems.get(`s${ev.seq}`);
706
+ const delta = String(ev.delta || '').replace(/\\n/g, '\n');
707
+ if (!item) {
708
+ item = this.push({ kind: 'text', text: delta });
709
+ st.seqItems.set(`s${ev.seq}`, item);
710
+ } else {
711
+ item.text += delta;
712
+ }
713
+ break;
714
+ }
715
+ case 'status':
716
+ if (ev.label) this.push({ kind: 'status', label: ev.label });
717
+ break;
718
+ case 'vision':
719
+ this.push({ kind: 'system', text: `vision: ${ev.vision === true ? 'yes' : 'no'}` });
720
+ break;
721
+ case 'user':
722
+ this.push({ kind: 'system', text: `${ev.label || 'user event'} ${ev.detail || ''}` });
723
+ break;
724
+ case 'error':
725
+ this.push({ kind: 'error', text: ev.label || ev.error || 'error' });
726
+ break;
727
+ case 'done': {
728
+ const hasText = st.items.some((i) => i.kind === 'text');
729
+ if (!hasText && ev.output) this.push({ kind: 'text', text: String(ev.output) });
730
+ this.push({ kind: 'done', state: ev.state || 'done', secs: ev.secs });
731
+ st.status = 'idle';
732
+ break;
733
+ }
734
+ default:
735
+ if (ev.label) this.push({ kind: 'status', label: ev.label });
736
+ }
737
+ this.invalidate();
738
+ }
739
+
740
+ // ---------------------------------------------------------------- local cards
741
+ registerLocal(type, payload, item) {
742
+ if (item && type === 'exec' && payload && payload.command) item.command = payload.command;
743
+ if (!payload || !payload.rid) return;
744
+ if (this.localQueue.some((q) => q.payload.rid === payload.rid)) return;
745
+ this.localQueue.push({ type, payload, item });
746
+ this.processQueue();
747
+ }
748
+
749
+ async processQueue() {
750
+ if (this.localBusy) return;
751
+ this.localBusy = true;
752
+ while (this.localQueue.length) {
753
+ const job = this.localQueue.shift();
754
+ try {
755
+ await this.runLocal(job);
756
+ } catch (e) {
757
+ job.item.state = 'failed';
758
+ job.item.detail = String(e.message || e);
759
+ }
760
+ this.invalidate();
761
+ }
762
+ this.localBusy = false;
763
+ }
764
+
765
+ async runLocal(job) {
766
+ const { type, payload, item } = job;
767
+ const st = this.state;
768
+ const taskId = st.taskId;
769
+
770
+ if (type === 'file') {
771
+ const remote = String(payload.remote || '');
772
+ const dest = String(payload.dest || payload.name || path.basename(remote));
773
+ const res = await this.bridge.fileRaw(remote);
774
+ if (res.status === 200) {
775
+ const target = resolveInRoot(this.folder, dest);
776
+ fs.mkdirSync(path.dirname(target), { recursive: true });
777
+ fs.writeFileSync(target, res.buffer);
778
+ item.state = 'done';
779
+ item.detail = `${remote} → ${dest} (${res.buffer.length} B)`;
780
+ } else {
781
+ item.state = 'failed';
782
+ item.detail = `download failed: ${remote}`;
783
+ }
784
+ return;
785
+ }
786
+
787
+ if (type === 'exec') {
788
+ item.kindDetail = payload.command;
789
+ const answer = this.autoApprove
790
+ ? 'y'
791
+ : await this.confirm({
792
+ title: 'terminal',
793
+ subject: payload.command,
794
+ note: payload.admin ? 'needs administrator rights (runs without UAC here)' : payload.sandbox_reason || '',
795
+ options: [
796
+ { key: 'y', label: 'run', tone: 'ok' },
797
+ { key: 'n', label: "don't run", tone: 'err' },
798
+ ],
799
+ });
800
+ if (answer !== 'y') {
801
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: "[ArenaCode] Command not run: rejected by the user", code: -1 });
802
+ item.state = 'failed';
803
+ item.detail = 'rejected by the user';
804
+ return;
805
+ }
806
+ item.state = 'running';
807
+ this.invalidate();
808
+ const { output, code } = await runShell(payload.command, {
809
+ cwd: payload.cwd ? resolveInRoot(this.folder, payload.cwd) : this.folder,
810
+ shell: payload.shell,
811
+ quiet: true,
812
+ });
813
+ await this.bridge.localResult(taskId, { rid: payload.rid, output, code });
814
+ item.state = code === 0 ? 'done' : 'failed';
815
+ item.detail = output.trim();
816
+ item.meta = `exit ${code}`;
817
+ return;
818
+ }
819
+
820
+ if (type === 'fs') {
821
+ const op = String(payload.op || '');
822
+ const mutating = ['write', 'replace', 'rm', 'mv', 'mkdir'].includes(op);
823
+ if (mutating && this.confirmWrites) {
824
+ const approved = (await this.confirm({
825
+ title: `file · ${op}`,
826
+ subject: payload.path || '',
827
+ options: [
828
+ { key: 'y', label: 'apply', tone: 'ok' },
829
+ { key: 'n', label: 'skip', tone: 'err' },
830
+ ],
831
+ })) === 'y';
832
+ if (!approved) {
833
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify({ ok: false, error: 'rejected by the user' }), code: 1 });
834
+ item.state = 'failed';
835
+ item.detail = 'rejected';
836
+ return;
837
+ }
838
+ }
839
+ item.state = 'running';
840
+ this.invalidate();
841
+ let result;
842
+ try {
843
+ result = runFsOp(op, payload, this.folder);
844
+ } catch (e) {
845
+ result = { ok: false, error: e.message };
846
+ }
847
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify(result), code: result.ok ? 0 : 1 });
848
+ item.state = result.ok ? 'done' : 'failed';
849
+ item.meta = result.ok ? (result.size ? `${result.size} B` : op) : result.error;
850
+ if (op === 'read' && result.ok && !result.binary) {
851
+ const preview = String(result.content || '').split('\n').slice(0, 6).join('\n');
852
+ item.detail = preview;
853
+ }
854
+ return;
855
+ }
856
+
857
+ if (type === 'ui') {
858
+ const op = String(payload.op || '');
859
+ if (op === 'plan') {
860
+ const answer = await this.confirm({
861
+ title: 'plan',
862
+ subject: payload.text || '',
863
+ multi: true,
864
+ options: [
865
+ { key: 'a', label: 'approve', tone: 'ok' },
866
+ { key: 'r', label: 'reject', tone: 'err' },
867
+ { key: 'f', label: 'revise', tone: 'warn' },
868
+ ],
869
+ });
870
+ let payloadOut;
871
+ if (answer === 'a') payloadOut = { ok: true, approved: true };
872
+ else if (answer === 'f') {
873
+ const feedback = await this.prompt('how should it be revised?');
874
+ payloadOut = { ok: false, approved: false, revise: true, feedback: feedback || '' };
875
+ } else payloadOut = { ok: false, approved: false };
876
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify(payloadOut), code: answer === 'a' ? 0 : 1 });
877
+ item.state = answer === 'a' ? 'done' : 'failed';
878
+ return;
879
+ }
880
+ if (op === 'wait') {
881
+ let duration = 1;
882
+ try {
883
+ duration = JSON.parse(payload.args_json || '{}').duration ?? 1;
884
+ } catch {
885
+ duration = 1;
886
+ }
887
+ await sleep(Math.min(60, Math.max(0, duration)) * 1000);
888
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify({ ok: true }), code: 0 });
889
+ item.state = 'done';
890
+ return;
891
+ }
892
+ if (op === 'sysinfo') {
893
+ const info = {
894
+ ok: true,
895
+ os: `${process.platform} ${os.release()}`,
896
+ hostname: os.hostname(),
897
+ arch: os.arch(),
898
+ cpus: os.cpus().length,
899
+ memory_mb: Math.round(os.totalmem() / 1048576),
900
+ node: process.version,
901
+ };
902
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify(info), code: 0 });
903
+ item.state = 'done';
904
+ return;
905
+ }
906
+ const error = `operation "${op}" is not available in the terminal client: the ArenaCode desktop client is required`;
907
+ await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify({ ok: false, error }), code: 1 });
908
+ item.state = 'failed';
909
+ item.meta = 'needs the desktop client';
910
+ }
911
+ }
912
+
913
+ /** Modal confirmation. Resolves with the pressed key (or the chosen key). */
914
+ confirm({ title, subject, note = '', options, multi = false }) {
915
+ return new Promise((resolve) => {
916
+ this.state.approval = { title, subject, note, options, resolve, index: 0 };
917
+ this.invalidate();
918
+ });
919
+ }
920
+
921
+ prompt(question) {
922
+ return new Promise((resolve) => {
923
+ this.state.prompt = { question, value: '', resolve };
924
+ this.invalidate();
925
+ });
926
+ }
927
+
928
+ onPromptKey(key) {
929
+ const p = this.state.prompt;
930
+ if (!p) return undefined;
931
+ if (key.name === 'escape') {
932
+ this.state.prompt = null;
933
+ this.invalidate();
934
+ p.resolve('');
935
+ return undefined;
936
+ }
937
+ if (key.name === 'enter') {
938
+ const value = p.value;
939
+ this.state.prompt = null;
940
+ this.invalidate();
941
+ p.resolve(value);
942
+ return undefined;
943
+ }
944
+ if (key.name === 'backspace') p.value = p.value.slice(0, -1);
945
+ else if (!key.ctrl && !key.alt && key.name.length === 1) p.value += key.name;
946
+ this.invalidate();
947
+ return undefined;
948
+ }
949
+
950
+ workspaceMeta() {
951
+ if (!this._wsMeta || Date.now() - this._wsMetaAt > 20000) {
952
+ try {
953
+ this._wsMeta = buildMeta(this.folder, { lang: this.lang, pcAccess: true });
954
+ this._wsMetaAt = Date.now();
955
+ } catch {
956
+ this._wsMeta = null;
957
+ }
958
+ }
959
+ return this._wsMeta;
960
+ }
961
+
962
+ onApprovalKey(key) {
963
+ const ap = this.state.approval;
964
+ if (!ap) return undefined;
965
+ if (key.name === 'down' || key.name === 'tab') {
966
+ ap.index = ((ap.index || 0) + 1) % ap.options.length;
967
+ this.invalidate();
968
+ return undefined;
969
+ }
970
+ if (key.name === 'up') {
971
+ ap.index = ((ap.index || 0) - 1 + ap.options.length) % ap.options.length;
972
+ this.invalidate();
973
+ return undefined;
974
+ }
975
+ if (key.name === 'enter') {
976
+ const hit = ap.options[ap.index || 0] || ap.options[0];
977
+ this.state.approval = null;
978
+ this.invalidate();
979
+ ap.resolve(hit.key);
980
+ return undefined;
981
+ }
982
+ if (key.name === 'escape') {
983
+ this.state.approval = null;
984
+ this.invalidate();
985
+ ap.resolve(ap.options[ap.options.length - 1].key);
986
+ return undefined;
987
+ }
988
+ const hit = ap.options.find((o) => o.key === key.name || o.key === key.name.toLowerCase());
989
+ if (!hit) return undefined;
990
+ this.state.approval = null;
991
+ this.invalidate();
992
+ ap.resolve(hit.key);
993
+ return undefined;
994
+ }
995
+
996
+ // ---------------------------------------------------------------- layout (opencode look)
997
+
998
+ mainHeight() {
999
+ return Math.max(4, this.term.height - 10);
1000
+ }
1001
+
1002
+ /** no messages yet → the home screen (logo + centered prompt) */
1003
+ isHome() {
1004
+ return this.state.items.length === 0;
1005
+ }
1006
+
1007
+ shortFolder() {
1008
+ const home = os.homedir();
1009
+ const folder = this.folder.startsWith(home) ? `~${this.folder.slice(home.length)}` : this.folder;
1010
+ const meta = this.workspaceMeta();
1011
+ const branch = meta && meta.git && meta.git.branch ? `:${meta.git.branch}` : '';
1012
+ return folder + branch;
1013
+ }
1014
+
1015
+ agentName() {
1016
+ return this.state.mode === 'plan' ? 'Plan' : 'Build';
1017
+ }
1018
+
1019
+ agentColor() {
1020
+ // opencode gives each agent its own colour; Build is the secondary blue, Plan the accent violet
1021
+ return this.state.mode === 'plan' ? this.theme.c.accent : this.theme.c.secondary;
1022
+ }
1023
+
1024
+ promptWidth(W) {
1025
+ if (!this.isHome()) return Math.max(20, W - 4);
1026
+ return Math.max(20, Math.min(75, W - 4));
1027
+ }
1028
+
1029
+ /** the prompt box, centered on the home screen and full width in a session */
1030
+ promptBlock(W) {
1031
+ const t = this.theme;
1032
+ const st = this.state;
1033
+ const width = this.promptWidth(W);
1034
+ const busy = st.status === 'running';
1035
+ const hints = busy
1036
+ ? []
1037
+ : [
1038
+ ['tab', st.mode === 'plan' ? 'plan' : 'agents'],
1039
+ ['ctrl+p', 'commands'],
1040
+ ];
1041
+ const block = W2.promptBlock(t, {
1042
+ width,
1043
+ input: st.input,
1044
+ cursor: st.cursor,
1045
+ placeholder: this.placeholder(),
1046
+ agent: this.agentName(),
1047
+ agentColor: this.agentColor(),
1048
+ model: this.modelName(),
1049
+ provider: this.providerLabel(),
1050
+ hints,
1051
+ busy,
1052
+ statusText: this.statusText(),
1053
+ frame: st.spinner,
1054
+ multi: st.attachments && st.attachments.length ? `+${st.attachments.length} file${st.attachments.length > 1 ? 's' : ''}` : '',
1055
+ });
1056
+ const pad = this.isHome() ? Math.max(0, Math.floor((W - width) / 2)) : 2;
1057
+ const lines = block.lines.map((line) => R.spaces(pad) + line);
1058
+ const cursor = block.cursor ? { row: block.cursor.row, col: block.cursor.col + pad } : null;
1059
+ return { lines, cursor, width, pad };
1060
+ }
1061
+
1062
+ placeholder() {
1063
+ const list = [
1064
+ 'Read package.json and add a lint script',
1065
+ 'Summarise what this project does',
1066
+ 'Fix the failing test',
1067
+ 'List the files in this folder',
1068
+ ];
1069
+ const idx = Math.floor((Date.now() - this.state.startedAt) / 9000) % list.length;
1070
+ return `Ask anything… "${list[idx]}"`;
1071
+ }
1072
+
1073
+ modelName() {
1074
+ return (this.state.health && (this.state.health.model || this.state.health.executor)) || 'arenacode';
1075
+ }
1076
+
1077
+ providerLabel() {
1078
+ const v = (this.state.health && this.state.health.version) || VERSION;
1079
+ return `bridge ${v}`;
1080
+ }
1081
+
1082
+ statusText() {
1083
+ const st = this.state;
1084
+ if (st.status !== 'running') return '';
1085
+ const last = [...st.items].reverse().find((i) => i.kind === 'tool' || i.kind === 'thought' || i.kind === 'text');
1086
+ if (!last) return 'Working…';
1087
+ if (last.kind === 'tool') return last.label || 'Working…';
1088
+ if (last.kind === 'thought') return last.label && last.label !== '…' ? `Thinking: ${last.label}` : 'Thinking…';
1089
+ return 'Responding…';
1090
+ }
1091
+
1092
+ render() {
1093
+ this.rendering = true;
1094
+ this.dirty = false;
1095
+ const t = this.theme;
1096
+ const W = this.term.width;
1097
+ const H = this.term.height;
1098
+ const st = this.state;
1099
+ const prompt = this.promptBlock(W);
1100
+ const taskTag = st.taskId ? `${st.taskId.slice(-6)} · ` : '';
1101
+ const footer = W2.footerLine(t, { width: W, left: this.shortFolder(), right: `${taskTag}v${VERSION}` });
1102
+
1103
+ let frame = [];
1104
+ let cursor = null;
1105
+
1106
+ if (this.isHome()) {
1107
+ const logo = W2.centerLines(logoLines(t), W);
1108
+ const notify = this.notifyLine(W);
1109
+ const blockH = logo.length + 1 + prompt.lines.length + 2 + 1;
1110
+ const top = Math.max(1, Math.floor((H - 2 - blockH) / 2));
1111
+ for (let i = 0; i < top; i++) frame.push('');
1112
+ frame.push(...logo);
1113
+ frame.push('');
1114
+ if (notify) frame.push(notify);
1115
+ const promptStart = frame.length;
1116
+ this._promptTop = promptStart;
1117
+ frame.push(...prompt.lines);
1118
+ frame.push('');
1119
+ frame.push(this.tipLine(W));
1120
+ while (frame.length < H - 2) frame.push('');
1121
+ frame.push('');
1122
+ frame.push(footer);
1123
+ if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1124
+ } else {
1125
+ const chat = W2
1126
+ .chatLines(t, st.items, {
1127
+ width: W - 4,
1128
+ frame: st.spinner,
1129
+ agent: this.agentName(),
1130
+ model: this.modelName(),
1131
+ details: st.details ? 'full' : false,
1132
+ })
1133
+ .map((line) => R.spaces(2) + line);
1134
+
1135
+ const bodyH = Math.max(1, H - prompt.lines.length - 2);
1136
+ const maxScroll = Math.max(0, chat.length - bodyH);
1137
+ st.scroll = Math.min(Math.max(0, st.scroll), maxScroll);
1138
+ const start = Math.max(0, chat.length - bodyH - st.scroll);
1139
+ const visible = chat.slice(start, start + bodyH);
1140
+ while (visible.length < bodyH) visible.unshift('');
1141
+ frame.push(...visible);
1142
+ const promptStart = frame.length;
1143
+ frame.push(...prompt.lines);
1144
+ frame.push('');
1145
+ frame.push(footer);
1146
+ this._promptTop = promptStart;
1147
+ if (prompt.cursor) cursor = { row: promptStart + prompt.cursor.row, col: prompt.cursor.col };
1148
+ }
1149
+
1150
+ while (frame.length < H) frame.push('');
1151
+ frame = frame.slice(0, H).map((line) => R.vpad(R.vclip(line, W), W));
1152
+ if (cursor && (cursor.row < 0 || cursor.row >= H)) cursor = null;
1153
+
1154
+ if (st.input.startsWith('/') && !st.overlay && !st.approval && !st.prompt) {
1155
+ const list = this.slashList();
1156
+ if (list.length) frame = this.overlaySlash(frame, W, H, list);
1157
+ }
1158
+ if (st.overlay) frame = this.renderOverlay(frame, W, H);
1159
+ if (st.approval) frame = this.renderApproval(frame, W, H);
1160
+ if (st.prompt) frame = this.renderPrompt(frame, W, H);
1161
+ if (st.toast) frame = this.renderToast(frame, W, H);
1162
+
1163
+ frame = frame.map((line) => R.vpad(R.vclip(line, W), W));
1164
+ this.term.draw(frame);
1165
+
1166
+ if (!cursor || st.overlay || st.approval || st.prompt) this.term.hideCursor();
1167
+ else this.term.placeCursor(cursor.row, Math.min(W - 1, Math.max(0, cursor.col)));
1168
+
1169
+ this.rendering = false;
1170
+ if (this.dirty) setTimeout(() => this.maybeRender(), 16);
1171
+ }
1172
+
1173
+ notifyLine(W) {
1174
+ const t = this.theme;
1175
+ const st = this.state;
1176
+ if (!st.connected) {
1177
+ return W2.notifyLine(t, { width: W, command: '/connect', text: ` bridge offline at ${this.url}` });
1178
+ }
1179
+ return '';
1180
+ }
1181
+
1182
+ tipLine(W) {
1183
+ const t = this.theme;
1184
+ const tips = [
1185
+ ['ctrl+p', ' for every command'],
1186
+ ['/tasks', ' to see the queue'],
1187
+ ['tab', ' to switch between agents'],
1188
+ ['@file', ' to attach a file to the message'],
1189
+ ['!cmd', ' to run something locally'],
1190
+ ];
1191
+ const tip = tips[Math.floor((Date.now() - this.state.startedAt) / 7000) % tips.length];
1192
+ const width = W;
1193
+ const body = `${t.warning('● Tip ')}${t.muted(tip[0].startsWith('/') || tip[0].startsWith('@') || tip[0].startsWith('!') ? 'Run ' : 'Press ')}${t.fg(tip[0])}${t.muted(tip[1])}`;
1194
+ const left = Math.max(0, Math.floor((width - R.vlen(body)) / 2));
1195
+ return R.spaces(left) + body;
1196
+ }
1197
+
1198
+ slashList() {
1199
+ const q = this.state.input.slice(1).toLowerCase().split(' ')[0];
1200
+ const all = [...this.slashCommands(), ...this.commands()];
1201
+ return all.filter((c) => !q || c.slash.replace('/', '').startsWith(q)).slice(0, 7);
1202
+ }
1203
+
1204
+ overlaySlash(frame, W, H, list) {
1205
+ const t = this.theme;
1206
+ const { bgSeq } = require('./theme');
1207
+ const width = this.promptWidth(W);
1208
+ const pad = this.isHome() ? Math.max(0, Math.floor((W - width) / 2)) : 2;
1209
+ const lines = list.map((cmd, i) => {
1210
+ const active = i === (this.state.slashIndex || 0);
1211
+ const bg = active ? bgSeq(t.c.backgroundElement) : bgSeq(t.c.backgroundPanel);
1212
+ const label = `${active ? '\x1b[1m' : ''}${fgSeqOf(t, active ? t.c.text : t.c.textMuted)}${cmd.slash}${RESET_OF()}${t.muted(' ' + cmd.title)}`;
1213
+ const hint = t.muted(cmd.hint || '');
1214
+ const body = R.spaces(1) + label + R.spaces(Math.max(1, width - 3 - R.vlen(label) - R.vlen(hint))) + hint + R.spaces(1);
1215
+ return R.spaces(pad) + W2.bgWrap(bg, body, width);
1216
+ });
1217
+ const y = Math.max(0, (this._promptTop || H - 8) - lines.length - 1);
1218
+ return R.overlay(frame, lines, 0, y);
1219
+ }
1220
+
1221
+ renderOverlay(frame, W, H) {
1222
+ const ov = this.state.overlay;
1223
+ const t = this.theme;
1224
+ let dialog;
1225
+
1226
+ if (ov.type === 'palette') {
1227
+ const list = this.filteredCommands();
1228
+ const items = [];
1229
+ let lastGroup = null;
1230
+ for (const cmd of list.slice(0, Math.max(4, H - 12))) {
1231
+ const group = cmd.group || 'Commands';
1232
+ if (group !== lastGroup) {
1233
+ items.push({ group });
1234
+ lastGroup = group;
1235
+ }
1236
+ items.push({ label: cmd.title, hint: cmd.hint || '', grouped: true });
1237
+ }
1238
+ dialog = D.listDialog(t, {
1239
+ W,
1240
+ title: 'Commands',
1241
+ filter: ov.query || '',
1242
+ items,
1243
+ index: ov.index,
1244
+ footer: [
1245
+ ['enter', 'run'],
1246
+ ['esc', 'close'],
1247
+ ],
1248
+ });
1249
+ } else if (ov.type === 'help') {
1250
+ const rows = HELP_ROWS();
1251
+ const items = [];
1252
+ let lastGroup = null;
1253
+ for (const [group, key, label] of rows) {
1254
+ if (group !== lastGroup) {
1255
+ items.push({ group });
1256
+ lastGroup = group;
1257
+ }
1258
+ items.push({ label, hint: key, group: '' });
1259
+ }
1260
+ dialog = D.listDialog(t, { W, size: 'large', title: 'Help', filter: '', items, index: -1, footer: [['esc', 'close']] });
1261
+ } else {
1262
+ const items = [{ group: 'Queue' }];
1263
+ if (ov.loading) items.push({ label: 'loading…', dim: true });
1264
+ else if (!ov.tasks.length) items.push({ label: 'no tasks', dim: true });
1265
+ else for (const task of ov.tasks.slice(0, Math.max(4, H - 12))) items.push({ label: `${task.state} ${R.vclip(task.id, 22)} ${R.vclip(String(task.instruction).split('\n').slice(-1)[0], 40)}`, grouped: true });
1266
+ dialog = D.listDialog(t, { W, size: 'large', title: 'Sessions', filter: '', items, index: -1, footer: [['esc', 'close']] });
1267
+ }
1268
+
1269
+ const y = D.dialogY(H, dialog.lines.length);
1270
+ return R.overlayDim(frame, dialog.lines, dialog.x, y);
1271
+ }
1272
+
1273
+ renderApproval(frame, W, H) {
1274
+ const ap = this.state.approval;
1275
+ const t = this.theme;
1276
+ const labels = { y: 'Run once', a: 'Approve', n: "Don't run", r: 'Reject', f: 'Revise' };
1277
+ const options = ap.options.map((o) => ({ label: labels[o.key] || o.label, key: o.key, hint: o.key }));
1278
+ const dialog = D.confirmDialog(t, {
1279
+ W,
1280
+ title: titleCase(ap.title || 'Permission'),
1281
+ subject: ap.subject || '',
1282
+ note: ap.note || '',
1283
+ options,
1284
+ index: Math.max(0, ap.index || 0),
1285
+ footer: [
1286
+ ['↑↓', 'select'],
1287
+ ['enter', 'confirm'],
1288
+ ['esc', 'cancel'],
1289
+ ],
1290
+ });
1291
+ const y = D.dialogY(H, dialog.lines.length);
1292
+ return R.overlayDim(frame, dialog.lines, dialog.x, y);
1293
+ }
1294
+
1295
+ renderPrompt(frame, W, H) {
1296
+ const t = this.theme;
1297
+ const p = this.state.prompt;
1298
+ const width = Math.min(60, W - 8);
1299
+ const inside = `${t.muted(p.question)} ${t.fg(p.value)}${t.inverse(' ')}`;
1300
+ const lines = [
1301
+ W2.bgWrap(require('./theme').bgSeq(t.c.backgroundPanel), ' ' + inside, width - 4),
1302
+ ];
1303
+ const block = [
1304
+ W2.bgWrap(require('./theme').bgSeq(t.c.backgroundPanel), ' ' + t.bold('Revise') + R.spaces(Math.max(1, width - 12 - R.vlen(p.question))) + t.muted('enter'), width),
1305
+ W2.bgWrap(require('./theme').bgSeq(t.c.backgroundPanel), '', width),
1306
+ ...lines,
1307
+ W2.bgWrap(require('./theme').bgSeq(t.c.backgroundPanel), '', width),
1308
+ ];
1309
+ const x = Math.max(0, Math.floor((W - width) / 2));
1310
+ const y = D.dialogY(H, block.length);
1311
+ return R.overlay(frame, block, x, y);
1312
+ }
1313
+
1314
+ renderToast(frame, W, H) {
1315
+ const t = this.theme;
1316
+ const toast = this.state.toast;
1317
+ const tone = toast.tone === 'error' ? t.err : toast.tone === 'warn' ? t.warn : t.primary;
1318
+ const text = ` ${tone(toast.text)} `;
1319
+ const x = Math.max(0, Math.floor((W - R.vlen(text)) / 2));
1320
+ return R.overlay(frame, [text], x, H - 4);
1321
+ }
1322
+
1323
+ quit() {
1324
+ this.running = false;
1325
+ this.term.exit();
1326
+ process.exit(0);
1327
+ }
1328
+ }
1329
+
1330
+ function HELP_ROWS() {
1331
+ return [
1332
+ ['Prompt', 'enter', 'send the message'],
1333
+ ['Prompt', 'shift+enter', 'newline'],
1334
+ ['Prompt', 'esc', 'interrupt the turn'],
1335
+ ['Prompt', 'tab', 'switch agent / plan'],
1336
+ ['Prompt', 'ctrl+p', 'command palette'],
1337
+ ['Prompt', '/', 'slash commands'],
1338
+ ['Prompt', '@', 'attach a file from the folder'],
1339
+ ['Prompt', '!', 'run a command locally'],
1340
+ ['Prompt', 'up / down', 'history'],
1341
+ ['Session', 'ctrl+x n', 'new session'],
1342
+ ['Session', 'ctrl+x l', 'task queue'],
1343
+ ['Session', 'ctrl+x t', 'switch theme'],
1344
+ ['Session', 'ctrl+x d', 'toggle tool output'],
1345
+ ['Session', 'ctrl+x b', 'toggle the sidebar'],
1346
+ ['Session', 'pgup / pgdn', 'scroll'],
1347
+ ['Session', 'ctrl+l', 'clear the timeline'],
1348
+ ['Session', 'ctrl+c', 'interrupt, then quit'],
1349
+ ['Session', 'ctrl+x q', 'quit'],
1350
+ ];
1351
+ }
1352
+
1353
+ function fgSeqOf(t, color) {
1354
+ return require('./theme').fgSeq(color);
1355
+ }
1356
+
1357
+ function RESET_OF() {
1358
+ return require('./theme').RESET;
1359
+ }
1360
+
1361
+ function titleCase(text) {
1362
+ return String(text)
1363
+ .split(' ')
1364
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
1365
+ .join(' ');
1366
+ }
1367
+
1368
+ module.exports = { App, MODE_LINES, HELP_ROWS, titleCase };