@jhizzard/termdeck 0.2.5 → 0.3.1

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