@jhizzard/termdeck 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3444 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TermDeck</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
+ <style>
9
+ :root {
10
+ --tg-bg: #0f1117;
11
+ --tg-surface: #161821;
12
+ --tg-surface-hover: #1c1e2a;
13
+ --tg-border: #2a2d3a;
14
+ --tg-border-active: #3d4155;
15
+ --tg-text: #c8ccd8;
16
+ --tg-text-dim: #6b7089;
17
+ --tg-text-bright: #eef1ff;
18
+ --tg-accent: #7aa2f7;
19
+ --tg-accent-dim: #3d5a9e;
20
+ --tg-green: #9ece6a;
21
+ --tg-amber: #e0af68;
22
+ --tg-red: #f7768e;
23
+ --tg-purple: #bb9af7;
24
+ --tg-cyan: #7dcfff;
25
+ --tg-mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace;
26
+ --tg-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
27
+ --tg-radius: 8px;
28
+ --tg-radius-sm: 5px;
29
+ }
30
+
31
+ * { margin: 0; padding: 0; box-sizing: border-box; }
32
+
33
+ body {
34
+ background: var(--tg-bg);
35
+ color: var(--tg-text);
36
+ font-family: var(--tg-sans);
37
+ font-size: 13px;
38
+ height: 100vh;
39
+ overflow: hidden;
40
+ display: flex;
41
+ flex-direction: column;
42
+ }
43
+
44
+ /* ===== TOP BAR ===== */
45
+ .topbar {
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ padding: 0 16px;
50
+ height: 42px;
51
+ background: var(--tg-surface);
52
+ border-bottom: 1px solid var(--tg-border);
53
+ flex-shrink: 0;
54
+ }
55
+
56
+ .topbar-left {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 12px;
60
+ }
61
+
62
+ .topbar-logo {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 8px;
66
+ font-weight: 600;
67
+ font-size: 14px;
68
+ color: var(--tg-text-bright);
69
+ letter-spacing: -0.3px;
70
+ }
71
+
72
+ .topbar-logo svg { opacity: 0.8; }
73
+
74
+ .topbar-stats {
75
+ display: flex;
76
+ gap: 16px;
77
+ font-size: 11px;
78
+ color: var(--tg-text-dim);
79
+ }
80
+
81
+ .topbar-stat { display: flex; align-items: center; gap: 4px; }
82
+ .topbar-stat .dot {
83
+ width: 6px; height: 6px;
84
+ border-radius: 50%;
85
+ display: inline-block;
86
+ }
87
+
88
+ .topbar-center {
89
+ display: flex;
90
+ gap: 2px;
91
+ background: var(--tg-bg);
92
+ padding: 3px;
93
+ border-radius: var(--tg-radius-sm);
94
+ }
95
+
96
+ .layout-btn {
97
+ background: none;
98
+ border: none;
99
+ color: var(--tg-text-dim);
100
+ font-family: var(--tg-mono);
101
+ font-size: 11px;
102
+ padding: 4px 10px;
103
+ border-radius: 3px;
104
+ cursor: pointer;
105
+ transition: all 0.15s;
106
+ }
107
+
108
+ .layout-btn:hover { color: var(--tg-text); background: var(--tg-surface-hover); }
109
+ .layout-btn.active { color: var(--tg-accent); background: var(--tg-surface); }
110
+
111
+ .topbar-right {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 8px;
115
+ }
116
+
117
+ .topbar-right button {
118
+ background: none;
119
+ border: 1px solid var(--tg-border);
120
+ color: var(--tg-text-dim);
121
+ font-size: 11px;
122
+ padding: 4px 12px;
123
+ border-radius: var(--tg-radius-sm);
124
+ cursor: pointer;
125
+ font-family: var(--tg-sans);
126
+ transition: all 0.15s;
127
+ }
128
+
129
+ .topbar-right button:hover {
130
+ color: var(--tg-text);
131
+ border-color: var(--tg-border-active);
132
+ }
133
+
134
+ /* Persistent quick-launch group in the top toolbar (always visible) */
135
+ .topbar-ql {
136
+ display: flex;
137
+ gap: 4px;
138
+ padding-right: 8px;
139
+ margin-right: 4px;
140
+ border-right: 1px solid var(--tg-border);
141
+ }
142
+ .topbar-ql-btn {
143
+ color: var(--tg-accent) !important;
144
+ }
145
+ .topbar-ql-btn:hover {
146
+ background: var(--tg-accent-dim);
147
+ color: var(--tg-bg) !important;
148
+ }
149
+
150
+ #btn-how {
151
+ border-color: var(--tg-accent-dim) !important;
152
+ color: var(--tg-accent) !important;
153
+ }
154
+ #btn-how:hover {
155
+ background: var(--tg-accent-dim);
156
+ color: var(--tg-bg) !important;
157
+ border-color: var(--tg-accent) !important;
158
+ }
159
+
160
+ /* ===== ONBOARDING TOUR (how this works) ===== */
161
+ .tour-backdrop {
162
+ position: fixed;
163
+ inset: 0;
164
+ background: transparent;
165
+ z-index: 2000;
166
+ pointer-events: auto;
167
+ display: none;
168
+ }
169
+ .tour-backdrop.active { display: block; }
170
+
171
+ .tour-spotlight {
172
+ position: fixed;
173
+ border-radius: 6px;
174
+ box-shadow:
175
+ 0 0 0 4px var(--tg-accent),
176
+ 0 0 0 9999px rgba(0, 0, 0, 0.78);
177
+ transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease;
178
+ pointer-events: none;
179
+ z-index: 2001;
180
+ display: none; /* default hidden — only visible during an active tour */
181
+ }
182
+ .tour-spotlight.centered {
183
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.85);
184
+ top: 50%;
185
+ left: 50%;
186
+ width: 0;
187
+ height: 0;
188
+ transform: translate(-50%, -50%);
189
+ }
190
+
191
+ .tour-tooltip {
192
+ position: fixed;
193
+ background: var(--tg-surface);
194
+ border: 1px solid var(--tg-accent-dim);
195
+ border-radius: 10px;
196
+ padding: 18px 20px 16px;
197
+ max-width: 360px;
198
+ min-width: 280px;
199
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
200
+ z-index: 2003;
201
+ font-family: var(--tg-sans);
202
+ color: var(--tg-text);
203
+ transition: top 0.3s ease, left 0.3s ease;
204
+ }
205
+ .tour-tooltip.centered {
206
+ top: 50% !important;
207
+ left: 50% !important;
208
+ transform: translate(-50%, -50%);
209
+ max-width: 440px;
210
+ }
211
+ .tour-tooltip h3 {
212
+ margin: 0 0 10px;
213
+ font-size: 15px;
214
+ font-weight: 700;
215
+ color: var(--tg-accent);
216
+ letter-spacing: 0.2px;
217
+ }
218
+ .tour-tooltip p {
219
+ margin: 0 0 14px;
220
+ font-size: 13px;
221
+ line-height: 1.55;
222
+ color: var(--tg-text-dim);
223
+ }
224
+ .tour-tooltip p strong { color: var(--tg-text); font-weight: 600; }
225
+ .tour-tooltip kbd {
226
+ display: inline-block;
227
+ padding: 1px 6px;
228
+ background: var(--tg-bg);
229
+ border: 1px solid var(--tg-border);
230
+ border-radius: 3px;
231
+ font-family: var(--tg-mono);
232
+ font-size: 11px;
233
+ color: var(--tg-accent);
234
+ }
235
+ .tour-tooltip .tour-controls {
236
+ display: flex;
237
+ justify-content: space-between;
238
+ align-items: center;
239
+ gap: 10px;
240
+ }
241
+ .tour-tooltip .tour-counter {
242
+ font-size: 11px;
243
+ color: var(--tg-text-dim);
244
+ font-family: var(--tg-mono);
245
+ }
246
+ .tour-tooltip .tour-btns { display: flex; gap: 6px; }
247
+ .tour-tooltip button {
248
+ background: var(--tg-accent);
249
+ color: var(--tg-bg);
250
+ border: none;
251
+ padding: 6px 14px;
252
+ border-radius: 4px;
253
+ font-size: 12px;
254
+ font-weight: 600;
255
+ cursor: pointer;
256
+ font-family: var(--tg-sans);
257
+ transition: filter 0.15s;
258
+ }
259
+ .tour-tooltip button:hover { filter: brightness(1.1); }
260
+ .tour-tooltip button.tour-skip,
261
+ .tour-tooltip button.tour-prev {
262
+ background: transparent;
263
+ color: var(--tg-text-dim);
264
+ border: 1px solid var(--tg-border);
265
+ }
266
+ .tour-tooltip button.tour-skip:hover,
267
+ .tour-tooltip button.tour-prev:hover {
268
+ color: var(--tg-text);
269
+ border-color: var(--tg-border-active);
270
+ filter: none;
271
+ }
272
+ .tour-tooltip button[disabled] {
273
+ opacity: 0.35;
274
+ cursor: not-allowed;
275
+ }
276
+
277
+ /* ===== MAIN GRID ===== */
278
+ .grid-container {
279
+ flex: 1;
280
+ padding: 6px;
281
+ overflow: hidden;
282
+ display: grid;
283
+ gap: 6px;
284
+ }
285
+
286
+ /* Layout modes */
287
+ .grid-container.layout-1x1 { grid-template-columns: 1fr; grid-template-rows: 1fr; }
288
+ .grid-container.layout-2x1 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; }
289
+ .grid-container.layout-1x2 { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
290
+ .grid-container.layout-2x2 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
291
+ .grid-container.layout-3x2 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
292
+ .grid-container.layout-4x2 { grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
293
+ .grid-container.layout-2x4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
294
+
295
+ /* Focus mode: single terminal fills the grid */
296
+ .grid-container.layout-focus { grid-template-columns: 1fr; grid-template-rows: 1fr; }
297
+ .grid-container.layout-focus .term-panel:not(.focused) { display: none; }
298
+
299
+ /* Half mode: one big + small stack */
300
+ .grid-container.layout-half {
301
+ grid-template-columns: 1fr 1fr;
302
+ grid-template-rows: 1fr 1fr;
303
+ }
304
+ .grid-container.layout-half .term-panel.primary {
305
+ grid-row: 1 / -1;
306
+ }
307
+
308
+ /* ===== TERMINAL PANEL ===== */
309
+ .term-panel {
310
+ display: flex;
311
+ flex-direction: column;
312
+ background: var(--tg-surface);
313
+ border: 1px solid var(--tg-border);
314
+ border-radius: var(--tg-radius);
315
+ overflow: hidden;
316
+ transition: border-color 0.2s;
317
+ min-height: 0;
318
+ }
319
+
320
+ .term-panel:hover { border-color: var(--tg-border-active); }
321
+ .term-panel.active-input { border-color: var(--tg-accent-dim); }
322
+ .term-panel.exited { opacity: 0.55; }
323
+ .term-panel.exited .panel-terminal { pointer-events: none; }
324
+
325
+ /* --- Panel Header (metadata bar) --- */
326
+ .panel-header {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: space-between;
330
+ padding: 6px 10px;
331
+ background: var(--tg-surface);
332
+ border-bottom: 1px solid var(--tg-border);
333
+ flex-shrink: 0;
334
+ }
335
+
336
+ .panel-header-left {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 8px;
340
+ min-width: 0;
341
+ flex: 1;
342
+ }
343
+
344
+ .status-dot {
345
+ width: 8px; height: 8px;
346
+ border-radius: 50%;
347
+ flex-shrink: 0;
348
+ transition: background 0.3s;
349
+ }
350
+
351
+ .status-dot.pulsing {
352
+ animation: pulse 2s ease-in-out infinite;
353
+ }
354
+
355
+ @keyframes pulse {
356
+ 0%, 100% { opacity: 1; }
357
+ 50% { opacity: 0.4; }
358
+ }
359
+
360
+ .panel-type {
361
+ font-size: 12px;
362
+ font-weight: 600;
363
+ color: var(--tg-text-bright);
364
+ white-space: nowrap;
365
+ }
366
+
367
+ /* Per-panel index suffix, shown only when ≥2 panels share (type, project). */
368
+ .panel-index {
369
+ font-size: 11px;
370
+ font-weight: 600;
371
+ color: var(--tg-accent);
372
+ white-space: nowrap;
373
+ }
374
+ .panel-index:empty { display: none; }
375
+
376
+ .panel-project {
377
+ font-size: 10px;
378
+ padding: 1px 7px;
379
+ border-radius: 3px;
380
+ white-space: nowrap;
381
+ font-weight: 500;
382
+ }
383
+
384
+ .panel-status {
385
+ font-size: 11px;
386
+ color: var(--tg-text-dim);
387
+ white-space: nowrap;
388
+ overflow: hidden;
389
+ text-overflow: ellipsis;
390
+ }
391
+
392
+ .panel-header-right {
393
+ display: flex;
394
+ gap: 2px;
395
+ flex-shrink: 0;
396
+ }
397
+
398
+ .panel-btn {
399
+ background: none;
400
+ border: none;
401
+ color: var(--tg-text-dim);
402
+ font-size: 11px;
403
+ padding: 2px 6px;
404
+ border-radius: 3px;
405
+ cursor: pointer;
406
+ font-family: var(--tg-mono);
407
+ transition: all 0.1s;
408
+ }
409
+
410
+ .panel-btn:hover { color: var(--tg-text); background: var(--tg-bg); }
411
+ .panel-btn.danger:hover { color: var(--tg-red); }
412
+
413
+ /* --- Metadata Strip (below header) --- */
414
+ .panel-meta {
415
+ display: flex;
416
+ align-items: center;
417
+ gap: 12px;
418
+ padding: 3px 10px;
419
+ background: rgba(0,0,0,0.15);
420
+ border-bottom: 1px solid var(--tg-border);
421
+ font-size: 10px;
422
+ color: var(--tg-text-dim);
423
+ flex-shrink: 0;
424
+ overflow: hidden;
425
+ }
426
+
427
+ .meta-item {
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 4px;
431
+ white-space: nowrap;
432
+ }
433
+
434
+ .meta-label { opacity: 0.6; }
435
+
436
+ /* --- Terminal Container --- */
437
+ .panel-terminal {
438
+ flex: 1;
439
+ min-height: 0;
440
+ position: relative;
441
+ overflow: hidden;
442
+ }
443
+
444
+ .panel-terminal .xterm { height: 100%; }
445
+ .panel-terminal .xterm-viewport { overflow-y: auto !important; }
446
+
447
+ /* --- Panel Control Strip (below terminal) --- */
448
+ .panel-controls {
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: space-between;
452
+ padding: 4px 8px;
453
+ background: var(--tg-surface);
454
+ border-top: 1px solid var(--tg-border);
455
+ flex-shrink: 0;
456
+ }
457
+
458
+ .control-group {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 4px;
462
+ }
463
+
464
+ .ctrl-btn {
465
+ background: none;
466
+ border: 1px solid var(--tg-border);
467
+ color: var(--tg-text-dim);
468
+ font-size: 10px;
469
+ padding: 2px 8px;
470
+ border-radius: 3px;
471
+ cursor: pointer;
472
+ font-family: var(--tg-sans);
473
+ transition: all 0.1s;
474
+ }
475
+
476
+ .ctrl-btn:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
477
+ .ctrl-btn.active { color: var(--tg-accent); border-color: var(--tg-accent-dim); }
478
+
479
+ .theme-select {
480
+ background: var(--tg-bg);
481
+ border: 1px solid var(--tg-border);
482
+ color: var(--tg-text-dim);
483
+ font-size: 10px;
484
+ padding: 2px 4px;
485
+ border-radius: 3px;
486
+ cursor: pointer;
487
+ font-family: var(--tg-sans);
488
+ }
489
+
490
+ .ctrl-input {
491
+ background: var(--tg-bg);
492
+ border: 1px solid var(--tg-border);
493
+ color: var(--tg-text);
494
+ font-size: 11px;
495
+ padding: 3px 8px;
496
+ border-radius: 3px;
497
+ font-family: var(--tg-sans);
498
+ width: 200px;
499
+ }
500
+
501
+ .ctrl-input::placeholder { color: var(--tg-text-dim); }
502
+ .ctrl-input:focus { outline: none; border-color: var(--tg-accent-dim); }
503
+
504
+ /* ===== PROMPT BAR (LAUNCHER) ===== */
505
+ .prompt-bar {
506
+ display: flex;
507
+ align-items: center;
508
+ gap: 8px;
509
+ padding: 8px 12px;
510
+ background: var(--tg-surface);
511
+ border-top: 1px solid var(--tg-border);
512
+ flex-shrink: 0;
513
+ }
514
+
515
+ .prompt-icon {
516
+ color: var(--tg-accent);
517
+ font-size: 14px;
518
+ font-family: var(--tg-mono);
519
+ font-weight: 600;
520
+ }
521
+
522
+ .prompt-input {
523
+ flex: 1;
524
+ background: var(--tg-bg);
525
+ border: 1px solid var(--tg-border);
526
+ color: var(--tg-text);
527
+ font-size: 13px;
528
+ padding: 7px 12px;
529
+ border-radius: var(--tg-radius-sm);
530
+ font-family: var(--tg-mono);
531
+ transition: border-color 0.2s;
532
+ }
533
+
534
+ .prompt-input::placeholder { color: var(--tg-text-dim); font-family: var(--tg-sans); }
535
+ .prompt-input:focus { outline: none; border-color: var(--tg-accent-dim); }
536
+
537
+ .prompt-project {
538
+ background: var(--tg-bg);
539
+ border: 1px solid var(--tg-border);
540
+ color: var(--tg-text-dim);
541
+ font-size: 12px;
542
+ padding: 6px 10px;
543
+ border-radius: var(--tg-radius-sm);
544
+ font-family: var(--tg-sans);
545
+ cursor: pointer;
546
+ }
547
+
548
+ .prompt-launch {
549
+ background: var(--tg-accent-dim);
550
+ border: 1px solid var(--tg-accent);
551
+ color: var(--tg-text-bright);
552
+ font-size: 12px;
553
+ padding: 6px 16px;
554
+ border-radius: var(--tg-radius-sm);
555
+ cursor: pointer;
556
+ font-family: var(--tg-sans);
557
+ font-weight: 500;
558
+ transition: all 0.15s;
559
+ }
560
+
561
+ .prompt-launch:hover { background: var(--tg-accent); }
562
+
563
+ .prompt-add-project {
564
+ background: var(--tg-bg);
565
+ border: 1px solid var(--tg-border);
566
+ color: var(--tg-accent);
567
+ font-size: 16px;
568
+ line-height: 1;
569
+ padding: 0;
570
+ width: 26px;
571
+ height: 26px;
572
+ border-radius: var(--tg-radius-sm);
573
+ cursor: pointer;
574
+ font-family: var(--tg-sans);
575
+ transition: all 0.15s;
576
+ display: flex;
577
+ align-items: center;
578
+ justify-content: center;
579
+ }
580
+ .prompt-add-project:hover {
581
+ border-color: var(--tg-accent);
582
+ background: var(--tg-accent-dim);
583
+ color: var(--tg-text-bright);
584
+ }
585
+
586
+ /* ===== ADD PROJECT MODAL ===== */
587
+ .add-project-modal {
588
+ display: none;
589
+ position: fixed;
590
+ inset: 0;
591
+ z-index: 3000;
592
+ align-items: center;
593
+ justify-content: center;
594
+ }
595
+ .add-project-modal.open { display: flex; }
596
+ .add-project-backdrop {
597
+ position: absolute;
598
+ inset: 0;
599
+ background: rgba(0, 0, 0, 0.72);
600
+ }
601
+ .add-project-card {
602
+ position: relative;
603
+ background: var(--tg-surface);
604
+ border: 1px solid var(--tg-accent-dim);
605
+ border-radius: 10px;
606
+ padding: 22px 24px 18px;
607
+ width: 420px;
608
+ max-width: calc(100vw - 40px);
609
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
610
+ font-family: var(--tg-sans);
611
+ color: var(--tg-text);
612
+ }
613
+ .add-project-card h3 {
614
+ margin: 0 0 4px;
615
+ font-size: 16px;
616
+ color: var(--tg-accent);
617
+ }
618
+ .add-project-card .apm-help {
619
+ margin: 0 0 14px;
620
+ font-size: 12px;
621
+ color: var(--tg-text-dim);
622
+ }
623
+ .add-project-card .apm-help code {
624
+ background: var(--tg-bg);
625
+ padding: 1px 5px;
626
+ border-radius: 3px;
627
+ font-family: var(--tg-mono);
628
+ font-size: 11px;
629
+ }
630
+ .add-project-card label {
631
+ display: block;
632
+ margin-bottom: 10px;
633
+ }
634
+ .add-project-card label > span {
635
+ display: block;
636
+ font-size: 11px;
637
+ color: var(--tg-text-dim);
638
+ margin-bottom: 3px;
639
+ text-transform: uppercase;
640
+ letter-spacing: 0.5px;
641
+ }
642
+ .add-project-card label > span em {
643
+ font-style: normal;
644
+ text-transform: none;
645
+ color: var(--tg-text-dim);
646
+ opacity: 0.7;
647
+ font-size: 10px;
648
+ margin-left: 4px;
649
+ }
650
+ .add-project-card input,
651
+ .add-project-card select {
652
+ width: 100%;
653
+ background: var(--tg-bg);
654
+ border: 1px solid var(--tg-border);
655
+ color: var(--tg-text);
656
+ font-size: 13px;
657
+ padding: 7px 10px;
658
+ border-radius: var(--tg-radius-sm);
659
+ font-family: var(--tg-mono);
660
+ box-sizing: border-box;
661
+ }
662
+ .add-project-card input:focus,
663
+ .add-project-card select:focus {
664
+ outline: none;
665
+ border-color: var(--tg-accent-dim);
666
+ }
667
+ .add-project-card .apm-status {
668
+ font-size: 12px;
669
+ min-height: 16px;
670
+ margin: 4px 0 8px;
671
+ color: var(--tg-text-dim);
672
+ }
673
+ .add-project-card .apm-status.error { color: var(--tg-red); }
674
+ .add-project-card .apm-status.ok { color: var(--tg-green); }
675
+ .add-project-card .apm-actions {
676
+ display: flex;
677
+ justify-content: flex-end;
678
+ gap: 8px;
679
+ margin-top: 6px;
680
+ }
681
+ .add-project-card button {
682
+ font-size: 12px;
683
+ font-weight: 600;
684
+ padding: 6px 16px;
685
+ border-radius: 4px;
686
+ cursor: pointer;
687
+ font-family: var(--tg-sans);
688
+ border: 1px solid var(--tg-border);
689
+ }
690
+ .add-project-card .apm-cancel {
691
+ background: transparent;
692
+ color: var(--tg-text-dim);
693
+ }
694
+ .add-project-card .apm-cancel:hover {
695
+ color: var(--tg-text);
696
+ border-color: var(--tg-border-active);
697
+ }
698
+ .add-project-card .apm-save {
699
+ background: var(--tg-accent);
700
+ color: var(--tg-bg);
701
+ border-color: var(--tg-accent);
702
+ }
703
+ .add-project-card .apm-save:hover { filter: brightness(1.1); }
704
+ .add-project-card .apm-save:disabled {
705
+ opacity: 0.5;
706
+ cursor: not-allowed;
707
+ filter: none;
708
+ }
709
+
710
+ /* ===== EMPTY STATE ===== */
711
+ .empty-state {
712
+ display: flex;
713
+ flex-direction: column;
714
+ align-items: center;
715
+ justify-content: center;
716
+ height: 100%;
717
+ color: var(--tg-text-dim);
718
+ gap: 12px;
719
+ }
720
+
721
+ .empty-state svg { opacity: 0.3; }
722
+ .empty-state h2 { font-size: 20px; font-weight: 600; color: var(--tg-text); margin: 0; }
723
+ .empty-state p { font-size: 14px; margin: 0; }
724
+ .empty-state .hint { font-size: 12px; opacity: 0.6; }
725
+
726
+ .quick-launch-group {
727
+ display: flex;
728
+ gap: 10px;
729
+ margin-top: 8px;
730
+ flex-wrap: wrap;
731
+ justify-content: center;
732
+ }
733
+ .quick-launch-btn {
734
+ background: var(--tg-panel);
735
+ border: 1px solid var(--tg-border);
736
+ color: var(--tg-text);
737
+ padding: 10px 18px;
738
+ border-radius: 6px;
739
+ cursor: pointer;
740
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
741
+ font-size: 12px;
742
+ transition: border-color 0.15s, background 0.15s;
743
+ text-align: left;
744
+ line-height: 1.5;
745
+ }
746
+ .quick-launch-btn:hover {
747
+ border-color: var(--tg-accent);
748
+ background: rgba(122, 162, 247, 0.08);
749
+ }
750
+ .quick-launch-btn .ql-cmd {
751
+ display: block;
752
+ color: var(--tg-accent);
753
+ font-weight: 600;
754
+ }
755
+ .quick-launch-btn .ql-desc {
756
+ display: block;
757
+ font-size: 11px;
758
+ opacity: 0.5;
759
+ font-weight: 400;
760
+ }
761
+ .empty-state .notes {
762
+ margin-top: 16px;
763
+ display: flex;
764
+ flex-direction: column;
765
+ gap: 6px;
766
+ align-items: center;
767
+ }
768
+ .empty-state .notes span {
769
+ font-size: 11px;
770
+ opacity: 0.55;
771
+ }
772
+ .empty-state kbd {
773
+ display: inline-block;
774
+ padding: 1px 6px;
775
+ margin: 0 2px;
776
+ font-family: var(--tg-mono);
777
+ font-size: 10px;
778
+ color: var(--tg-text);
779
+ background: var(--tg-surface);
780
+ border: 1px solid var(--tg-border);
781
+ border-radius: 3px;
782
+ }
783
+
784
+ /* ===== PROJECT TAG COLORS ===== */
785
+ .project-termdeck { background: #1a1a2e; color: #7aa2f7; }
786
+ .project-scheduling { background: #1a2a1a; color: #9ece6a; }
787
+ .project-aicouncil { background: #2a1520; color: #f7768e; }
788
+ .project-commerce { background: #2a1f0f; color: #e0af68; }
789
+ .project-imessageai { background: #1f1a2e; color: #bb9af7; }
790
+ .project-default { background: #1e1f28; color: #6b7089; }
791
+
792
+ /* ===== PANEL DRAWER (tabbed info drawer at bottom of each panel) ===== */
793
+ .panel-drawer {
794
+ display: flex;
795
+ flex-direction: column;
796
+ background: var(--tg-surface);
797
+ border-top: 1px solid var(--tg-border);
798
+ flex-shrink: 0;
799
+ }
800
+
801
+ .drawer-tabs {
802
+ display: flex;
803
+ align-items: center;
804
+ gap: 2px;
805
+ padding: 4px 6px;
806
+ flex-shrink: 0;
807
+ overflow-x: auto;
808
+ }
809
+
810
+ .drawer-tab {
811
+ background: none;
812
+ border: 1px solid transparent;
813
+ color: var(--tg-text-dim);
814
+ font-size: 10px;
815
+ padding: 3px 10px;
816
+ border-radius: 3px;
817
+ cursor: pointer;
818
+ font-family: var(--tg-sans);
819
+ transition: all 0.1s;
820
+ white-space: nowrap;
821
+ }
822
+ .drawer-tab:hover { color: var(--tg-text); background: var(--tg-bg); }
823
+ .drawer-tab.active {
824
+ color: var(--tg-accent);
825
+ border-color: var(--tg-accent-dim);
826
+ background: var(--tg-bg);
827
+ }
828
+ .panel-drawer.open .drawer-tab.active { color: var(--tg-text-bright); }
829
+
830
+ .tab-badge {
831
+ display: inline-block;
832
+ margin-left: 5px;
833
+ padding: 0 5px;
834
+ background: var(--tg-border);
835
+ color: var(--tg-text-dim);
836
+ border-radius: 7px;
837
+ font-size: 9px;
838
+ min-width: 14px;
839
+ text-align: center;
840
+ line-height: 13px;
841
+ }
842
+ .drawer-tab.active .tab-badge { background: var(--tg-accent-dim); color: var(--tg-text-bright); }
843
+
844
+ .drawer-body {
845
+ max-height: 0;
846
+ overflow: hidden;
847
+ transition: max-height 0.18s ease;
848
+ border-top: 1px solid transparent;
849
+ }
850
+ .panel-drawer.open .drawer-body {
851
+ max-height: 180px;
852
+ border-top-color: var(--tg-border);
853
+ }
854
+
855
+ .drawer-panel {
856
+ display: none;
857
+ height: 180px;
858
+ overflow-y: auto;
859
+ padding: 8px 10px;
860
+ }
861
+ .drawer-panel.active { display: block; }
862
+
863
+ .drawer-overview {
864
+ display: flex;
865
+ flex-direction: column;
866
+ gap: 8px;
867
+ }
868
+ .drawer-overview .overview-controls {
869
+ display: flex;
870
+ align-items: center;
871
+ gap: 6px;
872
+ flex-wrap: wrap;
873
+ }
874
+ .drawer-overview .overview-meta {
875
+ display: flex;
876
+ flex-wrap: wrap;
877
+ gap: 10px 14px;
878
+ font-size: 10px;
879
+ color: var(--tg-text-dim);
880
+ }
881
+ .drawer-overview .overview-meta .ov-label { opacity: 0.6; margin-right: 4px; }
882
+ .drawer-overview .overview-meta .ov-value { color: var(--tg-text); font-family: var(--tg-mono); }
883
+ .drawer-overview .ctrl-input { flex: 1; min-width: 180px; }
884
+
885
+ /* Reply form (T1.3) */
886
+ .reply-form {
887
+ display: none;
888
+ flex-wrap: wrap;
889
+ gap: 6px;
890
+ align-items: center;
891
+ padding: 6px 8px;
892
+ background: var(--tg-bg);
893
+ border: 1px solid var(--tg-border);
894
+ border-radius: 4px;
895
+ }
896
+ .reply-form.open { display: flex; }
897
+ .reply-form select,
898
+ .reply-form input {
899
+ background: var(--tg-surface);
900
+ border: 1px solid var(--tg-border);
901
+ color: var(--tg-text);
902
+ font-size: 11px;
903
+ padding: 3px 6px;
904
+ border-radius: 3px;
905
+ font-family: var(--tg-sans);
906
+ }
907
+ .reply-form input { flex: 1; min-width: 160px; font-family: var(--tg-mono); }
908
+ .reply-form input:focus,
909
+ .reply-form select:focus { outline: none; border-color: var(--tg-accent-dim); }
910
+ .reply-form .reply-send {
911
+ background: var(--tg-accent-dim);
912
+ border: 1px solid var(--tg-accent);
913
+ color: var(--tg-text-bright);
914
+ font-size: 11px;
915
+ padding: 3px 10px;
916
+ border-radius: 3px;
917
+ cursor: pointer;
918
+ }
919
+ .reply-form .reply-send:disabled {
920
+ opacity: 0.5;
921
+ cursor: not-allowed;
922
+ }
923
+ .reply-form .reply-status {
924
+ font-size: 10px;
925
+ color: var(--tg-text-dim);
926
+ width: 100%;
927
+ }
928
+ .reply-form .reply-status.error { color: var(--tg-red); }
929
+ .reply-form .reply-status.ok { color: var(--tg-green); }
930
+
931
+ .ctrl-btn.reply-toggle[disabled] { opacity: 0.5; cursor: not-allowed; }
932
+
933
+ .drawer-list {
934
+ display: flex;
935
+ flex-direction: column;
936
+ gap: 4px;
937
+ }
938
+ .drawer-list .empty-msg {
939
+ color: var(--tg-text-dim);
940
+ font-size: 11px;
941
+ font-style: italic;
942
+ padding: 4px 2px;
943
+ }
944
+
945
+ .drawer-row {
946
+ padding: 5px 8px;
947
+ border-radius: 3px;
948
+ background: var(--tg-bg);
949
+ border: 1px solid var(--tg-border);
950
+ cursor: pointer;
951
+ transition: border-color 0.1s, background 0.1s;
952
+ }
953
+ .drawer-row:hover { border-color: var(--tg-border-active); }
954
+ .drawer-row .row-meta {
955
+ display: flex;
956
+ gap: 8px;
957
+ font-size: 9px;
958
+ color: var(--tg-text-dim);
959
+ margin-bottom: 2px;
960
+ }
961
+ .drawer-row .row-cmd {
962
+ font-family: var(--tg-mono);
963
+ font-size: 11px;
964
+ color: var(--tg-text);
965
+ white-space: pre-wrap;
966
+ word-break: break-word;
967
+ }
968
+ .drawer-row .row-content {
969
+ font-family: var(--tg-sans);
970
+ font-size: 11px;
971
+ color: var(--tg-text);
972
+ white-space: pre-wrap;
973
+ word-break: break-word;
974
+ overflow: hidden;
975
+ display: -webkit-box;
976
+ -webkit-line-clamp: 2;
977
+ -webkit-box-orient: vertical;
978
+ }
979
+ .drawer-row.expanded .row-content {
980
+ -webkit-line-clamp: unset;
981
+ display: block;
982
+ }
983
+ .drawer-row.copied { border-color: var(--tg-green); }
984
+
985
+ .status-log-row {
986
+ display: flex;
987
+ gap: 10px;
988
+ align-items: baseline;
989
+ padding: 3px 6px;
990
+ border-radius: 3px;
991
+ font-size: 11px;
992
+ }
993
+ .status-log-row:hover { background: var(--tg-bg); }
994
+ .status-log-row .ts {
995
+ color: var(--tg-text-dim);
996
+ font-size: 10px;
997
+ font-family: var(--tg-mono);
998
+ min-width: 64px;
999
+ }
1000
+ .status-log-row .transition { font-family: var(--tg-mono); font-size: 10px; }
1001
+ .status-log-row .detail { color: var(--tg-text-dim); font-size: 10px; }
1002
+ .status-log-row .chip {
1003
+ display: inline-block;
1004
+ padding: 0 5px;
1005
+ border-radius: 3px;
1006
+ font-size: 9px;
1007
+ font-family: var(--tg-mono);
1008
+ background: var(--tg-surface);
1009
+ }
1010
+
1011
+ .term-panel.exited .drawer-overview .ctrl-input,
1012
+ .term-panel.exited .drawer-overview .theme-select,
1013
+ .term-panel.exited .drawer-overview .ctrl-btn { pointer-events: none; opacity: 0.55; }
1014
+
1015
+ /* ===== TERMINAL SWITCHER (T1.2) =====
1016
+ F1.2: lives inside the topbar as a chrome child, not a floating overlay —
1017
+ guarantees zero PTY overlap at any grid density. */
1018
+ .term-switcher {
1019
+ display: none;
1020
+ flex-direction: row;
1021
+ align-items: center;
1022
+ gap: 6px;
1023
+ padding: 2px 6px;
1024
+ background: var(--tg-bg);
1025
+ border: 1px solid var(--tg-border);
1026
+ border-radius: var(--tg-radius-sm);
1027
+ max-width: 60vw;
1028
+ overflow-x: auto;
1029
+ z-index: 1000;
1030
+ }
1031
+ .term-switcher.visible { display: flex; }
1032
+ .term-switcher-label {
1033
+ font-size: 9px;
1034
+ color: var(--tg-text-dim);
1035
+ text-transform: uppercase;
1036
+ letter-spacing: 0.5px;
1037
+ user-select: none;
1038
+ white-space: nowrap;
1039
+ }
1040
+ .switcher-grid {
1041
+ display: grid;
1042
+ grid-auto-flow: column;
1043
+ grid-auto-columns: 26px;
1044
+ grid-template-rows: 26px;
1045
+ gap: 4px;
1046
+ }
1047
+ .switcher-tile {
1048
+ position: relative;
1049
+ width: 26px;
1050
+ height: 26px;
1051
+ border-radius: 4px;
1052
+ background: var(--tg-bg);
1053
+ border: 1px solid var(--tg-border);
1054
+ color: var(--tg-text);
1055
+ font-family: var(--tg-mono);
1056
+ font-size: 12px;
1057
+ font-weight: 600;
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: center;
1061
+ cursor: pointer;
1062
+ transition: border-color 0.12s, background 0.12s, transform 0.08s;
1063
+ }
1064
+ .switcher-tile:hover { border-color: var(--tg-accent-dim); background: var(--tg-surface-hover); }
1065
+ .switcher-tile.active { border-color: var(--tg-accent); box-shadow: 0 0 0 1px var(--tg-accent-dim) inset; }
1066
+ .switcher-tile.exited { opacity: 0.45; }
1067
+ .switcher-tile .switcher-dot {
1068
+ position: absolute;
1069
+ top: 2px;
1070
+ right: 2px;
1071
+ width: 5px;
1072
+ height: 5px;
1073
+ border-radius: 50%;
1074
+ }
1075
+ .switcher-tile .switcher-bar {
1076
+ position: absolute;
1077
+ left: 2px;
1078
+ right: 2px;
1079
+ bottom: 2px;
1080
+ height: 2px;
1081
+ border-radius: 1px;
1082
+ }
1083
+
1084
+ /* ===== Proactive memory toast (T1.4) ===== */
1085
+ .term-panel { position: relative; }
1086
+ .proactive-toast {
1087
+ position: absolute;
1088
+ right: 10px;
1089
+ bottom: 44px;
1090
+ max-width: 320px;
1091
+ padding: 8px 10px 8px 12px;
1092
+ background: rgba(15, 17, 23, 0.95);
1093
+ border: 1px solid var(--tg-accent-dim);
1094
+ border-left: 3px solid var(--tg-purple);
1095
+ border-radius: var(--tg-radius-sm);
1096
+ box-shadow: 0 6px 18px rgba(0,0,0,0.35);
1097
+ color: var(--tg-text);
1098
+ font-size: 11px;
1099
+ cursor: pointer;
1100
+ z-index: 25;
1101
+ animation: toast-in 0.18s ease;
1102
+ }
1103
+ .proactive-toast .t-title {
1104
+ font-size: 10px;
1105
+ text-transform: uppercase;
1106
+ letter-spacing: 0.4px;
1107
+ color: var(--tg-purple);
1108
+ margin-bottom: 3px;
1109
+ }
1110
+ .proactive-toast .t-body {
1111
+ font-size: 11px;
1112
+ line-height: 1.35;
1113
+ color: var(--tg-text);
1114
+ display: -webkit-box;
1115
+ -webkit-line-clamp: 3;
1116
+ -webkit-box-orient: vertical;
1117
+ overflow: hidden;
1118
+ }
1119
+ .proactive-toast .t-meta {
1120
+ margin-top: 4px;
1121
+ font-size: 9px;
1122
+ color: var(--tg-text-dim);
1123
+ }
1124
+ .proactive-toast .t-dismiss {
1125
+ position: absolute;
1126
+ top: 2px;
1127
+ right: 4px;
1128
+ background: none;
1129
+ border: none;
1130
+ color: var(--tg-text-dim);
1131
+ cursor: pointer;
1132
+ font-size: 12px;
1133
+ padding: 0 4px;
1134
+ }
1135
+ .proactive-toast .t-dismiss:hover { color: var(--tg-text); }
1136
+ @keyframes toast-in {
1137
+ from { opacity: 0; transform: translateY(6px); }
1138
+ to { opacity: 1; transform: translateY(0); }
1139
+ }
1140
+
1141
+ /* ===== Control dashboard (T1.6) ===== */
1142
+ .control-feed {
1143
+ display: none;
1144
+ position: absolute;
1145
+ inset: 0;
1146
+ background: var(--tg-bg);
1147
+ border-radius: var(--tg-radius);
1148
+ padding: 12px 16px;
1149
+ overflow-y: auto;
1150
+ flex-direction: column;
1151
+ gap: 6px;
1152
+ }
1153
+ .grid-container.layout-control .term-panel { display: none; }
1154
+ .grid-container.layout-control .control-feed { display: flex; }
1155
+ .grid-container.layout-control {
1156
+ grid-template-columns: 1fr;
1157
+ grid-template-rows: 1fr;
1158
+ position: relative;
1159
+ }
1160
+
1161
+ .control-feed-header {
1162
+ display: flex;
1163
+ align-items: center;
1164
+ justify-content: space-between;
1165
+ padding-bottom: 8px;
1166
+ border-bottom: 1px solid var(--tg-border);
1167
+ margin-bottom: 4px;
1168
+ }
1169
+ .control-feed-header h3 {
1170
+ margin: 0;
1171
+ font-size: 12px;
1172
+ color: var(--tg-text-bright);
1173
+ text-transform: uppercase;
1174
+ letter-spacing: 0.5px;
1175
+ font-weight: 600;
1176
+ }
1177
+ .control-feed-header .feed-count {
1178
+ font-size: 10px;
1179
+ color: var(--tg-text-dim);
1180
+ }
1181
+
1182
+ .feed-row {
1183
+ display: grid;
1184
+ grid-template-columns: 68px 110px 62px 1fr;
1185
+ gap: 10px;
1186
+ align-items: start;
1187
+ padding: 6px 8px;
1188
+ border-radius: 4px;
1189
+ cursor: pointer;
1190
+ border: 1px solid transparent;
1191
+ font-size: 11px;
1192
+ line-height: 1.4;
1193
+ }
1194
+ .feed-row:hover { background: var(--tg-surface); border-color: var(--tg-border); }
1195
+ .feed-row .feed-time { color: var(--tg-text-dim); font-family: var(--tg-mono); font-size: 10px; }
1196
+ .feed-row .feed-panel-ref {
1197
+ display: flex;
1198
+ align-items: center;
1199
+ gap: 6px;
1200
+ color: var(--tg-text);
1201
+ font-size: 10px;
1202
+ }
1203
+ .feed-row .feed-panel-ref .dot {
1204
+ width: 6px; height: 6px;
1205
+ border-radius: 50%;
1206
+ }
1207
+ .feed-row .feed-kind {
1208
+ font-size: 9px;
1209
+ text-transform: uppercase;
1210
+ letter-spacing: 0.4px;
1211
+ font-family: var(--tg-mono);
1212
+ padding: 1px 6px;
1213
+ border-radius: 3px;
1214
+ background: var(--tg-surface);
1215
+ color: var(--tg-text-dim);
1216
+ text-align: center;
1217
+ align-self: start;
1218
+ }
1219
+ .feed-row .feed-kind.status { color: var(--tg-accent); }
1220
+ .feed-row .feed-kind.command { color: var(--tg-green); }
1221
+ .feed-row .feed-kind.error { color: var(--tg-red); }
1222
+ .feed-row .feed-kind.memory { color: var(--tg-purple); }
1223
+ .feed-row .feed-body {
1224
+ font-family: var(--tg-mono);
1225
+ color: var(--tg-text);
1226
+ word-break: break-word;
1227
+ white-space: pre-wrap;
1228
+ display: -webkit-box;
1229
+ -webkit-line-clamp: 2;
1230
+ -webkit-box-orient: vertical;
1231
+ overflow: hidden;
1232
+ }
1233
+ .feed-empty {
1234
+ color: var(--tg-text-dim);
1235
+ font-size: 12px;
1236
+ font-style: italic;
1237
+ padding: 20px 4px;
1238
+ text-align: center;
1239
+ }
1240
+
1241
+ .layout-btn.control-btn { color: var(--tg-purple); }
1242
+ .layout-btn.control-btn.active { color: var(--tg-purple); background: var(--tg-surface); }
1243
+
1244
+ /* Focus flash — applied briefly when a panel gains focus */
1245
+ .term-panel.focus-flash {
1246
+ box-shadow: 0 0 0 2px var(--tg-accent);
1247
+ border-color: var(--tg-accent);
1248
+ }
1249
+
1250
+ /* ===== SCROLLBAR ===== */
1251
+ ::-webkit-scrollbar { width: 6px; }
1252
+ ::-webkit-scrollbar-track { background: transparent; }
1253
+ ::-webkit-scrollbar-thumb { background: var(--tg-border); border-radius: 3px; }
1254
+ ::-webkit-scrollbar-thumb:hover { background: var(--tg-border-active); }
1255
+ </style>
1256
+ </head>
1257
+ <body>
1258
+
1259
+ <!-- TOP BAR -->
1260
+ <div class="topbar">
1261
+ <div class="topbar-left">
1262
+ <div class="topbar-logo">
1263
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
1264
+ <rect x="1" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
1265
+ <rect x="10" y="1" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
1266
+ <rect x="1" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
1267
+ <rect x="10" y="10" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
1268
+ </svg>
1269
+ TermDeck
1270
+ </div>
1271
+ <div class="topbar-stats" id="globalStats">
1272
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-green)"></span> <span id="stat-active">0</span> active</span>
1273
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-purple)"></span> <span id="stat-thinking">0</span> thinking</span>
1274
+ <span class="topbar-stat"><span class="dot" style="background:var(--tg-amber)"></span> <span id="stat-idle">0</span> idle</span>
1275
+ <span class="topbar-stat" id="stat-rag" style="display:none">RAG</span>
1276
+ </div>
1277
+ </div>
1278
+
1279
+ <div class="topbar-center">
1280
+ <button class="layout-btn" data-layout="1x1">1x1</button>
1281
+ <button class="layout-btn active" data-layout="2x1">2x1</button>
1282
+ <button class="layout-btn" data-layout="2x2">2x2</button>
1283
+ <button class="layout-btn" data-layout="3x2">3x2</button>
1284
+ <button class="layout-btn" data-layout="2x4">2x4</button>
1285
+ <button class="layout-btn" data-layout="4x2">4x2</button>
1286
+ <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
1287
+ </div>
1288
+
1289
+ <div class="topbar-right">
1290
+ <!-- TERMINAL SWITCHER (T1.2 / F1.2): lives in chrome, not over PTY content -->
1291
+ <div class="term-switcher" id="termSwitcher" aria-label="Terminal switcher">
1292
+ <div class="term-switcher-label">Alt+1…9</div>
1293
+ <div class="switcher-grid" id="switcherGrid"></div>
1294
+ </div>
1295
+ <div class="topbar-ql" id="topbarQuickLaunch" aria-label="Quick launch">
1296
+ <button class="topbar-ql-btn" onclick="quickLaunch('zsh')" title="Open a zsh shell">shell</button>
1297
+ <button class="topbar-ql-btn" onclick="quickLaunch('claude')" title="Open Claude Code">claude</button>
1298
+ <button class="topbar-ql-btn" onclick="quickLaunch('python3 -m http.server 8080')" title="Open a Python HTTP server on :8080">python</button>
1299
+ </div>
1300
+ <button id="btn-status">status</button>
1301
+ <button id="btn-config">config</button>
1302
+ <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
1303
+ <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://github.com/jhizzard/termdeck#readme','_blank','noopener')">help</button>
1304
+ </div>
1305
+ </div>
1306
+
1307
+ <!-- TERMINAL GRID -->
1308
+ <div class="grid-container layout-2x1" id="termGrid">
1309
+ <div class="control-feed" id="controlFeed">
1310
+ <div class="control-feed-header">
1311
+ <h3>Control · live activity</h3>
1312
+ <span class="feed-count" id="feedCount">0 events</span>
1313
+ </div>
1314
+ <div class="feed-rows" id="feedRows"></div>
1315
+ </div>
1316
+ <div class="empty-state" id="emptyState">
1317
+ <svg width="64" height="64" viewBox="0 0 48 48" fill="none">
1318
+ <rect x="4" y="4" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
1319
+ <rect x="26" y="4" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
1320
+ <rect x="4" y="26" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
1321
+ <rect x="26" y="26" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/>
1322
+ </svg>
1323
+ <h2>No terminals yet.</h2>
1324
+ <p>Launch one to get started.</p>
1325
+ <div class="quick-launch-group">
1326
+ <button class="quick-launch-btn" onclick="quickLaunch('zsh')">
1327
+ <span class="ql-cmd">zsh</span>
1328
+ <span class="ql-desc">Open a shell</span>
1329
+ </button>
1330
+ <button class="quick-launch-btn" onclick="quickLaunch('claude')">
1331
+ <span class="ql-cmd">claude</span>
1332
+ <span class="ql-desc">Open Claude Code</span>
1333
+ </button>
1334
+ <button class="quick-launch-btn" onclick="quickLaunch('python3 -m http.server 8080')">
1335
+ <span class="ql-cmd">python3 -m http.server 8080</span>
1336
+ <span class="ql-desc">Open a Python server</span>
1337
+ </button>
1338
+ </div>
1339
+ <div class="notes">
1340
+ <span>Press <kbd>/</kbd> to focus the prompt bar, or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd> to open a shell.</span>
1341
+ </div>
1342
+ </div>
1343
+ </div>
1344
+
1345
+ <!-- ONBOARDING TOUR elements (hidden by default, shown by startTour()) -->
1346
+ <div class="tour-backdrop" id="tourBackdrop"></div>
1347
+ <div class="tour-spotlight" id="tourSpotlight"></div>
1348
+ <div class="tour-tooltip" id="tourTooltip" style="display:none">
1349
+ <h3 id="tourTitle"></h3>
1350
+ <p id="tourBody"></p>
1351
+ <div class="tour-controls">
1352
+ <span class="tour-counter" id="tourCounter"></span>
1353
+ <div class="tour-btns">
1354
+ <button class="tour-skip" id="tourSkipBtn">skip</button>
1355
+ <button class="tour-prev" id="tourPrevBtn">back</button>
1356
+ <button class="tour-next" id="tourNextBtn">next</button>
1357
+ </div>
1358
+ </div>
1359
+ </div>
1360
+
1361
+ <!-- PROMPT BAR -->
1362
+ <div class="prompt-bar">
1363
+ <span class="prompt-icon">></span>
1364
+ <input
1365
+ type="text"
1366
+ class="prompt-input"
1367
+ id="promptInput"
1368
+ placeholder="Launch a terminal... e.g. 'claude code ~/scheduling-saas' or 'python3 manage.py runserver'"
1369
+ autocomplete="off"
1370
+ >
1371
+ <select class="prompt-project" id="promptProject">
1372
+ <option value="">no project</option>
1373
+ </select>
1374
+ <button class="prompt-add-project" id="btnAddProject" title="Add a new project">+</button>
1375
+ <button class="prompt-launch" id="promptLaunch">launch</button>
1376
+ </div>
1377
+
1378
+ <!-- Add-project modal (hidden by default) -->
1379
+ <div class="add-project-modal" id="addProjectModal">
1380
+ <div class="add-project-backdrop"></div>
1381
+ <div class="add-project-card">
1382
+ <h3>Add a project</h3>
1383
+ <p class="apm-help">Projects are saved to <code>~/.termdeck/config.yaml</code> and persist across restarts.</p>
1384
+ <label>
1385
+ <span>Name</span>
1386
+ <input type="text" id="apmName" placeholder="my-project" autocomplete="off" spellcheck="false">
1387
+ </label>
1388
+ <label>
1389
+ <span>Path</span>
1390
+ <input type="text" id="apmPath" placeholder="~/code/my-project" autocomplete="off" spellcheck="false">
1391
+ </label>
1392
+ <label>
1393
+ <span>Default command <em>(optional)</em></span>
1394
+ <input type="text" id="apmCommand" placeholder="claude" autocomplete="off" spellcheck="false">
1395
+ </label>
1396
+ <label>
1397
+ <span>Default theme <em>(optional)</em></span>
1398
+ <select id="apmTheme"><option value="">— pick a theme —</option></select>
1399
+ </label>
1400
+ <div class="apm-status" id="apmStatus"></div>
1401
+ <div class="apm-actions">
1402
+ <button class="apm-cancel" id="apmCancel">cancel</button>
1403
+ <button class="apm-save" id="apmSave">add project</button>
1404
+ </div>
1405
+ </div>
1406
+ </div>
1407
+
1408
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1409
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
1410
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
1411
+ <script>
1412
+ // ===== TermDeck Client =====
1413
+ const API = window.location.origin;
1414
+ const WS_BASE = `ws://${window.location.host}/ws`;
1415
+
1416
+ // State
1417
+ const state = {
1418
+ sessions: new Map(), // id → { session, terminal, ws, fitAddon, el }
1419
+ layout: '2x1',
1420
+ themes: {},
1421
+ config: {},
1422
+ focusedId: null
1423
+ };
1424
+
1425
+ // ===== API helpers =====
1426
+ async function api(method, path, body) {
1427
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
1428
+ if (body) opts.body = JSON.stringify(body);
1429
+ const res = await fetch(`${API}${path}`, opts);
1430
+ return res.json();
1431
+ }
1432
+
1433
+ // ===== Initialize =====
1434
+ async function init() {
1435
+ // Load config
1436
+ state.config = await api('GET', '/api/config');
1437
+
1438
+ // Populate project dropdown
1439
+ const sel = document.getElementById('promptProject');
1440
+ for (const name of Object.keys(state.config.projects || {})) {
1441
+ const opt = document.createElement('option');
1442
+ opt.value = name;
1443
+ opt.textContent = name;
1444
+ sel.appendChild(opt);
1445
+ }
1446
+
1447
+ // Load themes
1448
+ const themeList = await api('GET', '/api/themes');
1449
+ for (const t of themeList) {
1450
+ state.themes[t.id] = t;
1451
+ }
1452
+
1453
+ // Load existing sessions
1454
+ const sessions = await api('GET', '/api/sessions');
1455
+ for (const s of sessions) {
1456
+ if (s.meta.status !== 'exited') {
1457
+ createTerminalPanel(s);
1458
+ }
1459
+ }
1460
+
1461
+ // RAG indicator
1462
+ if (state.config.ragEnabled) {
1463
+ document.getElementById('stat-rag').style.display = '';
1464
+ }
1465
+
1466
+ // Disable AI input bars if Supabase/OpenAI not configured
1467
+ if (!state.config.aiQueryAvailable) {
1468
+ document.querySelectorAll('.ctrl-input').forEach(el => {
1469
+ el.placeholder = 'Configure Supabase in ~/.termdeck/config.yaml to enable';
1470
+ el.disabled = true;
1471
+ });
1472
+ }
1473
+
1474
+ updateEmptyState();
1475
+
1476
+ // First-run onboarding tour. Fires on the first visit only; never again
1477
+ // unless the user explicitly clicks "how this works" in the top toolbar.
1478
+ try {
1479
+ if (!localStorage.getItem('termdeck:tour:seen')) {
1480
+ setTimeout(() => { if (!tourState.active) startTour(); }, 1200);
1481
+ }
1482
+ } catch {}
1483
+ }
1484
+
1485
+ // ===== Create Terminal Panel =====
1486
+ function createTerminalPanel(sessionData) {
1487
+ const id = sessionData.id;
1488
+ const meta = sessionData.meta;
1489
+
1490
+ // Idempotency guard: multiple code paths can trigger this function for
1491
+ // the same session ID in rapid succession — status_broadcast handler
1492
+ // (2s interval), external-session poller (3s interval), launchTerminal
1493
+ // (immediate after POST), and init() on page load. Without a claim at
1494
+ // function entry, two of these can race and create two client panels
1495
+ // for the same server session — which means two WebSockets, the second
1496
+ // overwrites session.ws on the server, and term.onData output stops
1497
+ // reaching the first panel's xterm. Result: terminals spawn but never
1498
+ // render a prompt and don't accept input.
1499
+ //
1500
+ // Fix: reserve the slot in state.sessions immediately on entry. Any
1501
+ // subsequent call sees has(id) and early-returns. The full entry gets
1502
+ // written later when the xterm + ws + fitAddon are built; that write
1503
+ // overwrites this placeholder in place.
1504
+ if (state.sessions.has(id)) return;
1505
+ state.sessions.set(id, { _mounting: true });
1506
+
1507
+ // Hide empty state
1508
+ document.getElementById('emptyState').style.display = 'none';
1509
+
1510
+ // Project CSS class
1511
+ const projClass = meta.project
1512
+ ? `project-${meta.project.replace(/[^a-z0-9]/gi, '').toLowerCase()}`
1513
+ : 'project-default';
1514
+
1515
+ // Build panel HTML
1516
+ const panel = document.createElement('div');
1517
+ panel.className = 'term-panel';
1518
+ panel.id = `panel-${id}`;
1519
+ panel.innerHTML = `
1520
+ <div class="panel-header">
1521
+ <div class="panel-header-left">
1522
+ <span class="status-dot" id="dot-${id}" style="background:${getStatusColor(meta.status)}"></span>
1523
+ <span class="panel-type">${getTypeLabel(meta.type)}</span>
1524
+ ${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
1525
+ <span class="panel-index" id="idx-${id}"></span>
1526
+ <span class="panel-status" id="status-${id}">${meta.statusDetail || meta.status}</span>
1527
+ </div>
1528
+ <div class="panel-header-right">
1529
+ <button class="panel-btn" onclick="focusPanel('${id}')" title="Focus this terminal">&#9634;</button>
1530
+ <button class="panel-btn" onclick="halfPanel('${id}')" title="Half screen">&#9645;</button>
1531
+ <button class="panel-btn danger" onclick="closePanel('${id}')" title="Close terminal">&times;</button>
1532
+ </div>
1533
+ </div>
1534
+ <div class="panel-meta">
1535
+ <span class="meta-item"><span class="meta-label">opened</span> ${timeAgo(meta.createdAt)}</span>
1536
+ <span class="meta-item"><span class="meta-label">why</span> ${meta.reason}</span>
1537
+ <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>
1538
+ <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>
1539
+ <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>
1540
+ </div>
1541
+ <div class="panel-terminal" id="term-${id}"></div>
1542
+ <div class="panel-drawer" id="drawer-${id}">
1543
+ <div class="drawer-tabs" role="tablist">
1544
+ <button class="drawer-tab active" data-tab="overview" data-panel-id="${id}">Overview</button>
1545
+ <button class="drawer-tab" data-tab="commands" data-panel-id="${id}">Commands<span class="tab-badge" id="badge-commands-${id}">0</span></button>
1546
+ <button class="drawer-tab" data-tab="memory" data-panel-id="${id}">Memory<span class="tab-badge" id="badge-memory-${id}">0</span></button>
1547
+ <button class="drawer-tab" data-tab="log" data-panel-id="${id}">Status log<span class="tab-badge" id="badge-log-${id}">0</span></button>
1548
+ </div>
1549
+ <div class="drawer-body">
1550
+ <div class="drawer-panel drawer-overview active" data-panel="overview">
1551
+ <div class="overview-controls">
1552
+ <select class="theme-select" id="theme-${id}" onchange="changeTheme('${id}', this.value)">
1553
+ ${Object.entries(state.themes).map(([tid, t]) =>
1554
+ `<option value="${tid}" ${tid === meta.theme ? 'selected' : ''}>${t.label}</option>`
1555
+ ).join('')}
1556
+ </select>
1557
+ <button class="ctrl-btn" onclick="focusPanel('${id}')">focus</button>
1558
+ <button class="ctrl-btn" onclick="halfPanel('${id}')">half</button>
1559
+ <button class="ctrl-btn reply-toggle" id="reply-btn-${id}" onclick="toggleReplyForm('${id}')" title="Send text to another terminal">reply ▸</button>
1560
+ <input type="text" class="ctrl-input" id="ai-${id}" placeholder="Ask about this terminal..." onkeydown="if(event.key==='Enter')askAI('${id}', this.value)">
1561
+ </div>
1562
+ <div class="reply-form" id="reply-form-${id}">
1563
+ <select class="reply-target" id="reply-target-${id}"></select>
1564
+ <input type="text" class="reply-text" id="reply-text-${id}" placeholder="Text to send..." onkeydown="if(event.key==='Enter')sendReply('${id}')">
1565
+ <button class="reply-send" id="reply-send-${id}" onclick="sendReply('${id}')">send</button>
1566
+ <div class="reply-status" id="reply-status-${id}"></div>
1567
+ </div>
1568
+ <div class="overview-meta" id="ovmeta-${id}"></div>
1569
+ </div>
1570
+ <div class="drawer-panel drawer-list" data-panel="commands" id="dp-commands-${id}">
1571
+ <div class="empty-msg">No commands captured yet.</div>
1572
+ </div>
1573
+ <div class="drawer-panel drawer-list" data-panel="memory" id="dp-memory-${id}">
1574
+ <div class="empty-msg">No memory hits yet. Ask about this terminal or wait for a proactive lookup.</div>
1575
+ </div>
1576
+ <div class="drawer-panel drawer-list" data-panel="log" id="dp-log-${id}">
1577
+ <div class="empty-msg">No status transitions recorded yet.</div>
1578
+ </div>
1579
+ </div>
1580
+ </div>
1581
+ `;
1582
+
1583
+ document.getElementById('termGrid').appendChild(panel);
1584
+
1585
+ // Create xterm.js instance
1586
+ const terminal = new Terminal({
1587
+ fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
1588
+ fontSize: 13,
1589
+ lineHeight: 1.3,
1590
+ cursorBlink: true,
1591
+ cursorStyle: 'bar',
1592
+ allowProposedApi: true,
1593
+ scrollback: 5000,
1594
+ theme: getThemeObject(meta.theme)
1595
+ });
1596
+
1597
+ const fitAddon = new FitAddon.FitAddon();
1598
+ terminal.loadAddon(fitAddon);
1599
+
1600
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
1601
+ terminal.loadAddon(webLinksAddon);
1602
+
1603
+ const container = document.getElementById(`term-${id}`);
1604
+ terminal.open(container);
1605
+
1606
+ // Delay fit to ensure DOM is ready
1607
+ requestAnimationFrame(() => {
1608
+ fitAddon.fit();
1609
+ // Inform server of initial size
1610
+ api('POST', `/api/sessions/${id}/resize`, {
1611
+ cols: terminal.cols,
1612
+ rows: terminal.rows
1613
+ });
1614
+ });
1615
+
1616
+ // Connect WebSocket
1617
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
1618
+
1619
+ ws.onmessage = (event) => {
1620
+ try {
1621
+ const msg = JSON.parse(event.data);
1622
+ switch (msg.type) {
1623
+ case 'output':
1624
+ terminal.write(msg.data);
1625
+ break;
1626
+ case 'meta':
1627
+ updatePanelMeta(id, msg.session.meta);
1628
+ break;
1629
+ case 'exit':
1630
+ updatePanelMeta(id, {
1631
+ status: 'exited',
1632
+ statusDetail: `Exited (${msg.exitCode})`
1633
+ });
1634
+ // Dim the panel
1635
+ const exitPanel = document.getElementById(`panel-${id}`);
1636
+ if (exitPanel) exitPanel.classList.add('exited');
1637
+ refreshAllReplyFormsFor(id);
1638
+ refreshPanelIndices();
1639
+ renderSwitcher();
1640
+ break;
1641
+ case 'status_broadcast':
1642
+ updateGlobalStats(msg.sessions);
1643
+ break;
1644
+ }
1645
+ } catch (err) { console.error('[client] ws message parse failed:', err); }
1646
+ };
1647
+
1648
+ ws.onclose = (event) => {
1649
+ console.log(`[ws] Disconnected from session ${id} (code ${event.code})`);
1650
+ const entry = state.sessions.get(id);
1651
+ if (!entry) return;
1652
+
1653
+ // Don't reconnect if session was explicitly closed or exited
1654
+ if (event.code === 4000 || event.code === 4001) return;
1655
+ const panel = document.getElementById(`panel-${id}`);
1656
+ if (panel && panel.classList.contains('exited')) return;
1657
+
1658
+ // Auto-reconnect with backoff
1659
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
1660
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
1661
+
1662
+ if (entry._reconnectAttempts <= 5) {
1663
+ console.log(`[ws] Reconnecting session ${id} in ${delay}ms (attempt ${entry._reconnectAttempts})`);
1664
+ setTimeout(() => reconnectSession(id), delay);
1665
+ } else {
1666
+ updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
1667
+ }
1668
+ };
1669
+
1670
+ // Terminal input → WebSocket
1671
+ terminal.onData((data) => {
1672
+ if (ws.readyState === WebSocket.OPEN) {
1673
+ ws.send(JSON.stringify({ type: 'input', data }));
1674
+ }
1675
+ });
1676
+
1677
+ // Track focus
1678
+ terminal.textarea?.addEventListener('focus', () => {
1679
+ panel.classList.add('active-input');
1680
+ state.focusedId = id;
1681
+ });
1682
+ terminal.textarea?.addEventListener('blur', () => {
1683
+ panel.classList.remove('active-input');
1684
+ });
1685
+
1686
+ // Store reference
1687
+ state.sessions.set(id, {
1688
+ session: sessionData,
1689
+ terminal,
1690
+ ws,
1691
+ fitAddon,
1692
+ el: panel,
1693
+ activeTab: 'overview',
1694
+ drawerOpen: false,
1695
+ commandHistory: [],
1696
+ commandsLoaded: false,
1697
+ memoryHits: [],
1698
+ statusLog: [],
1699
+ lastKnownStatus: meta.status,
1700
+ });
1701
+
1702
+ // Seed an initial status-log entry so the tab isn't blank
1703
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
1704
+
1705
+ // Drawer tab wiring
1706
+ setupDrawerListeners(id);
1707
+ renderOverviewTab(id);
1708
+ renderSwitcher();
1709
+
1710
+ // Reply form: disabled until there's another panel to target
1711
+ const replyBtn = document.getElementById(`reply-btn-${id}`);
1712
+ if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
1713
+ refreshAllReplyFormsFor(id);
1714
+ refreshPanelIndices();
1715
+
1716
+ // Handle window resize
1717
+ const resizeObserver = new ResizeObserver(() => {
1718
+ try {
1719
+ fitAddon.fit();
1720
+ if (ws.readyState === WebSocket.OPEN) {
1721
+ ws.send(JSON.stringify({
1722
+ type: 'resize',
1723
+ cols: terminal.cols,
1724
+ rows: terminal.rows
1725
+ }));
1726
+ }
1727
+ } catch (err) { console.error('[client] terminal resize failed:', err); }
1728
+ });
1729
+ resizeObserver.observe(container);
1730
+
1731
+ return { terminal, ws, fitAddon };
1732
+ }
1733
+
1734
+ // ===== Control dashboard (T1.6) =====
1735
+ async function enterControlMode() {
1736
+ // Pre-warm command history for every open session so the feed is dense.
1737
+ const loads = [];
1738
+ for (const [sid, entry] of state.sessions) {
1739
+ if (entry.commandsLoaded) continue;
1740
+ loads.push(
1741
+ api('GET', `/api/sessions/${sid}/history`).then(resp => {
1742
+ const list = Array.isArray(resp) ? resp : (resp.commands || resp.history || []);
1743
+ entry.commandHistory = list;
1744
+ entry.commandsLoaded = true;
1745
+ }).catch(() => { /* silent */ })
1746
+ );
1747
+ }
1748
+ await Promise.allSettled(loads);
1749
+ renderControlFeed();
1750
+ }
1751
+
1752
+ function renderControlFeed() {
1753
+ const grid = document.getElementById('termGrid');
1754
+ const rowsEl = document.getElementById('feedRows');
1755
+ const countEl = document.getElementById('feedCount');
1756
+ if (!grid || !rowsEl) return;
1757
+ if (!grid.classList.contains('layout-control')) return;
1758
+
1759
+ const events = [];
1760
+ for (const [sid, entry] of state.sessions) {
1761
+ const meta = entry.session?.meta || {};
1762
+ const label = `${getTypeLabel(meta.type || 'shell')}${meta.project ? '·' + meta.project : ''}`;
1763
+ const statusColor = getStatusColor(meta.status || 'idle');
1764
+
1765
+ // Status transitions
1766
+ for (const ev of (entry.statusLog || [])) {
1767
+ const isErr = ev.status === 'errored';
1768
+ events.push({
1769
+ at: new Date(ev.at).getTime(),
1770
+ sid,
1771
+ label,
1772
+ statusColor,
1773
+ kind: isErr ? 'error' : 'status',
1774
+ body: `${ev.status}${ev.detail ? ' — ' + ev.detail : ''}`,
1775
+ });
1776
+ }
1777
+
1778
+ // Recent commands
1779
+ for (const c of (entry.commandHistory || []).slice(0, 25)) {
1780
+ const t = c.timestamp || c.createdAt || c.created_at;
1781
+ if (!t) continue;
1782
+ events.push({
1783
+ at: new Date(t).getTime(),
1784
+ sid,
1785
+ label,
1786
+ statusColor,
1787
+ kind: 'command',
1788
+ body: c.command || c.cmd || '',
1789
+ });
1790
+ }
1791
+
1792
+ // Memory hits cached from askAI / proactive queries
1793
+ for (const m of (entry.memoryHits || []).slice(0, 10)) {
1794
+ if (!m.cachedAt) continue;
1795
+ events.push({
1796
+ at: new Date(m.cachedAt).getTime(),
1797
+ sid,
1798
+ label,
1799
+ statusColor,
1800
+ kind: 'memory',
1801
+ body: (m.content || m.text || '(memory)').slice(0, 220),
1802
+ });
1803
+ }
1804
+ }
1805
+
1806
+ events.sort((a, b) => b.at - a.at);
1807
+ const capped = events.slice(0, 200);
1808
+
1809
+ if (countEl) countEl.textContent = `${capped.length} event${capped.length === 1 ? '' : 's'}`;
1810
+
1811
+ if (capped.length === 0) {
1812
+ rowsEl.innerHTML = '<div class="feed-empty">No activity yet. Commands, status transitions, and memory hits will appear here.</div>';
1813
+ return;
1814
+ }
1815
+
1816
+ rowsEl.innerHTML = capped.map(ev => {
1817
+ const t = new Date(ev.at);
1818
+ const hh = String(t.getHours()).padStart(2, '0');
1819
+ const mm = String(t.getMinutes()).padStart(2, '0');
1820
+ const ss = String(t.getSeconds()).padStart(2, '0');
1821
+ return `
1822
+ <div class="feed-row" data-session-id="${ev.sid}">
1823
+ <span class="feed-time">${hh}:${mm}:${ss}</span>
1824
+ <span class="feed-panel-ref"><span class="dot" style="background:${ev.statusColor}"></span>${escapeHtml(ev.label)}</span>
1825
+ <span class="feed-kind ${ev.kind}">${ev.kind}</span>
1826
+ <span class="feed-body">${escapeHtml(ev.body)}</span>
1827
+ </div>
1828
+ `;
1829
+ }).join('');
1830
+ }
1831
+
1832
+ function onFeedRowClick(e) {
1833
+ const row = e.target.closest('.feed-row');
1834
+ if (!row) return;
1835
+ const sid = row.dataset.sessionId;
1836
+ if (!sid) return;
1837
+ // Return to 2x2 layout and focus the source panel
1838
+ setLayout('2x2');
1839
+ requestAnimationFrame(() => focusSessionById(sid));
1840
+ }
1841
+
1842
+ // ===== Proactive memory toast (T1.4) =====
1843
+ const PROACTIVE_COOLDOWN_MS = 30_000;
1844
+
1845
+ async function triggerProactiveMemoryQuery(id) {
1846
+ const entry = state.sessions.get(id);
1847
+ if (!entry) return;
1848
+ if (!state.config.aiQueryAvailable) return;
1849
+
1850
+ const now = Date.now();
1851
+ if (entry._lastProactiveAt && now - entry._lastProactiveAt < PROACTIVE_COOLDOWN_MS) return;
1852
+ entry._lastProactiveAt = now;
1853
+
1854
+ const meta = entry.session?.meta || {};
1855
+ const lastCmd = meta.lastCommands?.length
1856
+ ? meta.lastCommands[meta.lastCommands.length - 1].command
1857
+ : '';
1858
+ const type = meta.type || 'shell';
1859
+ const question = `${type} error ${lastCmd}`.trim();
1860
+ if (!question || question === `${type} error`) {
1861
+ // No command context — still query using status detail as a last resort
1862
+ if (!meta.statusDetail) return;
1863
+ }
1864
+
1865
+ try {
1866
+ const result = await api('POST', '/api/ai/query', {
1867
+ question: question || `${type} error ${meta.statusDetail || ''}`.trim(),
1868
+ sessionId: id,
1869
+ project: meta.project || null,
1870
+ });
1871
+ if (result?.error) return;
1872
+ if (!Array.isArray(result?.memories) || result.memories.length === 0) return;
1873
+
1874
+ // Cache every hit into the Memory tab so the drawer stays in sync
1875
+ if (!entry.memoryHits) entry.memoryHits = [];
1876
+ const cachedAt = new Date().toISOString();
1877
+ for (const m of result.memories) entry.memoryHits.unshift({ ...m, cachedAt });
1878
+ if (entry.memoryHits.length > 60) entry.memoryHits.length = 60;
1879
+ setBadge(id, 'memory', entry.memoryHits.length);
1880
+ if (entry.drawerOpen && entry.activeTab === 'memory') renderMemoryTab(id);
1881
+
1882
+ showProactiveToast(id, result.memories[0]);
1883
+ } catch (err) {
1884
+ console.error('[client] proactive memory query failed:', err);
1885
+ }
1886
+ }
1887
+
1888
+ function showProactiveToast(id, hit) {
1889
+ const entry = state.sessions.get(id);
1890
+ if (!entry || !entry.el) return;
1891
+
1892
+ // Remove any prior toast for this panel
1893
+ const prev = entry.el.querySelector('.proactive-toast');
1894
+ if (prev) prev.remove();
1895
+
1896
+ const toast = document.createElement('div');
1897
+ toast.className = 'proactive-toast';
1898
+ const proj = hit.project ? escapeHtml(hit.project) : 'another session';
1899
+ const snippet = escapeHtml((hit.content || hit.text || '').slice(0, 220));
1900
+ const score = typeof hit.similarity === 'number' ? `${(hit.similarity * 100).toFixed(0)}%` : '';
1901
+
1902
+ toast.innerHTML = `
1903
+ <button class="t-dismiss" aria-label="Dismiss">×</button>
1904
+ <div class="t-title">Engram — possible match</div>
1905
+ <div class="t-body">Found a similar error in <b>${proj}</b>${score ? ` · ${score}` : ''} — click to see.</div>
1906
+ <div class="t-meta">${snippet}</div>
1907
+ `;
1908
+
1909
+ entry.el.appendChild(toast);
1910
+
1911
+ const dismiss = () => {
1912
+ toast.remove();
1913
+ clearTimeout(toast._autoTimer);
1914
+ };
1915
+ toast.querySelector('.t-dismiss').addEventListener('click', (e) => {
1916
+ e.stopPropagation();
1917
+ dismiss();
1918
+ });
1919
+ toast.addEventListener('click', () => {
1920
+ dismiss();
1921
+ focusSessionById(id);
1922
+ // Open the Memory tab so the user lands directly on the hit list
1923
+ const entry2 = state.sessions.get(id);
1924
+ if (entry2 && (!entry2.drawerOpen || entry2.activeTab !== 'memory')) {
1925
+ toggleDrawerTab(id, 'memory');
1926
+ }
1927
+ });
1928
+
1929
+ toast._autoTimer = setTimeout(dismiss, 8000);
1930
+ }
1931
+
1932
+ // ===== Reply / send-to-terminal (T1.3) =====
1933
+ // Flip this to false to force the local-WS fallback even when the server
1934
+ // endpoint is available — handy for debugging.
1935
+ const USE_SERVER_INPUT_API = true;
1936
+
1937
+ function toggleReplyForm(fromId) {
1938
+ const form = document.getElementById(`reply-form-${fromId}`);
1939
+ if (!form) return;
1940
+ const willOpen = !form.classList.contains('open');
1941
+ form.classList.toggle('open', willOpen);
1942
+ if (willOpen) {
1943
+ refreshReplyTargets(fromId);
1944
+ const input = document.getElementById(`reply-text-${fromId}`);
1945
+ setTimeout(() => input?.focus(), 20);
1946
+ }
1947
+ }
1948
+
1949
+ function refreshReplyTargets(fromId) {
1950
+ const select = document.getElementById(`reply-target-${fromId}`);
1951
+ if (!select) return;
1952
+ const prev = select.value;
1953
+
1954
+ // F1.3: number duplicate labels with `#N` so e.g. two "Claude Code · termdeck"
1955
+ // panels become "Claude Code · termdeck #1" / "... #2". Numbering is across
1956
+ // ALL live panels with that base label (including the current one) in
1957
+ // state.sessions insertion order, so suffixes stay stable as the user opens
1958
+ // the reply form from different panels.
1959
+ const groupIndex = new Map(); // sid → index-within-group (1-based, only when group.size ≥ 2)
1960
+ const groupCount = new Map(); // baseLabel → count so far
1961
+ for (const [sid, entry] of state.sessions) {
1962
+ const panel = entry.el;
1963
+ if (panel && panel.classList.contains('exited')) continue;
1964
+ const meta = entry.session?.meta || {};
1965
+ const base = `${getTypeLabel(meta.type || 'shell')}${meta.project ? ' · ' + meta.project : ''}`;
1966
+ const next = (groupCount.get(base) || 0) + 1;
1967
+ groupCount.set(base, next);
1968
+ groupIndex.set(sid, { base, n: next });
1969
+ }
1970
+
1971
+ const options = [];
1972
+ for (const [sid, entry] of state.sessions) {
1973
+ if (sid === fromId) continue;
1974
+ const panel = entry.el;
1975
+ if (panel && panel.classList.contains('exited')) continue;
1976
+ const info = groupIndex.get(sid);
1977
+ if (!info) continue;
1978
+ const needsSuffix = (groupCount.get(info.base) || 0) >= 2;
1979
+ const label = needsSuffix ? `${info.base} #${info.n}` : info.base;
1980
+ options.push(`<option value="${sid}">${escapeHtml(label)}</option>`);
1981
+ }
1982
+ if (options.length === 0) {
1983
+ select.innerHTML = `<option value="">(no other terminals)</option>`;
1984
+ } else {
1985
+ select.innerHTML = options.join('');
1986
+ if (prev && Array.from(select.options).some(o => o.value === prev)) {
1987
+ select.value = prev;
1988
+ }
1989
+ }
1990
+ }
1991
+
1992
+ // Assign #N index suffixes to panels that share (type, project) with another
1993
+ // panel. Insertion-order numbering via Map iteration (Map preserves insert order).
1994
+ // Groups of size 1 get no suffix — only collisions get numbered.
1995
+ function refreshPanelIndices() {
1996
+ const groups = new Map(); // key = "type|project" → [sid, ...]
1997
+ for (const [sid, entry] of state.sessions) {
1998
+ const meta = entry.session?.meta || {};
1999
+ const key = `${meta.type || 'shell'}|${meta.project || ''}`;
2000
+ if (!groups.has(key)) groups.set(key, []);
2001
+ groups.get(key).push(sid);
2002
+ }
2003
+ for (const [, sids] of groups) {
2004
+ const showIndex = sids.length >= 2;
2005
+ sids.forEach((sid, i) => {
2006
+ const el = document.getElementById(`idx-${sid}`);
2007
+ if (!el) return;
2008
+ el.textContent = showIndex ? `#${i + 1}` : '';
2009
+ });
2010
+ }
2011
+ }
2012
+
2013
+ function refreshAllReplyFormsFor(changedId) {
2014
+ // When a panel is added, removed, or exits, the target list in *other*
2015
+ // panels' open reply forms needs refreshing.
2016
+ for (const [sid, entry] of state.sessions) {
2017
+ if (sid === changedId) continue;
2018
+ const form = document.getElementById(`reply-form-${sid}`);
2019
+ if (form && form.classList.contains('open')) {
2020
+ refreshReplyTargets(sid);
2021
+ }
2022
+ const btn = document.getElementById(`reply-btn-${sid}`);
2023
+ if (btn) btn.disabled = state.sessions.size < 2;
2024
+ }
2025
+ }
2026
+
2027
+ async function sendReply(fromId) {
2028
+ const select = document.getElementById(`reply-target-${fromId}`);
2029
+ const input = document.getElementById(`reply-text-${fromId}`);
2030
+ const statusEl = document.getElementById(`reply-status-${fromId}`);
2031
+ if (!select || !input) return;
2032
+ const targetId = select.value;
2033
+ let text = input.value;
2034
+ if (!targetId) {
2035
+ showReplyStatus(statusEl, 'No target selected.', 'error');
2036
+ return;
2037
+ }
2038
+ if (!text) return;
2039
+
2040
+ const targetEntry = state.sessions.get(targetId);
2041
+ if (!targetEntry) {
2042
+ showReplyStatus(statusEl, 'Target not found.', 'error');
2043
+ return;
2044
+ }
2045
+
2046
+ // zsh and most shells want CR. Normalize \n → \r and strip \r\n pairs.
2047
+ const normalized = text.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
2048
+ // Ensure the line actually submits at the target prompt.
2049
+ const payload = normalized.endsWith('\r') ? normalized : normalized + '\r';
2050
+
2051
+ let delivered = false;
2052
+ let errMsg = '';
2053
+
2054
+ if (USE_SERVER_INPUT_API) {
2055
+ try {
2056
+ const result = await api('POST', `/api/sessions/${targetId}/input`, {
2057
+ text: payload,
2058
+ source: 'reply',
2059
+ fromSessionId: fromId,
2060
+ });
2061
+ if (result && !result.error) {
2062
+ delivered = true;
2063
+ } else {
2064
+ errMsg = result?.error || 'server returned an error';
2065
+ }
2066
+ } catch (err) {
2067
+ errMsg = err.message || String(err);
2068
+ }
2069
+ }
2070
+
2071
+ if (!delivered) {
2072
+ // Local-WS fallback. Used when USE_SERVER_INPUT_API is false, or when
2073
+ // the server endpoint is missing / failing.
2074
+ const ws = targetEntry.ws;
2075
+ if (ws && ws.readyState === WebSocket.OPEN) {
2076
+ try {
2077
+ ws.send(JSON.stringify({ type: 'input', data: payload }));
2078
+ delivered = true;
2079
+ } catch (err) {
2080
+ errMsg = err.message || String(err);
2081
+ }
2082
+ } else {
2083
+ if (!errMsg) errMsg = 'target websocket not open';
2084
+ }
2085
+ }
2086
+
2087
+ if (delivered) {
2088
+ input.value = '';
2089
+ showReplyStatus(statusEl, `Sent ${payload.length} bytes →`, 'ok');
2090
+ } else {
2091
+ showReplyStatus(statusEl, `Send failed: ${errMsg}`, 'error');
2092
+ }
2093
+ }
2094
+
2095
+ function showReplyStatus(el, msg, kind) {
2096
+ if (!el) return;
2097
+ el.textContent = msg;
2098
+ el.classList.remove('error', 'ok');
2099
+ if (kind) el.classList.add(kind);
2100
+ clearTimeout(el._timer);
2101
+ el._timer = setTimeout(() => { el.textContent = ''; el.classList.remove('error', 'ok'); }, 3500);
2102
+ }
2103
+
2104
+ // ===== Terminal switcher (T1.2) =====
2105
+ function renderSwitcher() {
2106
+ const wrap = document.getElementById('termSwitcher');
2107
+ const grid = document.getElementById('switcherGrid');
2108
+ if (!wrap || !grid) return;
2109
+
2110
+ const ids = Array.from(state.sessions.keys());
2111
+ if (ids.length < 2) {
2112
+ wrap.classList.remove('visible');
2113
+ grid.innerHTML = '';
2114
+ return;
2115
+ }
2116
+
2117
+ wrap.classList.add('visible');
2118
+ grid.innerHTML = '';
2119
+
2120
+ ids.forEach((id, idx) => {
2121
+ const entry = state.sessions.get(id);
2122
+ if (!entry) return;
2123
+ const meta = entry.session?.meta || {};
2124
+ const tile = document.createElement('button');
2125
+ tile.className = 'switcher-tile';
2126
+ tile.type = 'button';
2127
+ tile.dataset.sessionId = id;
2128
+ tile.title = `${getTypeLabel(meta.type || 'shell')}${meta.project ? ' · ' + meta.project : ''} — ${meta.status || ''}`;
2129
+ tile.textContent = String(idx + 1);
2130
+ if (state.focusedId === id) tile.classList.add('active');
2131
+ if (entry.el && entry.el.classList.contains('exited')) tile.classList.add('exited');
2132
+
2133
+ const dot = document.createElement('span');
2134
+ dot.className = 'switcher-dot';
2135
+ dot.style.background = getStatusColor(meta.status || 'idle');
2136
+ tile.appendChild(dot);
2137
+
2138
+ if (meta.project) {
2139
+ const bar = document.createElement('span');
2140
+ bar.className = 'switcher-bar';
2141
+ bar.style.background = getProjectBarColor(meta.project);
2142
+ tile.appendChild(bar);
2143
+ }
2144
+
2145
+ tile.addEventListener('click', (e) => {
2146
+ e.preventDefault();
2147
+ focusSessionById(id);
2148
+ });
2149
+
2150
+ grid.appendChild(tile);
2151
+ });
2152
+ }
2153
+
2154
+ // Pull the CSS-var color for a project tag, falling back to gray
2155
+ function getProjectBarColor(project) {
2156
+ const cls = `project-${project.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;
2157
+ const probe = document.createElement('span');
2158
+ probe.className = cls;
2159
+ probe.style.display = 'none';
2160
+ document.body.appendChild(probe);
2161
+ const color = getComputedStyle(probe).color;
2162
+ document.body.removeChild(probe);
2163
+ return color || '#6b7089';
2164
+ }
2165
+
2166
+ function focusSessionById(id) {
2167
+ const entry = state.sessions.get(id);
2168
+ if (!entry) return;
2169
+
2170
+ // If we're in focus-mode, swap which panel is the focused one
2171
+ const grid = document.getElementById('termGrid');
2172
+ if (grid.classList.contains('layout-focus')) {
2173
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('focused'));
2174
+ entry.el.classList.add('focused');
2175
+ } else if (grid.classList.contains('layout-half')) {
2176
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('primary'));
2177
+ entry.el.classList.add('primary');
2178
+ }
2179
+
2180
+ // Focus the xterm textarea (without stealing pointer)
2181
+ try { entry.terminal.focus(); } catch (err) { /* ignore */ }
2182
+ state.focusedId = id;
2183
+
2184
+ // Flash the panel border briefly
2185
+ entry.el.classList.remove('focus-flash');
2186
+ // Force reflow so the animation restarts on rapid switches
2187
+ void entry.el.offsetWidth;
2188
+ entry.el.classList.add('focus-flash');
2189
+ clearTimeout(entry._focusFlashTimer);
2190
+ entry._focusFlashTimer = setTimeout(() => {
2191
+ entry.el.classList.remove('focus-flash');
2192
+ }, 600);
2193
+
2194
+ // Refit if layout changed (focus / half swap)
2195
+ requestAnimationFrame(() => fitAll());
2196
+ renderSwitcher();
2197
+ }
2198
+
2199
+ function focusNthSession(n) {
2200
+ const ids = Array.from(state.sessions.keys());
2201
+ if (ids.length === 0) return;
2202
+ if (n < 1 || n > ids.length) return;
2203
+ focusSessionById(ids[n - 1]);
2204
+ }
2205
+
2206
+ function cycleSessionFocus() {
2207
+ const ids = Array.from(state.sessions.keys());
2208
+ if (ids.length === 0) return;
2209
+ const curIdx = ids.indexOf(state.focusedId);
2210
+ const next = curIdx < 0 ? 0 : (curIdx + 1) % ids.length;
2211
+ focusSessionById(ids[next]);
2212
+ }
2213
+
2214
+ // ===== Panel info drawer (T1.1) =====
2215
+ function setupDrawerListeners(id) {
2216
+ const drawer = document.getElementById(`drawer-${id}`);
2217
+ if (!drawer) return;
2218
+
2219
+ // Tab clicks
2220
+ drawer.querySelectorAll('.drawer-tab').forEach(tab => {
2221
+ tab.addEventListener('click', (e) => {
2222
+ e.stopPropagation();
2223
+ toggleDrawerTab(id, tab.dataset.tab);
2224
+ });
2225
+ });
2226
+
2227
+ // Commands tab — click a row to copy
2228
+ const cmdContainer = drawer.querySelector('[data-panel="commands"]');
2229
+ cmdContainer.addEventListener('click', (e) => {
2230
+ const row = e.target.closest('.drawer-row');
2231
+ if (!row || !row.dataset.command) return;
2232
+ copyRowText(row, row.dataset.command);
2233
+ });
2234
+
2235
+ // Memory tab — click a row to expand inline
2236
+ const memContainer = drawer.querySelector('[data-panel="memory"]');
2237
+ memContainer.addEventListener('click', (e) => {
2238
+ const row = e.target.closest('.drawer-row');
2239
+ if (!row) return;
2240
+ row.classList.toggle('expanded');
2241
+ });
2242
+ }
2243
+
2244
+ function toggleDrawerTab(id, tabName) {
2245
+ const entry = state.sessions.get(id);
2246
+ if (!entry) return;
2247
+ const drawer = document.getElementById(`drawer-${id}`);
2248
+ if (!drawer) return;
2249
+
2250
+ const wasOpen = !!entry.drawerOpen;
2251
+ const prevTab = entry.activeTab || 'overview';
2252
+
2253
+ // Clicking the same active tab while the drawer is open collapses it
2254
+ if (wasOpen && prevTab === tabName) {
2255
+ entry.drawerOpen = false;
2256
+ drawer.classList.remove('open');
2257
+ } else {
2258
+ entry.activeTab = tabName;
2259
+ entry.drawerOpen = true;
2260
+ drawer.classList.add('open');
2261
+ drawer.querySelectorAll('.drawer-tab').forEach(t => {
2262
+ t.classList.toggle('active', t.dataset.tab === tabName);
2263
+ });
2264
+ drawer.querySelectorAll('.drawer-panel').forEach(p => {
2265
+ p.classList.toggle('active', p.dataset.panel === tabName);
2266
+ });
2267
+ renderDrawerTab(id, tabName);
2268
+ }
2269
+
2270
+ // Re-fit the terminal after the drawer transitions
2271
+ requestAnimationFrame(() => {
2272
+ setTimeout(() => {
2273
+ try { entry.fitAddon.fit(); } catch (err) { /* ignore */ }
2274
+ const ws = entry.ws;
2275
+ if (ws && ws.readyState === WebSocket.OPEN) {
2276
+ ws.send(JSON.stringify({
2277
+ type: 'resize',
2278
+ cols: entry.terminal.cols,
2279
+ rows: entry.terminal.rows,
2280
+ }));
2281
+ }
2282
+ }, 190);
2283
+ });
2284
+ }
2285
+
2286
+ function renderDrawerTab(id, tabName) {
2287
+ if (tabName === 'overview') renderOverviewTab(id);
2288
+ else if (tabName === 'commands') renderCommandsTab(id);
2289
+ else if (tabName === 'memory') renderMemoryTab(id);
2290
+ else if (tabName === 'log') renderStatusLogTab(id);
2291
+ }
2292
+
2293
+ function renderOverviewTab(id) {
2294
+ const entry = state.sessions.get(id);
2295
+ const ov = document.getElementById(`ovmeta-${id}`);
2296
+ if (!entry || !ov) return;
2297
+ const meta = entry.session?.meta || {};
2298
+ const last = meta.lastCommands?.length
2299
+ ? meta.lastCommands[meta.lastCommands.length - 1].command
2300
+ : '—';
2301
+ const parts = [
2302
+ ['type', getTypeLabel(meta.type || 'shell')],
2303
+ ['project', meta.project || '—'],
2304
+ ['status', meta.statusDetail || meta.status || '—'],
2305
+ ['opened', meta.createdAt ? timeAgo(meta.createdAt) : '—'],
2306
+ ['last', last],
2307
+ ];
2308
+ if (meta.detectedPort) parts.push(['port', ':' + meta.detectedPort]);
2309
+ if (typeof meta.requestCount === 'number' && meta.requestCount > 0) {
2310
+ parts.push(['requests', String(meta.requestCount)]);
2311
+ }
2312
+ ov.innerHTML = parts.map(([k, v]) =>
2313
+ `<span><span class="ov-label">${k}</span><span class="ov-value">${escapeHtml(String(v))}</span></span>`
2314
+ ).join('');
2315
+ }
2316
+
2317
+ async function renderCommandsTab(id) {
2318
+ const entry = state.sessions.get(id);
2319
+ const container = document.getElementById(`dp-commands-${id}`);
2320
+ if (!entry || !container) return;
2321
+
2322
+ try {
2323
+ const resp = await api('GET', `/api/sessions/${id}/history`);
2324
+ const list = Array.isArray(resp) ? resp : (resp.commands || resp.history || []);
2325
+ entry.commandHistory = list;
2326
+ entry.commandsLoaded = true;
2327
+ } catch (err) {
2328
+ console.error('[client] failed to load command history:', err);
2329
+ if (!entry.commandsLoaded) {
2330
+ container.innerHTML = '<div class="empty-msg">Failed to load history.</div>';
2331
+ return;
2332
+ }
2333
+ }
2334
+
2335
+ // server returns command_history rows ordered DESC (newest first)
2336
+ const rows = (entry.commandHistory || []).slice(0, 60);
2337
+ if (rows.length === 0) {
2338
+ container.innerHTML = '<div class="empty-msg">No commands captured yet.</div>';
2339
+ } else {
2340
+ container.innerHTML = rows.map(r => {
2341
+ const cmd = r.command || r.cmd || '';
2342
+ const ts = r.timestamp || r.createdAt || r.created_at || null;
2343
+ const src = r.source ? ` · ${escapeHtml(r.source)}` : '';
2344
+ return `
2345
+ <div class="drawer-row" data-command="${escapeAttr(cmd)}">
2346
+ <div class="row-meta"><span>${escapeHtml(ts ? timeAgo(ts) : 'recent')}${src}</span></div>
2347
+ <div class="row-cmd">${escapeHtml(cmd)}</div>
2348
+ </div>
2349
+ `;
2350
+ }).join('');
2351
+ }
2352
+ container.scrollTop = 0;
2353
+ setBadge(id, 'commands', entry.commandHistory.length);
2354
+ }
2355
+
2356
+ function renderMemoryTab(id) {
2357
+ const entry = state.sessions.get(id);
2358
+ const container = document.getElementById(`dp-memory-${id}`);
2359
+ if (!entry || !container) return;
2360
+
2361
+ const hits = entry.memoryHits || [];
2362
+ if (hits.length === 0) {
2363
+ container.innerHTML = '<div class="empty-msg">No memory hits yet. Ask about this terminal or wait for a proactive lookup.</div>';
2364
+ setBadge(id, 'memory', 0);
2365
+ return;
2366
+ }
2367
+
2368
+ const rows = hits.slice(0, 40);
2369
+ container.innerHTML = rows.map(m => {
2370
+ const score = typeof m.similarity === 'number' ? `${(m.similarity * 100).toFixed(0)}%` : '';
2371
+ const proj = m.project ? escapeHtml(m.project) : '';
2372
+ const type = escapeHtml(m.source_type || m.sourceType || 'memory');
2373
+ const ts = m.cachedAt ? timeAgo(m.cachedAt) : '';
2374
+ return `
2375
+ <div class="drawer-row">
2376
+ <div class="row-meta">
2377
+ <span>${type}</span>
2378
+ ${proj ? `<span>${proj}</span>` : ''}
2379
+ ${score ? `<span>${score}</span>` : ''}
2380
+ ${ts ? `<span>${ts}</span>` : ''}
2381
+ </div>
2382
+ <div class="row-content">${escapeHtml(m.content || m.text || '(empty)')}</div>
2383
+ </div>
2384
+ `;
2385
+ }).join('');
2386
+ setBadge(id, 'memory', hits.length);
2387
+ }
2388
+
2389
+ function renderStatusLogTab(id) {
2390
+ const entry = state.sessions.get(id);
2391
+ const container = document.getElementById(`dp-log-${id}`);
2392
+ if (!entry || !container) return;
2393
+
2394
+ const log = entry.statusLog || [];
2395
+ if (log.length === 0) {
2396
+ container.innerHTML = '<div class="empty-msg">No status transitions recorded yet.</div>';
2397
+ setBadge(id, 'log', 0);
2398
+ return;
2399
+ }
2400
+
2401
+ const rows = log.slice().reverse();
2402
+ container.innerHTML = rows.map(ev => {
2403
+ const color = getStatusColor(ev.status);
2404
+ const t = new Date(ev.at);
2405
+ const hh = String(t.getHours()).padStart(2, '0');
2406
+ const mm = String(t.getMinutes()).padStart(2, '0');
2407
+ const ss = String(t.getSeconds()).padStart(2, '0');
2408
+ return `
2409
+ <div class="status-log-row">
2410
+ <span class="ts">${hh}:${mm}:${ss}</span>
2411
+ <span class="chip" style="color:${color}">${escapeHtml(ev.status)}</span>
2412
+ ${ev.detail ? `<span class="detail">${escapeHtml(ev.detail)}</span>` : ''}
2413
+ </div>
2414
+ `;
2415
+ }).join('');
2416
+ container.scrollTop = 0;
2417
+ setBadge(id, 'log', log.length);
2418
+ }
2419
+
2420
+ function appendStatusLog(id, status, detail) {
2421
+ const entry = state.sessions.get(id);
2422
+ if (!entry) return;
2423
+ if (!entry.statusLog) entry.statusLog = [];
2424
+ entry.statusLog.push({ at: new Date().toISOString(), status, detail: detail || '' });
2425
+ if (entry.statusLog.length > 500) entry.statusLog.splice(0, entry.statusLog.length - 500);
2426
+ setBadge(id, 'log', entry.statusLog.length);
2427
+ if (entry.drawerOpen && entry.activeTab === 'log') {
2428
+ renderStatusLogTab(id);
2429
+ }
2430
+ }
2431
+
2432
+ function setBadge(id, tab, count) {
2433
+ const el = document.getElementById(`badge-${tab}-${id}`);
2434
+ if (el) el.textContent = String(count);
2435
+ }
2436
+
2437
+ function copyRowText(row, text) {
2438
+ const done = () => {
2439
+ row.classList.add('copied');
2440
+ setTimeout(() => row.classList.remove('copied'), 700);
2441
+ };
2442
+ if (navigator.clipboard?.writeText) {
2443
+ navigator.clipboard.writeText(text).then(done).catch(err => {
2444
+ console.error('[client] clipboard write failed:', err);
2445
+ });
2446
+ } else {
2447
+ try {
2448
+ const ta = document.createElement('textarea');
2449
+ ta.value = text;
2450
+ ta.style.position = 'fixed';
2451
+ ta.style.opacity = '0';
2452
+ document.body.appendChild(ta);
2453
+ ta.select();
2454
+ document.execCommand('copy');
2455
+ document.body.removeChild(ta);
2456
+ done();
2457
+ } catch (err) { console.error('[client] fallback copy failed:', err); }
2458
+ }
2459
+ }
2460
+
2461
+ function escapeAttr(str) {
2462
+ return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2463
+ }
2464
+
2465
+ // ===== Panel actions =====
2466
+ function focusPanel(id) {
2467
+ const grid = document.getElementById('termGrid');
2468
+ const isAlreadyFocused = grid.classList.contains('layout-focus') && state.focusedId === id;
2469
+
2470
+ if (isAlreadyFocused) {
2471
+ // Restore previous layout
2472
+ setLayout(state.layout);
2473
+ document.querySelectorAll('.term-panel').forEach(p => {
2474
+ p.classList.remove('focused');
2475
+ p.style.display = '';
2476
+ });
2477
+ } else {
2478
+ grid.className = 'grid-container layout-focus';
2479
+ document.querySelectorAll('.term-panel').forEach(p => {
2480
+ p.classList.remove('focused');
2481
+ });
2482
+ const panel = document.getElementById(`panel-${id}`);
2483
+ if (panel) panel.classList.add('focused');
2484
+ state.focusedId = id;
2485
+ }
2486
+
2487
+ // Re-fit all visible terminals
2488
+ requestAnimationFrame(() => fitAll());
2489
+ }
2490
+
2491
+ function reconnectSession(id) {
2492
+ const entry = state.sessions.get(id);
2493
+ if (!entry) return;
2494
+
2495
+ const ws = new WebSocket(`${WS_BASE}?session=${id}`);
2496
+
2497
+ ws.onmessage = (event) => {
2498
+ try {
2499
+ const msg = JSON.parse(event.data);
2500
+ switch (msg.type) {
2501
+ case 'output':
2502
+ entry.terminal.write(msg.data);
2503
+ break;
2504
+ case 'meta':
2505
+ updatePanelMeta(id, msg.session.meta);
2506
+ break;
2507
+ case 'exit':
2508
+ updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
2509
+ const p = document.getElementById(`panel-${id}`);
2510
+ if (p) p.classList.add('exited');
2511
+ break;
2512
+ case 'status_broadcast':
2513
+ updateGlobalStats(msg.sessions);
2514
+ break;
2515
+ }
2516
+ } catch (err) { console.error('[client] reconnect ws message failed:', err); }
2517
+ };
2518
+
2519
+ ws.onopen = () => {
2520
+ console.log(`[ws] Reconnected session ${id}`);
2521
+ entry._reconnectAttempts = 0;
2522
+ entry.ws = ws;
2523
+ updatePanelMeta(id, { status: 'active', statusDetail: 'Reconnected' });
2524
+ };
2525
+
2526
+ ws.onclose = (event) => {
2527
+ const panel = document.getElementById(`panel-${id}`);
2528
+ if (panel && panel.classList.contains('exited')) return;
2529
+ if (event.code === 4001) {
2530
+ // Session no longer exists on server
2531
+ updatePanelMeta(id, { status: 'exited', statusDetail: 'Session ended' });
2532
+ if (panel) panel.classList.add('exited');
2533
+ return;
2534
+ }
2535
+ const delay = Math.min(1000 * Math.pow(2, (entry._reconnectAttempts || 0)), 10000);
2536
+ entry._reconnectAttempts = (entry._reconnectAttempts || 0) + 1;
2537
+ if (entry._reconnectAttempts <= 5) {
2538
+ setTimeout(() => reconnectSession(id), delay);
2539
+ } else {
2540
+ updatePanelMeta(id, { status: 'errored', statusDetail: 'Connection lost' });
2541
+ }
2542
+ };
2543
+
2544
+ // Re-wire terminal input
2545
+ entry.terminal.onData((data) => {
2546
+ if (ws.readyState === WebSocket.OPEN) {
2547
+ ws.send(JSON.stringify({ type: 'input', data }));
2548
+ }
2549
+ });
2550
+ }
2551
+
2552
+ function halfPanel(id) {
2553
+ const grid = document.getElementById('termGrid');
2554
+ grid.className = 'grid-container layout-half';
2555
+ document.querySelectorAll('.term-panel').forEach(p => p.classList.remove('primary'));
2556
+ const panel = document.getElementById(`panel-${id}`);
2557
+ if (panel) panel.classList.add('primary');
2558
+ requestAnimationFrame(() => fitAll());
2559
+ }
2560
+
2561
+ async function closePanel(id) {
2562
+ if (!confirm('Close this terminal? The process will be killed.')) return;
2563
+
2564
+ await api('DELETE', `/api/sessions/${id}`);
2565
+
2566
+ const entry = state.sessions.get(id);
2567
+ if (entry) {
2568
+ entry.terminal.dispose();
2569
+ entry.ws.close();
2570
+ entry.el.remove();
2571
+ state.sessions.delete(id);
2572
+ }
2573
+
2574
+ updateEmptyState();
2575
+ renderSwitcher();
2576
+ refreshAllReplyFormsFor(id);
2577
+ refreshPanelIndices();
2578
+ }
2579
+
2580
+ function changeTheme(id, themeId) {
2581
+ const entry = state.sessions.get(id);
2582
+ if (!entry) return;
2583
+
2584
+ const themeObj = getThemeObject(themeId);
2585
+ entry.terminal.options.theme = themeObj;
2586
+
2587
+ // Persist to server
2588
+ api('PATCH', `/api/sessions/${id}`, { theme: themeId });
2589
+ }
2590
+
2591
+ async function askAI(id, question) {
2592
+ if (!question.trim()) return;
2593
+ const entry = state.sessions.get(id);
2594
+ if (!entry) return;
2595
+
2596
+ // Early return if AI queries are not available
2597
+ if (!state.config.aiQueryAvailable) {
2598
+ entry.terminal.write(
2599
+ '\r\n\x1b[33m[engram] AI queries are not available.\x1b[0m\r\n' +
2600
+ '\x1b[33mTo enable, add the following to ~/.termdeck/config.yaml:\x1b[0m\r\n' +
2601
+ '\x1b[90m rag:\r\n' +
2602
+ ' supabaseUrl: https://your-project.supabase.co\r\n' +
2603
+ ' supabaseKey: your-anon-key\r\n' +
2604
+ ' openaiApiKey: sk-...\x1b[0m\r\n'
2605
+ );
2606
+ return;
2607
+ }
2608
+
2609
+ const inputEl = document.getElementById(`ai-${id}`);
2610
+ inputEl.value = 'Searching memories...';
2611
+ inputEl.disabled = true;
2612
+
2613
+ try {
2614
+ const result = await api('POST', '/api/ai/query', {
2615
+ question,
2616
+ sessionId: id,
2617
+ project: entry.session?.meta?.project || null
2618
+ });
2619
+
2620
+ if (result.error) {
2621
+ entry.terminal.write(`\r\n\x1b[33m[engram] ${result.error}\x1b[0m\r\n`);
2622
+ } else if (result.memories && result.memories.length > 0) {
2623
+ // Cache hits for the Memory tab
2624
+ if (!entry.memoryHits) entry.memoryHits = [];
2625
+ const cachedAt = new Date().toISOString();
2626
+ for (const m of result.memories) {
2627
+ entry.memoryHits.unshift({ ...m, cachedAt });
2628
+ }
2629
+ if (entry.memoryHits.length > 60) {
2630
+ entry.memoryHits.length = 60;
2631
+ }
2632
+ setBadge(id, 'memory', entry.memoryHits.length);
2633
+ if (entry.drawerOpen && entry.activeTab === 'memory') {
2634
+ renderMemoryTab(id);
2635
+ }
2636
+ const cols = entry.terminal.cols || 80;
2637
+ const wrap = (text, indent) => {
2638
+ const maxW = cols - indent - 2;
2639
+ const words = text.split(/\s+/);
2640
+ const lines = [];
2641
+ let line = '';
2642
+ for (const w of words) {
2643
+ if (line.length + w.length + 1 > maxW && line.length > 0) {
2644
+ lines.push(' '.repeat(indent) + line);
2645
+ line = w;
2646
+ } else {
2647
+ line = line ? line + ' ' + w : w;
2648
+ }
2649
+ }
2650
+ if (line) lines.push(' '.repeat(indent) + line);
2651
+ return lines;
2652
+ };
2653
+
2654
+ entry.terminal.write(`\r\n\x1b[36m━━━ Engram: ${result.total} memories found ━━━\x1b[0m\r\n`);
2655
+ for (const m of result.memories) {
2656
+ const score = m.similarity ? `${(m.similarity * 100).toFixed(0)}%` : '';
2657
+ const proj = m.project ? m.project : '';
2658
+ entry.terminal.write(`\r\n\x1b[35m● ${m.source_type}\x1b[0m \x1b[90m${proj} ${score}\x1b[0m\r\n`);
2659
+ const contentLines = wrap(m.content || '(empty)', 2);
2660
+ for (const cl of contentLines) {
2661
+ entry.terminal.write(`${cl}\r\n`);
2662
+ }
2663
+ }
2664
+ entry.terminal.write(`\r\n\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n\r\n`);
2665
+ } else {
2666
+ entry.terminal.write(`\r\n\x1b[33m[engram] No relevant memories found.\x1b[0m\r\n`);
2667
+ }
2668
+ } catch (err) {
2669
+ console.error('[client] AI query failed:', err);
2670
+ entry.terminal.write(`\r\n\x1b[31m[engram] Query failed: ${err.message}\x1b[0m\r\n`);
2671
+ }
2672
+
2673
+ inputEl.value = '';
2674
+ inputEl.disabled = false;
2675
+ inputEl.placeholder = 'Ask about this terminal...';
2676
+ }
2677
+
2678
+ // ===== Quick launch from empty state =====
2679
+ function quickLaunch(cmd) {
2680
+ document.getElementById('promptInput').value = cmd;
2681
+ launchTerminal();
2682
+ }
2683
+
2684
+ // ===== Add Project modal =====
2685
+ function rebuildProjectDropdown(selectName) {
2686
+ const sel = document.getElementById('promptProject');
2687
+ if (!sel) return;
2688
+ const prev = selectName || sel.value;
2689
+ sel.innerHTML = '<option value="">no project</option>';
2690
+ for (const name of Object.keys(state.config.projects || {})) {
2691
+ const opt = document.createElement('option');
2692
+ opt.value = name;
2693
+ opt.textContent = name;
2694
+ sel.appendChild(opt);
2695
+ }
2696
+ if (prev && state.config.projects && state.config.projects[prev]) {
2697
+ sel.value = prev;
2698
+ }
2699
+ }
2700
+
2701
+ function openAddProjectModal() {
2702
+ const modal = document.getElementById('addProjectModal');
2703
+ // Populate theme dropdown from loaded themes
2704
+ const themeSel = document.getElementById('apmTheme');
2705
+ themeSel.innerHTML = '<option value="">— pick a theme —</option>';
2706
+ for (const [tid, t] of Object.entries(state.themes || {})) {
2707
+ const opt = document.createElement('option');
2708
+ opt.value = tid;
2709
+ opt.textContent = t.label || tid;
2710
+ themeSel.appendChild(opt);
2711
+ }
2712
+ // Clear fields
2713
+ document.getElementById('apmName').value = '';
2714
+ document.getElementById('apmPath').value = '';
2715
+ document.getElementById('apmCommand').value = '';
2716
+ document.getElementById('apmTheme').value = '';
2717
+ setApmStatus('', null);
2718
+ modal.classList.add('open');
2719
+ setTimeout(() => document.getElementById('apmName').focus(), 50);
2720
+ }
2721
+
2722
+ function closeAddProjectModal() {
2723
+ document.getElementById('addProjectModal').classList.remove('open');
2724
+ }
2725
+
2726
+ function setApmStatus(msg, kind) {
2727
+ const el = document.getElementById('apmStatus');
2728
+ el.textContent = msg || '';
2729
+ el.classList.remove('error', 'ok');
2730
+ if (kind) el.classList.add(kind);
2731
+ }
2732
+
2733
+ async function submitAddProject() {
2734
+ const name = document.getElementById('apmName').value.trim();
2735
+ const projectPath = document.getElementById('apmPath').value.trim();
2736
+ const defaultCommand = document.getElementById('apmCommand').value.trim();
2737
+ const defaultTheme = document.getElementById('apmTheme').value;
2738
+
2739
+ if (!name) { setApmStatus('Name is required.', 'error'); return; }
2740
+ if (!projectPath) { setApmStatus('Path is required.', 'error'); return; }
2741
+
2742
+ const saveBtn = document.getElementById('apmSave');
2743
+ saveBtn.disabled = true;
2744
+ setApmStatus('Saving…', null);
2745
+
2746
+ try {
2747
+ const result = await api('POST', '/api/projects', {
2748
+ name,
2749
+ path: projectPath,
2750
+ defaultCommand: defaultCommand || undefined,
2751
+ defaultTheme: defaultTheme || undefined,
2752
+ });
2753
+ if (result && result.error) {
2754
+ setApmStatus(result.error, 'error');
2755
+ saveBtn.disabled = false;
2756
+ return;
2757
+ }
2758
+ // Merge the updated projects into in-memory state.config so subsequent
2759
+ // launches can immediately use the new project.
2760
+ state.config.projects = result.projects || {};
2761
+ rebuildProjectDropdown(name);
2762
+ setApmStatus(`Added "${name}" ✓`, 'ok');
2763
+ setTimeout(() => { closeAddProjectModal(); saveBtn.disabled = false; }, 700);
2764
+ } catch (err) {
2765
+ setApmStatus(`Failed: ${err.message || err}`, 'error');
2766
+ saveBtn.disabled = false;
2767
+ }
2768
+ }
2769
+
2770
+ // ===== Launch terminal =====
2771
+ async function launchTerminal() {
2772
+ const input = document.getElementById('promptInput');
2773
+ const project = document.getElementById('promptProject').value;
2774
+ let command = input.value.trim();
2775
+
2776
+ // If the input is empty but the selected project has a defaultCommand,
2777
+ // use it. That way "select project + click launch" actually runs the
2778
+ // project's declared default (e.g. `claude`) instead of silently falling
2779
+ // through to the global shell.
2780
+ if (!command && project) {
2781
+ const projectCfg = state.config.projects?.[project];
2782
+ if (projectCfg?.defaultCommand) {
2783
+ command = projectCfg.defaultCommand;
2784
+ }
2785
+ }
2786
+
2787
+ if (!command) {
2788
+ // Still nothing to run — launch a plain shell in the project's cwd
2789
+ const session = await api('POST', '/api/sessions', {
2790
+ project: project || undefined,
2791
+ reason: 'manual launch'
2792
+ });
2793
+ createTerminalPanel(session);
2794
+ input.value = '';
2795
+ updateEmptyState();
2796
+ return;
2797
+ }
2798
+
2799
+ // Parse shorthand commands
2800
+ let resolvedCommand = command;
2801
+ let resolvedType = 'shell';
2802
+ let resolvedCwd = undefined;
2803
+
2804
+ let resolvedProject = project || undefined;
2805
+
2806
+ if (/^claude\b/i.test(command) || /^cc\b/i.test(command)) {
2807
+ resolvedType = 'claude-code';
2808
+ const argMatch = command.match(/(?:claude|cc)\s+(?:code\s+)?(.+)/i);
2809
+ if (argMatch) {
2810
+ const arg = argMatch[1].trim();
2811
+ // Check if arg is a known project name
2812
+ if (state.config.projects && state.config.projects[arg]) {
2813
+ resolvedProject = arg;
2814
+ } else {
2815
+ resolvedCwd = arg;
2816
+ }
2817
+ }
2818
+ resolvedCommand = 'claude';
2819
+ } else if (/^gemini\b/i.test(command)) {
2820
+ resolvedType = 'gemini';
2821
+ } else if (/^python3?\b.*(?:runserver|uvicorn|flask|gunicorn)/i.test(command)) {
2822
+ resolvedType = 'python-server';
2823
+ }
2824
+
2825
+ const session = await api('POST', '/api/sessions', {
2826
+ command: resolvedCommand,
2827
+ cwd: resolvedCwd,
2828
+ project: resolvedProject,
2829
+ type: resolvedType,
2830
+ reason: `launched: ${command}`
2831
+ });
2832
+
2833
+ createTerminalPanel(session);
2834
+ input.value = '';
2835
+ updateEmptyState();
2836
+ }
2837
+
2838
+ // ===== Layout =====
2839
+ function setLayout(layout) {
2840
+ const wasControl = state.layout === 'control';
2841
+ // Only persist "real" grid layouts as state.layout; the control view is
2842
+ // an overlay, not a target to restore to when the user hits Escape.
2843
+ if (layout !== 'control') {
2844
+ state.layout = layout;
2845
+ }
2846
+ const grid = document.getElementById('termGrid');
2847
+ grid.className = `grid-container layout-${layout}`;
2848
+
2849
+ // Remove focus/half states
2850
+ document.querySelectorAll('.term-panel').forEach(p => {
2851
+ p.classList.remove('focused', 'primary');
2852
+ p.style.display = '';
2853
+ });
2854
+
2855
+ // Update buttons
2856
+ document.querySelectorAll('.layout-btn').forEach(b => {
2857
+ b.classList.toggle('active', b.dataset.layout === layout);
2858
+ });
2859
+
2860
+ // Control-mode side effects (T1.6)
2861
+ if (layout === 'control') {
2862
+ enterControlMode();
2863
+ } else if (wasControl) {
2864
+ // Leaving control — nothing to clean up; feed stays hidden via CSS
2865
+ }
2866
+
2867
+ requestAnimationFrame(() => fitAll());
2868
+ }
2869
+
2870
+ // ===== Helpers =====
2871
+ function getStatusColor(status) {
2872
+ const colors = {
2873
+ starting: '#7aa2f7',
2874
+ active: '#9ece6a',
2875
+ idle: '#6b7089',
2876
+ thinking: '#bb9af7',
2877
+ editing: '#e0af68',
2878
+ listening: '#7dcfff',
2879
+ errored: '#f7768e',
2880
+ exited: '#414868'
2881
+ };
2882
+ return colors[status] || '#6b7089';
2883
+ }
2884
+
2885
+ function getTypeLabel(type) {
2886
+ const labels = {
2887
+ 'shell': 'Shell',
2888
+ 'claude-code': 'Claude Code',
2889
+ 'gemini': 'Gemini CLI',
2890
+ 'python-server': 'Python Server',
2891
+ 'one-shot': 'One-shot'
2892
+ };
2893
+ return labels[type] || type;
2894
+ }
2895
+
2896
+ function getThemeObject(themeId) {
2897
+ // Fetch full theme from server cache or use fallback
2898
+ const known = state.themes[themeId];
2899
+ if (known?.theme) return known.theme;
2900
+ // Minimal fallback
2901
+ return { background: '#1a1b26', foreground: '#c0caf5' };
2902
+ }
2903
+
2904
+ function timeAgo(isoString) {
2905
+ const diff = Date.now() - new Date(isoString).getTime();
2906
+ const mins = Math.floor(diff / 60000);
2907
+ if (mins < 1) return 'just now';
2908
+ if (mins < 60) return `${mins}m ago`;
2909
+ const hrs = Math.floor(mins / 60);
2910
+ if (hrs < 24) return `${hrs}h ago`;
2911
+ return `${Math.floor(hrs / 24)}d ago`;
2912
+ }
2913
+
2914
+ function updatePanelMeta(id, meta) {
2915
+ // Track status transitions into the per-panel status log
2916
+ const entry = state.sessions.get(id);
2917
+ if (entry && meta.status && meta.status !== entry.lastKnownStatus) {
2918
+ appendStatusLog(id, meta.status, meta.statusDetail || '');
2919
+ // Proactive memory lookup on entering the errored state (T1.4)
2920
+ if (meta.status === 'errored') {
2921
+ // Fire-and-forget; own rate limiting lives inside the function.
2922
+ triggerProactiveMemoryQuery(id);
2923
+ }
2924
+ entry.lastKnownStatus = meta.status;
2925
+ }
2926
+ // Keep the cached session.meta fresh so the overview tab renders current data
2927
+ if (entry && entry.session) {
2928
+ entry.session.meta = { ...entry.session.meta, ...meta };
2929
+ }
2930
+
2931
+ const dot = document.getElementById(`dot-${id}`);
2932
+ const status = document.getElementById(`status-${id}`);
2933
+ const metaLast = document.getElementById(`meta-last-${id}`);
2934
+ const metaPort = document.getElementById(`meta-port-${id}`);
2935
+ const metaReqs = document.getElementById(`meta-reqs-${id}`);
2936
+
2937
+ if (dot) {
2938
+ dot.style.background = getStatusColor(meta.status);
2939
+ dot.classList.toggle('pulsing', meta.status === 'thinking');
2940
+ }
2941
+ if (status) status.textContent = meta.statusDetail || meta.status;
2942
+ if (metaLast && meta.lastCommands?.length) {
2943
+ metaLast.innerHTML = `<span class="meta-label">last</span> ${escapeHtml(meta.lastCommands[meta.lastCommands.length - 1].command)}`;
2944
+ }
2945
+ if (metaPort) {
2946
+ if (meta.detectedPort) {
2947
+ metaPort.style.display = '';
2948
+ metaPort.querySelector('.meta-value').textContent = ':' + meta.detectedPort;
2949
+ }
2950
+ }
2951
+ if (metaReqs) {
2952
+ if (meta.type === 'python-server' || meta.requestCount > 0) {
2953
+ metaReqs.style.display = '';
2954
+ metaReqs.querySelector('.meta-value').textContent = meta.requestCount || 0;
2955
+ }
2956
+ }
2957
+
2958
+ // If the drawer is showing the overview tab, refresh its metadata block
2959
+ if (entry && entry.drawerOpen && entry.activeTab === 'overview') {
2960
+ renderOverviewTab(id);
2961
+ }
2962
+
2963
+ // Sync theme dropdown if server-side theme changed
2964
+ if (meta.theme) {
2965
+ const themeSelect = document.getElementById(`theme-${id}`);
2966
+ if (themeSelect && themeSelect.value !== meta.theme) {
2967
+ themeSelect.value = meta.theme;
2968
+ const entry = state.sessions.get(id);
2969
+ if (entry) {
2970
+ entry.terminal.options.theme = getThemeObject(meta.theme);
2971
+ }
2972
+ }
2973
+ }
2974
+ }
2975
+
2976
+ function escapeHtml(str) {
2977
+ const div = document.createElement('div');
2978
+ div.textContent = str;
2979
+ return div.innerHTML;
2980
+ }
2981
+
2982
+ function updateGlobalStats(sessions) {
2983
+ let active = 0, thinking = 0, idle = 0;
2984
+ for (const s of sessions) {
2985
+ if (s.meta.status === 'active' || s.meta.status === 'listening') active++;
2986
+ else if (s.meta.status === 'thinking') thinking++;
2987
+ else if (s.meta.status === 'idle') idle++;
2988
+
2989
+ // Update existing panels from broadcast. NOTE: we deliberately do NOT
2990
+ // createTerminalPanel for sessions that aren't in state.sessions —
2991
+ // that creates a race between the immediate createTerminalPanel call
2992
+ // from launchTerminal and the 2s status_broadcast cycle, producing
2993
+ // duplicate WebSockets per session and breaking terminal input
2994
+ // rendering. External-session auto-discover is parked for Sprint 3.
2995
+ if (state.sessions.has(s.id)) {
2996
+ updatePanelMeta(s.id, s.meta);
2997
+ }
2998
+ }
2999
+ document.getElementById('stat-active').textContent = active;
3000
+ document.getElementById('stat-thinking').textContent = thinking;
3001
+ document.getElementById('stat-idle').textContent = idle;
3002
+ renderSwitcher();
3003
+ }
3004
+
3005
+ function updateEmptyState() {
3006
+ const empty = document.getElementById('emptyState');
3007
+ empty.style.display = state.sessions.size === 0 ? '' : 'none';
3008
+ }
3009
+
3010
+ function fitAll() {
3011
+ for (const [, entry] of state.sessions) {
3012
+ try { entry.fitAddon.fit(); } catch (err) { if (!entry._fitWarned) { console.error('[client] fitAddon.fit failed for session:', err); entry._fitWarned = true; } }
3013
+ }
3014
+ }
3015
+
3016
+ // ===== ONBOARDING TOUR =====
3017
+ // Spotlight + tooltip walkthrough of every TermDeck surface. Runs once on
3018
+ // first visit (localStorage gate) and replays on demand via the "how this
3019
+ // works" button. Zero dependencies — vanilla DOM, same philosophy as the
3020
+ // rest of this client.
3021
+ const TOUR_STEPS = [
3022
+ {
3023
+ target: null,
3024
+ title: 'Welcome to TermDeck',
3025
+ 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.`,
3026
+ },
3027
+ {
3028
+ target: '#topbarQuickLaunch',
3029
+ title: 'Quick launch',
3030
+ 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.`,
3031
+ },
3032
+ {
3033
+ target: '.topbar-center',
3034
+ title: 'Layout modes',
3035
+ 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.`,
3036
+ },
3037
+ {
3038
+ target: '#termSwitcher',
3039
+ title: 'Terminal switcher',
3040
+ 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.`,
3041
+ onEnter: async () => { await ensureSecondShellForTour(); },
3042
+ },
3043
+ {
3044
+ targets: ['#btn-status', '#btn-config'],
3045
+ title: 'Status and config',
3046
+ 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.`,
3047
+ },
3048
+ {
3049
+ targets: ['#btn-how', '#btn-help'],
3050
+ title: 'How this works and help',
3051
+ body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation on GitHub in a new tab.`,
3052
+ },
3053
+ {
3054
+ target: '.panel-header',
3055
+ title: 'Panel header',
3056
+ 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.`,
3057
+ fallback: '#topbarQuickLaunch',
3058
+ },
3059
+ {
3060
+ target: '.drawer-tabs',
3061
+ title: 'Info tabs',
3062
+ 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.`,
3063
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
3064
+ fallback: '#topbarQuickLaunch',
3065
+ },
3066
+ {
3067
+ target: '.reply-toggle',
3068
+ title: 'Reply — send text to another panel',
3069
+ 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.`,
3070
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
3071
+ fallback: '#topbarQuickLaunch',
3072
+ },
3073
+ {
3074
+ target: '.ctrl-input',
3075
+ title: 'Ask about this terminal',
3076
+ body: `Type a question here and TermDeck queries your <strong>Engram 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.`,
3077
+ onEnter: async () => { await openFirstPanelDrawer('overview'); },
3078
+ fallback: '#topbarQuickLaunch',
3079
+ },
3080
+ {
3081
+ target: null,
3082
+ title: 'Flashback — proactive recall',
3083
+ body: `When a panel errors out, TermDeck <strong>automatically</strong> queries Engram 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.`,
3084
+ },
3085
+ {
3086
+ target: '.prompt-bar',
3087
+ title: 'Prompt bar',
3088
+ 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.`,
3089
+ },
3090
+ {
3091
+ target: null,
3092
+ title: 'You are ready.',
3093
+ 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.`,
3094
+ },
3095
+ ];
3096
+
3097
+ // Tour setup helpers — manipulate DOM so target selectors resolve to
3098
+ // visible, sized elements before the spotlight positions itself.
3099
+ async function ensureSecondShellForTour() {
3100
+ if (state.sessions.size >= 2) return;
3101
+ try {
3102
+ const session = await api('POST', '/api/sessions', {
3103
+ command: 'zsh',
3104
+ type: 'shell',
3105
+ reason: 'onboarding tour (switcher demo)',
3106
+ });
3107
+ createTerminalPanel(session);
3108
+ updateEmptyState();
3109
+ await new Promise((r) => setTimeout(r, 450));
3110
+ } catch (err) {
3111
+ console.error('[tour] failed to auto-launch second shell:', err);
3112
+ }
3113
+ }
3114
+
3115
+ async function openFirstPanelDrawer(tabName = 'overview') {
3116
+ const firstId = state.sessions.keys().next().value;
3117
+ if (!firstId) return;
3118
+ const entry = state.sessions.get(firstId);
3119
+ if (!entry) return;
3120
+ // Only toggle if not already open on the requested tab — avoid bouncing
3121
+ // the drawer shut mid-tour.
3122
+ if (entry.drawerOpen && entry.activeTab === tabName) return;
3123
+ // Force-open by setting state first so toggleDrawerTab expands it.
3124
+ entry.drawerOpen = false;
3125
+ toggleDrawerTab(firstId, tabName);
3126
+ // Let the CSS transition settle so bounding rects stabilize.
3127
+ await new Promise((r) => setTimeout(r, 280));
3128
+ }
3129
+
3130
+ const tourState = { active: false, idx: 0 };
3131
+
3132
+ // Resolve a step's target(s) to a bounding rect. Supports single `target`
3133
+ // selector, a `targets` array (union rect across multiple elements), and
3134
+ // `fallback` as a last resort. Elements with 0×0 rects are treated as
3135
+ // invisible and ignored so collapsed drawer content doesn't produce
3136
+ // phantom spotlights in the top-left corner.
3137
+ function tourResolveRect(step) {
3138
+ const visibleRect = (sel) => {
3139
+ const el = document.querySelector(sel);
3140
+ if (!el) return null;
3141
+ const r = el.getBoundingClientRect();
3142
+ if (r.width < 2 || r.height < 2) return null;
3143
+ return r;
3144
+ };
3145
+
3146
+ if (step.targets && Array.isArray(step.targets)) {
3147
+ const rects = step.targets.map(visibleRect).filter(Boolean);
3148
+ if (rects.length > 0) {
3149
+ const left = Math.min(...rects.map((r) => r.left));
3150
+ const top = Math.min(...rects.map((r) => r.top));
3151
+ const right = Math.max(...rects.map((r) => r.right));
3152
+ const bottom = Math.max(...rects.map((r) => r.bottom));
3153
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
3154
+ }
3155
+ }
3156
+
3157
+ if (step.target) {
3158
+ const r = visibleRect(step.target);
3159
+ if (r) return r;
3160
+ }
3161
+
3162
+ if (step.fallback) {
3163
+ const r = visibleRect(step.fallback);
3164
+ if (r) return r;
3165
+ }
3166
+
3167
+ return null;
3168
+ }
3169
+
3170
+ function positionTourElements(step) {
3171
+ const backdrop = document.getElementById('tourBackdrop');
3172
+ const spotlight = document.getElementById('tourSpotlight');
3173
+ const tooltip = document.getElementById('tourTooltip');
3174
+ backdrop.classList.add('active');
3175
+ tooltip.style.display = 'block';
3176
+
3177
+ const rect = tourResolveRect(step);
3178
+ if (!rect) {
3179
+ // Centered step — no spotlight target, or resolved element was invisible
3180
+ spotlight.classList.add('centered');
3181
+ tooltip.classList.add('centered');
3182
+ tooltip.style.top = '';
3183
+ tooltip.style.left = '';
3184
+ return;
3185
+ }
3186
+ spotlight.classList.remove('centered');
3187
+ tooltip.classList.remove('centered');
3188
+
3189
+ const padding = 8;
3190
+ spotlight.style.top = `${rect.top - padding}px`;
3191
+ spotlight.style.left = `${rect.left - padding}px`;
3192
+ spotlight.style.width = `${rect.width + padding * 2}px`;
3193
+ spotlight.style.height = `${rect.height + padding * 2}px`;
3194
+
3195
+ // Place tooltip below the target by default; flip to above if it would
3196
+ // overflow the viewport. Clamp horizontally to avoid right-edge clipping.
3197
+ const tooltipRect = tooltip.getBoundingClientRect();
3198
+ let top = rect.bottom + 16;
3199
+ let left = Math.max(12, rect.left);
3200
+ if (top + tooltipRect.height > window.innerHeight - 12) {
3201
+ top = Math.max(12, rect.top - tooltipRect.height - 16);
3202
+ }
3203
+ if (left + tooltipRect.width > window.innerWidth - 12) {
3204
+ left = window.innerWidth - tooltipRect.width - 12;
3205
+ }
3206
+ tooltip.style.top = `${top}px`;
3207
+ tooltip.style.left = `${left}px`;
3208
+ }
3209
+
3210
+ async function renderTourStep() {
3211
+ const step = TOUR_STEPS[tourState.idx];
3212
+ if (!step) { endTour(); return; }
3213
+
3214
+ // Optional setup hook — launches a panel, opens a drawer, etc.
3215
+ if (typeof step.onEnter === 'function') {
3216
+ try { await step.onEnter(); } catch (err) { console.error('[tour] onEnter failed:', err); }
3217
+ }
3218
+
3219
+ document.getElementById('tourTitle').innerHTML = step.title;
3220
+ document.getElementById('tourBody').innerHTML = step.body;
3221
+ document.getElementById('tourCounter').textContent =
3222
+ `Step ${tourState.idx + 1} of ${TOUR_STEPS.length}`;
3223
+ document.getElementById('tourPrevBtn').disabled = tourState.idx === 0;
3224
+ document.getElementById('tourNextBtn').textContent =
3225
+ tourState.idx === TOUR_STEPS.length - 1 ? 'done' : 'next';
3226
+ positionTourElements(step);
3227
+ }
3228
+
3229
+ // Auto-launch a shell panel so the tour's panel-targeting steps
3230
+ // (header, drawer tabs, reply, ctrl-input) have a real DOM target.
3231
+ // Only fires when no panels exist yet. Replays of the tour against
3232
+ // an already-populated dashboard skip this — their existing panels
3233
+ // serve as the tour targets.
3234
+ async function ensurePanelForTour() {
3235
+ if (state.sessions.size > 0) return;
3236
+ try {
3237
+ const session = await api('POST', '/api/sessions', {
3238
+ command: 'zsh',
3239
+ type: 'shell',
3240
+ reason: 'onboarding tour',
3241
+ });
3242
+ createTerminalPanel(session);
3243
+ updateEmptyState();
3244
+ // Let xterm.js mount and .panel-* selectors settle before rendering.
3245
+ await new Promise((r) => setTimeout(r, 450));
3246
+ } catch (err) {
3247
+ console.error('[tour] failed to auto-launch shell:', err);
3248
+ }
3249
+ }
3250
+
3251
+ async function startTour() {
3252
+ tourState.active = true;
3253
+ tourState.idx = 0;
3254
+ // Explicitly show the spotlight. CSS default is `display:none` so the
3255
+ // 9999px box-shadow doesn't darken the page before/after a tour runs.
3256
+ document.getElementById('tourSpotlight').style.display = 'block';
3257
+ await ensurePanelForTour();
3258
+ renderTourStep();
3259
+ }
3260
+
3261
+ function nextTourStep() {
3262
+ if (tourState.idx >= TOUR_STEPS.length - 1) { endTour(); return; }
3263
+ tourState.idx += 1;
3264
+ renderTourStep();
3265
+ }
3266
+
3267
+ function prevTourStep() {
3268
+ if (tourState.idx <= 0) return;
3269
+ tourState.idx -= 1;
3270
+ renderTourStep();
3271
+ }
3272
+
3273
+ function endTour() {
3274
+ tourState.active = false;
3275
+ document.getElementById('tourBackdrop').classList.remove('active');
3276
+ document.getElementById('tourTooltip').style.display = 'none';
3277
+ // CRITICAL: hide the spotlight element too. Its 9999px box-shadow
3278
+ // creates the dark overlay effect independently of the backdrop, so
3279
+ // leaving display:block here means the dashboard looks "stuck in tour"
3280
+ // even after the tooltip is gone.
3281
+ const spotlight = document.getElementById('tourSpotlight');
3282
+ spotlight.style.display = 'none';
3283
+ spotlight.classList.remove('centered');
3284
+ const tooltip = document.getElementById('tourTooltip');
3285
+ tooltip.classList.remove('centered');
3286
+ tooltip.style.top = '';
3287
+ tooltip.style.left = '';
3288
+ try { localStorage.setItem('termdeck:tour:seen', '1'); } catch {}
3289
+ }
3290
+
3291
+ // ===== Event Listeners =====
3292
+ document.querySelectorAll('.layout-btn').forEach(btn => {
3293
+ btn.addEventListener('click', () => setLayout(btn.dataset.layout));
3294
+ });
3295
+
3296
+ document.getElementById('promptLaunch').addEventListener('click', launchTerminal);
3297
+ document.getElementById('promptInput').addEventListener('keydown', (e) => {
3298
+ if (e.key === 'Enter') launchTerminal();
3299
+ });
3300
+
3301
+ // Add-project modal wiring
3302
+ document.getElementById('btnAddProject').addEventListener('click', openAddProjectModal);
3303
+ document.getElementById('apmCancel').addEventListener('click', closeAddProjectModal);
3304
+ document.getElementById('apmSave').addEventListener('click', submitAddProject);
3305
+ document.querySelector('#addProjectModal .add-project-backdrop').addEventListener('click', closeAddProjectModal);
3306
+ // Enter in any input inside the modal submits; Escape closes
3307
+ document.getElementById('addProjectModal').addEventListener('keydown', (e) => {
3308
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAddProject(); }
3309
+ if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3310
+ });
3311
+
3312
+ // Onboarding tour wiring
3313
+ document.getElementById('btn-how').addEventListener('click', startTour);
3314
+ document.getElementById('tourNextBtn').addEventListener('click', nextTourStep);
3315
+ document.getElementById('tourPrevBtn').addEventListener('click', prevTourStep);
3316
+ document.getElementById('tourSkipBtn').addEventListener('click', endTour);
3317
+ // Clicking the backdrop (but not the spotlight/tooltip) also skips
3318
+ document.getElementById('tourBackdrop').addEventListener('click', (e) => {
3319
+ if (e.target.id === 'tourBackdrop') endTour();
3320
+ });
3321
+
3322
+ // Resize handler
3323
+ window.addEventListener('resize', () => {
3324
+ requestAnimationFrame(() => fitAll());
3325
+ });
3326
+
3327
+ // Re-render the tour on viewport changes so the spotlight tracks resizes
3328
+ window.addEventListener('resize', () => {
3329
+ if (tourState.active) renderTourStep();
3330
+ });
3331
+
3332
+ // Keyboard shortcuts
3333
+ document.addEventListener('keydown', (e) => {
3334
+ // Tour has priority: Esc exits, ArrowRight/Enter advances, ArrowLeft back
3335
+ if (tourState.active) {
3336
+ if (e.key === 'Escape') { e.preventDefault(); endTour(); return; }
3337
+ if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); nextTourStep(); return; }
3338
+ if (e.key === 'ArrowLeft') { e.preventDefault(); prevTourStep(); return; }
3339
+ }
3340
+ // Ctrl+Shift+N → new terminal
3341
+ if (e.ctrlKey && e.shiftKey && e.key === 'N') {
3342
+ e.preventDefault();
3343
+ document.getElementById('promptInput').focus();
3344
+ }
3345
+ // "/" → focus prompt bar (first-run hint, ignored when typing in any input/textarea)
3346
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
3347
+ const target = e.target;
3348
+ const tag = target?.tagName || '';
3349
+ const inEditable = tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable;
3350
+ if (!inEditable) {
3351
+ e.preventDefault();
3352
+ document.getElementById('promptInput').focus();
3353
+ }
3354
+ }
3355
+ // Ctrl+Shift+1-6 OR Cmd+Shift+1-6 → layout switch (Mac friendly)
3356
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '1' && e.key <= '6') {
3357
+ e.preventDefault();
3358
+ const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2'];
3359
+ setLayout(layouts[parseInt(e.key) - 1]);
3360
+ }
3361
+ // Ctrl+Shift+] / [ → cycle between terminals
3362
+ if (e.ctrlKey && e.shiftKey && (e.key === ']' || e.key === '[')) {
3363
+ e.preventDefault();
3364
+ const ids = Array.from(state.sessions.keys());
3365
+ if (ids.length > 0) {
3366
+ const curIdx = ids.indexOf(state.focusedId);
3367
+ const next = e.key === ']'
3368
+ ? (curIdx + 1) % ids.length
3369
+ : (curIdx - 1 + ids.length) % ids.length;
3370
+ const entry = state.sessions.get(ids[next]);
3371
+ if (entry) {
3372
+ entry.terminal.focus();
3373
+ state.focusedId = ids[next];
3374
+ }
3375
+ }
3376
+ }
3377
+ // Escape → exit focus mode
3378
+ if (e.key === 'Escape') {
3379
+ const grid = document.getElementById('termGrid');
3380
+ if (grid.classList.contains('layout-focus') || grid.classList.contains('layout-half')) {
3381
+ setLayout(state.layout);
3382
+ document.querySelectorAll('.term-panel').forEach(p => {
3383
+ p.classList.remove('focused', 'primary');
3384
+ p.style.display = '';
3385
+ });
3386
+ fitAll();
3387
+ }
3388
+ }
3389
+ });
3390
+
3391
+ // Control feed click (T1.6) — delegated at the feed container
3392
+ document.getElementById('feedRows').addEventListener('click', onFeedRowClick);
3393
+
3394
+ // Live refresh while in control mode
3395
+ setInterval(() => {
3396
+ const grid = document.getElementById('termGrid');
3397
+ if (grid && grid.classList.contains('layout-control')) {
3398
+ renderControlFeed();
3399
+ }
3400
+ }, 2000);
3401
+
3402
+ // Alt+1..9 → focus panel N, Alt+0 → cycle focus (T1.2)
3403
+ // Use capture-phase so xterm.js never sees the key as a Meta sequence.
3404
+ // Match on e.code, not e.key: on macOS, Option+1 produces "¡", not "1".
3405
+ document.addEventListener('keydown', (e) => {
3406
+ if (!e.altKey) return;
3407
+ if (e.ctrlKey || e.metaKey || e.shiftKey) return;
3408
+ if (e.code && e.code.startsWith('Digit')) {
3409
+ const n = parseInt(e.code.slice(5), 10);
3410
+ if (n >= 1 && n <= 9) {
3411
+ e.preventDefault();
3412
+ e.stopPropagation();
3413
+ focusNthSession(n);
3414
+ } else if (n === 0) {
3415
+ e.preventDefault();
3416
+ e.stopPropagation();
3417
+ cycleSessionFocus();
3418
+ }
3419
+ }
3420
+ }, { capture: true });
3421
+
3422
+ // External-session auto-discover disabled. The poller raced with the
3423
+ // immediate createTerminalPanel call in launchTerminal and caused
3424
+ // duplicate WebSocket connections per session, which broke terminal
3425
+ // input rendering (session.ws on the server got overwritten by the
3426
+ // second connect and term.onData output stopped reaching the visible
3427
+ // panel). Parked for Sprint 3 — needs an idempotent creation path
3428
+ // AND a way to suppress the race window during POST → createPanel.
3429
+
3430
+ // Refresh "opened X ago" timestamps every 30s
3431
+ setInterval(() => {
3432
+ for (const [id, entry] of state.sessions) {
3433
+ const metaOpened = document.querySelector(`#panel-${id} .panel-meta .meta-item:first-child`);
3434
+ if (metaOpened && entry.session?.meta?.createdAt) {
3435
+ metaOpened.innerHTML = `<span class="meta-label">opened</span> ${timeAgo(entry.session.meta.createdAt)}`;
3436
+ }
3437
+ }
3438
+ }, 30000);
3439
+
3440
+ // Boot
3441
+ init();
3442
+ </script>
3443
+ </body>
3444
+ </html>