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";
const CACHE_BUST = "spa";
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;
}
window.location.href = url;
}
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; el.onload = () => resolve();
el.onerror = () => {
loadedScripts.delete(src); 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(() => {
let cancelled = false;
let engine = null;
let viewCtl = null;
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}`))),
);
({ createTerminal } = await import(
u(`/static/terminal.js${v}`)
));
({ createReader } = await import(
u(`/static/reader.js${v}`)
));
({ createReadMode } = await import(
u(`/static/read-mode.js${v}`)
));
if (!window.__mobuxChime) {
await loadScript(u(`/static/chime.js${v}`));
}
} catch (e) {
const q = rootRef.current?.querySelector("#quote");
if (q) q.textContent = `Terminal failed to load: ${e.message}`;
return;
}
if (cancelled || !rootRef.current) return;
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,
build: readLoadedBundleHash() || "",
viewToggle,
readToggle,
});
viewCtl = createViewController({
root: rootRef.current,
session,
terminal: engine,
createReader,
createReadMode,
});
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(),
activeWindowId: () =>
engine.core.panes[engine.core.activeIndex]?.id || null,
},
};
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;
};
}, [node, session]);
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>
{}
<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>
);
}