@wangyaoshen/remux 0.3.9-dev.390cb29 → 0.3.10-dev.574c4d2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/macos/Package.swift +5 -0
- package/apps/macos/Sources/Remux/AppCommand.swift +114 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +26 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +56 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +18 -26
- package/apps/macos/Sources/Remux/NotificationManager.swift +52 -7
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +1 -1
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +10 -4
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +35 -5
- package/apps/macos/Sources/Remux/WindowObserver.swift +38 -0
- package/apps/macos/Tests/RemuxTests/AppCommandTests.swift +30 -0
- package/apps/macos/Tests/RemuxTests/NotificationManagerTests.swift +28 -0
- package/package.json +1 -1
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +3 -3
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +3 -3
- package/pty-daemon.js +17 -11
- package/server.js +250 -1467
- package/services/discovery/local-server.js +98 -0
- package/services/discovery/worker.js +125 -0
- package/services/discovery/wrangler.toml +7 -0
- package/src/pty-daemon.ts +17 -11
- package/src/server.ts +205 -1458
- package/src/session.ts +42 -4
- package/tests/auth.test.js +1 -1
- package/tests/e2e/app.spec.js +113 -288
- package/tests/pty-daemon.test.js +20 -1
- package/tests/server.test.js +49 -11
- package/vitest.config.js +1 -0
package/src/server.ts
CHANGED
|
@@ -166,13 +166,15 @@ startup().catch((e) => {
|
|
|
166
166
|
startupDone = true;
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
|
|
170
|
+
// ── HTML Template (Emergency Mode) ──────────────────────────────
|
|
171
|
+
// Read-only terminal display + compose input. No raw keyboard input.
|
|
170
172
|
|
|
171
173
|
const HTML_TEMPLATE = `<!doctype html>
|
|
172
174
|
<html lang="en" data-theme="dark">
|
|
173
175
|
<head>
|
|
174
176
|
<meta charset="UTF-8" />
|
|
175
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0,
|
|
177
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
176
178
|
<title>Remux</title>
|
|
177
179
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬛</text></svg>">
|
|
178
180
|
<style>
|
|
@@ -197,8 +199,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
197
199
|
--compose-bg: #3a3a3a;
|
|
198
200
|
--compose-border: #555;
|
|
199
201
|
--tab-hover: #383838;
|
|
200
|
-
--view-switch-bg: #1a1a1a;
|
|
201
|
-
--inspect-meta-border: #333;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
[data-theme="light"] {
|
|
@@ -221,8 +221,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
221
221
|
--compose-bg: #e8e8e8;
|
|
222
222
|
--compose-border: #c0c0c0;
|
|
223
223
|
--tab-hover: #d6d6d6;
|
|
224
|
-
--view-switch-bg: #d4d4d4;
|
|
225
|
-
--inspect-meta-border: #d4d4d4;
|
|
226
224
|
}
|
|
227
225
|
|
|
228
226
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
@@ -241,6 +239,17 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
241
239
|
font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 4px; }
|
|
242
240
|
.sidebar-header button:hover { color: var(--text-bright); background: var(--compose-bg); }
|
|
243
241
|
|
|
242
|
+
.session-composer { display: none; padding: 0 6px 8px; gap: 6px; }
|
|
243
|
+
.session-composer.visible { display: flex; }
|
|
244
|
+
.session-composer input { flex: 1; min-width: 0; padding: 6px 8px; font-size: 12px; font-family: inherit;
|
|
245
|
+
background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 4px;
|
|
246
|
+
color: var(--text-bright); outline: none; }
|
|
247
|
+
.session-composer input:focus { border-color: var(--accent); }
|
|
248
|
+
.session-composer button { padding: 6px 10px; font-size: 11px; font-family: inherit;
|
|
249
|
+
border-radius: 4px; border: 1px solid var(--compose-border); cursor: pointer; }
|
|
250
|
+
.session-composer button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
251
|
+
.session-composer button.secondary { background: var(--compose-bg); color: var(--text-bright); }
|
|
252
|
+
|
|
244
253
|
.session-list { flex: 1; overflow-y: auto; padding: 4px 6px; }
|
|
245
254
|
.session-item { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 4px;
|
|
246
255
|
font-size: 13px; cursor: pointer; color: var(--text); border: none; background: none;
|
|
@@ -256,23 +265,15 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
256
265
|
.session-item .del:hover { color: var(--dot-err); background: var(--compose-bg); }
|
|
257
266
|
|
|
258
267
|
.sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--border);
|
|
259
|
-
display: flex;
|
|
268
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
260
269
|
.sidebar-footer .version { font-size: 10px; color: var(--text-dim); }
|
|
261
|
-
|
|
262
|
-
.sidebar-footer .status { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
|
270
|
+
|
|
263
271
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; }
|
|
264
272
|
.status-dot.connected { background: var(--dot-ok); }
|
|
265
273
|
.status-dot.disconnected { background: var(--dot-err); }
|
|
266
274
|
.status-dot.connecting { background: var(--dot-warn); animation: pulse 1s infinite; }
|
|
267
275
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
|
268
276
|
|
|
269
|
-
.role-indicator { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
|
|
270
|
-
.role-indicator.active { color: var(--dot-ok); }
|
|
271
|
-
.role-indicator.observer { color: var(--dot-warn); }
|
|
272
|
-
.role-btn { background: none; border: 1px solid var(--border); border-radius: 4px;
|
|
273
|
-
color: var(--text-muted); font-size: 10px; padding: 2px 8px; cursor: pointer; font-family: inherit; }
|
|
274
|
-
.role-btn:hover { color: var(--text-bright); border-color: var(--text-muted); }
|
|
275
|
-
|
|
276
277
|
/* -- Theme toggle -- */
|
|
277
278
|
.theme-toggle { background: none; border: none; cursor: pointer; font-size: 16px;
|
|
278
279
|
color: var(--text-muted); padding: 4px 8px; border-radius: 4px; }
|
|
@@ -280,10 +281,13 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
280
281
|
|
|
281
282
|
/* -- Main -- */
|
|
282
283
|
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
284
|
+
.main-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
|
285
|
+
border-bottom: 1px solid var(--border); background: var(--bg); min-height: 40px; }
|
|
286
|
+
.main-toolbar .toolbar-spacer { flex: 1; }
|
|
283
287
|
|
|
284
|
-
/* -- Tab bar
|
|
288
|
+
/* -- Tab bar -- */
|
|
285
289
|
.tab-bar { background: var(--bg-tab-bar); display: flex; align-items: flex-end; flex-shrink: 0;
|
|
286
|
-
min-height: 36px;
|
|
290
|
+
min-height: 36px; position: relative; z-index: 101; }
|
|
287
291
|
.tab-toggle { padding: 8px 10px; background: none; border: none; color: var(--text-muted);
|
|
288
292
|
cursor: pointer; font-size: 16px; flex-shrink: 0; align-self: center; }
|
|
289
293
|
.tab-toggle:hover { color: var(--text-bright); }
|
|
@@ -316,270 +320,39 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
316
320
|
flex-shrink: 0; align-self: center; }
|
|
317
321
|
.tab-new:hover { color: var(--text); background: var(--compose-bg); }
|
|
318
322
|
|
|
319
|
-
/* -- Terminal -- */
|
|
323
|
+
/* -- Terminal (read-only) -- */
|
|
320
324
|
#terminal { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
|
321
325
|
#terminal canvas { display: block; position: absolute; top: 0; left: 0; }
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
color: var(--text-muted); background: var(--bg-tab-bar); border: none; cursor: pointer; }
|
|
329
|
-
.view-switch button:hover { color: var(--text); }
|
|
330
|
-
.view-switch button.active { color: var(--text-on-active); background: var(--accent); }
|
|
331
|
-
|
|
332
|
-
/* -- Inspect -- */
|
|
333
|
-
#inspect { flex: 1; background: var(--bg); overflow: auto; display: none;
|
|
334
|
-
padding: 12px 16px; -webkit-overflow-scrolling: touch; }
|
|
335
|
-
#inspect.visible { display: block; }
|
|
336
|
-
|
|
337
|
-
/* -- Workspace -- */
|
|
338
|
-
#workspace { flex: 1; background: var(--bg); overflow: auto; display: none;
|
|
339
|
-
padding: 12px 16px; -webkit-overflow-scrolling: touch; }
|
|
340
|
-
#workspace.visible { display: block; }
|
|
341
|
-
.ws-section { margin-bottom: 16px; }
|
|
342
|
-
.ws-section-title { font-size: 12px; font-weight: 600; color: var(--text-muted);
|
|
343
|
-
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px;
|
|
344
|
-
display: flex; align-items: center; justify-content: space-between; }
|
|
345
|
-
.ws-section-title button { background: none; border: 1px solid var(--border);
|
|
346
|
-
color: var(--text-muted); font-size: 11px; padding: 2px 8px; border-radius: 4px;
|
|
347
|
-
cursor: pointer; font-family: inherit; }
|
|
348
|
-
.ws-section-title button:hover { color: var(--text-bright); border-color: var(--text-muted); }
|
|
349
|
-
.ws-empty { font-size: 12px; color: var(--text-dim); padding: 8px 0; }
|
|
350
|
-
.ws-card { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
|
|
351
|
-
padding: 8px 12px; margin-bottom: 6px; }
|
|
352
|
-
.ws-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
353
|
-
.ws-card-title { font-size: 13px; color: var(--text-bright); font-weight: 500; flex: 1;
|
|
354
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
355
|
-
.ws-card-meta { font-size: 10px; color: var(--text-dim); }
|
|
356
|
-
.ws-card-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
357
|
-
.ws-badge { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 8px;
|
|
358
|
-
font-weight: 500; }
|
|
359
|
-
.ws-badge.running { background: #1a3a5c; color: #4da6ff; }
|
|
360
|
-
.ws-badge.completed { background: #1a3c1a; color: #4dff4d; }
|
|
361
|
-
.ws-badge.failed { background: #3c1a1a; color: #ff4d4d; }
|
|
362
|
-
.ws-badge.pending { background: #3c3a1a; color: #ffbd2e; }
|
|
363
|
-
.ws-badge.approved { background: #1a3c1a; color: #4dff4d; }
|
|
364
|
-
.ws-badge.rejected { background: #3c1a1a; color: #ff4d4d; }
|
|
365
|
-
.ws-badge.snapshot { background: #1a2a3c; color: #88bbdd; }
|
|
366
|
-
.ws-badge.command-card { background: #2a1a3c; color: #bb88dd; }
|
|
367
|
-
.ws-badge.note { background: #1a3c2a; color: #88ddbb; }
|
|
368
|
-
.ws-badge.diff { background: #2a2a1a; color: #ddbb55; }
|
|
369
|
-
.ws-badge.markdown { background: #1a2a2a; color: #55bbdd; }
|
|
370
|
-
.ws-badge.ansi { background: #2a1a2a; color: #dd88bb; }
|
|
371
|
-
.ws-card-actions { display: flex; gap: 4px; margin-top: 6px; }
|
|
372
|
-
.ws-card-actions button { background: none; border: 1px solid var(--border);
|
|
373
|
-
color: var(--text-muted); font-size: 11px; padding: 3px 10px; border-radius: 4px;
|
|
374
|
-
cursor: pointer; font-family: inherit; }
|
|
375
|
-
.ws-card-actions button:hover { color: var(--text-bright); border-color: var(--text-muted); }
|
|
376
|
-
.ws-card-actions button.approve { border-color: #27c93f; color: #27c93f; }
|
|
377
|
-
.ws-card-actions button.approve:hover { background: #27c93f22; }
|
|
378
|
-
.ws-card-actions button.reject { border-color: #ff5f56; color: #ff5f56; }
|
|
379
|
-
.ws-card-actions button.reject:hover { background: #ff5f5622; }
|
|
380
|
-
.ws-card .del-topic { opacity: 0; background: none; border: none; color: var(--text-dim);
|
|
381
|
-
cursor: pointer; font-size: 14px; padding: 0 4px; font-family: inherit; border-radius: 3px; }
|
|
382
|
-
.ws-card:hover .del-topic { opacity: 1; }
|
|
383
|
-
.ws-card .del-topic:hover { color: var(--dot-err); }
|
|
384
|
-
|
|
385
|
-
/* -- Search bar -- */
|
|
386
|
-
.ws-search { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
387
|
-
.ws-search input { flex: 1; padding: 6px 10px; font-size: 13px; font-family: inherit;
|
|
326
|
+
|
|
327
|
+
/* -- Compose input -- */
|
|
328
|
+
.compose-input { display: flex; background: var(--bg-sidebar); border-top: 1px solid var(--border);
|
|
329
|
+
padding: 8px; gap: 6px; flex-shrink: 0; align-items: center; }
|
|
330
|
+
.compose-input input { flex: 1; min-width: 0; padding: 8px 12px; font-size: 14px;
|
|
331
|
+
font-family: 'Menlo','Monaco','Courier New',monospace;
|
|
388
332
|
background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 6px;
|
|
389
333
|
color: var(--text-bright); outline: none; }
|
|
390
|
-
.
|
|
391
|
-
.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
.
|
|
396
|
-
.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
.
|
|
404
|
-
|
|
405
|
-
.
|
|
406
|
-
|
|
407
|
-
.ws-note-actions button:hover { color: var(--text-bright); background: var(--bg-hover); }
|
|
408
|
-
.ws-note-input { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
409
|
-
.ws-note-input input { flex: 1; padding: 6px 10px; font-size: 12px; font-family: inherit;
|
|
410
|
-
background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 4px;
|
|
411
|
-
color: var(--text-bright); outline: none; }
|
|
412
|
-
.ws-note-input input:focus { border-color: var(--accent); }
|
|
413
|
-
.ws-note-input button { padding: 4px 12px; font-size: 12px; font-family: inherit;
|
|
414
|
-
background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
|
415
|
-
|
|
416
|
-
/* -- Commands -- */
|
|
417
|
-
.ws-cmd { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
|
|
418
|
-
padding: 6px 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
|
|
419
|
-
.ws-cmd-text { font-size: 12px; color: var(--text-bright); font-family: 'Menlo','Monaco',monospace;
|
|
420
|
-
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
421
|
-
.ws-cmd-exit { font-size: 11px; font-weight: 600; }
|
|
422
|
-
.ws-cmd-exit.ok { color: #27c93f; }
|
|
423
|
-
.ws-cmd-exit.err { color: #ff5f56; }
|
|
424
|
-
.ws-cmd-meta { font-size: 10px; color: var(--text-dim); white-space: nowrap; }
|
|
425
|
-
|
|
426
|
-
/* -- Handoff -- */
|
|
427
|
-
.ws-handoff { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
|
|
428
|
-
padding: 12px; margin-bottom: 12px; display: none; }
|
|
429
|
-
.ws-handoff.visible { display: block; }
|
|
430
|
-
.ws-handoff-section { margin-bottom: 8px; }
|
|
431
|
-
.ws-handoff-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase;
|
|
432
|
-
letter-spacing: .5px; margin-bottom: 4px; }
|
|
433
|
-
.ws-handoff-list { font-size: 12px; color: var(--text-muted); padding-left: 12px; }
|
|
434
|
-
.ws-handoff-list li { margin-bottom: 2px; }
|
|
435
|
-
|
|
436
|
-
/* -- Rich content rendering (diff, markdown, ANSI) -- */
|
|
437
|
-
.ws-card-content { margin-top: 6px; font-size: 12px; max-height: 200px; overflow: auto;
|
|
438
|
-
border-top: 1px solid var(--border); padding-top: 6px; }
|
|
439
|
-
.ws-card-content.expanded { max-height: none; }
|
|
440
|
-
.ws-card-toggle { font-size: 11px; color: var(--text-dim); background: none; border: none;
|
|
441
|
-
cursor: pointer; padding: 2px 6px; font-family: inherit; border-radius: 3px; }
|
|
442
|
-
.ws-card-toggle:hover { color: var(--text-bright); background: var(--bg-hover); }
|
|
443
|
-
|
|
444
|
-
/* Diff */
|
|
445
|
-
.diff-container { font-family: 'Menlo','Monaco','Courier New',monospace; font-size: 11px;
|
|
446
|
-
line-height: 1.5; overflow-x: auto; }
|
|
447
|
-
.diff-container > div { padding: 0 8px; white-space: pre; }
|
|
448
|
-
.diff-add { background: #1a3a1a; color: #4eff4e; }
|
|
449
|
-
.diff-del { background: #3a1a1a; color: #ff4e4e; }
|
|
450
|
-
.diff-hunk { color: #6a9eff; font-style: italic; }
|
|
451
|
-
.diff-header { color: #888; font-style: italic; }
|
|
452
|
-
.diff-ctx { color: var(--text-muted); }
|
|
453
|
-
.diff-line-num { display: inline-block; width: 32px; text-align: right; margin-right: 8px;
|
|
454
|
-
color: var(--text-dim); user-select: none; }
|
|
455
|
-
|
|
456
|
-
/* Markdown */
|
|
457
|
-
.rendered-md { font-size: 13px; line-height: 1.6; color: var(--text-bright); }
|
|
458
|
-
.rendered-md h1 { font-size: 18px; margin: 0.5em 0 0.3em; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
|
459
|
-
.rendered-md h2 { font-size: 15px; margin: 0.5em 0 0.3em; }
|
|
460
|
-
.rendered-md h3 { font-size: 13px; margin: 0.5em 0 0.3em; font-weight: 600; }
|
|
461
|
-
.rendered-md p { margin: 0.4em 0; }
|
|
462
|
-
.rendered-md code { background: #2a2a2a; padding: 2px 6px; border-radius: 3px;
|
|
463
|
-
font-family: 'Menlo','Monaco',monospace; font-size: 11px; }
|
|
464
|
-
.rendered-md pre { background: #1e1e1e; padding: 12px; border-radius: 6px;
|
|
465
|
-
overflow-x: auto; margin: 0.4em 0; }
|
|
466
|
-
.rendered-md pre code { background: none; padding: 0; font-size: 11px; }
|
|
467
|
-
.rendered-md blockquote { border-left: 3px solid #555; padding-left: 12px; color: #aaa;
|
|
468
|
-
margin: 0.4em 0; }
|
|
469
|
-
.rendered-md ul, .rendered-md ol { padding-left: 20px; margin: 0.3em 0; }
|
|
470
|
-
.rendered-md li { margin: 0.15em 0; }
|
|
471
|
-
.rendered-md a { color: var(--accent); text-decoration: none; }
|
|
472
|
-
.rendered-md a:hover { text-decoration: underline; }
|
|
473
|
-
.rendered-md hr { border: none; border-top: 1px solid var(--border); margin: 0.5em 0; }
|
|
474
|
-
.rendered-md strong { color: var(--text-on-active); }
|
|
475
|
-
|
|
476
|
-
/* ANSI */
|
|
477
|
-
.ansi-bold { font-weight: bold; }
|
|
478
|
-
.ansi-dim { opacity: 0.6; }
|
|
479
|
-
.ansi-italic { font-style: italic; }
|
|
480
|
-
.ansi-underline { text-decoration: underline; }
|
|
481
|
-
|
|
482
|
-
/* Light theme overrides */
|
|
483
|
-
[data-theme="light"] .diff-add { background: #e6ffec; color: #1a7f37; }
|
|
484
|
-
[data-theme="light"] .diff-del { background: #ffebe9; color: #cf222e; }
|
|
485
|
-
[data-theme="light"] .diff-hunk { color: #0969da; }
|
|
486
|
-
[data-theme="light"] .diff-header { color: #6e7781; }
|
|
487
|
-
[data-theme="light"] .diff-ctx { color: #57606a; }
|
|
488
|
-
[data-theme="light"] .rendered-md code { background: #eee; }
|
|
489
|
-
[data-theme="light"] .rendered-md pre { background: #f6f8fa; }
|
|
490
|
-
[data-theme="light"] .rendered-md blockquote { border-left-color: #ccc; color: #666; }
|
|
491
|
-
|
|
492
|
-
#inspect-content { font-family: 'Menlo','Monaco','Courier New',monospace; font-size: 13px;
|
|
493
|
-
line-height: 1.5; color: var(--text-bright); white-space: pre-wrap; word-break: break-all;
|
|
494
|
-
tab-size: 8; user-select: text; -webkit-user-select: text; }
|
|
495
|
-
#inspect-content mark { background: #ffbd2e; color: #1e1e1e; border-radius: 2px; }
|
|
496
|
-
#inspect-header { font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 11px;
|
|
497
|
-
color: var(--text-dim); padding: 8px 0; border-bottom: 1px solid var(--inspect-meta-border); margin-bottom: 8px;
|
|
498
|
-
display: flex; flex-direction: column; gap: 8px; }
|
|
499
|
-
#inspect-meta { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
|
500
|
-
#inspect-meta span { white-space: nowrap; }
|
|
501
|
-
#inspect-meta .inspect-btn { padding: 2px 10px; font-size: 11px; font-family: inherit;
|
|
502
|
-
color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
|
|
503
|
-
border-radius: 4px; cursor: pointer; white-space: nowrap; }
|
|
504
|
-
#inspect-meta .inspect-btn:hover { background: var(--compose-border); }
|
|
505
|
-
#inspect-search { display: flex; gap: 8px; align-items: center; }
|
|
506
|
-
#inspect-search input { padding: 4px 8px; font-size: 12px; font-family: inherit;
|
|
507
|
-
background: var(--bg); border: 1px solid var(--compose-border); border-radius: 4px;
|
|
508
|
-
color: var(--text); outline: none; flex: 1; max-width: 260px; }
|
|
509
|
-
#inspect-search input:focus { border-color: var(--accent); }
|
|
510
|
-
#inspect-search .match-count { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
|
511
|
-
|
|
512
|
-
/* -- Compose bar -- */
|
|
513
|
-
.compose-bar { display: none; background: var(--bg-sidebar); border-top: 1px solid var(--border);
|
|
514
|
-
padding: 5px 8px; gap: 5px; flex-shrink: 0; overflow-x: auto; flex-wrap: wrap;
|
|
515
|
-
-webkit-overflow-scrolling: touch; }
|
|
516
|
-
.compose-bar button { padding: 8px 12px; font-size: 14px;
|
|
334
|
+
.compose-input input:focus { border-color: var(--accent); }
|
|
335
|
+
.compose-input .send-btn { padding: 8px 16px; font-size: 13px; font-family: inherit;
|
|
336
|
+
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
|
337
|
+
cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
|
338
|
+
.compose-input .send-btn:hover { opacity: 0.9; }
|
|
339
|
+
.compose-input .send-btn:active { opacity: 0.8; }
|
|
340
|
+
.compose-input .preset-toggle { padding: 8px; font-size: 16px; background: none;
|
|
341
|
+
border: 1px solid var(--compose-border); border-radius: 6px; color: var(--text-muted);
|
|
342
|
+
cursor: pointer; flex-shrink: 0; line-height: 1; }
|
|
343
|
+
.compose-input .preset-toggle:hover { color: var(--text-bright); background: var(--compose-bg); }
|
|
344
|
+
.compose-input .preset-toggle.active { color: var(--accent); border-color: var(--accent); }
|
|
345
|
+
|
|
346
|
+
/* -- Preset commands -- */
|
|
347
|
+
.preset-bar { display: none; background: var(--bg-sidebar); border-top: 1px solid var(--border);
|
|
348
|
+
padding: 6px 8px; gap: 5px; flex-shrink: 0; flex-wrap: wrap; }
|
|
349
|
+
.preset-bar.visible { display: flex; }
|
|
350
|
+
.preset-bar button { padding: 6px 12px; font-size: 12px;
|
|
517
351
|
font-family: 'Menlo','Monaco',monospace; color: var(--text-bright); background: var(--compose-bg);
|
|
518
352
|
border: 1px solid var(--compose-border); border-radius: 5px; cursor: pointer; white-space: nowrap;
|
|
519
353
|
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
|
|
520
|
-
min-width:
|
|
521
|
-
.
|
|
522
|
-
.compose-bar button.active { background: #4a6a9a; border-color: #6a9ade; }
|
|
523
|
-
@media (hover: none) and (pointer: coarse) { .compose-bar { display: flex; } }
|
|
524
|
-
|
|
525
|
-
/* -- Tab rename input -- */
|
|
526
|
-
.tab .rename-input { background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
|
|
527
|
-
color: var(--text-bright); font-size: 12px; font-family: inherit; padding: 1px 4px;
|
|
528
|
-
outline: none; width: 80px; }
|
|
529
|
-
|
|
530
|
-
/* -- Devices section -- */
|
|
531
|
-
.devices-section { border-top: 1px solid var(--border); }
|
|
532
|
-
.devices-header { padding: 8px 12px; font-size: 11px; font-weight: 600; color: var(--text-muted);
|
|
533
|
-
text-transform: uppercase; letter-spacing: .5px; cursor: pointer; display: flex;
|
|
534
|
-
align-items: center; justify-content: space-between; user-select: none; }
|
|
535
|
-
.devices-header:hover { color: var(--text-bright); }
|
|
536
|
-
.devices-toggle { font-size: 8px; transition: transform .2s; }
|
|
537
|
-
.devices-toggle.collapsed { transform: rotate(-90deg); }
|
|
538
|
-
.devices-list { padding: 2px 6px; max-height: 200px; overflow-y: auto; }
|
|
539
|
-
.devices-list.collapsed { display: none; }
|
|
540
|
-
.device-item { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 4px;
|
|
541
|
-
font-size: 12px; color: var(--text); }
|
|
542
|
-
.device-item:hover { background: var(--bg-hover); }
|
|
543
|
-
.device-item .device-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
544
|
-
.device-dot.trusted { background: var(--dot-ok); }
|
|
545
|
-
.device-dot.untrusted { background: var(--dot-warn); }
|
|
546
|
-
.device-dot.blocked { background: var(--dot-err); }
|
|
547
|
-
.device-item .device-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
548
|
-
.device-item .device-self { font-size: 9px; color: var(--accent); margin-left: 2px; }
|
|
549
|
-
.device-item .device-actions { display: flex; gap: 2px; opacity: 0; }
|
|
550
|
-
.device-item:hover .device-actions { opacity: 1; }
|
|
551
|
-
.device-actions button { background: none; border: none; color: var(--text-dim); cursor: pointer;
|
|
552
|
-
font-size: 11px; padding: 1px 4px; border-radius: 3px; font-family: inherit; }
|
|
553
|
-
.device-actions button:hover { color: var(--text-bright); background: var(--compose-bg); }
|
|
554
|
-
.devices-actions { padding: 4px 12px 8px; }
|
|
555
|
-
.pair-btn { width: 100%; padding: 5px 8px; font-size: 11px; font-family: inherit;
|
|
556
|
-
color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
|
|
557
|
-
border-radius: 4px; cursor: pointer; margin-bottom: 4px; }
|
|
558
|
-
.pair-btn:hover { background: var(--compose-border); }
|
|
559
|
-
.pair-code-display { text-align: center; padding: 6px; }
|
|
560
|
-
.pair-code { font-family: 'Menlo','Monaco',monospace; font-size: 24px; font-weight: bold;
|
|
561
|
-
color: var(--accent); letter-spacing: 4px; }
|
|
562
|
-
.pair-expires { display: block; font-size: 10px; color: var(--text-dim); margin-top: 2px; }
|
|
563
|
-
.pair-input-area { display: flex; gap: 4px; }
|
|
564
|
-
.pair-input-area input { flex: 1; min-width: 0; padding: 5px 8px; font-size: 13px; font-family: 'Menlo','Monaco',monospace;
|
|
565
|
-
background: var(--bg); border: 1px solid var(--compose-border); border-radius: 4px;
|
|
566
|
-
color: var(--text); outline: none; text-align: center; letter-spacing: 2px; }
|
|
567
|
-
.pair-input-area input:focus { border-color: var(--accent); }
|
|
568
|
-
.pair-input-area .pair-btn { flex-shrink: 0; width: auto; }
|
|
569
|
-
|
|
570
|
-
/* -- Push notification section -- */
|
|
571
|
-
.push-section { padding: 4px 12px 8px; border-top: 1px solid var(--border); }
|
|
572
|
-
.push-toggle { width: 100%; padding: 5px 8px; font-size: 11px; font-family: inherit;
|
|
573
|
-
color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
|
|
574
|
-
border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 6px;
|
|
575
|
-
justify-content: center; }
|
|
576
|
-
.push-toggle:hover { background: var(--compose-border); }
|
|
577
|
-
.push-toggle.subscribed { background: var(--accent); border-color: var(--accent); }
|
|
578
|
-
.push-toggle .push-icon { font-size: 14px; }
|
|
579
|
-
.push-test-btn { width: 100%; padding: 4px 8px; font-size: 10px; font-family: inherit;
|
|
580
|
-
color: var(--text-muted); background: none; border: 1px solid var(--border);
|
|
581
|
-
border-radius: 4px; cursor: pointer; margin-top: 4px; }
|
|
582
|
-
.push-test-btn:hover { color: var(--text-bright); border-color: var(--compose-border); }
|
|
354
|
+
min-width: 36px; text-align: center; user-select: none; }
|
|
355
|
+
.preset-bar button:active { background: var(--compose-border); }
|
|
583
356
|
|
|
584
357
|
/* -- Mobile -- */
|
|
585
358
|
@media (max-width: 768px) {
|
|
@@ -592,8 +365,17 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
592
365
|
.sidebar-overlay.visible { display: block; pointer-events: auto; }
|
|
593
366
|
.main { margin-left: 0 !important; width: 100vw; min-width: 0; }
|
|
594
367
|
.tab-bar { overflow-x: auto; }
|
|
595
|
-
.session-item { min-height: 44px; }
|
|
368
|
+
.session-item { min-height: 44px; }
|
|
596
369
|
.tab { min-height: 36px; }
|
|
370
|
+
.main-toolbar { padding-left: 8px; padding-right: 8px; }
|
|
371
|
+
.compose-input input { font-size: 16px; } /* prevent iOS zoom on focus */
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@media (hover: none), (pointer: coarse) {
|
|
375
|
+
.session-item .del,
|
|
376
|
+
.tab .close {
|
|
377
|
+
opacity: 1;
|
|
378
|
+
}
|
|
597
379
|
}
|
|
598
380
|
</style>
|
|
599
381
|
</head>
|
|
@@ -604,49 +386,15 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
604
386
|
<span>Sessions</span>
|
|
605
387
|
<button id="btn-new-session" title="New session">+</button>
|
|
606
388
|
</div>
|
|
607
|
-
<div class="session-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
<div class="devices-header" id="devices-header">
|
|
612
|
-
<span>Devices</span>
|
|
613
|
-
<span class="devices-toggle" id="devices-toggle">▼</span>
|
|
614
|
-
</div>
|
|
615
|
-
<div class="devices-list" id="devices-list"></div>
|
|
616
|
-
<div class="devices-actions" id="devices-actions" style="display:none">
|
|
617
|
-
<button class="pair-btn" id="btn-pair">Generate Pair Code</button>
|
|
618
|
-
<div class="pair-code-display" id="pair-code-display" style="display:none">
|
|
619
|
-
<span class="pair-code" id="pair-code-value"></span>
|
|
620
|
-
<span class="pair-expires" id="pair-expires"></span>
|
|
621
|
-
</div>
|
|
622
|
-
<div class="pair-input-area" id="pair-input-area" style="display:none">
|
|
623
|
-
<input type="text" id="pair-code-input" placeholder="Enter 6-digit code" maxlength="6" />
|
|
624
|
-
<button class="pair-btn" id="btn-submit-pair">Pair</button>
|
|
625
|
-
</div>
|
|
626
|
-
</div>
|
|
627
|
-
</div>
|
|
628
|
-
|
|
629
|
-
<!-- Push notifications -->
|
|
630
|
-
<div class="push-section" id="push-section" style="display:none">
|
|
631
|
-
<button class="push-toggle" id="btn-push-toggle">
|
|
632
|
-
<span class="push-icon">🔔</span>
|
|
633
|
-
<span id="push-label">Enable Notifications</span>
|
|
634
|
-
</button>
|
|
635
|
-
<button class="push-test-btn" id="btn-push-test" style="display:none">Send Test</button>
|
|
389
|
+
<div class="session-composer" id="session-composer">
|
|
390
|
+
<input type="text" id="new-session-input" placeholder="New session name" />
|
|
391
|
+
<button class="primary" id="btn-create-session">Add</button>
|
|
392
|
+
<button class="secondary" id="btn-cancel-session">Cancel</button>
|
|
636
393
|
</div>
|
|
394
|
+
<div class="session-list" id="session-list"></div>
|
|
637
395
|
|
|
638
396
|
<div class="sidebar-footer">
|
|
639
|
-
<div class="
|
|
640
|
-
<span id="role-dot"></span>
|
|
641
|
-
<span id="role-text"></span>
|
|
642
|
-
<button class="role-btn" id="btn-role" style="display:none"></button>
|
|
643
|
-
</div>
|
|
644
|
-
<button id="btn-theme" class="theme-toggle" title="Toggle theme">☀</button>
|
|
645
|
-
<div class="status">
|
|
646
|
-
<div class="status-dot connecting" id="status-dot"></div>
|
|
647
|
-
<span id="status-text">...</span>
|
|
648
|
-
</div>
|
|
649
|
-
<div class="version">v${VERSION}</div>
|
|
397
|
+
<div class="version">v\${VERSION}</div>
|
|
650
398
|
</div>
|
|
651
399
|
</aside>
|
|
652
400
|
<div class="main">
|
|
@@ -654,91 +402,30 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
654
402
|
<button class="tab-toggle" id="btn-sidebar" title="Toggle sidebar">☰</button>
|
|
655
403
|
<div class="tab-list" id="tab-list"></div>
|
|
656
404
|
<button class="tab-new" id="btn-new-tab" title="New tab">+</button>
|
|
657
|
-
<div class="view-switch">
|
|
658
|
-
<button id="btn-live" class="active">Live</button>
|
|
659
|
-
<button id="btn-inspect">Inspect</button>
|
|
660
|
-
<button id="btn-workspace">Workspace</button>
|
|
661
|
-
</div>
|
|
662
|
-
</div>
|
|
663
|
-
<div id="terminal"></div>
|
|
664
|
-
<div id="inspect">
|
|
665
|
-
<div id="inspect-header">
|
|
666
|
-
<div id="inspect-meta"></div>
|
|
667
|
-
<div id="inspect-search">
|
|
668
|
-
<input type="text" id="inspect-search-input" placeholder="Search..." />
|
|
669
|
-
<span class="match-count" id="inspect-match-count"></span>
|
|
670
|
-
</div>
|
|
671
|
-
</div>
|
|
672
|
-
<pre id="inspect-content"></pre>
|
|
673
405
|
</div>
|
|
674
|
-
<div
|
|
675
|
-
<div class="
|
|
676
|
-
<
|
|
677
|
-
|
|
678
|
-
<div id="ws-search-results" class="ws-search-results"></div>
|
|
679
|
-
<div id="ws-handoff" class="ws-handoff"></div>
|
|
680
|
-
<div class="ws-section" id="ws-notes-section">
|
|
681
|
-
<div class="ws-section-title">
|
|
682
|
-
<span>Notes</span>
|
|
683
|
-
<button id="btn-handoff">Handoff</button>
|
|
684
|
-
</div>
|
|
685
|
-
<div class="ws-note-input">
|
|
686
|
-
<input type="text" id="ws-note-input" placeholder="Add a note..." />
|
|
687
|
-
<button id="btn-add-note">Add</button>
|
|
688
|
-
</div>
|
|
689
|
-
<div id="ws-notes"></div>
|
|
690
|
-
</div>
|
|
691
|
-
<div class="ws-section">
|
|
692
|
-
<div class="ws-section-title">
|
|
693
|
-
<span>Pending Approvals</span>
|
|
694
|
-
</div>
|
|
695
|
-
<div id="ws-approvals"></div>
|
|
696
|
-
</div>
|
|
697
|
-
<div class="ws-section">
|
|
698
|
-
<div class="ws-section-title">
|
|
699
|
-
<span>Topics</span>
|
|
700
|
-
<button id="btn-new-topic">+ New</button>
|
|
701
|
-
</div>
|
|
702
|
-
<div id="ws-topics"></div>
|
|
703
|
-
</div>
|
|
704
|
-
<div class="ws-section">
|
|
705
|
-
<div class="ws-section-title">
|
|
706
|
-
<span>Active Runs</span>
|
|
707
|
-
</div>
|
|
708
|
-
<div id="ws-runs"></div>
|
|
709
|
-
</div>
|
|
710
|
-
<div class="ws-section">
|
|
711
|
-
<div class="ws-section-title">
|
|
712
|
-
<span>Recent Artifacts</span>
|
|
713
|
-
<button id="btn-capture-snapshot">Capture Snapshot</button>
|
|
714
|
-
</div>
|
|
715
|
-
<div id="ws-artifacts"></div>
|
|
716
|
-
</div>
|
|
717
|
-
<div class="ws-section">
|
|
718
|
-
<div class="ws-section-title">
|
|
719
|
-
<span>Commands</span>
|
|
720
|
-
</div>
|
|
721
|
-
<div id="ws-commands"></div>
|
|
406
|
+
<div class="main-toolbar">
|
|
407
|
+
<div class="status">
|
|
408
|
+
<div class="status-dot connecting" id="status-dot"></div>
|
|
409
|
+
<span id="status-text">Connecting...</span>
|
|
722
410
|
</div>
|
|
411
|
+
<div class="toolbar-spacer"></div>
|
|
412
|
+
<button id="btn-theme" class="theme-toggle" title="Toggle theme">☀</button>
|
|
723
413
|
</div>
|
|
724
|
-
<div
|
|
725
|
-
|
|
726
|
-
<button data-seq="
|
|
727
|
-
<button data-
|
|
414
|
+
<div id="terminal"></div>
|
|
415
|
+
<div class="preset-bar" id="preset-bar">
|
|
416
|
+
<button data-seq="ctrl-c">Ctrl+C</button>
|
|
417
|
+
<button data-seq="ctrl-d">Ctrl+D</button>
|
|
418
|
+
<button data-seq="ctrl-z">Ctrl+Z</button>
|
|
419
|
+
<button data-seq="ctrl-l">Ctrl+L</button>
|
|
728
420
|
<button data-seq="up">↑</button>
|
|
729
421
|
<button data-seq="down">↓</button>
|
|
730
|
-
<button data-seq="
|
|
731
|
-
<button data-seq="
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
<button
|
|
735
|
-
<
|
|
736
|
-
<button
|
|
737
|
-
<button data-seq="ctrl-z">C-z</button>
|
|
738
|
-
<button data-seq="pgup">PgUp</button>
|
|
739
|
-
<button data-seq="pgdn">PgDn</button>
|
|
740
|
-
<button data-seq="home">Home</button>
|
|
741
|
-
<button data-seq="end">End</button>
|
|
422
|
+
<button data-seq="tab">Tab</button>
|
|
423
|
+
<button data-seq="esc">Esc</button>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="compose-input" id="compose-input">
|
|
426
|
+
<button class="preset-toggle" id="btn-preset-toggle" title="Quick keys">⚙</button>
|
|
427
|
+
<input type="text" id="cmd-input" placeholder="Type command..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
|
428
|
+
<button class="send-btn" id="btn-send">Send</button>
|
|
742
429
|
</div>
|
|
743
430
|
</div>
|
|
744
431
|
|
|
@@ -746,10 +433,8 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
746
433
|
import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
|
|
747
434
|
await init();
|
|
748
435
|
|
|
749
|
-
// -- Terminal color themes (ghostty-web ITheme) --
|
|
750
436
|
const THEMES = {
|
|
751
437
|
dark: {
|
|
752
|
-
// ghostty-web default dark
|
|
753
438
|
background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff',
|
|
754
439
|
cursorAccent: '#1e1e1e', selectionBackground: '#264f78', selectionForeground: '#ffffff',
|
|
755
440
|
black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
|
|
@@ -759,7 +444,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
759
444
|
brightCyan: '#29b8db', brightWhite: '#ffffff',
|
|
760
445
|
},
|
|
761
446
|
light: {
|
|
762
|
-
// Ghostty-style light
|
|
763
447
|
background: '#ffffff', foreground: '#1d1f21', cursor: '#1d1f21',
|
|
764
448
|
cursorAccent: '#ffffff', selectionBackground: '#b4d5fe', selectionForeground: '#1d1f21',
|
|
765
449
|
black: '#1d1f21', red: '#c82829', green: '#718c00', yellow: '#eab700',
|
|
@@ -781,124 +465,43 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
781
465
|
if (term) { term.dispose(); container.innerHTML = ''; }
|
|
782
466
|
term = window._remuxTerm = new Terminal({ cols: 80, rows: 24,
|
|
783
467
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
784
|
-
fontSize: 14, cursorBlink:
|
|
468
|
+
fontSize: 14, cursorBlink: false,
|
|
785
469
|
theme: THEMES[themeMode] || THEMES.dark,
|
|
786
|
-
scrollback: 10000
|
|
470
|
+
scrollback: 10000,
|
|
471
|
+
disableStdin: true });
|
|
787
472
|
fitAddon = new FitAddon();
|
|
788
473
|
term.loadAddon(fitAddon);
|
|
789
474
|
term.open(container);
|
|
790
475
|
fitObserver = new ResizeObserver(() => safeFit());
|
|
791
476
|
fitObserver.observe(container);
|
|
792
477
|
safeFit();
|
|
793
|
-
fitSettleTimer = setTimeout(() => {
|
|
794
|
-
fitSettleTimer = null;
|
|
795
|
-
safeFit();
|
|
796
|
-
}, 250);
|
|
478
|
+
fitSettleTimer = setTimeout(() => { fitSettleTimer = null; safeFit(); }, 250);
|
|
797
479
|
if (document.fonts && document.fonts.ready) {
|
|
798
480
|
document.fonts.ready.then(() => safeFit()).catch(() => {});
|
|
799
481
|
}
|
|
800
482
|
return term;
|
|
801
483
|
}
|
|
802
484
|
|
|
803
|
-
// -- IME composition guard --
|
|
804
|
-
// Defer fit()/resize during active IME composition to avoid layout thrash.
|
|
805
|
-
// Note: ghostty-web binds composition listeners on the container, and
|
|
806
|
-
// browser-native composition events bubble from textarea to container,
|
|
807
|
-
// so no forwarding patch is needed. Just guard fit() during composition.
|
|
808
|
-
let _isComposing = false;
|
|
809
|
-
let _pendingFit = false;
|
|
810
485
|
let fitDebounceTimer = null;
|
|
811
|
-
|
|
812
|
-
function
|
|
813
|
-
|
|
814
|
-
if (
|
|
486
|
+
function safeFit() { if (fitAddon) fitAddon.fit(); }
|
|
487
|
+
function stabilizeFit() {
|
|
488
|
+
safeFit();
|
|
489
|
+
if (fitSettleTimer) clearTimeout(fitSettleTimer);
|
|
490
|
+
fitSettleTimer = setTimeout(() => { fitSettleTimer = null; safeFit(); }, 250);
|
|
815
491
|
}
|
|
816
492
|
function syncTouchViewportHeight() {
|
|
817
|
-
if (!window.visualViewport
|
|
493
|
+
if (!window.visualViewport) return;
|
|
818
494
|
const vh = window.visualViewport.height;
|
|
819
495
|
if (vh > 0) document.body.style.height = vh + 'px';
|
|
820
496
|
clearTimeout(fitDebounceTimer);
|
|
821
497
|
fitDebounceTimer = setTimeout(safeFit, 100);
|
|
822
498
|
}
|
|
823
|
-
function stabilizeFit() {
|
|
824
|
-
safeFit();
|
|
825
|
-
if (fitSettleTimer) clearTimeout(fitSettleTimer);
|
|
826
|
-
fitSettleTimer = setTimeout(() => {
|
|
827
|
-
fitSettleTimer = null;
|
|
828
|
-
safeFit();
|
|
829
|
-
}, 250);
|
|
830
|
-
}
|
|
831
499
|
window.addEventListener('resize', safeFit);
|
|
832
|
-
const _termContainer = document.getElementById('terminal');
|
|
833
|
-
_termContainer.addEventListener('compositionstart', () => { _isComposing = true; });
|
|
834
|
-
_termContainer.addEventListener('compositionend', () => {
|
|
835
|
-
_isComposing = false;
|
|
836
|
-
if (_pendingFit) { _pendingFit = false; stabilizeFit(); }
|
|
837
|
-
});
|
|
838
500
|
createTerminal(initTheme);
|
|
839
501
|
|
|
840
|
-
let sessions = [], currentSession = null, currentTabId = null, ws = null
|
|
841
|
-
let myClientId = null,
|
|
842
|
-
|
|
843
|
-
// -- Predictive echo via DOM overlay (see #80 Phase 2) --
|
|
844
|
-
// Shows predicted characters as transparent HTML spans over the canvas.
|
|
845
|
-
// Does NOT inject any ANSI escape sequences into the terminal — the overlay
|
|
846
|
-
// is purely visual and the terminal data path is never modified.
|
|
847
|
-
// Adapted from VS Code TypeAheadAddon concept, using DOM overlay instead of
|
|
848
|
-
// xterm.js decorations (which ghostty-web lacks).
|
|
849
|
-
const _peOverlay = document.createElement('div');
|
|
850
|
-
_peOverlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1;overflow:hidden;';
|
|
851
|
-
_termContainer.appendChild(_peOverlay);
|
|
852
|
-
const _pePreds = [];
|
|
853
|
-
function _peCellSize() {
|
|
854
|
-
// Use ghostty-web renderer's exact font metrics when available
|
|
855
|
-
if (term.renderer) return { w: term.renderer.charWidth, h: term.renderer.charHeight };
|
|
856
|
-
const cvs = _termContainer.querySelector('canvas');
|
|
857
|
-
if (!cvs) return { w: 8, h: 16 };
|
|
858
|
-
return { w: cvs.offsetWidth / term.cols, h: cvs.offsetHeight / term.rows };
|
|
859
|
-
}
|
|
860
|
-
function peOnInput(data) {
|
|
861
|
-
if (_isComposing) return;
|
|
862
|
-
if (term.buffer && term.buffer.active && term.buffer.active.type === 'alternate') return;
|
|
863
|
-
if (myRole && myRole !== 'active') return;
|
|
864
|
-
for (let i = 0; i < data.length; i++) {
|
|
865
|
-
const c = data.charCodeAt(i);
|
|
866
|
-
if (c >= 0x20 && c <= 0x7e && _pePreds.length < 32) {
|
|
867
|
-
const buf = term.buffer && term.buffer.active;
|
|
868
|
-
const cx = (buf ? buf.cursorX : 0) + _pePreds.length;
|
|
869
|
-
const cy = buf ? buf.cursorY : 0;
|
|
870
|
-
const cell = _peCellSize();
|
|
871
|
-
const span = document.createElement('span');
|
|
872
|
-
span.textContent = data[i];
|
|
873
|
-
span.style.cssText = 'position:absolute;display:inline-block;color:var(--text,#d4d4d4);opacity:0.6;'
|
|
874
|
-
+ 'font-family:Menlo,Monaco,Courier New,monospace;'
|
|
875
|
-
+ 'left:' + (cx * cell.w) + 'px;top:' + (cy * cell.h) + 'px;'
|
|
876
|
-
+ 'width:' + cell.w + 'px;height:' + cell.h + 'px;'
|
|
877
|
-
+ 'font-size:14px;line-height:' + cell.h + 'px;text-align:center;';
|
|
878
|
-
_peOverlay.appendChild(span);
|
|
879
|
-
_pePreds.push({ ch: data[i], span, ts: Date.now() });
|
|
880
|
-
} else if (c < 0x20 || c === 0x7f) {
|
|
881
|
-
peClearAll();
|
|
882
|
-
}
|
|
883
|
-
// Non-ASCII: skip prediction, don't clear
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
function peOnServerData(data) {
|
|
887
|
-
// Match predictions against server echo, remove confirmed overlay spans.
|
|
888
|
-
// NEVER modify or consume data — always pass full data to term.write().
|
|
889
|
-
if (_pePreds.length === 0) return;
|
|
890
|
-
for (let i = 0; i < data.length && _pePreds.length > 0; i++) {
|
|
891
|
-
if (data.charCodeAt(i) === 0x1b) { peClearAll(); return; }
|
|
892
|
-
if (data[i] === _pePreds[0].ch) {
|
|
893
|
-
const p = _pePreds.shift();
|
|
894
|
-
p.span.remove();
|
|
895
|
-
} else { peClearAll(); return; }
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
function peClearAll() {
|
|
899
|
-
for (const p of _pePreds) p.span.remove();
|
|
900
|
-
_pePreds.length = 0;
|
|
901
|
-
}
|
|
502
|
+
let sessions = [], currentSession = null, currentTabId = null, ws = null;
|
|
503
|
+
let myClientId = null, clientsList = [];
|
|
504
|
+
|
|
902
505
|
const $ = id => document.getElementById(id);
|
|
903
506
|
const esc = t => (t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
904
507
|
const setStatus = (s, t) => { $('status-dot').className = 'status-dot ' + s; $('status-text').textContent = t; };
|
|
@@ -908,27 +511,12 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
908
511
|
document.documentElement.setAttribute('data-theme', mode);
|
|
909
512
|
localStorage.setItem('remux-theme', mode);
|
|
910
513
|
$('btn-theme').innerHTML = mode === 'dark' ? '☀' : '☾';
|
|
911
|
-
// Recreate terminal with new theme (ghostty-web doesn't support runtime theme change)
|
|
912
514
|
createTerminal(mode);
|
|
913
|
-
peClearAll();
|
|
914
|
-
// Rebind terminal I/O
|
|
915
|
-
term.onData(data => {
|
|
916
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
917
|
-
if (ctrlActive) {
|
|
918
|
-
ctrlActive = false; $('btn-ctrl').classList.remove('active');
|
|
919
|
-
const ch = data.toLowerCase().charCodeAt(0);
|
|
920
|
-
if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
|
|
921
|
-
}
|
|
922
|
-
peOnInput(data);
|
|
923
|
-
sendTermData(data);
|
|
924
|
-
});
|
|
925
515
|
term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
|
|
926
|
-
// Re-attach to current tab to get snapshot
|
|
927
516
|
if (currentTabId != null) {
|
|
928
517
|
sendCtrl({ type: 'attach_tab', tabId: currentTabId, cols: term.cols, rows: term.rows });
|
|
929
518
|
}
|
|
930
519
|
}
|
|
931
|
-
// Apply initial theme CSS (terminal already created with correct theme)
|
|
932
520
|
document.documentElement.setAttribute('data-theme', initTheme);
|
|
933
521
|
$('btn-theme').innerHTML = initTheme === 'dark' ? '☀' : '☾';
|
|
934
522
|
$('btn-theme').addEventListener('click', () => {
|
|
@@ -956,27 +544,22 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
956
544
|
function renderSessions() {
|
|
957
545
|
const list = $('session-list'); list.innerHTML = '';
|
|
958
546
|
sessions.forEach(s => {
|
|
959
|
-
const el = document.createElement('
|
|
547
|
+
const el = document.createElement('div');
|
|
960
548
|
el.className = 'session-item' + (s.name === currentSession ? ' active' : '');
|
|
549
|
+
el.tabIndex = 0;
|
|
550
|
+
el.setAttribute('role', 'button');
|
|
961
551
|
const live = s.tabs.filter(t => !t.ended).length;
|
|
962
552
|
el.innerHTML = '<span class="dot"></span><span class="name">' + esc(s.name)
|
|
963
553
|
+ '</span><span class="count">' + live + '</span>'
|
|
964
|
-
+ '<button class="del" data-del="' + esc(s.name) + '"
|
|
554
|
+
+ '<button class="del" data-del="' + esc(s.name) + '">\\u00d7</button>';
|
|
965
555
|
el.addEventListener('pointerdown', e => {
|
|
966
556
|
if (e.target.dataset.del) {
|
|
967
557
|
e.stopPropagation(); e.preventDefault();
|
|
968
|
-
if (!confirm('Delete session "' + e.target.dataset.del + '"? All tabs will be closed.')) return;
|
|
969
558
|
sendCtrl({ type: 'delete_session', name: e.target.dataset.del });
|
|
970
|
-
// if deleting current, switch to another or create fresh
|
|
971
559
|
if (e.target.dataset.del === currentSession) {
|
|
972
560
|
const other = sessions.find(x => x.name !== currentSession);
|
|
973
|
-
if (other) {
|
|
974
|
-
|
|
975
|
-
} else {
|
|
976
|
-
// Last session deleted — re-bootstrap via attach_first
|
|
977
|
-
currentSession = null;
|
|
978
|
-
sendCtrl({ type: 'attach_first', cols: term.cols, rows: term.rows });
|
|
979
|
-
}
|
|
561
|
+
if (other) { selectSession(other.name); }
|
|
562
|
+
else { currentSession = null; sendCtrl({ type: 'attach_first', cols: term.cols, rows: term.rows }); }
|
|
980
563
|
}
|
|
981
564
|
return;
|
|
982
565
|
}
|
|
@@ -984,63 +567,40 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
984
567
|
selectSession(s.name);
|
|
985
568
|
closeSidebarMobile();
|
|
986
569
|
});
|
|
570
|
+
el.addEventListener('keydown', e => {
|
|
571
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectSession(s.name); closeSidebarMobile(); }
|
|
572
|
+
});
|
|
987
573
|
list.appendChild(el);
|
|
988
574
|
});
|
|
989
575
|
}
|
|
990
576
|
|
|
991
|
-
// -- Render tabs
|
|
577
|
+
// -- Render tabs --
|
|
992
578
|
function renderTabs() {
|
|
993
579
|
const list = $('tab-list'); list.innerHTML = '';
|
|
994
580
|
const sess = sessions.find(s => s.name === currentSession);
|
|
995
581
|
if (!sess) return;
|
|
996
582
|
sess.tabs.forEach(t => {
|
|
997
|
-
const el = document.createElement('
|
|
583
|
+
const el = document.createElement('div');
|
|
998
584
|
el.className = 'tab' + (t.id === currentTabId ? ' active' : '');
|
|
585
|
+
el.tabIndex = 0;
|
|
586
|
+
el.setAttribute('role', 'button');
|
|
999
587
|
const clientCount = t.clients || 0;
|
|
1000
588
|
const countBadge = clientCount > 1 ? '<span class="client-count">' + clientCount + '</span>' : '';
|
|
1001
589
|
el.innerHTML = '<span class="title">' + esc(t.title) + '</span>' + countBadge
|
|
1002
|
-
+ '<button class="close" data-close="' + t.id + '"
|
|
590
|
+
+ '<button class="close" data-close="' + t.id + '">\\u00d7</button>';
|
|
1003
591
|
el.addEventListener('pointerdown', e => {
|
|
1004
592
|
const closeId = e.target.dataset.close ?? e.target.closest('[data-close]')?.dataset.close;
|
|
1005
|
-
if (closeId != null) {
|
|
1006
|
-
e.stopPropagation(); e.preventDefault();
|
|
1007
|
-
closeTab(Number(closeId));
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
593
|
+
if (closeId != null) { e.stopPropagation(); e.preventDefault(); closeTab(Number(closeId)); return; }
|
|
1010
594
|
e.preventDefault();
|
|
1011
595
|
if (t.id !== currentTabId) attachTab(t.id);
|
|
1012
596
|
});
|
|
597
|
+
el.addEventListener('keydown', e => {
|
|
598
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (t.id !== currentTabId) attachTab(t.id); }
|
|
599
|
+
});
|
|
1013
600
|
list.appendChild(el);
|
|
1014
601
|
});
|
|
1015
602
|
}
|
|
1016
603
|
|
|
1017
|
-
// -- Render role indicator --
|
|
1018
|
-
function renderRole() {
|
|
1019
|
-
const indicator = $('role-indicator');
|
|
1020
|
-
const dot = $('role-dot');
|
|
1021
|
-
const text = $('role-text');
|
|
1022
|
-
const btn = $('btn-role');
|
|
1023
|
-
if (!indicator || !myRole) return;
|
|
1024
|
-
indicator.className = 'role-indicator ' + myRole;
|
|
1025
|
-
if (myRole === 'active') {
|
|
1026
|
-
dot.textContent = '\u25cf';
|
|
1027
|
-
text.textContent = 'Active';
|
|
1028
|
-
btn.textContent = 'Release';
|
|
1029
|
-
btn.style.display = 'inline-block';
|
|
1030
|
-
// Auto-focus terminal when becoming active so keystrokes reach xterm
|
|
1031
|
-
if (currentView === 'live') setTimeout(() => term.focus(), 50);
|
|
1032
|
-
} else {
|
|
1033
|
-
dot.textContent = '\u25cb';
|
|
1034
|
-
text.textContent = 'Observer';
|
|
1035
|
-
btn.textContent = 'Take control';
|
|
1036
|
-
btn.style.display = 'inline-block';
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
$('btn-role').addEventListener('click', () => {
|
|
1040
|
-
if (myRole === 'active') sendCtrl({ type: 'release_control' });
|
|
1041
|
-
else sendCtrl({ type: 'request_control' });
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
604
|
function selectSession(name) {
|
|
1045
605
|
currentSession = name;
|
|
1046
606
|
const sess = sessions.find(s => s.name === name);
|
|
@@ -1050,7 +610,7 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1050
610
|
|
|
1051
611
|
function attachTab(tabId) {
|
|
1052
612
|
currentTabId = tabId;
|
|
1053
|
-
term.reset();
|
|
613
|
+
term.reset();
|
|
1054
614
|
sendCtrl({ type: 'attach_tab', tabId, cols: term.cols, rows: term.rows });
|
|
1055
615
|
stabilizeFit();
|
|
1056
616
|
renderTabs(); renderSessions();
|
|
@@ -1059,7 +619,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1059
619
|
function closeTab(tabId) {
|
|
1060
620
|
const sess = sessions.find(s => s.name === currentSession);
|
|
1061
621
|
if (!sess) return;
|
|
1062
|
-
// if closing active tab, switch to neighbor first
|
|
1063
622
|
if (tabId === currentTabId) {
|
|
1064
623
|
const idx = sess.tabs.findIndex(t => t.id === tabId);
|
|
1065
624
|
const next = sess.tabs[idx + 1] || sess.tabs[idx - 1];
|
|
@@ -1072,106 +631,76 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1072
631
|
e.preventDefault();
|
|
1073
632
|
sendCtrl({ type: 'new_tab', session: currentSession, cols: term.cols, rows: term.rows });
|
|
1074
633
|
});
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
634
|
+
function openSessionComposer() {
|
|
635
|
+
$('session-composer').classList.add('visible');
|
|
636
|
+
$('new-session-input').focus();
|
|
637
|
+
$('new-session-input').select();
|
|
638
|
+
}
|
|
639
|
+
function closeSessionComposer() {
|
|
640
|
+
$('session-composer').classList.remove('visible');
|
|
641
|
+
$('new-session-input').value = '';
|
|
642
|
+
}
|
|
643
|
+
$('btn-new-session').addEventListener('pointerdown', e => { e.preventDefault(); openSessionComposer(); });
|
|
644
|
+
$('btn-create-session').addEventListener('click', () => {
|
|
645
|
+
const name = $('new-session-input').value.trim();
|
|
646
|
+
if (!name) return;
|
|
647
|
+
sendCtrl({ type: 'new_session', name, cols: term.cols, rows: term.rows });
|
|
648
|
+
closeSessionComposer();
|
|
649
|
+
});
|
|
650
|
+
$('btn-cancel-session').addEventListener('click', closeSessionComposer);
|
|
651
|
+
$('new-session-input').addEventListener('keydown', e => {
|
|
652
|
+
if (e.key === 'Enter') { e.preventDefault(); $('btn-create-session').click(); }
|
|
653
|
+
else if (e.key === 'Escape') { e.preventDefault(); closeSessionComposer(); }
|
|
1079
654
|
});
|
|
1080
655
|
|
|
1081
656
|
// -- E2EE client (Web Crypto API) --
|
|
1082
|
-
// Adapted from Signal Protocol X25519+AES-GCM pattern
|
|
1083
657
|
const e2ee = {
|
|
1084
|
-
established: false,
|
|
1085
|
-
|
|
1086
|
-
recvCounter: -1n,
|
|
1087
|
-
localKeyPair: null, // { publicKey: CryptoKey, privateKey: CryptoKey, rawPublic: Uint8Array }
|
|
1088
|
-
sharedKey: null, // CryptoKey (AES-GCM)
|
|
658
|
+
established: false, sendCounter: 0n, recvCounter: -1n,
|
|
659
|
+
localKeyPair: null, sharedKey: null,
|
|
1089
660
|
available: !!(crypto && crypto.subtle),
|
|
1090
|
-
|
|
1091
661
|
async init() {
|
|
1092
662
|
if (!this.available) return;
|
|
1093
663
|
try {
|
|
1094
664
|
const kp = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
|
|
1095
665
|
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
|
|
1096
666
|
this.localKeyPair = { publicKey: kp.publicKey, privateKey: kp.privateKey, rawPublic: rawPub };
|
|
1097
|
-
} catch (e) {
|
|
1098
|
-
console.warn('[e2ee] X25519 not available:', e);
|
|
1099
|
-
this.available = false;
|
|
1100
|
-
}
|
|
667
|
+
} catch (e) { this.available = false; }
|
|
1101
668
|
},
|
|
1102
|
-
|
|
1103
669
|
getPublicKeyB64() {
|
|
1104
670
|
if (!this.localKeyPair) return null;
|
|
1105
671
|
return btoa(String.fromCharCode(...this.localKeyPair.rawPublic));
|
|
1106
672
|
},
|
|
1107
|
-
|
|
1108
673
|
async completeHandshake(peerPubKeyB64) {
|
|
1109
674
|
if (!this.localKeyPair) return;
|
|
1110
675
|
try {
|
|
1111
676
|
const peerRaw = Uint8Array.from(atob(peerPubKeyB64), c => c.charCodeAt(0));
|
|
1112
677
|
const peerKey = await crypto.subtle.importKey('raw', peerRaw, 'X25519', false, []);
|
|
1113
|
-
|
|
1114
|
-
const rawBits = await crypto.subtle.deriveBits(
|
|
1115
|
-
{ name: 'X25519', public: peerKey },
|
|
1116
|
-
this.localKeyPair.privateKey,
|
|
1117
|
-
256
|
|
1118
|
-
);
|
|
1119
|
-
// HKDF-SHA256 to derive AES-256-GCM key
|
|
678
|
+
const rawBits = await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, this.localKeyPair.privateKey, 256);
|
|
1120
679
|
const hkdfKey = await crypto.subtle.importKey('raw', rawBits, 'HKDF', false, ['deriveBits']);
|
|
1121
|
-
const salt = new TextEncoder().encode('remux-e2ee-v1');
|
|
1122
|
-
const info = new TextEncoder().encode('aes-256-gcm');
|
|
1123
680
|
const derived = await crypto.subtle.deriveBits(
|
|
1124
|
-
{ name: 'HKDF', hash: 'SHA-256', salt, info },
|
|
1125
|
-
hkdfKey,
|
|
1126
|
-
256
|
|
1127
|
-
);
|
|
681
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode('remux-e2ee-v1'), info: new TextEncoder().encode('aes-256-gcm') }, hkdfKey, 256);
|
|
1128
682
|
this.sharedKey = await crypto.subtle.importKey('raw', derived, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
1129
|
-
this.established = true;
|
|
1130
|
-
|
|
1131
|
-
this.recvCounter = -1n;
|
|
1132
|
-
console.log('[e2ee] handshake complete');
|
|
1133
|
-
} catch (e) {
|
|
1134
|
-
console.error('[e2ee] handshake failed:', e);
|
|
1135
|
-
this.available = false;
|
|
1136
|
-
}
|
|
683
|
+
this.established = true; this.sendCounter = 0n; this.recvCounter = -1n;
|
|
684
|
+
} catch (e) { this.available = false; }
|
|
1137
685
|
},
|
|
1138
|
-
|
|
1139
686
|
async encryptMessage(plaintext) {
|
|
1140
687
|
if (!this.sharedKey) throw new Error('E2EE not established');
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
const iv = new Uint8Array(12);
|
|
1144
|
-
crypto.getRandomValues(iv.subarray(0, 4));
|
|
1145
|
-
const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
|
|
1146
|
-
counterView.setBigUint64(0, this.sendCounter, false);
|
|
688
|
+
const iv = new Uint8Array(12); crypto.getRandomValues(iv.subarray(0, 4));
|
|
689
|
+
new DataView(iv.buffer, iv.byteOffset + 4, 8).setBigUint64(0, this.sendCounter, false);
|
|
1147
690
|
this.sendCounter++;
|
|
1148
|
-
const encrypted = await crypto.subtle.encrypt(
|
|
1149
|
-
{ name: 'AES-GCM', iv, tagLength: 128 },
|
|
1150
|
-
this.sharedKey,
|
|
1151
|
-
plaintextBuf
|
|
1152
|
-
);
|
|
1153
|
-
// AES-GCM returns ciphertext + tag concatenated
|
|
691
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, this.sharedKey, new TextEncoder().encode(plaintext));
|
|
1154
692
|
const result = new Uint8Array(12 + encrypted.byteLength);
|
|
1155
|
-
result.set(iv, 0);
|
|
1156
|
-
result.set(new Uint8Array(encrypted), 12);
|
|
693
|
+
result.set(iv, 0); result.set(new Uint8Array(encrypted), 12);
|
|
1157
694
|
return btoa(String.fromCharCode(...result));
|
|
1158
695
|
},
|
|
1159
|
-
|
|
1160
696
|
async decryptMessage(encryptedB64) {
|
|
1161
697
|
if (!this.sharedKey) throw new Error('E2EE not established');
|
|
1162
698
|
const packed = Uint8Array.from(atob(encryptedB64), c => c.charCodeAt(0));
|
|
1163
|
-
if (packed.length < 28) throw new Error('E2EE message too short');
|
|
699
|
+
if (packed.length < 28) throw new Error('E2EE message too short');
|
|
1164
700
|
const iv = packed.subarray(0, 12);
|
|
1165
|
-
const
|
|
1166
|
-
// Anti-replay: check counter is monotonically increasing
|
|
1167
|
-
const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
|
|
1168
|
-
const counter = counterView.getBigUint64(0, false);
|
|
701
|
+
const counter = new DataView(iv.buffer, iv.byteOffset + 4, 8).getBigUint64(0, false);
|
|
1169
702
|
if (counter <= this.recvCounter) throw new Error('E2EE replay detected');
|
|
1170
|
-
const decrypted = await crypto.subtle.decrypt(
|
|
1171
|
-
{ name: 'AES-GCM', iv, tagLength: 128 },
|
|
1172
|
-
this.sharedKey,
|
|
1173
|
-
ciphertextWithTag
|
|
1174
|
-
);
|
|
703
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, this.sharedKey, packed.subarray(12));
|
|
1175
704
|
this.recvCounter = counter;
|
|
1176
705
|
return new TextDecoder().decode(decrypted);
|
|
1177
706
|
}
|
|
@@ -1180,18 +709,12 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1180
709
|
// -- WebSocket with exponential backoff + heartbeat --
|
|
1181
710
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1182
711
|
const urlToken = new URLSearchParams(location.search).get('token');
|
|
1183
|
-
|
|
1184
|
-
// Exponential backoff: 1s -> 2s -> 4s -> 8s -> 16s -> 30s max
|
|
1185
712
|
let backoffMs = 1000;
|
|
1186
713
|
const BACKOFF_MAX = 30000;
|
|
1187
714
|
let reconnectTimer = null;
|
|
1188
|
-
|
|
1189
|
-
// Heartbeat: if no message received for 45s, consider connection dead
|
|
1190
715
|
const HEARTBEAT_TIMEOUT = 45000;
|
|
1191
716
|
let lastMessageAt = Date.now();
|
|
1192
717
|
let heartbeatChecker = null;
|
|
1193
|
-
|
|
1194
|
-
// Track last received message timestamp for session recovery
|
|
1195
718
|
let lastReceivedTimestamp = 0;
|
|
1196
719
|
let isResuming = false;
|
|
1197
720
|
|
|
@@ -1199,29 +722,19 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1199
722
|
lastMessageAt = Date.now();
|
|
1200
723
|
if (heartbeatChecker) clearInterval(heartbeatChecker);
|
|
1201
724
|
heartbeatChecker = setInterval(() => {
|
|
1202
|
-
if (Date.now() - lastMessageAt > HEARTBEAT_TIMEOUT) {
|
|
1203
|
-
console.log('[remux] heartbeat timeout, reconnecting');
|
|
1204
|
-
if (ws) ws.close();
|
|
1205
|
-
}
|
|
725
|
+
if (Date.now() - lastMessageAt > HEARTBEAT_TIMEOUT) { if (ws) ws.close(); }
|
|
1206
726
|
}, 5000);
|
|
1207
727
|
}
|
|
1208
|
-
|
|
1209
|
-
function stopHeartbeat() {
|
|
1210
|
-
if (heartbeatChecker) { clearInterval(heartbeatChecker); heartbeatChecker = null; }
|
|
1211
|
-
}
|
|
728
|
+
function stopHeartbeat() { if (heartbeatChecker) { clearInterval(heartbeatChecker); heartbeatChecker = null; } }
|
|
1212
729
|
|
|
1213
730
|
function scheduleReconnect() {
|
|
1214
731
|
if (reconnectTimer) return;
|
|
1215
732
|
const delay = backoffMs;
|
|
1216
733
|
let remaining = Math.ceil(delay / 1000);
|
|
1217
734
|
setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
|
|
1218
|
-
const countdown = setInterval(() => {
|
|
1219
|
-
remaining--;
|
|
1220
|
-
if (remaining > 0) setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
|
|
1221
|
-
}, 1000);
|
|
735
|
+
const countdown = setInterval(() => { remaining--; if (remaining > 0) setStatus('disconnected', 'Reconnecting in ' + remaining + 's...'); }, 1000);
|
|
1222
736
|
reconnectTimer = setTimeout(() => {
|
|
1223
|
-
clearInterval(countdown);
|
|
1224
|
-
reconnectTimer = null;
|
|
737
|
+
clearInterval(countdown); reconnectTimer = null;
|
|
1225
738
|
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
|
|
1226
739
|
connect();
|
|
1227
740
|
}, delay);
|
|
@@ -1229,816 +742,137 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
1229
742
|
|
|
1230
743
|
function connect() {
|
|
1231
744
|
setStatus('connecting', 'Connecting...');
|
|
1232
|
-
e2ee.established = false;
|
|
1233
|
-
e2ee.sharedKey = null;
|
|
745
|
+
e2ee.established = false; e2ee.sharedKey = null;
|
|
1234
746
|
ws = new WebSocket(proto + '//' + location.host + '/ws');
|
|
1235
747
|
ws.onopen = async () => {
|
|
1236
|
-
backoffMs = 1000;
|
|
748
|
+
backoffMs = 1000;
|
|
1237
749
|
startHeartbeat();
|
|
1238
750
|
if (urlToken) {
|
|
1239
|
-
// Use persistent device ID from localStorage so each browser context
|
|
1240
|
-
// is a distinct device even with identical User-Agent
|
|
1241
751
|
if (!localStorage.getItem('remux-device-id')) {
|
|
1242
752
|
localStorage.setItem('remux-device-id', Math.random().toString(36).slice(2, 10) + Date.now().toString(36));
|
|
1243
753
|
}
|
|
1244
754
|
ws.send(JSON.stringify({ type: 'auth', token: urlToken, deviceId: localStorage.getItem('remux-device-id') }));
|
|
1245
755
|
}
|
|
1246
|
-
// Initiate E2EE handshake if Web Crypto API is available
|
|
1247
756
|
if (e2ee.available) {
|
|
1248
757
|
await e2ee.init();
|
|
1249
758
|
const pubKey = e2ee.getPublicKeyB64();
|
|
1250
|
-
if (pubKey) {
|
|
1251
|
-
ws.send(JSON.stringify({ v: 1, type: 'e2ee_init', payload: { publicKey: pubKey } }));
|
|
1252
|
-
}
|
|
759
|
+
if (pubKey) ws.send(JSON.stringify({ v: 1, type: 'e2ee_init', payload: { publicKey: pubKey } }));
|
|
1253
760
|
}
|
|
1254
|
-
// Session recovery: if we have a previous timestamp, request buffered messages
|
|
1255
761
|
const deviceId = localStorage.getItem('remux-device-id');
|
|
1256
762
|
if (lastReceivedTimestamp > 0 && deviceId) {
|
|
1257
763
|
isResuming = true;
|
|
1258
764
|
setStatus('connecting', 'Resuming session...');
|
|
1259
|
-
sendCtrl({ type: 'resume', deviceId
|
|
765
|
+
sendCtrl({ type: 'resume', deviceId, lastTimestamp: lastReceivedTimestamp });
|
|
1260
766
|
}
|
|
1261
|
-
// Let server pick the session if we have none (bootstrap flow)
|
|
1262
767
|
sendCtrl({ type: 'attach_first', session: currentSession || undefined, cols: term.cols, rows: term.rows });
|
|
1263
|
-
// Request device list (works with or without auth)
|
|
1264
|
-
sendCtrl({ type: 'list_devices' });
|
|
1265
|
-
// Request VAPID key for push notifications
|
|
1266
|
-
sendCtrl({ type: 'get_vapid_key' });
|
|
1267
768
|
};
|
|
1268
769
|
ws.onmessage = e => {
|
|
1269
770
|
lastMessageAt = Date.now();
|
|
1270
771
|
if (typeof e.data === 'string' && e.data[0] === '{') {
|
|
1271
772
|
try {
|
|
1272
773
|
const parsed = JSON.parse(e.data);
|
|
1273
|
-
// Handle both envelope (v:1) and legacy messages
|
|
1274
|
-
// Unwrap envelope: spread payload first, then override type with the
|
|
1275
|
-
// envelope's type to prevent payload.type (e.g. artifact type "snapshot")
|
|
1276
|
-
// from colliding with the message type (e.g. "snapshot_captured")
|
|
1277
774
|
const msg = parsed.v === 1 ? { ...(parsed.payload || {}), type: parsed.type } : parsed;
|
|
1278
|
-
// Server heartbeat — just keep connection alive (lastMessageAt already updated)
|
|
1279
775
|
if (msg.type === 'ping') return;
|
|
1280
|
-
|
|
1281
|
-
if (msg.type === '
|
|
1282
|
-
if (msg.publicKey && e2ee.available && e2ee.localKeyPair) {
|
|
1283
|
-
e2ee.completeHandshake(msg.publicKey);
|
|
1284
|
-
}
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
if (msg.type === 'e2ee_ready') {
|
|
1288
|
-
console.log('[e2ee] server confirmed E2EE established');
|
|
1289
|
-
return;
|
|
1290
|
-
}
|
|
1291
|
-
// E2EE encrypted message (terminal output from server)
|
|
776
|
+
if (msg.type === 'e2ee_init') { if (msg.publicKey && e2ee.available && e2ee.localKeyPair) e2ee.completeHandshake(msg.publicKey); return; }
|
|
777
|
+
if (msg.type === 'e2ee_ready') return;
|
|
1292
778
|
if (msg.type === 'e2ee_msg') {
|
|
1293
|
-
if (e2ee.established && msg.data) {
|
|
1294
|
-
e2ee.decryptMessage(msg.data).then(decrypted => {
|
|
1295
|
-
term.write(decrypted);
|
|
1296
|
-
}).catch(err => console.error('[e2ee] decrypt failed:', err));
|
|
1297
|
-
}
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
// Session recovery complete
|
|
1301
|
-
if (msg.type === 'resume_complete') {
|
|
1302
|
-
isResuming = false;
|
|
1303
|
-
if (msg.replayed > 0) {
|
|
1304
|
-
console.log('[remux] session recovered: ' + msg.replayed + ' buffered messages replayed');
|
|
1305
|
-
}
|
|
779
|
+
if (e2ee.established && msg.data) e2ee.decryptMessage(msg.data).then(d => term.write(d)).catch(() => {});
|
|
1306
780
|
return;
|
|
1307
781
|
}
|
|
1308
|
-
|
|
782
|
+
if (msg.type === 'resume_complete') { isResuming = false; return; }
|
|
1309
783
|
lastReceivedTimestamp = Date.now();
|
|
1310
|
-
if (msg.type === 'auth_ok')
|
|
1311
|
-
|
|
1312
|
-
// Request device list and workspace data after auth
|
|
1313
|
-
sendCtrl({ type: 'list_devices' });
|
|
1314
|
-
sendCtrl({ type: 'list_notes' });
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
if (msg.type === 'bootstrap') {
|
|
1318
|
-
sessions = msg.sessions || [];
|
|
1319
|
-
clientsList = msg.clients || [];
|
|
1320
|
-
renderSessions(); renderTabs(); renderRole(); stabilizeFit();
|
|
1321
|
-
return;
|
|
1322
|
-
}
|
|
784
|
+
if (msg.type === 'auth_ok') return;
|
|
785
|
+
if (msg.type === 'bootstrap') { sessions = msg.sessions || []; clientsList = msg.clients || []; renderSessions(); renderTabs(); stabilizeFit(); return; }
|
|
1323
786
|
if (msg.type === 'auth_error') { setStatus('disconnected', 'Auth failed'); ws.close(); return; }
|
|
1324
|
-
|
|
1325
|
-
if (msg.type === '
|
|
1326
|
-
console.warn('[remux] server error:', msg.reason || 'unknown');
|
|
1327
|
-
alert('Error: ' + (msg.reason || 'unknown error'));
|
|
1328
|
-
return;
|
|
1329
|
-
}
|
|
1330
|
-
if (msg.type === 'device_list') {
|
|
1331
|
-
devicesList = msg.devices || [];
|
|
1332
|
-
renderDevices(); return;
|
|
1333
|
-
}
|
|
1334
|
-
if (msg.type === 'pair_code') {
|
|
1335
|
-
const display = $('pair-code-display');
|
|
1336
|
-
if (display) {
|
|
1337
|
-
display.style.display = 'block';
|
|
1338
|
-
$('pair-code-value').textContent = msg.code;
|
|
1339
|
-
const remaining = Math.max(0, Math.ceil((msg.expiresAt - Date.now()) / 1000));
|
|
1340
|
-
$('pair-expires').textContent = 'Expires in ' + Math.ceil(remaining / 60) + ' min';
|
|
1341
|
-
setTimeout(() => { display.style.display = 'none'; }, remaining * 1000);
|
|
1342
|
-
}
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
if (msg.type === 'pair_result') {
|
|
1346
|
-
if (msg.success) {
|
|
1347
|
-
$('pair-code-input').value = '';
|
|
1348
|
-
sendCtrl({ type: 'list_devices' });
|
|
1349
|
-
} else {
|
|
1350
|
-
alert('Pairing failed: ' + (msg.reason || 'invalid code'));
|
|
1351
|
-
}
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
if (msg.type === 'vapid_key') {
|
|
1355
|
-
pushVapidKey = msg.publicKey;
|
|
1356
|
-
showPushSection();
|
|
1357
|
-
// Check current push status
|
|
1358
|
-
sendCtrl({ type: 'get_push_status' });
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
if (msg.type === 'push_subscribed') {
|
|
1362
|
-
pushSubscribed = msg.success;
|
|
1363
|
-
updatePushUI();
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
if (msg.type === 'push_unsubscribed') {
|
|
1367
|
-
pushSubscribed = false;
|
|
1368
|
-
updatePushUI();
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
if (msg.type === 'push_status') {
|
|
1372
|
-
pushSubscribed = msg.subscribed;
|
|
1373
|
-
updatePushUI();
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
if (msg.type === 'push_test_result') {
|
|
1377
|
-
// Brief visual feedback
|
|
1378
|
-
const testBtn = $('btn-push-test');
|
|
1379
|
-
if (testBtn) {
|
|
1380
|
-
testBtn.textContent = msg.sent ? 'Sent!' : 'Failed';
|
|
1381
|
-
setTimeout(() => { testBtn.textContent = 'Send Test'; }, 2000);
|
|
1382
|
-
}
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
if (msg.type === 'state') {
|
|
1386
|
-
sessions = msg.sessions || [];
|
|
1387
|
-
clientsList = msg.clients || [];
|
|
1388
|
-
// Re-derive own role from authoritative server state
|
|
1389
|
-
if (myClientId) {
|
|
1390
|
-
const me = clientsList.find(c => c.clientId === myClientId);
|
|
1391
|
-
if (me) myRole = me.role;
|
|
1392
|
-
}
|
|
1393
|
-
renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
|
|
1394
|
-
}
|
|
787
|
+
if (msg.type === 'error') { alert('Error: ' + (msg.reason || 'unknown error')); return; }
|
|
788
|
+
if (msg.type === 'state') { sessions = msg.sessions || []; clientsList = msg.clients || []; renderSessions(); renderTabs(); stabilizeFit(); return; }
|
|
1395
789
|
if (msg.type === 'attached') {
|
|
1396
790
|
currentTabId = msg.tabId; currentSession = msg.session;
|
|
1397
791
|
if (msg.clientId) myClientId = msg.clientId;
|
|
1398
|
-
|
|
1399
|
-
setStatus('connected', msg.session); renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
|
|
1400
|
-
}
|
|
1401
|
-
if (msg.type === 'role_changed') {
|
|
1402
|
-
if (msg.clientId === myClientId) myRole = msg.role;
|
|
1403
|
-
renderRole(); return;
|
|
1404
|
-
}
|
|
1405
|
-
if (msg.type === 'inspect_result') {
|
|
1406
|
-
window._inspectText = msg.text || '(empty)';
|
|
1407
|
-
const m = msg.meta || {};
|
|
1408
|
-
$('inspect-meta').innerHTML =
|
|
1409
|
-
'<span>' + esc(m.session) + ' / ' + esc(m.tabTitle || 'Tab ' + m.tabId) + '</span>' +
|
|
1410
|
-
'<span>' + (m.cols || '?') + 'x' + (m.rows || '?') + '</span>' +
|
|
1411
|
-
'<span>' + new Date(m.timestamp || Date.now()).toLocaleTimeString() + '</span>' +
|
|
1412
|
-
'<button class="inspect-btn" id="btn-copy-inspect">Copy</button>';
|
|
1413
|
-
$('btn-copy-inspect').addEventListener('click', () => {
|
|
1414
|
-
navigator.clipboard.writeText(window._inspectText).then(() => {
|
|
1415
|
-
$('btn-copy-inspect').textContent = 'Copied!';
|
|
1416
|
-
setTimeout(() => { const el = $('btn-copy-inspect'); if (el) el.textContent = 'Copy'; }, 1500);
|
|
1417
|
-
});
|
|
1418
|
-
});
|
|
1419
|
-
// Apply search highlight if active
|
|
1420
|
-
applyInspectSearch();
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
// Workspace message handlers
|
|
1424
|
-
if (msg.type === 'topic_list') { wsTopics = msg.topics || []; renderWorkspaceTopics(); return; }
|
|
1425
|
-
if (msg.type === 'topic_created') {
|
|
1426
|
-
// Optimistic render: add topic directly
|
|
1427
|
-
if (msg.id && msg.title) wsTopics.unshift({ id: msg.id, sessionName: msg.sessionName, title: msg.title, createdAt: msg.createdAt, updatedAt: msg.updatedAt });
|
|
1428
|
-
renderWorkspaceTopics();
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
if (msg.type === 'topic_deleted') { refreshWorkspace(); return; }
|
|
1432
|
-
if (msg.type === 'run_list') { wsRuns = msg.runs || []; renderWorkspaceRuns(); return; }
|
|
1433
|
-
if (msg.type === 'run_created' || msg.type === 'run_updated') { if (currentView === 'workspace') refreshWorkspace(); return; }
|
|
1434
|
-
if (msg.type === 'artifact_list') { wsArtifacts = msg.artifacts || []; renderWorkspaceArtifacts(); return; }
|
|
1435
|
-
if (msg.type === 'snapshot_captured') {
|
|
1436
|
-
// Optimistic render: add artifact directly (with server-rendered HTML)
|
|
1437
|
-
if (msg.id) wsArtifacts.unshift({ id: msg.id, type: 'snapshot', title: msg.title || 'Snapshot', content: msg.content, contentType: msg.contentType || 'plain', renderedHtml: msg.renderedHtml, createdAt: msg.createdAt || Date.now() });
|
|
1438
|
-
renderWorkspaceArtifacts();
|
|
1439
|
-
return;
|
|
1440
|
-
}
|
|
1441
|
-
if (msg.type === 'approval_list') { wsApprovals = msg.approvals || []; renderWorkspaceApprovals(); return; }
|
|
1442
|
-
if (msg.type === 'approval_created') { if (currentView === 'workspace') refreshWorkspace(); return; }
|
|
1443
|
-
if (msg.type === 'approval_resolved') { if (currentView === 'workspace') refreshWorkspace(); return; }
|
|
1444
|
-
// Search results
|
|
1445
|
-
if (msg.type === 'search_results') { renderSearchResults(msg.results || []); return; }
|
|
1446
|
-
// Handoff bundle
|
|
1447
|
-
if (msg.type === 'handoff_bundle') { renderHandoffBundle(msg); return; }
|
|
1448
|
-
// Notes
|
|
1449
|
-
if (msg.type === 'note_list') { wsNotes = msg.notes || []; renderNotes(); return; }
|
|
1450
|
-
if (msg.type === 'note_created') {
|
|
1451
|
-
// Optimistic render: add note directly without waiting for list refresh
|
|
1452
|
-
if (msg.id && msg.content) wsNotes.unshift({ id: msg.id, content: msg.content, pinned: msg.pinned || false, createdAt: msg.createdAt, updatedAt: msg.updatedAt });
|
|
1453
|
-
renderNotes();
|
|
1454
|
-
return;
|
|
1455
|
-
}
|
|
1456
|
-
if (msg.type === 'note_updated' || msg.type === 'note_deleted' || msg.type === 'note_pinned') { sendCtrl({ type: 'list_notes' }); return; }
|
|
1457
|
-
// Commands
|
|
1458
|
-
if (msg.type === 'command_list') { wsCommands = msg.commands || []; renderCommands(); return; }
|
|
1459
|
-
// Unrecognized enveloped control message — discard, never write to terminal
|
|
1460
|
-
if (parsed.v === 1) {
|
|
1461
|
-
console.warn('[remux] unhandled message type:', msg.type);
|
|
1462
|
-
return;
|
|
792
|
+
setStatus('connected', 'Connected'); renderSessions(); renderTabs(); stabilizeFit(); return;
|
|
1463
793
|
}
|
|
1464
|
-
|
|
794
|
+
if (msg.type === 'role_changed' || msg.type === 'inspect_result') return;
|
|
795
|
+
if (parsed.v === 1) return;
|
|
1465
796
|
} catch {}
|
|
1466
797
|
}
|
|
1467
|
-
peOnServerData(e.data);
|
|
1468
798
|
term.write(e.data);
|
|
1469
799
|
};
|
|
1470
|
-
ws.onclose = () => { stopHeartbeat();
|
|
800
|
+
ws.onclose = () => { stopHeartbeat(); scheduleReconnect(); };
|
|
1471
801
|
ws.onerror = () => setStatus('disconnected', 'Error');
|
|
1472
802
|
}
|
|
1473
803
|
connect();
|
|
1474
804
|
function sendCtrl(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
|
|
1475
|
-
// Send terminal data, encrypting if E2EE is established
|
|
1476
805
|
function sendTermData(data) {
|
|
1477
806
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1478
807
|
if (e2ee.established) {
|
|
1479
808
|
e2ee.encryptMessage(data).then(encrypted => {
|
|
1480
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
}).catch(err => {
|
|
1484
|
-
console.error('[e2ee] encrypt failed, sending plaintext:', err);
|
|
1485
|
-
ws.send(data);
|
|
1486
|
-
});
|
|
1487
|
-
} else {
|
|
1488
|
-
ws.send(data);
|
|
1489
|
-
}
|
|
809
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ v: 1, type: 'e2ee_msg', payload: { data: encrypted } }));
|
|
810
|
+
}).catch(() => { ws.send(data); });
|
|
811
|
+
} else { ws.send(data); }
|
|
1490
812
|
}
|
|
1491
813
|
|
|
1492
|
-
// -- Terminal
|
|
1493
|
-
term.onData(data => {
|
|
1494
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1495
|
-
if (ctrlActive) {
|
|
1496
|
-
ctrlActive = false; $('btn-ctrl').classList.remove('active');
|
|
1497
|
-
const ch = data.toLowerCase().charCodeAt(0);
|
|
1498
|
-
if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
|
|
1499
|
-
}
|
|
1500
|
-
peOnInput(data);
|
|
1501
|
-
sendTermData(data);
|
|
1502
|
-
});
|
|
814
|
+
// -- Terminal resize (read-only, no keyboard input) --
|
|
1503
815
|
term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
|
|
1504
816
|
|
|
1505
|
-
// -- Compose
|
|
817
|
+
// -- Compose input: text field + send --
|
|
818
|
+
const cmdInput = $('cmd-input');
|
|
819
|
+
function sendCommand() {
|
|
820
|
+
const cmd = cmdInput.value;
|
|
821
|
+
if (!cmd) return;
|
|
822
|
+
sendTermData(cmd + '\\n');
|
|
823
|
+
cmdInput.value = '';
|
|
824
|
+
cmdInput.focus();
|
|
825
|
+
}
|
|
826
|
+
$('btn-send').addEventListener('click', sendCommand);
|
|
827
|
+
cmdInput.addEventListener('keydown', e => {
|
|
828
|
+
if (e.key === 'Enter') { e.preventDefault(); sendCommand(); }
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// -- Preset quick keys --
|
|
1506
832
|
const SEQ = {
|
|
1507
833
|
esc: '\\x1b', tab: '\\t',
|
|
1508
|
-
up: '\\x1b[A', down: '\\x1b[B',
|
|
1509
|
-
'ctrl-c': '\\x03', 'ctrl-d': '\\x04', 'ctrl-z': '\\x1a',
|
|
1510
|
-
pgup: '\\x1b[5~', pgdn: '\\x1b[6~', home: '\\x1b[H', end: '\\x1b[F',
|
|
834
|
+
up: '\\x1b[A', down: '\\x1b[B',
|
|
835
|
+
'ctrl-c': '\\x03', 'ctrl-d': '\\x04', 'ctrl-z': '\\x1a', 'ctrl-l': '\\x0c',
|
|
1511
836
|
};
|
|
1512
|
-
$('
|
|
837
|
+
$('btn-preset-toggle').addEventListener('click', () => {
|
|
838
|
+
const bar = $('preset-bar');
|
|
839
|
+
const btn = $('btn-preset-toggle');
|
|
840
|
+
bar.classList.toggle('visible');
|
|
841
|
+
btn.classList.toggle('active', bar.classList.contains('visible'));
|
|
842
|
+
setTimeout(stabilizeFit, 50);
|
|
843
|
+
});
|
|
844
|
+
$('preset-bar').addEventListener('pointerdown', e => {
|
|
1513
845
|
const btn = e.target.closest('button'); if (!btn) return;
|
|
1514
846
|
e.preventDefault();
|
|
1515
|
-
|
|
1516
|
-
const d = SEQ[btn.dataset.seq] || btn.dataset.ch;
|
|
847
|
+
const d = SEQ[btn.dataset.seq];
|
|
1517
848
|
if (d) sendTermData(d);
|
|
1518
|
-
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
// -- Inspect view --
|
|
1522
|
-
let currentView = 'live', inspectTimer = null, wsRefreshTimer = null;
|
|
1523
|
-
function setView(mode) {
|
|
1524
|
-
currentView = mode;
|
|
1525
|
-
$('btn-live').classList.toggle('active', mode === 'live');
|
|
1526
|
-
$('btn-inspect').classList.toggle('active', mode === 'inspect');
|
|
1527
|
-
$('btn-workspace').classList.toggle('active', mode === 'workspace');
|
|
1528
|
-
$('terminal').classList.toggle('hidden', mode !== 'live');
|
|
1529
|
-
$('inspect').classList.toggle('visible', mode === 'inspect');
|
|
1530
|
-
$('workspace').classList.toggle('visible', mode === 'workspace');
|
|
1531
|
-
// Inspect auto-refresh
|
|
1532
|
-
if (inspectTimer) { clearInterval(inspectTimer); inspectTimer = null; }
|
|
1533
|
-
if (mode === 'inspect') {
|
|
1534
|
-
sendCtrl({ type: 'inspect' });
|
|
1535
|
-
inspectTimer = setInterval(() => sendCtrl({ type: 'inspect' }), 3000);
|
|
1536
|
-
}
|
|
1537
|
-
// Workspace auto-refresh
|
|
1538
|
-
if (wsRefreshTimer) { clearInterval(wsRefreshTimer); wsRefreshTimer = null; }
|
|
1539
|
-
if (mode === 'workspace') {
|
|
1540
|
-
refreshWorkspace();
|
|
1541
|
-
wsRefreshTimer = setInterval(refreshWorkspace, 5000);
|
|
1542
|
-
}
|
|
1543
|
-
if (mode === 'live') { term.focus(); stabilizeFit(); }
|
|
1544
|
-
}
|
|
1545
|
-
$('btn-live').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('live'); });
|
|
1546
|
-
$('btn-inspect').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('inspect'); });
|
|
1547
|
-
$('btn-workspace').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('workspace'); });
|
|
1548
|
-
|
|
1549
|
-
// -- Inspect search --
|
|
1550
|
-
function applyInspectSearch() {
|
|
1551
|
-
const query = ($('inspect-search-input') || {}).value || '';
|
|
1552
|
-
const text = window._inspectText || '';
|
|
1553
|
-
if (!query) {
|
|
1554
|
-
$('inspect-content').textContent = text;
|
|
1555
|
-
$('inspect-match-count').textContent = '';
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
// Simple case-insensitive text search with <mark> highlighting
|
|
1559
|
-
// Work on raw text to avoid HTML entity issues, then escape each fragment
|
|
1560
|
-
const esc = t => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1561
|
-
const q = query.toLowerCase();
|
|
1562
|
-
const lower = text.toLowerCase();
|
|
1563
|
-
let result = '', count = 0, pos = 0;
|
|
1564
|
-
while (pos < text.length) {
|
|
1565
|
-
const idx = lower.indexOf(q, pos);
|
|
1566
|
-
if (idx === -1) { result += esc(text.slice(pos)); break; }
|
|
1567
|
-
result += esc(text.slice(pos, idx)) + '<mark>' + esc(text.slice(idx, idx + q.length)) + '</mark>';
|
|
1568
|
-
count++; pos = idx + q.length;
|
|
1569
|
-
}
|
|
1570
|
-
$('inspect-content').innerHTML = result;
|
|
1571
|
-
$('inspect-match-count').textContent = count > 0 ? count + ' match' + (count !== 1 ? 'es' : '') : 'No matches';
|
|
1572
|
-
}
|
|
1573
|
-
$('inspect-search-input').addEventListener('input', applyInspectSearch);
|
|
1574
|
-
|
|
1575
|
-
// -- Devices section --
|
|
1576
|
-
let devicesList = [], myDeviceId = null, devicesCollapsed = false;
|
|
1577
|
-
|
|
1578
|
-
function renderDevices() {
|
|
1579
|
-
const list = $('devices-list');
|
|
1580
|
-
const actions = $('devices-actions');
|
|
1581
|
-
if (!list) return;
|
|
1582
|
-
list.innerHTML = '';
|
|
1583
|
-
devicesList.forEach(d => {
|
|
1584
|
-
const el = document.createElement('div');
|
|
1585
|
-
el.className = 'device-item';
|
|
1586
|
-
const isSelf = d.id === myDeviceId;
|
|
1587
|
-
el.innerHTML = '<span class="device-dot ' + esc(d.trust) + '"></span>'
|
|
1588
|
-
+ '<span class="device-name">' + esc(d.name) + (isSelf ? ' <span class="device-self">(you)</span>' : '') + '</span>'
|
|
1589
|
-
+ '<span class="device-actions">'
|
|
1590
|
-
+ (d.trust !== 'trusted' ? '<button data-trust="' + d.id + '" title="Trust">✓</button>' : '')
|
|
1591
|
-
+ (d.trust !== 'blocked' ? '<button data-block="' + d.id + '" title="Block">✗</button>' : '')
|
|
1592
|
-
+ '<button data-rename-dev="' + d.id + '" title="Rename">✎</button>'
|
|
1593
|
-
+ (!isSelf ? '<button data-revoke="' + d.id + '" title="Revoke">🗑</button>' : '')
|
|
1594
|
-
+ '</span>';
|
|
1595
|
-
el.addEventListener('click', e => {
|
|
1596
|
-
const btn = e.target.closest('button');
|
|
1597
|
-
if (!btn) return;
|
|
1598
|
-
if (btn.dataset.trust) sendCtrl({ type: 'trust_device', deviceId: btn.dataset.trust });
|
|
1599
|
-
if (btn.dataset.block) sendCtrl({ type: 'block_device', deviceId: btn.dataset.block });
|
|
1600
|
-
if (btn.dataset.renameDev) {
|
|
1601
|
-
const newName = prompt('Device name:', d.name);
|
|
1602
|
-
if (newName && newName.trim()) sendCtrl({ type: 'rename_device', deviceId: btn.dataset.renameDev, name: newName.trim() });
|
|
1603
|
-
}
|
|
1604
|
-
if (btn.dataset.revoke) {
|
|
1605
|
-
if (confirm('Revoke device "' + d.name + '"?')) sendCtrl({ type: 'revoke_device', deviceId: btn.dataset.revoke });
|
|
1606
|
-
}
|
|
1607
|
-
});
|
|
1608
|
-
list.appendChild(el);
|
|
1609
|
-
});
|
|
1610
|
-
|
|
1611
|
-
// Show actions only for trusted devices; untrusted see pair input instead
|
|
1612
|
-
const isTrusted = devicesList.find(d => d.id === myDeviceId && d.trust === 'trusted');
|
|
1613
|
-
if (actions) {
|
|
1614
|
-
actions.style.display = 'block';
|
|
1615
|
-
const btnPair = $('btn-pair');
|
|
1616
|
-
if (btnPair) {
|
|
1617
|
-
btnPair.disabled = !isTrusted;
|
|
1618
|
-
btnPair.title = isTrusted ? '' : 'Only trusted devices can generate pair codes';
|
|
1619
|
-
}
|
|
1620
|
-
// Show pair input for untrusted devices
|
|
1621
|
-
const pairInput = $('pair-input-area');
|
|
1622
|
-
if (pairInput) pairInput.style.display = isTrusted ? 'none' : 'flex';
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
$('devices-header').addEventListener('click', () => {
|
|
1627
|
-
devicesCollapsed = !devicesCollapsed;
|
|
1628
|
-
$('devices-list').classList.toggle('collapsed', devicesCollapsed);
|
|
1629
|
-
$('devices-toggle').classList.toggle('collapsed', devicesCollapsed);
|
|
1630
|
-
if ($('devices-actions')) $('devices-actions').style.display = devicesCollapsed ? 'none' : '';
|
|
1631
|
-
});
|
|
1632
|
-
|
|
1633
|
-
$('btn-pair').addEventListener('click', () => {
|
|
1634
|
-
sendCtrl({ type: 'generate_pair_code' });
|
|
1635
|
-
});
|
|
1636
|
-
|
|
1637
|
-
$('btn-submit-pair').addEventListener('click', () => {
|
|
1638
|
-
const code = $('pair-code-input').value.trim();
|
|
1639
|
-
if (!/^\d{6}$/.test(code)) { alert('Please enter a 6-digit pair code'); return; }
|
|
1640
|
-
sendCtrl({ type: 'pair', code });
|
|
1641
|
-
});
|
|
1642
|
-
|
|
1643
|
-
$('pair-code-input').addEventListener('keydown', e => {
|
|
1644
|
-
if (e.key === 'Enter') { e.preventDefault(); $('btn-submit-pair').click(); }
|
|
1645
|
-
});
|
|
1646
|
-
|
|
1647
|
-
// -- Push notifications --
|
|
1648
|
-
let pushSubscribed = false;
|
|
1649
|
-
let pushVapidKey = null;
|
|
1650
|
-
|
|
1651
|
-
function showPushSection() {
|
|
1652
|
-
// Show only if browser supports push + service workers
|
|
1653
|
-
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
|
1654
|
-
$('push-section').style.display = 'block';
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
function updatePushUI() {
|
|
1658
|
-
const btn = $('btn-push-toggle');
|
|
1659
|
-
const label = $('push-label');
|
|
1660
|
-
const testBtn = $('btn-push-test');
|
|
1661
|
-
if (pushSubscribed) {
|
|
1662
|
-
btn.classList.add('subscribed');
|
|
1663
|
-
label.textContent = 'Notifications On';
|
|
1664
|
-
testBtn.style.display = 'block';
|
|
1665
|
-
} else {
|
|
1666
|
-
btn.classList.remove('subscribed');
|
|
1667
|
-
label.textContent = 'Enable Notifications';
|
|
1668
|
-
testBtn.style.display = 'none';
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
function urlBase64ToUint8Array(base64String) {
|
|
1673
|
-
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
1674
|
-
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
1675
|
-
const rawData = atob(base64);
|
|
1676
|
-
const outputArray = new Uint8Array(rawData.length);
|
|
1677
|
-
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
|
|
1678
|
-
return outputArray;
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
async function subscribePush() {
|
|
1682
|
-
if (!pushVapidKey) return;
|
|
1683
|
-
try {
|
|
1684
|
-
const reg = await navigator.serviceWorker.register('/sw.js');
|
|
1685
|
-
await navigator.serviceWorker.ready;
|
|
1686
|
-
const sub = await reg.pushManager.subscribe({
|
|
1687
|
-
userVisibleOnly: true,
|
|
1688
|
-
applicationServerKey: urlBase64ToUint8Array(pushVapidKey),
|
|
1689
|
-
});
|
|
1690
|
-
const subJson = sub.toJSON();
|
|
1691
|
-
sendCtrl({
|
|
1692
|
-
type: 'subscribe_push',
|
|
1693
|
-
subscription: {
|
|
1694
|
-
endpoint: subJson.endpoint,
|
|
1695
|
-
keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth },
|
|
1696
|
-
},
|
|
1697
|
-
});
|
|
1698
|
-
} catch (err) {
|
|
1699
|
-
console.error('[push] subscribe failed:', err);
|
|
1700
|
-
if (Notification.permission === 'denied') {
|
|
1701
|
-
$('push-label').textContent = 'Permission Denied';
|
|
1702
|
-
} else {
|
|
1703
|
-
$('push-label').textContent = 'Not Available';
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
async function unsubscribePush() {
|
|
1709
|
-
try {
|
|
1710
|
-
const reg = await navigator.serviceWorker.getRegistration();
|
|
1711
|
-
if (reg) {
|
|
1712
|
-
const sub = await reg.pushManager.getSubscription();
|
|
1713
|
-
if (sub) await sub.unsubscribe();
|
|
1714
|
-
}
|
|
1715
|
-
sendCtrl({ type: 'unsubscribe_push' });
|
|
1716
|
-
} catch (err) {
|
|
1717
|
-
console.error('[push] unsubscribe failed:', err);
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
$('btn-push-toggle').addEventListener('click', async () => {
|
|
1722
|
-
if (pushSubscribed) {
|
|
1723
|
-
await unsubscribePush();
|
|
1724
|
-
pushSubscribed = false;
|
|
1725
|
-
} else {
|
|
1726
|
-
await subscribePush();
|
|
1727
|
-
}
|
|
1728
|
-
updatePushUI();
|
|
1729
|
-
});
|
|
1730
|
-
|
|
1731
|
-
$('btn-push-test').addEventListener('click', () => {
|
|
1732
|
-
sendCtrl({ type: 'test_push' });
|
|
1733
|
-
});
|
|
1734
|
-
|
|
1735
|
-
// -- Workspace view --
|
|
1736
|
-
let wsTopics = [], wsRuns = [], wsArtifacts = [], wsApprovals = [];
|
|
1737
|
-
let wsNotes = [], wsCommands = [];
|
|
1738
|
-
|
|
1739
|
-
function refreshWorkspace() {
|
|
1740
|
-
if (!currentSession) return; // Wait until bootstrap resolves a session
|
|
1741
|
-
sendCtrl({ type: 'list_topics', sessionName: currentSession });
|
|
1742
|
-
sendCtrl({ type: 'list_runs' });
|
|
1743
|
-
sendCtrl({ type: 'list_artifacts', sessionName: currentSession });
|
|
1744
|
-
sendCtrl({ type: 'list_approvals' });
|
|
1745
|
-
sendCtrl({ type: 'list_notes' }); // Notes are global workspace memory, not session-scoped
|
|
1746
|
-
sendCtrl({ type: 'list_commands' });
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
function timeAgo(ts) {
|
|
1750
|
-
const s = Math.floor((Date.now() - ts) / 1000);
|
|
1751
|
-
if (s < 60) return s + 's ago';
|
|
1752
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
1753
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
1754
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
function renderWorkspaceApprovals() {
|
|
1758
|
-
const el = $('ws-approvals');
|
|
1759
|
-
if (!el) return;
|
|
1760
|
-
const pending = wsApprovals.filter(a => a.status === 'pending');
|
|
1761
|
-
if (pending.length === 0) { el.innerHTML = '<div class="ws-empty">No pending approvals</div>'; return; }
|
|
1762
|
-
el.innerHTML = pending.map(a =>
|
|
1763
|
-
'<div class="ws-card">' +
|
|
1764
|
-
'<div class="ws-card-header">' +
|
|
1765
|
-
'<span class="ws-badge pending">pending</span>' +
|
|
1766
|
-
'<span class="ws-card-title">' + esc(a.title) + '</span>' +
|
|
1767
|
-
'<span class="ws-card-meta">' + timeAgo(a.createdAt) + '</span>' +
|
|
1768
|
-
'</div>' +
|
|
1769
|
-
(a.description ? '<div class="ws-card-desc">' + esc(a.description) + '</div>' : '') +
|
|
1770
|
-
'<div class="ws-card-actions">' +
|
|
1771
|
-
'<button class="approve" data-approve-id="' + a.id + '">Approve</button>' +
|
|
1772
|
-
'<button class="reject" data-reject-id="' + a.id + '">Reject</button>' +
|
|
1773
|
-
'</div>' +
|
|
1774
|
-
'</div>'
|
|
1775
|
-
).join('');
|
|
1776
|
-
el.querySelectorAll('[data-approve-id]').forEach(btn => {
|
|
1777
|
-
btn.addEventListener('click', () => sendCtrl({ type: 'resolve_approval', approvalId: btn.dataset.approveId, status: 'approved' }));
|
|
1778
|
-
});
|
|
1779
|
-
el.querySelectorAll('[data-reject-id]').forEach(btn => {
|
|
1780
|
-
btn.addEventListener('click', () => sendCtrl({ type: 'resolve_approval', approvalId: btn.dataset.rejectId, status: 'rejected' }));
|
|
1781
|
-
});
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
function renderWorkspaceTopics() {
|
|
1785
|
-
const el = $('ws-topics');
|
|
1786
|
-
if (!el) return;
|
|
1787
|
-
if (wsTopics.length === 0) { el.innerHTML = '<div class="ws-empty">No topics yet</div>'; return; }
|
|
1788
|
-
el.innerHTML = wsTopics.map(t =>
|
|
1789
|
-
'<div class="ws-card">' +
|
|
1790
|
-
'<div class="ws-card-header">' +
|
|
1791
|
-
'<span class="ws-card-title">' + esc(t.title) + '</span>' +
|
|
1792
|
-
'<span class="ws-card-meta">' + timeAgo(t.createdAt) + '</span>' +
|
|
1793
|
-
'<button class="del-topic" data-del-topic="' + t.id + '" title="Delete">×</button>' +
|
|
1794
|
-
'</div>' +
|
|
1795
|
-
'<div class="ws-card-meta">' + esc(t.sessionName) + '</div>' +
|
|
1796
|
-
'</div>'
|
|
1797
|
-
).join('');
|
|
1798
|
-
el.querySelectorAll('[data-del-topic]').forEach(btn => {
|
|
1799
|
-
btn.addEventListener('click', () => {
|
|
1800
|
-
sendCtrl({ type: 'delete_topic', topicId: btn.dataset.delTopic });
|
|
1801
|
-
setTimeout(refreshWorkspace, 200);
|
|
1802
|
-
});
|
|
1803
|
-
});
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
function renderWorkspaceRuns() {
|
|
1807
|
-
const el = $('ws-runs');
|
|
1808
|
-
if (!el) return;
|
|
1809
|
-
const active = wsRuns.filter(r => r.status === 'running');
|
|
1810
|
-
const recent = wsRuns.filter(r => r.status !== 'running').slice(-5).reverse();
|
|
1811
|
-
const all = [...active, ...recent];
|
|
1812
|
-
if (all.length === 0) { el.innerHTML = '<div class="ws-empty">No runs</div>'; return; }
|
|
1813
|
-
el.innerHTML = all.map(r =>
|
|
1814
|
-
'<div class="ws-card">' +
|
|
1815
|
-
'<div class="ws-card-header">' +
|
|
1816
|
-
'<span class="ws-badge ' + r.status + '">' + r.status + '</span>' +
|
|
1817
|
-
'<span class="ws-card-title">' + esc(r.command || '(no command)') + '</span>' +
|
|
1818
|
-
'<span class="ws-card-meta">' + timeAgo(r.startedAt) + '</span>' +
|
|
1819
|
-
'</div>' +
|
|
1820
|
-
(r.exitCode !== null ? '<div class="ws-card-meta">Exit: ' + r.exitCode + '</div>' : '') +
|
|
1821
|
-
'</div>'
|
|
1822
|
-
).join('');
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// Track which artifact IDs are expanded (persists across re-renders)
|
|
1826
|
-
const _expandedArtifacts = new Set();
|
|
1827
|
-
|
|
1828
|
-
function renderWorkspaceArtifacts() {
|
|
1829
|
-
const el = $('ws-artifacts');
|
|
1830
|
-
if (!el) return;
|
|
1831
|
-
// Artifacts are already filtered by session_name on the server side
|
|
1832
|
-
const recent = wsArtifacts.slice(-10).reverse();
|
|
1833
|
-
if (recent.length === 0) { el.innerHTML = '<div class="ws-empty">No artifacts</div>'; return; }
|
|
1834
|
-
el.innerHTML = recent.map((a) => {
|
|
1835
|
-
var hasContent = a.content && a.content.trim();
|
|
1836
|
-
var ct = a.contentType || 'plain';
|
|
1837
|
-
var badge = (ct !== 'plain') ? ' <span class="ws-badge ' + esc(ct) + '">' + esc(ct) + '</span>' : '';
|
|
1838
|
-
var rendered = a.renderedHtml || (hasContent ? '<pre style="margin:0;font-size:11px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word">' + esc(a.content) + '</pre>' : '');
|
|
1839
|
-
var isExpanded = _expandedArtifacts.has(a.id);
|
|
1840
|
-
return '<div class="ws-card">' +
|
|
1841
|
-
'<div class="ws-card-header">' +
|
|
1842
|
-
'<span class="ws-badge ' + esc(a.type) + '">' + esc(a.type) + '</span>' +
|
|
1843
|
-
badge +
|
|
1844
|
-
'<span class="ws-card-title">' + esc(a.title) + '</span>' +
|
|
1845
|
-
'<span class="ws-card-meta">' + timeAgo(a.createdAt) + '</span>' +
|
|
1846
|
-
(hasContent ? '<button class="ws-card-toggle" data-toggle-art="' + esc(a.id) + '">' + (isExpanded ? 'Hide' : 'Show') + '</button>' : '') +
|
|
1847
|
-
'</div>' +
|
|
1848
|
-
(hasContent ? '<div class="ws-card-content" data-art-content="' + esc(a.id) + '" style="display:' + (isExpanded ? 'block' : 'none') + '">' + rendered + '</div>' : '') +
|
|
1849
|
-
'</div>';
|
|
1850
|
-
}).join('');
|
|
1851
|
-
// Wire up toggle buttons
|
|
1852
|
-
el.querySelectorAll('[data-toggle-art]').forEach(function(btn) {
|
|
1853
|
-
btn.addEventListener('click', function() {
|
|
1854
|
-
var artId = btn.getAttribute('data-toggle-art');
|
|
1855
|
-
var contentEl = el.querySelector('[data-art-content="' + artId + '"]');
|
|
1856
|
-
if (!contentEl) return;
|
|
1857
|
-
var visible = contentEl.style.display !== 'none';
|
|
1858
|
-
contentEl.style.display = visible ? 'none' : 'block';
|
|
1859
|
-
btn.textContent = visible ? 'Show' : 'Hide';
|
|
1860
|
-
if (visible) _expandedArtifacts.delete(artId); else _expandedArtifacts.add(artId);
|
|
1861
|
-
});
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
$('btn-new-topic').addEventListener('click', () => {
|
|
1866
|
-
const title = prompt('Topic title:');
|
|
1867
|
-
if (title && title.trim()) {
|
|
1868
|
-
sendCtrl({ type: 'create_topic', sessionName: currentSession, title: title.trim() });
|
|
1869
|
-
// Optimistic render in topic_created handler, no delayed refresh needed
|
|
1870
|
-
}
|
|
1871
|
-
});
|
|
1872
|
-
|
|
1873
|
-
$('btn-capture-snapshot').addEventListener('click', () => {
|
|
1874
|
-
sendCtrl({ type: 'capture_snapshot' });
|
|
1875
|
-
// Optimistic render in snapshot_captured handler, no delayed refresh needed
|
|
1876
|
-
});
|
|
1877
|
-
|
|
1878
|
-
// -- Search --
|
|
1879
|
-
let searchDebounce = null;
|
|
1880
|
-
$('ws-search-input').addEventListener('input', () => {
|
|
1881
|
-
clearTimeout(searchDebounce);
|
|
1882
|
-
const q = $('ws-search-input').value.trim();
|
|
1883
|
-
if (!q) { $('ws-search-results').innerHTML = ''; return; }
|
|
1884
|
-
searchDebounce = setTimeout(() => sendCtrl({ type: 'search', query: q }), 200);
|
|
1885
|
-
});
|
|
1886
|
-
|
|
1887
|
-
function renderSearchResults(results) {
|
|
1888
|
-
const el = $('ws-search-results');
|
|
1889
|
-
if (!el) return;
|
|
1890
|
-
if (results.length === 0) {
|
|
1891
|
-
const q = ($('ws-search-input') || {}).value || '';
|
|
1892
|
-
el.innerHTML = q ? '<div class="ws-empty">No results</div>' : '';
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
const esc = t => (t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1896
|
-
el.innerHTML = results.map(r =>
|
|
1897
|
-
'<div class="ws-search-result">' +
|
|
1898
|
-
'<span class="sr-type">' + esc(r.entityType) + '</span> ' +
|
|
1899
|
-
'<span class="sr-title">' + esc(r.title) + '</span>' +
|
|
1900
|
-
'</div>'
|
|
1901
|
-
).join('');
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
// -- Handoff --
|
|
1905
|
-
$('btn-handoff').addEventListener('click', () => {
|
|
1906
|
-
const el = $('ws-handoff');
|
|
1907
|
-
if (el.classList.contains('visible')) { el.classList.remove('visible'); return; }
|
|
1908
|
-
sendCtrl({ type: 'get_handoff' });
|
|
849
|
+
cmdInput.focus();
|
|
1909
850
|
});
|
|
1910
851
|
|
|
1911
|
-
function renderHandoffBundle(bundle) {
|
|
1912
|
-
const el = $('ws-handoff');
|
|
1913
|
-
if (!el) return;
|
|
1914
|
-
el.classList.add('visible');
|
|
1915
|
-
const esc = t => (t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1916
|
-
let html = '<div class="ws-handoff-section"><div class="ws-handoff-label">Sessions</div>';
|
|
1917
|
-
html += '<ul class="ws-handoff-list">';
|
|
1918
|
-
(bundle.sessions || []).forEach(s => {
|
|
1919
|
-
html += '<li>' + esc(s.name) + ' (' + s.activeTabs + ' active tabs)</li>';
|
|
1920
|
-
});
|
|
1921
|
-
html += '</ul></div>';
|
|
1922
|
-
if ((bundle.activeTopics || []).length > 0) {
|
|
1923
|
-
html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Active Topics (24h)</div>';
|
|
1924
|
-
html += '<ul class="ws-handoff-list">';
|
|
1925
|
-
bundle.activeTopics.forEach(t => { html += '<li>' + esc(t.title) + '</li>'; });
|
|
1926
|
-
html += '</ul></div>';
|
|
1927
|
-
}
|
|
1928
|
-
if ((bundle.pendingApprovals || []).length > 0) {
|
|
1929
|
-
html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Pending Approvals</div>';
|
|
1930
|
-
html += '<ul class="ws-handoff-list">';
|
|
1931
|
-
bundle.pendingApprovals.forEach(a => { html += '<li>' + esc(a.title) + '</li>'; });
|
|
1932
|
-
html += '</ul></div>';
|
|
1933
|
-
}
|
|
1934
|
-
html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Recent Runs (' + (bundle.recentRuns || []).length + ')</div></div>';
|
|
1935
|
-
html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Key Artifacts (' + (bundle.keyArtifacts || []).length + ')</div></div>';
|
|
1936
|
-
el.innerHTML = html;
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// -- Notes --
|
|
1940
|
-
function renderNotes() {
|
|
1941
|
-
const el = $('ws-notes');
|
|
1942
|
-
if (!el) return;
|
|
1943
|
-
if (wsNotes.length === 0) { el.innerHTML = '<div class="ws-empty">No notes yet</div>'; return; }
|
|
1944
|
-
const esc = t => (t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1945
|
-
el.innerHTML = wsNotes.map(n =>
|
|
1946
|
-
'<div class="ws-note' + (n.pinned ? ' pinned' : '') + '">' +
|
|
1947
|
-
'<div class="ws-note-content">' + esc(n.content) + '</div>' +
|
|
1948
|
-
'<div class="ws-note-actions">' +
|
|
1949
|
-
'<button data-pin-note="' + n.id + '">' + (n.pinned ? 'Unpin' : 'Pin') + '</button>' +
|
|
1950
|
-
'<button data-del-note="' + n.id + '">Delete</button>' +
|
|
1951
|
-
'</div>' +
|
|
1952
|
-
'</div>'
|
|
1953
|
-
).join('');
|
|
1954
|
-
el.querySelectorAll('[data-pin-note]').forEach(btn => {
|
|
1955
|
-
btn.addEventListener('click', () => sendCtrl({ type: 'pin_note', noteId: btn.dataset.pinNote }));
|
|
1956
|
-
});
|
|
1957
|
-
el.querySelectorAll('[data-del-note]').forEach(btn => {
|
|
1958
|
-
btn.addEventListener('click', () => sendCtrl({ type: 'delete_note', noteId: btn.dataset.delNote }));
|
|
1959
|
-
});
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
$('btn-add-note').addEventListener('click', () => {
|
|
1963
|
-
const input = $('ws-note-input');
|
|
1964
|
-
const content = input.value.trim();
|
|
1965
|
-
if (!content) return;
|
|
1966
|
-
sendCtrl({ type: 'create_note', content });
|
|
1967
|
-
input.value = '';
|
|
1968
|
-
// Feedback: show saving indicator, revert if no response in 3s
|
|
1969
|
-
const el = $('ws-notes');
|
|
1970
|
-
const prevHtml = el.innerHTML;
|
|
1971
|
-
el.innerHTML = '<div class="ws-empty">Saving...</div>';
|
|
1972
|
-
setTimeout(() => {
|
|
1973
|
-
if (el.innerHTML.includes('Saving...')) {
|
|
1974
|
-
el.innerHTML = '<div class="ws-empty" style="color:var(--text-dim)">Note may not have saved — check server logs</div>';
|
|
1975
|
-
}
|
|
1976
|
-
}, 3000);
|
|
1977
|
-
});
|
|
1978
|
-
$('ws-note-input').addEventListener('keydown', e => {
|
|
1979
|
-
if (e.key === 'Enter') { e.preventDefault(); $('btn-add-note').click(); }
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
|
-
// -- Commands --
|
|
1983
|
-
function formatDuration(startedAt, endedAt) {
|
|
1984
|
-
if (!endedAt) return 'running';
|
|
1985
|
-
const ms = endedAt - startedAt;
|
|
1986
|
-
if (ms < 1000) return ms + 'ms';
|
|
1987
|
-
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
1988
|
-
return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
function renderCommands() {
|
|
1992
|
-
const el = $('ws-commands');
|
|
1993
|
-
if (!el) return;
|
|
1994
|
-
if (wsCommands.length === 0) { el.innerHTML = '<div class="ws-empty">No commands detected (requires shell integration)</div>'; return; }
|
|
1995
|
-
const esc = t => (t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1996
|
-
el.innerHTML = wsCommands.slice(0, 20).map(c => {
|
|
1997
|
-
const exitClass = c.exitCode === null ? '' : (c.exitCode === 0 ? 'ok' : 'err');
|
|
1998
|
-
const exitSymbol = c.exitCode === null ? '' : (c.exitCode === 0 ? '✓' : '✗ ' + c.exitCode);
|
|
1999
|
-
return '<div class="ws-cmd">' +
|
|
2000
|
-
'<span class="ws-cmd-text">' + esc(c.command || '(unknown)') + '</span>' +
|
|
2001
|
-
(exitSymbol ? '<span class="ws-cmd-exit ' + exitClass + '">' + exitSymbol + '</span>' : '') +
|
|
2002
|
-
'<span class="ws-cmd-meta">' + formatDuration(c.startedAt, c.endedAt) + '</span>' +
|
|
2003
|
-
(c.cwd ? '<span class="ws-cmd-meta">' + esc(c.cwd) + '</span>' : '') +
|
|
2004
|
-
'</div>';
|
|
2005
|
-
}).join('');
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
852
|
// -- Tab rename (double-click) --
|
|
2009
853
|
$('tab-list').addEventListener('dblclick', e => {
|
|
2010
854
|
const tabEl = e.target.closest('.tab');
|
|
2011
855
|
if (!tabEl) return;
|
|
2012
856
|
const titleSpan = tabEl.querySelector('.title');
|
|
2013
857
|
if (!titleSpan) return;
|
|
2014
|
-
// Find tab id from close button
|
|
2015
858
|
const closeBtn = tabEl.querySelector('.close');
|
|
2016
859
|
if (!closeBtn) return;
|
|
2017
860
|
const tabId = Number(closeBtn.dataset.close);
|
|
2018
861
|
const oldTitle = titleSpan.textContent;
|
|
2019
|
-
|
|
2020
862
|
const input = document.createElement('input');
|
|
2021
863
|
input.className = 'rename-input';
|
|
2022
864
|
input.value = oldTitle;
|
|
2023
865
|
input.setAttribute('maxlength', '32');
|
|
2024
866
|
titleSpan.replaceWith(input);
|
|
2025
|
-
input.focus();
|
|
2026
|
-
input.select();
|
|
2027
|
-
|
|
867
|
+
input.focus(); input.select();
|
|
2028
868
|
function commit() {
|
|
2029
869
|
const newTitle = input.value.trim() || oldTitle;
|
|
2030
|
-
// Send rename to server
|
|
2031
870
|
if (newTitle !== oldTitle) sendCtrl({ type: 'rename_tab', tabId, title: newTitle });
|
|
2032
|
-
|
|
2033
|
-
const span = document.createElement('span');
|
|
2034
|
-
span.className = 'title';
|
|
2035
|
-
span.textContent = newTitle;
|
|
871
|
+
const span = document.createElement('span'); span.className = 'title'; span.textContent = newTitle;
|
|
2036
872
|
input.replaceWith(span);
|
|
2037
873
|
}
|
|
2038
874
|
function cancel() {
|
|
2039
|
-
const span = document.createElement('span');
|
|
2040
|
-
span.className = 'title';
|
|
2041
|
-
span.textContent = oldTitle;
|
|
875
|
+
const span = document.createElement('span'); span.className = 'title'; span.textContent = oldTitle;
|
|
2042
876
|
input.replaceWith(span);
|
|
2043
877
|
}
|
|
2044
878
|
input.addEventListener('keydown', ev => {
|
|
@@ -2048,101 +882,14 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
2048
882
|
input.addEventListener('blur', commit);
|
|
2049
883
|
});
|
|
2050
884
|
|
|
2051
|
-
// -- Mobile
|
|
2052
|
-
|
|
2053
|
-
// Ignore viewport sync during IME composition so candidate UI can't
|
|
2054
|
-
// temporarily collapse the terminal area.
|
|
2055
|
-
if (window.visualViewport && isTouchDevice) {
|
|
885
|
+
// -- Mobile viewport handling --
|
|
886
|
+
if (window.visualViewport) {
|
|
2056
887
|
window.visualViewport.addEventListener('resize', syncTouchViewportHeight);
|
|
2057
888
|
window.visualViewport.addEventListener('scroll', () => window.scrollTo(0, 0));
|
|
2058
889
|
}
|
|
2059
|
-
// iOS Safari: touching terminal area focuses hidden textarea for input
|
|
2060
|
-
document.getElementById('terminal').addEventListener('touchend', () => { if (currentView === 'live') term.focus(); });
|
|
2061
|
-
|
|
2062
|
-
// -- IME diagnostic (active when ?debug=1) --
|
|
2063
|
-
if (new URLSearchParams(location.search).has('debug')) {
|
|
2064
|
-
const _d = window._imeDiag = { events: [], t0: Date.now() };
|
|
2065
|
-
function _ilog(type, detail) {
|
|
2066
|
-
const e = { t: Date.now() - _d.t0, type, ...detail };
|
|
2067
|
-
_d.events.push(e);
|
|
2068
|
-
if (_d.events.length > 300) _d.events.shift();
|
|
2069
|
-
try { localStorage.setItem('_imeDiag', JSON.stringify(_d.events.slice(-50))); } catch {}
|
|
2070
|
-
}
|
|
2071
|
-
const _ta = document.querySelector('textarea');
|
|
2072
|
-
if (_ta) {
|
|
2073
|
-
['compositionstart','compositionupdate','compositionend'].forEach(n =>
|
|
2074
|
-
_ta.addEventListener(n, e => _ilog(n, { data: e.data }), true));
|
|
2075
|
-
_ta.addEventListener('input', e => _ilog('input', {
|
|
2076
|
-
data: e.data?.substring(0,20), inputType: e.inputType, isComposing: e.isComposing
|
|
2077
|
-
}), true);
|
|
2078
|
-
_ta.addEventListener('keydown', e => {
|
|
2079
|
-
if (e.isComposing || e.keyCode === 229)
|
|
2080
|
-
_ilog('keydown-ime', { key: e.key, code: e.code, kc: e.keyCode });
|
|
2081
|
-
}, true);
|
|
2082
|
-
}
|
|
2083
|
-
new ResizeObserver(entries => entries.forEach(e => {
|
|
2084
|
-
const n = e.target.id || e.target.tagName;
|
|
2085
|
-
_ilog('resize', { el: n, h: Math.round(e.contentRect.height) });
|
|
2086
|
-
})).observe(document.body);
|
|
2087
|
-
if (window.visualViewport) window.visualViewport.addEventListener('resize', () =>
|
|
2088
|
-
_ilog('vv-resize', { vh: Math.round(window.visualViewport.height), bh: document.body.offsetHeight }));
|
|
2089
|
-
new MutationObserver(() => _ilog('body-style', {
|
|
2090
|
-
h: document.body.style.height, oh: document.body.offsetHeight
|
|
2091
|
-
})).observe(document.body, { attributes: true, attributeFilter: ['style'] });
|
|
2092
|
-
window.addEventListener('error', e => _ilog('js-error', { msg: e.message, line: e.lineno }));
|
|
2093
|
-
// Track textarea style/size changes (IME may resize it)
|
|
2094
|
-
if (_ta) {
|
|
2095
|
-
new ResizeObserver(() => {
|
|
2096
|
-
_ilog('ta-resize', { w: _ta.offsetWidth, h: _ta.offsetHeight, vis: getComputedStyle(_ta).visibility, op: getComputedStyle(_ta).opacity });
|
|
2097
|
-
}).observe(_ta);
|
|
2098
|
-
}
|
|
2099
|
-
// Track canvas visibility
|
|
2100
|
-
const _cvs = document.querySelector('#terminal canvas');
|
|
2101
|
-
if (_cvs) {
|
|
2102
|
-
new ResizeObserver(() => {
|
|
2103
|
-
_ilog('canvas-resize', { w: _cvs.width, h: _cvs.height, display: getComputedStyle(_cvs).display });
|
|
2104
|
-
}).observe(_cvs);
|
|
2105
|
-
}
|
|
2106
|
-
// Floating debug overlay — stays visible even when page goes "blank"
|
|
2107
|
-
const _dbg = document.createElement('div');
|
|
2108
|
-
_dbg.id = 'ime-debug';
|
|
2109
|
-
_dbg.style.cssText = 'position:fixed;bottom:0;right:0;z-index:999999;background:rgba(0,0,0,0.85);color:#0f0;font:10px monospace;padding:4px 8px;max-width:60vw;max-height:40vh;overflow:auto;pointer-events:none;white-space:pre-wrap;';
|
|
2110
|
-
document.documentElement.appendChild(_dbg);
|
|
2111
|
-
|
|
2112
|
-
// Continuous polling — captures state even when no events fire
|
|
2113
|
-
let _prevState = '';
|
|
2114
|
-
setInterval(() => {
|
|
2115
|
-
const _ta2 = document.querySelector('textarea');
|
|
2116
|
-
const _cvs2 = document.querySelector('#terminal canvas');
|
|
2117
|
-
const _term2 = document.getElementById('terminal');
|
|
2118
|
-
const _main2 = document.querySelector('.main');
|
|
2119
|
-
const _sidebar2 = document.querySelector('.sidebar');
|
|
2120
|
-
const state = JSON.stringify({
|
|
2121
|
-
body: { h: document.body.offsetHeight, w: document.body.offsetWidth, styleH: document.body.style.height, vis: document.body.style.visibility, disp: document.body.style.display },
|
|
2122
|
-
main: _main2 ? { h: _main2.offsetHeight, vis: getComputedStyle(_main2).visibility, op: getComputedStyle(_main2).opacity, disp: getComputedStyle(_main2).display } : null,
|
|
2123
|
-
term: _term2 ? { h: _term2.offsetHeight, w: _term2.offsetWidth, vis: getComputedStyle(_term2).visibility, disp: getComputedStyle(_term2).display, op: getComputedStyle(_term2).opacity } : null,
|
|
2124
|
-
cvs: _cvs2 ? { w: _cvs2.width, h: _cvs2.height, styleW: _cvs2.style.width, styleH: _cvs2.style.height, vis: getComputedStyle(_cvs2).visibility, disp: getComputedStyle(_cvs2).display } : null,
|
|
2125
|
-
ta: _ta2 ? { w: _ta2.offsetWidth, h: _ta2.offsetHeight, styleW: _ta2.style.width, styleH: _ta2.style.height, pos: getComputedStyle(_ta2).position, vis: getComputedStyle(_ta2).visibility, op: getComputedStyle(_ta2).opacity, zIdx: getComputedStyle(_ta2).zIndex, bg: getComputedStyle(_ta2).background?.substring(0,40) } : null,
|
|
2126
|
-
sidebar: _sidebar2 ? { h: _sidebar2.offsetHeight, vis: getComputedStyle(_sidebar2).visibility } : null,
|
|
2127
|
-
vv: window.visualViewport ? { h: Math.round(window.visualViewport.height), w: Math.round(window.visualViewport.width) } : null
|
|
2128
|
-
});
|
|
2129
|
-
if (state !== _prevState) {
|
|
2130
|
-
_prevState = state;
|
|
2131
|
-
_ilog('poll', JSON.parse(state));
|
|
2132
|
-
}
|
|
2133
|
-
// Always update overlay with latest events
|
|
2134
|
-
const last = _d.events.slice(-12);
|
|
2135
|
-
_dbg.textContent = last.map(e => {
|
|
2136
|
-
const {t, type, ...r} = e;
|
|
2137
|
-
return t + 'ms ' + type + ': ' + JSON.stringify(r).substring(0, 120);
|
|
2138
|
-
}).join('\\n');
|
|
2139
|
-
}, 200);
|
|
2140
|
-
console.log('[remux] IME diagnostic v2 active — polling every 200ms');
|
|
2141
|
-
}
|
|
2142
890
|
</script>
|
|
2143
891
|
</body>
|
|
2144
892
|
</html>`;
|
|
2145
|
-
|
|
2146
893
|
// ── Service Worker for Push Notifications ───────────────────────
|
|
2147
894
|
|
|
2148
895
|
const SW_SCRIPT = `self.addEventListener('push', function(event) {
|