@wangyaoshen/remux 0.3.10-dev.19fb76c → 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/server.js CHANGED
@@ -5013,8 +5013,6 @@ var init_server = __esm({
5013
5013
  --compose-bg: #3a3a3a;
5014
5014
  --compose-border: #555;
5015
5015
  --tab-hover: #383838;
5016
- --view-switch-bg: #1a1a1a;
5017
- --inspect-meta-border: #333;
5018
5016
  }
5019
5017
 
5020
5018
  [data-theme="light"] {
@@ -5037,8 +5035,6 @@ var init_server = __esm({
5037
5035
  --compose-bg: #e8e8e8;
5038
5036
  --compose-border: #c0c0c0;
5039
5037
  --tab-hover: #d6d6d6;
5040
- --view-switch-bg: #d4d4d4;
5041
- --inspect-meta-border: #d4d4d4;
5042
5038
  }
5043
5039
 
5044
5040
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -5085,20 +5081,13 @@ var init_server = __esm({
5085
5081
  .sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--border);
5086
5082
  display: flex; align-items: center; justify-content: space-between; gap: 8px; }
5087
5083
  .sidebar-footer .version { font-size: 10px; color: var(--text-dim); }
5088
- .sidebar-footer .status { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
5084
+
5089
5085
  .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; }
5090
5086
  .status-dot.connected { background: var(--dot-ok); }
5091
5087
  .status-dot.disconnected { background: var(--dot-err); }
5092
5088
  .status-dot.connecting { background: var(--dot-warn); animation: pulse 1s infinite; }
5093
5089
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
5094
5090
 
5095
- .role-indicator { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
5096
- .role-indicator.active { color: var(--dot-ok); }
5097
- .role-indicator.observer { color: var(--dot-warn); }
5098
- .role-btn { background: none; border: 1px solid var(--border); border-radius: 4px;
5099
- color: var(--text-muted); font-size: 10px; padding: 2px 8px; cursor: pointer; font-family: inherit; }
5100
- .role-btn:hover { color: var(--text-bright); border-color: var(--text-muted); }
5101
-
5102
5091
  /* -- Theme toggle -- */
5103
5092
  .theme-toggle { background: none; border: none; cursor: pointer; font-size: 16px;
5104
5093
  color: var(--text-muted); padding: 4px 8px; border-radius: 4px; }
@@ -5110,9 +5099,9 @@ var init_server = __esm({
5110
5099
  border-bottom: 1px solid var(--border); background: var(--bg); min-height: 40px; }
5111
5100
  .main-toolbar .toolbar-spacer { flex: 1; }
5112
5101
 
5113
- /* -- Tab bar (Chrome-style) -- */
5102
+ /* -- Tab bar -- */
5114
5103
  .tab-bar { background: var(--bg-tab-bar); display: flex; align-items: flex-end; flex-shrink: 0;
5115
- min-height: 36px; padding: 0 0 0 0; position: relative; z-index: 101; }
5104
+ min-height: 36px; position: relative; z-index: 101; }
5116
5105
  .tab-toggle { padding: 8px 10px; background: none; border: none; color: var(--text-muted);
5117
5106
  cursor: pointer; font-size: 16px; flex-shrink: 0; align-self: center; }
5118
5107
  .tab-toggle:hover { color: var(--text-bright); }
@@ -5145,56 +5134,39 @@ var init_server = __esm({
5145
5134
  flex-shrink: 0; align-self: center; }
5146
5135
  .tab-new:hover { color: var(--text); background: var(--compose-bg); }
5147
5136
 
5148
- /* -- Terminal -- */
5137
+ /* -- Terminal (read-only) -- */
5149
5138
  #terminal { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
5150
5139
  #terminal canvas { display: block; position: absolute; top: 0; left: 0; }
5151
- #terminal.hidden { display: none; }
5152
-
5153
- /* -- View switcher -- */
5154
- .view-switch { display: flex; gap: 1px; margin-left: auto; margin-right: 8px;
5155
- align-self: center; background: var(--view-switch-bg); border-radius: 4px; overflow: hidden; }
5156
- .view-switch button { padding: 4px 10px; font-size: 11px; font-family: inherit;
5157
- color: var(--text-muted); background: var(--bg-tab-bar); border: none; cursor: pointer; }
5158
- .view-switch button:hover { color: var(--text); }
5159
- .view-switch button.active { color: var(--text-on-active); background: var(--accent); }
5160
-
5161
- /* -- Inspect -- */
5162
- #inspect { flex: 1; background: var(--bg); overflow: auto; display: none;
5163
- padding: 12px 16px; -webkit-overflow-scrolling: touch; }
5164
- #inspect.visible { display: block; }
5165
5140
 
5166
- #inspect-content { font-family: 'Menlo','Monaco','Courier New',monospace; font-size: 13px;
5167
- line-height: 1.5; color: var(--text-bright); white-space: pre-wrap; word-break: break-all;
5168
- tab-size: 8; user-select: text; -webkit-user-select: text; }
5169
- #inspect-content mark { background: #ffbd2e; color: #1e1e1e; border-radius: 2px; }
5170
- #inspect-header { font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 11px;
5171
- color: var(--text-dim); padding: 8px 0; border-bottom: 1px solid var(--inspect-meta-border); margin-bottom: 8px;
5172
- display: flex; flex-direction: column; gap: 8px; }
5173
- #inspect-meta { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
5174
- #inspect-meta span { white-space: nowrap; }
5175
- #inspect-meta .inspect-btn { padding: 2px 10px; font-size: 11px; font-family: inherit;
5176
- color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
5177
- border-radius: 4px; cursor: pointer; white-space: nowrap; }
5178
- #inspect-meta .inspect-btn:hover { background: var(--compose-border); }
5179
- #inspect-search { display: flex; gap: 8px; align-items: center; }
5180
- #inspect-search input { padding: 4px 8px; font-size: 12px; font-family: inherit;
5181
- background: var(--bg); border: 1px solid var(--compose-border); border-radius: 4px;
5182
- color: var(--text); outline: none; flex: 1; max-width: 260px; }
5183
- #inspect-search input:focus { border-color: var(--accent); }
5184
- #inspect-search .match-count { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
5141
+ /* -- Compose input -- */
5142
+ .compose-input { display: flex; background: var(--bg-sidebar); border-top: 1px solid var(--border);
5143
+ padding: 8px; gap: 6px; flex-shrink: 0; align-items: center; }
5144
+ .compose-input input { flex: 1; min-width: 0; padding: 8px 12px; font-size: 14px;
5145
+ font-family: 'Menlo','Monaco','Courier New',monospace;
5146
+ background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 6px;
5147
+ color: var(--text-bright); outline: none; }
5148
+ .compose-input input:focus { border-color: var(--accent); }
5149
+ .compose-input .send-btn { padding: 8px 16px; font-size: 13px; font-family: inherit;
5150
+ background: var(--accent); color: #fff; border: none; border-radius: 6px;
5151
+ cursor: pointer; white-space: nowrap; flex-shrink: 0; }
5152
+ .compose-input .send-btn:hover { opacity: 0.9; }
5153
+ .compose-input .send-btn:active { opacity: 0.8; }
5154
+ .compose-input .preset-toggle { padding: 8px; font-size: 16px; background: none;
5155
+ border: 1px solid var(--compose-border); border-radius: 6px; color: var(--text-muted);
5156
+ cursor: pointer; flex-shrink: 0; line-height: 1; }
5157
+ .compose-input .preset-toggle:hover { color: var(--text-bright); background: var(--compose-bg); }
5158
+ .compose-input .preset-toggle.active { color: var(--accent); border-color: var(--accent); }
5185
5159
 
