ninja-terminals 2.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/public/app.js ADDED
@@ -0,0 +1,860 @@
1
+ /* Ninja Terminals v2 — Frontend */
2
+
3
+ const WS_BASE = `ws://${location.host}`;
4
+ const API_BASE = '';
5
+
6
+ // ── State ────────────────────────────────────────────────────
7
+
8
+ const state = {
9
+ terminals: new Map(), // id -> { id, label, status, progress, elapsed, term, ws, fitAddon, paneEl, ... }
10
+ maximizedId: null,
11
+ activeId: null,
12
+ terminalIndex: new Map(), // id -> index (0-3) for feed coloring
13
+ nextIndex: 0,
14
+ };
15
+
16
+ // ── DOM Refs ─────────────────────────────────────────────────
17
+
18
+ const grid = document.getElementById('grid');
19
+ const sidebar = document.getElementById('sidebar');
20
+ const activityFeed = document.getElementById('activity-feed');
21
+ const taskQueue = document.getElementById('task-queue');
22
+ const statusCounts = document.getElementById('status-counts');
23
+ const statusProgress = document.getElementById('status-progress');
24
+ const addTaskBtn = document.getElementById('add-task-btn');
25
+ const sidebarToggle = document.getElementById('sidebar-toggle');
26
+
27
+ // ── State Color Map ──────────────────────────────────────────
28
+
29
+ const STATE_ICONS = {
30
+ idle: '\u25CB', // circle outline
31
+ working: '\u25B6', // play triangle
32
+ done: '\u2713', // checkmark
33
+ blocked: '\u23F8', // pause
34
+ error: '\u2717', // X
35
+ compacting: '\u21BB', // rotating arrows
36
+ waiting_approval: '\u26A0', // warning
37
+ starting: '\u25CB',
38
+ exited: '\u2717',
39
+ };
40
+
41
+ const STATE_LABELS = {
42
+ idle: 'IDLE',
43
+ working: 'WORKING',
44
+ done: 'DONE',
45
+ blocked: 'BLOCKED',
46
+ error: 'ERROR',
47
+ compacting: 'COMPACTING',
48
+ waiting_approval: 'APPROVAL',
49
+ starting: 'STARTING',
50
+ exited: 'EXITED',
51
+ };
52
+
53
+ // ── Utilities ────────────────────────────────────────────────
54
+
55
+ function timestamp() {
56
+ const d = new Date();
57
+ return d.toTimeString().slice(0, 8);
58
+ }
59
+
60
+ function getTerminalFeedClass(id) {
61
+ let idx = state.terminalIndex.get(id);
62
+ if (idx === undefined) {
63
+ idx = state.nextIndex % 4;
64
+ state.terminalIndex.set(id, idx);
65
+ state.nextIndex++;
66
+ }
67
+ return `feed-t${idx + 1}`;
68
+ }
69
+
70
+ // ── Activity Feed ────────────────────────────────────────────
71
+
72
+ function addFeedEntry(message, terminalId) {
73
+ const entry = document.createElement('div');
74
+ const feedClass = terminalId != null ? getTerminalFeedClass(terminalId) : '';
75
+ entry.className = `feed-entry ${feedClass}`;
76
+ entry.innerHTML = `<span class="feed-time">${timestamp()}</span><span class="feed-msg">${escapeHtml(message)}</span>`;
77
+
78
+ // Insert at top (most recent first)
79
+ if (activityFeed.firstChild) {
80
+ activityFeed.insertBefore(entry, activityFeed.firstChild);
81
+ } else {
82
+ activityFeed.appendChild(entry);
83
+ }
84
+
85
+ // Cap at 200 entries
86
+ while (activityFeed.children.length > 200) {
87
+ activityFeed.removeChild(activityFeed.lastChild);
88
+ }
89
+ }
90
+
91
+ function escapeHtml(str) {
92
+ const div = document.createElement('div');
93
+ div.textContent = str;
94
+ return div.innerHTML;
95
+ }
96
+
97
+ // ── Terminal Creation ────────────────────────────────────────
98
+
99
+ function createTerminalUI(termData) {
100
+ const { id, label, status, elapsed, progress, taskName } = termData;
101
+
102
+ // Pane
103
+ const pane = document.createElement('div');
104
+ pane.className = 'terminal-pane';
105
+ pane.id = `pane-${id}`;
106
+
107
+ // Header
108
+ const header = document.createElement('div');
109
+ header.className = 'pane-header';
110
+
111
+ // Label (editable on double-click)
112
+ const labelEl = document.createElement('span');
113
+ labelEl.className = 'pane-label';
114
+ labelEl.textContent = label || `Terminal ${id.slice(0, 6)}`;
115
+ labelEl.title = 'Double-click to rename';
116
+ labelEl.addEventListener('dblclick', (e) => {
117
+ e.stopPropagation();
118
+ startLabelEdit(id, labelEl);
119
+ });
120
+
121
+ // State indicator
122
+ const stateEl = document.createElement('span');
123
+ stateEl.className = 'pane-state';
124
+ const stateIcon = document.createElement('span');
125
+ stateIcon.className = `state-icon ${status || 'idle'}`;
126
+ stateIcon.id = `state-icon-${id}`;
127
+ const stateText = document.createElement('span');
128
+ stateText.className = `state-text ${status || 'idle'}`;
129
+ stateText.id = `state-text-${id}`;
130
+ stateText.textContent = STATE_LABELS[status] || 'IDLE';
131
+ stateEl.appendChild(stateIcon);
132
+ stateEl.appendChild(stateText);
133
+
134
+ // Elapsed
135
+ const elapsedEl = document.createElement('span');
136
+ elapsedEl.className = 'pane-elapsed';
137
+ elapsedEl.id = `elapsed-${id}`;
138
+ elapsedEl.textContent = elapsed || '';
139
+
140
+ // Spacer
141
+ const spacer = document.createElement('span');
142
+ spacer.className = 'pane-spacer';
143
+
144
+ // Action buttons container
145
+ const actionsEl = document.createElement('span');
146
+ actionsEl.className = 'pane-actions';
147
+ actionsEl.id = `actions-${id}`;
148
+
149
+ // Progress bar
150
+ const progressTrack = document.createElement('div');
151
+ progressTrack.className = 'progress-bar-track';
152
+ const progressFill = document.createElement('div');
153
+ progressFill.className = `progress-bar-fill ${status || 'idle'}`;
154
+ progressFill.id = `progress-${id}`;
155
+ progressFill.style.width = `${progress || 0}%`;
156
+ progressTrack.appendChild(progressFill);
157
+
158
+ header.appendChild(labelEl);
159
+ header.appendChild(stateEl);
160
+ header.appendChild(elapsedEl);
161
+ header.appendChild(spacer);
162
+ header.appendChild(actionsEl);
163
+ header.appendChild(progressTrack);
164
+
165
+ // Double-click header to maximize/restore
166
+ header.addEventListener('dblclick', (e) => {
167
+ if (e.target.closest('.pane-label') || e.target.closest('.action-btn')) return;
168
+ toggleMaximize(id);
169
+ });
170
+
171
+ // Terminal container
172
+ const container = document.createElement('div');
173
+ container.className = 'terminal-container';
174
+ container.id = `terminal-${id}`;
175
+
176
+ pane.appendChild(header);
177
+ pane.appendChild(container);
178
+ grid.appendChild(pane);
179
+
180
+ // Click pane to focus
181
+ pane.addEventListener('mousedown', () => {
182
+ setActiveTerminal(id);
183
+ });
184
+
185
+ // xterm.js
186
+ const term = new window.Terminal({
187
+ cursorBlink: true,
188
+ fontSize: 13,
189
+ fontFamily: "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
190
+ theme: {
191
+ background: '#0C0C0C',
192
+ foreground: '#C8C0B4',
193
+ cursor: '#E8A917',
194
+ cursorAccent: '#0C0C0C',
195
+ selectionBackground: '#2a2520',
196
+ },
197
+ scrollback: 5000,
198
+ allowProposedApi: true,
199
+ });
200
+
201
+ const fitAddon = new window.FitAddon.FitAddon();
202
+ const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
203
+ term.loadAddon(fitAddon);
204
+ term.loadAddon(webLinksAddon);
205
+ term.open(container);
206
+
207
+ requestAnimationFrame(() => {
208
+ try { fitAddon.fit(); } catch {}
209
+ });
210
+
211
+ // WebSocket
212
+ const ws = new WebSocket(`${WS_BASE}/ws/${id}`);
213
+ ws.binaryType = 'arraybuffer';
214
+
215
+ ws.onopen = () => {
216
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
217
+ };
218
+
219
+ ws.onmessage = (event) => {
220
+ const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data);
221
+ term.write(data);
222
+ // Always scroll to bottom on new output
223
+ term.scrollToBottom();
224
+ };
225
+
226
+ ws.onclose = () => {
227
+ term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
228
+ };
229
+
230
+ term.onData((data) => {
231
+ if (ws.readyState === WebSocket.OPEN) ws.send(data);
232
+ });
233
+
234
+ term.onResize(({ cols, rows }) => {
235
+ if (ws.readyState === WebSocket.OPEN) {
236
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
237
+ }
238
+ });
239
+
240
+ // Store
241
+ const termState = {
242
+ id,
243
+ label: label || `Terminal ${id.slice(0, 6)}`,
244
+ status: status || 'idle',
245
+ progress: progress || 0,
246
+ elapsed: elapsed || '',
247
+ taskName: taskName || '',
248
+ term,
249
+ ws,
250
+ fitAddon,
251
+ paneEl: pane,
252
+ labelEl,
253
+ };
254
+ state.terminals.set(id, termState);
255
+
256
+ // Assign feed index
257
+ getTerminalFeedClass(id);
258
+
259
+ // Set initial action buttons
260
+ updateActionButtons(id, termState.status);
261
+
262
+ // Set as active
263
+ setActiveTerminal(id);
264
+
265
+ addFeedEntry(`Terminal created: ${termState.label}`, id);
266
+
267
+ return termState;
268
+ }
269
+
270
+ // ── Label Editing ────────────────────────────────────────────
271
+
272
+ function startLabelEdit(id, labelEl) {
273
+ const t = state.terminals.get(id);
274
+ if (!t) return;
275
+
276
+ const input = document.createElement('input');
277
+ input.type = 'text';
278
+ input.className = 'pane-label editing';
279
+ input.value = t.label;
280
+ input.style.width = `${Math.max(80, labelEl.offsetWidth + 20)}px`;
281
+
282
+ labelEl.replaceWith(input);
283
+ input.focus();
284
+ input.select();
285
+
286
+ const commit = () => {
287
+ const newLabel = input.value.trim() || t.label;
288
+ t.label = newLabel;
289
+
290
+ const newLabelEl = document.createElement('span');
291
+ newLabelEl.className = 'pane-label';
292
+ newLabelEl.textContent = newLabel;
293
+ newLabelEl.title = 'Double-click to rename';
294
+ newLabelEl.addEventListener('dblclick', (e) => {
295
+ e.stopPropagation();
296
+ startLabelEdit(id, newLabelEl);
297
+ });
298
+
299
+ input.replaceWith(newLabelEl);
300
+ t.labelEl = newLabelEl;
301
+
302
+ // Persist to server
303
+ fetch(`${API_BASE}/api/terminals/${id}/label`, {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify({ label: newLabel }),
307
+ }).catch(() => {});
308
+ };
309
+
310
+ input.addEventListener('blur', commit);
311
+ input.addEventListener('keydown', (e) => {
312
+ if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
313
+ if (e.key === 'Escape') { input.value = t.label; input.blur(); }
314
+ });
315
+ }
316
+
317
+ // ── Action Buttons ───────────────────────────────────────────
318
+
319
+ function updateActionButtons(id, status) {
320
+ const actionsEl = document.getElementById(`actions-${id}`);
321
+ if (!actionsEl) return;
322
+ actionsEl.innerHTML = '';
323
+
324
+ const buttons = [];
325
+
326
+ switch (status) {
327
+ case 'working':
328
+ case 'starting':
329
+ buttons.push({ label: 'Pause', action: () => pauseTerminal(id) });
330
+ buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
331
+ break;
332
+ case 'done':
333
+ buttons.push({ label: 'Clear', action: () => clearTerminal(id) });
334
+ buttons.push({ label: 'New', action: () => restartTerminal(id) });
335
+ break;
336
+ case 'blocked':
337
+ case 'waiting_approval':
338
+ buttons.push({ label: 'Unblock', action: () => restartTerminal(id) });
339
+ buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
340
+ break;
341
+ case 'error':
342
+ case 'exited':
343
+ buttons.push({ label: 'Retry', action: () => restartTerminal(id) });
344
+ buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
345
+ break;
346
+ case 'compacting':
347
+ buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
348
+ break;
349
+ case 'idle':
350
+ default:
351
+ buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
352
+ break;
353
+ }
354
+
355
+ for (const btn of buttons) {
356
+ const el = document.createElement('button');
357
+ el.className = `action-btn${btn.danger ? ' danger' : ''}`;
358
+ el.textContent = btn.label;
359
+ // mousedown + preventDefault to avoid stealing focus from terminal
360
+ el.addEventListener('mousedown', (e) => {
361
+ e.preventDefault();
362
+ e.stopPropagation();
363
+ btn.action();
364
+ });
365
+ actionsEl.appendChild(el);
366
+ }
367
+ }
368
+
369
+ // ── Terminal Actions ─────────────────────────────────────────
370
+
371
+ function setActiveTerminal(id) {
372
+ state.activeId = id;
373
+ for (const [tid, terminal] of state.terminals) {
374
+ terminal.paneEl.classList.toggle('active', tid === id);
375
+ }
376
+ const t = state.terminals.get(id);
377
+ if (t) t.term.focus();
378
+ }
379
+
380
+ function toggleMaximize(id) {
381
+ const t = state.terminals.get(id);
382
+ if (!t) return;
383
+
384
+ if (state.maximizedId === id) {
385
+ // Restore
386
+ t.paneEl.classList.remove('maximized');
387
+ state.maximizedId = null;
388
+ for (const [, terminal] of state.terminals) {
389
+ terminal.paneEl.classList.remove('hidden');
390
+ }
391
+ } else {
392
+ // Maximize
393
+ if (state.maximizedId) {
394
+ const prev = state.terminals.get(state.maximizedId);
395
+ if (prev) prev.paneEl.classList.remove('maximized');
396
+ }
397
+ for (const [tid, terminal] of state.terminals) {
398
+ if (tid !== id) terminal.paneEl.classList.add('hidden');
399
+ else terminal.paneEl.classList.remove('hidden');
400
+ }
401
+ t.paneEl.classList.add('maximized');
402
+ state.maximizedId = id;
403
+ }
404
+
405
+ requestAnimationFrame(() => fitAll());
406
+ }
407
+
408
+ async function closeTerminal(id) {
409
+ const t = state.terminals.get(id);
410
+ if (!t) return;
411
+
412
+ try {
413
+ await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE' });
414
+ } catch {}
415
+
416
+ t.ws.close();
417
+ t.term.dispose();
418
+ t.paneEl.remove();
419
+ state.terminals.delete(id);
420
+
421
+ if (state.maximizedId === id) {
422
+ state.maximizedId = null;
423
+ for (const [, terminal] of state.terminals) {
424
+ terminal.paneEl.classList.remove('hidden');
425
+ }
426
+ }
427
+
428
+ addFeedEntry(`Terminal closed: ${t.label}`, id);
429
+ updateStatusBar();
430
+ requestAnimationFrame(() => fitAll());
431
+ }
432
+
433
+ async function killTerminal(id) {
434
+ try {
435
+ await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST' });
436
+ addFeedEntry(`Kill sent to terminal`, id);
437
+ } catch (err) {
438
+ console.error('Kill failed:', err);
439
+ }
440
+ }
441
+
442
+ async function pauseTerminal(id) {
443
+ // Send escape sequence (Ctrl+C)
444
+ try {
445
+ await fetch(`${API_BASE}/api/terminals/${id}/input`, {
446
+ method: 'POST',
447
+ headers: { 'Content-Type': 'application/json' },
448
+ body: JSON.stringify({ text: '\x1b' }),
449
+ });
450
+ addFeedEntry(`Escape sent to terminal`, id);
451
+ } catch (err) {
452
+ console.error('Pause failed:', err);
453
+ }
454
+ }
455
+
456
+ async function restartTerminal(id) {
457
+ try {
458
+ await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST' });
459
+ addFeedEntry(`Restart requested`, id);
460
+ } catch (err) {
461
+ console.error('Restart failed:', err);
462
+ }
463
+ }
464
+
465
+ function clearTerminal(id) {
466
+ const t = state.terminals.get(id);
467
+ if (t) {
468
+ t.term.clear();
469
+ addFeedEntry(`Terminal cleared`, id);
470
+ }
471
+ }
472
+
473
+ async function addNewTerminal() {
474
+ try {
475
+ const res = await fetch(`${API_BASE}/api/terminals`, {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ });
479
+ const data = await res.json();
480
+ createTerminalUI(data);
481
+ requestAnimationFrame(() => fitAll());
482
+ } catch (err) {
483
+ console.error('Failed to create terminal:', err);
484
+ }
485
+ }
486
+
487
+ // ── Fit All Terminals ────────────────────────────────────────
488
+
489
+ function fitAll() {
490
+ for (const [, t] of state.terminals) {
491
+ if (!t.paneEl.classList.contains('hidden')) {
492
+ try { t.fitAddon.fit(); } catch {}
493
+ }
494
+ }
495
+ }
496
+
497
+ // ── Status Updates ───────────────────────────────────────────
498
+
499
+ function updateTerminalState(id, newStatus, extra) {
500
+ const t = state.terminals.get(id);
501
+ if (!t) return;
502
+
503
+ const oldStatus = t.status;
504
+ t.status = newStatus;
505
+
506
+ if (extra) {
507
+ if (extra.elapsed !== undefined) t.elapsed = extra.elapsed;
508
+ if (extra.progress !== undefined) t.progress = extra.progress;
509
+ if (extra.label !== undefined) {
510
+ t.label = extra.label;
511
+ if (t.labelEl) t.labelEl.textContent = extra.label;
512
+ }
513
+ if (extra.taskName !== undefined) t.taskName = extra.taskName;
514
+ }
515
+
516
+ // Update state icon
517
+ const stateIcon = document.getElementById(`state-icon-${id}`);
518
+ if (stateIcon) stateIcon.className = `state-icon ${newStatus}`;
519
+
520
+ // Update state text
521
+ const stateText = document.getElementById(`state-text-${id}`);
522
+ if (stateText) {
523
+ stateText.className = `state-text ${newStatus}`;
524
+ stateText.textContent = STATE_LABELS[newStatus] || newStatus.toUpperCase();
525
+ }
526
+
527
+ // Update elapsed
528
+ const elapsedEl = document.getElementById(`elapsed-${id}`);
529
+ if (elapsedEl) elapsedEl.textContent = t.elapsed;
530
+
531
+ // Update progress bar
532
+ const progressFill = document.getElementById(`progress-${id}`);
533
+ if (progressFill) {
534
+ progressFill.className = `progress-bar-fill ${newStatus}`;
535
+ progressFill.style.width = `${t.progress}%`;
536
+ }
537
+
538
+ // Update pane border state classes
539
+ t.paneEl.classList.remove('state-error', 'state-blocked', 'state-done', 'flash');
540
+
541
+ if (newStatus === 'error' || newStatus === 'exited') {
542
+ t.paneEl.classList.add('state-error');
543
+ } else if (newStatus === 'blocked' || newStatus === 'waiting_approval') {
544
+ t.paneEl.classList.add('state-blocked');
545
+ }
546
+
547
+ // Done flash animation (one-shot)
548
+ if (newStatus === 'done' && oldStatus !== 'done') {
549
+ t.paneEl.classList.add('state-done', 'flash');
550
+ t.paneEl.addEventListener('animationend', () => {
551
+ t.paneEl.classList.remove('flash');
552
+ }, { once: true });
553
+ }
554
+
555
+ // Update action buttons
556
+ updateActionButtons(id, newStatus);
557
+
558
+ // Update status bar
559
+ updateStatusBar();
560
+
561
+ // Desktop notification for done/error when tab not focused
562
+ if ((newStatus === 'done' || newStatus === 'error') && oldStatus !== newStatus && !document.hasFocus()) {
563
+ fireNotification(t.label, newStatus);
564
+ }
565
+ }
566
+
567
+ function updateProgress(id, progress) {
568
+ const t = state.terminals.get(id);
569
+ if (!t) return;
570
+
571
+ t.progress = progress;
572
+
573
+ const progressFill = document.getElementById(`progress-${id}`);
574
+ if (progressFill) {
575
+ progressFill.style.width = `${progress}%`;
576
+ }
577
+
578
+ updateStatusBar();
579
+ }
580
+
581
+ function updateStatusBar() {
582
+ const counts = {};
583
+ let totalProgress = 0;
584
+ let termCount = 0;
585
+
586
+ for (const [, t] of state.terminals) {
587
+ const s = t.status || 'idle';
588
+ counts[s] = (counts[s] || 0) + 1;
589
+ totalProgress += t.progress || 0;
590
+ termCount++;
591
+ }
592
+
593
+ // Render counts
594
+ const order = ['working', 'done', 'blocked', 'error', 'waiting_approval', 'compacting', 'idle'];
595
+ const parts = [];
596
+ for (const s of order) {
597
+ if (counts[s]) {
598
+ parts.push(`<span class="status-count"><span class="status-dot ${s}"></span>${counts[s]} ${STATE_LABELS[s] ? STATE_LABELS[s].toLowerCase() : s}</span>`);
599
+ }
600
+ }
601
+ statusCounts.innerHTML = parts.join('');
602
+
603
+ // Overall progress
604
+ const overall = termCount > 0 ? Math.round(totalProgress / termCount) : 0;
605
+ statusProgress.textContent = `Overall: ${overall}%`;
606
+ }
607
+
608
+ // ── Desktop Notifications ────────────────────────────────────
609
+
610
+ function requestNotificationPermission() {
611
+ if ('Notification' in window && Notification.permission === 'default') {
612
+ Notification.requestPermission();
613
+ }
614
+ }
615
+
616
+ function fireNotification(label, status) {
617
+ if ('Notification' in window && Notification.permission === 'granted') {
618
+ const icon = status === 'done' ? '\u2713' : '\u2717';
619
+ new Notification(`Ninja Terminals: ${label}`, {
620
+ body: `${icon} Terminal is ${status.toUpperCase()}`,
621
+ tag: `ninja-${label}-${status}`,
622
+ });
623
+ }
624
+ }
625
+
626
+ // ── SSE Connection ───────────────────────────────────────────
627
+
628
+ function connectSSE() {
629
+ const evtSource = new EventSource(`${API_BASE}/api/events`);
630
+
631
+ evtSource.addEventListener('status_change', (e) => {
632
+ try {
633
+ const data = JSON.parse(e.data);
634
+ const { terminalId, status, elapsed, progress, label, taskName } = data;
635
+ updateTerminalState(terminalId, status, { elapsed, progress, label, taskName });
636
+
637
+ const t = state.terminals.get(terminalId);
638
+ const name = t ? t.label : terminalId;
639
+ addFeedEntry(`${name} -> ${STATE_LABELS[status] || status}`, terminalId);
640
+ } catch {}
641
+ });
642
+
643
+ evtSource.addEventListener('progress', (e) => {
644
+ try {
645
+ const data = JSON.parse(e.data);
646
+ const { terminalId, progress } = data;
647
+ updateProgress(terminalId, progress);
648
+ } catch {}
649
+ });
650
+
651
+ evtSource.addEventListener('permission_request', (e) => {
652
+ try {
653
+ const data = JSON.parse(e.data);
654
+ const { terminalId, message } = data;
655
+ const t = state.terminals.get(terminalId);
656
+ const name = t ? t.label : terminalId;
657
+ addFeedEntry(`[APPROVAL] ${name}: ${message || 'Permission requested'}`, terminalId);
658
+ } catch {}
659
+ });
660
+
661
+ evtSource.addEventListener('error', (e) => {
662
+ try {
663
+ const data = JSON.parse(e.data);
664
+ const { terminalId, message } = data;
665
+ const t = state.terminals.get(terminalId);
666
+ const name = t ? t.label : terminalId;
667
+ addFeedEntry(`[ERROR] ${name}: ${message || 'Unknown error'}`, terminalId);
668
+ } catch {}
669
+ });
670
+
671
+ evtSource.addEventListener('compacted', (e) => {
672
+ try {
673
+ const data = JSON.parse(e.data);
674
+ const { terminalId } = data;
675
+ const t = state.terminals.get(terminalId);
676
+ const name = t ? t.label : terminalId;
677
+ addFeedEntry(`${name}: Context compacted`, terminalId);
678
+ } catch {}
679
+ });
680
+
681
+ evtSource.addEventListener('context_low', (e) => {
682
+ try {
683
+ const data = JSON.parse(e.data);
684
+ const { terminalId, contextPct } = data;
685
+ const t = state.terminals.get(terminalId);
686
+ const name = t ? t.label : terminalId;
687
+ addFeedEntry(`[WARN] ${name}: Context low (${contextPct}%)`, terminalId);
688
+ } catch {}
689
+ });
690
+
691
+ evtSource.onerror = () => {
692
+ // EventSource will auto-reconnect
693
+ };
694
+
695
+ return evtSource;
696
+ }
697
+
698
+ // ── Task Queue ───────────────────────────────────────────────
699
+
700
+ async function fetchTasks() {
701
+ try {
702
+ const res = await fetch(`${API_BASE}/api/tasks`);
703
+ if (!res.ok) {
704
+ taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
705
+ return;
706
+ }
707
+ const tasks = await res.json();
708
+ renderTasks(tasks);
709
+ } catch {
710
+ taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
711
+ }
712
+ }
713
+
714
+ function renderTasks(tasks) {
715
+ if (!tasks || !Array.isArray(tasks) || tasks.length === 0) {
716
+ taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
717
+ return;
718
+ }
719
+
720
+ taskQueue.innerHTML = '';
721
+ for (const task of tasks) {
722
+ const item = document.createElement('div');
723
+ item.className = 'task-item';
724
+ const dotClass = task.status || 'pending';
725
+ item.innerHTML = `
726
+ <span class="task-dot ${dotClass}"></span>
727
+ <span class="task-name" title="${escapeHtml(task.name || task.id || '')}">${escapeHtml(task.name || task.id || 'Unnamed')}</span>
728
+ <span class="task-status">${escapeHtml(task.status || 'pending')}</span>
729
+ `;
730
+ taskQueue.appendChild(item);
731
+ }
732
+ }
733
+
734
+ // ── Status Polling (fallback + elapsed updates) ──────────────
735
+
736
+ async function pollStatus() {
737
+ try {
738
+ const res = await fetch(`${API_BASE}/api/terminals`);
739
+ const list = await res.json();
740
+
741
+ for (const item of list) {
742
+ const t = state.terminals.get(item.id);
743
+ if (!t) {
744
+ // Terminal exists on server but not in UI — create it
745
+ createTerminalUI(item);
746
+ continue;
747
+ }
748
+
749
+ // Update elapsed time (SSE might not send this continuously)
750
+ if (item.elapsed !== undefined) {
751
+ t.elapsed = item.elapsed;
752
+ const elapsedEl = document.getElementById(`elapsed-${item.id}`);
753
+ if (elapsedEl) elapsedEl.textContent = item.elapsed;
754
+ }
755
+
756
+ // Update progress if present
757
+ if (item.progress !== undefined && item.progress !== t.progress) {
758
+ updateProgress(item.id, item.progress);
759
+ }
760
+
761
+ // Update status if changed (SSE is primary, this is fallback)
762
+ if (item.status && item.status !== t.status) {
763
+ updateTerminalState(item.id, item.status, {
764
+ elapsed: item.elapsed,
765
+ progress: item.progress,
766
+ label: item.label,
767
+ taskName: item.taskName,
768
+ });
769
+ }
770
+ }
771
+
772
+ // Check for removed terminals
773
+ for (const [id] of state.terminals) {
774
+ if (!list.find((item) => item.id === id)) {
775
+ closeTerminal(id);
776
+ }
777
+ }
778
+
779
+ updateStatusBar();
780
+ } catch {}
781
+ }
782
+
783
+ // ── Sidebar Toggle ───────────────────────────────────────────
784
+
785
+ function setupSidebar() {
786
+ sidebarToggle.addEventListener('click', () => {
787
+ sidebar.classList.toggle('collapsed');
788
+ requestAnimationFrame(() => fitAll());
789
+ });
790
+
791
+ // Mobile: create overlay toggle
792
+ const mobileToggle = document.createElement('button');
793
+ mobileToggle.className = 'mobile-toggle';
794
+ mobileToggle.innerHTML = '&#9776;';
795
+ mobileToggle.addEventListener('click', () => {
796
+ sidebar.classList.toggle('open');
797
+ sidebar.classList.remove('collapsed');
798
+ });
799
+ document.body.appendChild(mobileToggle);
800
+ }
801
+
802
+ // ── Add Task ─────────────────────────────────────────────────
803
+
804
+ function setupAddTask() {
805
+ addTaskBtn.addEventListener('click', () => {
806
+ addNewTerminal();
807
+ });
808
+ }
809
+
810
+ // ── Resize Handler ───────────────────────────────────────────
811
+
812
+ let resizeTimeout;
813
+ window.addEventListener('resize', () => {
814
+ clearTimeout(resizeTimeout);
815
+ resizeTimeout = setTimeout(() => fitAll(), 100);
816
+ });
817
+
818
+ // ── Initialize ───────────────────────────────────────────────
819
+
820
+ async function init() {
821
+ // Request desktop notification permission
822
+ requestNotificationPermission();
823
+
824
+ // Setup sidebar
825
+ setupSidebar();
826
+ setupAddTask();
827
+
828
+ // Load existing terminals
829
+ try {
830
+ const res = await fetch(`${API_BASE}/api/terminals`);
831
+ const list = await res.json();
832
+ for (const item of list) {
833
+ createTerminalUI(item);
834
+ }
835
+ } catch (err) {
836
+ console.error('Failed to load terminals:', err);
837
+ }
838
+
839
+ // Initial fit after all terminals loaded
840
+ setTimeout(() => fitAll(), 300);
841
+
842
+ // Connect SSE for real-time updates
843
+ connectSSE();
844
+
845
+ // Fetch initial task queue
846
+ fetchTasks();
847
+
848
+ // Poll status every 3 seconds (fallback for SSE + elapsed updates)
849
+ setInterval(pollStatus, 3000);
850
+
851
+ // Poll task queue every 5 seconds
852
+ setInterval(fetchTasks, 5000);
853
+
854
+ // Initial status bar
855
+ updateStatusBar();
856
+
857
+ addFeedEntry('Ninja Terminals v2 started');
858
+ }
859
+
860
+ init();