termbeam 0.0.9 → 0.1.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.
@@ -2,20 +2,14 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta
6
- name="viewport"
7
- content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
8
- />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
9
6
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
7
  <meta name="mobile-web-app-capable" content="yes" />
11
8
  <meta name="theme-color" content="#1e1e1e" />
12
9
  <link rel="manifest" href="/manifest.json" />
13
10
  <link rel="apple-touch-icon" href="/icons/icon-192.png" />
14
11
  <title>TermBeam — Terminal</title>
15
- <link
16
- rel="stylesheet"
17
- href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
18
- />
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
19
13
  <style>
20
14
  :root {
21
15
  --bg: #1e1e1e;
@@ -59,364 +53,657 @@
59
53
  }
60
54
  @font-face {
61
55
  font-family: 'NerdFont';
62
- src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')
63
- format('truetype');
64
- font-weight: normal;
65
- font-style: normal;
66
- font-display: swap;
56
+ src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
57
+ font-weight: normal; font-style: normal; font-display: swap;
67
58
  }
68
59
  @font-face {
69
60
  font-family: 'NerdFont';
70
- src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf')
71
- format('truetype');
72
- font-weight: bold;
73
- font-style: normal;
74
- font-display: swap;
61
+ src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
62
+ font-weight: bold; font-style: normal; font-display: swap;
75
63
  }
76
-
77
- * {
78
- margin: 0;
79
- padding: 0;
80
- box-sizing: border-box;
81
- }
82
- html,
83
- body {
84
- height: 100%;
85
- width: 100%;
86
- background: var(--bg);
87
- color: var(--text);
64
+ :root { --sab: env(safe-area-inset-bottom, 0px); }
65
+ * { margin: 0; padding: 0; box-sizing: border-box; }
66
+ html, body {
67
+ height: 100%; width: 100%; background: var(--bg); color: var(--text);
88
68
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
89
- overflow: hidden;
90
- touch-action: manipulation;
91
- height: 100dvh;
69
+ overflow: hidden; touch-action: manipulation; height: 100dvh;
70
+ overscroll-behavior: none;
92
71
  transition: background 0.3s, color 0.3s;
93
72
  }
94
73
 
95
- #status-bar {
74
+ /* ===== Top Bar (unified) ===== */
75
+ #top-bar {
96
76
  height: 40px;
97
77
  display: flex;
98
78
  align-items: center;
99
- justify-content: space-between;
100
- padding: 0 8px;
101
79
  background: var(--surface);
102
80
  border-bottom: 1px solid var(--border);
103
- font-size: 13px;
81
+ padding: 0 calc(4px + env(safe-area-inset-right, 0px)) 0 calc(4px + env(safe-area-inset-left, 0px));
82
+ padding-top: env(safe-area-inset-top, 0px);
83
+ gap: 2px;
104
84
  transition: background 0.3s, border-color 0.3s;
105
85
  }
106
- #status-bar .left {
86
+ #top-bar .left { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
87
+ #top-bar .right { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
88
+ #status-dot {
89
+ width: 8px; height: 8px; border-radius: 50%; background: var(--danger);
90
+ display: inline-block; transition: background 0.3s;
91
+ }
92
+ #status-dot.connected { background: var(--success); }
93
+ #session-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
94
+ #status-text { color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
95
+ #tab-list {
107
96
  display: flex;
108
97
  align-items: center;
109
- gap: 6px;
98
+ flex: 1;
99
+ overflow-x: auto;
100
+ -webkit-overflow-scrolling: touch;
101
+ scrollbar-width: none;
102
+ gap: 2px;
103
+ padding: 0 4px;
104
+ height: 100%;
105
+ min-width: 0;
110
106
  }
111
- #status-bar .right {
107
+ #tab-list::-webkit-scrollbar { display: none; }
108
+ .session-tab {
112
109
  display: flex;
113
110
  align-items: center;
114
- gap: 4px;
115
- }
116
- .bar-btn {
117
- background: none;
111
+ gap: 6px;
112
+ padding: 4px 10px;
113
+ background: transparent;
118
114
  border: none;
115
+ border-left: 3px solid transparent;
116
+ border-radius: 6px;
119
117
  color: var(--text-dim);
120
- width: 30px;
121
- height: 30px;
122
- border-radius: 8px;
118
+ font-size: 12px;
119
+ font-weight: 500;
123
120
  cursor: pointer;
124
- display: flex;
125
- align-items: center;
126
- justify-content: center;
121
+ white-space: nowrap;
122
+ flex-shrink: 0;
123
+ height: 30px;
127
124
  transition: background 0.15s, color 0.15s;
128
125
  -webkit-tap-highlight-color: transparent;
126
+ user-select: none;
129
127
  }