5186
- /* -- Compose bar -- */
5187
- .compose-bar { display: none; background: var(--bg-sidebar); border-top: 1px solid var(--border);
5188
- padding: 5px 8px; gap: 5px; flex-shrink: 0; overflow-x: auto; flex-wrap: wrap;
5189
- -webkit-overflow-scrolling: touch; }
5190
- body.touch-device .compose-bar.visible { display: flex; }
5191
- .compose-bar button { padding: 8px 12px; font-size: 14px;
5160
+ /* -- Preset commands -- */
5161
+ .preset-bar { display: none; background: var(--bg-sidebar); border-top: 1px solid var(--border);
5162
+ padding: 6px 8px; gap: 5px; flex-shrink: 0; flex-wrap: wrap; }
5163
+ .preset-bar.visible { display: flex; }
5164
+ .preset-bar button { padding: 6px 12px; font-size: 12px;
5192
5165
  font-family: 'Menlo','Monaco',monospace; color: var(--text-bright); background: var(--compose-bg);
5193
5166
  border: 1px solid var(--compose-border); border-radius: 5px; cursor: pointer; white-space: nowrap;
5194
5167
  -webkit-tap-highlight-color: transparent; touch-action: manipulation;
5195
- min-width: 40px; text-align: center; user-select: none; }
5196
- .compose-bar button:active { background: var(--compose-border); }
5197
- .compose-bar button.active { background: #4a6a9a; border-color: #6a9ade; }
5168
+ min-width: 36px; text-align: center; user-select: none; }
5169
+ .preset-bar button:active { background: var(--compose-border); }
5198
5170
 
5199
5171
  /* -- Mobile -- */
5200
5172
  @media (max-width: 768px) {
@@ -5207,9 +5179,10 @@ var init_server = __esm({
5207
5179
  .sidebar-overlay.visible { display: block; pointer-events: auto; }
5208
5180
  .main { margin-left: 0 !important; width: 100vw; min-width: 0; }
5209
5181
  .tab-bar { overflow-x: auto; }
5210
- .session-item { min-height: 44px; } /* touch-friendly */
5182
+ .session-item { min-height: 44px; }
5211
5183
  .tab { min-height: 36px; }
5212
5184
  .main-toolbar { padding-left: 8px; padding-right: 8px; }
5185
+ .compose-input input { font-size: 16px; } /* prevent iOS zoom on focus */
5213
5186
  }
5214
5187
 
5215
5188
  @media (hover: none), (pointer: coarse) {
@@ -5235,7 +5208,7 @@ var init_server = __esm({
5235
5208
  <div class="session-list" id="session-list"></div>
5236
5209
 
5237
5210
  <div class="sidebar-footer">
5238
- <div class="version">v${VERSION}</div>
5211
+ <div class="version">v\${VERSION}</div>
5239
5212
  </div>
5240
5213
  </aside>
5241
5214
  <div class="main">
@@ -5243,53 +5216,30 @@ var init_server = __esm({
5243
5216
  <button class="tab-toggle" id="btn-sidebar" title="Toggle sidebar">&#9776;</button>
5244
5217
  <div class="tab-list" id="tab-list"></div>
5245
5218
  <button class="tab-new" id="btn-new-tab" title="New tab">+</button>
5246
- <div class="view-switch">
5247
- <button id="btn-live" class="active">Live</button>
5248
- <button id="btn-inspect">Inspect</button>
5249
- </div>
5250
5219
  </div>
5251
5220
  <div class="main-toolbar">
5252
5221
  <div class="status">
5253
5222
  <div class="status-dot connecting" id="status-dot"></div>
5254
5223
  <span id="status-text">Connecting...</span>
5255
5224
  </div>
5256
- <div class="role-indicator" id="role-indicator">
5257
- <span id="role-dot"></span>
5258
- <span id="role-text"></span>
5259
- </div>
5260
- <button class="role-btn" id="btn-role" style="display:none"></button>
5261
5225
  <div class="toolbar-spacer"></div>
5262
5226
  <button id="btn-theme" class="theme-toggle" title="Toggle theme">&#9728;</button>
5263
5227
  </div>
5264
5228
  <div id="terminal"></div>
5265
- <div id="inspect">
5266
- <div id="inspect-header">
5267
- <div id="inspect-meta"></div>
5268
- <div id="inspect-search">
5269
- <input type="text" id="inspect-search-input" placeholder="Search..." />
5270
- <span class="match-count" id="inspect-match-count"></span>
5271
- </div>
5272
- </div>
5273
- <pre id="inspect-content"></pre>
5274
- </div>
5275
- <div class="compose-bar" id="compose-bar">
5276
- <button data-seq="esc">Esc</button>
5277
- <button data-seq="tab">Tab</button>
5278
- <button data-mod="ctrl" id="btn-ctrl">Ctrl</button>
5229
+ <div class="preset-bar" id="preset-bar">
5230
+ <button data-seq="ctrl-c">Ctrl+C</button>
5231
+ <button data-seq="ctrl-d">Ctrl+D</button>
5232
+ <button data-seq="ctrl-z">Ctrl+Z</button>
5233
+ <button data-seq="ctrl-l">Ctrl+L</button>
5279
5234
  <button data-seq="up">&#8593;</button>
5280
5235
  <button data-seq="down">&#8595;</button>
5281
- <button data-seq="left">&#8592;</button>
5282
- <button data-seq="right">&#8594;</button>
5283
- <button data-ch="|">|</button>
5284
- <button data-ch="~">~</button>
5285
- <button data-ch="/">/ </button>
5286
- <button data-seq="ctrl-c">C-c</button>
5287
- <button data-seq="ctrl-d">C-d</button>
5288
- <button data-seq="ctrl-z">C-z</button>
5289
- <button data-seq="pgup">PgUp</button>
5290
- <button data-seq="pgdn">PgDn</button>
5291
- <button data-seq="home">Home</button>
5292
- <button data-seq="end">End</button>
5236
+ <button data-seq="tab">Tab</button>
5237
+ <button data-seq="esc">Esc</button>
5238
+ </div>
5239
+ <div class="compose-input" id="compose-input">
5240
+ <button class="preset-toggle" id="btn-preset-toggle" title="Quick keys">&#9881;</button>
5241
+ <input type="text" id="cmd-input" placeholder="Type command..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
5242
+ <button class="send-btn" id="btn-send">Send</button>
5293
5243
  </div>
5294
5244
  </div>
5295
5245
 
@@ -5297,10 +5247,8 @@ var init_server = __esm({
5297
5247
  import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
5298
5248
  await init();
5299
5249
 
5300
- // -- Terminal color themes (ghostty-web ITheme) --
5301
5250
  const THEMES = {
5302
5251
  dark: {
5303
- // ghostty-web default dark
5304
5252
  background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff',
5305
5253
  cursorAccent: '#1e1e1e', selectionBackground: '#264f78', selectionForeground: '#ffffff',
5306
5254
  black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
@@ -5310,7 +5258,6 @@ var init_server = __esm({
5310
5258
  brightCyan: '#29b8db', brightWhite: '#ffffff',
5311
5259
  },
5312
5260
  light: {
5313
- // Ghostty-style light
5314
5261
  background: '#ffffff', foreground: '#1d1f21', cursor: '#1d1f21',
5315
5262
  cursorAccent: '#ffffff', selectionBackground: '#b4d5fe', selectionForeground: '#1d1f21',
5316
5263
  black: '#1d1f21', red: '#c82829', green: '#718c00', yellow: '#eab700',
@@ -5332,125 +5279,43 @@ var init_server = __esm({
5332
5279
  if (term) { term.dispose(); container.innerHTML = ''; }
5333
5280
  term = window._remuxTerm = new Terminal({ cols: 80, rows: 24,
5334
5281
  fontFamily: 'Menlo, Monaco, "Courier New", monospace',
5335
- fontSize: 14, cursorBlink: true,
5282
+ fontSize: 14, cursorBlink: false,
5336
5283
  theme: THEMES[themeMode] || THEMES.dark,
5337
- scrollback: 10000 });
5284
+ scrollback: 10000,
5285
+ disableStdin: true });
5338
5286
  fitAddon = new FitAddon();
5339
5287
  term.loadAddon(fitAddon);
5340
5288
  term.open(container);
5341
5289
  fitObserver = new ResizeObserver(() => safeFit());
5342
5290
  fitObserver.observe(container);
5343
5291
  safeFit();
5344
- fitSettleTimer = setTimeout(() => {
5345
- fitSettleTimer = null;
5346
- safeFit();
5347
- }, 250);
5292
+ fitSettleTimer = setTimeout(() => { fitSettleTimer = null; safeFit(); }, 250);
5348
5293
  if (document.fonts && document.fonts.ready) {
5349
5294
  document.fonts.ready.then(() => safeFit()).catch(() => {});
5350
5295
  }
5351
5296
  return term;
5352
5297
  }
5353
5298
 
5354
- // -- IME composition guard --
5355
- // Defer fit()/resize during active IME composition to avoid layout thrash.
5356
- // Note: ghostty-web binds composition listeners on the container, and
5357
- // browser-native composition events bubble from textarea to container,
5358
- // so no forwarding patch is needed. Just guard fit() during composition.
5359
- let _isComposing = false;
5360
- let _pendingFit = false;
5361
5299
  let fitDebounceTimer = null;
5362
- const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
5363
- document.body.classList.toggle('touch-device', isTouchDevice);
5364
- function safeFit() {
5365
- if (_isComposing) { _pendingFit = true; return; }
5366
- if (fitAddon) fitAddon.fit();
5300
+ function safeFit() { if (fitAddon) fitAddon.fit(); }
5301
+ function stabilizeFit() {
5302
+ safeFit();
5303
+ if (fitSettleTimer) clearTimeout(fitSettleTimer);
5304
+ fitSettleTimer = setTimeout(() => { fitSettleTimer = null; safeFit(); }, 250);
5367
5305
  }
5368
5306
  function syncTouchViewportHeight() {
5369
- if (!window.visualViewport || !isTouchDevice || _isComposing) return;
5307
+ if (!window.visualViewport) return;
5370
5308
  const vh = window.visualViewport.height;
5371
5309
  if (vh > 0) document.body.style.height = vh + 'px';
5372
5310
  clearTimeout(fitDebounceTimer);
5373
5311
  fitDebounceTimer = setTimeout(safeFit, 100);
5374
5312
  }
5375
- function stabilizeFit() {
5376
- safeFit();
5377
- if (fitSettleTimer) clearTimeout(fitSettleTimer);
5378
- fitSettleTimer = setTimeout(() => {
5379
- fitSettleTimer = null;
5380
- safeFit();
5381
- }, 250);
5382
- }
5383
5313
  window.addEventListener('resize', safeFit);
5384
- const _termContainer = document.getElementById('terminal');
5385
- _termContainer.addEventListener('compositionstart', () => { _isComposing = true; });
5386
- _termContainer.addEventListener('compositionend', () => {
5387
- _isComposing = false;
5388
- if (_pendingFit) { _pendingFit = false; stabilizeFit(); }
5389
- });
5390
5314
  createTerminal(initTheme);
5391
5315
 
5392
- let sessions = [], currentSession = null, currentTabId = null, ws = null, ctrlActive = false;
5393
- let myClientId = null, myRole = null, clientsList = [];
5316
+ let sessions = [], currentSession = null, currentTabId = null, ws = null;
5317
+ let myClientId = null, clientsList = [];
5394
5318
 
5395
- // -- Predictive echo via DOM overlay (see #80 Phase 2) --
5396
- // Shows predicted characters as transparent HTML spans over the canvas.
5397
- // Does NOT inject any ANSI escape sequences into the terminal \u2014 the overlay
5398
- // is purely visual and the terminal data path is never modified.
5399
- // Adapted from VS Code TypeAheadAddon concept, using DOM overlay instead of
5400
- // xterm.js decorations (which ghostty-web lacks).
5401
- const _peOverlay = document.createElement('div');
5402
- _peOverlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1;overflow:hidden;';
5403
- _termContainer.appendChild(_peOverlay);
5404
- const _pePreds = [];
5405
- function _peCellSize() {
5406
- // Use ghostty-web renderer's exact font metrics when available
5407
- if (term.renderer) return { w: term.renderer.charWidth, h: term.renderer.charHeight };
5408
- const cvs = _termContainer.querySelector('canvas');
5409
- if (!cvs) return { w: 8, h: 16 };
5410
- return { w: cvs.offsetWidth / term.cols, h: cvs.offsetHeight / term.rows };
5411
- }
5412
- function peOnInput(data) {
5413
- if (_isComposing) return;
5414
- if (term.buffer && term.buffer.active && term.buffer.active.type === 'alternate') return;
5415
- if (myRole && myRole !== 'active') return;
5416
- for (let i = 0; i < data.length; i++) {
5417
- const c = data.charCodeAt(i);
5418
- if (c >= 0x20 && c <= 0x7e && _pePreds.length < 32) {
5419
- const buf = term.buffer && term.buffer.active;
5420
- const cx = (buf ? buf.cursorX : 0) + _pePreds.length;
5421
- const cy = buf ? buf.cursorY : 0;
5422
- const cell = _peCellSize();
5423
- const span = document.createElement('span');
5424
- span.textContent = data[i];
5425
- span.style.cssText = 'position:absolute;display:inline-block;color:var(--text,#d4d4d4);opacity:0.6;'
5426
- + 'font-family:Menlo,Monaco,Courier New,monospace;'
5427
- + 'left:' + (cx * cell.w) + 'px;top:' + (cy * cell.h) + 'px;'
5428
- + 'width:' + cell.w + 'px;height:' + cell.h + 'px;'
5429
- + 'font-size:14px;line-height:' + cell.h + 'px;text-align:center;';
5430
- _peOverlay.appendChild(span);
5431
- _pePreds.push({ ch: data[i], span, ts: Date.now() });
5432
- } else if (c < 0x20 || c === 0x7f) {
5433
- peClearAll();
5434
- }
5435
- // Non-ASCII: skip prediction, don't clear
5436
- }
5437
- }
5438
- function peOnServerData(data) {
5439
- // Match predictions against server echo, remove confirmed overlay spans.
5440
- // NEVER modify or consume data \u2014 always pass full data to term.write().
5441
- if (_pePreds.length === 0) return;
5442
- for (let i = 0; i < data.length && _pePreds.length > 0; i++) {
5443
- if (data.charCodeAt(i) === 0x1b) { peClearAll(); return; }
5444
- if (data[i] === _pePreds[0].ch) {
5445
- const p = _pePreds.shift();
5446
- p.span.remove();
5447
- } else { peClearAll(); return; }
5448
- }
5449
- }
5450
- function peClearAll() {
5451
- for (const p of _pePreds) p.span.remove();
5452
- _pePreds.length = 0;
5453
- }
5454
5319
  const $ = id => document.getElementById(id);
5455
5320
  const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5456
5321
  const setStatus = (s, t) => { $('status-dot').className = 'status-dot ' + s; $('status-text').textContent = t; };
@@ -5460,27 +5325,12 @@ var init_server = __esm({
5460
5325
  document.documentElement.setAttribute('data-theme', mode);
5461
5326
  localStorage.setItem('remux-theme', mode);
5462
5327
  $('btn-theme').innerHTML = mode === 'dark' ? '&#9728;' : '&#9790;';
5463
- // Recreate terminal with new theme (ghostty-web doesn't support runtime theme change)
5464
5328
  createTerminal(mode);
5465
- peClearAll();
5466
- // Rebind terminal I/O
5467
- term.onData(data => {
5468
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
5469
- if (ctrlActive) {
5470
- ctrlActive = false; $('btn-ctrl').classList.remove('active');
5471
- const ch = data.toLowerCase().charCodeAt(0);
5472
- if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
5473
- }
5474
- peOnInput(data);
5475
- sendTermData(data);
5476
- });
5477
5329
  term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
5478
- // Re-attach to current tab to get snapshot
5479
5330
  if (currentTabId != null) {
5480
5331
  sendCtrl({ type: 'attach_tab', tabId: currentTabId, cols: term.cols, rows: term.rows });
5481
5332
  }
5482
5333
  }
5483
- // Apply initial theme CSS (terminal already created with correct theme)
5484
5334
  document.documentElement.setAttribute('data-theme', initTheme);
5485
5335
  $('btn-theme').innerHTML = initTheme === 'dark' ? '&#9728;' : '&#9790;';
5486
5336
  $('btn-theme').addEventListener('click', () => {
@@ -5515,21 +5365,15 @@ var init_server = __esm({
5515
5365
  const live = s.tabs.filter(t => !t.ended).length;
5516
5366
  el.innerHTML = '<span class="dot"></span><span class="name">' + esc(s.name)
5517
5367
  + '</span><span class="count">' + live + '</span>'
5518
- + '<button class="del" data-del="' + esc(s.name) + '">\xD7</button>';
5368
+ + '<button class="del" data-del="' + esc(s.name) + '">\\u00d7</button>';
5519
5369
  el.addEventListener('pointerdown', e => {
5520
5370
  if (e.target.dataset.del) {
5521
5371
  e.stopPropagation(); e.preventDefault();
5522
5372
  sendCtrl({ type: 'delete_session', name: e.target.dataset.del });
5523
- // if deleting current, switch to another or create fresh
5524
5373
  if (e.target.dataset.del === currentSession) {
5525
5374
  const other = sessions.find(x => x.name !== currentSession);
5526
- if (other) {
5527
- selectSession(other.name);
5528
- } else {
5529
- // Last session deleted \u2014 re-bootstrap via attach_first
5530
- currentSession = null;
5531
- sendCtrl({ type: 'attach_first', cols: term.cols, rows: term.rows });
5532
- }
5375
+ if (other) { selectSession(other.name); }
5376
+ else { currentSession = null; sendCtrl({ type: 'attach_first', cols: term.cols, rows: term.rows }); }
5533
5377
  }
5534
5378
  return;
5535
5379
  }
@@ -5538,17 +5382,13 @@ var init_server = __esm({
5538
5382
  closeSidebarMobile();
5539
5383
  });
5540
5384
  el.addEventListener('keydown', e => {
5541
- if (e.key === 'Enter' || e.key === ' ') {
5542
- e.preventDefault();
5543
- selectSession(s.name);
5544
- closeSidebarMobile();
5545
- }
5385
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectSession(s.name); closeSidebarMobile(); }
5546
5386
  });
5547
5387
  list.appendChild(el);
5548
5388
  });
5549
5389
  }
5550
5390
 
5551
- // -- Render tabs (Chrome-style) --
5391
+ // -- Render tabs --
5552
5392
  function renderTabs() {
5553
5393
  const list = $('tab-list'); list.innerHTML = '';
5554
5394
  const sess = sessions.find(s => s.name === currentSession);
@@ -5561,54 +5401,20 @@ var init_server = __esm({
5561
5401
  const clientCount = t.clients || 0;
5562
5402
  const countBadge = clientCount > 1 ? '<span class="client-count">' + clientCount + '</span>' : '';
5563
5403
  el.innerHTML = '<span class="title">' + esc(t.title) + '</span>' + countBadge
5564
- + '<button class="close" data-close="' + t.id + '">\xD7</button>';
5404
+ + '<button class="close" data-close="' + t.id + '">\\u00d7</button>';
5565
5405
  el.addEventListener('pointerdown', e => {
5566
5406
  const closeId = e.target.dataset.close ?? e.target.closest('[data-close]')?.dataset.close;
5567
- if (closeId != null) {
5568
- e.stopPropagation(); e.preventDefault();
5569
- closeTab(Number(closeId));
5570
- return;
5571
- }
5407
+ if (closeId != null) { e.stopPropagation(); e.preventDefault(); closeTab(Number(closeId)); return; }
5572
5408
  e.preventDefault();
5573
5409
  if (t.id !== currentTabId) attachTab(t.id);
5574
5410
  });
5575
5411
  el.addEventListener('keydown', e => {
5576
- if (e.key === 'Enter' || e.key === ' ') {
5577
- e.preventDefault();
5578
- if (t.id !== currentTabId) attachTab(t.id);
5579
- }
5412
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (t.id !== currentTabId) attachTab(t.id); }
5580
5413
  });
5581
5414
  list.appendChild(el);
5582
5415
  });
5583
5416
  }
5584
5417
 
5585
- // -- Render role indicator --
5586
- function renderRole() {
5587
- const indicator = $('role-indicator');
5588
- const dot = $('role-dot');
5589
- const text = $('role-text');
5590
- const btn = $('btn-role');
5591
- if (!indicator || !myRole) return;
5592
- indicator.className = 'role-indicator ' + myRole;
5593
- if (myRole === 'active') {
5594
- dot.textContent = '\u25CF';
5595
- text.textContent = 'Active';
5596
- btn.textContent = 'Release';
5597
- btn.style.display = 'inline-block';
5598
- // Auto-focus terminal when becoming active so keystrokes reach xterm
5599
- if (currentView === 'live') setTimeout(() => term.focus(), 50);
5600
- } else {
5601
- dot.textContent = '\u25CB';
5602
- text.textContent = 'Observer';
5603
- btn.textContent = 'Take control';
5604
- btn.style.display = 'inline-block';
5605
- }
5606
- }
5607
- $('btn-role').addEventListener('click', () => {
5608
- if (myRole === 'active') sendCtrl({ type: 'release_control' });
5609
- else sendCtrl({ type: 'request_control' });
5610
- });
5611
-
5612
5418
  function selectSession(name) {
5613
5419
  currentSession = name;
5614
5420
  const sess = sessions.find(s => s.name === name);
@@ -5618,7 +5424,7 @@ var init_server = __esm({
5618
5424
 
5619
5425
  function attachTab(tabId) {
5620
5426
  currentTabId = tabId;
5621
- term.reset(); // full reset to avoid duplicate content
5427
+ term.reset();
5622
5428
  sendCtrl({ type: 'attach_tab', tabId, cols: term.cols, rows: term.rows });
5623
5429
  stabilizeFit();
5624
5430
  renderTabs(); renderSessions();
@@ -5627,7 +5433,6 @@ var init_server = __esm({
5627
5433
  function closeTab(tabId) {
5628
5434
  const sess = sessions.find(s => s.name === currentSession);
5629
5435
  if (!sess) return;
5630
- // if closing active tab, switch to neighbor first
5631
5436
  if (tabId === currentTabId) {
5632
5437
  const idx = sess.tabs.findIndex(t => t.id === tabId);
5633
5438
  const next = sess.tabs[idx + 1] || sess.tabs[idx - 1];
@@ -5649,10 +5454,7 @@ var init_server = __esm({
5649
5454
  $('session-composer').classList.remove('visible');
5650
5455
  $('new-session-input').value = '';
5651
5456
  }
5652
- $('btn-new-session').addEventListener('pointerdown', e => {
5653
- e.preventDefault();
5654
- openSessionComposer();
5655
- });
5457
+ $('btn-new-session').addEventListener('pointerdown', e => { e.preventDefault(); openSessionComposer(); });
5656
5458
  $('btn-create-session').addEventListener('click', () => {
5657
5459
  const name = $('new-session-input').value.trim();
5658
5460
  if (!name) return;
@@ -5661,109 +5463,58 @@ var init_server = __esm({
5661
5463
  });
5662
5464
  $('btn-cancel-session').addEventListener('click', closeSessionComposer);
5663
5465
  $('new-session-input').addEventListener('keydown', e => {
5664
- if (e.key === 'Enter') {
5665
- e.preventDefault();
5666
- $('btn-create-session').click();
5667
- } else if (e.key === 'Escape') {
5668
- e.preventDefault();
5669
- closeSessionComposer();
5670
- }
5466
+ if (e.key === 'Enter') { e.preventDefault(); $('btn-create-session').click(); }
5467
+ else if (e.key === 'Escape') { e.preventDefault(); closeSessionComposer(); }
5671
5468
  });
5672
5469
 
5673
5470
  // -- E2EE client (Web Crypto API) --
5674
- // Adapted from Signal Protocol X25519+AES-GCM pattern
5675
5471
  const e2ee = {
5676
- established: false,
5677
- sendCounter: 0n,
5678
- recvCounter: -1n,
5679
- localKeyPair: null, // { publicKey: CryptoKey, privateKey: CryptoKey, rawPublic: Uint8Array }
5680
- sharedKey: null, // CryptoKey (AES-GCM)
5472
+ established: false, sendCounter: 0n, recvCounter: -1n,
5473
+ localKeyPair: null, sharedKey: null,
5681
5474
  available: !!(crypto && crypto.subtle),
5682
-
5683
5475
  async init() {
5684
5476
  if (!this.available) return;
5685
5477
  try {
5686
5478
  const kp = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
5687
5479
  const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
5688
5480
  this.localKeyPair = { publicKey: kp.publicKey, privateKey: kp.privateKey, rawPublic: rawPub };
5689
- } catch (e) {
5690
- console.warn('[e2ee] X25519 not available:', e);
5691
- this.available = false;
5692
- }
5481
+ } catch (e) { this.available = false; }
5693
5482
  },
5694
-
5695
5483
  getPublicKeyB64() {
5696
5484
  if (!this.localKeyPair) return null;
5697
5485
  return btoa(String.fromCharCode(...this.localKeyPair.rawPublic));
5698
5486
  },
5699
-
5700
5487
  async completeHandshake(peerPubKeyB64) {
5701
5488
  if (!this.localKeyPair) return;
5702
5489
  try {
5703
5490
  const peerRaw = Uint8Array.from(atob(peerPubKeyB64), c => c.charCodeAt(0));
5704
5491
  const peerKey = await crypto.subtle.importKey('raw', peerRaw, 'X25519', false, []);
5705
- // ECDH: derive raw shared bits
5706
- const rawBits = await crypto.subtle.deriveBits(
5707
- { name: 'X25519', public: peerKey },
5708
- this.localKeyPair.privateKey,
5709
- 256
5710
- );
5711
- // HKDF-SHA256 to derive AES-256-GCM key
5492
+ const rawBits = await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, this.localKeyPair.privateKey, 256);
5712
5493
  const hkdfKey = await crypto.subtle.importKey('raw', rawBits, 'HKDF', false, ['deriveBits']);
5713
- const salt = new TextEncoder().encode('remux-e2ee-v1');
5714
- const info = new TextEncoder().encode('aes-256-gcm');
5715
5494
  const derived = await crypto.subtle.deriveBits(
5716
- { name: 'HKDF', hash: 'SHA-256', salt, info },
5717
- hkdfKey,
5718
- 256
5719
- );
5495
+ { name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode('remux-e2ee-v1'), info: new TextEncoder().encode('aes-256-gcm') }, hkdfKey, 256);
5720
5496
  this.sharedKey = await crypto.subtle.importKey('raw', derived, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
5721
- this.established = true;
5722
- this.sendCounter = 0n;
5723
- this.recvCounter = -1n;
5724
- console.log('[e2ee] handshake complete');
5725
- } catch (e) {
5726
- console.error('[e2ee] handshake failed:', e);
5727
- this.available = false;
5728
- }
5497
+ this.established = true; this.sendCounter = 0n; this.recvCounter = -1n;
5498
+ } catch (e) { this.available = false; }
5729
5499
  },
5730
-
5731
5500
  async encryptMessage(plaintext) {
5732
5501
  if (!this.sharedKey) throw new Error('E2EE not established');
5733
- const plaintextBuf = new TextEncoder().encode(plaintext);
5734
- // IV: 4 random bytes + 8 byte counter (big-endian)
5735
- const iv = new Uint8Array(12);
5736
- crypto.getRandomValues(iv.subarray(0, 4));
5737
- const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
5738
- counterView.setBigUint64(0, this.sendCounter, false);
5502
+ const iv = new Uint8Array(12); crypto.getRandomValues(iv.subarray(0, 4));
5503
+ new DataView(iv.buffer, iv.byteOffset + 4, 8).setBigUint64(0, this.sendCounter, false);
5739
5504
  this.sendCounter++;
5740
- const encrypted = await crypto.subtle.encrypt(
5741
- { name: 'AES-GCM', iv, tagLength: 128 },
5742
- this.sharedKey,
5743
- plaintextBuf
5744
- );
5745
- // AES-GCM returns ciphertext + tag concatenated
5505
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, this.sharedKey, new TextEncoder().encode(plaintext));
5746
5506
  const result = new Uint8Array(12 + encrypted.byteLength);
5747
- result.set(iv, 0);
5748
- result.set(new Uint8Array(encrypted), 12);
5507
+ result.set(iv, 0); result.set(new Uint8Array(encrypted), 12);
5749
5508
  return btoa(String.fromCharCode(...result));
5750
5509
  },
5751
-
5752
5510
  async decryptMessage(encryptedB64) {
5753
5511
  if (!this.sharedKey) throw new Error('E2EE not established');
5754
5512
  const packed = Uint8Array.from(atob(encryptedB64), c => c.charCodeAt(0));
5755
- if (packed.length < 28) throw new Error('E2EE message too short'); // 12 iv + 16 tag minimum
5513
+ if (packed.length < 28) throw new Error('E2EE message too short');
5756
5514
  const iv = packed.subarray(0, 12);
5757
- const ciphertextWithTag = packed.subarray(12);
5758
- // Anti-replay: check counter is monotonically increasing
5759
- const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
5760
- const counter = counterView.getBigUint64(0, false);
5515
+ const counter = new DataView(iv.buffer, iv.byteOffset + 4, 8).getBigUint64(0, false);
5761
5516
  if (counter <= this.recvCounter) throw new Error('E2EE replay detected');
5762
- const decrypted = await crypto.subtle.decrypt(
5763
- { name: 'AES-GCM', iv, tagLength: 128 },
5764
- this.sharedKey,
5765
- ciphertextWithTag
5766
- );
5517
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, this.sharedKey, packed.subarray(12));
5767
5518
  this.recvCounter = counter;
5768
5519
  return new TextDecoder().decode(decrypted);
5769
5520
  }
@@ -5772,18 +5523,12 @@ var init_server = __esm({
5772
5523
  // -- WebSocket with exponential backoff + heartbeat --
5773
5524
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
5774
5525
  const urlToken = new URLSearchParams(location.search).get('token');
5775
-
5776
- // Exponential backoff: 1s -> 2s -> 4s -> 8s -> 16s -> 30s max
5777
5526
  let backoffMs = 1000;
5778
5527
  const BACKOFF_MAX = 30000;
5779
5528
  let reconnectTimer = null;
5780
-
5781
- // Heartbeat: if no message received for 45s, consider connection dead
5782
5529
  const HEARTBEAT_TIMEOUT = 45000;
5783
5530
  let lastMessageAt = Date.now();
5784
5531
  let heartbeatChecker = null;
5785
-
5786
- // Track last received message timestamp for session recovery
5787
5532
  let lastReceivedTimestamp = 0;
5788
5533
  let isResuming = false;
5789
5534
 
@@ -5791,29 +5536,19 @@ var init_server = __esm({
5791
5536
  lastMessageAt = Date.now();
5792
5537
  if (heartbeatChecker) clearInterval(heartbeatChecker);
5793
5538
  heartbeatChecker = setInterval(() => {
5794
- if (Date.now() - lastMessageAt > HEARTBEAT_TIMEOUT) {
5795
- console.log('[remux] heartbeat timeout, reconnecting');
5796
- if (ws) ws.close();
5797
- }
5539
+ if (Date.now() - lastMessageAt > HEARTBEAT_TIMEOUT) { if (ws) ws.close(); }
5798
5540
  }, 5000);
5799
5541
  }
5800
-
5801
- function stopHeartbeat() {
5802
- if (heartbeatChecker) { clearInterval(heartbeatChecker); heartbeatChecker = null; }
5803
- }
5542
+ function stopHeartbeat() { if (heartbeatChecker) { clearInterval(heartbeatChecker); heartbeatChecker = null; } }
5804
5543
 
5805
5544
  function scheduleReconnect() {
5806
5545
  if (reconnectTimer) return;
5807
5546
  const delay = backoffMs;
5808
5547
  let remaining = Math.ceil(delay / 1000);
5809
5548
  setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
5810
- const countdown = setInterval(() => {
5811
- remaining--;
5812
- if (remaining > 0) setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
5813
- }, 1000);
5549
+ const countdown = setInterval(() => { remaining--; if (remaining > 0) setStatus('disconnected', 'Reconnecting in ' + remaining + 's...'); }, 1000);
5814
5550
  reconnectTimer = setTimeout(() => {
5815
- clearInterval(countdown);
5816
- reconnectTimer = null;
5551
+ clearInterval(countdown); reconnectTimer = null;
5817
5552
  backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
5818
5553
  connect();
5819
5554
  }, delay);
@@ -5821,36 +5556,28 @@ var init_server = __esm({
5821
5556
 
5822
5557
  function connect() {
5823
5558
  setStatus('connecting', 'Connecting...');
5824
- e2ee.established = false;
5825
- e2ee.sharedKey = null;
5559
+ e2ee.established = false; e2ee.sharedKey = null;
5826
5560
  ws = new WebSocket(proto + '//' + location.host + '/ws');
5827
5561
  ws.onopen = async () => {
5828
- backoffMs = 1000; // reset backoff on successful connection
5562
+ backoffMs = 1000;
5829
5563
  startHeartbeat();
5830
5564
  if (urlToken) {
5831
- // Use persistent device ID from localStorage so each browser context
5832
- // is a distinct device even with identical User-Agent
5833
5565
  if (!localStorage.getItem('remux-device-id')) {
5834
5566
  localStorage.setItem('remux-device-id', Math.random().toString(36).slice(2, 10) + Date.now().toString(36));
5835
5567
  }
5836
5568
  ws.send(JSON.stringify({ type: 'auth', token: urlToken, deviceId: localStorage.getItem('remux-device-id') }));
5837
5569
  }
5838
- // Initiate E2EE handshake if Web Crypto API is available
5839
5570
  if (e2ee.available) {
5840
5571
  await e2ee.init();
5841
5572
  const pubKey = e2ee.getPublicKeyB64();
5842
- if (pubKey) {
5843
- ws.send(JSON.stringify({ v: 1, type: 'e2ee_init', payload: { publicKey: pubKey } }));
5844
- }
5573
+ if (pubKey) ws.send(JSON.stringify({ v: 1, type: 'e2ee_init', payload: { publicKey: pubKey } }));
5845
5574
  }
5846
- // Session recovery: if we have a previous timestamp, request buffered messages
5847
5575
  const deviceId = localStorage.getItem('remux-device-id');
5848
5576
  if (lastReceivedTimestamp > 0 && deviceId) {
5849
5577
  isResuming = true;
5850
5578
  setStatus('connecting', 'Resuming session...');
5851
- sendCtrl({ type: 'resume', deviceId: deviceId, lastTimestamp: lastReceivedTimestamp });
5579
+ sendCtrl({ type: 'resume', deviceId, lastTimestamp: lastReceivedTimestamp });
5852
5580
  }
5853
- // Let server pick the session if we have none (bootstrap flow)
5854
5581
  sendCtrl({ type: 'attach_first', session: currentSession || undefined, cols: term.cols, rows: term.rows });
5855
5582
  };
5856
5583
  ws.onmessage = e => {
@@ -5858,240 +5585,108 @@ var init_server = __esm({
5858
5585
  if (typeof e.data === 'string' && e.data[0] === '{') {
5859
5586
  try {
5860
5587
  const parsed = JSON.parse(e.data);
5861
- // Handle both envelope (v:1) and legacy messages
5862
- // Unwrap envelope: spread payload first, then override type with the
5863
- // envelope's type to prevent payload.type (e.g. artifact type "snapshot")
5864
- // from colliding with the message type (e.g. "snapshot_captured")
5865
5588
  const msg = parsed.v === 1 ? { ...(parsed.payload || {}), type: parsed.type } : parsed;
5866
- // Server heartbeat \u2014 just keep connection alive (lastMessageAt already updated)
5867
5589
  if (msg.type === 'ping') return;
5868
- // E2EE handshake: server responds with its public key
5869
- if (msg.type === 'e2ee_init') {
5870
- if (msg.publicKey && e2ee.available && e2ee.localKeyPair) {
5871
- e2ee.completeHandshake(msg.publicKey);
5872
- }
5873
- return;
5874
- }
5875
- if (msg.type === 'e2ee_ready') {
5876
- console.log('[e2ee] server confirmed E2EE established');
5877
- return;
5878
- }
5879
- // E2EE encrypted message (terminal output from server)
5590
+ if (msg.type === 'e2ee_init') { if (msg.publicKey && e2ee.available && e2ee.localKeyPair) e2ee.completeHandshake(msg.publicKey); return; }
5591
+ if (msg.type === 'e2ee_ready') return;
5880
5592
  if (msg.type === 'e2ee_msg') {
5881
- if (e2ee.established && msg.data) {
5882
- e2ee.decryptMessage(msg.data).then(decrypted => {
5883
- term.write(decrypted);
5884
- }).catch(err => console.error('[e2ee] decrypt failed:', err));
5885
- }
5593
+ if (e2ee.established && msg.data) e2ee.decryptMessage(msg.data).then(d => term.write(d)).catch(() => {});
5886
5594
  return;
5887
5595
  }
5888
- // Session recovery complete
5889
- if (msg.type === 'resume_complete') {
5890
- isResuming = false;
5891
- if (msg.replayed > 0) {
5892
- console.log('[remux] session recovered: ' + msg.replayed + ' buffered messages replayed');
5893
- }
5894
- return;
5895
- }
5896
- // Track timestamp for session recovery on reconnect
5596
+ if (msg.type === 'resume_complete') { isResuming = false; return; }
5897
5597
  lastReceivedTimestamp = Date.now();
5898
- if (msg.type === 'auth_ok') {
5899
- return;
5900
- }
5901
- if (msg.type === 'bootstrap') {
5902
- sessions = msg.sessions || [];
5903
- clientsList = msg.clients || [];
5904
- renderSessions(); renderTabs(); renderRole(); stabilizeFit();
5905
- return;
5906
- }
5598
+ if (msg.type === 'auth_ok') return;
5599
+ if (msg.type === 'bootstrap') { sessions = msg.sessions || []; clientsList = msg.clients || []; renderSessions(); renderTabs(); stabilizeFit(); return; }
5907
5600
  if (msg.type === 'auth_error') { setStatus('disconnected', 'Auth failed'); ws.close(); return; }
5908
- // Generic server error \u2014 show to user (e.g. pair code trust errors)
5909
- if (msg.type === 'error') {
5910
- console.warn('[remux] server error:', msg.reason || 'unknown');
5911
- alert('Error: ' + (msg.reason || 'unknown error'));
5912
- return;
5913
- }
5914
- if (msg.type === 'state') {
5915
- sessions = msg.sessions || [];
5916
- clientsList = msg.clients || [];
5917
- // Re-derive own role from authoritative server state
5918
- if (myClientId) {
5919
- const me = clientsList.find(c => c.clientId === myClientId);
5920
- if (me) myRole = me.role;
5921
- }
5922
- renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
5923
- }
5601
+ if (msg.type === 'error') { alert('Error: ' + (msg.reason || 'unknown error')); return; }
5602
+ if (msg.type === 'state') { sessions = msg.sessions || []; clientsList = msg.clients || []; renderSessions(); renderTabs(); stabilizeFit(); return; }
5924
5603
  if (msg.type === 'attached') {
5925
5604
  currentTabId = msg.tabId; currentSession = msg.session;
5926
5605
  if (msg.clientId) myClientId = msg.clientId;
5927
- if (msg.role) myRole = msg.role;
5928
- setStatus('connected', 'Connected'); renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
5929
- }
5930
- if (msg.type === 'role_changed') {
5931
- if (msg.clientId === myClientId) myRole = msg.role;
5932
- renderRole(); return;
5606
+ setStatus('connected', 'Connected'); renderSessions(); renderTabs(); stabilizeFit(); return;
5933
5607
  }
5934
- if (msg.type === 'inspect_result') {
5935
- window._inspectText = msg.text || '(empty)';
5936
- const m = msg.meta || {};
5937
- $('inspect-meta').innerHTML =
5938
- '<span>' + esc(m.session) + ' / ' + esc(m.tabTitle || 'Tab ' + m.tabId) + '</span>' +
5939
- '<span>' + (m.cols || '?') + 'x' + (m.rows || '?') + '</span>' +
5940
- '<span>' + new Date(m.timestamp || Date.now()).toLocaleTimeString() + '</span>' +
5941
- '<button class="inspect-btn" id="btn-copy-inspect">Copy</button>';
5942
- $('btn-copy-inspect').addEventListener('click', () => {
5943
- navigator.clipboard.writeText(window._inspectText).then(() => {
5944
- $('btn-copy-inspect').textContent = 'Copied!';
5945
- setTimeout(() => { const el = $('btn-copy-inspect'); if (el) el.textContent = 'Copy'; }, 1500);
5946
- });
5947
- });
5948
- // Apply search highlight if active
5949
- applyInspectSearch();
5950
- return;
5951
- }
5952
- // Unrecognized enveloped control message \u2014 discard, never write to terminal
5953
- if (parsed.v === 1) {
5954
- console.warn('[remux] unhandled message type:', msg.type);
5955
- return;
5956
- }
5957
- // Non-enveloped JSON (e.g. PTY output that looks like JSON) \u2014 fall through to term.write
5608
+ if (msg.type === 'role_changed' || msg.type === 'inspect_result') return;
5609
+ if (parsed.v === 1) return;
5958
5610
  } catch {}
5959
5611
  }
5960
- peOnServerData(e.data);
5961
5612
  term.write(e.data);
5962
5613
  };
5963
- ws.onclose = () => { stopHeartbeat(); peClearAll(); scheduleReconnect(); };
5614
+ ws.onclose = () => { stopHeartbeat(); scheduleReconnect(); };
5964
5615
  ws.onerror = () => setStatus('disconnected', 'Error');
5965
5616
  }
5966
5617
  connect();
5967
5618
  function sendCtrl(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
5968
- // Send terminal data, encrypting if E2EE is established
5969
5619
  function sendTermData(data) {
5970
5620
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
5971
5621
  if (e2ee.established) {
5972
5622
  e2ee.encryptMessage(data).then(encrypted => {
5973
- if (ws && ws.readyState === WebSocket.OPEN) {
5974
- ws.send(JSON.stringify({ v: 1, type: 'e2ee_msg', payload: { data: encrypted } }));
5975
- }
5976
- }).catch(err => {
5977
- console.error('[e2ee] encrypt failed, sending plaintext:', err);
5978
- ws.send(data);
5979
- });
5980
- } else {
5981
- ws.send(data);
5982
- }
5623
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ v: 1, type: 'e2ee_msg', payload: { data: encrypted } }));
5624
+ }).catch(() => { ws.send(data); });
5625
+ } else { ws.send(data); }
5983
5626
  }
5984
5627
 
5985
- // -- Terminal I/O --
5986
- term.onData(data => {
5987
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
5988
- if (ctrlActive) {
5989
- ctrlActive = false; $('btn-ctrl').classList.remove('active');
5990
- const ch = data.toLowerCase().charCodeAt(0);
5991
- if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
5992
- }
5993
- peOnInput(data);
5994
- sendTermData(data);
5995
- });
5628
+ // -- Terminal resize (read-only, no keyboard input) --
5996
5629
  term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
5997
5630
 
5998
- // -- Compose bar --
5631
+ // -- Compose input: text field + send --
5632
+ const cmdInput = $('cmd-input');
5633
+ function sendCommand() {
5634
+ const cmd = cmdInput.value;
5635
+ if (!cmd) return;
5636
+ sendTermData(cmd + '\\n');
5637
+ cmdInput.value = '';
5638
+ cmdInput.focus();
5639
+ }
5640
+ $('btn-send').addEventListener('click', sendCommand);
5641
+ cmdInput.addEventListener('keydown', e => {
5642
+ if (e.key === 'Enter') { e.preventDefault(); sendCommand(); }
5643
+ });
5644
+
5645
+ // -- Preset quick keys --
5999
5646
  const SEQ = {
6000
5647
  esc: '\\x1b', tab: '\\t',
6001
- up: '\\x1b[A', down: '\\x1b[B', left: '\\x1b[D', right: '\\x1b[C',
6002
- 'ctrl-c': '\\x03', 'ctrl-d': '\\x04', 'ctrl-z': '\\x1a',
6003
- pgup: '\\x1b[5~', pgdn: '\\x1b[6~', home: '\\x1b[H', end: '\\x1b[F',
5648
+ up: '\\x1b[A', down: '\\x1b[B',
5649
+ 'ctrl-c': '\\x03', 'ctrl-d': '\\x04', 'ctrl-z': '\\x1a', 'ctrl-l': '\\x0c',
6004
5650
  };
6005
- $('compose-bar').addEventListener('pointerdown', e => {
5651
+ $('btn-preset-toggle').addEventListener('click', () => {
5652
+ const bar = $('preset-bar');
5653
+ const btn = $('btn-preset-toggle');
5654
+ bar.classList.toggle('visible');
5655
+ btn.classList.toggle('active', bar.classList.contains('visible'));
5656
+ setTimeout(stabilizeFit, 50);
5657
+ });
5658
+ $('preset-bar').addEventListener('pointerdown', e => {
6006
5659
  const btn = e.target.closest('button'); if (!btn) return;
6007
5660
  e.preventDefault();
6008
- if (btn.dataset.mod === 'ctrl') { ctrlActive = !ctrlActive; btn.classList.toggle('active', ctrlActive); return; }
6009
- const d = SEQ[btn.dataset.seq] || btn.dataset.ch;
5661
+ const d = SEQ[btn.dataset.seq];
6010
5662
  if (d) sendTermData(d);
6011
- term.focus();
5663
+ cmdInput.focus();
6012
5664
  });
6013
5665
 
6014
- // -- Inspect view --
6015
- let currentView = 'live', inspectTimer = null;
6016
- function syncComposeBar() {
6017
- $('compose-bar').classList.toggle('visible', isTouchDevice && currentView === 'live');
6018
- }
6019
- function setView(mode) {
6020
- currentView = mode;
6021
- $('btn-live').classList.toggle('active', mode === 'live');
6022
- $('btn-inspect').classList.toggle('active', mode === 'inspect');
6023
- $('terminal').classList.toggle('hidden', mode !== 'live');
6024
- $('inspect').classList.toggle('visible', mode === 'inspect');
6025
- if (inspectTimer) { clearInterval(inspectTimer); inspectTimer = null; }
6026
- if (mode === 'inspect') {
6027
- sendCtrl({ type: 'inspect' });
6028
- inspectTimer = setInterval(() => sendCtrl({ type: 'inspect' }), 3000);
6029
- }
6030
- syncComposeBar();
6031
- if (mode === 'live') { term.focus(); stabilizeFit(); }
6032
- }
6033
- $('btn-live').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('live'); });
6034
- $('btn-inspect').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('inspect'); });
6035
-
6036
- // -- Inspect search --
6037
- function applyInspectSearch() {
6038
- const query = ($('inspect-search-input') || {}).value || '';
6039
- const text = window._inspectText || '';
6040
- if (!query) {
6041
- $('inspect-content').textContent = text;
6042
- $('inspect-match-count').textContent = '';
6043
- return;
6044
- }
6045
- const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
6046
- const q = query.toLowerCase();
6047
- const lower = text.toLowerCase();
6048
- let result = '', count = 0, pos = 0;
6049
- while (pos < text.length) {
6050
- const idx = lower.indexOf(q, pos);
6051
- if (idx === -1) { result += esc(text.slice(pos)); break; }
6052
- result += esc(text.slice(pos, idx)) + '<mark>' + esc(text.slice(idx, idx + q.length)) + '</mark>';
6053
- count++; pos = idx + q.length;
6054
- }
6055
- $('inspect-content').innerHTML = result;
6056
- $('inspect-match-count').textContent = count > 0 ? count + ' match' + (count !== 1 ? 'es' : '') : 'No matches';
6057
- }
6058
- $('inspect-search-input').addEventListener('input', applyInspectSearch);
6059
- syncComposeBar();
6060
-
6061
5666
  // -- Tab rename (double-click) --
6062
5667
  $('tab-list').addEventListener('dblclick', e => {
6063
5668
  const tabEl = e.target.closest('.tab');
6064
5669
  if (!tabEl) return;
6065
5670
  const titleSpan = tabEl.querySelector('.title');
6066
5671
  if (!titleSpan) return;
6067
- // Find tab id from close button
6068
5672
  const closeBtn = tabEl.querySelector('.close');
6069
5673
  if (!closeBtn) return;
6070
5674
  const tabId = Number(closeBtn.dataset.close);
6071
5675
  const oldTitle = titleSpan.textContent;
6072
-
6073
5676
  const input = document.createElement('input');
6074
5677
  input.className = 'rename-input';
6075
5678
  input.value = oldTitle;
6076
5679
  input.setAttribute('maxlength', '32');
6077
5680
  titleSpan.replaceWith(input);
6078
- input.focus();
6079
- input.select();
6080
-
5681
+ input.focus(); input.select();
6081
5682
  function commit() {
6082
5683
  const newTitle = input.value.trim() || oldTitle;
6083
- // Send rename to server
6084
5684
  if (newTitle !== oldTitle) sendCtrl({ type: 'rename_tab', tabId, title: newTitle });
6085
- // Restore span immediately
6086
- const span = document.createElement('span');
6087
- span.className = 'title';
6088
- span.textContent = newTitle;
5685
+ const span = document.createElement('span'); span.className = 'title'; span.textContent = newTitle;
6089
5686
  input.replaceWith(span);
6090
5687
  }
6091
5688
  function cancel() {
6092
- const span = document.createElement('span');
6093
- span.className = 'title';
6094
- span.textContent = oldTitle;
5689
+ const span = document.createElement('span'); span.className = 'title'; span.textContent = oldTitle;
6095
5690
  input.replaceWith(span);
6096
5691
  }
6097
5692
  input.addEventListener('keydown', ev => {
@@ -6101,97 +5696,11 @@ var init_server = __esm({
6101
5696
  input.addEventListener('blur', commit);
6102
5697
  });
6103
5698
 
6104
- // -- Mobile virtual keyboard handling --
6105
- // Only apply visualViewport height adjustments on touch devices.
6106
- // Ignore viewport sync during IME composition so candidate UI can't
6107
- // temporarily collapse the terminal area.
6108
- if (window.visualViewport && isTouchDevice) {
5699
+ // -- Mobile viewport handling --
5700
+ if (window.visualViewport) {
6109
5701
  window.visualViewport.addEventListener('resize', syncTouchViewportHeight);
6110
5702
  window.visualViewport.addEventListener('scroll', () => window.scrollTo(0, 0));
6111
5703
  }
6112
- // iOS Safari: touching terminal area focuses hidden textarea for input
6113
- document.getElementById('terminal').addEventListener('touchend', () => { if (currentView === 'live') term.focus(); });
6114
-
6115
- // -- IME diagnostic (active when ?debug=1) --
6116
- if (new URLSearchParams(location.search).has('debug')) {
6117
- const _d = window._imeDiag = { events: [], t0: Date.now() };
6118
- function _ilog(type, detail) {
6119
- const e = { t: Date.now() - _d.t0, type, ...detail };
6120
- _d.events.push(e);
6121
- if (_d.events.length > 300) _d.events.shift();
6122
- try { localStorage.setItem('_imeDiag', JSON.stringify(_d.events.slice(-50))); } catch {}
6123
- }
6124
- const _ta = document.querySelector('textarea');
6125
- if (_ta) {
6126
- ['compositionstart','compositionupdate','compositionend'].forEach(n =>
6127
- _ta.addEventListener(n, e => _ilog(n, { data: e.data }), true));
6128
- _ta.addEventListener('input', e => _ilog('input', {
6129
- data: e.data?.substring(0,20), inputType: e.inputType, isComposing: e.isComposing
6130
- }), true);
6131
- _ta.addEventListener('keydown', e => {
6132
- if (e.isComposing || e.keyCode === 229)
6133
- _ilog('keydown-ime', { key: e.key, code: e.code, kc: e.keyCode });
6134
- }, true);
6135
- }
6136
- new ResizeObserver(entries => entries.forEach(e => {
6137
- const n = e.target.id || e.target.tagName;
6138
- _ilog('resize', { el: n, h: Math.round(e.contentRect.height) });
6139
- })).observe(document.body);
6140
- if (window.visualViewport) window.visualViewport.addEventListener('resize', () =>
6141
- _ilog('vv-resize', { vh: Math.round(window.visualViewport.height), bh: document.body.offsetHeight }));
6142
- new MutationObserver(() => _ilog('body-style', {
6143
- h: document.body.style.height, oh: document.body.offsetHeight
6144
- })).observe(document.body, { attributes: true, attributeFilter: ['style'] });
6145
- window.addEventListener('error', e => _ilog('js-error', { msg: e.message, line: e.lineno }));
6146
- // Track textarea style/size changes (IME may resize it)
6147
- if (_ta) {
6148
- new ResizeObserver(() => {
6149
- _ilog('ta-resize', { w: _ta.offsetWidth, h: _ta.offsetHeight, vis: getComputedStyle(_ta).visibility, op: getComputedStyle(_ta).opacity });
6150
- }).observe(_ta);
6151
- }
6152
- // Track canvas visibility
6153
- const _cvs = document.querySelector('#terminal canvas');
6154
- if (_cvs) {
6155
- new ResizeObserver(() => {
6156
- _ilog('canvas-resize', { w: _cvs.width, h: _cvs.height, display: getComputedStyle(_cvs).display });
6157
- }).observe(_cvs);
6158
- }
6159
- // Floating debug overlay \u2014 stays visible even when page goes "blank"
6160
- const _dbg = document.createElement('div');
6161
- _dbg.id = 'ime-debug';
6162
- _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;';
6163
- document.documentElement.appendChild(_dbg);
6164
-
6165
- // Continuous polling \u2014 captures state even when no events fire
6166
- let _prevState = '';
6167
- setInterval(() => {
6168
- const _ta2 = document.querySelector('textarea');
6169
- const _cvs2 = document.querySelector('#terminal canvas');
6170
- const _term2 = document.getElementById('terminal');
6171
- const _main2 = document.querySelector('.main');
6172
- const _sidebar2 = document.querySelector('.sidebar');
6173
- const state = JSON.stringify({
6174
- body: { h: document.body.offsetHeight, w: document.body.offsetWidth, styleH: document.body.style.height, vis: document.body.style.visibility, disp: document.body.style.display },
6175
- main: _main2 ? { h: _main2.offsetHeight, vis: getComputedStyle(_main2).visibility, op: getComputedStyle(_main2).opacity, disp: getComputedStyle(_main2).display } : null,
6176
- term: _term2 ? { h: _term2.offsetHeight, w: _term2.offsetWidth, vis: getComputedStyle(_term2).visibility, disp: getComputedStyle(_term2).display, op: getComputedStyle(_term2).opacity } : null,
6177
- 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,
6178
- 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,
6179
- sidebar: _sidebar2 ? { h: _sidebar2.offsetHeight, vis: getComputedStyle(_sidebar2).visibility } : null,
6180
- vv: window.visualViewport ? { h: Math.round(window.visualViewport.height), w: Math.round(window.visualViewport.width) } : null
6181
- });
6182
- if (state !== _prevState) {
6183
- _prevState = state;
6184
- _ilog('poll', JSON.parse(state));
6185
- }
6186
- // Always update overlay with latest events
6187
- const last = _d.events.slice(-12);
6188
- _dbg.textContent = last.map(e => {
6189
- const {t, type, ...r} = e;
6190
- return t + 'ms ' + type + ': ' + JSON.stringify(r).substring(0, 120);
6191
- }).join('\\n');
6192
- }, 200);
6193
- console.log('[remux] IME diagnostic v2 active \u2014 polling every 200ms');
6194
- }
6195
5704
  </script>
6196
5705
  </body>
6197
5706
  </html>`;