mobux 0.1.4

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
import { TerminalCore } from './terminal-core.js';
import { ReaderView } from './reader-view.js';
import { createGestureRecognizer } from './touch.js';
import { createInputBar } from './input-bar.js';
import { applyTheme, getStoredThemeId } from './themes.js';

const session = window.MOBUX_SESSION;
const termEl = document.getElementById("terminal");
const readerEl = document.getElementById("reader");
const overlay = document.getElementById("touchOverlay");
const loadquote = document.getElementById("loadquote");
const paneIndicator = document.getElementById("paneIndicator");
const cmdPickList = document.getElementById("cmdPickList");
const cmdOverlayBg = document.getElementById("cmdOverlayBg");
const cmdCloseBtn = document.getElementById("cmdCloseBtn");

// ── Loading screen quotes ───────────────────────────────────────────
const quotes = [
  ["Simplicity is prerequisite for reliability.", "Edsger W. Dijkstra"],
  ["If debugging is the process of removing bugs, then programming must be the process of putting them in.", "Edsger W. Dijkstra"],
  ["The Analytical Engine weaves algebraical patterns just as the Jacquard loom weaves flowers and leaves.", "Ada Lovelace"],
  ["We can only see a short distance ahead, but we can see plenty there that needs to be done.", "Alan Turing"],
  ["Those who can imagine anything, can create the impossible.", "Alan Turing"],
  ["The most dangerous phrase in the language is: we\u2019ve always done it this way.", "Grace Hopper"],
  ["The best way to predict the future is to invent it.", "Alan Kay"],
  ["Premature optimization is the root of all evil.", "Donald Knuth"],
  ["Talk is cheap. Show me the code.", "Linus Torvalds"],
  ["Controlling complexity is the essence of computer programming.", "Brian Kernighan"],
  ["Any sufficiently advanced technology is indistinguishable from magic.", "Arthur C. Clarke"],
  ["Information is the resolution of uncertainty.", "Claude Shannon"],
  ["Looking back, we were the luckiest people in the world; there was no choice but to be pioneers.", "Margaret Hamilton"],

  // Contemporary craft
  ["Any fool can write code that a computer can understand. Good programmers write code that humans can understand.", "Martin Fowler"],
  ["Truth can only be found in one place: the code.", "Robert C. Martin"],
  ["I'm not a great programmer; I'm just a good programmer with great habits.", "Kent Beck"],
  ["Duplication is far cheaper than the wrong abstraction.", "Sandi Metz"],
  ["It's harder to read code than to write it.", "Joel Spolsky"],
  ["I call it my billion-dollar mistake.", "Tony Hoare, on null"],
  ["Programmers know the value of everything and the cost of nothing.", "Rich Hickey"],
  ["Fancy algorithms are slow when n is small, and n is usually small.", "Rob Pike"],
  ["There are only two kinds of programming languages: the ones people complain about and the ones nobody uses.", "Bjarne Stroustrup"],
  ["Ruby is designed to make programmers happy.", "Yukihiro Matsumoto"],
  ["The three chief virtues of a programmer are: laziness, impatience, and hubris.", "Larry Wall"],
  ["If you're not failing every now and again, it's a sign you're not doing anything very innovative.", "John Carmack"],
];
{
  const [text, author] = quotes[Math.floor(Math.random() * quotes.length)];
  document.getElementById("quote").textContent = text;
  document.getElementById("qauthor").textContent = "\u2014 " + author;
}

// ── External links ──────────────────────────────────────────────────
// In a TWA (Trusted Web Activity, package id `io.github.mvhenten.mobux`),
// external links should open in the user's system default browser (e.g.
// Firefox, Chrome, whatever the user has configured), not Chrome Custom
// Tabs. Android's intent:// URL scheme with action=VIEW and no package=
// attribute forces the system to resolve through the default browser.
//
// TWA detection: document.referrer starts with 'android-app://' when
// running inside the TWA shell.
//
// On desktop / regular browsers, this uses the standard anchor-click
// new-tab behavior.

