@clux-cli/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/web.d.ts +3 -0
- package/dist/commands/web.js +16 -0
- package/dist/commands/web.js.map +1 -0
- package/dist/index.js +26773 -4
- package/dist/index.js.map +1 -1
- package/dist/public/index.html +2210 -0
- package/dist/public/logo.svg +37 -0
- package/package.json +2 -2
|
@@ -0,0 +1,2210 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Clux</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/style.css">
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css">
|
|
10
|
+
<style>
|
|
11
|
+
@font-face {
|
|
12
|
+
font-family: 'JetBrainsMono NF';
|
|
13
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
|
14
|
+
font-weight: 400;
|
|
15
|
+
font-style: normal;
|
|
16
|
+
font-display: swap;
|
|
17
|
+
}
|
|
18
|
+
@font-face {
|
|
19
|
+
font-family: 'JetBrainsMono NF';
|
|
20
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
|
21
|
+
font-weight: 700;
|
|
22
|
+
font-style: normal;
|
|
23
|
+
font-display: swap;
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
26
|
+
<style>
|
|
27
|
+
/* ── Radix Design System ─────────────────────────────────── */
|
|
28
|
+
|
|
29
|
+
:root {
|
|
30
|
+
/* Radix Mauve Dark (warm gray) */
|
|
31
|
+
--gray-1: #121113;
|
|
32
|
+
--gray-2: #1a191b;
|
|
33
|
+
--gray-3: #211f22;
|
|
34
|
+
--gray-4: #282528;
|
|
35
|
+
--gray-5: #2f2c30;
|
|
36
|
+
--gray-6: #383538;
|
|
37
|
+
--gray-7: #454245;
|
|
38
|
+
--gray-8: #5c585c;
|
|
39
|
+
--gray-9: #65626a;
|
|
40
|
+
--gray-10: #76737a;
|
|
41
|
+
--gray-11: #b5b2bc;
|
|
42
|
+
--gray-12: #eeeef0;
|
|
43
|
+
|
|
44
|
+
/* Radix Amber accent */
|
|
45
|
+
--accent-9: #ffb224;
|
|
46
|
+
--accent-10: #ffcb47;
|
|
47
|
+
--accent-11: #f1a10d;
|
|
48
|
+
--accent-12: #fef3dd;
|
|
49
|
+
--accent-a3: rgba(255, 178, 36, 0.08);
|
|
50
|
+
--accent-a4: rgba(255, 178, 36, 0.12);
|
|
51
|
+
--accent-a5: rgba(255, 178, 36, 0.18);
|
|
52
|
+
|
|
53
|
+
/* Radix functional colors */
|
|
54
|
+
--green-9: #46a758;
|
|
55
|
+
--green-11: #63c174;
|
|
56
|
+
--green-a3: rgba(70, 167, 88, 0.1);
|
|
57
|
+
--red-9: #e5484d;
|
|
58
|
+
--red-11: #ff6369;
|
|
59
|
+
--red-a3: rgba(229, 72, 77, 0.1);
|
|
60
|
+
--red-a5: rgba(229, 72, 77, 0.2);
|
|
61
|
+
--orange-9: #f76b15;
|
|
62
|
+
--orange-11: #ff8b3e;
|
|
63
|
+
--violet-9: #6e56cf;
|
|
64
|
+
--violet-11: #baa7ff;
|
|
65
|
+
--violet-a3: rgba(110, 86, 207, 0.12);
|
|
66
|
+
|
|
67
|
+
/* Semantic tokens */
|
|
68
|
+
--bg: var(--gray-1);
|
|
69
|
+
--surface: var(--gray-2);
|
|
70
|
+
--surface2: var(--gray-3);
|
|
71
|
+
--border: var(--gray-4);
|
|
72
|
+
--border-hover: var(--gray-6);
|
|
73
|
+
--text: var(--gray-12);
|
|
74
|
+
--text-dim: var(--gray-11);
|
|
75
|
+
--accent: var(--accent-9);
|
|
76
|
+
--accent2: var(--green-9);
|
|
77
|
+
--danger: var(--red-9);
|
|
78
|
+
--warning: var(--orange-9);
|
|
79
|
+
--radius: 6px;
|
|
80
|
+
--radius-lg: 10px;
|
|
81
|
+
--font-mono: 'Geist Mono', 'SF Mono', 'Cascadia Code', monospace;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
85
|
+
|
|
86
|
+
body {
|
|
87
|
+
font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
88
|
+
background: var(--bg);
|
|
89
|
+
color: var(--text);
|
|
90
|
+
min-height: 100vh;
|
|
91
|
+
-webkit-font-smoothing: antialiased;
|
|
92
|
+
-moz-osx-font-smoothing: grayscale;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Custom scrollbar */
|
|
96
|
+
::-webkit-scrollbar { width: 6px; }
|
|
97
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
98
|
+
::-webkit-scrollbar-thumb { background: var(--gray-5); border-radius: 3px; }
|
|
99
|
+
::-webkit-scrollbar-thumb:hover { background: var(--gray-6); }
|
|
100
|
+
|
|
101
|
+
/* ── Header ──────────────────────────────────────────────── */
|
|
102
|
+
|
|
103
|
+
header {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: space-between;
|
|
107
|
+
padding: 0 24px;
|
|
108
|
+
height: 52px;
|
|
109
|
+
border-bottom: 1px solid var(--border);
|
|
110
|
+
background: var(--surface);
|
|
111
|
+
position: relative;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Amber accent line at top */
|
|
115
|
+
header::before {
|
|
116
|
+
content: '';
|
|
117
|
+
position: absolute;
|
|
118
|
+
top: 0;
|
|
119
|
+
left: 0;
|
|
120
|
+
right: 0;
|
|
121
|
+
height: 2px;
|
|
122
|
+
background: linear-gradient(90deg, var(--accent-9), var(--orange-9), var(--accent-9));
|
|
123
|
+
opacity: 0.8;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
header h1 {
|
|
127
|
+
font-size: 15px;
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
letter-spacing: -0.02em;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
header h1 span {
|
|
136
|
+
color: var(--accent-9);
|
|
137
|
+
font-weight: 700;
|
|
138
|
+
font-family: var(--font-mono);
|
|
139
|
+
font-size: 14px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.header-actions {
|
|
143
|
+
display: flex;
|
|
144
|
+
gap: 8px;
|
|
145
|
+
align-items: center;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.status-dot {
|
|
149
|
+
width: 7px;
|
|
150
|
+
height: 7px;
|
|
151
|
+
border-radius: 50%;
|
|
152
|
+
background: var(--green-9);
|
|
153
|
+
display: inline-block;
|
|
154
|
+
box-shadow: 0 0 6px rgba(70, 167, 88, 0.4);
|
|
155
|
+
animation: pulse-green 2s ease-in-out infinite;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@keyframes pulse-green {
|
|
159
|
+
0%, 100% { box-shadow: 0 0 6px rgba(70, 167, 88, 0.4); }
|
|
160
|
+
50% { box-shadow: 0 0 10px rgba(70, 167, 88, 0.6); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.status-dot.offline {
|
|
164
|
+
background: var(--red-9);
|
|
165
|
+
box-shadow: 0 0 6px rgba(229, 72, 77, 0.4);
|
|
166
|
+
animation: none;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ── Layout ──────────────────────────────────────────────── */
|
|
170
|
+
|
|
171
|
+
.app {
|
|
172
|
+
display: grid;
|
|
173
|
+
grid-template-columns: 300px 1fr;
|
|
174
|
+
height: calc(100vh - 52px);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ── Sidebar ─────────────────────────────────────────────── */
|
|
178
|
+
|
|
179
|
+
.sidebar {
|
|
180
|
+
border-right: 1px solid var(--border);
|
|
181
|
+
overflow-y: auto;
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-direction: column;
|
|
184
|
+
background: var(--gray-1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.sidebar-header {
|
|
188
|
+
padding: 14px 16px;
|
|
189
|
+
border-bottom: 1px solid var(--border);
|
|
190
|
+
display: flex;
|
|
191
|
+
justify-content: space-between;
|
|
192
|
+
align-items: center;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.sidebar-header h2 {
|
|
196
|
+
font-size: 11px;
|
|
197
|
+
font-weight: 600;
|
|
198
|
+
text-transform: uppercase;
|
|
199
|
+
letter-spacing: 0.08em;
|
|
200
|
+
color: var(--gray-9);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.session-list {
|
|
204
|
+
flex: 1;
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
padding: 6px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.session-card {
|
|
210
|
+
background: transparent;
|
|
211
|
+
border: 1px solid transparent;
|
|
212
|
+
border-radius: var(--radius);
|
|
213
|
+
padding: 10px 12px;
|
|
214
|
+
margin-bottom: 2px;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
transition: all 0.15s ease;
|
|
217
|
+
position: relative;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.session-card:hover {
|
|
221
|
+
background: var(--gray-3);
|
|
222
|
+
border-color: var(--gray-4);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.session-card.selected {
|
|
226
|
+
background: var(--accent-a3);
|
|
227
|
+
border-color: rgba(255, 178, 36, 0.15);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.session-card.selected::before {
|
|
231
|
+
content: '';
|
|
232
|
+
position: absolute;
|
|
233
|
+
left: 0;
|
|
234
|
+
top: 6px;
|
|
235
|
+
bottom: 6px;
|
|
236
|
+
width: 2px;
|
|
237
|
+
border-radius: 1px;
|
|
238
|
+
background: var(--accent-9);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.session-card-name {
|
|
242
|
+
font-weight: 600;
|
|
243
|
+
font-size: 13px;
|
|
244
|
+
margin-bottom: 4px;
|
|
245
|
+
letter-spacing: -0.01em;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.session-card-meta {
|
|
249
|
+
display: flex;
|
|
250
|
+
gap: 10px;
|
|
251
|
+
font-size: 11px;
|
|
252
|
+
color: var(--gray-9);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.badge {
|
|
256
|
+
padding: 1px 7px;
|
|
257
|
+
border-radius: 99px;
|
|
258
|
+
font-size: 10px;
|
|
259
|
+
font-weight: 500;
|
|
260
|
+
letter-spacing: 0.02em;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.badge.attached {
|
|
264
|
+
background: var(--green-a3);
|
|
265
|
+
color: var(--green-11);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.badge.detached {
|
|
269
|
+
background: rgba(255, 139, 62, 0.08);
|
|
270
|
+
color: var(--orange-11);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.badge.claude {
|
|
274
|
+
background: var(--violet-a3);
|
|
275
|
+
color: var(--violet-11);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.no-sessions {
|
|
279
|
+
padding: 48px 16px;
|
|
280
|
+
text-align: center;
|
|
281
|
+
color: var(--gray-9);
|
|
282
|
+
font-size: 13px;
|
|
283
|
+
line-height: 1.6;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── Main Content ────────────────────────────────────────── */
|
|
287
|
+
|
|
288
|
+
.main {
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-direction: column;
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
min-height: 0;
|
|
293
|
+
background: var(--bg);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.main-empty {
|
|
297
|
+
display: flex;
|
|
298
|
+
align-items: center;
|
|
299
|
+
justify-content: center;
|
|
300
|
+
height: 100%;
|
|
301
|
+
color: var(--gray-9);
|
|
302
|
+
flex-direction: column;
|
|
303
|
+
gap: 16px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.main-empty svg { opacity: 0.15; }
|
|
307
|
+
.main-empty div { font-size: 13px; }
|
|
308
|
+
|
|
309
|
+
/* ── Toolbar ──────────────────────────────────────────────── */
|
|
310
|
+
|
|
311
|
+
.toolbar {
|
|
312
|
+
display: flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
gap: 6px;
|
|
315
|
+
padding: 10px 16px;
|
|
316
|
+
border-bottom: 1px solid var(--border);
|
|
317
|
+
background: var(--surface);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.toolbar h3 {
|
|
321
|
+
font-size: 14px;
|
|
322
|
+
font-weight: 600;
|
|
323
|
+
flex: 1;
|
|
324
|
+
letter-spacing: -0.01em;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.pane-selector {
|
|
328
|
+
display: flex;
|
|
329
|
+
gap: 2px;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.pane-tab {
|
|
333
|
+
padding: 3px 10px;
|
|
334
|
+
border-radius: var(--radius);
|
|
335
|
+
font-size: 11px;
|
|
336
|
+
font-family: var(--font-mono);
|
|
337
|
+
cursor: pointer;
|
|
338
|
+
border: 1px solid transparent;
|
|
339
|
+
background: transparent;
|
|
340
|
+
color: var(--gray-9);
|
|
341
|
+
transition: all 0.12s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
align-items: center;
|
|
344
|
+
gap: 4px;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.pane-tab-close {
|
|
348
|
+
font-size: 14px;
|
|
349
|
+
line-height: 1;
|
|
350
|
+
opacity: 0;
|
|
351
|
+
transition: opacity 0.12s;
|
|
352
|
+
padding: 0 2px;
|
|
353
|
+
border-radius: 3px;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.pane-tab:hover .pane-tab-close { opacity: 0.6; }
|
|
357
|
+
.pane-tab-close:hover { opacity: 1 !important; background: rgba(255,255,255,0.1); }
|
|
358
|
+
|
|
359
|
+
.pane-tab:hover {
|
|
360
|
+
background: var(--gray-3);
|
|
361
|
+
color: var(--text);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.pane-tab.active {
|
|
365
|
+
background: var(--accent-9);
|
|
366
|
+
color: var(--gray-1);
|
|
367
|
+
border-color: var(--accent-9);
|
|
368
|
+
font-weight: 600;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ── Terminal Output ──────────────────────────────────────── */
|
|
372
|
+
|
|
373
|
+
.terminal-wrapper {
|
|
374
|
+
flex: 1 1 0;
|
|
375
|
+
min-height: 0;
|
|
376
|
+
overflow: hidden;
|
|
377
|
+
position: relative;
|
|
378
|
+
padding: 8px 0 0 8px;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#terminal-container {
|
|
382
|
+
width: 100%;
|
|
383
|
+
height: 100%;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#terminal-container .xterm {
|
|
387
|
+
height: 100%;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#terminal-container .xterm-viewport {
|
|
391
|
+
height: 100% !important;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* ── Terminal Status Bar ──────────────────────────────────── */
|
|
395
|
+
|
|
396
|
+
.terminal-status-bar {
|
|
397
|
+
display: flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
gap: 8px;
|
|
400
|
+
padding: 4px 16px;
|
|
401
|
+
border-top: 1px solid var(--border);
|
|
402
|
+
background: var(--surface);
|
|
403
|
+
min-height: 30px;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.terminal-status-hint {
|
|
407
|
+
font-size: 11px;
|
|
408
|
+
color: var(--gray-9);
|
|
409
|
+
font-family: var(--font-mono);
|
|
410
|
+
flex: 1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ── Buttons ──────────────────────────────────────────────── */
|
|
414
|
+
|
|
415
|
+
.btn {
|
|
416
|
+
padding: 5px 12px;
|
|
417
|
+
border-radius: var(--radius);
|
|
418
|
+
font-size: 12px;
|
|
419
|
+
font-weight: 500;
|
|
420
|
+
font-family: 'Geist', -apple-system, sans-serif;
|
|
421
|
+
cursor: pointer;
|
|
422
|
+
border: 1px solid var(--gray-5);
|
|
423
|
+
background: var(--gray-3);
|
|
424
|
+
color: var(--gray-12);
|
|
425
|
+
transition: all 0.12s ease;
|
|
426
|
+
white-space: nowrap;
|
|
427
|
+
letter-spacing: -0.01em;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.btn:hover {
|
|
431
|
+
background: var(--gray-4);
|
|
432
|
+
border-color: var(--gray-6);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.btn:active {
|
|
436
|
+
background: var(--gray-5);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.btn.primary {
|
|
440
|
+
background: var(--accent-9);
|
|
441
|
+
color: var(--gray-1);
|
|
442
|
+
border-color: var(--accent-9);
|
|
443
|
+
font-weight: 600;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.btn.primary:hover {
|
|
447
|
+
background: var(--accent-10);
|
|
448
|
+
border-color: var(--accent-10);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.btn.danger {
|
|
452
|
+
background: var(--red-a3);
|
|
453
|
+
color: var(--red-11);
|
|
454
|
+
border-color: var(--red-a5);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.btn.danger:hover {
|
|
458
|
+
background: var(--red-a5);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.btn.small { padding: 3px 9px; font-size: 11px; }
|
|
462
|
+
|
|
463
|
+
/* ── Modal ────────────────────────────────────────────────── */
|
|
464
|
+
|
|
465
|
+
.modal-overlay {
|
|
466
|
+
position: fixed;
|
|
467
|
+
inset: 0;
|
|
468
|
+
background: rgba(0, 0, 0, 0.7);
|
|
469
|
+
backdrop-filter: blur(4px);
|
|
470
|
+
display: flex;
|
|
471
|
+
align-items: center;
|
|
472
|
+
justify-content: center;
|
|
473
|
+
z-index: 100;
|
|
474
|
+
animation: overlay-in 0.15s ease-out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
@keyframes overlay-in {
|
|
478
|
+
from { opacity: 0; }
|
|
479
|
+
to { opacity: 1; }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.modal-overlay.hidden { display: none; }
|
|
483
|
+
|
|
484
|
+
.modal {
|
|
485
|
+
background: var(--gray-2);
|
|
486
|
+
border: 1px solid var(--gray-4);
|
|
487
|
+
border-radius: var(--radius-lg);
|
|
488
|
+
padding: 24px;
|
|
489
|
+
width: 440px;
|
|
490
|
+
max-width: 90vw;
|
|
491
|
+
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
|
492
|
+
animation: modal-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@keyframes modal-in {
|
|
496
|
+
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
|
497
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.modal h2 {
|
|
501
|
+
font-size: 16px;
|
|
502
|
+
font-weight: 600;
|
|
503
|
+
margin-bottom: 20px;
|
|
504
|
+
letter-spacing: -0.02em;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.form-group {
|
|
508
|
+
margin-bottom: 14px;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.form-group label {
|
|
512
|
+
display: block;
|
|
513
|
+
font-size: 12px;
|
|
514
|
+
font-weight: 500;
|
|
515
|
+
margin-bottom: 6px;
|
|
516
|
+
color: var(--gray-11);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.form-group input, .form-group select {
|
|
520
|
+
width: 100%;
|
|
521
|
+
padding: 7px 10px;
|
|
522
|
+
background: var(--gray-1);
|
|
523
|
+
border: 1px solid var(--gray-4);
|
|
524
|
+
border-radius: var(--radius);
|
|
525
|
+
color: var(--text);
|
|
526
|
+
font-size: 13px;
|
|
527
|
+
font-family: 'Geist', -apple-system, sans-serif;
|
|
528
|
+
outline: none;
|
|
529
|
+
transition: all 0.15s ease;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.form-group input:focus, .form-group select:focus {
|
|
533
|
+
border-color: var(--accent-9);
|
|
534
|
+
box-shadow: 0 0 0 1px var(--accent-9);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.modal-actions {
|
|
538
|
+
display: flex;
|
|
539
|
+
justify-content: flex-end;
|
|
540
|
+
gap: 8px;
|
|
541
|
+
margin-top: 20px;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* ── Session Details Panel ────────────────────────────────── */
|
|
545
|
+
|
|
546
|
+
.session-details {
|
|
547
|
+
padding: 10px 16px;
|
|
548
|
+
border-bottom: 1px solid var(--border);
|
|
549
|
+
background: var(--surface);
|
|
550
|
+
display: flex;
|
|
551
|
+
gap: 20px;
|
|
552
|
+
font-size: 11px;
|
|
553
|
+
color: var(--gray-9);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.detail-item { display: flex; gap: 5px; align-items: center; }
|
|
557
|
+
.detail-label { font-weight: 600; color: var(--gray-11); }
|
|
558
|
+
|
|
559
|
+
.tag {
|
|
560
|
+
padding: 1px 6px;
|
|
561
|
+
border-radius: 99px;
|
|
562
|
+
font-size: 10px;
|
|
563
|
+
background: var(--accent-a4);
|
|
564
|
+
color: var(--accent-11);
|
|
565
|
+
cursor: default;
|
|
566
|
+
font-weight: 500;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.session-card-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
|
570
|
+
|
|
571
|
+
/* ── Prompt Actions ──────────────────────────────────────── */
|
|
572
|
+
|
|
573
|
+
.prompt-actions {
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
gap: 6px;
|
|
577
|
+
padding: 8px 16px;
|
|
578
|
+
background: var(--accent-a3);
|
|
579
|
+
border-top: 1px solid rgba(255, 178, 36, 0.1);
|
|
580
|
+
animation: prompt-slide-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
|
581
|
+
flex-wrap: wrap;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@keyframes prompt-slide-in {
|
|
585
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
586
|
+
to { opacity: 1; transform: translateY(0); }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.prompt-label {
|
|
590
|
+
font-size: 11px;
|
|
591
|
+
color: var(--accent-11);
|
|
592
|
+
font-weight: 600;
|
|
593
|
+
margin-right: 4px;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.prompt-hint {
|
|
597
|
+
font-size: 10px;
|
|
598
|
+
color: var(--gray-9);
|
|
599
|
+
font-style: italic;
|
|
600
|
+
margin-left: auto;
|
|
601
|
+
font-family: var(--font-mono);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.btn.prompt-yes {
|
|
605
|
+
background: var(--green-a3);
|
|
606
|
+
color: var(--green-11);
|
|
607
|
+
border-color: rgba(70, 167, 88, 0.15);
|
|
608
|
+
}
|
|
609
|
+
.btn.prompt-yes:hover { background: rgba(70, 167, 88, 0.15); }
|
|
610
|
+
|
|
611
|
+
.btn.prompt-no {
|
|
612
|
+
background: var(--red-a3);
|
|
613
|
+
color: var(--red-11);
|
|
614
|
+
border-color: var(--red-a5);
|
|
615
|
+
}
|
|
616
|
+
.btn.prompt-no:hover { background: var(--red-a5); }
|
|
617
|
+
|
|
618
|
+
.btn.prompt-always {
|
|
619
|
+
background: rgba(255, 139, 62, 0.08);
|
|
620
|
+
color: var(--orange-11);
|
|
621
|
+
border-color: rgba(255, 139, 62, 0.15);
|
|
622
|
+
}
|
|
623
|
+
.btn.prompt-always:hover { background: rgba(255, 139, 62, 0.15); }
|
|
624
|
+
|
|
625
|
+
.btn.prompt-choice {
|
|
626
|
+
background: var(--accent-a3);
|
|
627
|
+
color: var(--accent-11);
|
|
628
|
+
border-color: rgba(255, 178, 36, 0.12);
|
|
629
|
+
}
|
|
630
|
+
.btn.prompt-choice:hover { background: var(--accent-a4); }
|
|
631
|
+
.btn.prompt-choice.selected {
|
|
632
|
+
background: var(--accent-9);
|
|
633
|
+
color: var(--gray-1);
|
|
634
|
+
border-color: var(--accent-9);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* ── Toast ────────────────────────────────────────────────── */
|
|
638
|
+
|
|
639
|
+
.toast {
|
|
640
|
+
position: fixed;
|
|
641
|
+
bottom: 24px;
|
|
642
|
+
right: 24px;
|
|
643
|
+
background: var(--gray-3);
|
|
644
|
+
border: 1px solid var(--gray-5);
|
|
645
|
+
border-radius: var(--radius);
|
|
646
|
+
padding: 10px 16px;
|
|
647
|
+
font-size: 13px;
|
|
648
|
+
color: var(--text);
|
|
649
|
+
z-index: 200;
|
|
650
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
651
|
+
animation: toast-in 0.2s cubic-bezier(0.16, 1, 0.3, 1), toast-out 0.3s ease-in 2.5s forwards;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@keyframes toast-in {
|
|
656
|
+
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
|
657
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
658
|
+
}
|
|
659
|
+
@keyframes toast-out {
|
|
660
|
+
from { opacity: 1; }
|
|
661
|
+
to { opacity: 0; }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/* ── Relay Modal ───────────────────────────────────────────── */
|
|
665
|
+
|
|
666
|
+
.relay-section {
|
|
667
|
+
margin-bottom: 12px;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.relay-section-label {
|
|
671
|
+
font-size: 11px;
|
|
672
|
+
font-weight: 600;
|
|
673
|
+
text-transform: uppercase;
|
|
674
|
+
letter-spacing: 0.06em;
|
|
675
|
+
color: var(--gray-9);
|
|
676
|
+
margin-bottom: 8px;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.relay-pane-grid {
|
|
680
|
+
display: flex;
|
|
681
|
+
flex-wrap: wrap;
|
|
682
|
+
gap: 6px;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.relay-pane-tile {
|
|
686
|
+
padding: 8px 12px;
|
|
687
|
+
border-radius: var(--radius);
|
|
688
|
+
border: 1px solid var(--gray-5);
|
|
689
|
+
background: var(--gray-3);
|
|
690
|
+
cursor: pointer;
|
|
691
|
+
transition: all 0.12s ease;
|
|
692
|
+
min-width: 90px;
|
|
693
|
+
text-align: center;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.relay-pane-tile:hover:not(.disabled) {
|
|
697
|
+
border-color: var(--gray-6);
|
|
698
|
+
background: var(--gray-4);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.relay-pane-tile.selected {
|
|
702
|
+
background: var(--accent-a4);
|
|
703
|
+
border-color: var(--accent-9);
|
|
704
|
+
color: var(--accent-12);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.relay-pane-tile.disabled {
|
|
708
|
+
opacity: 0.35;
|
|
709
|
+
cursor: not-allowed;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.relay-pane-tile .tile-label {
|
|
713
|
+
font-size: 13px;
|
|
714
|
+
font-weight: 600;
|
|
715
|
+
font-family: var(--font-mono);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.relay-pane-tile .tile-cmd {
|
|
719
|
+
font-size: 10px;
|
|
720
|
+
color: var(--gray-9);
|
|
721
|
+
margin-top: 2px;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.relay-direction {
|
|
725
|
+
text-align: center;
|
|
726
|
+
font-size: 20px;
|
|
727
|
+
color: var(--gray-7);
|
|
728
|
+
padding: 4px 0;
|
|
729
|
+
user-select: none;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.relay-mode-toggle {
|
|
733
|
+
display: flex;
|
|
734
|
+
border: 1px solid var(--gray-5);
|
|
735
|
+
border-radius: var(--radius);
|
|
736
|
+
overflow: hidden;
|
|
737
|
+
margin-bottom: 12px;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.relay-mode-btn {
|
|
741
|
+
flex: 1;
|
|
742
|
+
padding: 6px 12px;
|
|
743
|
+
font-size: 12px;
|
|
744
|
+
font-weight: 500;
|
|
745
|
+
font-family: 'Geist', -apple-system, sans-serif;
|
|
746
|
+
background: var(--gray-3);
|
|
747
|
+
color: var(--gray-11);
|
|
748
|
+
border: none;
|
|
749
|
+
cursor: pointer;
|
|
750
|
+
transition: all 0.12s ease;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.relay-mode-btn:not(:last-child) {
|
|
754
|
+
border-right: 1px solid var(--gray-5);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.relay-mode-btn:hover {
|
|
758
|
+
background: var(--gray-4);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.relay-mode-btn.active {
|
|
762
|
+
background: var(--accent-a4);
|
|
763
|
+
color: var(--accent-12);
|
|
764
|
+
font-weight: 600;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.relay-preview {
|
|
768
|
+
background: var(--gray-1);
|
|
769
|
+
border: 1px solid var(--gray-4);
|
|
770
|
+
border-radius: var(--radius);
|
|
771
|
+
padding: 10px;
|
|
772
|
+
font-family: var(--font-mono);
|
|
773
|
+
font-size: 11px;
|
|
774
|
+
line-height: 1.4;
|
|
775
|
+
max-height: 180px;
|
|
776
|
+
overflow-y: auto;
|
|
777
|
+
white-space: pre-wrap;
|
|
778
|
+
word-break: break-all;
|
|
779
|
+
color: var(--gray-11);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.relay-message-input {
|
|
783
|
+
width: 100%;
|
|
784
|
+
padding: 8px 10px;
|
|
785
|
+
background: var(--gray-1);
|
|
786
|
+
border: 1px solid var(--gray-4);
|
|
787
|
+
border-radius: var(--radius);
|
|
788
|
+
color: var(--text);
|
|
789
|
+
font-size: 13px;
|
|
790
|
+
font-family: var(--font-mono);
|
|
791
|
+
resize: vertical;
|
|
792
|
+
min-height: 80px;
|
|
793
|
+
outline: none;
|
|
794
|
+
transition: border-color 0.15s ease;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.relay-message-input:focus {
|
|
798
|
+
border-color: var(--accent-9);
|
|
799
|
+
box-shadow: 0 0 0 1px var(--accent-9);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.relay-lines-row {
|
|
803
|
+
display: flex;
|
|
804
|
+
align-items: center;
|
|
805
|
+
gap: 8px;
|
|
806
|
+
margin-bottom: 8px;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.relay-lines-row label {
|
|
810
|
+
font-size: 12px;
|
|
811
|
+
color: var(--gray-11);
|
|
812
|
+
white-space: nowrap;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.relay-lines-row input {
|
|
816
|
+
width: 60px;
|
|
817
|
+
padding: 4px 8px;
|
|
818
|
+
background: var(--gray-1);
|
|
819
|
+
border: 1px solid var(--gray-4);
|
|
820
|
+
border-radius: var(--radius);
|
|
821
|
+
color: var(--text);
|
|
822
|
+
font-size: 12px;
|
|
823
|
+
font-family: var(--font-mono);
|
|
824
|
+
outline: none;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.relay-lines-row input:focus {
|
|
828
|
+
border-color: var(--accent-9);
|
|
829
|
+
box-shadow: 0 0 0 1px var(--accent-9);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* ── Settings ──────────────────────────────────────────────── */
|
|
833
|
+
|
|
834
|
+
.settings-section {
|
|
835
|
+
padding: 24px;
|
|
836
|
+
overflow: auto;
|
|
837
|
+
height: 100%;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
.settings-section h3 {
|
|
841
|
+
margin-bottom: 20px;
|
|
842
|
+
font-size: 16px;
|
|
843
|
+
font-weight: 600;
|
|
844
|
+
letter-spacing: -0.02em;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.settings-info-box {
|
|
848
|
+
background: rgba(255, 139, 62, 0.06);
|
|
849
|
+
border: 1px solid rgba(255, 139, 62, 0.15);
|
|
850
|
+
border-radius: var(--radius);
|
|
851
|
+
padding: 12px 16px;
|
|
852
|
+
font-size: 12px;
|
|
853
|
+
color: var(--orange-11);
|
|
854
|
+
line-height: 1.5;
|
|
855
|
+
margin-bottom: 20px;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
.settings-info-box code {
|
|
859
|
+
background: var(--gray-3);
|
|
860
|
+
padding: 1px 5px;
|
|
861
|
+
border-radius: 3px;
|
|
862
|
+
font-family: var(--font-mono);
|
|
863
|
+
font-size: 11px;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.ip-add-row {
|
|
867
|
+
display: flex;
|
|
868
|
+
gap: 8px;
|
|
869
|
+
margin-bottom: 16px;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.ip-add-row input {
|
|
873
|
+
flex: 1;
|
|
874
|
+
padding: 7px 10px;
|
|
875
|
+
background: var(--gray-1);
|
|
876
|
+
border: 1px solid var(--gray-4);
|
|
877
|
+
border-radius: var(--radius);
|
|
878
|
+
color: var(--text);
|
|
879
|
+
font-size: 13px;
|
|
880
|
+
font-family: var(--font-mono);
|
|
881
|
+
outline: none;
|
|
882
|
+
transition: all 0.15s ease;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.ip-add-row input:focus {
|
|
886
|
+
border-color: var(--accent-9);
|
|
887
|
+
box-shadow: 0 0 0 1px var(--accent-9);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.ip-list {
|
|
891
|
+
display: flex;
|
|
892
|
+
flex-direction: column;
|
|
893
|
+
gap: 4px;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.ip-entry {
|
|
897
|
+
display: flex;
|
|
898
|
+
align-items: center;
|
|
899
|
+
justify-content: space-between;
|
|
900
|
+
padding: 8px 12px;
|
|
901
|
+
background: var(--surface2);
|
|
902
|
+
border: 1px solid var(--border);
|
|
903
|
+
border-radius: var(--radius);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.ip-entry-addr {
|
|
907
|
+
font-family: var(--font-mono);
|
|
908
|
+
font-size: 13px;
|
|
909
|
+
font-weight: 500;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
.ip-list-empty {
|
|
913
|
+
padding: 24px;
|
|
914
|
+
text-align: center;
|
|
915
|
+
color: var(--gray-9);
|
|
916
|
+
font-size: 13px;
|
|
917
|
+
background: var(--surface2);
|
|
918
|
+
border: 1px dashed var(--gray-5);
|
|
919
|
+
border-radius: var(--radius);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.settings-server-info {
|
|
923
|
+
display: flex;
|
|
924
|
+
gap: 20px;
|
|
925
|
+
margin-bottom: 20px;
|
|
926
|
+
font-size: 12px;
|
|
927
|
+
color: var(--gray-9);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.settings-server-info .detail-label {
|
|
931
|
+
color: var(--gray-11);
|
|
932
|
+
font-weight: 600;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* ── Hamburger Button ───────────────────────────────────── */
|
|
936
|
+
|
|
937
|
+
.hamburger-btn {
|
|
938
|
+
display: none;
|
|
939
|
+
background: none;
|
|
940
|
+
border: none;
|
|
941
|
+
color: var(--text);
|
|
942
|
+
cursor: pointer;
|
|
943
|
+
padding: 4px;
|
|
944
|
+
border-radius: var(--radius);
|
|
945
|
+
transition: background 0.12s ease;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.hamburger-btn:hover {
|
|
949
|
+
background: var(--gray-3);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.hamburger-btn svg {
|
|
953
|
+
display: block;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* ── Mobile Sidebar Overlay ─────────────────────────────── */
|
|
957
|
+
|
|
958
|
+
.sidebar-overlay {
|
|
959
|
+
display: none;
|
|
960
|
+
position: fixed;
|
|
961
|
+
inset: 0;
|
|
962
|
+
background: rgba(0, 0, 0, 0.6);
|
|
963
|
+
backdrop-filter: blur(4px);
|
|
964
|
+
z-index: 50;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.sidebar-overlay.visible {
|
|
968
|
+
display: block;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/* ── Responsive ─────────────────────────────────────────── */
|
|
972
|
+
|
|
973
|
+
@media (max-width: 768px) {
|
|
974
|
+
.hamburger-btn {
|
|
975
|
+
display: block;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.app {
|
|
979
|
+
grid-template-columns: 1fr;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.sidebar {
|
|
983
|
+
position: fixed;
|
|
984
|
+
top: 52px;
|
|
985
|
+
left: 0;
|
|
986
|
+
bottom: 0;
|
|
987
|
+
width: 300px;
|
|
988
|
+
max-width: 85vw;
|
|
989
|
+
z-index: 51;
|
|
990
|
+
transform: translateX(-100%);
|
|
991
|
+
transition: transform 0.25s ease;
|
|
992
|
+
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.sidebar.open {
|
|
996
|
+
transform: translateX(0);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
header {
|
|
1000
|
+
padding: 0 12px;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.toolbar {
|
|
1004
|
+
padding: 8px 10px;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.terminal-wrapper {
|
|
1008
|
+
padding: 4px 0 0 4px;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.terminal-status-bar {
|
|
1012
|
+
padding: 4px 10px;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
</style>
|
|
1016
|
+
</head>
|
|
1017
|
+
<body>
|
|
1018
|
+
|
|
1019
|
+
<header>
|
|
1020
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
1021
|
+
<button class="hamburger-btn" id="hamburger-btn" onclick="toggleSidebar()">
|
|
1022
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
1023
|
+
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
|
1024
|
+
</svg>
|
|
1025
|
+
</button>
|
|
1026
|
+
<h1><img src="logo.svg" alt="Clux" style="width:36px;height:36px"><span>Clux</span></h1>
|
|
1027
|
+
</div>
|
|
1028
|
+
<div class="header-actions">
|
|
1029
|
+
<button class="btn small" onclick="showSettingsUI()">Settings</button>
|
|
1030
|
+
<span class="status-dot" id="ws-status"></span>
|
|
1031
|
+
<span id="ws-status-text" style="font-size:11px;color:var(--gray-9);font-family:var(--font-mono)">connecting...</span>
|
|
1032
|
+
</div>
|
|
1033
|
+
</header>
|
|
1034
|
+
|
|
1035
|
+
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
|
|
1036
|
+
|
|
1037
|
+
<div class="app">
|
|
1038
|
+
<!-- Sidebar -->
|
|
1039
|
+
<div class="sidebar">
|
|
1040
|
+
<div class="sidebar-header">
|
|
1041
|
+
<h2>Sessions</h2>
|
|
1042
|
+
<button class="btn primary small" onclick="showCreateModal()">+ New</button>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="session-list" id="session-list">
|
|
1045
|
+
<div class="no-sessions">No active sessions</div>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
|
|
1049
|
+
<!-- Main -->
|
|
1050
|
+
<div class="main" id="main">
|
|
1051
|
+
<div class="main-empty">
|
|
1052
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1053
|
+
<rect x="2" y="3" width="20" height="18" rx="2"/>
|
|
1054
|
+
<path d="M7 9l3 3-3 3M13 15h4"/>
|
|
1055
|
+
</svg>
|
|
1056
|
+
<div>Select a session or create a new one</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
|
|
1061
|
+
<!-- Create Session Modal -->
|
|
1062
|
+
<div class="modal-overlay hidden" id="create-modal">
|
|
1063
|
+
<div class="modal">
|
|
1064
|
+
<h2>New Session</h2>
|
|
1065
|
+
<div class="form-group">
|
|
1066
|
+
<label>Session Name</label>
|
|
1067
|
+
<input type="text" id="new-name" placeholder="my-project" autofocus>
|
|
1068
|
+
</div>
|
|
1069
|
+
<div class="form-group">
|
|
1070
|
+
<label>Template (optional)</label>
|
|
1071
|
+
<select id="new-template" onchange="onTemplateChange()">
|
|
1072
|
+
<option value="">Custom (manual config)</option>
|
|
1073
|
+
</select>
|
|
1074
|
+
<div id="template-desc" style="font-size:11px;color:var(--text-dim);margin-top:4px"></div>
|
|
1075
|
+
</div>
|
|
1076
|
+
<div class="form-group">
|
|
1077
|
+
<label>Working Directory</label>
|
|
1078
|
+
<input type="text" id="new-path" placeholder="/path/to/project">
|
|
1079
|
+
</div>
|
|
1080
|
+
<div id="custom-fields">
|
|
1081
|
+
<div class="form-group">
|
|
1082
|
+
<label>Command (optional)</label>
|
|
1083
|
+
<input type="text" id="new-command" placeholder='e.g. claude, bash, python3'>
|
|
1084
|
+
</div>
|
|
1085
|
+
<div class="form-group">
|
|
1086
|
+
<label>Layout</label>
|
|
1087
|
+
<select id="new-layout">
|
|
1088
|
+
<option value="">Default</option>
|
|
1089
|
+
<option value="tiled">Tiled</option>
|
|
1090
|
+
<option value="even-horizontal">Even Horizontal</option>
|
|
1091
|
+
<option value="even-vertical">Even Vertical</option>
|
|
1092
|
+
<option value="main-horizontal">Main Horizontal</option>
|
|
1093
|
+
<option value="main-vertical">Main Vertical</option>
|
|
1094
|
+
</select>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
<div class="modal-actions">
|
|
1098
|
+
<button class="btn" onclick="hideCreateModal()">Cancel</button>
|
|
1099
|
+
<button class="btn primary" onclick="createSession()">Create</button>
|
|
1100
|
+
</div>
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
|
|
1104
|
+
<!-- Relay Modal -->
|
|
1105
|
+
<div class="modal-overlay hidden" id="relay-modal" onclick="if(event.target===this)hideRelayModal()">
|
|
1106
|
+
<div class="modal" style="width:520px">
|
|
1107
|
+
<h2>Relay Between Panes</h2>
|
|
1108
|
+
|
|
1109
|
+
<div class="relay-section">
|
|
1110
|
+
<div class="relay-section-label">From</div>
|
|
1111
|
+
<div class="relay-pane-grid" id="relay-from-grid"></div>
|
|
1112
|
+
</div>
|
|
1113
|
+
|
|
1114
|
+
<div class="relay-direction">↓</div>
|
|
1115
|
+
|
|
1116
|
+
<div class="relay-section">
|
|
1117
|
+
<div class="relay-section-label">To</div>
|
|
1118
|
+
<div class="relay-pane-grid" id="relay-to-grid"></div>
|
|
1119
|
+
</div>
|
|
1120
|
+
|
|
1121
|
+
<div class="relay-mode-toggle">
|
|
1122
|
+
<button class="relay-mode-btn active" onclick="setRelayMode('capture')">Capture Output</button>
|
|
1123
|
+
<button class="relay-mode-btn" onclick="setRelayMode('message')">Send Message</button>
|
|
1124
|
+
</div>
|
|
1125
|
+
|
|
1126
|
+
<div id="relay-capture-controls">
|
|
1127
|
+
<div class="relay-lines-row">
|
|
1128
|
+
<label>Lines:</label>
|
|
1129
|
+
<input type="number" id="relay-lines" value="50" min="1" max="500">
|
|
1130
|
+
<button class="btn small" onclick="previewRelayCapture()">Preview</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
<div class="relay-preview" id="relay-preview" style="display:none"></div>
|
|
1133
|
+
</div>
|
|
1134
|
+
|
|
1135
|
+
<div id="relay-message-controls" style="display:none">
|
|
1136
|
+
<textarea class="relay-message-input" id="relay-message" placeholder="Type a message to send to the target pane..."></textarea>
|
|
1137
|
+
</div>
|
|
1138
|
+
|
|
1139
|
+
<div class="modal-actions">
|
|
1140
|
+
<button class="btn" onclick="hideRelayModal()">Cancel</button>
|
|
1141
|
+
<button class="btn primary" onclick="executeRelay()">Send Relay</button>
|
|
1142
|
+
</div>
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
|
|
1146
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
|
1147
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
|
1148
|
+
<script>
|
|
1149
|
+
// ── Mobile Sidebar ───────────────────────────────────────────────────
|
|
1150
|
+
|
|
1151
|
+
function toggleSidebar() {
|
|
1152
|
+
const sidebar = document.querySelector('.sidebar');
|
|
1153
|
+
const overlay = document.getElementById('sidebar-overlay');
|
|
1154
|
+
const isOpen = sidebar.classList.toggle('open');
|
|
1155
|
+
overlay.classList.toggle('visible', isOpen);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function closeSidebar() {
|
|
1159
|
+
document.querySelector('.sidebar').classList.remove('open');
|
|
1160
|
+
document.getElementById('sidebar-overlay').classList.remove('visible');
|
|
1161
|
+
// Refit terminal after sidebar slide-out transition
|
|
1162
|
+
setTimeout(() => { if (fitAddon && xterm) fitAndResize(); }, 300);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function closeSidebarIfMobile() {
|
|
1166
|
+
if (window.innerWidth <= 768) {
|
|
1167
|
+
closeSidebar();
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ── State ─────────────────────────────────────────────────────────────
|
|
1172
|
+
|
|
1173
|
+
let sessions = [];
|
|
1174
|
+
let selectedSession = null;
|
|
1175
|
+
let selectedPane = '0.0';
|
|
1176
|
+
let ws = null;
|
|
1177
|
+
let xterm = null;
|
|
1178
|
+
let fitAddon = null;
|
|
1179
|
+
let relayFrom = null;
|
|
1180
|
+
let relayTo = null;
|
|
1181
|
+
let relayMode = 'capture';
|
|
1182
|
+
|
|
1183
|
+
// ── WebSocket ─────────────────────────────────────────────────────────
|
|
1184
|
+
|
|
1185
|
+
function connectWS() {
|
|
1186
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1187
|
+
ws = new WebSocket(`${protocol}//${location.host}`);
|
|
1188
|
+
|
|
1189
|
+
ws.onopen = () => {
|
|
1190
|
+
document.getElementById('ws-status').className = 'status-dot';
|
|
1191
|
+
document.getElementById('ws-status-text').textContent = 'connected';
|
|
1192
|
+
if (selectedSession) {
|
|
1193
|
+
subscribeToPane(selectedSession, selectedPane);
|
|
1194
|
+
if (xterm) fitAndResize();
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
ws.onclose = () => {
|
|
1199
|
+
document.getElementById('ws-status').className = 'status-dot offline';
|
|
1200
|
+
document.getElementById('ws-status-text').textContent = 'disconnected';
|
|
1201
|
+
setTimeout(connectWS, 2000);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
ws.onmessage = (event) => {
|
|
1205
|
+
const msg = JSON.parse(event.data);
|
|
1206
|
+
if (msg.type === 'output') {
|
|
1207
|
+
// Ignore stale responses from a previous pane subscription
|
|
1208
|
+
if (msg.paneId && msg.paneId !== selectedPane) return;
|
|
1209
|
+
if (msg.sessionName && msg.sessionName !== selectedSession) return;
|
|
1210
|
+
updateTerminal(msg.content);
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function subscribeToPane(session, pane) {
|
|
1216
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1217
|
+
ws.send(JSON.stringify({ type: 'subscribe', session, pane }));
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function unsubscribePane() {
|
|
1222
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1223
|
+
ws.send(JSON.stringify({ type: 'unsubscribe' }));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// ── API ───────────────────────────────────────────────────────────────
|
|
1228
|
+
|
|
1229
|
+
async function fetchSessions() {
|
|
1230
|
+
try {
|
|
1231
|
+
const res = await fetch('/api/sessions');
|
|
1232
|
+
sessions = await res.json();
|
|
1233
|
+
renderSessionList();
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
console.error('Failed to fetch sessions:', err);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function createSession() {
|
|
1240
|
+
const name = document.getElementById('new-name').value.trim();
|
|
1241
|
+
const template = document.getElementById('new-template').value;
|
|
1242
|
+
|
|
1243
|
+
if (!name) return alert('Name is required');
|
|
1244
|
+
|
|
1245
|
+
try {
|
|
1246
|
+
let res;
|
|
1247
|
+
if (template) {
|
|
1248
|
+
const projectPath = document.getElementById('new-path').value.trim();
|
|
1249
|
+
res = await fetch('/api/sessions/from-template', {
|
|
1250
|
+
method: 'POST',
|
|
1251
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1252
|
+
body: JSON.stringify({
|
|
1253
|
+
projectName: name,
|
|
1254
|
+
projectPath: projectPath || undefined,
|
|
1255
|
+
template,
|
|
1256
|
+
}),
|
|
1257
|
+
});
|
|
1258
|
+
} else {
|
|
1259
|
+
const projectPath = document.getElementById('new-path').value.trim();
|
|
1260
|
+
const command = document.getElementById('new-command').value.trim();
|
|
1261
|
+
const layout = document.getElementById('new-layout').value;
|
|
1262
|
+
res = await fetch('/api/sessions', {
|
|
1263
|
+
method: 'POST',
|
|
1264
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1265
|
+
body: JSON.stringify({
|
|
1266
|
+
projectName: name,
|
|
1267
|
+
projectPath: projectPath || undefined,
|
|
1268
|
+
command: command || undefined,
|
|
1269
|
+
layout: layout || undefined,
|
|
1270
|
+
}),
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (!res.ok) {
|
|
1275
|
+
const err = await res.json();
|
|
1276
|
+
alert(err.error);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
hideCreateModal();
|
|
1281
|
+
document.getElementById('new-name').value = '';
|
|
1282
|
+
document.getElementById('new-template').value = '';
|
|
1283
|
+
onTemplateChange();
|
|
1284
|
+
await fetchSessions();
|
|
1285
|
+
selectSession(sessions.find(s => s.name.includes(name.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))));
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
alert('Failed to create session: ' + err.message);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async function killSession(name) {
|
|
1292
|
+
if (!confirm(`Kill session "${name}"?`)) return;
|
|
1293
|
+
await fetch(`/api/sessions/${name}`, { method: 'DELETE' });
|
|
1294
|
+
if (selectedSession === name) {
|
|
1295
|
+
selectedSession = null;
|
|
1296
|
+
renderMain();
|
|
1297
|
+
}
|
|
1298
|
+
await fetchSessions();
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function focusTerminal() {
|
|
1302
|
+
if (xterm) xterm.focus();
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
// ── Rendering ─────────────────────────────────────────────────────────
|
|
1307
|
+
|
|
1308
|
+
function renderSessionList() {
|
|
1309
|
+
const container = document.getElementById('session-list');
|
|
1310
|
+
|
|
1311
|
+
if (sessions.length === 0) {
|
|
1312
|
+
container.innerHTML = '<div class="no-sessions">No active sessions.<br>Create one to get started.</div>';
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
container.innerHTML = sessions.map(s => {
|
|
1317
|
+
const totalPanes = s.windows.reduce((sum, w) => sum + w.panes.length, 0);
|
|
1318
|
+
const isSelected = selectedSession === s.name;
|
|
1319
|
+
const meta = s.metadata || {};
|
|
1320
|
+
const tags = (meta.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('');
|
|
1321
|
+
// Detect Claude from actual running processes across all windows/panes,
|
|
1322
|
+
// not just from the stored creation command
|
|
1323
|
+
const isClaude = (meta.command && meta.command.includes('claude')) ||
|
|
1324
|
+
s.windows.some(w => w.panes.some(p => p.currentCommand && p.currentCommand.includes('claude')));
|
|
1325
|
+
|
|
1326
|
+
return `
|
|
1327
|
+
<div class="session-card ${isSelected ? 'selected' : ''}" onclick="selectSession(${JSON.stringify(s).replace(/"/g, '"')})">
|
|
1328
|
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
1329
|
+
<div class="session-card-name">${isClaude ? '<span class="badge claude" style="margin-right:6px">Claude</span>' : ''}${escapeHtml(s.name)}</div>
|
|
1330
|
+
<button class="btn danger small" onclick="event.stopPropagation();killSession('${s.name}')">Kill</button>
|
|
1331
|
+
</div>
|
|
1332
|
+
<div class="session-card-meta">
|
|
1333
|
+
<span class="badge ${s.isAttached ? 'attached' : 'detached'}">${s.isAttached ? 'attached' : 'detached'}</span>
|
|
1334
|
+
<span>${s.windows.length} win</span>
|
|
1335
|
+
<span>${totalPanes} pane${totalPanes !== 1 ? 's' : ''}</span>
|
|
1336
|
+
</div>
|
|
1337
|
+
${tags ? `<div class="session-card-tags">${tags}</div>` : ''}
|
|
1338
|
+
</div>
|
|
1339
|
+
`;
|
|
1340
|
+
}).join('');
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
async function selectSession(session) {
|
|
1344
|
+
closeSidebarIfMobile();
|
|
1345
|
+
unsubscribePane();
|
|
1346
|
+
selectedSession = session.name;
|
|
1347
|
+
selectedPane = '0.0';
|
|
1348
|
+
renderSessionList();
|
|
1349
|
+
renderMain(session);
|
|
1350
|
+
await initTerminal();
|
|
1351
|
+
subscribeToPane(selectedSession, selectedPane);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function selectPane(paneId) {
|
|
1355
|
+
selectedPane = paneId;
|
|
1356
|
+
document.querySelectorAll('.pane-tab').forEach(el => {
|
|
1357
|
+
el.classList.toggle('active', el.dataset.pane === paneId);
|
|
1358
|
+
});
|
|
1359
|
+
if (xterm) xterm.reset();
|
|
1360
|
+
renderPromptActions(null);
|
|
1361
|
+
// No explicit unsubscribe needed — the server's subscribe handler
|
|
1362
|
+
// cleans up the old subscription internally, which avoids destroying
|
|
1363
|
+
// the control client when switching panes within the same session.
|
|
1364
|
+
subscribeToPane(selectedSession, selectedPane);
|
|
1365
|
+
if (xterm) fitAndResize();
|
|
1366
|
+
// REST fallback: fetch content directly in case the WS subscribe
|
|
1367
|
+
// response is delayed or lost due to async handler interleaving.
|
|
1368
|
+
fetchPaneContent(selectedSession, selectedPane);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function fetchPaneContent(session, pane) {
|
|
1372
|
+
try {
|
|
1373
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(session)}/capture/${encodeURIComponent(pane)}?lines=80`);
|
|
1374
|
+
if (!res.ok) return;
|
|
1375
|
+
const output = await res.json();
|
|
1376
|
+
// Only apply if this is still the active pane (user might have switched again)
|
|
1377
|
+
if (selectedSession === session && selectedPane === pane && output.content) {
|
|
1378
|
+
updateTerminal(output.content);
|
|
1379
|
+
}
|
|
1380
|
+
} catch {}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function renderMain(session) {
|
|
1384
|
+
const main = document.getElementById('main');
|
|
1385
|
+
|
|
1386
|
+
if (!session) {
|
|
1387
|
+
main.innerHTML = `
|
|
1388
|
+
<div class="main-empty">
|
|
1389
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
1390
|
+
<rect x="2" y="3" width="20" height="18" rx="2"/>
|
|
1391
|
+
<path d="M7 9l3 3-3 3M13 15h4"/>
|
|
1392
|
+
</svg>
|
|
1393
|
+
<div>Select a session or create a new one</div>
|
|
1394
|
+
</div>`;
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Build pane tabs
|
|
1399
|
+
const paneTabs = [];
|
|
1400
|
+
for (const win of session.windows) {
|
|
1401
|
+
for (const pane of win.panes) {
|
|
1402
|
+
const pid = `${win.index}.${pane.index}`;
|
|
1403
|
+
paneTabs.push(`
|
|
1404
|
+
<div class="pane-tab ${pid === selectedPane ? 'active' : ''}"
|
|
1405
|
+
data-pane="${pid}"
|
|
1406
|
+
onclick="selectPane('${pid}')">
|
|
1407
|
+
${win.name}:${pane.index}
|
|
1408
|
+
<span class="pane-tab-close" onclick="event.stopPropagation();selectedPane='${pid}';closeWindowUI(${win.index})" title="Close">×</span>
|
|
1409
|
+
</div>
|
|
1410
|
+
`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
main.innerHTML = `
|
|
1415
|
+
<div class="toolbar">
|
|
1416
|
+
<h3>${escapeHtml(session.name)}</h3>
|
|
1417
|
+
<div class="pane-selector">${paneTabs.join('')}</div>
|
|
1418
|
+
<button class="btn small" onclick="addWindowUI()">+ Window</button>
|
|
1419
|
+
<button class="btn small" onclick="showRelayModal()">Relay</button>
|
|
1420
|
+
<a class="btn small" href="/api/sessions/${encodeURIComponent(session.name)}/export" download="${session.name}.md" style="text-decoration:none">Export</a>
|
|
1421
|
+
</div>
|
|
1422
|
+
|
|
1423
|
+
<div class="session-details">
|
|
1424
|
+
<div class="detail-item">
|
|
1425
|
+
<span class="detail-label">Status:</span>
|
|
1426
|
+
<span class="badge ${session.isAttached ? 'attached' : 'detached'}">${session.isAttached ? 'attached' : 'detached'}</span>
|
|
1427
|
+
</div>
|
|
1428
|
+
<div class="detail-item">
|
|
1429
|
+
<span class="detail-label">Created:</span>
|
|
1430
|
+
<span>${new Date(session.createdAt).toLocaleString()}</span>
|
|
1431
|
+
</div>
|
|
1432
|
+
${session.metadata?.projectPath ? `<div class="detail-item"><span class="detail-label">Path:</span><span>${escapeHtml(session.metadata.projectPath)}</span></div>` : ''}
|
|
1433
|
+
</div>
|
|
1434
|
+
|
|
1435
|
+
<div class="terminal-wrapper">
|
|
1436
|
+
<div id="terminal-container"></div>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1439
|
+
<div id="prompt-actions" class="prompt-actions" style="display:none"></div>
|
|
1440
|
+
|
|
1441
|
+
<div class="terminal-status-bar">
|
|
1442
|
+
<span class="terminal-status-hint">Terminal input active — type directly</span>
|
|
1443
|
+
<button class="btn small" onclick="focusTerminal()">Focus Terminal</button>
|
|
1444
|
+
</div>
|
|
1445
|
+
`;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
async function initTerminal() {
|
|
1449
|
+
if (xterm) {
|
|
1450
|
+
xterm.dispose();
|
|
1451
|
+
xterm = null;
|
|
1452
|
+
fitAddon = null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const container = document.getElementById('terminal-container');
|
|
1456
|
+
if (!container) return;
|
|
1457
|
+
|
|
1458
|
+
// Wait for nerd font to load so glyphs render correctly
|
|
1459
|
+
try {
|
|
1460
|
+
await document.fonts.load("14px 'JetBrainsMono NF'");
|
|
1461
|
+
} catch {}
|
|
1462
|
+
|
|
1463
|
+
xterm = new Terminal({
|
|
1464
|
+
theme: {
|
|
1465
|
+
background: '#121113',
|
|
1466
|
+
foreground: '#eeeef0',
|
|
1467
|
+
cursor: '#ffb224',
|
|
1468
|
+
cursorAccent: '#121113',
|
|
1469
|
+
selectionBackground: 'rgba(255, 178, 36, 0.18)',
|
|
1470
|
+
black: '#454245',
|
|
1471
|
+
red: '#ff6369',
|
|
1472
|
+
green: '#63c174',
|
|
1473
|
+
yellow: '#ffb224',
|
|
1474
|
+
blue: '#70b8ff',
|
|
1475
|
+
magenta: '#baa7ff',
|
|
1476
|
+
cyan: '#4ccba0',
|
|
1477
|
+
white: '#b5b2bc',
|
|
1478
|
+
brightBlack: '#65626a',
|
|
1479
|
+
brightRed: '#ff8b8b',
|
|
1480
|
+
brightGreen: '#7dd191',
|
|
1481
|
+
brightYellow: '#ffcb47',
|
|
1482
|
+
brightBlue: '#9dd2ff',
|
|
1483
|
+
brightMagenta: '#d4c4ff',
|
|
1484
|
+
brightCyan: '#6edcb8',
|
|
1485
|
+
brightWhite: '#eeeef0',
|
|
1486
|
+
},
|
|
1487
|
+
fontFamily: "'JetBrainsMono NF', 'Geist Mono', 'SF Mono', 'Cascadia Code', monospace",
|
|
1488
|
+
fontSize: 13,
|
|
1489
|
+
lineHeight: 1.2,
|
|
1490
|
+
cursorStyle: 'block',
|
|
1491
|
+
cursorBlink: false,
|
|
1492
|
+
cursorInactiveStyle: 'none',
|
|
1493
|
+
scrollback: 1000,
|
|
1494
|
+
convertEol: true,
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
fitAddon = new FitAddon.FitAddon();
|
|
1498
|
+
xterm.loadAddon(fitAddon);
|
|
1499
|
+
xterm.open(container);
|
|
1500
|
+
// Delay fit() so the browser finishes layout and the container has its real width
|
|
1501
|
+
requestAnimationFrame(() => { fitAndResize(); });
|
|
1502
|
+
|
|
1503
|
+
// Direct keyboard input → tmux pane
|
|
1504
|
+
// Batch keystrokes into ~8ms windows to avoid per-char process spawns
|
|
1505
|
+
let inputBuffer = '';
|
|
1506
|
+
let inputFlushTimer = null;
|
|
1507
|
+
|
|
1508
|
+
xterm.onData((data) => {
|
|
1509
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || !selectedSession) return;
|
|
1510
|
+
inputBuffer += data;
|
|
1511
|
+
if (!inputFlushTimer) {
|
|
1512
|
+
inputFlushTimer = setTimeout(() => {
|
|
1513
|
+
const batch = inputBuffer;
|
|
1514
|
+
inputBuffer = '';
|
|
1515
|
+
inputFlushTimer = null;
|
|
1516
|
+
ws.send(JSON.stringify({
|
|
1517
|
+
type: 'input',
|
|
1518
|
+
session: selectedSession,
|
|
1519
|
+
pane: selectedPane,
|
|
1520
|
+
data: batch,
|
|
1521
|
+
}));
|
|
1522
|
+
}, 8);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// Focus terminal immediately
|
|
1527
|
+
xterm.focus();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function updateTerminal(content) {
|
|
1531
|
+
if (!xterm) return;
|
|
1532
|
+
xterm.reset();
|
|
1533
|
+
const trimmed = trimTrailingEmptyLines(content);
|
|
1534
|
+
xterm.write(trimmed);
|
|
1535
|
+
const prompt = detectPrompt(content);
|
|
1536
|
+
renderPromptActions(prompt);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function trimTrailingEmptyLines(text) {
|
|
1540
|
+
const lines = text.split('\n');
|
|
1541
|
+
while (lines.length > 1 && lines[lines.length - 1].trim() === '') {
|
|
1542
|
+
lines.pop();
|
|
1543
|
+
}
|
|
1544
|
+
return lines.join('\n');
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// ── Prompt Detection ────────────────────────────────────────────────
|
|
1548
|
+
|
|
1549
|
+
let lastPromptKey = null;
|
|
1550
|
+
|
|
1551
|
+
function stripAnsi(text) {
|
|
1552
|
+
return text
|
|
1553
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
1554
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') // OSC sequences
|
|
1555
|
+
.replace(/\x1b[()][0-9A-B]/g, '') // charset
|
|
1556
|
+
.replace(/\x1b\[?[0-9;]*[Hf]/g, '') // cursor positioning
|
|
1557
|
+
.replace(/\x1b[78]/g, '') // cursor save/restore
|
|
1558
|
+
.replace(/\r/g, ''); // carriage return
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function detectPrompt(content) {
|
|
1562
|
+
const plain = stripAnsi(content);
|
|
1563
|
+
const allLines = plain.split('\n');
|
|
1564
|
+
// Get last 20 non-empty lines
|
|
1565
|
+
const lines = allLines.filter(l => l.trim().length > 0).slice(-20);
|
|
1566
|
+
if (lines.length === 0) return null;
|
|
1567
|
+
|
|
1568
|
+
const tail = lines.slice(-6).join('\n').toLowerCase();
|
|
1569
|
+
const tailOriginal = lines.slice(-6).join('\n');
|
|
1570
|
+
|
|
1571
|
+
// Priority 1: Permission prompt (allow/deny with y/n/a)
|
|
1572
|
+
if ((tail.includes('allow') && tail.includes('always')) ||
|
|
1573
|
+
(tail.includes('allow') && tail.includes('deny'))) {
|
|
1574
|
+
return { type: 'permission', label: 'Permission Request' };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Priority 2: Simple yes/no
|
|
1578
|
+
const lastLine = lines[lines.length - 1];
|
|
1579
|
+
const ynMatch = lastLine.match(/\((y\/n)\)|\[(Y\/n)\]|\[(y\/N)\]/i);
|
|
1580
|
+
if (ynMatch) {
|
|
1581
|
+
return { type: 'yesno', label: 'Confirmation' };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Priority 3: Numbered choices on separate lines (e.g. "❯ 1. Yes\n 2. No\n 3. Always")
|
|
1585
|
+
// Claude Code shows: " ❯ 1. Option" for selected, " 2. Option" for others
|
|
1586
|
+
const choicePattern = /(?:❯\s*)?(\d+)\.\s+(.+)/;
|
|
1587
|
+
const options = [];
|
|
1588
|
+
let selectedNum = null;
|
|
1589
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 10); i--) {
|
|
1590
|
+
const line = lines[i].trim();
|
|
1591
|
+
const m = line.match(choicePattern);
|
|
1592
|
+
if (m) {
|
|
1593
|
+
const num = parseInt(m[1]);
|
|
1594
|
+
const text = m[2].trim();
|
|
1595
|
+
if (line.startsWith('❯')) selectedNum = num;
|
|
1596
|
+
options.unshift({ num, text });
|
|
1597
|
+
} else if (options.length > 0) {
|
|
1598
|
+
// Stop scanning once we hit a non-option line above collected options
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
if (options.length >= 2) {
|
|
1603
|
+
return {
|
|
1604
|
+
type: 'choice',
|
|
1605
|
+
label: 'Select Option',
|
|
1606
|
+
options: options,
|
|
1607
|
+
selectedNum: selectedNum || options[0].num,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Priority 4: Press Enter to continue
|
|
1612
|
+
if (tail.includes('press enter') || tail.includes('hit enter') || tail.includes('press ↵')) {
|
|
1613
|
+
return { type: 'continue', label: 'Continue' };
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function renderPromptActions(prompt) {
|
|
1620
|
+
const container = document.getElementById('prompt-actions');
|
|
1621
|
+
if (!container) return;
|
|
1622
|
+
|
|
1623
|
+
if (!prompt) {
|
|
1624
|
+
container.style.display = 'none';
|
|
1625
|
+
lastPromptKey = null;
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Avoid unnecessary re-renders
|
|
1630
|
+
const key = prompt.type + ':' + (prompt.options ? prompt.options.map(o => o.text).join(',') : '');
|
|
1631
|
+
if (key === lastPromptKey) return;
|
|
1632
|
+
lastPromptKey = key;
|
|
1633
|
+
|
|
1634
|
+
container.style.display = '';
|
|
1635
|
+
container.innerHTML = '';
|
|
1636
|
+
|
|
1637
|
+
const label = document.createElement('span');
|
|
1638
|
+
label.className = 'prompt-label';
|
|
1639
|
+
label.textContent = prompt.label + ':';
|
|
1640
|
+
container.appendChild(label);
|
|
1641
|
+
|
|
1642
|
+
if (prompt.type === 'permission') {
|
|
1643
|
+
container.appendChild(makeBtn('Yes', 'prompt-yes', () => sendPromptResponse('permission', 'y')));
|
|
1644
|
+
container.appendChild(makeBtn('No', 'prompt-no', () => sendPromptResponse('permission', 'n')));
|
|
1645
|
+
container.appendChild(makeBtn('Always', 'prompt-always', () => sendPromptResponse('permission', 'a')));
|
|
1646
|
+
const hint = document.createElement('span');
|
|
1647
|
+
hint.className = 'prompt-hint';
|
|
1648
|
+
hint.textContent = 'y / n / a';
|
|
1649
|
+
container.appendChild(hint);
|
|
1650
|
+
} else if (prompt.type === 'yesno') {
|
|
1651
|
+
container.appendChild(makeBtn('Yes', 'prompt-yes', () => sendPromptResponse('yesno', 'y')));
|
|
1652
|
+
container.appendChild(makeBtn('No', 'prompt-no', () => sendPromptResponse('yesno', 'n')));
|
|
1653
|
+
const hint = document.createElement('span');
|
|
1654
|
+
hint.className = 'prompt-hint';
|
|
1655
|
+
hint.textContent = 'y / n';
|
|
1656
|
+
container.appendChild(hint);
|
|
1657
|
+
} else if (prompt.type === 'choice') {
|
|
1658
|
+
prompt.options.forEach((opt) => {
|
|
1659
|
+
const isSelected = opt.num === prompt.selectedNum;
|
|
1660
|
+
const btn = makeBtn(opt.num + '. ' + opt.text, 'prompt-choice' + (isSelected ? ' selected' : ''), () => {
|
|
1661
|
+
sendPromptResponse('choice', { targetNum: opt.num, selectedNum: prompt.selectedNum, options: prompt.options });
|
|
1662
|
+
});
|
|
1663
|
+
container.appendChild(btn);
|
|
1664
|
+
});
|
|
1665
|
+
} else if (prompt.type === 'continue') {
|
|
1666
|
+
container.appendChild(makeBtn('Continue (Enter)', 'prompt-yes', () => sendPromptResponse('continue', null)));
|
|
1667
|
+
const hint = document.createElement('span');
|
|
1668
|
+
hint.className = 'prompt-hint';
|
|
1669
|
+
hint.textContent = 'Press Enter to continue';
|
|
1670
|
+
container.appendChild(hint);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function makeBtn(text, className, onclick) {
|
|
1675
|
+
const btn = document.createElement('button');
|
|
1676
|
+
btn.className = 'btn small ' + className;
|
|
1677
|
+
btn.textContent = text;
|
|
1678
|
+
btn.onclick = onclick;
|
|
1679
|
+
return btn;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function sendPromptResponse(promptType, action) {
|
|
1683
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || !selectedSession) return;
|
|
1684
|
+
|
|
1685
|
+
// Optimistic UI: hide prompt bar immediately
|
|
1686
|
+
const container = document.getElementById('prompt-actions');
|
|
1687
|
+
if (container) container.style.display = 'none';
|
|
1688
|
+
lastPromptKey = null;
|
|
1689
|
+
|
|
1690
|
+
if (promptType === 'permission' || promptType === 'yesno') {
|
|
1691
|
+
// Send single char without Enter
|
|
1692
|
+
ws.send(JSON.stringify({
|
|
1693
|
+
type: 'send',
|
|
1694
|
+
session: selectedSession,
|
|
1695
|
+
pane: selectedPane,
|
|
1696
|
+
text: action,
|
|
1697
|
+
noEnter: true,
|
|
1698
|
+
}));
|
|
1699
|
+
} else if (promptType === 'continue') {
|
|
1700
|
+
ws.send(JSON.stringify({
|
|
1701
|
+
type: 'special-key',
|
|
1702
|
+
session: selectedSession,
|
|
1703
|
+
pane: selectedPane,
|
|
1704
|
+
key: 'Enter',
|
|
1705
|
+
}));
|
|
1706
|
+
} else if (promptType === 'choice') {
|
|
1707
|
+
const { targetNum, selectedNum, options } = action;
|
|
1708
|
+
const selectedIdx = options.findIndex(o => o.num === selectedNum);
|
|
1709
|
+
const targetIdx = options.findIndex(o => o.num === targetNum);
|
|
1710
|
+
const steps = targetIdx - selectedIdx;
|
|
1711
|
+
|
|
1712
|
+
if (steps === 0) {
|
|
1713
|
+
ws.send(JSON.stringify({
|
|
1714
|
+
type: 'special-key',
|
|
1715
|
+
session: selectedSession,
|
|
1716
|
+
pane: selectedPane,
|
|
1717
|
+
key: 'Enter',
|
|
1718
|
+
}));
|
|
1719
|
+
} else {
|
|
1720
|
+
const direction = steps > 0 ? 'Down' : 'Up';
|
|
1721
|
+
const count = Math.abs(steps);
|
|
1722
|
+
let sent = 0;
|
|
1723
|
+
const interval = setInterval(() => {
|
|
1724
|
+
ws.send(JSON.stringify({
|
|
1725
|
+
type: 'special-key',
|
|
1726
|
+
session: selectedSession,
|
|
1727
|
+
pane: selectedPane,
|
|
1728
|
+
key: direction,
|
|
1729
|
+
}));
|
|
1730
|
+
sent++;
|
|
1731
|
+
if (sent >= count) {
|
|
1732
|
+
clearInterval(interval);
|
|
1733
|
+
setTimeout(() => {
|
|
1734
|
+
ws.send(JSON.stringify({
|
|
1735
|
+
type: 'special-key',
|
|
1736
|
+
session: selectedSession,
|
|
1737
|
+
pane: selectedPane,
|
|
1738
|
+
key: 'Enter',
|
|
1739
|
+
}));
|
|
1740
|
+
}, 80);
|
|
1741
|
+
}
|
|
1742
|
+
}, 80);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// ── Window Management ─────────────────────────────────────────────────
|
|
1748
|
+
|
|
1749
|
+
async function addWindowUI() {
|
|
1750
|
+
if (!selectedSession) return;
|
|
1751
|
+
const name = prompt('Window name:');
|
|
1752
|
+
if (!name) return;
|
|
1753
|
+
const command = prompt('Command for new window (leave empty for shell):');
|
|
1754
|
+
try {
|
|
1755
|
+
const res = await fetch(`/api/sessions/${selectedSession}/windows`, {
|
|
1756
|
+
method: 'POST',
|
|
1757
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1758
|
+
body: JSON.stringify({ windowName: name, command: command || undefined }),
|
|
1759
|
+
});
|
|
1760
|
+
if (!res.ok) { const err = await res.json(); throw new Error(err.error); }
|
|
1761
|
+
await fetchSessions();
|
|
1762
|
+
const session = sessions.find(s => s.name === selectedSession);
|
|
1763
|
+
if (session) {
|
|
1764
|
+
renderMain(session);
|
|
1765
|
+
await initTerminal();
|
|
1766
|
+
subscribeToPane(selectedSession, selectedPane);
|
|
1767
|
+
}
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
alert('Failed to add window: ' + err.message);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
async function closeWindowUI(windowIndex) {
|
|
1774
|
+
if (!selectedSession) return;
|
|
1775
|
+
const session = sessions.find(s => s.name === selectedSession);
|
|
1776
|
+
if (!session) return;
|
|
1777
|
+
const totalPanes = session.windows.reduce((sum, w) => sum + w.panes.length, 0);
|
|
1778
|
+
if (totalPanes <= 1) { alert('Cannot close the last pane.'); return; }
|
|
1779
|
+
if (!confirm('Close this pane?')) return;
|
|
1780
|
+
try {
|
|
1781
|
+
const res = await fetch(`/api/sessions/${selectedSession}/panes/${selectedPane}`, { method: 'DELETE' });
|
|
1782
|
+
if (!res.ok) { const err = await res.json(); throw new Error(err.error); }
|
|
1783
|
+
await fetchSessions();
|
|
1784
|
+
const updated = sessions.find(s => s.name === selectedSession);
|
|
1785
|
+
if (updated) {
|
|
1786
|
+
selectedPane = updated.windows[0]?.panes[0] ? `${updated.windows[0].index}.${updated.windows[0].panes[0].index}` : '0.0';
|
|
1787
|
+
renderMain(updated);
|
|
1788
|
+
await initTerminal();
|
|
1789
|
+
subscribeToPane(selectedSession, selectedPane);
|
|
1790
|
+
}
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
alert('Failed to close pane: ' + err.message);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// ── Relay ─────────────────────────────────────────────────────────────
|
|
1797
|
+
|
|
1798
|
+
function showRelayModal() {
|
|
1799
|
+
if (!selectedSession) return;
|
|
1800
|
+
const session = sessions.find(s => s.name === selectedSession);
|
|
1801
|
+
if (!session) return;
|
|
1802
|
+
|
|
1803
|
+
const paneIds = [];
|
|
1804
|
+
for (const win of session.windows) {
|
|
1805
|
+
for (const pane of win.panes) {
|
|
1806
|
+
paneIds.push(`${win.index}.${pane.index}`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (paneIds.length < 2) return alert('Need at least 2 panes to relay between');
|
|
1810
|
+
|
|
1811
|
+
relayFrom = selectedPane;
|
|
1812
|
+
relayTo = null;
|
|
1813
|
+
relayMode = 'capture';
|
|
1814
|
+
|
|
1815
|
+
renderRelayPanes(session);
|
|
1816
|
+
setRelayMode('capture');
|
|
1817
|
+
document.getElementById('relay-preview').style.display = 'none';
|
|
1818
|
+
document.getElementById('relay-preview').textContent = '';
|
|
1819
|
+
document.getElementById('relay-message').value = '';
|
|
1820
|
+
document.getElementById('relay-lines').value = '50';
|
|
1821
|
+
document.getElementById('relay-modal').classList.remove('hidden');
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function hideRelayModal() {
|
|
1825
|
+
document.getElementById('relay-modal').classList.add('hidden');
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function renderRelayPanes(session) {
|
|
1829
|
+
const fromGrid = document.getElementById('relay-from-grid');
|
|
1830
|
+
const toGrid = document.getElementById('relay-to-grid');
|
|
1831
|
+
fromGrid.innerHTML = '';
|
|
1832
|
+
toGrid.innerHTML = '';
|
|
1833
|
+
|
|
1834
|
+
for (const win of session.windows) {
|
|
1835
|
+
for (const pane of win.panes) {
|
|
1836
|
+
const pid = `${win.index}.${pane.index}`;
|
|
1837
|
+
const cmd = pane.currentCommand || 'shell';
|
|
1838
|
+
|
|
1839
|
+
// FROM tile
|
|
1840
|
+
const fromTile = document.createElement('div');
|
|
1841
|
+
fromTile.className = 'relay-pane-tile' + (pid === relayFrom ? ' selected' : '');
|
|
1842
|
+
fromTile.dataset.pane = pid;
|
|
1843
|
+
fromTile.innerHTML = `<div class="tile-label">${escapeHtml(win.name)}:${pane.index}</div><div class="tile-cmd">${escapeHtml(cmd)}</div>`;
|
|
1844
|
+
fromTile.onclick = () => selectRelayPane(pid, 'from');
|
|
1845
|
+
fromGrid.appendChild(fromTile);
|
|
1846
|
+
|
|
1847
|
+
// TO tile
|
|
1848
|
+
const toTile = document.createElement('div');
|
|
1849
|
+
toTile.className = 'relay-pane-tile' + (pid === relayTo ? ' selected' : '') + (pid === relayFrom ? ' disabled' : '');
|
|
1850
|
+
toTile.dataset.pane = pid;
|
|
1851
|
+
toTile.innerHTML = `<div class="tile-label">${escapeHtml(win.name)}:${pane.index}</div><div class="tile-cmd">${escapeHtml(cmd)}</div>`;
|
|
1852
|
+
toTile.onclick = () => { if (pid !== relayFrom) selectRelayPane(pid, 'to'); };
|
|
1853
|
+
toGrid.appendChild(toTile);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function selectRelayPane(paneId, role) {
|
|
1859
|
+
if (role === 'from') {
|
|
1860
|
+
relayFrom = paneId;
|
|
1861
|
+
if (relayTo === paneId) relayTo = null;
|
|
1862
|
+
} else {
|
|
1863
|
+
relayTo = paneId;
|
|
1864
|
+
}
|
|
1865
|
+
updateRelayPaneStates();
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function updateRelayPaneStates() {
|
|
1869
|
+
document.querySelectorAll('#relay-from-grid .relay-pane-tile').forEach(el => {
|
|
1870
|
+
el.classList.toggle('selected', el.dataset.pane === relayFrom);
|
|
1871
|
+
});
|
|
1872
|
+
document.querySelectorAll('#relay-to-grid .relay-pane-tile').forEach(el => {
|
|
1873
|
+
el.classList.toggle('selected', el.dataset.pane === relayTo);
|
|
1874
|
+
el.classList.toggle('disabled', el.dataset.pane === relayFrom);
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function setRelayMode(mode) {
|
|
1879
|
+
relayMode = mode;
|
|
1880
|
+
document.querySelectorAll('.relay-mode-btn').forEach((btn, i) => {
|
|
1881
|
+
btn.classList.toggle('active', (i === 0 && mode === 'capture') || (i === 1 && mode === 'message'));
|
|
1882
|
+
});
|
|
1883
|
+
document.getElementById('relay-capture-controls').style.display = mode === 'capture' ? '' : 'none';
|
|
1884
|
+
document.getElementById('relay-message-controls').style.display = mode === 'message' ? '' : 'none';
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function previewRelayCapture() {
|
|
1888
|
+
if (!relayFrom || !selectedSession) return;
|
|
1889
|
+
const lines = parseInt(document.getElementById('relay-lines').value) || 50;
|
|
1890
|
+
const previewEl = document.getElementById('relay-preview');
|
|
1891
|
+
previewEl.style.display = '';
|
|
1892
|
+
previewEl.textContent = 'Loading...';
|
|
1893
|
+
|
|
1894
|
+
try {
|
|
1895
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(selectedSession)}/capture/${encodeURIComponent(relayFrom)}?lines=${lines}`);
|
|
1896
|
+
if (!res.ok) { previewEl.textContent = 'Failed to capture'; return; }
|
|
1897
|
+
const data = await res.json();
|
|
1898
|
+
previewEl.textContent = stripAnsi(data.content || '(empty)');
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
previewEl.textContent = 'Error: ' + err.message;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
async function executeRelay() {
|
|
1905
|
+
if (!selectedSession || !relayFrom || !relayTo) {
|
|
1906
|
+
return alert('Select both FROM and TO panes');
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
try {
|
|
1910
|
+
const body = { from: relayFrom, to: relayTo };
|
|
1911
|
+
if (relayMode === 'capture') {
|
|
1912
|
+
body.capture = true;
|
|
1913
|
+
body.lines = parseInt(document.getElementById('relay-lines').value) || 50;
|
|
1914
|
+
} else {
|
|
1915
|
+
const msg = document.getElementById('relay-message').value.trim();
|
|
1916
|
+
if (!msg) return alert('Enter a message to send');
|
|
1917
|
+
body.message = msg;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(selectedSession)}/relay`, {
|
|
1921
|
+
method: 'POST',
|
|
1922
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1923
|
+
body: JSON.stringify(body),
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
if (!res.ok) {
|
|
1927
|
+
const err = await res.json().catch(() => ({}));
|
|
1928
|
+
alert('Relay failed: ' + (err.error || res.statusText));
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
hideRelayModal();
|
|
1933
|
+
showToast(`Relayed ${relayMode === 'capture' ? 'output' : 'message'} from ${relayFrom} to ${relayTo}`);
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
alert('Relay failed: ' + err.message);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// ── Settings ──────────────────────────────────────────────────────────
|
|
1940
|
+
|
|
1941
|
+
async function showSettingsUI() {
|
|
1942
|
+
const main = document.getElementById('main');
|
|
1943
|
+
main.innerHTML = '<div style="padding:24px;color:var(--text-dim)">Loading settings...</div>';
|
|
1944
|
+
|
|
1945
|
+
try {
|
|
1946
|
+
const res = await fetch('/api/settings');
|
|
1947
|
+
const data = await res.json();
|
|
1948
|
+
renderSettingsView(data);
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
main.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load settings: ' + err.message + '</div>';
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function renderSettingsView(data) {
|
|
1955
|
+
const main = document.getElementById('main');
|
|
1956
|
+
const { config, server: serverInfo } = data;
|
|
1957
|
+
const whitelist = config.ipWhitelist || [];
|
|
1958
|
+
|
|
1959
|
+
const needsHostWarning = serverInfo.host === '127.0.0.1' && whitelist.length > 0;
|
|
1960
|
+
|
|
1961
|
+
let ipListHtml;
|
|
1962
|
+
if (whitelist.length === 0) {
|
|
1963
|
+
ipListHtml = '<div class="ip-list-empty">No IPs in whitelist. Only localhost can access this server.</div>';
|
|
1964
|
+
} else {
|
|
1965
|
+
ipListHtml = '<div class="ip-list">' + whitelist.map(ip =>
|
|
1966
|
+
`<div class="ip-entry">
|
|
1967
|
+
<span class="ip-entry-addr">${escapeHtml(ip)}</span>
|
|
1968
|
+
<button class="btn danger small" onclick="removeWhitelistIP('${escapeHtml(ip)}')">Remove</button>
|
|
1969
|
+
</div>`
|
|
1970
|
+
).join('') + '</div>';
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
main.innerHTML = `
|
|
1974
|
+
<div class="settings-section">
|
|
1975
|
+
<h3>Settings</h3>
|
|
1976
|
+
|
|
1977
|
+
<div class="settings-server-info">
|
|
1978
|
+
<div class="detail-item">
|
|
1979
|
+
<span class="detail-label">Host:</span>
|
|
1980
|
+
<span style="font-family:var(--font-mono)">${escapeHtml(serverInfo.host)}</span>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div class="detail-item">
|
|
1983
|
+
<span class="detail-label">Port:</span>
|
|
1984
|
+
<span style="font-family:var(--font-mono)">${serverInfo.port}</span>
|
|
1985
|
+
</div>
|
|
1986
|
+
</div>
|
|
1987
|
+
|
|
1988
|
+
${needsHostWarning ? `
|
|
1989
|
+
<div class="settings-info-box">
|
|
1990
|
+
The server is bound to <code>127.0.0.1</code> (localhost only). Whitelisted IPs won't be able to connect until you start the server with <code>HOST=0.0.0.0</code> to listen on all interfaces.
|
|
1991
|
+
</div>
|
|
1992
|
+
` : ''}
|
|
1993
|
+
|
|
1994
|
+
<h4 style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--gray-11)">IP Whitelist</h4>
|
|
1995
|
+
<p style="font-size:12px;color:var(--gray-9);margin-bottom:12px">Allow specific IPs or CIDR ranges (e.g. <code style="background:var(--gray-3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono);font-size:11px">192.168.1.0/24</code>) to access this server. Localhost is always allowed.</p>
|
|
1996
|
+
|
|
1997
|
+
<div class="ip-add-row">
|
|
1998
|
+
<input type="text" id="new-ip" placeholder="e.g. 192.168.1.100 or 10.0.0.0/8" onkeydown="if(event.key==='Enter')addWhitelistIP()">
|
|
1999
|
+
<button class="btn primary" onclick="addWhitelistIP()">Add IP</button>
|
|
2000
|
+
</div>
|
|
2001
|
+
|
|
2002
|
+
<div id="ip-error" style="display:none;font-size:12px;color:var(--red-11);margin-bottom:12px"></div>
|
|
2003
|
+
|
|
2004
|
+
${ipListHtml}
|
|
2005
|
+
</div>`;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function validateIPClient(value) {
|
|
2009
|
+
value = value.trim();
|
|
2010
|
+
if (!value) return 'IP address is required';
|
|
2011
|
+
|
|
2012
|
+
if (value.includes('/')) {
|
|
2013
|
+
const parts = value.split('/');
|
|
2014
|
+
if (parts.length !== 2) return 'Invalid CIDR notation';
|
|
2015
|
+
const [ip, prefix] = parts;
|
|
2016
|
+
if (!isValidIPv4(ip)) return 'Invalid IP address in CIDR range';
|
|
2017
|
+
const p = parseInt(prefix, 10);
|
|
2018
|
+
if (isNaN(p) || p < 0 || p > 32) return 'CIDR prefix must be 0-32';
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (!isValidIPv4(value) && !isValidIPv6(value)) {
|
|
2023
|
+
return 'Invalid IP address';
|
|
2024
|
+
}
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function isValidIPv4(ip) {
|
|
2029
|
+
const parts = ip.split('.');
|
|
2030
|
+
if (parts.length !== 4) return false;
|
|
2031
|
+
return parts.every(p => {
|
|
2032
|
+
const n = parseInt(p, 10);
|
|
2033
|
+
return !isNaN(n) && n >= 0 && n <= 255 && String(n) === p;
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function isValidIPv6(ip) {
|
|
2038
|
+
// Basic IPv6 check — defer full validation to server
|
|
2039
|
+
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
async function addWhitelistIP() {
|
|
2043
|
+
const input = document.getElementById('new-ip');
|
|
2044
|
+
const errorEl = document.getElementById('ip-error');
|
|
2045
|
+
const ip = input.value.trim();
|
|
2046
|
+
|
|
2047
|
+
const clientError = validateIPClient(ip);
|
|
2048
|
+
if (clientError) {
|
|
2049
|
+
errorEl.textContent = clientError;
|
|
2050
|
+
errorEl.style.display = '';
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
errorEl.style.display = 'none';
|
|
2054
|
+
|
|
2055
|
+
try {
|
|
2056
|
+
const res = await fetch('/api/settings/whitelist', {
|
|
2057
|
+
method: 'POST',
|
|
2058
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2059
|
+
body: JSON.stringify({ ip }),
|
|
2060
|
+
});
|
|
2061
|
+
if (!res.ok) {
|
|
2062
|
+
const err = await res.json();
|
|
2063
|
+
errorEl.textContent = err.error || 'Failed to add IP';
|
|
2064
|
+
errorEl.style.display = '';
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
input.value = '';
|
|
2068
|
+
showSettingsUI();
|
|
2069
|
+
showToast('Added ' + ip + ' to whitelist');
|
|
2070
|
+
} catch (err) {
|
|
2071
|
+
errorEl.textContent = 'Network error: ' + err.message;
|
|
2072
|
+
errorEl.style.display = '';
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function removeWhitelistIP(ip) {
|
|
2077
|
+
try {
|
|
2078
|
+
const res = await fetch('/api/settings/whitelist/' + encodeURIComponent(ip), {
|
|
2079
|
+
method: 'DELETE',
|
|
2080
|
+
});
|
|
2081
|
+
if (!res.ok) {
|
|
2082
|
+
const err = await res.json().catch(() => ({}));
|
|
2083
|
+
alert('Failed to remove IP: ' + (err.error || res.statusText));
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
showSettingsUI();
|
|
2087
|
+
showToast('Removed ' + ip + ' from whitelist');
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
alert('Failed to remove IP: ' + err.message);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// ── Templates ─────────────────────────────────────────────────────────
|
|
2094
|
+
|
|
2095
|
+
let availableTemplates = [];
|
|
2096
|
+
|
|
2097
|
+
async function fetchTemplates() {
|
|
2098
|
+
try {
|
|
2099
|
+
const res = await fetch('/api/templates');
|
|
2100
|
+
availableTemplates = await res.json();
|
|
2101
|
+
const select = document.getElementById('new-template');
|
|
2102
|
+
select.innerHTML = '<option value="">Custom (manual config)</option>';
|
|
2103
|
+
for (const t of availableTemplates) {
|
|
2104
|
+
const agents = t.agents?.length ? ` (${t.agents.length} agent${t.agents.length > 1 ? 's' : ''})` : '';
|
|
2105
|
+
select.innerHTML += `<option value="${escapeHtml(t.name)}">${escapeHtml(t.name)}${agents}</option>`;
|
|
2106
|
+
}
|
|
2107
|
+
} catch (err) {
|
|
2108
|
+
console.error('Failed to fetch templates:', err);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function onTemplateChange() {
|
|
2113
|
+
const template = document.getElementById('new-template').value;
|
|
2114
|
+
const customFields = document.getElementById('custom-fields');
|
|
2115
|
+
const descEl = document.getElementById('template-desc');
|
|
2116
|
+
|
|
2117
|
+
if (template) {
|
|
2118
|
+
const t = availableTemplates.find(x => x.name === template);
|
|
2119
|
+
const agents = t?.agents?.length ? `\nAgents: ${t.agents.map(a => a.role).join(', ')}` : '';
|
|
2120
|
+
const layout = t?.layout ? ` | Layout: ${t.layout}` : '';
|
|
2121
|
+
descEl.textContent = (t?.description || '') + layout + agents;
|
|
2122
|
+
customFields.style.display = 'none';
|
|
2123
|
+
} else {
|
|
2124
|
+
descEl.textContent = '';
|
|
2125
|
+
customFields.style.display = '';
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// ── Modal ─────────────────────────────────────────────────────────────
|
|
2130
|
+
|
|
2131
|
+
function showCreateModal() {
|
|
2132
|
+
fetchTemplates();
|
|
2133
|
+
document.getElementById('create-modal').classList.remove('hidden');
|
|
2134
|
+
document.getElementById('new-name').focus();
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function hideCreateModal() {
|
|
2138
|
+
document.getElementById('create-modal').classList.add('hidden');
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// Close modals on Escape
|
|
2142
|
+
document.addEventListener('keydown', (e) => {
|
|
2143
|
+
if (e.key === 'Escape') {
|
|
2144
|
+
hideCreateModal();
|
|
2145
|
+
hideRelayModal();
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
// ── Util ──────────────────────────────────────────────────────────────
|
|
2150
|
+
|
|
2151
|
+
function showToast(message) {
|
|
2152
|
+
const el = document.createElement('div');
|
|
2153
|
+
el.className = 'toast';
|
|
2154
|
+
el.textContent = message;
|
|
2155
|
+
document.body.appendChild(el);
|
|
2156
|
+
setTimeout(() => el.remove(), 3000);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function escapeHtml(str) {
|
|
2160
|
+
const div = document.createElement('div');
|
|
2161
|
+
div.textContent = str;
|
|
2162
|
+
return div.innerHTML;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// ── Resize & Visibility ───────────────────────────────────────────────
|
|
2166
|
+
|
|
2167
|
+
function fitAndResize() {
|
|
2168
|
+
if (!fitAddon || !xterm) return;
|
|
2169
|
+
fitAddon.fit();
|
|
2170
|
+
if (ws && ws.readyState === WebSocket.OPEN && selectedSession) {
|
|
2171
|
+
ws.send(JSON.stringify({
|
|
2172
|
+
type: 'resize',
|
|
2173
|
+
session: selectedSession,
|
|
2174
|
+
pane: selectedPane,
|
|
2175
|
+
cols: xterm.cols,
|
|
2176
|
+
rows: xterm.rows,
|
|
2177
|
+
}));
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
let resizeTimer = null;
|
|
2182
|
+
window.addEventListener('resize', () => {
|
|
2183
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2184
|
+
resizeTimer = setTimeout(fitAndResize, 100);
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
// Re-render terminal canvas when switching back from another tab/window.
|
|
2188
|
+
// xterm.js uses canvas which may not repaint while hidden.
|
|
2189
|
+
document.addEventListener('visibilitychange', () => {
|
|
2190
|
+
if (!document.hidden && xterm && fitAddon) {
|
|
2191
|
+
fitAndResize();
|
|
2192
|
+
xterm.refresh(0, xterm.rows - 1);
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
window.addEventListener('focus', () => {
|
|
2197
|
+
if (xterm && fitAddon) {
|
|
2198
|
+
fitAndResize();
|
|
2199
|
+
xterm.refresh(0, xterm.rows - 1);
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
// ── Init ──────────────────────────────────────────────────────────────
|
|
2204
|
+
|
|
2205
|
+
connectWS();
|
|
2206
|
+
fetchSessions();
|
|
2207
|
+
setInterval(fetchSessions, 5000); // Poll session list every 5s
|
|
2208
|
+
</script>
|
|
2209
|
+
</body>
|
|
2210
|
+
</html>
|