mobux 0.46.0

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
import { useRef, useLayoutEffect } from "preact/hooks";
import { collectDiagnostics } from "../lib/diagnostics.js";
import { buildIssueUrl } from "../lib/githubIssue.js";
import { getPref } from "../lib/prefs.js";
import { readLoadedBundleHash } from "../lib/bundleHash.js";
import { createViewController } from "../lib/viewController.js";
import { u } from "../lib/base.js";

// ── Terminal island ──────────────────────────────────────────────────
//
// Hosts the mobux terminal engine (`/static/terminal.js`) as a real
// component. The engine exports `createTerminal({ node, session, host,
// renderer })` → `{ dispose() }`: config goes in as arguments (no window
// globals), and dispose() tears down everything the engine attached —
// WebSocket, renderer instance, window/document listeners, timers.
//
// The island's job:
//   1. Render the DOM scaffold the engine binds to (#terminal, #reader,
//      #loadquote, the #inputBar ribbon, the #cmdPickList overlay, …).
//   2. Load the vendor bundles (once per document), then create
//      the engine in an effect and dispose it on unmount. A route-param
//      change is a clean dispose + create — no document reload (#188), and
//      the new engine attaches to exactly the (node, session) in the URL
//      (the #185 regression class).
//
// `CACHE_BUST` mirrors the old Rust page's `?v=` query-param convention on
// <script>/<link> tags, but it's a fixed string — it does no cache-busting
// of its own. What actually guarantees a stale cache never hands back an
// old bundle is `serve_static` in src/main.rs serving every /static/* asset
// `no-store, must-revalidate`; the query param is vestigial and harmless.
const CACHE_BUST = "spa";

// Ribbon bug-report button (#191): grab the current diagnostics bundle and
// open a prefilled GitHub issue in a new tab. Reuses the same bundle/URL
// builder as the fail-hard error page (lib/diagnostics.js, lib/githubIssue.js).
//
// On desktop the tab MUST be opened synchronously inside the click gesture:
// awaiting the diagnostics fetches first loses the user activation, and popup
// blockers (Android Chrome) silently kill the window.open. So open about:blank
// while the gesture is live, then steer it to the issue URL once the bundle
// resolves. `noopener` can't be passed as a window feature here (it makes
// window.open return null, losing the handle), so sever the opener by hand
// while the tab is still same-origin.
//
// In the TWA that placeholder tab would open inside the app shell. There the
// intent:// escape (window.__mobuxOpenExternal, set by the engine) leaves the
// shell and needs no window handle, so skip the popup entirely.
async function openBugReport() {
  const isTWA = document.referrer.startsWith("android-app://");
  const win = isTWA ? null : window.open("about:blank", "_blank");
  if (win) win.opener = null;
  const diagnostics = await collectDiagnostics();
  const url = buildIssueUrl({ title: "Bug report", diagnostics });
  const openExternal = window.__mobuxOpenExternal;
  if (isTWA && typeof openExternal === "function") {
    openExternal(url);
    return;
  }
  if (win) {
    win.location = url;
    return;
  }
  // Popup blocked anyway — fall back to navigating this tab.
  window.location.href = url;
}

// Append a classic <script> and resolve when it loads. Deduped per document:
// the vendor bundles pin window globals (window.Terminal / window.Sterk) and
// chime.js self-boots, so each URL loads exactly once no matter how many
// times the island mounts.
const loadedScripts = new Map();
function loadScript(src) {
  if (!loadedScripts.has(src)) {
    loadedScripts.set(
      src,
      new Promise((resolve, reject) => {
        const el = document.createElement("script");
        el.src = src;
        el.async = false; // preserve execution order
        el.onload = () => resolve();
        el.onerror = () => {
          loadedScripts.delete(src); // a later mount may retry
          reject(new Error(`failed to load ${src}`));
        };
        document.body.appendChild(el);
      }),
    );
  }
  return loadedScripts.get(src);
}