// Helper for navigation - exposed for test stubbing
function navigateToUrl(url) {
  window.location.assign(url);
}

function openExternal(url) {
  const isTWA = document.referrer.startsWith('android-app://');
  
  if (isTWA && /^https?:\/\//.test(url)) {
    // Build an intent:// URL that opens the link in the system default
    // browser. Format:
    // intent://<url>#Intent;action=android.intent.action.VIEW;scheme=<scheme>;S.browser_fallback_url=<url>;end;
    const urlObj = new URL(url);
    const intentUrl = `intent://${urlObj.host}${urlObj.pathname}${urlObj.search}${urlObj.hash}#Intent;action=android.intent.action.VIEW;scheme=${urlObj.protocol.replace(':', '')};S.browser_fallback_url=${encodeURIComponent(url)};end;`;
    window.__mobuxNavigateToUrl(intentUrl);
    return;
  }
  
  // Non-TWA or non-http(s) URLs: use anchor-click fallback
  const a = document.createElement('a');
  a.href = url;
  a.target = '_blank';
  a.rel = 'noopener noreferrer';
  a.style.display = 'none';
  document.body.appendChild(a);
  a.click();
  a.remove();
}
// Expose for tests
window.__mobuxNavigateToUrl = navigateToUrl;
// Expose for smoke tests (mirrors `window.__mobuxView` etc.).
window.__mobuxOpenExternal = openExternal;

// ── Core ────────────────────────────────────────────────────────────
// `coarse` pointer = touch primary (phones + tablets). Width fallback
// catches devices that misreport pointer capability. Desktops with a
// mouse stay `false` and skip the on-screen input bar.
const isMobile = window.matchMedia('(pointer: coarse)').matches || window.innerWidth < 620;
const core = new TerminalCore({ session, host: termEl });

// Apply the stored theme to all three layers. terminal-core.js already
// picked the matching palette + Ace theme at construction; this call
// pushes the --ansi-* vars onto #reader for tokenized reader output.
// Only the sterk backend has an Ace editor under the hood; xterm has none.
const getEditor = () => core.term?._sterk?.renderer?.getEditor?.();
applyTheme(getStoredThemeId(), { editor: getEditor() });

// Live swap when the settings page (or another tab) changes the theme.
// `storage` only fires in OTHER documents — same-doc swaps go through
// `mobux:theme` (dispatched by the picker).
function onThemeChange() {
  applyTheme(getStoredThemeId(), { editor: getEditor() });
}
window.addEventListener('storage', (e) => {
  if (e.key === 'mobux:theme') onThemeChange();
});
window.addEventListener('mobux:theme', onThemeChange);

// Enable overlay for touch devices
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
  overlay.style.pointerEvents = 'auto';
}

// ── Pane indicator ──────────────────────────────────────────────────
function updatePaneUI() {
  const { panes, activeIndex } = core;
  if (panes.length <= 1) {
    paneIndicator.textContent = panes.length === 1 ? panes[0].title : "";
  } else {
    const current = panes[activeIndex];
    paneIndicator.textContent = `${current ? current.title : "?"} (${activeIndex + 1}/${panes.length})`;
  }
}
core.addEventListener('panes', () => {
  updatePaneUI();
  pruneViewPrefs();
  applyStoredViewForActiveWindow();
});

// ── Command pick list ───────────────────────────────────────────────
function showCmdList() {
  cmdPickList.classList.add('visible');
  cmdOverlayBg.classList.add('visible');
  overlay.style.pointerEvents = 'none';
}

function hideCmdList() {
  cmdPickList.classList.remove('visible');
  cmdOverlayBg.classList.remove('visible');
  if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
    overlay.style.pointerEvents = 'auto';
  }
}

