@jhizzard/termdeck 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/cli/src/init-rumen.js +99 -10
- package/packages/client/public/app.js +2786 -0
- package/packages/client/public/index.html +39 -3280
- package/packages/client/public/style.css +1776 -0
- package/packages/server/src/index.js +277 -6
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +40 -0
- package/packages/server/src/session.js +13 -2
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +6 -1
- package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +39 -3
- package/packages/server/src/transcripts.js +290 -0
|
@@ -5,1254 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>TermDeck</title>
|
|
7
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>
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
1256
9
|
</head>
|
|
1257
10
|
<body>
|
|
1258
11
|
|
|
@@ -1273,6 +26,10 @@
|
|
|
1273
26
|
<span class="topbar-stat"><span class="dot" style="background:var(--tg-purple)"></span> <span id="stat-thinking">0</span> thinking</span>
|
|
1274
27
|
<span class="topbar-stat"><span class="dot" style="background:var(--tg-amber)"></span> <span id="stat-idle">0</span> idle</span>
|
|
1275
28
|
<span class="topbar-stat" id="stat-rag" style="display:none">RAG</span>
|
|
29
|
+
<button type="button" class="rumen-badge" id="rumenBadge" title="Rumen insights" aria-haspopup="dialog" aria-controls="rumenModal" aria-label="Open Rumen insights briefing">
|
|
30
|
+
<span class="rb-icon" aria-hidden="true">💡</span>
|
|
31
|
+
<span id="rumenBadgeLabel">0 insights</span>
|
|
32
|
+
</button>
|
|
1276
33
|
</div>
|
|
1277
34
|
</div>
|
|
1278
35
|
|
|
@@ -1405,2040 +162,42 @@
|
|
|
1405
162
|
</div>
|
|
1406
163
|
</div>
|
|
1407
164
|
|
|
165
|
+
<!-- Rumen morning-briefing modal (hidden by default) -->
|
|
166
|
+
<div class="rumen-modal" id="rumenModal" role="dialog" aria-modal="true" aria-labelledby="rumenTitle" aria-describedby="rumenSummary">
|
|
167
|
+
<div class="rumen-backdrop" id="rumenBackdrop"></div>
|
|
168
|
+
<div class="rumen-card">
|
|
169
|
+
<header>
|
|
170
|
+
<h3 id="rumenTitle">Rumen Insights</h3>
|
|
171
|
+
<p class="rm-summary" id="rumenSummary">Loading…</p>
|
|
172
|
+
</header>
|
|
173
|
+
<div class="rumen-filters">
|
|
174
|
+
<label>
|
|
175
|
+
project
|
|
176
|
+
<select id="rumenFilterProject"><option value="">all</option></select>
|
|
177
|
+
</label>
|
|
178
|
+
<label>
|
|
179
|
+
sort
|
|
180
|
+
<select id="rumenFilterSort">
|
|
181
|
+
<option value="newest">newest</option>
|
|
182
|
+
<option value="confidence">highest confidence</option>
|
|
183
|
+
</select>
|
|
184
|
+
</label>
|
|
185
|
+
<label>
|
|
186
|
+
<input type="checkbox" id="rumenFilterUnseen"> unseen only
|
|
187
|
+
</label>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="rumen-list" id="rumenList" role="list">
|
|
190
|
+
<div class="rumen-empty">Loading insights…</div>
|
|
191
|
+
</div>
|
|
192
|
+
<footer>
|
|
193
|
+
<button type="button" class="rm-close" id="rumenClose">close (Esc)</button>
|
|
194
|
+
</footer>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
1408
198
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
1409
199
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
1410
200
|
<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">▢</button>
|
|
1530
|
-
<button class="panel-btn" onclick="halfPanel('${id}')" title="Half screen">▭</button>
|
|
1531
|
-
<button class="panel-btn danger" onclick="closePanel('${id}')" title="Close terminal">×</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">Mnestra — 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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[mnestra] 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[mnestra] ${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━━━ Mnestra: ${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[mnestra] 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[mnestra] 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 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>Mnestra memory store</strong> for relevant context — scoped to the current panel's project. Prefix with <kbd>all:</kbd> to search every project. Results render inline in the terminal with similarity scores.`,
|
|
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 Mnestra for similar past errors and surfaces the top match as a toast. You don't have to ask. Rate-limited to one per 30 seconds per panel. Click the toast to open the Memory tab with the full hit expanded.`,
|
|
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>
|
|
201
|
+
<script src="app.js" defer></script>
|
|
3443
202
|
</body>
|
|
3444
203
|
</html>
|