mobux 0.6.1

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
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
import { TerminalCore } from "./terminal-core.js";
import { ReaderView } from "./reader-view.js";
import { createGestureRecognizer } from "./touch.js";
import { createInputBar } from "./input-bar.js";
import { createTopBar } from "./top-bar.js";
import { applyTheme, getStoredThemeId } from "./themes.js";

const session = window.MOBUX_SESSION;

// ── Pin this page to the session's host (issue #123) ─────────────────
// The host the session lives on is canonical in the URL path (/s/<host>/<name>)
// and the server injects it as window.MOBUX_PEER. Bind routing to that peer for
// the whole lifetime of this page BEFORE the terminal core constructs its WS /
// fetches, so the very first WS + history + panes — and every later reconnect,
// which calls wsUrl() again — target the host the session actually lives on,
// regardless of what the global host picker is set to. Empty (same-origin /
// current node) → no override, behaviour unchanged.
const pinnedHost = window.MOBUX_PEER || "";
if (pinnedHost) window.MobuxMesh.usePeerForPage(pinnedHost);

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() {
    // This handler is wired on the touch overlay, so a double-tap here always
    // comes from a touch device — exactly the case that wants the on-screen
    // input bar. Lazily create it on first activation so a device that loaded
    // as non-mobile (or just rotated into touch mode) still gets the bar
    // instead of being stuck with no keyboard affordance.
    ensureInputBar().show();
  },

  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");
        ensureInputBar().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 ────────────────────────────────────────────────
// `isMobile` is a one-shot guess at load time. It can be wrong: a device
// may load as non-mobile and later become touch-primary (rotation, an
// attached/detached input device, a misreported initial pointer query). So
// we don't gate creation on it — we create the bar lazily on first use
// (double-tap / activate), and also (re)evaluate when the pointer modality
// changes. Either path funnels through `ensureInputBar()`, which is
// idempotent.
let inputBar = null;
function ensureInputBar() {
  if (!inputBar) {
    inputBar = createInputBar(core.term, (d) => core.send(d));
  }
  return inputBar;
}

// If we already look like a touch device, mount eagerly so the existing
// auto-hide / viewport plumbing is wired from the start.
if (isMobile) {
  ensureInputBar();
}

// Re-evaluate when the primary pointer flips to coarse (e.g. a 2-in-1
// switching to tablet mode). matchMedia change fires on modality changes;
// once coarse, make sure the bar exists.
try {
  const coarse = window.matchMedia("(pointer: coarse)");
  const onPointerChange = (e) => {
    if (e.matches) ensureInputBar();
  };
  if (coarse.addEventListener)
    coarse.addEventListener("change", onPointerChange);
  else if (coarse.addListener) coarse.addListener(onPointerChange);
} catch (_) {
  /* matchMedia unsupported: lazy creation on tap still covers us */
}

// ── 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");
  });
}

// ── Desktop top bar ─────────────────────────────────────────────────
// On a non-touch browser xterm.js owns the keyboard, so attach / dictate /
// reader-toggle have no shortcut. Mount a slim top bar with those three as
// the desktop counterpart to the mobile input bar. Mirror the isMobile gate
// so the two surfaces are mutually exclusive, and (re)evaluate cheaply when
// the pointer modality flips to coarse (a 2-in-1 going tablet → drop it).
let topBar = null;
function ensureTopBar() {
  if (topBar || isMobile) return;
  topBar = createTopBar({
    send: (d) => core.send(d),
    toggleReader: () => swapView(currentView === "xterm" ? "reader" : "xterm"),
    isReader: () => currentView === "reader",
  });
}
if (!isMobile) ensureTopBar();
try {
  const coarse = window.matchMedia("(pointer: coarse)");
  const onCoarse = (e) => {
    if (e.matches && topBar) {
      topBar.destroy();
      topBar = null;
    }
  };
  if (coarse.addEventListener) coarse.addEventListener("change", onCoarse);
  else if (coarse.addListener) coarse.addListener(onCoarse);
} catch (_) {
  /* matchMedia unsupported: static gate still covers us */
}

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 () => {
  // When pinned to a peer (deep-linked /s/<host>/<name>) whose creds aren't
  // stored on this device yet, the relayed WS would 401 → close-loop and the
  // panes/history fetches would .catch() silently, leaving a blank
  // "reconnecting" terminal. Prompt for the host's creds first so the very
  // first connect carries them; if the prompt is unavailable or declined,
  // surface a visible note instead of wedging.
  if (pinnedHost && !window.MobuxMesh.getPeerCred(pinnedHost)) {
    const picker = window.MobuxHostPicker;
    let signedIn = false;
    if (picker && typeof picker.promptPeerCred === "function") {
      try {
        signedIn = await picker.promptPeerCred(pinnedHost);
      } catch (_) {}
    }
    if (!signedIn) {
      if (loadquote) {
        const q = document.getElementById("quote");
        const a = document.getElementById("qauthor");
        if (q) q.textContent = `Sign in to ${pinnedHost} to open this session.`;
        if (a) a.textContent = "";
      }
      return; // don't connect with no creds — avoids the silent close-loop
    }
  }
  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;
});

// ── Peer WS auth-failure → in-app re-prompt ─────────────────────────
// When pinned to a peer, the relay accepts the browser WS first, then dials
// the peer with the stored upstream_auth. A *stale/wrong* cred makes that
// server-side dial 401, so the browser socket closes WITHOUT ever opening —
// indistinguishable from a network blip, so the core would just back off and
// retry the same bad cred forever (a silent close-loop, never the browser's
// native dialog since a WS upgrade can't trigger it). To recover: if a peer
// socket closes before it ever opened, treat it as an auth failure — clear the
// cred, prompt in-app for fresh creds, then reconnect. A clean open clears the
// flag so a later blip on a working session reconnects normally.
if (pinnedHost) {
  let everOpened = false;
  let reprompting = false;
  core.addEventListener("open", () => {
    everOpened = true;
  });
  core.addEventListener("close", async () => {
    if (everOpened || reprompting || core.intentionalClose) return;
    reprompting = true;
    const mesh = window.MobuxMesh;
    const picker = window.MobuxHostPicker;
    mesh.clearPeerCred(pinnedHost);
    let signedIn = false;
    if (picker && typeof picker.promptPeerCred === "function") {
      try {
        signedIn = await picker.promptPeerCred(pinnedHost, {
          note: `Sign in to ${pinnedHost} to open this session.`,
        });
      } catch (_) {}
    }
    reprompting = false;
    if (signedIn) core.connect();
  });
}

// ── 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;
  });
}