cmdPickList.addEventListener('click', (e) => {
  const cmdItem = e.target.closest('[data-cmd]');
  if (cmdItem) { core.runTmuxCmd(cmdItem.dataset.cmd); hideCmdList(); return; }
});
cmdCloseBtn.addEventListener('click', hideCmdList);
cmdOverlayBg.addEventListener('click', hideCmdList);

// ── Touch gestures ──────────────────────────────────────────────────
function scrollByPixels(dy) {
  const lines = Math.round(dy / core.cellSize().height);
  if (lines !== 0) core.scrollLines(lines);
}

createGestureRecognizer(overlay, {
  onScroll: scrollByPixels,
  onReconnect: () => core.reconnect(),
  getFontSize: () => core.getFontSize(),

  onPinch(scale, startSize) {
    const newSize = Math.round(Math.max(8, Math.min(32, startSize * scale)));
    core.setFontSize(newSize);
  },

  onTwoPullMove(pull, vh) {
    if (pull > vh * 0.08) paneIndicator.textContent = '↻ Release to reload';
    else if (pull > vh * 0.03) paneIndicator.textContent = '↓ Pull to reload...';
  },

  onTwoPullEnd(pull, vh) {
    if (pull > vh * 0.08) location.reload(true);
    else updatePaneUI();
  },

  onTap(x, y) {
    // Detect URLs in terminal text at tap position and open them.
    // WebLinksAddon uses hover-based links which don't work on mobile,
    // so we read the buffer text directly.
    const cell = core.cellSize();
    const rect = termEl.getBoundingClientRect();
    const col = Math.floor((x - rect.left) / cell.width);
    const row = Math.floor((y - rect.top) / cell.height);
    const buffer = core.getActiveBuffer();
    const bufferRow = buffer.viewportY + row;
    const line = buffer.getLine(bufferRow);
    if (!line) return;
    const text = line.translateToString(true);
    const urlRe = /https?:\/\/[^\s)"'>]+/g;
    let match;
    while ((match = urlRe.exec(text)) !== null) {
      if (col >= match.index && col < match.index + match[0].length) {
        openExternal(match[0]);
        return;
      }
    }
  },

  onDoubleTap(x, y) {
    if (inputBar) {
      inputBar.show();
      return;
    }
    overlay.style.pointerEvents = 'none';
    setTimeout(() => { overlay.style.pointerEvents = 'auto'; }, 500);
    const el = document.elementFromPoint(x, y);
    if (el) {
      el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y }));
      el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y }));
      el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: x, clientY: y }));
    }
  },

  onHSwipe: (dir) => core.switchWindow(dir),

  onLongPress: showCmdList,
  onSwipeUp: showCmdList,
});

// ReaderView uses fully synthetic scroll: native overflow scrolling
// on mobile WebViews has been unreliable (engaged-only-after-fresh-touch
// on iOS, locked-state on Android with large scrollbacks). We feed the
// gesture recogniser's onScroll/fling output straight into reader's
// translateY transform.
let readerGestures = null;
function mountReaderGestures() {
  if (readerGestures) return;
  readerGestures = createGestureRecognizer(readerEl, {
    onReconnect: () => core.reconnect(),
    onLongPress: showCmdList,
    onSwipeUp: showCmdList,
    onHSwipe: (dir) => core.switchWindow(dir),
    onTap: () => {},
    // Double-tap in reader mode is for typing, but the reader has no
    // cursor / no live editing affordance — opening the keyboard
    // there is confusing. Drop back to xterm first, then show the
    // input bar so the keystrokes have somewhere to land.
    onDoubleTap: () => { swapView('xterm'); if (inputBar) inputBar.show(); },
    onScroll: (dy) => reader.scrollBy(dy),
    onTwoPullMove(pull, vh) {
      if (pull > vh * 0.08) paneIndicator.textContent = '↻ Release to reload';
      else if (pull > vh * 0.03) paneIndicator.textContent = '↓ Pull to reload...';
    },
    onTwoPullEnd(pull, vh) {
      if (pull > vh * 0.08) location.reload(true);
      else updatePaneUI();
    },
  }, { passiveScroll: false });
}
function unmountReaderGestures() {
  if (!readerGestures) return;
  readerGestures.destroy();
  readerGestures = null;
}

