@jhizzard/termdeck 0.2.4 → 0.3.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.
@@ -0,0 +1,2315 @@
1
+ /* Extracted from index.html 2026-04-15 — see git blame on index.html prior to commit UNCOMMITTED for history */
2
+ // ===== TermDeck Client =====
3
+ const API = window.location.origin;
4
+ const WS_BASE = `ws://${window.location.host}/ws`;
5
+
6
+ // State
7
+ const state = {
8
+ sessions: new Map(), // id → { session, terminal, ws, fitAddon, el }
9
+ layout: '2x1',
10
+ themes: {},
11
+ config: {},
12
+ focusedId: null
13
+ };
14
+
15
+ // ===== API helpers =====
16
+ async function api(method, path, body) {
17
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
18
+ if (body) opts.body = JSON.stringify(body);
19
+ const res = await fetch(`${API}${path}`, opts);
20
+ return res.json();
21
+ }
22
+
23
+ // ===== Initialize =====
24
+ async function init() {
25
+ // Load config
26
+ state.config = await api('GET', '/api/config');
27
+
28
+ // Populate project dropdown
29
+ const sel = document.getElementById('promptProject');
30
+ for (const name of Object.keys(state.config.projects || {})) {
31
+ const opt = document.createElement('option');
32
+ opt.value = name;
33
+ opt.textContent = name;
34
+ sel.appendChild(opt);
35
+ }
36
+
37
+ // Load themes
38
+ const themeList = await api('GET', '/api/themes');
39
+ for (const t of themeList) {
40
+ state.themes[t.id] = t;
41
+ }
42
+
43
+ // Load existing sessions
44
+ const sessions = await api('GET', '/api/sessions');
45
+ for (const s of sessions) {
46
+ if (s.meta.status !== 'exited') {
47
+ createTerminalPanel(s);
48
+ }
49
+ }
50
+
51
+ // RAG indicator
52
+ if (state.config.ragEnabled) {
53
+ document.getElementById('stat-rag').style.display = '';
54
+ }
55
+
56
+ // Disable AI input bars if Supabase/OpenAI not configured
57
+ if (!state.config.aiQueryAvailable) {
58
+ document.querySelectorAll('.ctrl-input').forEach(el => {
59
+ el.placeholder = 'Configure Supabase in ~/.termdeck/config.yaml to enable';
60
+ el.disabled = true;
61
+ });
62
+ }
63
+
64
+ updateEmptyState();
65
+
66
+ // Rumen insights badge + briefing (no-op when server reports enabled:false)
67
+ setupRumen();
68
+
69
+ // First-run onboarding tour. Fires on the first visit only; never again
70
+ // unless the user explicitly clicks "how this works" in the top toolbar.
71
+ try {
72
+ if (!localStorage.getItem('termdeck:tour:seen')) {
73
+ setTimeout(() => { if (!tourState.active) startTour(); }, 1200);
74
+ }
75
+ } catch {}
76
+ }
77
+
78
+ // ===== Create Terminal Panel =====
79
+ function createTerminalPanel(sessionData) {
80
+ const id = sessionData.id;
81
+ const meta = sessionData.meta;
82
+
83
+ // Idempotency guard: multiple code paths can trigger this function for
84
+ // the same session ID in rapid succession — status_broadcast handler
85
+ // (2s interval), external-session poller (3s interval), launchTerminal
86
+ // (immediate after POST), and init() on page load. Without a claim at
87
+ // function entry, two of these can race and create two client panels
88
+ // for the same server session — which means two WebSockets, the second
89
+ // overwrites session.ws on the server, and term.onData output stops
90
+ // reaching the first panel's xterm. Result: terminals spawn but never
91
+ // render a prompt and don't accept input.
92
+ //
93
+ // Fix: reserve the slot in state.sessions immediately on entry. Any
94
+ // subsequent call sees has(id) and early-returns. The full entry gets
95
+ // written later when the xterm + ws + fitAddon are built; that write
96
+ // overwrites this placeholder in place.
97
+ if (state.sessions.has(id)) return;
98
+ state.sessions.set(id, { _mounting: true });
99
+
100
+ // Hide empty state
101
+ document.getElementById('emptyState').style.display = 'none';
102
+
103
+ // Project CSS class
104
+ const projClass = meta.project
105
+ ? `project-${meta.project.replace(/[^a-z0-9]/gi, '').toLowerCase()}`
106
+ : 'project-default';
107
+
108
+ // Build panel HTML
109
+ const panel = document.createElement('div');
110
+ panel.className = 'term-panel';
111
+ panel.id = `panel-${id}`;
112
+ panel.innerHTML = `
113
+ <div class="panel-header">
114
+ <div class="panel-header-left">
115
+ <span class="status-dot" id="dot-${id}" style="background:${getStatusColor(meta.status)}"></span>
116
+ <span class="panel-type">${getTypeLabel(meta.type)}</span>
117
+ ${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
118
+ <span class="panel-index" id="idx-${id}"></span>
119
+ <span class="panel-status" id="status-${id}">${meta.statusDetail || meta.status}</span>
120
+ </div>
121
+ <div class="panel-header-right">
122
+ <button class="panel-btn" onclick="focusPanel('${id}')" title="Focus this terminal">&#9634;</button>
123
+ <button class="panel-btn" onclick="halfPanel('${id}')" title="Half screen">&#9645;</button>
124
+ <button class="panel-btn danger" onclick="closePanel('${id}')" title="Close terminal">&times;</button>
125
+ </div>
126
+ </div>
127
+ <div class="panel-meta">
128
+ <span class="meta-item"><span class="meta-label">opened</span> ${timeAgo(meta.createdAt)}</span>
129
+ <span class="meta-item"><span class="meta-label">why</span> ${meta.reason}</span>
130
+ <span class="meta-item" id="meta-last-${id}"><span class="meta-label">last</span> ${meta.lastCommands?.length ? meta.lastCommands[meta.lastCommands.length - 1].command : '—'}</span>
131
+ <span class="meta-item" id="meta-port-${id}" style="${meta.detectedPort ? '' : 'display:none'}"><span class="meta-label">port</span> <span class="meta-value">:${meta.detectedPort || ''}</span></span>
132
+ <span class="meta-item" id="meta-reqs-${id}" style="${meta.type === 'python-server' ? '' : 'display:none'}"><span class="meta-label">reqs</span> <span class="meta-value">${meta.requestCount || 0}</span></span>
133
+ </div>
134
+ <div class="panel-terminal" id="term-${id}"></div>
135
+ <div class="panel-drawer" id="drawer-${id}">
136
+ <div class="drawer-tabs" role="tablist">
137
+ <button class="drawer-tab active" data-tab="overview" data-panel-id="${id}">Overview</button>
138
+ <button class="drawer-tab" data-tab="commands" data-panel-id="${id}">Commands<span class="tab-badge" id="badge-commands-${id}">0</span></button>
139
+ <button class="drawer-tab" data-tab="memory" data-panel-id="${id}">Memory<span class="tab-badge" id="badge-memory-${id}">0</span></button>
140
+ <button class="drawer-tab" data-tab="log" data-panel-id="${id}">Status log<span class="tab-badge" id="badge-log-${id}">0</span></button>
141
+ </div>
142
+ <div class="drawer-body">
143
+ <div class="drawer-panel drawer-overview active" data-panel="overview">
144
+ <div class="overview-controls">
145
+ <select class="theme-select" id="theme-${id}" onchange="changeTheme('${id}', this.value)">
146
+ ${Object.entries(state.themes).map(([tid, t]) =>
147
+ `<option value="${tid}" ${tid === meta.theme ? 'selected' : ''}>${t.label}</option>`
148
+ ).join('')}
149
+ </select>
150
+ <button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
151
+ <button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
152
+ <button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
153
+ <input type="text" class="ctrl-input" id="ai-${id}" placeholder="Ask about this terminal..." onkeydown="if(event.key==='Enter')askAI('${id}', this.value)">
154
+ </div>
155
+ <div class="reply-form" id="reply-form-${id}">
156
+ <select class="reply-target" id="reply-target-${id}"></select>
157
+ <input type="text" class="reply-text" id="reply-text-${id}" placeholder="Text to send..." onkeydown="if(event.key==='Enter')sendReply('${id}')">
158
+ <button class="reply-send" id="reply-send-${id}" onclick="sendReply('${id}')">send</button>
159
+ <div class="reply-status" id="reply-status-${id}"></div>
160
+ </div>
161
+ <div class="overview-meta" id="ovmeta-${id}"></div>
162
+ </div>
163
+ <div class="drawer-panel drawer-list" data-panel="commands" id="dp-commands-${id}">
164
+ <div class="empty-msg">No commands captured yet.</div>
165
+ </div>
166
+ <div class="drawer-panel drawer-list" data-panel="memory" id="dp-memory-${id}">
167
+ <div class="empty-msg">No memory hits yet. Ask about this terminal or wait for a proactive lookup.</div>
168
+ </div>
169
+ <div class="drawer-panel drawer-list" data-panel="log" id="dp-log-${id}">
170
+ <div class="empty-msg">No status transitions recorded yet.</div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ `;
175
+
176
+ document.getElementById('termGrid').appendChild(panel);
177
+
178
+ // Create xterm.js instance
179
+ const terminal = new Terminal({
180
+ fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
181
+ fontSize: 13,
182
+ lineHeight: 1.3,
183
+ cursorBlink: true,
184
+ cursorStyle: 'bar',
185
+ allowProposedApi: true,
186
+ scrollback: 5000,
187
+ theme: getThemeObject(meta.theme)
188
+ });
189
+
190
+ const fitAddon = new FitAddon.FitAddon();
191
+ terminal.loadAddon(fitAddon);
192
+
193
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
194
+ terminal.loadAddon(webLinksAddon);
195
+
196
+ const container = document.getElementById(`term-${id}`);
197
+ terminal.open(container);
198
+
199
+ // Delay fit to ensure DOM is ready
200
+ requestAnimationFrame(() => {
201
+ fitAddon.fit();
202
+ // Inform server of initial size
203
+ api('POST', `/api/sessions/${id}/resize`, {
204
+ cols: terminal.cols,
205
+ rows: terminal.rows
206
+ });
207
+ });
208
+
209
+ // Connect WebSocket
210
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
211
+
212
+ ws.onmessage = (event) => {
213
+ try {
214
+ const msg = JSON.parse(event.data);
215
+ switch (msg.type) {
216
+ case 'output':
217
+ terminal.write(msg.data);
218
+ break;
219
+ case 'meta':
220
+ updatePanelMeta(id, msg.session.meta);
221
+ break;
222
+ case 'exit':
223
+ updatePanelMeta(id, {
224
+ status: 'exited',
225
+ statusDetail: `Exited (${msg.exitCode})`
226
+ });
227
+ // Dim the panel
228
+ const exitPanel = document.getElementById(`panel-${id}`);
229
+ if (exitPanel) exitPanel.classList.add('exited');
230
+ refreshAllReplyFormsFor(id);
231
+ refreshPanelIndices();
232
+ renderSwitcher();
233
+ break;
234
+ case 'status_broadcast':
235
+ updateGlobalStats(msg.sessions);
236
+ break;
237
+ }
238
+ } catch (err) { console.error('[client] ws message parse failed:', err); }
239
+ };
240
+
241
+ ws.onclose = (event) => {
242
+ console.log(`[ws] Disconnected from session ${id} (code ${event.code})`);
243
+ const entry = state.sessions.get(id);
244
+ if (!entry) return;
245
+
246
+ // Don't reconnect if session was explicitly closed or exited
247
+ if (event.code === 4000 || event.code === 4001) return;
248
+ const panel = document.getElementById(`panel-${id}`);
249
+ if (panel && panel.classList.contains('exited')) return;
250
+
251
+ // Auto-reconnect with backoff
252
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
253
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
254
+
255
+ if (entry._reconnectAttempts <= 5) {
256
+ console.log(`[ws] Reconnecting session ${id} in ${delay}ms (attempt ${entry._reconnectAttempts})`);
257
+ setTimeout(() => reconnectSession(id), delay);
258
+ } else {
259
+ updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
260
+ }
261
+ };
262
+
263
+ // Terminal input → WebSocket
264
+ terminal.onData((data) => {
265
+ if (ws.readyState === WebSocket.OPEN) {
266
+ ws.send(JSON.stringify({ type: 'input', data }));
267
+ }
268
+ });
269
+
270
+ // Track focus
271
+ terminal.textarea?.addEventListener('focus', () => {
272
+ panel.classList.add('active-input');
273
+ state.focusedId = id;
274
+ });
275
+ terminal.textarea?.addEventListener('blur', () => {
276
+ panel.classList.remove('active-input');
277
+ });
278
+
279
+ // Store reference
280
+ state.sessions.set(id, {
281
+ session: sessionData,
282
+ terminal,
283
+ ws,
284
+ fitAddon,
285
+ el: panel,
286
+ activeTab: 'overview',
287
+ drawerOpen: false,
288
+ commandHistory: [],
289
+ commandsLoaded: false,
290
+ memoryHits: [],
291
+ statusLog: [],
292
+ lastKnownStatus: meta.status,
293
+ });
294
+
295
+ // Seed an initial status-log entry so the tab isn't blank
296
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
297
+
298
+ // Drawer tab wiring
299
+ setupDrawerListeners(id);
300
+ renderOverviewTab(id);
301
+ renderSwitcher();
302
+
303
+ // Reply form: disabled until there's another panel to target
304
+ const replyBtn = document.getElementById(`reply-btn-${id}`);
305
+ if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
306
+ refreshAllReplyFormsFor(id);
307
+ refreshPanelIndices();
308
+
309
+ // Handle window resize
310
+ const resizeObserver = new ResizeObserver(() => {
311
+ try {
312
+ fitAddon.fit();
313
+ if (ws.readyState === WebSocket.OPEN) {
314
+ ws.send(JSON.stringify({
315
+ type: 'resize',
316
+ cols: terminal.cols,
317
+ rows: terminal.rows
318
+ }));
319
+ }
320
+ } catch (err) { console.error('[client] terminal resize failed:', err); }
321
+ });
322
+ resizeObserver.observe(container);
323
+
324
+ return { terminal, ws, fitAddon };
325
+ }
326
+
327
+ // ===== Control dashboard (T1.6) =====
328
+ async function enterControlMode() {
329
+ // Pre-warm command history for every open session so the feed is dense.
330
+ const loads = [];
331
+ for (const [sid, entry] of state.sessions) {
332
+ if (entry.commandsLoaded) continue;
333
+ loads.push(
334
+ api('GET', `/api/sessions/${sid}/history`).then(resp => {
335
+ const list = Array.isArray(resp) ? resp : (resp.commands || resp.history || []);
336
+ entry.commandHistory = list;
337
+ entry.commandsLoaded = true;
338
+ }).catch(() => { /* silent */ })
339
+ );
340
+ }
341
+ await Promise.allSettled(loads);
342
+ renderControlFeed();
343
+ }
344
+
345
+ function renderControlFeed() {
346
+ const grid = document.getElementById('termGrid');
347
+ const rowsEl = document.getElementById('feedRows');
348
+ const countEl = document.getElementById('feedCount');
349
+ if (!grid || !rowsEl) return;
350
+ if (!grid.classList.contains('layout-control')) return;
351
+
352
+ const events = [];
353
+ for (const [sid, entry] of state.sessions) {
354
+ const meta = entry.session?.meta || {};
355
+ const label = `${getTypeLabel(meta.type || 'shell')}${meta.project ? '·' + meta.project : ''}`;
356
+ const statusColor = getStatusColor(meta.status || 'idle');
357
+
358
+ // Status transitions
359
+ for (const ev of (entry.statusLog || [])) {
360
+ const isErr = ev.status === 'errored';
361
+ events.push({
362
+ at: new Date(ev.at).getTime(),
363
+ sid,
364
+ label,
365
+ statusColor,
366
+ kind: isErr ? 'error' : 'status',
367
+ body: `${ev.status}${ev.detail ? ' — ' + ev.detail : ''}`,
368
+ });
369
+ }
370
+
371
+ // Recent commands
372
+ for (const c of (entry.commandHistory || []).slice(0, 25)) {
373
+ const t = c.timestamp || c.createdAt || c.created_at;
374
+ if (!t) continue;
375
+ events.push({
376
+ at: new Date(t).getTime(),
377
+ sid,
378
+ label,
379
+ statusColor,
380
+ kind: 'command',
381
+ body: c.command || c.cmd || '',
382
+ });
383
+ }
384
+
385
+ // Memory hits cached from askAI / proactive queries
386
+ for (const m of (entry.memoryHits || []).slice(0, 10)) {
387
+ if (!m.cachedAt) continue;
388
+ events.push({
389
+ at: new Date(m.cachedAt).getTime(),
390
+ sid,
391
+ label,
392
+ statusColor,
393
+ kind: 'memory',
394
+ body: (m.content || m.text || '(memory)').slice(0, 220),
395
+ });
396
+ }
397
+ }
398
+
399
+ events.sort((a, b) => b.at - a.at);
400
+ const capped = events.slice(0, 200);
401
+
402
+ if (countEl) countEl.textContent = `${capped.length} event${capped.length === 1 ? '' : 's'}`;
403
+
404
+ if (capped.length === 0) {
405
+ rowsEl.innerHTML = '<div class="feed-empty">No activity yet. Commands, status transitions, and memory hits will appear here.</div>';
406
+ return;
407
+ }
408
+
409
+ rowsEl.innerHTML = capped.map(ev => {
410
+ const t = new Date(ev.at);
411
+ const hh = String(t.getHours()).padStart(2, '0');
412
+ const mm = String(t.getMinutes()).padStart(2, '0');
413
+ const ss = String(t.getSeconds()).padStart(2, '0');
414
+ return `
415
+ <div class="feed-row" data-session-id="${ev.sid}">
416
+ <span class="feed-time">${hh}:${mm}:${ss}</span>
417
+ <span class="feed-panel-ref"><span class="dot" style="background:${ev.statusColor}"></span>${escapeHtml(ev.label)}</span>
418
+ <span class="feed-kind ${ev.kind}">${ev.kind}</span>
419
+ <span class="feed-body">${escapeHtml(ev.body)}</span>
420
+ </div>
421
+ `;
422
+ }).join('');
423
+ }
424
+
425
+ function onFeedRowClick(e) {
426
+ const row = e.target.closest('.feed-row');
427
+ if (!row) return;
428
+ const sid = row.dataset.sessionId;
429
+ if (!sid) return;
430
+ // Return to 2x2 layout and focus the source panel
431
+ setLayout('2x2');
432
+ requestAnimationFrame(() => focusSessionById(sid));
433
+ }
434
+
435
+ // ===== Proactive memory toast (T1.4) =====
436
+ const PROACTIVE_COOLDOWN_MS = 30_000;
437
+
438
+ async function triggerProactiveMemoryQuery(id) {
439
+ const entry = state.sessions.get(id);
440
+ if (!entry) return;
441
+ if (!state.config.aiQueryAvailable) return;
442
+
443
+ const now = Date.now();
444
+ if (entry._lastProactiveAt && now - entry._lastProactiveAt < PROACTIVE_COOLDOWN_MS) return;
445
+ entry._lastProactiveAt = now;
446
+
447
+ const meta = entry.session?.meta || {};
448
+ const lastCmd = meta.lastCommands?.length
449
+ ? meta.lastCommands[meta.lastCommands.length - 1].command
450
+ : '';
451
+ const type = meta.type || 'shell';
452
+ const question = `${type} error ${lastCmd}`.trim();
453
+ if (!question || question === `${type} error`) {
454
+ // No command context — still query using status detail as a last resort
455
+ if (!meta.statusDetail) return;
456
+ }
457
+
458
+ try {
459
+ const result = await api('POST', '/api/ai/query', {
460
+ question: question || `${type} error ${meta.statusDetail || ''}`.trim(),
461
+ sessionId: id,
462
+ project: meta.project || null,
463
+ });
464
+ if (result?.error) return;
465
+ if (!Array.isArray(result?.memories) || result.memories.length === 0) return;
466
+
467
+ // Cache every hit into the Memory tab so the drawer stays in sync
468
+ if (!entry.memoryHits) entry.memoryHits = [];
469
+ const cachedAt = new Date().toISOString();
470
+ for (const m of result.memories) entry.memoryHits.unshift({ ...m, cachedAt });
471
+ if (entry.memoryHits.length > 60) entry.memoryHits.length = 60;
472
+ setBadge(id, 'memory', entry.memoryHits.length);
473
+ if (entry.drawerOpen && entry.activeTab === 'memory') renderMemoryTab(id);
474
+
475
+ showProactiveToast(id, result.memories[0]);
476
+ } catch (err) {
477
+ console.error('[client] proactive memory query failed:', err);
478
+ }
479
+ }
480
+
481
+ function showProactiveToast(id, hit) {
482
+ const entry = state.sessions.get(id);
483
+ if (!entry || !entry.el) return;
484
+
485
+ // Remove any prior toast for this panel
486
+ const prev = entry.el.querySelector('.proactive-toast');
487
+ if (prev) prev.remove();
488
+
489
+ const toast = document.createElement('div');
490
+ toast.className = 'proactive-toast';
491
+ const proj = hit.project ? escapeHtml(hit.project) : 'another session';
492
+ const snippet = escapeHtml((hit.content || hit.text || '').slice(0, 220));
493
+ const score = typeof hit.similarity === 'number' ? `${(hit.similarity * 100).toFixed(0)}%` : '';
494
+
495
+ toast.innerHTML = `
496
+ <button class="t-dismiss" aria-label="Dismiss">×</button>
497
+ <div class="t-title">Mnestra — possible match</div>
498
+ <div class="t-body">Found a similar error in <b>${proj}</b>${score ? ` · ${score}` : ''} — click to see.</div>
499
+ <div class="t-meta">${snippet}</div>
500
+ `;
501
+
502
+ entry.el.appendChild(toast);
503
+
504
+ const dismiss = () => {
505
+ toast.remove();
506
+ clearTimeout(toast._autoTimer);
507
+ };
508
+ toast.querySelector('.t-dismiss').addEventListener('click', (e) => {
509
+ e.stopPropagation();
510
+ dismiss();
511
+ });
512
+ toast.addEventListener('click', () => {
513
+ dismiss();
514
+ focusSessionById(id);
515
+ // Open the Memory tab so the user lands directly on the hit list
516
+ const entry2 = state.sessions.get(id);
517
+ if (entry2 && (!entry2.drawerOpen || entry2.activeTab !== 'memory')) {
518
+ toggleDrawerTab(id, 'memory');
519
+ }
520
+ });
521
+
522
+ toast._autoTimer = setTimeout(dismiss, 30000);
523
+ }
524
+
525
+ // ===== Reply / send-to-terminal (T1.3) =====
526
+ // Flip this to false to force the local-WS fallback even when the server
527
+ // endpoint is available — handy for debugging.
528
+ const USE_SERVER_INPUT_API = true;
529
+
530
+ function toggleReplyForm(fromId) {
531
+ const form = document.getElementById(`reply-form-${fromId}`);
532
+ if (!form) return;
533
+ const willOpen = !form.classList.contains('open');
534
+ form.classList.toggle('open', willOpen);
535
+ if (willOpen) {
536
+ refreshReplyTargets(fromId);
537
+ const input = document.getElementById(`reply-text-${fromId}`);
538
+ setTimeout(() => input?.focus(), 20);
539
+ }
540
+ }
541
+
542
+ function refreshReplyTargets(fromId) {
543
+ const select = document.getElementById(`reply-target-${fromId}`);
544
+ if (!select) return;
545
+ const prev = select.value;
546
+
547
+ // F1.3: number duplicate labels with `#N` so e.g. two "Claude Code · termdeck"
548
+ // panels become "Claude Code · termdeck #1" / "... #2". Numbering is across
549
+ // ALL live panels with that base label (including the current one) in
550
+ // state.sessions insertion order, so suffixes stay stable as the user opens
551
+ // the reply form from different panels.
552
+ const groupIndex = new Map(); // sid → index-within-group (1-based, only when group.size ≥ 2)
553
+ const groupCount = new Map(); // baseLabel → count so far
554
+ for (const [sid, entry] of state.sessions) {
555
+ const panel = entry.el;
556
+ if (panel && panel.classList.contains('exited')) continue;
557
+ const meta = entry.session?.meta || {};
558
+ const base = `${getTypeLabel(meta.type || 'shell')}${meta.project ? ' · ' + meta.project : ''}`;
559
+ const next = (groupCount.get(base) || 0) + 1;
560
+ groupCount.set(base, next);
561
+ groupIndex.set(sid, { base, n: next });
562
+ }
563
+
564
+ const options = [];
565
+ for (const [sid, entry] of state.sessions) {
566
+ if (sid === fromId) continue;
567
+ const panel = entry.el;
568
+ if (panel && panel.classList.contains('exited')) continue;
569
+ const info = groupIndex.get(sid);
570
+ if (!info) continue;
571
+ const needsSuffix = (groupCount.get(info.base) || 0) >= 2;
572
+ const label = needsSuffix ? `${info.base} #${info.n}` : info.base;
573
+ options.push(`<option value="${sid}">${escapeHtml(label)}</option>`);
574
+ }
575
+ if (options.length === 0) {
576
+ select.innerHTML = `<option value="">(no other terminals)</option>`;
577
+ } else {
578
+ select.innerHTML = options.join('');
579
+ if (prev && Array.from(select.options).some(o => o.value === prev)) {
580
+ select.value = prev;
581
+ }
582
+ }
583
+ }
584
+
585
+ // Assign #N index suffixes to panels that share (type, project) with another
586
+ // panel. Insertion-order numbering via Map iteration (Map preserves insert order).
587
+ // Groups of size 1 get no suffix — only collisions get numbered.
588
+ function refreshPanelIndices() {
589
+ const groups = new Map(); // key = "type|project" → [sid, ...]
590
+ for (const [sid, entry] of state.sessions) {
591
+ const meta = entry.session?.meta || {};
592
+ const key = `${meta.type || 'shell'}|${meta.project || ''}`;
593
+ if (!groups.has(key)) groups.set(key, []);
594
+ groups.get(key).push(sid);
595
+ }
596
+ for (const [, sids] of groups) {
597
+ const showIndex = sids.length >= 2;
598
+ sids.forEach((sid, i) => {
599
+ const el = document.getElementById(`idx-${sid}`);
600
+ if (!el) return;
601
+ el.textContent = showIndex ? `#${i + 1}` : '';
602
+ });
603
+ }
604
+ }
605
+
606
+ function refreshAllReplyFormsFor(changedId) {
607
+ // When a panel is added, removed, or exits, the target list in *other*
608
+ // panels' open reply forms needs refreshing.
609
+ for (const [sid, entry] of state.sessions) {
610
+ if (sid === changedId) continue;
611
+ const form = document.getElementById(`reply-form-${sid}`);
612
+ if (form && form.classList.contains('open')) {
613
+ refreshReplyTargets(sid);
614
+ }
615
+ const btn = document.getElementById(`reply-btn-${sid}`);
616
+ if (btn) btn.disabled = state.sessions.size < 2;
617
+ }
618
+ }
619
+
620
+ async function sendReply(fromId) {
621
+ const select = document.getElementById(`reply-target-${fromId}`);
622
+ const input = document.getElementById(`reply-text-${fromId}`);
623
+ const statusEl = document.getElementById(`reply-status-${fromId}`);
624
+ if (!select || !input) return;
625
+ const targetId = select.value;
626
+ let text = input.value;
627
+ if (!targetId) {
628
+ showReplyStatus(statusEl, 'No target selected.', 'error');
629
+ return;
630
+ }
631
+ if (!text) return;
632
+
633
+ const targetEntry = state.sessions.get(targetId);
634
+ if (!targetEntry) {
635
+ showReplyStatus(statusEl, 'Target not found.', 'error');
636
+ return;
637
+ }
638
+
639
+ // zsh and most shells want CR. Normalize \n → \r and strip \r\n pairs.
640
+ const normalized = text.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
641
+ // Ensure the line actually submits at the target prompt.
642
+ const payload = normalized.endsWith('\r') ? normalized : normalized + '\r';
643
+
644
+ let delivered = false;
645
+ let errMsg = '';
646
+
647
+ if (USE_SERVER_INPUT_API) {
648
+ try {
649
+ const result = await api('POST', `/api/sessions/${targetId}/input`, {
650
+ text: payload,
651
+ source: 'reply',
652
+ fromSessionId: fromId,
653
+ });
654
+ if (result && !result.error) {
655
+ delivered = true;
656
+ } else {
657
+ errMsg = result?.error || 'server returned an error';
658
+ }
659
+ } catch (err) {
660
+ errMsg = err.message || String(err);
661
+ }
662
+ }
663
+
664
+ if (!delivered) {
665
+ // Local-WS fallback. Used when USE_SERVER_INPUT_API is false, or when
666
+ // the server endpoint is missing / failing.
667
+ const ws = targetEntry.ws;
668
+ if (ws && ws.readyState === WebSocket.OPEN) {
669
+ try {
670
+ ws.send(JSON.stringify({ type: 'input', data: payload }));
671
+ delivered = true;
672
+ } catch (err) {
673
+ errMsg = err.message || String(err);
674
+ }
675
+ } else {
676
+ if (!errMsg) errMsg = 'target websocket not open';
677
+ }
678
+ }
679
+
680
+ if (delivered) {
681
+ input.value = '';
682
+ showReplyStatus(statusEl, `Sent ${payload.length} bytes →`, 'ok');
683
+ } else {
684
+ showReplyStatus(statusEl, `Send failed: ${errMsg}`, 'error');
685
+ }
686
+ }
687
+
688
+ function showReplyStatus(el, msg, kind) {
689
+ if (!el) return;
690
+ el.textContent = msg;
691
+ el.classList.remove('error', 'ok');
692
+ if (kind) el.classList.add(kind);
693
+ clearTimeout(el._timer);
694
+ el._timer = setTimeout(() => { el.textContent = ''; el.classList.remove('error', 'ok'); }, 3500);
695
+ }
696
+
697
+ // ===== Terminal switcher (T1.2) =====
698
+ function renderSwitcher() {
699
+ const wrap = document.getElementById('termSwitcher');
700
+ const grid = document.getElementById('switcherGrid');
701
+ if (!wrap || !grid) return;
702
+
703
+ const ids = Array.from(state.sessions.keys());
704
+ if (ids.length < 2) {
705
+ wrap.classList.remove('visible');
706
+ grid.innerHTML = '';
707
+ return;
708
+ }
709
+
710
+ wrap.classList.add('visible');
711
+ grid.innerHTML = '';
712
+
713
+ ids.forEach((id, idx) => {
714
+ const entry = state.sessions.get(id);
715
+ if (!entry) return;
716
+ const meta = entry.session?.meta || {};
717
+ const tile = document.createElement('button');
718
+ tile.className = 'switcher-tile';
719
+ tile.type = 'button';
720
+ tile.dataset.sessionId = id;
721
+ tile.title = `${getTypeLabel(meta.type || 'shell')}${meta.project ? ' · ' + meta.project : ''} — ${meta.status || ''}`;
722
+ tile.textContent = String(idx + 1);
723
+ if (state.focusedId === id) tile.classList.add('active');
724
+ if (entry.el && entry.el.classList.contains('exited')) tile.classList.add('exited');
725
+
726
+ const dot = document.createElement('span');
727
+ dot.className = 'switcher-dot';
728
+ dot.style.background = getStatusColor(meta.status || 'idle');
729
+ tile.appendChild(dot);
730
+
731
+ if (meta.project) {
732
+ const bar = document.createElement('span');
733
+ bar.className = 'switcher-bar';
734
+ bar.style.background = getProjectBarColor(meta.project);
735
+ tile.appendChild(bar);
736
+ }
737
+
738
+ tile.addEventListener('click', (e) => {
739
+ e.preventDefault();
740
+ focusSessionById(id);
741
+ });
742
+
743
+ grid.appendChild(tile);
744
+ });
745
+ }
746
+
747
+ // Pull the CSS-var color for a project tag, falling back to gray
748
+ function getProjectBarColor(project) {
749
+ const cls = `project-${project.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;
750
+ const probe = document.createElement('span');
751
+ probe.className = cls;
752
+ probe.style.display = 'none';
753
+ document.body.appendChild(probe);
754
+ const color = getComputedStyle(probe).color;
755
+ document.body.removeChild(probe);
756
+ return color || '#6b7089';
757
+ }
758
+
759
+ function focusSessionById(id) {
760
+ const entry = state.sessions.get(id);
761
+ if (!entry) return;
762
+
763
+ // If we're in focus-mode, swap which panel is the focused one
764
+ const grid = document.getElementById('termGrid');
765
+ if (grid.classList.contains('layout-focus')) {
766
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('focused'));
767
+ entry.el.classList.add('focused');
768
+ } else if (grid.classList.contains('layout-half')) {
769
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('primary'));
770
+ entry.el.classList.add('primary');
771
+ }
772
+
773
+ // Focus the xterm textarea (without stealing pointer)
774
+ try { entry.terminal.focus(); } catch (err) { /* ignore */ }
775
+ state.focusedId = id;
776
+
777
+ // Flash the panel border briefly
778
+ entry.el.classList.remove('focus-flash');
779
+ // Force reflow so the animation restarts on rapid switches
780
+ void entry.el.offsetWidth;
781
+ entry.el.classList.add('focus-flash');
782
+ clearTimeout(entry._focusFlashTimer);
783
+ entry._focusFlashTimer = setTimeout(() => {
784
+ entry.el.classList.remove('focus-flash');
785
+ }, 600);
786
+
787
+ // Refit if layout changed (focus / half swap)
788
+ requestAnimationFrame(() => fitAll());
789
+ renderSwitcher();
790
+ }
791
+
792
+ function focusNthSession(n) {
793
+ const ids = Array.from(state.sessions.keys());
794
+ if (ids.length === 0) return;
795
+ if (n < 1 || n > ids.length) return;
796
+ focusSessionById(ids[n - 1]);
797
+ }
798
+
799
+ function cycleSessionFocus() {
800
+ const ids = Array.from(state.sessions.keys());
801
+ if (ids.length === 0) return;
802
+ const curIdx = ids.indexOf(state.focusedId);
803
+ const next = curIdx < 0 ? 0 : (curIdx + 1) % ids.length;
804
+ focusSessionById(ids[next]);
805
+ }
806
+
807
+ // ===== Panel info drawer (T1.1) =====
808
+ function setupDrawerListeners(id) {
809
+ const drawer = document.getElementById(`drawer-${id}`);
810
+ if (!drawer) return;
811
+
812
+ // Tab clicks
813
+ drawer.querySelectorAll('.drawer-tab').forEach(tab => {
814
+ tab.addEventListener('click', (e) => {
815
+ e.stopPropagation();
816
+ toggleDrawerTab(id, tab.dataset.tab);
817
+ });
818
+ });
819
+
820
+ // Commands tab — click a row to copy
821
+ const cmdContainer = drawer.querySelector('[data-panel="commands"]');
822
+ cmdContainer.addEventListener('click', (e) => {
823
+ const row = e.target.closest('.drawer-row');
824
+ if (!row || !row.dataset.command) return;
825
+ copyRowText(row, row.dataset.command);
826
+ });
827
+
828
+ // Memory tab — click a row to expand inline
829
+ const memContainer = drawer.querySelector('[data-panel="memory"]');
830
+ memContainer.addEventListener('click', (e) => {
831
+ const row = e.target.closest('.drawer-row');
832
+ if (!row) return;
833
+ row.classList.toggle('expanded');
834
+ });
835
+ }
836
+
837
+ function toggleDrawerTab(id, tabName) {
838
+ const entry = state.sessions.get(id);
839
+ if (!entry) return;
840
+ const drawer = document.getElementById(`drawer-${id}`);
841
+ if (!drawer) return;
842
+
843
+ const wasOpen = !!entry.drawerOpen;
844
+ const prevTab = entry.activeTab || 'overview';
845
+
846
+ // Clicking the same active tab while the drawer is open collapses it
847
+ if (wasOpen && prevTab === tabName) {
848
+ entry.drawerOpen = false;
849
+ drawer.classList.remove('open');
850
+ } else {
851
+ entry.activeTab = tabName;
852
+ entry.drawerOpen = true;
853
+ drawer.classList.add('open');
854
+ drawer.querySelectorAll('.drawer-tab').forEach(t => {
855
+ t.classList.toggle('active', t.dataset.tab === tabName);
856
+ });
857
+ drawer.querySelectorAll('.drawer-panel').forEach(p => {
858
+ p.classList.toggle('active', p.dataset.panel === tabName);
859
+ });
860
+ renderDrawerTab(id, tabName);
861
+ }
862
+
863
+ // Re-fit the terminal after the drawer transitions
864
+ requestAnimationFrame(() => {
865
+ setTimeout(() => {
866
+ try { entry.fitAddon.fit(); } catch (err) { /* ignore */ }
867
+ const ws = entry.ws;
868
+ if (ws && ws.readyState === WebSocket.OPEN) {
869
+ ws.send(JSON.stringify({
870
+ type: 'resize',
871
+ cols: entry.terminal.cols,
872
+ rows: entry.terminal.rows,
873
+ }));
874
+ }
875
+ }, 190);
876
+ });
877
+ }
878
+
879
+ function renderDrawerTab(id, tabName) {
880
+ if (tabName === 'overview') renderOverviewTab(id);
881
+ else if (tabName === 'commands') renderCommandsTab(id);
882
+ else if (tabName === 'memory') renderMemoryTab(id);
883
+ else if (tabName === 'log') renderStatusLogTab(id);
884
+ }
885
+
886
+ function renderOverviewTab(id) {
887
+ const entry = state.sessions.get(id);
888
+ const ov = document.getElementById(`ovmeta-${id}`);
889
+ if (!entry || !ov) return;
890
+ const meta = entry.session?.meta || {};
891
+ const last = meta.lastCommands?.length
892
+ ? meta.lastCommands[meta.lastCommands.length - 1].command
893
+ : '—';
894
+ const parts = [
895
+ ['type', getTypeLabel(meta.type || 'shell')],
896
+ ['project', meta.project || '—'],
897
+ ['status', meta.statusDetail || meta.status || '—'],
898
+ ['opened', meta.createdAt ? timeAgo(meta.createdAt) : '—'],
899
+ ['last', last],
900
+ ];
901
+ if (meta.detectedPort) parts.push(['port', ':' + meta.detectedPort]);
902
+ if (typeof meta.requestCount === 'number' && meta.requestCount > 0) {
903
+ parts.push(['requests', String(meta.requestCount)]);
904
+ }
905
+ ov.innerHTML = parts.map(([k, v]) =>
906
+ `<span><span class="ov-label">${k}</span><span class="ov-value">${escapeHtml(String(v))}</span></span>`
907
+ ).join('');
908
+ }
909
+
910
+ async function renderCommandsTab(id) {
911
+ const entry = state.sessions.get(id);
912
+ const container = document.getElementById(`dp-commands-${id}`);
913
+ if (!entry || !container) return;
914
+
915
+ try {
916
+ const resp = await api('GET', `/api/sessions/${id}/history`);
917
+ const list = Array.isArray(resp) ? resp : (resp.commands || resp.history || []);
918
+ entry.commandHistory = list;
919
+ entry.commandsLoaded = true;
920
+ } catch (err) {
921
+ console.error('[client] failed to load command history:', err);
922
+ if (!entry.commandsLoaded) {
923
+ container.innerHTML = '<div class="empty-msg">Failed to load history.</div>';
924
+ return;
925
+ }
926
+ }
927
+
928
+ // server returns command_history rows ordered DESC (newest first)
929
+ const rows = (entry.commandHistory || []).slice(0, 60);
930
+ if (rows.length === 0) {
931
+ container.innerHTML = '<div class="empty-msg">No commands captured yet.</div>';
932
+ } else {
933
+ container.innerHTML = rows.map(r => {
934
+ const cmd = r.command || r.cmd || '';
935
+ const ts = r.timestamp || r.createdAt || r.created_at || null;
936
+ const src = r.source ? ` · ${escapeHtml(r.source)}` : '';
937
+ return `
938
+ <div class="drawer-row" data-command="${escapeAttr(cmd)}">
939
+ <div class="row-meta"><span>${escapeHtml(ts ? timeAgo(ts) : 'recent')}${src}</span></div>
940
+ <div class="row-cmd">${escapeHtml(cmd)}</div>
941
+ </div>
942
+ `;
943
+ }).join('');
944
+ }
945
+ container.scrollTop = 0;
946
+ setBadge(id, 'commands', entry.commandHistory.length);
947
+ }
948
+
949
+ function renderMemoryTab(id) {
950
+ const entry = state.sessions.get(id);
951
+ const container = document.getElementById(`dp-memory-${id}`);
952
+ if (!entry || !container) return;
953
+
954
+ const hits = entry.memoryHits || [];
955
+ if (hits.length === 0) {
956
+ container.innerHTML = '<div class="empty-msg">No memory hits yet. Ask about this terminal or wait for a proactive lookup.</div>';
957
+ setBadge(id, 'memory', 0);
958
+ return;
959
+ }
960
+
961
+ const rows = hits.slice(0, 40);
962
+ container.innerHTML = rows.map(m => {
963
+ const score = typeof m.similarity === 'number' ? `${(m.similarity * 100).toFixed(0)}%` : '';
964
+ const proj = m.project ? escapeHtml(m.project) : '';
965
+ const type = escapeHtml(m.source_type || m.sourceType || 'memory');
966
+ const ts = m.cachedAt ? timeAgo(m.cachedAt) : '';
967
+ return `
968
+ <div class="drawer-row">
969
+ <div class="row-meta">
970
+ <span>${type}</span>
971
+ ${proj ? `<span>${proj}</span>` : ''}
972
+ ${score ? `<span>${score}</span>` : ''}
973
+ ${ts ? `<span>${ts}</span>` : ''}
974
+ </div>
975
+ <div class="row-content">${escapeHtml(m.content || m.text || '(empty)')}</div>
976
+ </div>
977
+ `;
978
+ }).join('');
979
+ setBadge(id, 'memory', hits.length);
980
+ }
981
+
982
+ function renderStatusLogTab(id) {
983
+ const entry = state.sessions.get(id);
984
+ const container = document.getElementById(`dp-log-${id}`);
985
+ if (!entry || !container) return;
986
+
987
+ const log = entry.statusLog || [];
988
+ if (log.length === 0) {
989
+ container.innerHTML = '<div class="empty-msg">No status transitions recorded yet.</div>';
990
+ setBadge(id, 'log', 0);
991
+ return;
992
+ }
993
+
994
+ const rows = log.slice().reverse();
995
+ container.innerHTML = rows.map(ev => {
996
+ const color = getStatusColor(ev.status);
997
+ const t = new Date(ev.at);
998
+ const hh = String(t.getHours()).padStart(2, '0');
999
+ const mm = String(t.getMinutes()).padStart(2, '0');
1000
+ const ss = String(t.getSeconds()).padStart(2, '0');
1001
+ return `
1002
+ <div class="status-log-row">
1003
+ <span class="ts">${hh}:${mm}:${ss}</span>
1004
+ <span class="chip" style="color:${color}">${escapeHtml(ev.status)}</span>
1005
+ ${ev.detail ? `<span class="detail">${escapeHtml(ev.detail)}</span>` : ''}
1006
+ </div>
1007
+ `;
1008
+ }).join('');
1009
+ container.scrollTop = 0;
1010
+ setBadge(id, 'log', log.length);
1011
+ }
1012
+
1013
+ function appendStatusLog(id, status, detail) {
1014
+ const entry = state.sessions.get(id);
1015
+ if (!entry) return;
1016
+ if (!entry.statusLog) entry.statusLog = [];
1017
+ entry.statusLog.push({ at: new Date().toISOString(), status, detail: detail || '' });
1018
+ if (entry.statusLog.length > 500) entry.statusLog.splice(0, entry.statusLog.length - 500);
1019
+ setBadge(id, 'log', entry.statusLog.length);
1020
+ if (entry.drawerOpen && entry.activeTab === 'log') {
1021
+ renderStatusLogTab(id);
1022
+ }
1023
+ }
1024
+
1025
+ function setBadge(id, tab, count) {
1026
+ const el = document.getElementById(`badge-${tab}-${id}`);
1027
+ if (el) el.textContent = String(count);
1028
+ }
1029
+
1030
+ function copyRowText(row, text) {
1031
+ const done = () => {
1032
+ row.classList.add('copied');
1033
+ setTimeout(() => row.classList.remove('copied'), 700);
1034
+ };
1035
+ if (navigator.clipboard?.writeText) {
1036
+ navigator.clipboard.writeText(text).then(done).catch(err => {
1037
+ console.error('[client] clipboard write failed:', err);
1038
+ });
1039
+ } else {
1040
+ try {
1041
+ const ta = document.createElement('textarea');
1042
+ ta.value = text;
1043
+ ta.style.position = 'fixed';
1044
+ ta.style.opacity = '0';
1045
+ document.body.appendChild(ta);
1046
+ ta.select();
1047
+ document.execCommand('copy');
1048
+ document.body.removeChild(ta);
1049
+ done();
1050
+ } catch (err) { console.error('[client] fallback copy failed:', err); }
1051
+ }
1052
+ }
1053
+
1054
+ function escapeAttr(str) {
1055
+ return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1056
+ }
1057
+
1058
+ // ===== Panel actions =====
1059
+ function focusPanel(id) {
1060
+ const grid = document.getElementById('termGrid');
1061
+ const isAlreadyFocused = grid.classList.contains('layout-focus') && state.focusedId === id;
1062
+
1063
+ if (isAlreadyFocused) {
1064
+ // Restore previous layout
1065
+ setLayout(state.layout);
1066
+ document.querySelectorAll('.term-panel').forEach(p => {
1067
+ p.classList.remove('focused');
1068
+ p.style.display = '';
1069
+ });
1070
+ } else {
1071
+ grid.className = 'grid-container layout-focus';
1072
+ document.querySelectorAll('.term-panel').forEach(p => {
1073
+ p.classList.remove('focused');
1074
+ });
1075
+ const panel = document.getElementById(`panel-${id}`);
1076
+ if (panel) panel.classList.add('focused');
1077
+ state.focusedId = id;
1078
+ }
1079
+
1080
+ // Re-fit all visible terminals
1081
+ requestAnimationFrame(() => fitAll());
1082
+ }
1083
+
1084
+ function reconnectSession(id) {
1085
+ const entry = state.sessions.get(id);
1086
+ if (!entry) return;
1087
+
1088
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
1089
+
1090
+ ws.onmessage = (event) => {
1091
+ try {
1092
+ const msg = JSON.parse(event.data);
1093
+ switch (msg.type) {
1094
+ case 'output':
1095
+ entry.terminal.write(msg.data);
1096
+ break;
1097
+ case 'meta':
1098
+ updatePanelMeta(id, msg.session.meta);
1099
+ break;
1100
+ case 'exit':
1101
+ updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
1102
+ const p = document.getElementById(`panel-${id}`);
1103
+ if (p) p.classList.add('exited');
1104
+ break;
1105
+ case 'status_broadcast':
1106
+ updateGlobalStats(msg.sessions);
1107
+ break;
1108
+ }
1109
+ } catch (err) { console.error('[client] reconnect ws message failed:', err); }
1110
+ };
1111
+
1112
+ ws.onopen = () => {
1113
+ console.log(`[ws] Reconnected session ${id}`);
1114
+ entry._reconnectAttempts = 0;
1115
+ entry.ws = ws;
1116
+ updatePanelMeta(id, { status: 'active', statusDetail: 'Reconnected' });
1117
+ };
1118
+
1119
+ ws.onclose = (event) => {
1120
+ const panel = document.getElementById(`panel-${id}`);
1121
+ if (panel && panel.classList.contains('exited')) return;
1122
+ if (event.code === 4001) {
1123
+ // Session no longer exists on server
1124
+ updatePanelMeta(id, { status: 'exited', statusDetail: 'Session ended' });
1125
+ if (panel) panel.classList.add('exited');
1126
+ return;
1127
+ }
1128
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
1129
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
1130
+ if (entry._reconnectAttempts <= 5) {
1131
+ setTimeout(() => reconnectSession(id), delay);
1132
+ } else {
1133
+ updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
1134
+ }
1135
+ };
1136
+
1137
+ // Re-wire terminal input
1138
+ entry.terminal.onData((data) => {
1139
+ if (ws.readyState === WebSocket.OPEN) {
1140
+ ws.send(JSON.stringify({ type: 'input', data }));
1141
+ }
1142
+ });
1143
+ }
1144
+
1145
+ function halfPanel(id) {
1146
+ const grid = document.getElementById('termGrid');
1147
+ grid.className = 'grid-container layout-half';
1148
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('primary'));
1149
+ const panel = document.getElementById(`panel-${id}`);
1150
+ if (panel) panel.classList.add('primary');
1151
+ requestAnimationFrame(() => fitAll());
1152
+ }
1153
+
1154
+ async function closePanel(id) {
1155
+ if (!confirm('Close this terminal? The process will be killed.')) return;
1156
+
1157
+ await api('DELETE', `/api/sessions/${id}`);
1158
+
1159
+ const entry = state.sessions.get(id);
1160
+ if (entry) {
1161
+ entry.terminal.dispose();
1162
+ entry.ws.close();
1163
+ entry.el.remove();
1164
+ state.sessions.delete(id);
1165
+ }
1166
+
1167
+ updateEmptyState();
1168
+ renderSwitcher();
1169
+ refreshAllReplyFormsFor(id);
1170
+ refreshPanelIndices();
1171
+ }
1172
+
1173
+ function changeTheme(id, themeId) {
1174
+ const entry = state.sessions.get(id);
1175
+ if (!entry) return;
1176
+
1177
+ const themeObj = getThemeObject(themeId);
1178
+ entry.terminal.options.theme = themeObj;
1179
+
1180
+ // Persist to server
1181
+ api('PATCH', `/api/sessions/${id}`, { theme: themeId });
1182
+ }
1183
+
1184
+ async function askAI(id, question) {
1185
+ if (!question.trim()) return;
1186
+ const entry = state.sessions.get(id);
1187
+ if (!entry) return;
1188
+
1189
+ // Early return if AI queries are not available
1190
+ if (!state.config.aiQueryAvailable) {
1191
+ entry.terminal.write(
1192
+ '\r\n\x1b[33m[mnestra] AI queries are not available.\x1b[0m\r\n' +
1193
+ '\x1b[33mTo enable, add the following to ~/.termdeck/config.yaml:\x1b[0m\r\n' +
1194
+ '\x1b[90m rag:\r\n' +
1195
+ ' supabaseUrl: https://your-project.supabase.co\r\n' +
1196
+ ' supabaseKey: your-anon-key\r\n' +
1197
+ ' openaiApiKey: sk-...\x1b[0m\r\n'
1198
+ );
1199
+ return;
1200
+ }
1201
+
1202
+ const inputEl = document.getElementById(`ai-${id}`);
1203
+ inputEl.value = 'Searching memories...';
1204
+ inputEl.disabled = true;
1205
+
1206
+ try {
1207
+ const result = await api('POST', '/api/ai/query', {
1208
+ question,
1209
+ sessionId: id,
1210
+ project: entry.session?.meta?.project || null
1211
+ });
1212
+
1213
+ if (result.error) {
1214
+ entry.terminal.write(`\r\n\x1b[33m[mnestra] ${result.error}\x1b[0m\r\n`);
1215
+ } else if (result.memories && result.memories.length > 0) {
1216
+ // Cache hits for the Memory tab
1217
+ if (!entry.memoryHits) entry.memoryHits = [];
1218
+ const cachedAt = new Date().toISOString();
1219
+ for (const m of result.memories) {
1220
+ entry.memoryHits.unshift({ ...m, cachedAt });
1221
+ }
1222
+ if (entry.memoryHits.length > 60) {
1223
+ entry.memoryHits.length = 60;
1224
+ }
1225
+ setBadge(id, 'memory', entry.memoryHits.length);
1226
+ if (entry.drawerOpen && entry.activeTab === 'memory') {
1227
+ renderMemoryTab(id);
1228
+ }
1229
+ const cols = entry.terminal.cols || 80;
1230
+ const wrap = (text, indent) => {
1231
+ const maxW = cols - indent - 2;
1232
+ const words = text.split(/\s+/);
1233
+ const lines = [];
1234
+ let line = '';
1235
+ for (const w of words) {
1236
+ if (line.length + w.length + 1 > maxW && line.length > 0) {
1237
+ lines.push(' '.repeat(indent) + line);
1238
+ line = w;
1239
+ } else {
1240
+ line = line ? line + ' ' + w : w;
1241
+ }
1242
+ }
1243
+ if (line) lines.push(' '.repeat(indent) + line);
1244
+ return lines;
1245
+ };
1246
+
1247
+ entry.terminal.write(`\r\n\x1b[36m━━━ Mnestra: ${result.total} memories found ━━━\x1b[0m\r\n`);
1248
+ for (const m of result.memories) {
1249
+ const score = m.similarity ? `${(m.similarity * 100).toFixed(0)}%` : '';
1250
+ const proj = m.project ? m.project : '';
1251
+ entry.terminal.write(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
1252
+ const contentLines = wrap(m.content || '(empty)', 2);
1253
+ for (const cl of contentLines) {
1254
+ entry.terminal.write(`${cl}\r\n`);
1255
+ }
1256
+ }
1257
+ entry.terminal.write(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
1258
+ } else {
1259
+ entry.terminal.write(`\r\n\x1b[33m[mnestra] No relevant memories found.\x1b[0m\r\n`);
1260
+ }
1261
+ } catch (err) {
1262
+ console.error('[client] AI query failed:', err);
1263
+ entry.terminal.write(`\r\n\x1b[31m[mnestra] Query failed: ${err.message}\x1b[0m\r\n`);
1264
+ }
1265
+
1266
+ inputEl.value = '';
1267
+ inputEl.disabled = false;
1268
+ inputEl.placeholder = 'Ask about this terminal...';
1269
+ }
1270
+
1271
+ // ===== Quick launch from empty state =====
1272
+ function quickLaunch(cmd) {
1273
+ document.getElementById('promptInput').value = cmd;
1274
+ launchTerminal();
1275
+ }
1276
+
1277
+ // ===== Add Project modal =====
1278
+ function rebuildProjectDropdown(selectName) {
1279
+ const sel = document.getElementById('promptProject');
1280
+ if (!sel) return;
1281
+ const prev = selectName || sel.value;
1282
+ sel.innerHTML = '<option value="">no project</option>';
1283
+ for (const name of Object.keys(state.config.projects || {})) {
1284
+ const opt = document.createElement('option');
1285
+ opt.value = name;
1286
+ opt.textContent = name;
1287
+ sel.appendChild(opt);
1288
+ }
1289
+ if (prev && state.config.projects && state.config.projects[prev]) {
1290
+ sel.value = prev;
1291
+ }
1292
+ }
1293
+
1294
+ function openAddProjectModal() {
1295
+ const modal = document.getElementById('addProjectModal');
1296
+ // Populate theme dropdown from loaded themes
1297
+ const themeSel = document.getElementById('apmTheme');
1298
+ themeSel.innerHTML = '<option value="">— pick a theme —</option>';
1299
+ for (const [tid, t] of Object.entries(state.themes || {})) {
1300
+ const opt = document.createElement('option');
1301
+ opt.value = tid;
1302
+ opt.textContent = t.label || tid;
1303
+ themeSel.appendChild(opt);
1304
+ }
1305
+ // Clear fields
1306
+ document.getElementById('apmName').value = '';
1307
+ document.getElementById('apmPath').value = '';
1308
+ document.getElementById('apmCommand').value = '';
1309
+ document.getElementById('apmTheme').value = '';
1310
+ setApmStatus('', null);
1311
+ modal.classList.add('open');
1312
+ setTimeout(() => document.getElementById('apmName').focus(), 50);
1313
+ }
1314
+
1315
+ function closeAddProjectModal() {
1316
+ document.getElementById('addProjectModal').classList.remove('open');
1317
+ }
1318
+
1319
+ function setApmStatus(msg, kind) {
1320
+ const el = document.getElementById('apmStatus');
1321
+ el.textContent = msg || '';
1322
+ el.classList.remove('error', 'ok');
1323
+ if (kind) el.classList.add(kind);
1324
+ }
1325
+
1326
+ async function submitAddProject() {
1327
+ const name = document.getElementById('apmName').value.trim();
1328
+ const projectPath = document.getElementById('apmPath').value.trim();
1329
+ const defaultCommand = document.getElementById('apmCommand').value.trim();
1330
+ const defaultTheme = document.getElementById('apmTheme').value;
1331
+
1332
+ if (!name) { setApmStatus('Name is required.', 'error'); return; }
1333
+ if (!projectPath) { setApmStatus('Path is required.', 'error'); return; }
1334
+
1335
+ const saveBtn = document.getElementById('apmSave');
1336
+ saveBtn.disabled = true;
1337
+ setApmStatus('Saving…', null);
1338
+
1339
+ try {
1340
+ const result = await api('POST', '/api/projects', {
1341
+ name,
1342
+ path: projectPath,
1343
+ defaultCommand: defaultCommand || undefined,
1344
+ defaultTheme: defaultTheme || undefined,
1345
+ });
1346
+ if (result && result.error) {
1347
+ setApmStatus(result.error, 'error');
1348
+ saveBtn.disabled = false;
1349
+ return;
1350
+ }
1351
+ // Merge the updated projects into in-memory state.config so subsequent
1352
+ // launches can immediately use the new project.
1353
+ state.config.projects = result.projects || {};
1354
+ rebuildProjectDropdown(name);
1355
+ setApmStatus(`Added "${name}" ✓`, 'ok');
1356
+ setTimeout(() => { closeAddProjectModal(); saveBtn.disabled = false; }, 700);
1357
+ } catch (err) {
1358
+ setApmStatus(`Failed: ${err.message || err}`, 'error');
1359
+ saveBtn.disabled = false;
1360
+ }
1361
+ }
1362
+
1363
+ // ===== Rumen insights badge + briefing modal =====
1364
+ function rumenState() {
1365
+ if (!state.rumen) {
1366
+ state.rumen = {
1367
+ enabled: false,
1368
+ status: null,
1369
+ insights: [],
1370
+ total: 0,
1371
+ unseen: 0,
1372
+ filters: { project: '', sort: 'newest', unseen: false },
1373
+ pollTimer: null,
1374
+ modalOpen: false,
1375
+ prevFocus: null,
1376
+ };
1377
+ }
1378
+ return state.rumen;
1379
+ }
1380
+
1381
+ function rumenRelTime(iso) {
1382
+ if (!iso) return '—';
1383
+ const t = new Date(iso).getTime();
1384
+ if (!Number.isFinite(t)) return '—';
1385
+ const delta = Math.max(0, Date.now() - t);
1386
+ const s = Math.floor(delta / 1000);
1387
+ if (s < 60) return `${s}s ago`;
1388
+ const m = Math.floor(s / 60);
1389
+ if (m < 60) return `${m}m ago`;
1390
+ const h = Math.floor(m / 60);
1391
+ if (h < 24) return `${h}h ago`;
1392
+ const d = Math.floor(h / 24);
1393
+ return `${d}d ago`;
1394
+ }
1395
+
1396
+ function rumenEscape(str) {
1397
+ return String(str == null ? '' : str)
1398
+ .replace(/&/g, '&amp;')
1399
+ .replace(/</g, '&lt;')
1400
+ .replace(/>/g, '&gt;')
1401
+ .replace(/"/g, '&quot;')
1402
+ .replace(/'/g, '&#39;');
1403
+ }
1404
+
1405
+ function renderRumenBadge() {
1406
+ const r = rumenState();
1407
+ const badge = document.getElementById('rumenBadge');
1408
+ const label = document.getElementById('rumenBadgeLabel');
1409
+ if (!badge || !label) return;
1410
+ if (!r.enabled) {
1411
+ badge.classList.remove('visible', 'has-unseen');
1412
+ return;
1413
+ }
1414
+ badge.classList.add('visible');
1415
+ const unseen = r.unseen | 0;
1416
+ const total = r.total | 0;
1417
+ if (unseen > 0) {
1418
+ badge.classList.add('has-unseen');
1419
+ label.textContent = `${unseen} new insight${unseen === 1 ? '' : 's'}`;
1420
+ } else {
1421
+ badge.classList.remove('has-unseen');
1422
+ label.textContent = `${total} insight${total === 1 ? '' : 's'}`;
1423
+ }
1424
+ }
1425
+
1426
+ async function fetchRumenStatus() {
1427
+ try {
1428
+ const data = await api('GET', '/api/rumen/status');
1429
+ const r = rumenState();
1430
+ if (data && data.enabled === true) {
1431
+ r.enabled = true;
1432
+ r.status = data;
1433
+ r.total = data.total_insights | 0;
1434
+ r.unseen = data.unseen_insights | 0;
1435
+ } else {
1436
+ r.enabled = false;
1437
+ r.status = data || { enabled: false };
1438
+ }
1439
+ renderRumenBadge();
1440
+ if (r.modalOpen) renderRumenSummary();
1441
+ } catch (err) {
1442
+ console.warn('[rumen] status fetch failed', err);
1443
+ }
1444
+ }
1445
+
1446
+ function buildRumenQuery() {
1447
+ const r = rumenState();
1448
+ const qs = new URLSearchParams();
1449
+ qs.set('limit', '50');
1450
+ if (r.filters.project) qs.set('project', r.filters.project);
1451
+ if (r.filters.unseen) qs.set('unseen', 'true');
1452
+ return qs.toString();
1453
+ }
1454
+
1455
+ async function fetchRumenInsights() {
1456
+ const r = rumenState();
1457
+ try {
1458
+ const data = await api('GET', `/api/rumen/insights?${buildRumenQuery()}`);
1459
+ if (data && data.enabled === false) {
1460
+ r.enabled = false;
1461
+ r.insights = [];
1462
+ r.total = 0;
1463
+ renderRumenBadge();
1464
+ renderRumenList();
1465
+ return;
1466
+ }
1467
+ let list = Array.isArray(data?.insights) ? data.insights : [];
1468
+ if (r.filters.sort === 'confidence') {
1469
+ list = list.slice().sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
1470
+ }
1471
+ r.insights = list;
1472
+ renderRumenList();
1473
+ renderRumenSummary();
1474
+ } catch (err) {
1475
+ console.warn('[rumen] insights fetch failed', err);
1476
+ const list = document.getElementById('rumenList');
1477
+ if (list) list.innerHTML = '<div class="rumen-empty">Could not load insights. Try again.</div>';
1478
+ }
1479
+ }
1480
+
1481
+ function renderRumenSummary() {
1482
+ const r = rumenState();
1483
+ const summary = document.getElementById('rumenSummary');
1484
+ const title = document.getElementById('rumenTitle');
1485
+ if (!summary || !title) return;
1486
+ const total = r.total | 0;
1487
+ const unseen = r.unseen | 0;
1488
+ title.textContent = `Rumen Insights — ${total} total · ${unseen} new`;
1489
+ const s = r.status || {};
1490
+ if (s.last_job_completed_at) {
1491
+ summary.textContent =
1492
+ `Last processed: ${s.last_job_sessions_processed | 0} sessions → ` +
1493
+ `${s.last_job_insights_generated | 0} insights · ${rumenRelTime(s.last_job_completed_at)}`;
1494
+ } else if (s.last_job_status) {
1495
+ summary.textContent = `Last job: ${s.last_job_status}`;
1496
+ } else {
1497
+ summary.textContent = 'No jobs have run yet.';
1498
+ }
1499
+ }
1500
+
1501
+ function renderRumenList() {
1502
+ const r = rumenState();
1503
+ const list = document.getElementById('rumenList');
1504
+ if (!list) return;
1505
+ if (!r.insights.length) {
1506
+ list.innerHTML = '<div class="rumen-empty">No insights match the current filter.</div>';
1507
+ return;
1508
+ }
1509
+ const rows = r.insights.map((ins) => {
1510
+ const chips = (ins.projects || []).map((p) => `<span class="ri-chip">${rumenEscape(p)}</span>`).join('');
1511
+ const conf = typeof ins.confidence === 'number' ? `conf ${ins.confidence.toFixed(2)}` : 'conf —';
1512
+ const seen = ins.acted_upon === true;
1513
+ const btnLabel = seen ? 'seen ✓' : 'mark seen';
1514
+ const btnClass = seen ? 'ri-mark seen' : 'ri-mark';
1515
+ const btnDisabled = seen ? 'disabled' : '';
1516
+ return (
1517
+ `<div class="rumen-item" role="listitem" data-id="${rumenEscape(ins.id)}">` +
1518
+ `<div class="ri-text">${rumenEscape(ins.insight_text)}</div>` +
1519
+ `<div class="ri-meta">` +
1520
+ `<span class="ri-conf">${conf}</span>` +
1521
+ chips +
1522
+ `<span class="ri-time">${rumenRelTime(ins.created_at)}</span>` +
1523
+ `<button type="button" class="${btnClass}" data-seen-id="${rumenEscape(ins.id)}" ${btnDisabled}>${btnLabel}</button>` +
1524
+ `</div>` +
1525
+ `</div>`
1526
+ );
1527
+ });
1528
+ list.innerHTML = rows.join('');
1529
+ }
1530
+
1531
+ function populateRumenProjectFilter() {
1532
+ const r = rumenState();
1533
+ const sel = document.getElementById('rumenFilterProject');
1534
+ if (!sel) return;
1535
+ const prev = sel.value;
1536
+ const projects = new Set();
1537
+ for (const ins of r.insights) {
1538
+ for (const p of (ins.projects || [])) projects.add(p);
1539
+ }
1540
+ for (const name of Object.keys(state.config?.projects || {})) projects.add(name);
1541
+ const opts = Array.from(projects).sort();
1542
+ sel.innerHTML = '<option value="">all</option>' +
1543
+ opts.map((p) => `<option value="${rumenEscape(p)}">${rumenEscape(p)}</option>`).join('');
1544
+ if (prev && opts.includes(prev)) sel.value = prev;
1545
+ }
1546
+
1547
+ async function markRumenInsightSeen(id) {
1548
+ const r = rumenState();
1549
+ const sel = (attr) => `[${attr}="${(window.CSS && CSS.escape) ? CSS.escape(id) : id}"]`;
1550
+ const item = document.querySelector(`.rumen-item${sel('data-id')}`);
1551
+ const btn = document.querySelector(`.ri-mark${sel('data-seen-id')}`);
1552
+ if (btn) btn.disabled = true;
1553
+ const insight = r.insights.find((i) => i.id === id);
1554
+ const wasUnseen = insight && insight.acted_upon === false;
1555
+ if (insight) insight.acted_upon = true;
1556
+ if (wasUnseen && r.unseen > 0) r.unseen -= 1;
1557
+ renderRumenBadge();
1558
+ if (item) item.classList.add('fading');
1559
+ try {
1560
+ const resp = await fetch(`${API}/api/rumen/insights/${encodeURIComponent(id)}/seen`, {
1561
+ method: 'POST',
1562
+ headers: { 'Content-Type': 'application/json' },
1563
+ });
1564
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1565
+ setTimeout(() => {
1566
+ if (r.filters.unseen) {
1567
+ r.insights = r.insights.filter((i) => i.id !== id);
1568
+ renderRumenList();
1569
+ } else if (item) {
1570
+ item.classList.remove('fading');
1571
+ const b = item.querySelector('.ri-mark');
1572
+ if (b) { b.classList.add('seen'); b.textContent = 'seen ✓'; b.disabled = true; }
1573
+ }
1574
+ }, 360);
1575
+ } catch (err) {
1576
+ console.warn('[rumen] mark-seen failed', err);
1577
+ if (insight) insight.acted_upon = false;
1578
+ if (wasUnseen) r.unseen += 1;
1579
+ renderRumenBadge();
1580
+ if (item) item.classList.remove('fading');
1581
+ if (btn) { btn.disabled = false; btn.textContent = 'retry'; }
1582
+ }
1583
+ }
1584
+
1585
+ function openRumenModal() {
1586
+ const r = rumenState();
1587
+ if (!r.enabled) return;
1588
+ r.modalOpen = true;
1589
+ r.prevFocus = document.activeElement;
1590
+ document.getElementById('rumenModal').classList.add('open');
1591
+ populateRumenProjectFilter();
1592
+ renderRumenSummary();
1593
+ fetchRumenInsights();
1594
+ setTimeout(() => {
1595
+ const close = document.getElementById('rumenClose');
1596
+ if (close) close.focus();
1597
+ }, 30);
1598
+ }
1599
+
1600
+ function closeRumenModal() {
1601
+ const r = rumenState();
1602
+ r.modalOpen = false;
1603
+ document.getElementById('rumenModal').classList.remove('open');
1604
+ if (r.prevFocus && typeof r.prevFocus.focus === 'function') {
1605
+ try { r.prevFocus.focus(); } catch {}
1606
+ }
1607
+ }
1608
+
1609
+ function setupRumen() {
1610
+ const r = rumenState();
1611
+ document.getElementById('rumenBadge').addEventListener('click', openRumenModal);
1612
+ document.getElementById('rumenClose').addEventListener('click', closeRumenModal);
1613
+ document.getElementById('rumenBackdrop').addEventListener('click', closeRumenModal);
1614
+ document.getElementById('rumenModal').addEventListener('keydown', (e) => {
1615
+ if (e.key === 'Escape') { e.preventDefault(); closeRumenModal(); }
1616
+ });
1617
+ document.getElementById('rumenFilterProject').addEventListener('change', (e) => {
1618
+ r.filters.project = e.target.value || '';
1619
+ fetchRumenInsights();
1620
+ });
1621
+ document.getElementById('rumenFilterSort').addEventListener('change', (e) => {
1622
+ r.filters.sort = e.target.value || 'newest';
1623
+ if (r.filters.sort === 'confidence') {
1624
+ r.insights = r.insights.slice().sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
1625
+ } else {
1626
+ r.insights = r.insights.slice().sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
1627
+ }
1628
+ renderRumenList();
1629
+ });
1630
+ document.getElementById('rumenFilterUnseen').addEventListener('change', (e) => {
1631
+ r.filters.unseen = !!e.target.checked;
1632
+ fetchRumenInsights();
1633
+ });
1634
+ document.getElementById('rumenList').addEventListener('click', (e) => {
1635
+ const btn = e.target.closest('.ri-mark');
1636
+ if (!btn || btn.disabled) return;
1637
+ const id = btn.getAttribute('data-seen-id');
1638
+ if (id) markRumenInsightSeen(id);
1639
+ });
1640
+ fetchRumenStatus();
1641
+ r.pollTimer = setInterval(fetchRumenStatus, 60000);
1642
+ }
1643
+
1644
+ // ===== Launch terminal =====
1645
+ async function launchTerminal() {
1646
+ const input = document.getElementById('promptInput');
1647
+ const project = document.getElementById('promptProject').value;
1648
+ let command = input.value.trim();
1649
+
1650
+ // If the input is empty but the selected project has a defaultCommand,
1651
+ // use it. That way "select project + click launch" actually runs the
1652
+ // project's declared default (e.g. `claude`) instead of silently falling
1653
+ // through to the global shell.
1654
+ if (!command && project) {
1655
+ const projectCfg = state.config.projects?.[project];
1656
+ if (projectCfg?.defaultCommand) {
1657
+ command = projectCfg.defaultCommand;
1658
+ }
1659
+ }
1660
+
1661
+ if (!command) {
1662
+ // Still nothing to run — launch a plain shell in the project's cwd
1663
+ const session = await api('POST', '/api/sessions', {
1664
+ project: project || undefined,
1665
+ reason: 'manual launch'
1666
+ });
1667
+ createTerminalPanel(session);
1668
+ input.value = '';
1669
+ updateEmptyState();
1670
+ return;
1671
+ }
1672
+
1673
+ // Parse shorthand commands
1674
+ let resolvedCommand = command;
1675
+ let resolvedType = 'shell';
1676
+ let resolvedCwd = undefined;
1677
+
1678
+ let resolvedProject = project || undefined;
1679
+
1680
+ if (/^claude\b/i.test(command) || /^cc\b/i.test(command)) {
1681
+ resolvedType = 'claude-code';
1682
+ const argMatch = command.match(/(?:claude|cc)\s+(?:code\s+)?(.+)/i);
1683
+ if (argMatch) {
1684
+ const arg = argMatch[1].trim();
1685
+ // Check if arg is a known project name
1686
+ if (state.config.projects && state.config.projects[arg]) {
1687
+ resolvedProject = arg;
1688
+ } else {
1689
+ resolvedCwd = arg;
1690
+ }
1691
+ }
1692
+ resolvedCommand = 'claude';
1693
+ } else if (/^gemini\b/i.test(command)) {
1694
+ resolvedType = 'gemini';
1695
+ } else if (/^python3?\b.*(?:runserver|uvicorn|flask|gunicorn)/i.test(command)) {
1696
+ resolvedType = 'python-server';
1697
+ }
1698
+
1699
+ const session = await api('POST', '/api/sessions', {
1700
+ command: resolvedCommand,
1701
+ cwd: resolvedCwd,
1702
+ project: resolvedProject,
1703
+ type: resolvedType,
1704
+ reason: `launched: ${command}`
1705
+ });
1706
+
1707
+ createTerminalPanel(session);
1708
+ input.value = '';
1709
+ updateEmptyState();
1710
+ }
1711
+
1712
+ // ===== Layout =====
1713
+ function setLayout(layout) {
1714
+ const wasControl = state.layout === 'control';
1715
+ // Only persist "real" grid layouts as state.layout; the control view is
1716
+ // an overlay, not a target to restore to when the user hits Escape.
1717
+ if (layout !== 'control') {
1718
+ state.layout = layout;
1719
+ }
1720
+ const grid = document.getElementById('termGrid');
1721
+ grid.className = `grid-container layout-${layout}`;
1722
+
1723
+ // Remove focus/half states
1724
+ document.querySelectorAll('.term-panel').forEach(p => {
1725
+ p.classList.remove('focused', 'primary');
1726
+ p.style.display = '';
1727
+ });
1728
+
1729
+ // Update buttons
1730
+ document.querySelectorAll('.layout-btn').forEach(b => {
1731
+ b.classList.toggle('active', b.dataset.layout === layout);
1732
+ });
1733
+
1734
+ // Control-mode side effects (T1.6)
1735
+ if (layout === 'control') {
1736
+ enterControlMode();
1737
+ } else if (wasControl) {
1738
+ // Leaving control — nothing to clean up; feed stays hidden via CSS
1739
+ }
1740
+
1741
+ requestAnimationFrame(() => fitAll());
1742
+ }
1743
+
1744
+ // ===== Helpers =====
1745
+ function getStatusColor(status) {
1746
+ const colors = {
1747
+ starting: '#7aa2f7',
1748
+ active: '#9ece6a',
1749
+ idle: '#6b7089',
1750
+ thinking: '#bb9af7',
1751
+ editing: '#e0af68',
1752
+ listening: '#7dcfff',
1753
+ errored: '#f7768e',
1754
+ exited: '#414868'
1755
+ };
1756
+ return colors[status] || '#6b7089';
1757
+ }
1758
+
1759
+ function getTypeLabel(type) {
1760
+ const labels = {
1761
+ 'shell': 'Shell',
1762
+ 'claude-code': 'Claude Code',
1763
+ 'gemini': 'Gemini CLI',
1764
+ 'python-server': 'Python Server',
1765
+ 'one-shot': 'One-shot'
1766
+ };
1767
+ return labels[type] || type;
1768
+ }
1769
+
1770
+ function getThemeObject(themeId) {
1771
+ // Fetch full theme from server cache or use fallback
1772
+ const known = state.themes[themeId];
1773
+ if (known?.theme) return known.theme;
1774
+ // Minimal fallback
1775
+ return { background: '#1a1b26', foreground: '#c0caf5' };
1776
+ }
1777
+
1778
+ function timeAgo(isoString) {
1779
+ const diff = Date.now() - new Date(isoString).getTime();
1780
+ const mins = Math.floor(diff / 60000);
1781
+ if (mins < 1) return 'just now';
1782
+ if (mins < 60) return `${mins}m ago`;
1783
+ const hrs = Math.floor(mins / 60);
1784
+ if (hrs < 24) return `${hrs}h ago`;
1785
+ return `${Math.floor(hrs / 24)}d ago`;
1786
+ }
1787
+
1788
+ function updatePanelMeta(id, meta) {
1789
+ // Track status transitions into the per-panel status log
1790
+ const entry = state.sessions.get(id);
1791
+ if (entry && meta.status && meta.status !== entry.lastKnownStatus) {
1792
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
1793
+ // Proactive memory lookup on entering the errored state (T1.4)
1794
+ if (meta.status === 'errored') {
1795
+ // Fire-and-forget; own rate limiting lives inside the function.
1796
+ triggerProactiveMemoryQuery(id);
1797
+ }
1798
+ entry.lastKnownStatus = meta.status;
1799
+ }
1800
+ // Keep the cached session.meta fresh so the overview tab renders current data
1801
+ if (entry && entry.session) {
1802
+ entry.session.meta = { ...entry.session.meta, ...meta };
1803
+ }
1804
+
1805
+ const dot = document.getElementById(`dot-${id}`);
1806
+ const status = document.getElementById(`status-${id}`);
1807
+ const metaLast = document.getElementById(`meta-last-${id}`);
1808
+ const metaPort = document.getElementById(`meta-port-${id}`);
1809
+ const metaReqs = document.getElementById(`meta-reqs-${id}`);
1810
+
1811
+ if (dot) {
1812
+ dot.style.background = getStatusColor(meta.status);
1813
+ dot.classList.toggle('pulsing', meta.status === 'thinking');
1814
+ }
1815
+ if (status) status.textContent = meta.statusDetail || meta.status;
1816
+ if (metaLast && meta.lastCommands?.length) {
1817
+ metaLast.innerHTML = `<span class="meta-label">last</span> ${escapeHtml(meta.lastCommands[meta.lastCommands.length - 1].command)}`;
1818
+ }
1819
+ if (metaPort) {
1820
+ if (meta.detectedPort) {
1821
+ metaPort.style.display = '';
1822
+ metaPort.querySelector('.meta-value').textContent = ':' + meta.detectedPort;
1823
+ }
1824
+ }
1825
+ if (metaReqs) {
1826
+ if (meta.type === 'python-server' || meta.requestCount > 0) {
1827
+ metaReqs.style.display = '';
1828
+ metaReqs.querySelector('.meta-value').textContent = meta.requestCount || 0;
1829
+ }
1830
+ }
1831
+
1832
+ // If the drawer is showing the overview tab, refresh its metadata block
1833
+ if (entry && entry.drawerOpen && entry.activeTab === 'overview') {
1834
+ renderOverviewTab(id);
1835
+ }
1836
+
1837
+ // Sync theme dropdown if server-side theme changed
1838
+ if (meta.theme) {
1839
+ const themeSelect = document.getElementById(`theme-${id}`);
1840
+ if (themeSelect && themeSelect.value !== meta.theme) {
1841
+ themeSelect.value = meta.theme;
1842
+ const entry = state.sessions.get(id);
1843
+ if (entry) {
1844
+ entry.terminal.options.theme = getThemeObject(meta.theme);
1845
+ }
1846
+ }
1847
+ }
1848
+ }
1849
+
1850
+ function escapeHtml(str) {
1851
+ const div = document.createElement('div');
1852
+ div.textContent = str;
1853
+ return div.innerHTML;
1854
+ }
1855
+
1856
+ function updateGlobalStats(sessions) {
1857
+ let active = 0, thinking = 0, idle = 0;
1858
+ for (const s of sessions) {
1859
+ if (s.meta.status === 'active' || s.meta.status === 'listening') active++;
1860
+ else if (s.meta.status === 'thinking') thinking++;
1861
+ else if (s.meta.status === 'idle') idle++;
1862
+
1863
+ // Update existing panels from broadcast. NOTE: we deliberately do NOT
1864
+ // createTerminalPanel for sessions that aren't in state.sessions —
1865
+ // that creates a race between the immediate createTerminalPanel call
1866
+ // from launchTerminal and the 2s status_broadcast cycle, producing
1867
+ // duplicate WebSockets per session and breaking terminal input
1868
+ // rendering. External-session auto-discover is parked for Sprint 3.
1869
+ if (state.sessions.has(s.id)) {
1870
+ updatePanelMeta(s.id, s.meta);
1871
+ }
1872
+ }
1873
+ document.getElementById('stat-active').textContent = active;
1874
+ document.getElementById('stat-thinking').textContent = thinking;
1875
+ document.getElementById('stat-idle').textContent = idle;
1876
+ renderSwitcher();
1877
+ }
1878
+
1879
+ function updateEmptyState() {
1880
+ const empty = document.getElementById('emptyState');
1881
+ empty.style.display = state.sessions.size === 0 ? '' : 'none';
1882
+ }
1883
+
1884
+ function fitAll() {
1885
+ for (const [, entry] of state.sessions) {
1886
+ try { entry.fitAddon.fit(); } catch (err) { if (!entry._fitWarned) { console.error('[client] fitAddon.fit failed for session:', err); entry._fitWarned = true; } }
1887
+ }
1888
+ }
1889
+
1890
+ // ===== ONBOARDING TOUR =====
1891
+ // Spotlight + tooltip walkthrough of every TermDeck surface. Runs once on
1892
+ // first visit (localStorage gate) and replays on demand via the "how this
1893
+ // works" button. Zero dependencies — vanilla DOM, same philosophy as the
1894
+ // rest of this client.
1895
+ const TOUR_STEPS = [
1896
+ {
1897
+ target: null,
1898
+ title: 'Welcome to TermDeck',
1899
+ body: `TermDeck is a browser-based terminal multiplexer with a persistent memory layer. It lets you run many real terminals side by side, each with rich metadata and automatic recall of similar past errors. This walkthrough takes about 90 seconds and covers every button on the screen. Press <kbd>Esc</kbd> any time to exit.`,
1900
+ },
1901
+ {
1902
+ target: '#topbarQuickLaunch',
1903
+ title: 'Quick launch',
1904
+ body: `These three buttons instantly spawn a new terminal. <strong>shell</strong> opens zsh, <strong>claude</strong> opens Claude Code, <strong>python</strong> starts a Python HTTP server on port 8080. One click — no typing required.`,
1905
+ },
1906
+ {
1907
+ target: '.topbar-center',
1908
+ title: 'Layout modes',
1909
+ body: `Seven preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd> plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+6</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>6</kbd>) do the same.`,
1910
+ },
1911
+ {
1912
+ target: '#termSwitcher',
1913
+ title: 'Terminal switcher',
1914
+ body: `When you have 2+ terminals open, this overlay shows numbered tiles. Click a tile to focus that panel, or press <kbd>Alt+1</kbd> through <kbd>Alt+9</kbd>. Color-coded by project, status-dot updates live. Watch — a second shell is spawning right now so you can see it appear.`,
1915
+ onEnter: async () => { await ensureSecondShellForTour(); },
1916
+ },
1917
+ {
1918
+ targets: ['#btn-status', '#btn-config'],
1919
+ title: 'Status and config',
1920
+ body: `<strong>status</strong> opens a global-metrics modal (session counts by state, RAG mode, memory bridge). <strong>config</strong> shows your loaded project list and theme defaults. Both are in the polish queue for Sprint 3 — buttons are visible but unwired right now.`,
1921
+ },
1922
+ {
1923
+ targets: ['#btn-how', '#btn-help'],
1924
+ title: 'How this works and help',
1925
+ body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation in a new tab.`,
1926
+ },
1927
+ {
1928
+ target: '.panel-header',
1929
+ title: 'Panel header',
1930
+ body: `Every terminal has a header showing a <strong>status dot</strong> (active · thinking · idle · errored · exited), the detected <strong>type</strong> (shell · Claude Code · Python server · etc.), a colored <strong>project tag</strong>, and a <strong>#N index</strong> when multiple panels share the same (type, project). The right side has focus, half-screen, and close buttons.`,
1931
+ fallback: '#topbarQuickLaunch',
1932
+ },
1933
+ {
1934
+ target: '.drawer-tabs',
1935
+ title: 'Info tabs',
1936
+ body: `Below every terminal is a drawer with four tabs. <strong>Overview</strong> — live metadata + "Ask about this terminal" input + reply button. <strong>Commands</strong> — scrollable command history (click to copy). <strong>Memory</strong> — every Flashback hit this panel has collected. <strong>Status log</strong> — chronological status transitions with detail chips.`,
1937
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
1938
+ fallback: '#topbarQuickLaunch',
1939
+ },
1940
+ {
1941
+ target: '.reply-toggle',
1942
+ title: 'Reply — send text to another panel',
1943
+ body: `Click <strong>reply ▸</strong> on any panel to route text to another open terminal. Pick the target from the dropdown (labels use <kbd>#N</kbd> suffixes to disambiguate same-project duplicates), type your message, hit send. Useful for handing off work to a Claude Code panel, broadcasting a command, or piping errors into a debug agent.`,
1944
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
1945
+ fallback: '#topbarQuickLaunch',
1946
+ },
1947
+ {
1948
+ target: '.ctrl-input',
1949
+ title: 'Ask about this terminal',
1950
+ body: `Type a question here and TermDeck queries your <strong>Mnestra memory store</strong> for relevant context — scoped to the current panel's project. Prefix with <kbd>all:</kbd> to search every project. Results render inline in the terminal with similarity scores.`,
1951
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
1952
+ fallback: '#topbarQuickLaunch',
1953
+ },
1954
+ {
1955
+ target: null,
1956
+ title: 'Flashback — proactive recall',
1957
+ body: `When a panel errors out, TermDeck <strong>automatically</strong> queries Mnestra for similar past errors and surfaces the top match as a toast. You don't have to ask. Rate-limited to one per 30 seconds per panel. Click the toast to open the Memory tab with the full hit expanded.`,
1958
+ },
1959
+ {
1960
+ target: '.prompt-bar',
1961
+ title: 'Prompt bar',
1962
+ body: `Type any command here to launch it as a new terminal — <kbd>claude code ~/myproject</kbd>, <kbd>python3 manage.py runserver</kbd>, <kbd>npm run dev</kbd>. Pick a project from the dropdown to auto-cd into its path and apply its default theme. <kbd>Ctrl+Shift+N</kbd> focuses this bar from anywhere.`,
1963
+ },
1964
+ {
1965
+ target: null,
1966
+ title: 'You are ready.',
1967
+ body: `That's every major surface. Click <strong>how this works</strong> in the top toolbar to replay this walkthrough. <strong>help</strong> opens the full docs. Questions, bugs, feedback: <a href="https://github.com/jhizzard/termdeck/issues" target="_blank" style="color:var(--tg-accent)">github.com/jhizzard/termdeck/issues</a>. Now launch something.`,
1968
+ },
1969
+ ];
1970
+
1971
+ // Tour setup helpers — manipulate DOM so target selectors resolve to
1972
+ // visible, sized elements before the spotlight positions itself.
1973
+ async function ensureSecondShellForTour() {
1974
+ if (state.sessions.size >= 2) return;
1975
+ try {
1976
+ const session = await api('POST', '/api/sessions', {
1977
+ command: 'zsh',
1978
+ type: 'shell',
1979
+ reason: 'onboarding tour (switcher demo)',
1980
+ });
1981
+ createTerminalPanel(session);
1982
+ updateEmptyState();
1983
+ await new Promise((r) => setTimeout(r, 450));
1984
+ } catch (err) {
1985
+ console.error('[tour] failed to auto-launch second shell:', err);
1986
+ }
1987
+ }
1988
+
1989
+ async function openFirstPanelDrawer(tabName = 'overview') {
1990
+ const firstId = state.sessions.keys().next().value;
1991
+ if (!firstId) return;
1992
+ const entry = state.sessions.get(firstId);
1993
+ if (!entry) return;
1994
+ // Only toggle if not already open on the requested tab — avoid bouncing
1995
+ // the drawer shut mid-tour.
1996
+ if (entry.drawerOpen && entry.activeTab === tabName) return;
1997
+ // Force-open by setting state first so toggleDrawerTab expands it.
1998
+ entry.drawerOpen = false;
1999
+ toggleDrawerTab(firstId, tabName);
2000
+ // Let the CSS transition settle so bounding rects stabilize.
2001
+ await new Promise((r) => setTimeout(r, 280));
2002
+ }
2003
+
2004
+ const tourState = { active: false, idx: 0 };
2005
+
2006
+ // Resolve a step's target(s) to a bounding rect. Supports single `target`
2007
+ // selector, a `targets` array (union rect across multiple elements), and
2008
+ // `fallback` as a last resort. Elements with 0×0 rects are treated as
2009
+ // invisible and ignored so collapsed drawer content doesn't produce
2010
+ // phantom spotlights in the top-left corner.
2011
+ function tourResolveRect(step) {
2012
+ const visibleRect = (sel) => {
2013
+ const el = document.querySelector(sel);
2014
+ if (!el) return null;
2015
+ const r = el.getBoundingClientRect();
2016
+ if (r.width < 2 || r.height < 2) return null;
2017
+ return r;
2018
+ };
2019
+
2020
+ if (step.targets && Array.isArray(step.targets)) {
2021
+ const rects = step.targets.map(visibleRect).filter(Boolean);
2022
+ if (rects.length > 0) {
2023
+ const left = Math.min(...rects.map((r) => r.left));
2024
+ const top = Math.min(...rects.map((r) => r.top));
2025
+ const right = Math.max(...rects.map((r) => r.right));
2026
+ const bottom = Math.max(...rects.map((r) => r.bottom));
2027
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
2028
+ }
2029
+ }
2030
+
2031
+ if (step.target) {
2032
+ const r = visibleRect(step.target);
2033
+ if (r) return r;
2034
+ }
2035
+
2036
+ if (step.fallback) {
2037
+ const r = visibleRect(step.fallback);
2038
+ if (r) return r;
2039
+ }
2040
+
2041
+ return null;
2042
+ }
2043
+
2044
+ function positionTourElements(step) {
2045
+ const backdrop = document.getElementById('tourBackdrop');
2046
+ const spotlight = document.getElementById('tourSpotlight');
2047
+ const tooltip = document.getElementById('tourTooltip');
2048
+ backdrop.classList.add('active');
2049
+ tooltip.style.display = 'block';
2050
+
2051
+ const rect = tourResolveRect(step);
2052
+ if (!rect) {
2053
+ // Centered step — no spotlight target, or resolved element was invisible
2054
+ spotlight.classList.add('centered');
2055
+ tooltip.classList.add('centered');
2056
+ tooltip.style.top = '';
2057
+ tooltip.style.left = '';
2058
+ return;
2059
+ }
2060
+ spotlight.classList.remove('centered');
2061
+ tooltip.classList.remove('centered');
2062
+
2063
+ const padding = 8;
2064
+ spotlight.style.top = `${rect.top - padding}px`;
2065
+ spotlight.style.left = `${rect.left - padding}px`;
2066
+ spotlight.style.width = `${rect.width + padding * 2}px`;
2067
+ spotlight.style.height = `${rect.height + padding * 2}px`;
2068
+
2069
+ // Place tooltip below the target by default; flip to above if it would
2070
+ // overflow the viewport. Clamp horizontally to avoid right-edge clipping.
2071
+ const tooltipRect = tooltip.getBoundingClientRect();
2072
+ let top = rect.bottom + 16;
2073
+ let left = Math.max(12, rect.left);
2074
+ if (top + tooltipRect.height > window.innerHeight - 12) {
2075
+ top = Math.max(12, rect.top - tooltipRect.height - 16);
2076
+ }
2077
+ if (left + tooltipRect.width > window.innerWidth - 12) {
2078
+ left = window.innerWidth - tooltipRect.width - 12;
2079
+ }
2080
+ tooltip.style.top = `${top}px`;
2081
+ tooltip.style.left = `${left}px`;
2082
+ }
2083
+
2084
+ async function renderTourStep() {
2085
+ const step = TOUR_STEPS[tourState.idx];
2086
+ if (!step) { endTour(); return; }
2087
+
2088
+ // Optional setup hook — launches a panel, opens a drawer, etc.
2089
+ if (typeof step.onEnter === 'function') {
2090
+ try { await step.onEnter(); } catch (err) { console.error('[tour] onEnter failed:', err); }
2091
+ }
2092
+
2093
+ document.getElementById('tourTitle').innerHTML = step.title;
2094
+ document.getElementById('tourBody').innerHTML = step.body;
2095
+ document.getElementById('tourCounter').textContent =
2096
+ `Step ${tourState.idx + 1} of ${TOUR_STEPS.length}`;
2097
+ document.getElementById('tourPrevBtn').disabled = tourState.idx === 0;
2098
+ document.getElementById('tourNextBtn').textContent =
2099
+ tourState.idx === TOUR_STEPS.length - 1 ? 'done' : 'next';
2100
+ positionTourElements(step);
2101
+ }
2102
+
2103
+ // Auto-launch a shell panel so the tour's panel-targeting steps
2104
+ // (header, drawer tabs, reply, ctrl-input) have a real DOM target.
2105
+ // Only fires when no panels exist yet. Replays of the tour against
2106
+ // an already-populated dashboard skip this — their existing panels
2107
+ // serve as the tour targets.
2108
+ async function ensurePanelForTour() {
2109
+ if (state.sessions.size > 0) return;
2110
+ try {
2111
+ const session = await api('POST', '/api/sessions', {
2112
+ command: 'zsh',
2113
+ type: 'shell',
2114
+ reason: 'onboarding tour',
2115
+ });
2116
+ createTerminalPanel(session);
2117
+ updateEmptyState();
2118
+ // Let xterm.js mount and .panel-* selectors settle before rendering.
2119
+ await new Promise((r) => setTimeout(r, 450));
2120
+ } catch (err) {
2121
+ console.error('[tour] failed to auto-launch shell:', err);
2122
+ }
2123
+ }
2124
+
2125
+ async function startTour() {
2126
+ tourState.active = true;
2127
+ tourState.idx = 0;
2128
+ // Explicitly show the spotlight. CSS default is `display:none` so the
2129
+ // 9999px box-shadow doesn't darken the page before/after a tour runs.
2130
+ document.getElementById('tourSpotlight').style.display = 'block';
2131
+ await ensurePanelForTour();
2132
+ renderTourStep();
2133
+ }
2134
+
2135
+ function nextTourStep() {
2136
+ if (tourState.idx >= TOUR_STEPS.length - 1) { endTour(); return; }
2137
+ tourState.idx += 1;
2138
+ renderTourStep();
2139
+ }
2140
+
2141
+ function prevTourStep() {
2142
+ if (tourState.idx <= 0) return;
2143
+ tourState.idx -= 1;
2144
+ renderTourStep();
2145
+ }
2146
+
2147
+ function endTour() {
2148
+ tourState.active = false;
2149
+ document.getElementById('tourBackdrop').classList.remove('active');
2150
+ document.getElementById('tourTooltip').style.display = 'none';
2151
+ // CRITICAL: hide the spotlight element too. Its 9999px box-shadow
2152
+ // creates the dark overlay effect independently of the backdrop, so
2153
+ // leaving display:block here means the dashboard looks "stuck in tour"
2154
+ // even after the tooltip is gone.
2155
+ const spotlight = document.getElementById('tourSpotlight');
2156
+ spotlight.style.display = 'none';
2157
+ spotlight.classList.remove('centered');
2158
+ const tooltip = document.getElementById('tourTooltip');
2159
+ tooltip.classList.remove('centered');
2160
+ tooltip.style.top = '';
2161
+ tooltip.style.left = '';
2162
+ try { localStorage.setItem('termdeck:tour:seen', '1'); } catch {}
2163
+ }
2164
+
2165
+ // ===== Event Listeners =====
2166
+ document.querySelectorAll('.layout-btn').forEach(btn => {
2167
+ btn.addEventListener('click', () => setLayout(btn.dataset.layout));
2168
+ });
2169
+
2170
+ document.getElementById('promptLaunch').addEventListener('click', launchTerminal);
2171
+ document.getElementById('promptInput').addEventListener('keydown', (e) => {
2172
+ if (e.key === 'Enter') launchTerminal();
2173
+ });
2174
+
2175
+ // Add-project modal wiring
2176
+ document.getElementById('btnAddProject').addEventListener('click', openAddProjectModal);
2177
+ document.getElementById('apmCancel').addEventListener('click', closeAddProjectModal);
2178
+ document.getElementById('apmSave').addEventListener('click', submitAddProject);
2179
+ document.querySelector('#addProjectModal .add-project-backdrop').addEventListener('click', closeAddProjectModal);
2180
+ // Enter in any input inside the modal submits; Escape closes
2181
+ document.getElementById('addProjectModal').addEventListener('keydown', (e) => {
2182
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAddProject(); }
2183
+ if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
2184
+ });
2185
+
2186
+ // Onboarding tour wiring
2187
+ document.getElementById('btn-how').addEventListener('click', startTour);
2188
+ document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
2189
+ document.getElementById('tourPrevBtn').addEventListener('click', prevTourStep);
2190
+ document.getElementById('tourSkipBtn').addEventListener('click', endTour);
2191
+ // Clicking the backdrop (but not the spotlight/tooltip) also skips
2192
+ document.getElementById('tourBackdrop').addEventListener('click', (e) => {
2193
+ if (e.target.id === 'tourBackdrop') endTour();
2194
+ });
2195
+
2196
+ // Resize handler
2197
+ window.addEventListener('resize', () => {
2198
+ requestAnimationFrame(() => fitAll());
2199
+ });
2200
+
2201
+ // Re-render the tour on viewport changes so the spotlight tracks resizes
2202
+ window.addEventListener('resize', () => {
2203
+ if (tourState.active) renderTourStep();
2204
+ });
2205
+
2206
+ // Keyboard shortcuts
2207
+ document.addEventListener('keydown', (e) => {
2208
+ // Tour has priority: Esc exits, ArrowRight/Enter advances, ArrowLeft back
2209
+ if (tourState.active) {
2210
+ if (e.key === 'Escape') { e.preventDefault(); endTour(); return; }
2211
+ if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); nextTourStep(); return; }
2212
+ if (e.key === 'ArrowLeft') { e.preventDefault(); prevTourStep(); return; }
2213
+ }
2214
+ // Ctrl+Shift+N → new terminal
2215
+ if (e.ctrlKey && e.shiftKey && e.key === 'N') {
2216
+ e.preventDefault();
2217
+ document.getElementById('promptInput').focus();
2218
+ }
2219
+ // "/" → focus prompt bar (first-run hint, ignored when typing in any input/textarea)
2220
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
2221
+ const target = e.target;
2222
+ const tag = target?.tagName || '';
2223
+ const inEditable = tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable;
2224
+ if (!inEditable) {
2225
+ e.preventDefault();
2226
+ document.getElementById('promptInput').focus();
2227
+ }
2228
+ }
2229
+ // Ctrl+Shift+1-6 OR Cmd+Shift+1-6 → layout switch (Mac friendly)
2230
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '1' && e.key <= '6') {
2231
+ e.preventDefault();
2232
+ const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2'];
2233
+ setLayout(layouts[parseInt(e.key) - 1]);
2234
+ }
2235
+ // Ctrl+Shift+] / [ → cycle between terminals
2236
+ if (e.ctrlKey && e.shiftKey && (e.key === ']' || e.key === '[')) {
2237
+ e.preventDefault();
2238
+ const ids = Array.from(state.sessions.keys());
2239
+ if (ids.length > 0) {
2240
+ const curIdx = ids.indexOf(state.focusedId);
2241
+ const next = e.key === ']'
2242
+ ? (curIdx + 1) % ids.length
2243
+ : (curIdx - 1 + ids.length) % ids.length;
2244
+ const entry = state.sessions.get(ids[next]);
2245
+ if (entry) {
2246
+ entry.terminal.focus();
2247
+ state.focusedId = ids[next];
2248
+ }
2249
+ }
2250
+ }
2251
+ // Escape → exit focus mode
2252
+ if (e.key === 'Escape') {
2253
+ const grid = document.getElementById('termGrid');
2254
+ if (grid.classList.contains('layout-focus') || grid.classList.contains('layout-half')) {
2255
+ setLayout(state.layout);
2256
+ document.querySelectorAll('.term-panel').forEach(p => {
2257
+ p.classList.remove('focused', 'primary');
2258
+ p.style.display = '';
2259
+ });
2260
+ fitAll();
2261
+ }
2262
+ }
2263
+ });
2264
+
2265
+ // Control feed click (T1.6) — delegated at the feed container
2266
+ document.getElementById('feedRows').addEventListener('click', onFeedRowClick);
2267
+
2268
+ // Live refresh while in control mode
2269
+ setInterval(() => {
2270
+ const grid = document.getElementById('termGrid');
2271
+ if (grid && grid.classList.contains('layout-control')) {
2272
+ renderControlFeed();
2273
+ }
2274
+ }, 2000);
2275
+
2276
+ // Alt+1..9 → focus panel N, Alt+0 → cycle focus (T1.2)
2277
+ // Use capture-phase so xterm.js never sees the key as a Meta sequence.
2278
+ // Match on e.code, not e.key: on macOS, Option+1 produces "¡", not "1".
2279
+ document.addEventListener('keydown', (e) => {
2280
+ if (!e.altKey) return;
2281
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return;
2282
+ if (e.code && e.code.startsWith('Digit')) {
2283
+ const n = parseInt(e.code.slice(5), 10);
2284
+ if (n >= 1 && n <= 9) {
2285
+ e.preventDefault();
2286
+ e.stopPropagation();
2287
+ focusNthSession(n);
2288
+ } else if (n === 0) {
2289
+ e.preventDefault();
2290
+ e.stopPropagation();
2291
+ cycleSessionFocus();
2292
+ }
2293
+ }
2294
+ }, { capture: true });
2295
+
2296
+ // External-session auto-discover disabled. The poller raced with the
2297
+ // immediate createTerminalPanel call in launchTerminal and caused
2298
+ // duplicate WebSocket connections per session, which broke terminal
2299
+ // input rendering (session.ws on the server got overwritten by the
2300
+ // second connect and term.onData output stopped reaching the visible
2301
+ // panel). Parked for Sprint 3 — needs an idempotent creation path
2302
+ // AND a way to suppress the race window during POST → createPanel.
2303
+
2304
+ // Refresh "opened X ago" timestamps every 30s
2305
+ setInterval(() => {
2306
+ for (const [id, entry] of state.sessions) {
2307
+ const metaOpened = document.querySelector(`#panel-${id} .panel-meta .meta-item:first-child`);
2308
+ if (metaOpened && entry.session?.meta?.createdAt) {
2309
+ metaOpened.innerHTML = `<span class="meta-label">opened</span> ${timeAgo(entry.session.meta.createdAt)}`;
2310
+ }
2311
+ }
2312
+ }, 30000);
2313
+
2314
+ // Boot
2315
+ init();