function ensureStylesheet(href) {
  if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return;
  const link = document.createElement("link");
  link.rel = "stylesheet";
  link.href = href;
  document.head.appendChild(link);
}

export function TerminalIsland({ node, session }) {
  const rootRef = useRef(null);
  const resizeObsRef = useRef(null);

  useLayoutEffect(() => {
    // The engine attaches to exactly this (node, session) — both come from
    // the route (`#/s/<node>/<name>`), never from the device's selected-node
    // preference, so a stale selection can't re-target a session URL (#185).
    // No node segment ⇒ local host.
    let cancelled = false;
    let engine = null;
    let viewCtl = null;

    // Resolve the renderer choice from the server-held preference (hydrated at
    // boot by main.jsx), then load the vendor bundles + css (once per
    // document) before constructing the engine. The headless xterm bundle
    // always loads: it parses the engine's text buffer under either renderer.
    const renderer = getPref("renderer") === "sterk" ? "sterk" : "xterm";

    const v = `?v=${CACHE_BUST}`;
    const bundles = [
      "xterm-headless.bundle.js",
      renderer === "sterk" ? "sterk.bundle.js" : "xterm.bundle.js",
    ];

    if (renderer === "xterm") {
      ensureStylesheet(u(`/static/vendor/xterm.css${v}`));
    }

    (async () => {
      let createTerminal;
      let createReader;
      let createReadMode;
      try {
        await Promise.all(
          bundles.map((b) => loadScript(u(`/static/vendor/${b}${v}`))),
        );
        // The engine and reader modules are pure factory exports (no side
        // effects), so the browser's module-map caching is exactly right:
        // first mount fetches them, every later mount reuses them.
        ({ createTerminal } = await import(
          /* @vite-ignore */ u(`/static/terminal.js${v}`)
        ));
        ({ createReader } = await import(
          /* @vite-ignore */ u(`/static/reader.js${v}`)
        ));
        ({ createReadMode } = await import(
          /* @vite-ignore */ u(`/static/read-mode.js${v}`)
        ));
        // chime.js sets up the in-page bell that plays when the SW delivers a
        // push notification. It self-boots via IIFE (attaches to SW messages),
        // so loading it once is enough; it also guards itself via the global
        // it exposes.
        if (!window.__mobuxChime) {
          await loadScript(u(`/static/chime.js${v}`));
        }
      } catch (e) {
        // Surface boot failure in the loading splash rather than a blank page.
        const q = rootRef.current?.querySelector("#quote");
        if (q) q.textContent = `Terminal failed to load: ${e.message}`;
        return;
      }
      if (cancelled || !rootRef.current) return;

      // The engine renders the view toggle affordances but owns no view
      // state (#206 D3); it calls back into these opaque hooks, which the
      // controller (created just below) fulfils.
      //
      // 📖 is the terminal↔reader toggle: from read mode it goes to the
      // reader, which is what its icon says. 💬 is read mode's own button,
      // so read mode is one tap away from either other view (#235).
      const viewToggle = {
        toggle: () =>
          viewCtl?.swap(viewCtl.current === "reader" ? "xterm" : "reader"),
        isReader: () => viewCtl?.current === "reader",
      };
      const readToggle = {
        toggle: () =>
          viewCtl?.swap(viewCtl.current === "read" ? "xterm" : "read"),
        isRead: () => viewCtl?.current === "read",
      };

      engine = createTerminal({
        node: node || "",
        session,
        host: rootRef.current,
        renderer,
        // Diagnostic only: rides to the WS URL as &build=<hash> so a stale tab
        // identifies itself in the server's attach log (never affects routing).
        build: readLoadedBundleHash() || "",
        viewToggle,
        readToggle,
      });

      // The reader and read mode are sibling components mounted next to the
      // terminal; the controller owns swap / mount / persistence / per-window
      // state.
      viewCtl = createViewController({
        root: rootRef.current,
        session,
        terminal: engine,
        createReader,
        createReadMode,
      });

      // Assemble the page's test surface from the factory handles. The engine
      // no longer self-wires a global (#206 D3); tests drive the handles.
      const { reader, readMode } = viewCtl;
      window.__mobuxView = {
        swap: (mode) => viewCtl.swap(mode),
        get current() {
          return viewCtl.current;
        },
        send: (d) => engine.core.send(d),
        test: {
          ...engine.test,
          readerAwaitRender: () => reader.awaitNextRender(),
          readerForceRender: () => reader.forceRender(),
          readerAtBottom: () => reader.atBottom,
          readerForceScrollTop: () => reader.forceScrollTop(),
          readerScrollY: () => reader.scrollY,
          readerMaxScroll: () => reader.maxScroll,
          readerInnerHeight: () => reader.innerHeight,
          readerScrollBy: (dy) => reader.scrollBy(dy),
          readerStickToBottom: () => reader.stickToBottom(),
          statusBarOffsetHeight: () => reader.statusBarOffsetHeight(),
          statusBarFilled: () => reader.statusBarFilled(),
          readModeMounted: () => readMode.mounted,
          readModeRefreshNow: () => readMode.refreshNow(),
          readModeScrollBy: (dy) => readMode.scrollBy(dy),
          readModeScrollY: () => readMode.scrollY,
          readModeMaxScroll: () => readMode.maxScroll,
          readModeEntryCount: () => readMode.entryCount,
          readModeHasOlder: () => readMode.hasOlder,
          readModeLoadOlder: () => readMode.loadOlderNow(),
          // The tmux window the controller keys per-window view state on. The
          // panes API is the server's answer; this is the client's, and only
          // this one has taken the `panes` event.
          activeWindowId: () =>
            engine.core.panes[engine.core.activeIndex]?.id || null,
        },
      };

      // Force a resize once the SPA layout has actually painted. The engine
      // sizes the PTY (cols/rows) from the host element's clientHeight, and it
      // does its own resize at 0ms/100ms after boot — but in the SPA the
      // engine boots while Preact's island subtree is still settling its flex
      // height, so that early measurement can read a too-short host and the
      // backend computes far too few rows (the stranded-status-bar /
      // dead-black bug). The engine already listens on window `resize` →
      // core.resize(), so we reuse that machinery: re-fire a synthetic resize
      // after a double-rAF (one full painted frame later) and again after the
      // host's box settles, via a ResizeObserver, so the initial row count is
      // correct without a rotate/keyboard nudge.
      const kick = () => window.dispatchEvent(new Event("resize"));
      requestAnimationFrame(() => requestAnimationFrame(kick));

      const host = rootRef.current?.querySelector("#terminal");
      if (host && "ResizeObserver" in window) {
        let last = 0;
        const ro = new ResizeObserver(() => {
          const h = host.clientHeight;
          if (h && h !== last) {
            last = h;
            kick();
          }
        });
        ro.observe(host);
        resizeObsRef.current = ro;
      }
    })();

    return () => {
      cancelled = true;
      resizeObsRef.current?.disconnect();
      resizeObsRef.current = null;
      if (window.__mobuxView) delete window.__mobuxView;
      viewCtl?.dispose();
      viewCtl = null;
      engine?.dispose();
      engine = null;
    };
    // A (node, session) change is a full dispose + create. TerminalPage also
    // keys the island on the pair, so in practice Preact remounts the whole
    // scaffold (fresh splash, fresh #terminal); these deps are the backstop.
  }, [node, session]);

  // The engine binds to these ids (scoped to this subtree via the `host`
  // factory argument); we render them once per mount and hand the subtree
  // to the engine.
  return (
    <div ref={rootRef} class="term-body-spa">
      <div id="terminal" />
      <div id="reader" class="hidden" />
      <div id="readmode" class="hidden" />
      <div id="loadquote">
        <q id="quote" />
        <br />
        <cite id="qauthor" />
      </div>
      <div id="touchOverlay" />
      <div id="paneIndicator" />
      <div id="cmdOverlayBg" />
      <div
        id="cmdPickList"
        role="dialog"
        aria-modal="true"
        aria-labelledby="cmdSheetTitle"
      >
        <div class="cmd-handle" aria-hidden="true" />
        <div class="cmd-header">
          <h3 id="cmdSheetTitle">tmux</h3>
          <button
            type="button"
            class="cmd-close"
            id="cmdCloseBtn"
            aria-label="Close"
          >
            ✕
          </button>
        </div>
        <section class="cmd-group" aria-labelledby="cmdGroupWindows">
          <h4 class="cmd-group-title" id="cmdGroupWindows">
            Windows
          </h4>
          <button class="cmd-item" data-cmd="new-window">
            New window
          </button>
          <button class="cmd-item" data-cmd="next-window">
            Next window
          </button>
          <button class="cmd-item" data-cmd="prev-window">
            Previous window
          </button>
          <button class="cmd-item" data-cmd="kill-window">
            Close window
          </button>
        </section>
        <section class="cmd-group" aria-labelledby="cmdGroupPanes">
          <h4 class="cmd-group-title" id="cmdGroupPanes">
            Panes
          </h4>
          <button class="cmd-item" data-cmd="next-pane">
            Next pane
          </button>
          <button class="cmd-item" data-cmd="prev-pane">
            Previous pane
          </button>
          <button class="cmd-item" data-cmd="zoom-pane">
            Zoom pane
          </button>
          <button class="cmd-item" data-cmd="kill-pane">
            Close pane
          </button>
        </section>
        <section class="cmd-group">
          <button type="button" class="cmd-item" id="cmdSettingsBtn">
            <span class="cmd-glyph" aria-hidden="true">
              {"\u2699\uFE0E"}
            </span>
            Settings
          </button>
        </section>
      </div>

      <div id="inputBar" class="input-bar hidden">
        <div id="inputRibbon" class="input-ribbon">
          <button id="viewToggleBtn" title="Toggle reader/terminal view">
            📖
          </button>
          <button id="readModeBtn" title="Switch to read mode">
            💬
          </button>
          <button id="uploadBtn" title="Attach file">
            📎
          </button>
          <button id="micBtn" title="Dictate (speech to text)">
            🎤
          </button>
          <button id="settingsBtn" title="Settings">
            ⚙
          </button>
          {/* Single-action hard reload (#189) — no data-key, so it's ignored
              by the engine's ribbon delegation (input-bar.js only intercepts
              `button[data-key]`); the click is handled directly by Preact. */}
          <button
            id="reloadBtn"
            type="button"
            title="Reload app"
            onClick={() => location.reload()}
          >
            🔄
          </button>
          <button
            id="reportBugBtn"
            title="Report a bug"
            onMouseDown={(e) => e.preventDefault()}
            onClick={openBugReport}
          >
            🐛
          </button>
          <button data-key="\x7f">⌫</button>
          <button data-key="\r">⏎</button>
          <button data-key="\x1b[D">←</button>
          <button data-key="\x1b[C">→</button>
          <button data-key="\x1b[A">↑</button>
          <button data-key="\x1b[B">↓</button>
          <button data-key="\x03">^C</button>
          <button data-key="\x04">^D</button>
          <button data-key="\x1b">Esc</button>
          <button data-key="\t">Tab</button>
          <button data-key="\x1a">^Z</button>
          <button data-key="\x1b[3~">Del</button>
          <button data-key="\x1b[H">Home</button>
          <button data-key="\x1b[F">End</button>
          <button data-key="\x15">^U</button>
          <button data-key="\x0c">^L</button>
          <button data-key="/clear\r">/clear</button>
          <button data-key="/quit\r">/quit</button>
        </div>
        <div id="inputToast" class="mobux-attach-error" />
        <div class="input-row">
          <input
            id="inputText"
            type="text"
            enterkeyhint="send"
            placeholder="Type here…"
            autocomplete="off"
            autocorrect="on"
            autocapitalize="off"
            spellcheck={false}
          />
          <button id="inputSend" class="input-send" title="Send without Enter">
            ▶
          </button>
        </div>
      </div>
    </div>
  );
}