// ── Reveal on first output ──────────────────────────────────────────
// Fire on the first `data` event, not on settle. The previous
// implementation reset an 800 ms timer per event, which never
// settled when the attached session pumped continuous output (e.g.
// a TUI like Claude Code), leaving the loading splash up forever.
let revealScheduled = false;
function scheduleReveal() {
  if (revealScheduled) return;
  if (!loadquote || !loadquote.parentNode) return;
  revealScheduled = true;
  setTimeout(() => {
    core.scrollToBottom();
    loadquote.style.opacity = '0';
    setTimeout(() => { if (loadquote.parentNode) loadquote.remove(); }, 300);
  }, 200);
}
core.addEventListener('data', scheduleReveal);

// ── Mobile input bar ────────────────────────────────────────────────
let inputBar = null;
if (isMobile) {
  inputBar = createInputBar(core.term, (d) => core.send(d));
}

// ── View swap (xterm <-> reader) ────────────────────────────────────
const reader = new ReaderView({ host: readerEl, core, overlay });
let currentView = 'xterm';

const VIEW_DEFAULT_KEY = 'mobux.view.default';
const viewPrefKey = (windowId) => `mobux.view.${session}.${windowId}`;

function activeWindowId() {
  const p = core.panes[core.activeIndex];
  return p?.id || null;
}

function storedDefaultView() {
  try { return localStorage.getItem(VIEW_DEFAULT_KEY) || 'xterm'; }
  catch (_) { return 'xterm'; }
}

function storedViewFor(windowId) {
  if (!windowId) return null;
  try { return localStorage.getItem(viewPrefKey(windowId)); }
  catch (_) { return null; }
}

function updateToggleLabel() {
  const btn = document.getElementById('viewToggleBtn');
  if (!btn) return;
  if (currentView === 'reader') {
    btn.textContent = '';
    btn.title = 'Switch to terminal view';
  } else {
    btn.textContent = '📖';
    btn.title = 'Switch to reader view';
  }
}

function applyView(mode, { persist = true } = {}) {
  if (mode !== 'xterm' && mode !== 'reader') return;
  if (mode === currentView) { updateToggleLabel(); return; }
  if (mode === 'reader') {
    termEl.classList.add('hidden');
    // Reader has its own gesture recogniser on #reader. Disable the
    // xterm overlay so it doesn't sit on top and eat every touch.
    overlay.style.pointerEvents = 'none';
    reader.mount();
    mountReaderGestures();
  } else {
    unmountReaderGestures();
    reader.unmount();
    termEl.classList.remove('hidden');
    if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
      overlay.style.pointerEvents = 'auto';
    }
    setTimeout(() => core.resize(), 0);
  }
  currentView = mode;
  if (persist) {
    try {
      localStorage.setItem(VIEW_DEFAULT_KEY, mode);
      const wid = activeWindowId();
      if (wid) localStorage.setItem(viewPrefKey(wid), mode);
    } catch (_) {}
  }
  updateToggleLabel();
  window.dispatchEvent(new CustomEvent('mobux:viewchange', { detail: mode }));
}

function swapView(mode) { applyView(mode, { persist: true }); }

// Ribbon view-toggle button (mobile input bar).
const viewToggleBtn = document.getElementById('viewToggleBtn');
if (viewToggleBtn) {
  viewToggleBtn.addEventListener('mousedown', (e) => e.preventDefault());
  viewToggleBtn.addEventListener('click', (e) => {
    e.preventDefault();
    swapView(currentView === 'xterm' ? 'reader' : 'xterm');
  });
}

function applyStoredViewForActiveWindow() {
  const wid = activeWindowId();
  const stored = storedViewFor(wid);
  const mode = stored || storedDefaultView();
  applyView(mode, { persist: false });
}

