mobux 0.35.2

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
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
import { u } from "./base.js";
import { TerminalEngine } from "./terminal-engine.js";
import { createXtermRenderer } from "./renderer-xterm.js";
import { createSterkRenderer } from "./renderer-sterk.js";
import { createGestureRecognizer } from "./touch.js";
import { createInputBar } from "./input-bar.js";
import { createTopBar } from "./top-bar.js";
import { applyTheme, getStoredThemeId } from "./themes.js";
import {
  navigateToUrl,
  openExternal,
  isExternalUrl,
  installExternalLinkHandler,
} from "./external-link.js";

// ── 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’ve 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",
  ],
];

// Renderer construction options shared by both adapters. The typography
// matches the reader's (style.css `.rb-line`); `altScreen: false` is mobux's
// standing "no alternate screen" policy (tmux alt screen has no scrollback).
const RENDERER_OPTIONS = {
  fontFamily:
    "'SF Mono', 'Cascadia Code', 'Consolas', 'Liberation Mono', monospace",
  fontSize: 13,
  scrollback: 10000,
  altScreen: false,
};

// ── Terminal engine factory ─────────────────────────────────────────
//
// createTerminal({ node, session, host, renderer }) →
//   { core, document, dispose(), openCommandMenu(), refreshViewToggle(),
//     showInputBar(), twoPullMove(), twoPullEnd(), test }
//
//   session    tmux session name to attach to.
//   node       remote node name (#176) — every PTY/tmux call carries
//              ?node=<name> so the hub proxies it over SSH. "" ⇒ local.
//   host       element containing the terminal scaffold (#terminal,
//              #touchOverlay, #loadquote, #cmdPickList, #inputBar, …). The
//              engine binds all its DOM inside this subtree.
//   renderer   'xterm' (default) | 'sterk'; the matching vendor bundle must
//              already be loaded (window.Terminal / window.Sterk).
//   build      the SPA's own loaded-bundle hash — ridden through to the WS URL
//              (&build=<hash>) purely so a stale tab identifies itself in the
//              server attach log. Never affects routing.
//   viewToggle optional opaque hooks the owner supplies to wire the reader
//              toggle affordances (ribbon + desktop top bar): `{ toggle(),
//              isReader() }`. The engine renders and reflects the buttons but
//              has no knowledge of what a "reader" is — view state lives in the
//              owner (the SPA's TerminalIsland, issue #206 D3). Absent ⇒ no
//              reader toggle is shown and the engine runs standalone.
//   readToggle the same contract for read mode (#235): `{ toggle(), isRead()
//              }`. A second button rather than a third state on the reader
//              toggle, so read mode is one tap from either other view. The
//              engine knows no more about a "read mode" than it does about a
//              reader. Absent ⇒ no read-mode button is shown.
//
// The engine used to be a self-booting module: it read window.MOBUX_* at
// eval time, so a second (node, session) in the same document silently kept
// the FIRST target — the "session not found"/wrong-tmux bug class (#185,
// #188). Config now arrives as arguments and every side effect the engine
// attaches (WebSocket, renderer instance, window/document/visualViewport
// listeners, timers, observers) is registered for teardown, so dispose() +
// createTerminal() is a real remount.
export function createTerminal({
  node = "",
  session,
  host,
  renderer,
  build = "",
  viewToggle = null,
  readToggle = null,
} = {}) {
  const $ = (id) => host.querySelector(`#${id}`);

  const nodeQuery = () => (node ? `?node=${encodeURIComponent(node)}` : "");

  const termEl = $("terminal");
  const overlay = $("touchOverlay");
  const loadquote = $("loadquote");
  const paneIndicator = $("paneIndicator");
  const cmdPickList = $("cmdPickList");
  const cmdOverlayBg = $("cmdOverlayBg");
  const cmdCloseBtn = $("cmdCloseBtn");

  // Every teardown is registered here; dispose() drains it. `on`/`later`/
  // `every` are the tracked variants of addEventListener/setTimeout/
  // setInterval.
  let disposed = false;
  const cleanups = [];
  const on = (target, type, fn, opts) => {
    target.addEventListener(type, fn, opts);
    cleanups.push(() => target.removeEventListener(type, fn, opts));
  };
  // Both no-op after dispose: a straggler event (an in-flight WS message, a
  // resolving fetch) must not create new timers — `cleanups` has already
  // been drained, so anything registered now would never be torn down.
  const later = (fn, ms) => {
    if (disposed) return;
    const t = setTimeout(() => {
      if (!disposed) fn();
    }, ms);
    cleanups.push(() => clearTimeout(t));
  };
  const every = (fn, ms) => {
    if (disposed) return;
    const t = setInterval(fn, ms);
    cleanups.push(() => clearInterval(t));
  };

  {
    const [text, author] = quotes[Math.floor(Math.random() * quotes.length)];
    const quoteEl = $("quote");
    const qauthorEl = $("qauthor");
    if (quoteEl) quoteEl.textContent = text;
    if (qauthorEl) qauthorEl.textContent = "— " + author;
  }

  // ── External links ──────────────────────────────────────────────────
  // navigateToUrl/openExternal live in external-link.js (shared with
  // mic-overlay.js's fault "Report issue" link) — see that file for the TWA
  // intent:// rationale. Expose for tests (mirrors `window.__mobuxView` etc.).
  window.__mobuxNavigateToUrl = navigateToUrl;
  window.__mobuxOpenExternal = openExternal;
  window.__mobuxIsExternalUrl = isExternalUrl;
  // Route any anchor to another origin (reader hints, future rendered
  // links) out of the app shell. Idempotent with the SPA's own install.
  installExternalLinkHandler();

  // ── 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;
  // The engine is renderer-agnostic; it drives whichever adapter the host
  // selected (the vendor bundle is already loaded). All renderer-specific
  // code lives inside the two adapter factories.
  const rendererImpl =
    renderer === "sterk"
      ? createSterkRenderer(termEl, RENDERER_OPTIONS)
      : createXtermRenderer(termEl, RENDERER_OPTIONS);
  const core = new TerminalEngine({
    session,
    node,
    host: termEl,
    renderer: rendererImpl,
    build,
  });

  // Apply the stored theme to all three layers. The renderer applied its boot
  // palette at construction; this call pushes the --ansi-* vars onto #reader
  // for tokenized reader output and (re)applies the palette through the
  // renderer interface — no reach-through into renderer internals.
  applyTheme(getStoredThemeId(), { engine: core });

  // Live swap when the settings picker changes the theme in this document.
  // The picker dispatches `mobux:theme`; prefs.js dispatches `mobux:prefschange`
  // for every preference write, so honour a theme change from either.
  function onThemeChange() {
    applyTheme(getStoredThemeId(), { engine: core });
  }
  on(window, "mobux:theme", onThemeChange);
  on(window, "mobux:prefschange", (e) => {
    if (e.detail?.key === "theme") onThemeChange();
  });

  // ── Renderer events (R13 links) ──────────────────────────────────────
  // The renderer detects URL activations; the UI owns the response — a
  // clicked link leaves the app shell (system browser in the TWA). The
  // renderer also detects terminal bells (R14, `core.onBell`), but the UI
  // must not react to them here: a raw client-side BEL ignores notification
  // prefs entirely. The chime is driven only by the server's gated push path
  // (`prefs.bell` in src/main.rs → `chime.js`'s service-worker listener).
  const linkSub = core.onLink((uri) => openExternal(uri));
  cleanups.push(() => linkSub?.dispose?.());

  // 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})`;
    }
  }
  on(core, "panes", () => {
    if (disposed) return;
    updatePaneUI();
  });

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

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

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

  // Two-finger pull-to-reload feedback. Shared with the reader: the SPA hands
  // these to the reader's own recognizer, so the thresholds and the indicator
  // strings live in exactly one place.
  function twoPullMove(pull, vh) {
    if (pull > vh * 0.08) paneIndicator.textContent = "↻ Release to reload";
    else if (pull > vh * 0.03)
      paneIndicator.textContent = "↓ Pull to reload...";
  }
  function twoPullEnd(pull, vh) {
    if (pull > vh * 0.08) location.reload(true);
    else updatePaneUI();
  }

  const gestures = 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: twoPullMove,
    onTwoPullEnd: twoPullEnd,

    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,
  });

  // ── 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.
  //
  // The mobile input bar (ribbon) does NOT share this trigger (#201). #198
  // made splash dismissal reveal the ribbon too, but that left it popping up
  // the instant an attached session produced any output and then sitting
  // pinned at the bottom of an already-familiar session for the rest of the
  // read — the same "showed once, now permanently in the way" problem #198
  // was trying to avoid, just moved earlier. Splash dismissal is not an
  // input event; the ribbon stays hidden through it and only reveals on
  // actual engagement (tap-to-focus — see the `onDoubleTap` handlers below
  // and in the reader's gesture wiring), same as it already hides on
  // keyboard dismissal (input-bar.js's visualViewport handler).
  let revealScheduled = false;
  function scheduleReveal() {
    if (disposed || revealScheduled) return;
    if (!loadquote || !loadquote.parentNode) return;
    revealScheduled = true;
    later(() => {
      core.scrollToBottom();
      loadquote.style.opacity = "0";
      later(() => {
        if (loadquote.parentNode) loadquote.remove();
      }, 300);
    }, 200);
  }
  on(core, "data", scheduleReveal);

  // A quiet session (already sitting at its prompt) never fires `data`, so
  // the splash would otherwise stick forever. Back it up with a timeout that
  // starts once a connection actually opens — a chatty session still reveals
  // on its first `data` event well before this fires; this only catches the
  // silent case. `scheduleReveal` is idempotent, so racing with the data path
  // is harmless.
  const REVEAL_FALLBACK_MS = 1500;
  on(core, "open", () => {
    later(scheduleReveal, REVEAL_FALLBACK_MS);
  });

  // ── Mobile input bar ────────────────────────────────────────────────
  // `isMobile` is a one-shot guess at mount time. It can be wrong: a device
  // may mount 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, (d) => core.send(d), node);
    }
    return inputBar;
  }

  // If we already look like a touch device, mount eagerly so the mic button
  // (and the full control-key ribbon) exist and are wired from the start —
  // but stay hidden. The bar only reveals on engagement: the `onDoubleTap`
  // handlers below (xterm overlay; the reader via showInputBar) call
  // `ensureInputBar().show()`,
  // which is the only path that unhides it (#201). Mounting without revealing
  // keeps `ensureInputBar()` idempotent and the mic button wired the moment a
  // tap asks for it, without popping the bar — or the soft keyboard — on load.
  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) {
      on(coarse, "change", onPointerChange);
    } else if (coarse.addListener) {
      coarse.addListener(onPointerChange);
      cleanups.push(() => coarse.removeListener(onPointerChange));
    }
  } catch (_) {
    /* matchMedia unsupported: lazy creation on tap still covers us */
  }

  // ── Reader toggle affordances ───────────────────────────────────────
  // View state (which view is showing, mount/unmount, per-window persistence)
  // lives in the owner (the SPA's TerminalIsland, issue #206 D3). The engine
  // only renders the toggle buttons and reflects their label; `viewToggle` is
  // opaque — the engine has no knowledge of a reader. Absent ⇒ no toggle.
  function refreshViewToggle() {
    const isReader = !!viewToggle?.isReader?.();
    const btn = $("viewToggleBtn");
    if (btn) {
      if (!viewToggle) {
        btn.hidden = true;
      } else {
        btn.hidden = false;
        btn.textContent = isReader ? "▣" : "📖";
        btn.title = isReader
          ? "Switch to terminal view"
          : "Switch to reader view";
      }
    }
    const readBtn = $("readModeBtn");
    if (readBtn) {
      if (!readToggle) {
        readBtn.hidden = true;
      } else {
        readBtn.hidden = false;
        const isRead = !!readToggle.isRead?.();
        readBtn.textContent = isRead ? "▣" : "💬";
        readBtn.title = isRead
          ? "Switch to terminal view"
          : "Switch to read mode";
      }
    }
    topBar?.sync?.();
  }

  // Ribbon view-toggle button (mobile input bar).
  const viewToggleBtn = $("viewToggleBtn");
  if (viewToggleBtn) {
    if (viewToggle) {
      on(viewToggleBtn, "mousedown", (e) => e.preventDefault());
      on(viewToggleBtn, "click", (e) => {
        e.preventDefault();
        viewToggle.toggle();
      });
    } else {
      viewToggleBtn.hidden = true;
    }
  }

  // Ribbon read-mode button (mobile input bar).
  const readModeBtn = $("readModeBtn");
  if (readModeBtn) {
    if (readToggle) {
      on(readModeBtn, "mousedown", (e) => e.preventDefault());
      on(readModeBtn, "click", (e) => {
        e.preventDefault();
        readToggle.toggle();
      });
    } else {
      readModeBtn.hidden = true;
    }
  }

  // ── 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: viewToggle ? () => viewToggle.toggle() : null,
      isReader: () => !!viewToggle?.isReader?.(),
      toggleRead: readToggle ? () => readToggle.toggle() : null,
      isRead: () => !!readToggle?.isRead?.(),
      node,
    });
  }
  if (!isMobile) ensureTopBar();
  try {
    const coarse = window.matchMedia("(pointer: coarse)");
    const onCoarse = (e) => {
      if (e.matches && topBar) {
        topBar.destroy();
        topBar = null;
      }
    };
    if (coarse.addEventListener) {
      on(coarse, "change", onCoarse);
    } else if (coarse.addListener) {
      coarse.addListener(onCoarse);
      cleanups.push(() => coarse.removeListener(onCoarse));
    }
  } catch (_) {
    /* matchMedia unsupported: static gate still covers us */
  }

  // ── Command menu control (exposed for the reader's gestures) ─────────
  // The reader runs in its own component; when its long-press / swipe-up
  // gesture wants the tmux menu, the owner routes it here through the handle
  // so there is one command-menu implementation, not a duplicate in the
  // reader.
  const openCommandMenu = showCmdList;

  // Engine test surface. Renderer-agnostic probes the conformance suite drives
  // against BOTH adapters, plus the inject helpers the reader/view tests use.
  // The owner (SPA) assembles `window.__mobuxView` from this and the reader
  // handle; the engine no longer self-wires a global (issue #206 D3).
  const testApi = {
    // Test injections close the WS first so tmux can't race/clobber the
    // injected content (e.g. by re-asserting alt-screen mode). Mark the close
    // intentional so auto-reconnect doesn't reopen the WS.
    inject: (str) => {
      core.intentionalClose = true;
      try {
        core.ws?.close();
      } catch (_) {}
      return core.write("\x1b[?1049l" + str.replace(/\n/g, "\r\n"));
    },
    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 core.write(s);
    },
    // 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 core.write(s);
    },
    bufferLength: () => core.getActiveBuffer().length,
    isAlternate: () => core.isAlternateScreenActive(),
    terminalRows: () => core.rows,
    cols: () => core.cols,
    rows: () => core.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.
    forceDrop: () => {
      core.intentionalClose = false;
      try {
        core.ws?.close();
      } catch (_) {}
    },
    oscDetected: () => !!core.oscDetected,
    switchWindow: (dir) => core.switchWindow(dir),

    // ── Renderer-interface conformance hooks ──────────────────────
    // Renderer-agnostic probes the conformance suite (test/conformance
    // .spec.cjs) drives against BOTH adapters through the engine. They go
    // through the engine's interface passthroughs — never a renderer global.
    writeData: (str) => core.write(str),
    awaitBufferChange: () =>
      new Promise((resolve) => {
        const sub = core.onBufferChanged(() => {
          sub.dispose();
          resolve();
        });
      }),
    // ── Selection / links / bell probes (R12–R14) ─────────────────
    getSelection: () => core.getSelection(),
    hasSelection: () => core.hasSelection(),
    clearSelection: () => core.clearSelection(),
    selectAll: () => core.selectAll(),
    awaitSelectionChange: () =>
      new Promise((resolve) => {
        const sub = core.onSelectionChange(() => {
          sub.dispose();
          resolve();
        });
      }),
    awaitLink: () =>
      new Promise((resolve) => {
        const sub = core.onLink((uri) => {
          sub.dispose();
          resolve(uri);
        });
      }),
    awaitBell: () =>
      new Promise((resolve) => {
        const sub = core.onBell(() => {
          sub.dispose();
          resolve();
        });
      }),
    measure: () => core.measure(),
    cellMetrics: () => core.cellSize(),
    scrollLines: (n) => core.scrollLines(n),
    getFontSize: () => core.getFontSize(),
    setFontSize: (px) => core.setFontSize(px),
    focus: () => core.focus(),
    setNativeInputEnabled: (enabled) => core.setNativeInputEnabled(enabled),
    lineText: (y) => {
      const line = core.getActiveBuffer().getLine(y);
      return line ? line.translateToString(true) : null;
    },
    cellInfo: (y, x) => {
      const line = core.getActiveBuffer().getLine(y);
      if (!line) return null;
      const cell = line.getCell(x);
      return {
        chars: cell.getChars(),
        code: cell.getCode(),
        bold: cell.isBold(),
        fgDefault: cell.isFgDefault(),
        bgDefault: cell.isBgDefault(),
      };
    },
    oscMarkerCount: () => core.oscMarkers.size,
  };

  refreshViewToggle();

  // ── 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;
    fetch(
      u(
        `api/sessions/${encodeURIComponent(session)}/panes/${encodeURIComponent(windowIndex)}/select${nodeQuery()}`,
      ),
      { method: "POST" },
    )
      .then(() => {
        core.clear();
        core.scrollToBottom();
        later(() => {
          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) {
    on(navigator.serviceWorker, "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();
    if (disposed) return;
    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.
      later(() => selectWindow(w), 500);
    }
  })();

  on(window, "resize", () => core.resize());
  later(() => core.resize(), 100);
  every(() => 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 || disposed) return;
    core.reconnect();
  }

  // Primary path: screen/tab is visible again → reconnect now.
  on(document, "visibilitychange", () => {
    if (document.visibilityState === "visible") autoReconnect();
  });
  // Network came back.
  on(window, "online", autoReconnect);
  // Android bfcache restore (app swapped back into the foreground).
  on(window, "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.
  on(window, "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"));
      }
    };
    on(vv, "resize", trackKeyboard);
    on(vv, "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;

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

    on(termEl, "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.
    on(termEl, "pointercancel", () => {
      tracking = false;
    });
  }

  function dispose() {
    if (disposed) return;
    disposed = true;
    // No backoff reconnect out of the teardown's own ws.close().
    core.intentionalClose = true;
    gestures.destroy();
    if (topBar) {
      topBar.destroy();
      topBar = null;
    }
    if (inputBar) {
      inputBar.destroy();
      inputBar = null;
    }
    for (const fn of cleanups.splice(0)) {
      try {
        fn();
      } catch (_) {}
    }
    core.dispose();
    // The keyboard tracker may have pinned an inline body height.
    document.body.style.height = "";
  }

  return {
    core,
    document: core.document,
    dispose,
    openCommandMenu,
    refreshViewToggle,
    // Reveal the on-screen input bar (the reader's double-tap-to-type path
    // routes here so the keyboard has somewhere to land).
    showInputBar: () => ensureInputBar().show(),
    // Two-finger pull-to-reload handlers, shared with the reader's recognizer
    // so the thresholds, strings, and pane-indicator restore live once.
    twoPullMove,
    twoPullEnd,
    test: testApi,
  };
}