@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/src/server.ts CHANGED
@@ -166,13 +166,15 @@ startup().catch((e) => {
166
166
  startupDone = true;
167
167
  });
168
168
 
169
- // ── HTML Template ────────────────────────────────────────────────
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, maximum-scale=1.0, user-scalable=no" />
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; flex-direction: column; gap: 6px; }
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
- .sidebar-footer .footer-row { display: flex; align-items: center; gap: 8px; }
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 (Chrome-style) -- */
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; padding: 0 0 0 0; position: relative; z-index: 101; }
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
- #terminal.hidden { display: none; }
323
-
324
- /* -- View switcher -- */
325
- .view-switch { display: flex; gap: 1px; margin-left: auto; margin-right: 8px;
326
- align-self: center; background: var(--view-switch-bg); border-radius: 4px; overflow: hidden; }
327
- .view-switch button { padding: 4px 10px; font-size: 11px; font-family: inherit;
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
- .ws-search input:focus { border-color: var(--accent); }
391
- .ws-search input::placeholder { color: var(--text-dim); }
392
- .ws-search-results { margin-bottom: 12px; }
393
- .ws-search-result { padding: 6px 10px; margin-bottom: 4px; background: var(--bg-sidebar);
394
- border: 1px solid var(--border); border-radius: 4px; cursor: pointer; }
395
- .ws-search-result:hover { border-color: var(--accent); }
396
- .ws-search-result .sr-type { font-size: 10px; color: var(--text-dim); text-transform: uppercase; }
397
- .ws-search-result .sr-title { font-size: 12px; color: var(--text-bright); }
398
-
399
- /* -- Notes -- */
400
- .ws-note { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
401
- padding: 8px 12px; margin-bottom: 6px; position: relative; }
402
- .ws-note.pinned { border-color: var(--accent); }
403
- .ws-note-content { font-size: 12px; color: var(--text-bright); white-space: pre-wrap; word-break: break-word; }
404
- .ws-note-actions { display: flex; gap: 4px; margin-top: 4px; }
405
- .ws-note-actions button { background: none; border: none; color: var(--text-dim);
406
- font-size: 11px; cursor: pointer; padding: 2px 6px; border-radius: 3px; font-family: inherit; }
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: 40px; text-align: center; user-select: none; }
521
- .compose-bar button:active { background: var(--compose-border); }
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; } /* touch-friendly */
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-list" id="session-list"></div>
608
-
609
- <!-- Devices section (collapsible) -->
610
- <div class="devices-section" id="devices-section">
611
- <div class="devices-header" id="devices-header">
612
- <span>Devices</span>
613
- <span class="devices-toggle" id="devices-toggle">&#9660;</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">&#128276;</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="role-indicator" id="role-indicator">
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">&#9728;</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">&#9776;</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 id="workspace">
675
- <div class="ws-search">
676
- <input type="text" id="ws-search-input" placeholder="Search topics, artifacts, runs..." />
677
- </div>
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">&#9728;</button>
723
413
  </div>
724
- <div class="compose-bar" id="compose-bar">
725
- <button data-seq="esc">Esc</button>
726
- <button data-seq="tab">Tab</button>
727
- <button data-mod="ctrl" id="btn-ctrl">Ctrl</button>
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">&#8593;</button>
729
421
  <button data-seq="down">&#8595;</button>
730
- <button data-seq="left">&#8592;</button>
731
- <button data-seq="right">&#8594;</button>
732
- <button data-ch="|">|</button>
733
- <button data-ch="~">~</button>
734
- <button data-ch="/">/ </button>
735
- <button data-seq="ctrl-c">C-c</button>
736
- <button data-seq="ctrl-d">C-d</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">&#9881;</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: true,
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
- const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
812
- function safeFit() {
813
- if (_isComposing) { _pendingFit = true; return; }
814
- if (fitAddon) fitAddon.fit();
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 || !isTouchDevice || _isComposing) return;
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, ctrlActive = false;
841
- let myClientId = null, myRole = null, clientsList = [];
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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' ? '&#9728;' : '&#9790;';
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' ? '&#9728;' : '&#9790;';
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('button');
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) + '">\u00d7</button>';
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
- selectSession(other.name);
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 (Chrome-style) --
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('button');
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 + '">\u00d7</button>';
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(); // full reset to avoid duplicate content
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
- $('btn-new-session').addEventListener('pointerdown', e => {
1076
- e.preventDefault();
1077
- const name = prompt('Session name:');
1078
- if (name && name.trim()) sendCtrl({ type: 'new_session', name: name.trim(), cols: term.cols, rows: term.rows });
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
- sendCounter: 0n,
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
- // ECDH: derive raw shared bits
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
- this.sendCounter = 0n;
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 plaintextBuf = new TextEncoder().encode(plaintext);
1142
- // IV: 4 random bytes + 8 byte counter (big-endian)
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'); // 12 iv + 16 tag minimum
699
+ if (packed.length < 28) throw new Error('E2EE message too short');
1164
700
  const iv = packed.subarray(0, 12);
1165
- const ciphertextWithTag = packed.subarray(12);
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; // reset backoff on successful connection
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: deviceId, lastTimestamp: lastReceivedTimestamp });
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
- // E2EE handshake: server responds with its public key
1281
- if (msg.type === 'e2ee_init') {
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
- // Track timestamp for session recovery on reconnect
782
+ if (msg.type === 'resume_complete') { isResuming = false; return; }
1309
783
  lastReceivedTimestamp = Date.now();
1310
- if (msg.type === 'auth_ok') {
1311
- if (msg.deviceId) myDeviceId = msg.deviceId;
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
- // Generic server error show to user (e.g. pair code trust errors)
1325
- if (msg.type === 'error') {
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
- if (msg.role) myRole = msg.role;
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
- // Non-enveloped JSON (e.g. PTY output that looks like JSON) — fall through to term.write
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(); peClearAll(); scheduleReconnect(); };
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
- ws.send(JSON.stringify({ v: 1, type: 'e2ee_msg', payload: { data: encrypted } }));
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 I/O --
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 bar --
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', left: '\\x1b[D', right: '\\x1b[C',
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
- $('compose-bar').addEventListener('pointerdown', e => {
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
- if (btn.dataset.mod === 'ctrl') { ctrlActive = !ctrlActive; btn.classList.toggle('active', ctrlActive); return; }
1516
- const d = SEQ[btn.dataset.seq] || btn.dataset.ch;
847
+ const d = SEQ[btn.dataset.seq];
1517
848
  if (d) sendTermData(d);
1518
- term.focus();
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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">&#10003;</button>' : '')
1591
- + (d.trust !== 'blocked' ? '<button data-block="' + d.id + '" title="Block">&#10007;</button>' : '')
1592
- + '<button data-rename-dev="' + d.id + '" title="Rename">&#9998;</button>'
1593
- + (!isSelf ? '<button data-revoke="' + d.id + '" title="Revoke">&#128465;</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">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 ? '&#10003;' : '&#10007; ' + 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
- // Restore span immediately
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 virtual keyboard handling --
2052
- // Only apply visualViewport height adjustments on touch devices.
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) {