function pruneViewPrefs() {
  const live = new Set(core.panes.map((p) => p.id).filter(Boolean));
  const prefix = `mobux.view.${session}.`;
  try {
    for (let i = localStorage.length - 1; i >= 0; i--) {
      const k = localStorage.key(i);
      if (k?.startsWith(prefix) && !live.has(k.slice(prefix.length))) {
        localStorage.removeItem(k);
      }
    }
  } catch (_) {}
}

window.__mobuxView = {
  swap: swapView,
  get current() { return currentView; },
  send: (d) => core.send(d),
  test: {
    // Test injections close the WS first so tmux can't race/clobber
    // the injected content (e.g. by re-asserting alt-screen mode).
    inject: (str) => {
      // Mark the close intentional so auto-reconnect doesn't reopen the
      // WS and let tmux clobber the injected content.
      core.intentionalClose = true;
      try { core.ws?.close(); } catch (_) {}
      return new Promise((resolve) =>
        core.term.write('\x1b[?1049l' + str.replace(/\n/g, '\r\n'), resolve));
    },
    injectLines: (n, prefix = 'inject') => {
      core.intentionalClose = true;
      try { core.ws?.close(); } catch (_) {}
      let s = '\x1b[?1049l';
      for (let i = 0; i < n; i++) s += `${prefix} ${i}\r\n`;
      return new Promise((resolve) => core.term.write(s, resolve));
    },
    // Like injectLines but WITHOUT the \x1b[?1049l (alt-screen exit)
    // prefix. Use this in tests that care about sticky-to-bottom
    // behaviour after incremental content growth: the alt-screen exit
    // sequence causes sterk to reset the buffer, which races with the
    // test's scroll-geometry probe.
    injectLinesPlain: (n, prefix = 'inject') => {
      try { core.ws?.close(); } catch (_) {}
      let s = '';
      for (let i = 0; i < n; i++) s += `${prefix} ${i}\r\n`;
      return new Promise((resolve) => core.term.write(s, resolve));
    },
    // Returns a Promise that resolves after the reader's next _render()
    // call has committed updated scroll geometry (maxScroll, scrollY).
    // Safe to call from page.evaluate() — Playwright serialises the
    // resolved value via structured-clone, so callers should not await
    // a non-serialisable payload.
    readerAwaitRender: () => reader.awaitNextRender(),
    bufferLength: () => core.getActiveBuffer().length,
    isAlternate: () => {
      // sterk: compare alternate vs active buffer references
      if (core.term?._sterk?.buffer) {
        return core.term._sterk.buffer.alternate === core.term._sterk.buffer.active;
      }
      // xterm: the BufferNamespace exposes `active.type` ('normal' | 'alternate')
      const t = core.term?.buffer?.active?.type;
      return t === 'alternate';
    },
    readerAtBottom: () => reader._atBottom,
    readerForceScrollTop: () => { reader._atBottom = false; reader._scrollY = 0; reader._applyTransform?.(); },
    terminalRows: () => core.term.rows,
    cols: () => core.term.cols,
    rows: () => core.term.rows,
    viewportY: () => core.getActiveBuffer().viewportY,
    scrollToBottom: () => core.scrollToBottom(),
    wsReady: () => core.ws?.readyState === WebSocket.OPEN,
    // Simulate an *unexpected* server-side drop: close the socket
    // WITHOUT marking the close intentional, so the core's onclose
    // backoff fires exactly as it would for a real network/server blip.
    // Used by the auto-reconnect test.
    forceDrop: () => {
      core.intentionalClose = false;
      try { core.ws?.close(); } catch (_) {}
    },
    oscDetected: () => !!core.oscDetected,
    readerScrollY: () => reader.scrollY,
    readerMaxScroll: () => reader.maxScroll,
    readerInnerHeight: () => reader.innerHeight,
    readerScrollBy: (dy) => reader.scrollBy(dy),
    readerStickToBottom: () => reader.stickToBottom(),
    // Force a synchronous re-render. Used by tests that need to assert
    // post-render invariants (e.g. that rb-speaking re-applies after
    // _inner.replaceChildren wipes the icon DOM) without racing the
    // 50ms render throttle.
    readerForceRender: () => reader._render(),
    switchWindow: (dir) => core.switchWindow(dir),
    statusBarOffsetHeight: () => document.querySelector('.reader-statusbar')?.offsetHeight ?? 0,
    statusBarFilled: () => document.querySelector('.reader-statusbar')?.classList.contains('reader-statusbar--filled') ?? false,
  },
};