130
- .bar-btn:hover {
131
- background: var(--border);
128
+ .session-tab:hover { background: var(--border); color: var(--text); }
129
+ .session-tab.active {
130
+ background: var(--bg);
132
131
  color: var(--text);
132
+ font-weight: 600;
133
133
  }
134
- .bar-btn:active {
135
- background: var(--accent);
136
- color: #ffffff;
134
+ .session-tab.in-split {
135
+ background: rgba(0,120,212,0.1);
136
+ color: var(--text);
137
137
  }
138
- .bar-btn svg {
139
- display: block;
138
+ .session-tab.dragging { opacity: 0.4; }
139
+ .tab-dot {
140
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
140
141
  }
141
- .bar-group {
142
- display: flex;
143
- align-items: center;
142
+ .tab-name { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
143
+ .tab-activity {
144
+ font-size: 10px; color: var(--text-muted); flex-shrink: 0;
145
+ }
146
+ .tab-status {
147
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
148
+ }
149
+ .tab-close {
150
+ display: none;
151
+ background: none; border: none; color: var(--text-muted);
152
+ font-size: 14px; cursor: pointer; padding: 0 2px; line-height: 1;
153
+ transition: color 0.15s;
154
+ }
155
+ .session-tab:hover .tab-close,
156
+ .session-tab.active .tab-close { display: block; }
157
+ .tab-close:hover { color: var(--danger); }
158
+ /* ===== Tab Preview ===== */
159
+ #tab-preview {
160
+ display: none;
161
+ position: fixed;
162
+ z-index: 300;
163
+ width: min(85vw, 340px);
144
164
  background: var(--bg);
145
- border-radius: 8px;
146
- padding: 2px;
147
- gap: 1px;
148
- transition: background 0.3s;
165
+ border: 1px solid var(--border);
166
+ border-radius: 10px;
167
+ box-shadow: 0 8px 24px rgba(0,0,0,0.35);
168
+ overflow: hidden;
169
+ pointer-events: none;
149
170
  }
150
- .bar-group .bar-btn {
151
- width: 26px;
152
- height: 26px;
153
- border-radius: 6px;
154
- font-size: 14px;
155
- font-weight: 600;
171
+ #tab-preview.visible { display: block; }
172
+ .preview-header {
173
+ display: flex; align-items: center; gap: 6px;
174
+ padding: 6px 10px;
175
+ border-bottom: 1px solid var(--border);
176
+ font-size: 12px; font-weight: 600;
156
177
  }
157
- #stop-btn {
158
- background: none;
159
- border: none;
160
- color: var(--danger);
161
- height: 30px;
162
- border-radius: 8px;
163
- cursor: pointer;
164
- display: flex;
165
- align-items: center;
166
- justify-content: center;
167
- gap: 4px;
168
- padding: 0 10px;
169
- font-size: 11px;
170
- font-weight: 600;
171
- margin-left: 2px;
172
- transition: background 0.15s, color 0.15s, transform 0.1s;
173
- -webkit-tap-highlight-color: transparent;
178
+ .preview-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
179
+ .preview-body {
180
+ padding: 6px 8px;
181
+ font-family: 'NerdFont', 'JetBrains Mono', monospace;
182
+ font-size: 10px;
183
+ line-height: 1.35;
184
+ color: var(--text);
185
+ white-space: pre;
186
+ overflow: hidden;
187
+ max-height: 140px;
188
+ -webkit-text-size-adjust: none;
174
189
  }
175
- #stop-btn:hover {
176
- background: var(--danger);
177
- color: #ffffff;
190
+ .preview-empty {
191
+ padding: 12px 10px;
192
+ font-size: 11px;
193
+ color: var(--text-muted);
194
+ text-align: center;
195
+ font-style: italic;
178
196
  }
179
- #stop-btn:active {
180
- background: var(--danger-hover);
181
- color: #ffffff;
182
- transform: scale(0.9);
197
+
198
+ .tab-bar-btn {
199
+ background: none; border: none; color: var(--text-dim);
200
+ width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
201
+ display: flex; align-items: center; justify-content: center;
202
+ flex-shrink: 0; font-size: 18px; font-weight: 600;
203
+ transition: background 0.15s, color 0.15s;
204
+ -webkit-tap-highlight-color: transparent;
183
205
  }
184
- #status-dot {
185
- width: 8px;
186
- height: 8px;
187
- border-radius: 50%;
188
- background: var(--danger);
189
- display: inline-block;
190
- transition: background 0.3s;
206
+ .tab-bar-btn:hover { background: var(--border); color: var(--text); }
207
+ .tab-bar-btn:active { background: var(--accent); color: #fff; }
208
+ .tab-bar-btn.active { color: var(--accent); }
209
+ #tab-new-btn {
210
+ gap: 3px; width: auto; padding: 0 10px; font-size: 12px;
191
211
  }
192
- #status-dot.connected {
193
- background: var(--success);
212
+ .new-btn-label { font-size: 11px; font-weight: 600; }
213
+
214
+ .bar-btn {
215
+ background: none; border: none; color: var(--text-dim);
216
+ width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
217
+ display: flex; align-items: center; justify-content: center;
218
+ transition: background 0.15s, color 0.15s;
219
+ -webkit-tap-highlight-color: transparent;
194
220
  }
195
- #status-text {
196
- color: var(--text-secondary);
221
+ .bar-btn:hover { background: var(--border); color: var(--text); }
222
+ .bar-btn:active { background: var(--accent); color: #fff; }
223
+ .bar-btn svg { display: block; }
224
+ .bar-group {
225
+ display: flex; align-items: center; background: var(--bg);
226
+ border-radius: 8px; padding: 2px; gap: 1px; transition: background 0.3s;
197
227
  }
198
- #session-name {
228
+ .bar-group .bar-btn { width: 26px; height: 26px; border-radius: 6px; font-size: 14px; font-weight: 600; }
229
+ #stop-btn {
230
+ background: none; border: none; color: var(--danger); height: 30px;
231
+ border-radius: 8px; cursor: pointer; display: flex; align-items: center;
232
+ justify-content: center; gap: 4px; padding: 0 8px; font-size: 11px;
199
233
  font-weight: 600;
234
+ transition: background 0.15s, color 0.15s, transform 0.1s;
235
+ -webkit-tap-highlight-color: transparent;
200
236
  }
237
+ #stop-btn:hover { background: var(--danger); color: #fff; }
238
+ #stop-btn:active { background: var(--danger-hover); color: #fff; transform: scale(0.9); }
201
239
 
202
- #terminal-container {
240
+ /* ===== Terminals Wrapper ===== */
241
+ #terminals-wrapper {
203
242
  position: absolute;
204
- top: 40px;
205
- left: 0;
206
- right: 0;
207
- bottom: 52px;
208
- padding: 2px;
243
+ top: calc(41px + env(safe-area-inset-top, 0px));
244
+ left: env(safe-area-inset-left, 0px);
245
+ right: env(safe-area-inset-right, 0px);
246
+ bottom: calc(52px + env(safe-area-inset-bottom, 0px));
247
+ display: flex;
209
248
  overflow: hidden;
210
249
  }
250
+ #terminals-wrapper.split-h { flex-direction: row; }
251
+ #terminals-wrapper.split-v { flex-direction: column; }
252
+ .terminal-pane {
253
+ flex: 1; padding: 2px; overflow: hidden; position: relative;
254
+ display: none;
255
+ }
256
+ .terminal-pane.visible { display: block; }
257
+ #terminals-wrapper.split-h .terminal-pane.visible + .terminal-pane.visible {
258
+ border-left: 2px solid var(--accent);
259
+ }
260
+ #terminals-wrapper.split-v .terminal-pane.visible + .terminal-pane.visible {
261
+ border-top: 2px solid var(--accent);
262
+ }
211
263
 
264
+ /* ===== Key Bar ===== */
212
265
  #key-bar {
213
- position: fixed;
214
- bottom: 0;
215
- left: 0;
216
- right: 0;
217
- height: 52px;
218
- display: flex;
219
- align-items: center;
220
- background: var(--surface);
266
+ position: fixed; bottom: 0; left: 0; right: 0;
267
+ height: calc(52px + env(safe-area-inset-bottom, 0px));
268
+ display: flex; align-items: center; background: var(--surface);
221
269
  border-top: 1px solid var(--border);
222
- padding: 0 4px;
223
- padding-bottom: env(safe-area-inset-bottom, 0);
224
- gap: 4px;
225
- z-index: 50;
270
+ padding: 0 calc(4px + env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) calc(4px + env(safe-area-inset-left, 0px));
271
+ gap: 4px; z-index: 50;
226
272
  transition: background 0.3s, border-color 0.3s;
227
273
  }
228
274
  .key-btn {
229
- min-width: 0;
230
- height: 40px;
231
- background: var(--key-bg);
232
- color: var(--text);
233
- border: 1px solid var(--key-border);
234
- border-radius: 8px;
235
- font-size: 12px;
236
- font-weight: 600;
237
- cursor: pointer;
238
- display: flex;
239
- flex-direction: column;
240
- align-items: center;
241
- justify-content: center;
242
- -webkit-tap-highlight-color: transparent;
243
- user-select: none;
244
- white-space: nowrap;
245
- padding: 2px 8px;
246
- flex: 1 1 0;
247
- gap: 0;
248
- line-height: 1;
275
+ min-width: 0; height: 40px; background: var(--key-bg); color: var(--text);
276
+ border: 1px solid var(--key-border); border-radius: 8px; font-size: 12px;
277
+ font-weight: 600; cursor: pointer; display: flex; flex-direction: column;
278
+ align-items: center; justify-content: center;
279
+ -webkit-tap-highlight-color: transparent; user-select: none;
280
+ white-space: nowrap; padding: 2px 8px; flex: 1 1 0; gap: 0; line-height: 1;
249
281
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s, box-shadow 0.15s;
250
282
  box-shadow: 0 1px 3px var(--key-shadow), inset 0 1px 0 rgba(255,255,255,0.05);
251
283
  }
252
- .key-btn .hint {
253
- font-size: 8px;
254
- font-weight: 400;
255
- opacity: 0.5;
256
- margin-top: 1px;
257
- letter-spacing: 0.02em;
284
+ .key-btn .hint { font-size: 8px; font-weight: 400; opacity: 0.5; margin-top: 1px; letter-spacing: 0.02em; }
285
+ .key-btn:hover { background: var(--border); border-color: var(--accent); box-shadow: 0 2px 6px var(--key-shadow); }
286
+ .key-btn:active { background: var(--accent); color: #fff; border-color: var(--accent); transform: scale(0.93); box-shadow: none; }
287
+ .key-btn.wide { flex: 1.4 1 0; }
288
+ .key-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
289
+
290
+ .xterm { height: 100% !important; }
291
+ .xterm-viewport { overflow-y: auto !important; scrollbar-width: none; overscroll-behavior: contain; }
292
+ .xterm-viewport::-webkit-scrollbar { display: none; }
293
+ .terminal-pane,
294
+ .terminal-pane .xterm,
295
+ .terminal-pane .xterm-screen,
296
+ .terminal-pane .xterm-viewport,
297
+ .terminal-pane .xterm-helper-textarea { touch-action: none; }
298
+
299
+ /* ===== Toasts & Overlays ===== */
300
+ #copy-toast {
301
+ position: fixed; top: 48px; left: 50%;
302
+ transform: translateX(-50%) translateY(-8px);
303
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
304
+ padding: 6px 16px; border-radius: 8px; font-size: 13px; font-weight: 600;
305
+ opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s; z-index: 200;
258
306
  }
259
- .key-btn:hover {
260
- background: var(--border);
261
- border-color: var(--accent);
262
- box-shadow: 0 2px 6px var(--key-shadow);
307
+ #copy-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
308
+
309
+ #paste-overlay {
310
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
311
+ background: var(--overlay-bg); z-index: 150;
312
+ flex-direction: column; align-items: center; justify-content: center; gap: 12px;
263
313
  }
264
- .key-btn:active {
265
- background: var(--accent);
266
- color: #ffffff;
267
- border-color: var(--accent);
268
- transform: scale(0.93);
269
- box-shadow: none;
314
+ #paste-overlay.visible { display: flex; }
315
+ #paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
316
+ #paste-input {
317
+ width: 80%; max-width: 400px; min-height: 80px; background: var(--surface);
318
+ color: var(--text); border: 1px solid var(--border); border-radius: 8px;
319
+ padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
270
320
  }
271
- .key-btn.wide {
272
- flex: 1.4 1 0;
321
+
322
+ #reconnect-overlay {
323
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
324
+ background: var(--overlay-bg); z-index: 100;
325
+ flex-direction: column; align-items: center; justify-content: center; gap: 16px;
273
326
  }
274
- .key-sep {
275
- width: 1px;
276
- height: 20px;
277
- background: var(--border);
278
- flex-shrink: 0;
327
+ #reconnect-overlay.visible { display: flex; }
328
+ #reconnect-overlay .msg { font-size: 17px; color: #fff; }
329
+ .overlay-actions { display: flex; gap: 12px; }
330
+ .overlay-actions button {
331
+ padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px;
332
+ font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.1s;
279
333
  }
334
+ .overlay-actions button:active { transform: scale(0.95); }
335
+ #reconnect-btn { background: var(--accent); color: #fff; }
336
+ #reconnect-btn:hover { background: var(--accent-hover); }
337
+ #back-to-sessions { background: rgba(255,255,255,0.15); color: #fff; }
338
+ #back-to-sessions:hover { background: rgba(255,255,255,0.25); }
280
339
 
281
- .xterm {
282
- height: 100% !important;
340
+ /* ===== Folder Browser ===== */
341
+ .cwd-picker {
342
+ display: flex;
343
+ gap: 8px;
283
344
  }
284
- .xterm-viewport {
285
- overflow-y: hidden !important;
345
+ .cwd-picker input {
346
+ flex: 1;
286
347
  }
287
-
288
- #copy-toast {
289
- position: fixed;
290
- top: 56px;
291
- left: 50%;
292
- transform: translateX(-50%) translateY(-8px);
293
- background: var(--surface);
348
+ .cwd-browse-btn {
349
+ background: var(--border);
294
350
  color: var(--text);
295
351
  border: 1px solid var(--border);
296
- padding: 6px 16px;
297
352
  border-radius: 8px;
298
- font-size: 13px;
299
- font-weight: 600;
300
- opacity: 0;
301
- pointer-events: none;
302
- transition: opacity 0.2s, transform 0.2s;
303
- z-index: 200;
353
+ padding: 0 14px;
354
+ font-size: 18px;
355
+ cursor: pointer;
356
+ flex-shrink: 0;
357
+ display: flex;
358
+ align-items: center;
359
+ transition: background 0.15s, border-color 0.15s;
304
360
  }
305
- #copy-toast.visible {
306
- opacity: 1;
307
- transform: translateX(-50%) translateY(0);
361
+ .cwd-browse-btn:hover {
362
+ border-color: var(--accent);
308
363
  }
309
-
310
- #paste-overlay {
364
+ .cwd-browse-btn:active {
365
+ background: var(--accent);
366
+ color: #ffffff;
367
+ }
368
+ .browser-overlay {
311
369
  display: none;
312
370
  position: fixed;
313
- top: 0;
314
- left: 0;
315
- right: 0;
316
- bottom: 0;
371
+ top: 0; left: 0; right: 0; bottom: 0;
317
372
  background: var(--overlay-bg);
318
- z-index: 150;
319
- flex-direction: column;
320
- align-items: center;
373
+ z-index: 300;
321
374
  justify-content: center;
322
- gap: 12px;
375
+ align-items: flex-end;
323
376
  }
324
- #paste-overlay.visible {
377
+ .browser-overlay.visible {
325
378
  display: flex;
326
379
  }
327
- #paste-overlay label {
328
- font-size: 15px;
329
- color: #ffffff;
330
- font-weight: 600;
331
- }
332
- #paste-input {
333
- width: 80%;
334
- max-width: 400px;
335
- min-height: 80px;
380
+ .browser-sheet {
336
381
  background: var(--surface);
337
- color: var(--text);
338
- border: 1px solid var(--border);
339
- border-radius: 8px;
340
- padding: 10px;
341
- font-size: 14px;
342
- font-family: 'NerdFont', 'JetBrains Mono', monospace;
343
- resize: vertical;
344
- }
345
-
346
- #reconnect-overlay {
347
- display: none;
348
- position: fixed;
349
- top: 0;
350
- left: 0;
351
- right: 0;
352
- bottom: 0;
353
- background: var(--overlay-bg);
354
- z-index: 100;
382
+ border-radius: 16px 16px 0 0;
383
+ width: 100%;
384
+ max-width: 500px;
385
+ height: 80vh;
386
+ display: flex;
355
387
  flex-direction: column;
356
- align-items: center;
357
- justify-content: center;
358
- gap: 16px;
388
+ overflow: hidden;
389
+ transition: background 0.3s;
359
390
  }
360
- #reconnect-overlay.visible {
391
+ .browser-header {
392
+ padding: 16px 16px 12px;
393
+ border-bottom: 1px solid var(--border);
361
394
  display: flex;
395
+ align-items: center;
396
+ justify-content: space-between;
362
397
  }
363
- #reconnect-overlay .msg {
398
+ .browser-header h3 {
364
399
  font-size: 17px;
365
- color: #ffffff;
366
- }
367
- .overlay-actions {
368
- display: flex;
369
- gap: 12px;
400
+ font-weight: 600;
370
401
  }
371
- .overlay-actions button {
372
- padding: 10px 24px;
402
+ .browser-close {
403
+ background: none;
373
404
  border: none;
374
- border-radius: 8px;
375
- font-size: 15px;
376
- font-weight: 600;
405
+ color: var(--text-dim);
406
+ font-size: 24px;
377
407
  cursor: pointer;
408
+ padding: 0 4px;
409
+ line-height: 1;
410
+ transition: color 0.15s;
411
+ }
412
+ .browser-close:hover { color: var(--text); }
413
+ .browser-close:active { color: var(--text); }
414
+ .browser-breadcrumb {
415
+ padding: 8px 16px;
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 2px;
419
+ font-size: 13px;
420
+ overflow-x: auto;
421
+ white-space: nowrap;
422
+ border-bottom: 1px solid var(--border);
423
+ flex-shrink: 0;
424
+ -webkit-overflow-scrolling: touch;
425
+ }
426
+ .crumb {
427
+ background: none; border: none; color: var(--text-dim);
428
+ font-size: 13px; cursor: pointer; padding: 4px 6px;
429
+ border-radius: 4px; flex-shrink: 0;
430
+ transition: background 0.15s, color 0.15s;
431
+ }
432
+ .crumb:active, .crumb:hover { background: var(--border); color: var(--text); }
433
+ .crumb.current { color: var(--text); font-weight: 600; }
434
+ .crumb-sep { color: var(--border-subtle); flex-shrink: 0; }
435
+ .browser-list {
436
+ flex: 1; overflow-y: auto; padding: 4px 0;
437
+ -webkit-overflow-scrolling: touch;
438
+ }
439
+ .browser-empty {
440
+ text-align: center; padding: 40px 20px;
441
+ color: var(--text-muted); font-size: 14px;
442
+ }
443
+ .folder-item {
444
+ display: flex; align-items: center; gap: 12px;
445
+ padding: 12px 16px; cursor: pointer;
446
+ border-bottom: 1px solid rgba(60,60,60,0.5);
447
+ transition: background 0.1s;
448
+ -webkit-tap-highlight-color: transparent;
449
+ }
450
+ .folder-item:active { background: rgba(0,120,212,0.2); }
451
+ .folder-item:hover { background: rgba(0,120,212,0.1); }
452
+ .folder-icon { font-size: 22px; flex-shrink: 0; width: 28px; text-align: center; }
453
+ .folder-name {
454
+ font-size: 15px; color: var(--text); flex: 1;
455
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
456
+ }
457
+ .folder-arrow { color: var(--border-subtle); font-size: 18px; flex-shrink: 0; }
458
+ .browser-footer {
459
+ padding: 12px 16px calc(env(safe-area-inset-bottom, 8px) + 8px);
460
+ border-top: 1px solid var(--border);
461
+ display: flex; flex-direction: column; gap: 8px;
462
+ }
463
+ .browser-current-path {
464
+ font-size: 12px; color: var(--text-dim);
465
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
466
+ }
467
+ .browser-select-btn {
468
+ width: 100%; padding: 12px; background: var(--accent);
469
+ color: #ffffff; border: none; border-radius: 10px;
470
+ font-size: 16px; font-weight: 600; cursor: pointer;
378
471
  transition: background 0.15s, transform 0.1s;
379
472
  }
380
- .overlay-actions button:active {
381
- transform: scale(0.95);
473
+ .browser-select-btn:hover { background: var(--accent-hover); }
474
+ .browser-select-btn:active { background: var(--accent-active); transform: scale(0.98); }
475
+
476
+ /* ===== New Session Modal ===== */
477
+ .modal-overlay {
478
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
479
+ background: var(--overlay-bg); z-index: 200;
480
+ justify-content: center; align-items: center;
382
481
  }
383
- #reconnect-btn {
384
- background: var(--accent);
385
- color: #ffffff;
482
+ .modal-overlay.visible { display: flex; }
483
+ .modal {
484
+ background: var(--surface); border-radius: 16px; width: 90%; max-width: 500px;
485
+ padding: 24px 20px; transition: background 0.3s; max-height: 90vh; overflow-y: auto;
386
486
  }
387
- #reconnect-btn:hover {
388
- background: var(--accent-hover);
487
+ .modal h2 { font-size: 18px; margin-bottom: 16px; }
488
+ .modal label {
489
+ display: block; font-size: 13px; color: var(--text-secondary);
490
+ margin-bottom: 4px; margin-top: 12px;
389
491
  }
390
- #back-to-sessions {
391
- background: rgba(255,255,255,0.15);
392
- color: #ffffff;
492
+ .modal input, .modal select {
493
+ width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border);
494
+ border-radius: 8px; color: var(--text); font-size: 15px; outline: none;
495
+ -webkit-appearance: none; appearance: none;
496
+ transition: background 0.3s, border-color 0.15s, color 0.3s;
497
+ }
498
+ .modal select {
499
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
500
+ background-repeat: no-repeat; background-position: right 12px center;
501
+ padding-right: 32px; cursor: pointer;
502
+ }
503
+ .modal input:focus, .modal select:focus { border-color: var(--accent); }
504
+ .modal-actions { display: flex; gap: 12px; margin-top: 20px; }
505
+ .modal-actions button {
506
+ flex: 1; padding: 12px; border: none; border-radius: 8px;
507
+ font-size: 15px; font-weight: 600; cursor: pointer;
508
+ transition: background 0.15s, transform 0.1s;
509
+ }
510
+ .modal-actions button:active { transform: scale(0.95); }
511
+ .btn-cancel { background: var(--border); color: var(--text); }
512
+ .btn-cancel:hover { background: var(--border-subtle); }
513
+ .btn-create { background: var(--accent); color: #fff; }
514
+ .btn-create:hover { background: var(--accent-hover); }
515
+
516
+ .color-picker { display: flex; gap: 8px; padding: 6px 0; flex-wrap: wrap; }
517
+ .color-swatch {
518
+ width: 32px; height: 32px; border-radius: 50%; border: 3px solid transparent;
519
+ cursor: pointer; padding: 0; outline: none;
520
+ transition: border-color 0.15s, transform 0.1s;
521
+ -webkit-tap-highlight-color: transparent;
522
+ }
523
+ .color-swatch:hover { transform: scale(1.1); }
524
+ .color-swatch.selected { border-color: var(--text); transform: scale(1.15); }
525
+
526
+ /* ===== Sessions Side Panel (mobile) ===== */
527
+ #panel-toggle {
528
+ display: none;
529
+ background: none; border: none; color: var(--text-dim);
530
+ width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
531
+ align-items: center; justify-content: center;
532
+ flex-shrink: 0;
533
+ transition: background 0.15s, color 0.15s;
534
+ -webkit-tap-highlight-color: transparent;
535
+ }
536
+ #panel-toggle:hover { background: var(--border); color: var(--text); }
537
+
538
+ #side-panel-backdrop {
539
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
540
+ background: rgba(0,0,0,0.5); z-index: 400;
541
+ opacity: 0; transition: opacity 0.25s;
542
+ }
543
+ #side-panel-backdrop.visible { display: block; opacity: 1; }
544
+
545
+ #side-panel {
546
+ position: fixed; top: 0; left: 0; bottom: 0;
547
+ width: min(85vw, 380px);
548
+ background: var(--surface);
549
+ border-right: 1px solid var(--border);
550
+ z-index: 410;
551
+ transform: translateX(-100%);
552
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s;
553
+ display: flex; flex-direction: column;
554
+ overflow: hidden;
555
+ padding-top: env(safe-area-inset-top, 0px);
556
+ padding-left: env(safe-area-inset-left, 0px);
557
+ padding-bottom: env(safe-area-inset-bottom, 0px);
393
558
  }
394
- #back-to-sessions:hover {
395
- background: rgba(255,255,255,0.25);
559
+ #side-panel.open { transform: translateX(0); }
560
+
561
+ .side-panel-header {
562
+ display: flex; align-items: center; justify-content: space-between;
563
+ padding: 12px 14px; border-bottom: 1px solid var(--border);
564
+ font-size: 15px; font-weight: 700;
565
+ }
566
+ .side-panel-close {
567
+ background: none; border: none; color: var(--text-dim);
568
+ width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
569
+ display: flex; align-items: center; justify-content: center;
570
+ font-size: 20px; transition: background 0.15s, color 0.15s;
571
+ -webkit-tap-highlight-color: transparent;
572
+ }
573
+ .side-panel-close:hover { background: var(--border); color: var(--text); }
574
+
575
+ .side-panel-list {
576
+ flex: 1; overflow-y: auto; padding: 8px;
577
+ -webkit-overflow-scrolling: touch;
578
+ display: flex; flex-direction: column; gap: 6px;
579
+ }
580
+
581
+ .side-panel-card {
582
+ background: var(--bg); border: 1px solid var(--border);
583
+ border-radius: 10px; cursor: pointer; overflow: hidden;
584
+ transition: background 0.15s, border-color 0.15s;
585
+ -webkit-tap-highlight-color: transparent;
586
+ }
587
+ .side-panel-card:hover { border-color: var(--accent); }
588
+ .side-panel-card.active { border-color: var(--accent); border-width: 2px; }
589
+
590
+ .side-panel-card-header {
591
+ display: flex; align-items: center; gap: 8px;
592
+ padding: 10px 12px 6px;
593
+ }
594
+ .side-panel-card-dot {
595
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
596
+ }
597
+ .side-panel-card-name {
598
+ font-size: 13px; font-weight: 600; flex: 1;
599
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
600
+ }
601
+ .side-panel-card-status {
602
+ width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
603
+ }
604
+ .side-panel-card-close {
605
+ background: none; border: none; color: var(--text-muted);
606
+ width: 26px; height: 26px; border-radius: 6px; cursor: pointer;
607
+ display: flex; align-items: center; justify-content: center;
608
+ font-size: 16px; flex-shrink: 0; padding: 0;
609
+ transition: background 0.15s, color 0.15s;
610
+ -webkit-tap-highlight-color: transparent;
611
+ }
612
+ .side-panel-card-close:hover { background: var(--danger); color: #fff; }
613
+ .side-panel-card-meta {
614
+ padding: 0 12px 4px;
615
+ font-size: 10px; color: var(--text-muted);
616
+ }
617
+
618
+ .side-panel-card-preview {
619
+ margin: 0 8px 8px;
620
+ padding: 6px 8px;
621
+ background: var(--surface);
622
+ border-radius: 6px;
623
+ font-family: 'NerdFont', 'JetBrains Mono', monospace;
624
+ font-size: 9px;
625
+ line-height: 1.3;
626
+ color: var(--text-secondary);
627
+ white-space: pre;
628
+ overflow: hidden;
629
+ max-height: 72px;
630
+ -webkit-text-size-adjust: none;
631
+ }
632
+ .side-panel-card-preview.empty {
633
+ font-style: italic; color: var(--text-muted);
634
+ text-align: center; font-family: inherit; font-size: 11px;
635
+ white-space: normal;
396
636
  }
397
637
 
638
+ @media (max-width: 640px) {
639
+ #panel-toggle { display: flex; }
640
+ #tab-list { display: none; }
641
+ #split-toggle { display: none; }
642
+ #version-text { display: none; }
643
+ #back-btn { display: none; }
644
+ #theme-toggle { display: flex; }
645
+ #stop-btn { padding: 0 8px; }
646
+ #session-name { max-width: 30vw; }
647
+ #status-text { display: none; }
648
+ #tab-new-btn { font-size: 13px; padding: 0 8px; width: auto; }
649
+ }
398
650
  </style>
399
651
  </head>
400
652
  <body>
401
- <div id="status-bar">
653
+ <!-- Sessions Side Panel -->
654
+ <div id="side-panel-backdrop"></div>
655
+ <div id="side-panel">
656
+ <div class="side-panel-header">
657
+ <span>Sessions</span>
658
+ <button class="side-panel-close" id="side-panel-close" title="Close">×</button>
659
+ </div>
660
+ <div class="side-panel-list" id="side-panel-list"></div>
661
+ <div style="padding:8px;border-top:1px solid var(--border)">
662
+ <button id="side-panel-new-btn" style="width:100%;padding:10px;border:1px dashed var(--border);border-radius:8px;background:none;color:var(--accent);font-size:14px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background 0.15s">+ New Session</button>
663
+ </div>
664
+ </div>
665
+
666
+ <!-- Top Bar (unified) -->
667
+ <div id="top-bar">
402
668
  <div class="left">
403
- <button class="bar-btn" id="back-btn" onclick="location.href = '/'" title="Back to sessions"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
669
+ <button id="panel-toggle" title="Sessions">
670
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
671
+ </button>
672
+ <button class="bar-btn" id="back-btn" onclick="location.href='/'" title="Back to sessions"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
404
673
  <span id="status-dot"></span>
405
674
  <span id="session-name">…</span>
675
+ <span id="status-text">Connecting…</span>
406
676
  </div>
677
+ <div id="tab-list"></div>
407
678
  <div class="right">
408
- <span id="status-text">Connecting…</span>
409
- <span id="version-text" style="font-size: 11px; color: var(--text-muted)"></span>
679
+ <span id="version-text" style="font-size:11px;color:var(--text-muted)"></span>
680
+ <button class="tab-bar-btn" id="tab-new-btn" title="New session"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="new-btn-label">New</span></button>
681
+ <button class="tab-bar-btn" id="split-toggle" title="Split view">
682
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
683
+ </button>
410
684
  <div class="bar-group">
411
685
  <button class="bar-btn" id="zoom-out" title="Decrease font size">−</button>
412
686
  <button class="bar-btn" id="zoom-in" title="Increase font size">+</button>
413
687
  </div>
688
+ <button class="bar-btn" id="share-btn" title="Share link"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></button>
689
+ <button class="bar-btn" id="refresh-btn" title="Refresh app"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
414
690
  <button class="bar-btn" id="theme-toggle" title="Toggle theme"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button>
415
691
  <button id="stop-btn" title="Stop session"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
416
692
  </div>
417
693
  </div>
418
694
 
419
- <div id="terminal-container"></div>
695
+ <!-- Tab Preview Popover -->
696
+ <div id="tab-preview">
697
+ <div class="preview-header">
698
+ <span class="preview-dot" id="preview-dot"></span>
699
+ <span id="preview-name"></span>
700
+ </div>
701
+ <div class="preview-body" id="preview-body"></div>
702
+ </div>
703
+
704
+ <!-- Terminals Wrapper (panes created dynamically) -->
705
+ <div id="terminals-wrapper"></div>
706
+
420
707
  <div id="copy-toast">Copied!</div>
421
708
 
422
709
  <div id="key-bar">
@@ -432,12 +719,14 @@
432
719
  <button class="key-btn wide" data-key="&#x09;" title="Autocomplete">Tab</button>
433
720
  <div class="key-sep"></div>
434
721
  <button class="key-btn" data-key="&#x03;" title="Interrupt process">^C<span class="hint">stop</span></button>
722
+ <div class="key-sep"></div>
723
+ <button class="key-btn" data-key="enter" title="Enter / Return">↵<span class="hint">enter</span></button>
435
724
  </div>
436
725
 
437
726
  <div id="reconnect-overlay">
438
727
  <div class="msg">Session disconnected</div>
439
728
  <div class="overlay-actions">
440
- <button id="back-to-sessions" onclick="location.href = '/'">Sessions</button>
729
+ <button id="back-to-sessions" onclick="location.href='/'">Sessions</button>
441
730
  <button id="reconnect-btn">Reconnect</button>
442
731
  </div>
443
732
  </div>
@@ -446,8 +735,61 @@
446
735
  <label for="paste-input">Paste your text below</label>
447
736
  <textarea id="paste-input" placeholder="Long-press here and paste…"></textarea>
448
737
  <div class="overlay-actions">
449
- <button id="paste-cancel" style="background: rgba(255,255,255,0.15); color: #ffffff;">Cancel</button>
450
- <button id="paste-send" style="background: var(--accent); color: #ffffff;">Send</button>
738
+ <button id="paste-cancel" style="background:rgba(255,255,255,0.15);color:#fff;">Cancel</button>
739
+ <button id="paste-send" style="background:var(--accent);color:#fff;">Send</button>
740
+ </div>
741
+ </div>
742
+
743
+ <!-- New Session Modal -->
744
+ <div class="modal-overlay" id="new-session-modal">
745
+ <div class="modal">
746
+ <h2>New Session</h2>
747
+ <label for="ns-name">Name</label>
748
+ <input type="text" id="ns-name" placeholder="My Session" />
749
+ <label for="ns-shell">Shell</label>
750
+ <select id="ns-shell"><option value="">Loading shells…</option></select>
751
+ <label for="ns-cmd">Initial Command <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
752
+ <input type="text" id="ns-cmd" placeholder="e.g. htop, vim" />
753
+ <label>Color</label>
754
+ <div class="color-picker" id="ns-color-picker">
755
+ <button type="button" class="color-swatch selected" data-color="#4a9eff" style="background:#4a9eff" title="Blue"></button>
756
+ <button type="button" class="color-swatch" data-color="#4ade80" style="background:#4ade80" title="Green"></button>
757
+ <button type="button" class="color-swatch" data-color="#fbbf24" style="background:#fbbf24" title="Amber"></button>
758
+ <button type="button" class="color-swatch" data-color="#c084fc" style="background:#c084fc" title="Purple"></button>
759
+ <button type="button" class="color-swatch" data-color="#f87171" style="background:#f87171" title="Red"></button>
760
+ <button type="button" class="color-swatch" data-color="#22d3ee" style="background:#22d3ee" title="Cyan"></button>
761
+ <button type="button" class="color-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
762
+ <button type="button" class="color-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
763
+ </div>
764
+ <label for="ns-cwd">Working Directory <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
765
+ <div class="cwd-picker">
766
+ <input type="text" id="ns-cwd" placeholder="Uses server default" />
767
+ <button type="button" class="cwd-browse-btn" id="ns-browse-btn" title="Browse folders">
768
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
769
+ </button>
770
+ </div>
771
+ <div class="modal-actions">
772
+ <button class="btn-cancel" id="ns-cancel">Cancel</button>
773
+ <button class="btn-create" id="ns-create">Create</button>
774
+ </div>
775
+ </div>
776
+ </div>
777
+
778
+ <!-- Folder Browser -->
779
+ <div class="browser-overlay" id="ns-browser-overlay">
780
+ <div class="browser-sheet">
781
+ <div class="browser-header">
782
+ <h3>
783
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 6px"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>Choose Folder
784
+ </h3>
785
+ <button class="browser-close" id="ns-browser-close">×</button>
786
+ </div>
787
+ <div class="browser-breadcrumb" id="ns-browser-breadcrumb"></div>
788
+ <div class="browser-list" id="ns-browser-list"></div>
789
+ <div class="browser-footer">
790
+ <div class="browser-current-path" id="ns-browser-path">/</div>
791
+ <button class="browser-select-btn" id="ns-browser-select">Select This Folder</button>
792
+ </div>
451
793
  </div>
452
794
  </div>
453
795
 
@@ -455,21 +797,24 @@
455
797
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
456
798
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
457
799
  <script>
458
- const sessionId = new URLSearchParams(location.search).get('id');
459
- if (!sessionId) {
460
- location.href = '/';
461
- }
800
+ // ===== Constants =====
801
+ const SESSION_COLORS = ['#4a9eff','#4ade80','#fbbf24','#c084fc','#f87171','#22d3ee','#fb923c','#f472b6'];
802
+
803
+ // ===== State =====
804
+ const managed = new Map(); // sessionId -> ManagedSession
805
+ let activeId = null;
806
+ let splitMode = false;
807
+ let splitSecondId = null;
462
808
 
809
+ // ===== DOM Refs =====
463
810
  const statusDot = document.getElementById('status-dot');
464
811
  const statusText = document.getElementById('status-text');
465
- const sessionName = document.getElementById('session-name');
812
+ const sessionNameEl = document.getElementById('session-name');
466
813
  const reconnectOverlay = document.getElementById('reconnect-overlay');
467
- let sessionExited = false;
468
- let reconnectTimer = null;
469
- let reconnectDelay = 3000;
470
- const MAX_RECONNECT_DELAY = 30000;
814
+ const tabListEl = document.getElementById('tab-list');
815
+ const terminalsWrapper = document.getElementById('terminals-wrapper');
471
816
 
472
- // Terminal themes
817
+ // ===== Terminal Themes =====
473
818
  const darkTermTheme = {
474
819
  background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#aeafad', cursorAccent: '#1e1e1e',
475
820
  selectionBackground: 'rgba(38, 79, 120, 0.5)',
@@ -489,200 +834,849 @@
489
834
  brightCyan: '#0598bc', brightWhite: '#a5a5a5',
490
835
  };
491
836
 
837
+ // ===== Theme =====
492
838
  function getTheme() { return localStorage.getItem('termbeam-theme') || 'dark'; }
493
839
  function applyTheme(theme) {
494
840
  document.documentElement.setAttribute('data-theme', theme);
495
- document.querySelector('meta[name="theme-color"]').content =
496
- theme === 'light' ? '#f3f3f3' : '#1e1e1e';
841
+ document.querySelector('meta[name="theme-color"]').content = theme === 'light' ? '#f3f3f3' : '#1e1e1e';
497
842
  const btn = document.getElementById('theme-toggle');
498
843
  if (btn) btn.innerHTML = theme === 'light'
499
844
  ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
500
845
  : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
501
846
  localStorage.setItem('termbeam-theme', theme);
502
- if (window._term) {
503
- window._term.options.theme = theme === 'light' ? lightTermTheme : darkTermTheme;
847
+ for (const [, ms] of managed) {
848
+ ms.term.options.theme = theme === 'light' ? lightTermTheme : darkTermTheme;
504
849
  }
505
850
  }
506
851
  applyTheme(getTheme());
507
-
508
852
  document.getElementById('theme-toggle').addEventListener('click', () => {
509
853
  applyTheme(getTheme() === 'light' ? 'dark' : 'light');
510
854
  });
511
855
 
512
- // Load Nerd Font, then init terminal
513
- const nerdFont = new FontFace(
514
- 'NerdFont',
515
- "url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')",
516
- );
517
-
518
- nerdFont
519
- .load()
520
- .then((font) => {
521
- document.fonts.add(font);
522
- initTerminal();
523
- })
524
- .catch(() => {
525
- // Fallback: init without Nerd Font
526
- console.warn('Nerd Font failed to load, using fallback');
527
- initTerminal();
856
+ // ===== Font Loading (non-blocking) =====
857
+ const nerdFont = new FontFace('NerdFont',
858
+ "url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@latest/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')");
859
+ nerdFont.load().then(font => { document.fonts.add(font); }).catch(() => { console.warn('Nerd Font failed to load, using fallback'); });
860
+
861
+ // Start immediately — don't wait for font
862
+ init();
863
+
864
+ // ===== Helpers =====
865
+ function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
866
+ function escAttr(str) { return String(str).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
867
+ function safeColor(c) { return /^(#[0-9a-fA-F]{3,8}|var\(--[a-z-]+\)|[a-z]+)$/.test(c) ? c : 'var(--text-muted)'; }
868
+
869
+ function showToast(msg) {
870
+ const toast = document.getElementById('copy-toast');
871
+ toast.textContent = msg;
872
+ toast.classList.add('visible');
873
+ clearTimeout(toast._timer);
874
+ toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
875
+ }
876
+
877
+ function getActivityLabel(ts) {
878
+ if (!ts) return '';
879
+ const diff = (Date.now() - ts) / 1000;
880
+ if (diff < 5) return '';
881
+ if (diff < 60) return Math.floor(diff) + 's';
882
+ if (diff < 3600) return Math.floor(diff / 60) + 'm';
883
+ return Math.floor(diff / 3600) + 'h';
884
+ }
885
+
886
+ // ===== Zoom =====
887
+ const MIN_FONT = 2, MAX_FONT = 28;
888
+ let fontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
889
+
890
+ function applyZoom(size) {
891
+ fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
892
+ localStorage.setItem('termbeam-fontsize', fontSize);
893
+ for (const [, ms] of managed) {
894
+ ms.term.options.fontSize = fontSize;
895
+ if (ms.container.classList.contains('visible')) {
896
+ ms.fitAddon.fit();
897
+ sendResize(ms);
898
+ }
899
+ }
900
+ }
901
+
902
+ function sendResize(ms) {
903
+ if (ms.ws && ms.ws.readyState === 1) {
904
+ const dims = ms.fitAddon.proposeDimensions();
905
+ if (dims && (dims.cols !== ms._lastCols || dims.rows !== ms._lastRows)) {
906
+ ms._lastCols = dims.cols;
907
+ ms._lastRows = dims.rows;
908
+ ms.ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
909
+ }
910
+ }
911
+ }
912
+
913
+ // ===== Tab Order (persisted in localStorage) =====
914
+ function getTabOrder() {
915
+ let order = JSON.parse(localStorage.getItem('termbeam-tab-order') || '[]');
916
+ const allIds = [...managed.keys()];
917
+ for (const id of allIds) { if (!order.includes(id)) order.push(id); }
918
+ order = order.filter(id => managed.has(id));
919
+ return order;
920
+ }
921
+ function saveTabOrder(order) { localStorage.setItem('termbeam-tab-order', JSON.stringify(order)); }
922
+
923
+ // ===== Init =====
924
+ async function init() {
925
+ const sessionList = await fetch('/api/sessions').then(r => r.json());
926
+ const initialId = new URLSearchParams(location.search).get('id');
927
+
928
+ for (const s of sessionList) addSession(s);
929
+
930
+ const startId = (initialId && managed.has(initialId))
931
+ ? initialId
932
+ : (sessionList.length > 0 ? sessionList[0].id : null);
933
+
934
+ if (startId) activateSession(startId);
935
+ else { sessionNameEl.textContent = 'No sessions'; statusText.textContent = ''; document.getElementById('stop-btn').style.display = 'none'; }
936
+
937
+ renderTabs();
938
+ setupKeyBar();
939
+ setupPaste();
940
+ setupNewSessionModal();
941
+ startPolling();
942
+
943
+ // Zoom
944
+ document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
945
+ document.getElementById('zoom-out').addEventListener('click', () => applyZoom(fontSize - 2));
946
+
947
+ // Resize
948
+ function doResize() {
949
+ for (const [, ms] of managed) {
950
+ if (ms.container.classList.contains('visible')) { ms.fitAddon.fit(); sendResize(ms); }
951
+ }
952
+ }
953
+ window.addEventListener('resize', doResize);
954
+ screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
955
+
956
+ // Mobile soft keyboard
957
+ if (window.visualViewport) {
958
+ const keyBar = document.getElementById('key-bar');
959
+ let vpResizeTimer = null;
960
+ function resetScroll() {
961
+ window.scrollTo(0, 0);
962
+ document.documentElement.scrollTop = 0;
963
+ document.body.scrollTop = 0;
964
+ }
965
+ function onViewportResize() {
966
+ const vv = window.visualViewport;
967
+ const keyboardHeight = window.innerHeight - vv.height;
968
+ const keyboardOpen = keyboardHeight > 50;
969
+ if (keyboardOpen) {
970
+ keyBar.style.bottom = keyboardHeight + 'px';
971
+ keyBar.style.height = '52px';
972
+ keyBar.style.paddingBottom = '0px';
973
+ terminalsWrapper.style.bottom = (52 + keyboardHeight) + 'px';
974
+ } else {
975
+ keyBar.style.bottom = '0px';
976
+ keyBar.style.height = '';
977
+ keyBar.style.paddingBottom = '';
978
+ terminalsWrapper.style.bottom = '';
979
+ }
980
+ resetScroll();
981
+ clearTimeout(vpResizeTimer);
982
+ vpResizeTimer = setTimeout(() => {
983
+ resetScroll();
984
+ doResize();
985
+ }, 150);
986
+ }
987
+ function onViewportScroll() {
988
+ resetScroll();
989
+ }
990
+ window.visualViewport.addEventListener('resize', onViewportResize);
991
+ window.visualViewport.addEventListener('scroll', onViewportScroll);
992
+ // Page should never scroll — catch any browser-initiated scroll
993
+ // (e.g. iOS scrolling to show focused xterm textarea behind keyboard)
994
+ window.addEventListener('scroll', () => {
995
+ if (window.scrollY !== 0) resetScroll();
996
+ }, { passive: true });
997
+ }
998
+
999
+ // Split toggle
1000
+ document.getElementById('split-toggle').addEventListener('click', toggleSplit);
1001
+
1002
+ // Scroll to bottom when returning from idle / tab switch
1003
+ document.addEventListener('visibilitychange', () => {
1004
+ if (!document.hidden && activeId) {
1005
+ const ms = managed.get(activeId);
1006
+ if (ms) {
1007
+ ms.term.scrollToBottom();
1008
+ ms.fitAddon.fit();
1009
+ sendResize(ms);
1010
+ }
1011
+ if (splitMode && splitSecondId) {
1012
+ const ms2 = managed.get(splitSecondId);
1013
+ if (ms2) {
1014
+ ms2.term.scrollToBottom();
1015
+ ms2.fitAddon.fit();
1016
+ sendResize(ms2);
1017
+ }
1018
+ }
1019
+ }
528
1020
  });
529
1021
 
530
- function initTerminal() {
531
- const savedFontSize = parseInt(localStorage.getItem('termbeam-fontsize') || '8', 10);
1022
+ // Reconnect button
1023
+ document.getElementById('reconnect-btn').addEventListener('click', () => {
1024
+ const ms = managed.get(activeId);
1025
+ if (ms) {
1026
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1027
+ ms.exited = false;
1028
+ ms.reconnectDelay = 3000;
1029
+ ms.term.clear();
1030
+ connectSession(ms);
1031
+ }
1032
+ });
1033
+
1034
+ // Stop button
1035
+ document.getElementById('stop-btn').addEventListener('click', async () => {
1036
+ if (!activeId) return;
1037
+ if (!confirm('Stop this session? The process will be killed.')) return;
1038
+ await removeSession(activeId);
1039
+ });
1040
+
1041
+ // Version
1042
+ fetch('/api/version').then(r => r.json()).then(d => {
1043
+ document.getElementById('version-text').textContent = 'v' + d.version;
1044
+ }).catch(() => {});
1045
+ }
1046
+
1047
+ // ===== Session Management =====
1048
+ function addSession(data) {
1049
+ if (managed.has(data.id)) return;
1050
+
532
1051
  const term = new window.Terminal({
533
- cursorBlink: true,
534
- fontSize: savedFontSize,
535
- fontFamily:
536
- "'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
537
- fontWeight: 'normal',
538
- fontWeightBold: 'bold',
539
- letterSpacing: 0,
540
- lineHeight: 1.1,
1052
+ cursorBlink: true, fontSize: fontSize,
1053
+ fontFamily: "'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
1054
+ fontWeight: 'normal', fontWeightBold: 'bold', letterSpacing: 0, lineHeight: 1.1,
541
1055
  theme: getTheme() === 'light' ? lightTermTheme : darkTermTheme,
542
- allowProposedApi: true,
543
- scrollback: 10000,
1056
+ allowProposedApi: true, scrollback: 10000,
544
1057
  });
545
- window._term = term;
546
1058
 
547
1059
  const fitAddon = new window.FitAddon.FitAddon();
548
1060
  const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
549
1061
  term.loadAddon(fitAddon);
550
1062
  term.loadAddon(webLinksAddon);
551
1063
 
552
- const container = document.getElementById('terminal-container');
1064
+ const container = document.createElement('div');
1065
+ container.className = 'terminal-pane';
1066
+ terminalsWrapper.appendChild(container);
553
1067
  term.open(container);
554
- fitAddon.fit();
555
1068
 
556
- let ws = null;
1069
+ // Pointer-event scroll handler — uses setPointerCapture so scrolling
1070
+ // survives xterm DOM re-renders under the finger (touch on a letter).
1071
+ (function() {
1072
+ let startY = null, scrolling = false, accum = 0, ptrId = null;
1073
+
1074
+ container.addEventListener('pointerdown', (e) => {
1075
+ if (e.pointerType !== 'touch' || ptrId !== null) return;
1076
+ startY = e.clientY;
1077
+ scrolling = false;
1078
+ accum = 0;
1079
+ ptrId = e.pointerId;
1080
+ }, { capture: true });
1081
+
1082
+ container.addEventListener('pointermove', (e) => {
1083
+ if (e.pointerId !== ptrId) return;
1084
+ const y = e.clientY;
1085
+ const delta = startY - y;
1086
+ if (!scrolling) {
1087
+ if (Math.abs(delta) > 10) {
1088
+ scrolling = true;
1089
+ term.clearSelection();
1090
+ // Lock all future pointer events to this element —
1091
+ // immune to DOM mutations, xterm re-renders, etc.
1092
+ try { container.setPointerCapture(ptrId); } catch (_) {}
1093
+ } else {
1094
+ return;
1095
+ }
1096
+ }
1097
+ e.preventDefault();
1098
+ e.stopPropagation();
1099
+ startY = y;
1100
+ const lineH = term.options.fontSize * (term.options.lineHeight || 1);
1101
+ accum += delta;
1102
+ const lines = Math.trunc(accum / lineH);
1103
+ if (lines !== 0) { term.scrollLines(lines); accum -= lines * lineH; }
1104
+ }, { capture: true, passive: false });
1105
+
1106
+ function endScroll(e) {
1107
+ if (e.pointerId !== ptrId) return;
1108
+ if (scrolling) {
1109
+ e.stopPropagation();
1110
+ try { container.releasePointerCapture(ptrId); } catch (_) {}
1111
+ }
1112
+ startY = null;
1113
+ scrolling = false;
1114
+ ptrId = null;
1115
+ }
1116
+ container.addEventListener('pointerup', endScroll, { capture: true });
1117
+ container.addEventListener('pointercancel', endScroll, { capture: true });
1118
+ })();
1119
+
1120
+ // Copy on selection
1121
+ term.onSelectionChange(() => {
1122
+ const sel = term.getSelection();
1123
+ if (sel && navigator.clipboard && navigator.clipboard.writeText) {
1124
+ navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {});
1125
+ }
1126
+ });
1127
+
1128
+ // Click to focus
1129
+ container.addEventListener('click', () => {
1130
+ term.focus();
1131
+ // In split mode, clicking a pane makes it active
1132
+ if (splitMode && ms.id !== activeId) {
1133
+ const oldSecond = splitSecondId;
1134
+ splitSecondId = activeId;
1135
+ activeId = ms.id;
1136
+ updateStatusBar();
1137
+ renderTabs();
1138
+ }
1139
+ });
1140
+
1141
+ const ms = {
1142
+ id: data.id, name: data.name,
1143
+ color: data.color || SESSION_COLORS[managed.size % SESSION_COLORS.length],
1144
+ shell: data.shell, cwd: data.cwd, pid: data.pid,
1145
+ term, fitAddon, container,
1146
+ ws: null, exited: false,
1147
+ reconnectTimer: null, reconnectDelay: 3000,
1148
+ lastActivity: data.lastActivity || Date.now(),
1149
+ };
557
1150
 
558
- function connect() {
559
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
560
- ws = new WebSocket(`${proto}://${location.host}/ws`);
1151
+ managed.set(data.id, ms);
561
1152
 
562
- ws.onopen = () => {
1153
+ // Terminal input WebSocket
1154
+ term.onData(input => {
1155
+ if (ms.ws && ms.ws.readyState === 1) {
1156
+ ms.ws.send(JSON.stringify({ type: 'input', data: input }));
1157
+ }
1158
+ });
1159
+
1160
+ connectSession(ms);
1161
+ return ms;
1162
+ }
1163
+
1164
+ function connectSession(ms) {
1165
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1166
+ if (ms.ws) { try { ms.ws.close(); } catch {} }
1167
+
1168
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1169
+ const ws = new WebSocket(`${proto}://${location.host}/ws`);
1170
+ ms.ws = ws;
1171
+
1172
+ ws.onopen = () => {
1173
+ if (ms.ws !== ws) return;
1174
+ ws.send(JSON.stringify({ type: 'attach', sessionId: ms.id }));
1175
+ ms.reconnectDelay = 3000;
1176
+ if (ms.id === activeId) {
563
1177
  statusDot.className = 'connected';
564
1178
  statusText.textContent = '';
565
1179
  reconnectOverlay.classList.remove('visible');
566
- reconnectDelay = 3000;
567
- // Attach to session
568
- ws.send(JSON.stringify({ type: 'attach', sessionId }));
569
- };
570
-
571
- ws.onmessage = (event) => {
572
- try {
573
- const msg = JSON.parse(event.data);
574
- if (msg.type === 'output') {
575
- term.write(msg.data);
576
- } else if (msg.type === 'attached') {
577
- // Send terminal size after attach
578
- const dims = fitAddon.proposeDimensions();
579
- if (dims) {
580
- ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
581
- }
582
- } else if (msg.type === 'exit') {
583
- sessionExited = true;
584
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
585
- statusText.textContent = `Exited (code ${msg.code})`;
1180
+ }
1181
+ renderTabs();
1182
+ };
1183
+
1184
+ ws.onmessage = (event) => {
1185
+ if (ms.ws !== ws) return;
1186
+ try {
1187
+ const msg = JSON.parse(event.data);
1188
+ if (msg.type === 'output') {
1189
+ ms.term.write(msg.data);
1190
+ ms.lastActivity = Date.now();
1191
+ } else if (msg.type === 'attached') {
1192
+ if (ms.container.classList.contains('visible')) {
1193
+ sendResize(ms);
1194
+ }
1195
+ if (ms.id === activeId) { statusDot.className = 'connected'; statusText.textContent = ''; }
1196
+ } else if (msg.type === 'exit') {
1197
+ ms.exited = true;
1198
+ renderTabs();
1199
+ if (ms.id === activeId) {
1200
+ statusText.textContent = 'Exited (code ' + msg.code + ')';
586
1201
  statusDot.className = '';
587
- reconnectOverlay.querySelector('.msg').textContent =
588
- `Session exited (code ${msg.code})`;
1202
+ reconnectOverlay.querySelector('.msg').textContent = 'Session exited (code ' + msg.code + ')';
589
1203
  reconnectOverlay.classList.add('visible');
590
- } else if (msg.type === 'error') {
1204
+ }
1205
+ } else if (msg.type === 'error') {
1206
+ if (ms.id === activeId) {
591
1207
  statusText.textContent = msg.message;
592
1208
  reconnectOverlay.querySelector('.msg').textContent = msg.message;
593
1209
  reconnectOverlay.classList.add('visible');
594
1210
  }
595
- } catch {
596
- term.write(event.data);
597
1211
  }
598
- };
1212
+ } catch {
1213
+ ms.term.write(event.data);
1214
+ }
1215
+ };
599
1216
 
600
- ws.onclose = () => {
601
- statusDot.className = '';
602
- statusText.textContent = 'Disconnected';
603
- reconnectOverlay.classList.add('visible');
604
- if (!sessionExited) {
605
- scheduleReconnect();
1217
+ ws.onclose = () => {
1218
+ if (ms.ws !== ws) return;
1219
+ if (ms.id === activeId) { statusDot.className = ''; statusText.textContent = 'Disconnected'; }
1220
+ if (!ms.exited) {
1221
+ ms.reconnectTimer = setTimeout(() => {
1222
+ ms.reconnectDelay = Math.min(ms.reconnectDelay * 1.5, 30000);
1223
+ connectSession(ms);
1224
+ }, ms.reconnectDelay);
1225
+ }
1226
+ };
1227
+
1228
+ ws.onerror = () => {
1229
+ if (ms.ws !== ws) return;
1230
+ if (ms.id === activeId) statusText.textContent = 'Connection error';
1231
+ };
1232
+ }
1233
+
1234
+ function activateSession(id) {
1235
+ if (!managed.has(id)) return;
1236
+ activeId = id;
1237
+ reconnectOverlay.classList.remove('visible');
1238
+
1239
+ // Show/hide panes
1240
+ for (const [sid, ms] of managed) {
1241
+ const show = sid === id || (splitMode && sid === splitSecondId);
1242
+ ms.container.classList.toggle('visible', show);
1243
+ }
1244
+
1245
+ updateStatusBar();
1246
+
1247
+ // Fit visible terminals and scroll to bottom
1248
+ requestAnimationFrame(() => {
1249
+ for (const [, ms] of managed) {
1250
+ if (ms.container.classList.contains('visible')) {
1251
+ ms.fitAddon.fit();
1252
+ sendResize(ms);
1253
+ ms.term.scrollToBottom();
606
1254
  }
607
- };
1255
+ }
1256
+ });
1257
+
1258
+ // Update URL
1259
+ const url = new URL(location);
1260
+ url.searchParams.set('id', id);
1261
+ history.replaceState(null, '', url);
1262
+
1263
+ renderTabs();
1264
+ }
608
1265
 
609
- ws.onerror = () => {
610
- statusText.textContent = 'Connection error';
611
- };
1266
+ function updateStatusBar() {
1267
+ const ms = managed.get(activeId);
1268
+ const stopBtn = document.getElementById('stop-btn');
1269
+ if (!ms) {
1270
+ stopBtn.style.display = 'none';
1271
+ return;
612
1272
  }
1273
+ stopBtn.style.display = '';
1274
+ sessionNameEl.textContent = ms.name;
1275
+ statusDot.className = (ms.ws && ms.ws.readyState === 1) ? 'connected' : '';
1276
+ statusText.textContent = (ms.ws && ms.ws.readyState === 1) ? '' : (ms.exited ? 'Exited' : 'Disconnected');
1277
+ }
613
1278
 
614
- // Terminal input → WebSocket
615
- term.onData((data) => {
616
- if (ws && ws.readyState === 1) {
617
- ws.send(JSON.stringify({ type: 'input', data }));
1279
+ async function removeSession(id) {
1280
+ try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
1281
+
1282
+ const ms = managed.get(id);
1283
+ if (ms) {
1284
+ if (ms.ws) try { ms.ws.close(); } catch {}
1285
+ if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
1286
+ ms.term.dispose();
1287
+ ms.container.remove();
1288
+ managed.delete(id);
1289
+ }
1290
+
1291
+ if (id === splitSecondId) splitSecondId = null;
1292
+ if (id === activeId) {
1293
+ const remaining = [...managed.keys()];
1294
+ if (remaining.length > 0) activateSession(remaining[0]);
1295
+ else {
1296
+ activeId = null;
1297
+ sessionNameEl.textContent = 'No sessions';
1298
+ statusText.textContent = '';
1299
+ statusDot.className = '';
1300
+ document.getElementById('stop-btn').style.display = 'none';
618
1301
  }
1302
+ }
1303
+ renderTabs();
1304
+ }
1305
+
1306
+ // ===== Tab Rendering =====
1307
+ function renderTabs() {
1308
+ const order = getTabOrder();
1309
+
1310
+ tabListEl.innerHTML = order.map(id => {
1311
+ const ms = managed.get(id);
1312
+ if (!ms) return '';
1313
+ const isActive = id === activeId;
1314
+ const isSplit = splitMode && id === splitSecondId;
1315
+ const statusColor = ms.exited ? 'var(--danger)'
1316
+ : (ms.ws && ms.ws.readyState === 1 ? 'var(--success)' : 'var(--text-muted)');
1317
+ const activity = getActivityLabel(ms.lastActivity);
1318
+ let cls = 'session-tab';
1319
+ if (isActive) cls += ' active';
1320
+ if (isSplit) cls += ' in-split';
1321
+ return '<button class="' + cls + '" data-id="' + escAttr(id) + '" draggable="true">'
1322
+ + '<span class="tab-dot" style="background:' + safeColor(ms.color) + '"></span>'
1323
+ + '<span class="tab-name">' + esc(ms.name) + '</span>'
1324
+ + (activity ? '<span class="tab-activity">' + activity + '</span>' : '')
1325
+ + '<span class="tab-status" style="background:' + safeColor(statusColor) + '"></span>'
1326
+ + '<span class="tab-close" data-close="' + escAttr(id) + '">×</span>'
1327
+ + '</button>';
1328
+ }).join('');
1329
+
1330
+ attachTabHandlers();
1331
+ initTabDrag();
1332
+ }
1333
+
1334
+ // ===== Tab Preview =====
1335
+ const previewEl = document.getElementById('tab-preview');
1336
+ const previewDot = document.getElementById('preview-dot');
1337
+ const previewName = document.getElementById('preview-name');
1338
+ const previewBody = document.getElementById('preview-body');
1339
+ let previewTimer = null;
1340
+ let previewVisible = false;
1341
+
1342
+ function getTerminalLines(ms, count) {
1343
+ const buf = ms.term.buffer.active;
1344
+ const totalRows = buf.length;
1345
+ // Find the last non-empty line (buffer has empty padding rows)
1346
+ let lastNonEmpty = -1;
1347
+ for (let i = totalRows - 1; i >= 0; i--) {
1348
+ const line = buf.getLine(i);
1349
+ if (line && line.translateToString(true).trim() !== '') { lastNonEmpty = i; break; }
1350
+ }
1351
+ if (lastNonEmpty < 0) return [];
1352
+ const start = Math.max(0, lastNonEmpty - count + 1);
1353
+ const lines = [];
1354
+ for (let i = start; i <= lastNonEmpty; i++) {
1355
+ const line = buf.getLine(i);
1356
+ if (line) lines.push(line.translateToString(true));
1357
+ }
1358
+ return lines;
1359
+ }
1360
+
1361
+ function showPreview(tabEl, sessionId) {
1362
+ const ms = managed.get(sessionId);
1363
+ if (!ms) return;
1364
+ const lines = getTerminalLines(ms, 12);
1365
+ previewDot.style.background = ms.color;
1366
+ previewName.textContent = ms.name;
1367
+ if (lines.length === 0) {
1368
+ previewBody.innerHTML = '<div class="preview-empty">No output yet</div>';
1369
+ } else {
1370
+ previewBody.textContent = lines.join('\n');
1371
+ }
1372
+
1373
+ previewEl.classList.add('visible');
1374
+ previewVisible = true;
1375
+
1376
+ // Position: above the tab, centered
1377
+ const tabRect = tabEl.getBoundingClientRect();
1378
+ const pw = previewEl.offsetWidth;
1379
+ const ph = previewEl.offsetHeight;
1380
+ let left = tabRect.left + tabRect.width / 2 - pw / 2;
1381
+ left = Math.max(6, Math.min(left, window.innerWidth - pw - 6));
1382
+ let top = tabRect.top - ph - 6;
1383
+ if (top < 6) top = tabRect.bottom + 6; // Below if not enough room
1384
+ previewEl.style.left = left + 'px';
1385
+ previewEl.style.top = top + 'px';
1386
+ }
1387
+
1388
+ function hidePreview() {
1389
+ previewEl.classList.remove('visible');
1390
+ previewVisible = false;
1391
+ if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
1392
+ }
1393
+
1394
+ function attachTabHandlers() {
1395
+ tabListEl.querySelectorAll('.session-tab').forEach(tab => {
1396
+ tab.addEventListener('click', e => {
1397
+ if (e.target.dataset.close) return;
1398
+ hidePreview();
1399
+ activateSession(tab.dataset.id);
1400
+ });
1401
+
1402
+ // Desktop: hover preview (non-active tabs only)
1403
+ tab.addEventListener('mouseenter', () => {
1404
+ if (tab.dataset.id === activeId) return;
1405
+ previewTimer = setTimeout(() => showPreview(tab, tab.dataset.id), 400);
1406
+ });
1407
+ tab.addEventListener('mouseleave', () => hidePreview());
1408
+ });
1409
+ tabListEl.querySelectorAll('.tab-close').forEach(btn => {
1410
+ btn.addEventListener('click', e => {
1411
+ e.stopPropagation();
1412
+ if (confirm('Close this session?')) removeSession(btn.dataset.close);
1413
+ });
619
1414
  });
1415
+ }
620
1416
 
621
- // Resize
622
- function doResize() {
623
- fitAddon.fit();
624
- if (ws && ws.readyState === 1) {
625
- const dims = fitAddon.proposeDimensions();
626
- if (dims) {
627
- ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
1417
+ // ===== Sessions Side Panel =====
1418
+ const sidePanel = document.getElementById('side-panel');
1419
+ const sidePanelBackdrop = document.getElementById('side-panel-backdrop');
1420
+ const sidePanelList = document.getElementById('side-panel-list');
1421
+
1422
+ function openSidePanel() {
1423
+ renderSidePanel();
1424
+ sidePanel.classList.add('open');
1425
+ sidePanelBackdrop.classList.add('visible');
1426
+ }
1427
+
1428
+ function closeSidePanel() {
1429
+ sidePanel.classList.remove('open');
1430
+ sidePanelBackdrop.classList.remove('visible');
1431
+ }
1432
+
1433
+ function renderSidePanel() {
1434
+ const order = getTabOrder();
1435
+ sidePanelList.innerHTML = order.map(id => {
1436
+ const ms = managed.get(id);
1437
+ if (!ms) return '';
1438
+ const isActive = id === activeId;
1439
+ const statusColor = ms.exited ? 'var(--danger)'
1440
+ : (ms.ws && ms.ws.readyState === 1 ? 'var(--success)' : 'var(--text-muted)');
1441
+ const activity = getActivityLabel(ms.lastActivity);
1442
+ const lines = getTerminalLines(ms, 6);
1443
+ const previewContent = lines.length > 0
1444
+ ? '<div class="side-panel-card-preview">' + esc(lines.join('\n')) + '</div>'
1445
+ : '<div class="side-panel-card-preview empty">No output yet</div>';
1446
+ return '<div class="side-panel-card' + (isActive ? ' active' : '') + '" data-id="' + escAttr(id) + '">'
1447
+ + '<div class="side-panel-card-header">'
1448
+ + '<span class="side-panel-card-dot" style="background:' + safeColor(ms.color) + '"></span>'
1449
+ + '<span class="side-panel-card-name">' + esc(ms.name) + '</span>'
1450
+ + '<span class="side-panel-card-status" style="background:' + safeColor(statusColor) + '"></span>'
1451
+ + '<button class="side-panel-card-close" data-close-id="' + escAttr(id) + '" title="Close session">×</button>'
1452
+ + '</div>'
1453
+ + (activity ? '<div class="side-panel-card-meta">' + activity + ' ago</div>' : '')
1454
+ + previewContent
1455
+ + '</div>';
1456
+ }).join('');
1457
+
1458
+ sidePanelList.querySelectorAll('.side-panel-card-close').forEach(btn => {
1459
+ btn.addEventListener('click', async (e) => {
1460
+ e.stopPropagation();
1461
+ const id = btn.dataset.closeId;
1462
+ if (confirm('Close this session?')) {
1463
+ await removeSession(id);
1464
+ renderSidePanel();
1465
+ }
1466
+ });
1467
+ });
1468
+
1469
+ sidePanelList.querySelectorAll('.side-panel-card').forEach(card => {
1470
+ card.addEventListener('click', () => {
1471
+ activateSession(card.dataset.id);
1472
+ closeSidePanel();
1473
+ });
1474
+ });
1475
+ }
1476
+
1477
+ document.getElementById('panel-toggle').addEventListener('click', openSidePanel);
1478
+ document.getElementById('side-panel-close').addEventListener('click', closeSidePanel);
1479
+ sidePanelBackdrop.addEventListener('click', closeSidePanel);
1480
+
1481
+ // ===== Drag to Reorder =====
1482
+ function initTabDrag() {
1483
+ let dragId = null;
1484
+ let dragEl = null;
1485
+
1486
+ tabListEl.querySelectorAll('.session-tab').forEach(tab => {
1487
+ // --- Touch drag (long press) + preview ---
1488
+ let longPressTimer = null;
1489
+ let previewHoldTimer = null;
1490
+ let startX = 0, startY = 0;
1491
+ let isDragging = false;
1492
+ let didPreview = false;
1493
+
1494
+ tab.addEventListener('touchstart', e => {
1495
+ startX = e.touches[0].clientX;
1496
+ startY = e.touches[0].clientY;
1497
+ didPreview = false;
1498
+
1499
+ // 200ms: show preview (if not active tab)
1500
+ if (tab.dataset.id !== activeId) {
1501
+ previewHoldTimer = setTimeout(() => {
1502
+ didPreview = true;
1503
+ showPreview(tab, tab.dataset.id);
1504
+ if (navigator.vibrate) navigator.vibrate(30);
1505
+ }, 200);
1506
+ }
1507
+
1508
+ // 600ms: enter drag mode (dismiss preview)
1509
+ longPressTimer = setTimeout(() => {
1510
+ hidePreview();
1511
+ isDragging = true;
1512
+ dragId = tab.dataset.id;
1513
+ dragEl = tab;
1514
+ tab.classList.add('dragging');
1515
+ if (navigator.vibrate) navigator.vibrate(50);
1516
+ }, 600);
1517
+ }, { passive: true });
1518
+
1519
+ tab.addEventListener('touchmove', e => {
1520
+ const dx = Math.abs(e.touches[0].clientX - startX);
1521
+ const dy = Math.abs(e.touches[0].clientY - startY);
1522
+ if (!isDragging && (dx > 10 || dy > 10)) {
1523
+ clearTimeout(longPressTimer);
1524
+ clearTimeout(previewHoldTimer);
1525
+ hidePreview();
1526
+ return;
1527
+ }
1528
+ if (isDragging) {
1529
+ e.preventDefault();
1530
+ const touch = e.touches[0];
1531
+ const el = document.elementFromPoint(touch.clientX, touch.clientY);
1532
+ const target = el ? el.closest('.session-tab') : null;
1533
+ if (target && target !== dragEl && target.dataset.id) {
1534
+ const rect = target.getBoundingClientRect();
1535
+ if (touch.clientX < rect.left + rect.width / 2) {
1536
+ target.parentNode.insertBefore(dragEl, target);
1537
+ } else {
1538
+ target.parentNode.insertBefore(dragEl, target.nextSibling);
1539
+ }
1540
+ }
1541
+ }
1542
+ });
1543
+
1544
+ tab.addEventListener('touchend', () => {
1545
+ clearTimeout(longPressTimer);
1546
+ clearTimeout(previewHoldTimer);
1547
+ hidePreview();
1548
+ if (isDragging) {
1549
+ isDragging = false;
1550
+ dragEl.classList.remove('dragging');
1551
+ // Save new order from DOM
1552
+ const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(t => t.dataset.id);
1553
+ saveTabOrder(newOrder);
1554
+ dragId = null;
1555
+ dragEl = null;
628
1556
  }
1557
+ });
1558
+
1559
+ // --- Desktop drag ---
1560
+ tab.addEventListener('dragstart', e => {
1561
+ dragId = tab.dataset.id;
1562
+ dragEl = tab;
1563
+ tab.classList.add('dragging');
1564
+ e.dataTransfer.effectAllowed = 'move';
1565
+ });
1566
+
1567
+ tab.addEventListener('dragover', e => {
1568
+ e.preventDefault();
1569
+ e.dataTransfer.dropEffect = 'move';
1570
+ });
1571
+
1572
+ tab.addEventListener('drop', e => {
1573
+ e.preventDefault();
1574
+ if (dragId && tab.dataset.id !== dragId) {
1575
+ const rect = tab.getBoundingClientRect();
1576
+ if (e.clientX < rect.left + rect.width / 2) {
1577
+ tab.parentNode.insertBefore(dragEl, tab);
1578
+ } else {
1579
+ tab.parentNode.insertBefore(dragEl, tab.nextSibling);
1580
+ }
1581
+ const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(t => t.dataset.id);
1582
+ saveTabOrder(newOrder);
1583
+ }
1584
+ });
1585
+
1586
+ tab.addEventListener('dragend', () => {
1587
+ if (dragEl) dragEl.classList.remove('dragging');
1588
+ dragId = null;
1589
+ dragEl = null;
1590
+ });
1591
+ });
1592
+ }
1593
+
1594
+ // ===== Split View =====
1595
+ function toggleSplit() {
1596
+ splitMode = !splitMode;
1597
+ document.getElementById('split-toggle').classList.toggle('active', splitMode);
1598
+
1599
+ if (splitMode) {
1600
+ // Find a second session to show
1601
+ const order = getTabOrder();
1602
+ const others = order.filter(id => id !== activeId);
1603
+ splitSecondId = others.length > 0 ? others[0] : null;
1604
+
1605
+ if (splitSecondId) {
1606
+ const isMobile = window.innerWidth < 640;
1607
+ terminalsWrapper.classList.add(isMobile ? 'split-v' : 'split-h');
1608
+ managed.get(splitSecondId).container.classList.add('visible');
1609
+ }
1610
+ } else {
1611
+ terminalsWrapper.classList.remove('split-h', 'split-v');
1612
+ splitSecondId = null;
1613
+ // Hide all except active
1614
+ for (const [id, ms] of managed) {
1615
+ ms.container.classList.toggle('visible', id === activeId);
629
1616
  }
630
1617
  }
631
- window.addEventListener('resize', doResize);
632
- screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
633
1618
 
634
- // Key bar
635
- // Use mousedown + preventDefault to stop buttons from stealing focus/opening keyboard
636
- document.getElementById('key-bar').addEventListener('mousedown', (e) => {
637
- // Only prevent default on buttons, not the scrollable bar itself
638
- if (e.target.closest('.key-btn')) {
639
- e.preventDefault();
1619
+ requestAnimationFrame(() => {
1620
+ for (const [, ms] of managed) {
1621
+ if (ms.container.classList.contains('visible')) { ms.fitAddon.fit(); sendResize(ms); }
640
1622
  }
641
1623
  });
642
- // touchstart must be passive to allow native horizontal scrolling
643
- document
644
- .getElementById('key-bar')
645
- .addEventListener('touchstart', () => {}, { passive: true });
646
- document.getElementById('key-bar').addEventListener('click', (e) => {
647
- const btn = e.target.closest('.key-btn');
1624
+
1625
+ renderTabs();
1626
+ }
1627
+
1628
+ // ===== Key Bar =====
1629
+ function setupKeyBar() {
1630
+ const keyBar = document.getElementById('key-bar');
1631
+ let repeatTimer = null;
1632
+ let repeatInterval = null;
1633
+
1634
+ function sendKey(btn) {
648
1635
  if (!btn || !btn.dataset.key) return;
649
- if (ws && ws.readyState === 1) {
650
- ws.send(JSON.stringify({ type: 'input', data: btn.dataset.key }));
1636
+ const data = btn.dataset.key === 'enter' ? '\r' : btn.dataset.key;
1637
+ const ms = managed.get(activeId);
1638
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1639
+ ms.ws.send(JSON.stringify({ type: 'input', data }));
651
1640
  }
652
- // Don't call term.focus() here — it opens the soft keyboard
653
- });
1641
+ }
654
1642
 
655
- // Zoom (status bar)
656
- const MIN_FONT = 2, MAX_FONT = 28;
657
- let fontSize = savedFontSize;
658
- function applyZoom(size) {
659
- fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
660
- term.options.fontSize = fontSize;
661
- localStorage.setItem('termbeam-fontsize', fontSize);
662
- doResize();
1643
+ function stopRepeat() {
1644
+ clearTimeout(repeatTimer);
1645
+ clearInterval(repeatInterval);
1646
+ repeatTimer = null;
1647
+ repeatInterval = null;
663
1648
  }
664
- document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
665
- document.getElementById('zoom-out').addEventListener('click', () => applyZoom(fontSize - 2));
666
1649
 
667
- // Clipboard: copy on selection
668
- function showToast(msg) {
669
- const toast = document.getElementById('copy-toast');
670
- toast.textContent = msg;
671
- toast.classList.add('visible');
672
- clearTimeout(toast._timer);
673
- toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
1650
+ function startRepeat(btn) {
1651
+ stopRepeat();
1652
+ sendKey(btn);
1653
+ repeatTimer = setTimeout(() => {
1654
+ repeatInterval = setInterval(() => sendKey(btn), 80);
1655
+ }, 400);
674
1656
  }
675
1657
 
676
- term.onSelectionChange(() => {
677
- const sel = term.getSelection();
678
- if (sel && navigator.clipboard && navigator.clipboard.writeText) {
679
- navigator.clipboard.writeText(sel).then(() => {
680
- showToast('Copied!');
681
- }).catch(() => {});
682
- }
1658
+ keyBar.addEventListener('mousedown', e => {
1659
+ const btn = e.target.closest('.key-btn');
1660
+ if (btn) { e.preventDefault(); startRepeat(btn); }
1661
+ });
1662
+ keyBar.addEventListener('mouseup', stopRepeat);
1663
+ keyBar.addEventListener('mouseleave', stopRepeat);
1664
+
1665
+ keyBar.addEventListener('touchstart', e => {
1666
+ const btn = e.target.closest('.key-btn');
1667
+ if (btn) startRepeat(btn);
1668
+ }, { passive: true });
1669
+ keyBar.addEventListener('touchend', stopRepeat);
1670
+ keyBar.addEventListener('touchcancel', stopRepeat);
1671
+
1672
+ keyBar.addEventListener('click', e => {
1673
+ const btn = e.target.closest('.key-btn');
1674
+ if (btn) { const ms = managed.get(activeId); if (ms) ms.term.focus(); }
683
1675
  });
1676
+ }
684
1677
 
685
- // Clipboard: paste button
1678
+ // ===== Paste =====
1679
+ function setupPaste() {
686
1680
  const pasteOverlay = document.getElementById('paste-overlay');
687
1681
  const pasteInput = document.getElementById('paste-input');
688
1682
 
@@ -692,24 +1686,16 @@
692
1686
  pasteInput.focus();
693
1687
  }
694
1688
 
695
- function closePasteModal() {
696
- pasteOverlay.classList.remove('visible');
697
- pasteInput.value = '';
698
- }
699
-
700
- document.getElementById('paste-btn').addEventListener('mousedown', (e) => {
701
- e.preventDefault();
702
- });
1689
+ document.getElementById('paste-btn').addEventListener('mousedown', e => e.preventDefault());
703
1690
  document.getElementById('paste-btn').addEventListener('click', () => {
704
1691
  if (navigator.clipboard && navigator.clipboard.readText) {
705
- navigator.clipboard.readText().then((text) => {
706
- if (text && ws && ws.readyState === 1) {
707
- ws.send(JSON.stringify({ type: 'input', data: text }));
1692
+ navigator.clipboard.readText().then(text => {
1693
+ const ms = managed.get(activeId);
1694
+ if (text && ms && ms.ws && ms.ws.readyState === 1) {
1695
+ ms.ws.send(JSON.stringify({ type: 'input', data: text }));
708
1696
  showToast('Pasted!');
709
1697
  }
710
- }).catch(() => {
711
- openPasteModal();
712
- });
1698
+ }).catch(() => openPasteModal());
713
1699
  } else {
714
1700
  openPasteModal();
715
1701
  }
@@ -717,93 +1703,260 @@
717
1703
 
718
1704
  document.getElementById('paste-send').addEventListener('click', () => {
719
1705
  const text = pasteInput.value;
720
- if (text && ws && ws.readyState === 1) {
721
- ws.send(JSON.stringify({ type: 'input', data: text }));
1706
+ const ms = managed.get(activeId);
1707
+ if (text && ms && ms.ws && ms.ws.readyState === 1) {
1708
+ ms.ws.send(JSON.stringify({ type: 'input', data: text }));
722
1709
  }
723
- closePasteModal();
1710
+ pasteOverlay.classList.remove('visible');
1711
+ pasteInput.value = '';
724
1712
  });
725
- document.getElementById('paste-cancel').addEventListener('click', closePasteModal);
726
-
727
- function scheduleReconnect() {
728
- if (reconnectTimer) clearTimeout(reconnectTimer);
729
- const seconds = Math.round(reconnectDelay / 1000);
730
- reconnectOverlay.querySelector('.msg').textContent =
731
- `Disconnected — reconnecting in ${seconds}s…`;
732
- reconnectTimer = setTimeout(() => {
733
- reconnectTimer = null;
734
- reconnectOverlay.querySelector('.msg').textContent = 'Reconnecting…';
735
- reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY);
736
- connect();
737
- }, reconnectDelay);
738
- }
739
1713
 
740
- // Reconnect
741
- document.getElementById('reconnect-btn').addEventListener('click', () => {
742
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
743
- sessionExited = false;
744
- reconnectDelay = 3000;
745
- term.clear();
746
- connect();
1714
+ document.getElementById('paste-cancel').addEventListener('click', () => {
1715
+ pasteOverlay.classList.remove('visible');
1716
+ pasteInput.value = '';
747
1717
  });
1718
+ }
748
1719
 
749
- // Stop session
750
- document.getElementById('stop-btn').addEventListener('click', async () => {
751
- if (!confirm('Stop this session? The process will be killed.')) return;
752
- try {
753
- await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });
754
- } catch {}
755
- location.href = '/';
1720
+ // ===== New Session Modal =====
1721
+ let shellsLoaded = false;
1722
+
1723
+ function openNewSessionModal() {
1724
+ loadShellsForModal();
1725
+ document.getElementById('new-session-modal').classList.add('visible');
1726
+ }
1727
+
1728
+ function setupNewSessionModal() {
1729
+ document.getElementById('tab-new-btn').addEventListener('click', openNewSessionModal);
1730
+ document.getElementById('side-panel-new-btn').addEventListener('click', () => {
1731
+ closeSidePanel();
1732
+ openNewSessionModal();
1733
+ });
1734
+ document.getElementById('ns-cancel').addEventListener('click', () => {
1735
+ document.getElementById('new-session-modal').classList.remove('visible');
1736
+ });
1737
+ document.getElementById('new-session-modal').addEventListener('click', e => {
1738
+ if (e.target.id === 'new-session-modal') document.getElementById('new-session-modal').classList.remove('visible');
756
1739
  });
757
1740
 
758
- // Tap terminal area to toggle keyboard (intentional user action)
759
- container.addEventListener('click', () => term.focus());
1741
+ // Color picker
1742
+ document.getElementById('ns-color-picker').addEventListener('click', e => {
1743
+ const swatch = e.target.closest('.color-swatch');
1744
+ if (!swatch) return;
1745
+ document.querySelectorAll('#ns-color-picker .color-swatch').forEach(s => s.classList.remove('selected'));
1746
+ swatch.classList.add('selected');
1747
+ });
760
1748
 
761
- // Handle mobile soft keyboard via visualViewport
762
- // When keyboard opens, the viewport shrinks — reposition key bar and resize terminal
763
- if (window.visualViewport) {
764
- const keyBar = document.getElementById('key-bar');
765
- const statusBar = document.getElementById('status-bar');
1749
+ document.getElementById('ns-create').addEventListener('click', createNewSession);
766
1750
 
767
- function onViewportResize() {
768
- const vv = window.visualViewport;
769
- const keyboardHeight = window.innerHeight - vv.height;
1751
+ // Folder browser
1752
+ const nsCwdInput = document.getElementById('ns-cwd');
1753
+ const nsBrowserOverlay = document.getElementById('ns-browser-overlay');
1754
+ const nsBrowserList = document.getElementById('ns-browser-list');
1755
+ const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
1756
+ const nsBrowserPath = document.getElementById('ns-browser-path');
1757
+ let nsBrowsePath = '/';
770
1758
 
771
- if (keyboardHeight > 50) {
772
- // Keyboard is open move key bar above it
773
- keyBar.style.bottom = keyboardHeight + 'px';
774
- container.style.bottom = 52 + keyboardHeight + 'px';
775
- } else {
776
- // Keyboard closed
777
- keyBar.style.bottom = '0px';
778
- container.style.bottom = '52px';
1759
+ document.getElementById('ns-browse-btn').addEventListener('click', () => {
1760
+ const initial = nsCwdInput.value.trim() || '/';
1761
+ nsBrowseNavigate(initial);
1762
+ nsBrowserOverlay.classList.add('visible');
1763
+ });
1764
+
1765
+ document.getElementById('ns-browser-close').addEventListener('click', () => {
1766
+ nsBrowserOverlay.classList.remove('visible');
1767
+ });
1768
+ nsBrowserOverlay.addEventListener('click', (e) => {
1769
+ if (e.target === nsBrowserOverlay) nsBrowserOverlay.classList.remove('visible');
1770
+ });
1771
+
1772
+ document.getElementById('ns-browser-select').addEventListener('click', () => {
1773
+ nsCwdInput.value = nsBrowsePath;
1774
+ nsBrowserOverlay.classList.remove('visible');
1775
+ });
1776
+
1777
+ async function nsBrowseNavigate(dir) {
1778
+ nsBrowsePath = dir;
1779
+ nsBrowserPath.textContent = dir;
1780
+ nsBrowseRenderBreadcrumb(dir);
1781
+ nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
1782
+ try {
1783
+ const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1784
+ const data = await res.json();
1785
+ if (!data.dirs.length) {
1786
+ nsBrowserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
1787
+ return;
779
1788
  }
780
- // Refit terminal to new available space
781
- setTimeout(() => doResize(), 50);
1789
+ nsBrowserList.innerHTML = data.dirs.map(d => {
1790
+ const name = d.split(/[/\\]/).pop();
1791
+ return `<div class="folder-item" data-path="${escAttr(d)}">
1792
+ <span class="folder-icon">📁</span>
1793
+ <span class="folder-name">${esc(name)}</span>
1794
+ <span class="folder-arrow">›</span>
1795
+ </div>`;
1796
+ }).join('');
1797
+ nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
1798
+ el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
1799
+ });
1800
+ nsBrowserList.scrollTop = 0;
1801
+ } catch {
1802
+ nsBrowserList.innerHTML = '<div class="browser-empty">Error loading folders</div>';
782
1803
  }
1804
+ }
783
1805
 
784
- window.visualViewport.addEventListener('resize', onViewportResize);
785
- window.visualViewport.addEventListener('scroll', onViewportResize);
1806
+ function nsBrowseRenderBreadcrumb(dir) {
1807
+ const parts = dir.split('/').filter(Boolean);
1808
+ let html = `<button class="crumb" data-path="/">/</button>`;
1809
+ let accumulated = '';
1810
+ parts.forEach((part, i) => {
1811
+ accumulated += '/' + part;
1812
+ const isCurrent = i === parts.length - 1;
1813
+ html += `<span class="crumb-sep">›</span>`;
1814
+ html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
1815
+ });
1816
+ nsBrowserBreadcrumb.innerHTML = html;
1817
+ nsBrowserBreadcrumb.querySelectorAll('.crumb').forEach(el => {
1818
+ el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
1819
+ });
1820
+ nsBrowserBreadcrumb.scrollLeft = nsBrowserBreadcrumb.scrollWidth;
786
1821
  }
1822
+ }
1823
+
1824
+ async function loadShellsForModal() {
1825
+ if (shellsLoaded) return;
1826
+ const sel = document.getElementById('ns-shell');
1827
+ try {
1828
+ const data = await fetch('/api/shells').then(r => r.json());
1829
+ sel.innerHTML = data.shells.map(s =>
1830
+ '<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
1831
+ + esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
1832
+ ).join('');
1833
+ shellsLoaded = true;
1834
+ } catch { sel.innerHTML = '<option value="">Could not detect shells</option>'; }
1835
+ }
1836
+
1837
+ async function createNewSession() {
1838
+ const name = document.getElementById('ns-name').value.trim();
1839
+ const shell = document.getElementById('ns-shell').value;
1840
+ const cwd = document.getElementById('ns-cwd').value.trim();
1841
+ const cmd = document.getElementById('ns-cmd').value.trim();
1842
+ const colorEl = document.querySelector('#ns-color-picker .color-swatch.selected');
1843
+ const color = colorEl ? colorEl.dataset.color : null;
1844
+
1845
+ const body = {};
1846
+ if (name) body.name = name;
1847
+ if (shell) body.shell = shell;
1848
+ if (cwd) body.cwd = cwd;
1849
+ if (cmd) body.initialCommand = cmd;
1850
+ if (color) body.color = color;
787
1851
 
788
- // Fetch session name
789
- fetch('/api/sessions')
790
- .then((r) => r.json())
791
- .then((sessions) => {
792
- const s = sessions.find((s) => s.id === sessionId);
793
- if (s) sessionName.textContent = s.name;
1852
+ try {
1853
+ const res = await fetch('/api/sessions', {
1854
+ method: 'POST',
1855
+ headers: { 'Content-Type': 'application/json' },
1856
+ body: JSON.stringify(body),
794
1857
  });
1858
+ const data = await res.json();
1859
+
1860
+ // Fetch full session list to get the new session data
1861
+ const list = await fetch('/api/sessions').then(r => r.json());
1862
+ const newSession = list.find(s => s.id === data.id);
1863
+ if (newSession) {
1864
+ addSession(newSession);
1865
+ activateSession(data.id);
1866
+ }
1867
+
1868
+ document.getElementById('new-session-modal').classList.remove('visible');
1869
+ document.getElementById('ns-name').value = '';
1870
+ document.getElementById('ns-cmd').value = '';
1871
+ document.getElementById('ns-cwd').value = '';
1872
+ } catch (err) {
1873
+ console.error('Failed to create session:', err);
1874
+ }
1875
+ }
1876
+
1877
+ // ===== Polling =====
1878
+ function startPolling() {
1879
+ setInterval(async () => {
1880
+ try {
1881
+ const list = await fetch('/api/sessions').then(r => r.json());
1882
+ const serverIds = new Set(list.map(s => s.id));
1883
+
1884
+ // Add new sessions created elsewhere
1885
+ for (const s of list) {
1886
+ if (!managed.has(s.id)) {
1887
+ addSession(s);
1888
+ } else {
1889
+ // Update metadata
1890
+ const ms = managed.get(s.id);
1891
+ ms.name = s.name;
1892
+ ms.color = s.color;
1893
+ ms.lastActivity = s.lastActivity;
1894
+ }
1895
+ }
795
1896
 
796
- // Fetch version
797
- fetch('/api/version')
798
- .then((r) => r.json())
799
- .then((d) => {
800
- document.getElementById('version-text').textContent = 'v' + d.version;
801
- })
802
- .catch(() => {});
1897
+ // Remove sessions deleted elsewhere
1898
+ for (const id of [...managed.keys()]) {
1899
+ if (!serverIds.has(id)) {
1900
+ const ms = managed.get(id);
1901
+ if (ms.ws) try { ms.ws.close(); } catch {}
1902
+ if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
1903
+ ms.term.dispose();
1904
+ ms.container.remove();
1905
+ managed.delete(id);
1906
+ if (id === splitSecondId) splitSecondId = null;
1907
+ if (id === activeId) {
1908
+ const remaining = [...managed.keys()];
1909
+ if (remaining.length > 0) activateSession(remaining[0]);
1910
+ else {
1911
+ activeId = null;
1912
+ sessionNameEl.textContent = 'No sessions';
1913
+ statusText.textContent = '';
1914
+ statusDot.className = '';
1915
+ document.getElementById('stop-btn').style.display = 'none';
1916
+ }
1917
+ }
1918
+ }
1919
+ }
803
1920
 
804
- connect();
1921
+ renderTabs();
1922
+ } catch {}
1923
+ }, 3000);
805
1924
  }
806
1925
 
1926
+ // ===== Share Button =====
1927
+ function copyToClipboardFallback(text) {
1928
+ const ta = document.createElement('textarea');
1929
+ ta.value = text;
1930
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1931
+ document.body.appendChild(ta);
1932
+ ta.select();
1933
+ try { document.execCommand('copy'); } catch {}
1934
+ document.body.removeChild(ta);
1935
+ }
1936
+
1937
+ document.getElementById('share-btn').addEventListener('click', async () => {
1938
+ const url = location.href;
1939
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1940
+ try { await navigator.clipboard.writeText(url); showToast('Link copied!'); return; } catch {}
1941
+ }
1942
+ copyToClipboardFallback(url);
1943
+ showToast('Link copied!');
1944
+ });
1945
+
1946
+ // ===== Refresh Button =====
1947
+ document.getElementById('refresh-btn').addEventListener('click', async () => {
1948
+ if ('caches' in window) {
1949
+ const keys = await caches.keys();
1950
+ await Promise.all(keys.map(k => caches.delete(k)));
1951
+ }
1952
+ if (navigator.serviceWorker) {
1953
+ const reg = await navigator.serviceWorker.getRegistration();
1954
+ if (reg) await reg.update();
1955
+ }
1956
+ location.reload();
1957
+ });
1958
+
1959
+ // ===== Service Worker =====
807
1960
  if ('serviceWorker' in navigator) {
808
1961
  navigator.serviceWorker.register('/sw.js').catch(() => {});
809
1962
  }