// Apply stored default at boot so the user lands in their preferred
// view even before the first /panes refresh resolves. Per-window
// override (if any) is applied later in the panes listener.
const bootDefault = storedDefaultView();
if (bootDefault === 'reader') {
  setTimeout(() => applyView('reader', { persist: false }), 0);
}

updateToggleLabel();

// ── Notification deep-link ─────────────────────────────────────────
// A push notification's URL embeds ?w={window_index} for the tmux
// window that fired the alert-bell hook. On boot we honor that, and
// on a click into an already-open tab the SW posts `mobux-navigate`
// so we can switch without a reload.
function selectWindow(windowIndex) {
  if (windowIndex == null || windowIndex === '') return;
  window.MobuxMesh.apiFetch(
    `/api/sessions/${encodeURIComponent(session)}/panes/${encodeURIComponent(windowIndex)}/select`,
    { method: 'POST' },
  ).then(() => {
    core.clear();
    core.scrollToBottom();
    setTimeout(() => { core.refreshPanes(); core.reloadHistory(); }, 300);
  }).catch(() => {});
}

function windowFromUrl(href) {
  try { return new URL(href, location.origin).searchParams.get('w'); }
  catch (_) { return null; }
}

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.addEventListener('message', (ev) => {
    if (ev.data?.type === 'mobux-navigate') {
      selectWindow(windowFromUrl(ev.data.url));
    }
  });
}

// ── Boot ────────────────────────────────────────────────────────────
// `booted` gates the page-level auto-reconnect listeners below: until
// boot's own connect() has run there's nothing to reconnect, and firing
// reconnect() while `core.ws` is still null would open a competing
// socket that boot then immediately replaces.
let booted = false;
(async () => {
  await core.reloadHistory();
  core.connect();
  booted = true;
  const w = windowFromUrl(location.href);
  if (w != null) {
    // Brief wait so the WS attach completes before we ask tmux to
    // switch windows; refreshPanes after the switch then sees the new
    // active window.
    setTimeout(() => selectWindow(w), 500);
  }
})();

window.addEventListener("resize", () => core.resize());
setTimeout(() => core.resize(), 100);
setInterval(() => core.refreshPanes(), 5000);

// ── Auto-reconnect ──────────────────────────────────────────────────
// Renderer-agnostic. The tmux session persists server-side, so
// re-establishing the WS resumes cleanly. `core.reconnect()` is
// idempotent (no-ops if the socket is already OPEN), so wiring several
// triggers is safe — whichever fires first reconnects, the rest no-op.
//
// The core's own `ws.onclose` handler does capped exponential backoff
// for the "server bounced / network blip" case; these page-level
// listeners are the "user came back to the app" fast paths that
// reconnect immediately instead of waiting out the backoff window. The
// existing touch-based reconnect (touch.js onTouchStart → onReconnect)
// stays as a manual fallback.

function autoReconnect() {
  if (!booted) return;
  core.reconnect();
}

// Primary path: screen/tab is visible again → reconnect now.
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') autoReconnect();
});
// Network came back.
window.addEventListener('online', autoReconnect);
// Android bfcache restore (app swapped back into the foreground).
window.addEventListener('pageshow', autoReconnect);

// A real navigation away / unload is an intentional teardown — mark it
// so the socket's onclose doesn't arm a (pointless) backoff retry on a
// page that's going away.
window.addEventListener('pagehide', () => { core.intentionalClose = true; });

// ── Soft keyboard (visualViewport) handler ──────────────────────────
// Renderer-agnostic. On Android Chrome (the TWA target) the soft
// keyboard does NOT shrink the layout viewport — `window.innerHeight`
// and the `100vh`/`100dvh` units used by `.term-body` stay at full
// screen — but `window.visualViewport.height` does shrink. Without
// this handler the bottom rows of the terminal (typically the tmux
// status line + active prompt) end up rendered behind the keyboard.
//
// We shrink the body to the visual viewport height so the flex
// children (#terminal, #reader, #inputBar) reflow into the visible
// area. Then we dispatch a `resize` so both backends recompute their
// (cols, rows) from the new host clientHeight. input-bar.js still
// owns its show/hide auto-restore on viewport grow-back — this handler
// only handles the body height tracking, which must work whether the
// input bar is mounted or not (the bug also reproduces when the
// renderer's native textarea gets focus directly).
if (window.visualViewport) {
  const vv = window.visualViewport;
  let lastH = vv.height;
  const trackKeyboard = () => {
    const shrunk = vv.height < window.innerHeight - 1;
    document.body.style.height = shrunk ? `${vv.height}px` : '';
    if (Math.abs(vv.height - lastH) > 0.5) {
      lastH = vv.height;
      // Synchronous resize so both backends recompute cols/rows from
      // the freshly-laid-out host height in the same task — no visible
      // jump on the next frame.
      window.dispatchEvent(new Event('resize'));
    }
  };
  vv.addEventListener('resize', trackKeyboard);
  vv.addEventListener('scroll', trackKeyboard);
}

// ── Tap-to-snap-to-bottom ───────────────────────────────────────────
// Renderer-agnostic. When the user is parked mid-scrollback and TAPS
// the terminal to type, the soft keyboard comes up but the viewport
// stays parked in scrollback — so what they type lands somewhere they
// can't see (issue #99). Snap to the live screen on a genuine tap so
// keystrokes always land in view.
//
// We discriminate a TAP from a SWIPE using pointer events, NOT focus.
// PR #100 hooked `focusin` and snapped on every touch — but focusin
// fires on tap-to-scroll too, so swiping up to read scrollback
// immediately snapped back to bottom and broke incremental scrolling.
// That PR was reverted in #102. Here we only snap when the pointer
// barely moved (< TAP_MOVE_PX) and was down only briefly
// (< TAP_MAX_MS): a real tap, not a swipe or a long-press-drag.
//
// Both backends mount under `#terminal` (xterm: `.xterm-helper-textarea`,
// sterk: `.ace_text-input`), so listening on the host element keeps
// this renderer-agnostic. This coexists with the visualViewport
// handler above (PR #98) — that one tracks keyboard height, this one
// tracks the viewport scroll position. Both stay.
{
  const TAP_MOVE_PX = 10; // max pointer travel for a tap (vs. swipe)
  const TAP_MAX_MS = 250; // max press duration for a tap (vs. drag)
  let downX = 0;
  let downY = 0;
  let downT = 0;
  let tracking = false;

  termEl.addEventListener('pointerdown', (e) => {
    downX = e.clientX;
    downY = e.clientY;
    downT = e.timeStamp;
    tracking = true;
  });

  termEl.addEventListener('pointerup', (e) => {
    if (!tracking) return;
    tracking = false;
    const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
    const elapsed = e.timeStamp - downT;
    if (moved < TAP_MOVE_PX && elapsed < TAP_MAX_MS) {
      core.scrollToBottom();
    }
  });

  // A canceled pointer (e.g. the gesture recogniser claims it for a
  // scroll/pinch) is never a tap — drop tracking so the next pointerup
  // can't be misread.
  termEl.addEventListener('pointercancel', () => { tracking = false; });
}