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;
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");
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",
],
[
"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;
}
function navigateToUrl(url) {
window.location.assign(url);
}
function openExternal(url) {
const isTWA = document.referrer.startsWith("android-app://");
if (isTWA && /^https?:\/\//.test(url)) {
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;
}
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();
}
window.__mobuxNavigateToUrl = navigateToUrl;
window.__mobuxOpenExternal = openExternal;
const isMobile =
window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 620;
const core = new TerminalCore({ session, host: termEl });
const getEditor = () => core.term?._sterk?.renderer?.getEditor?.();
applyTheme(getStoredThemeId(), { editor: getEditor() });
function onThemeChange() {
applyTheme(getStoredThemeId(), { editor: getEditor() });
}
window.addEventListener("storage", (e) => {
if (e.key === "mobux:theme") onThemeChange();
});
window.addEventListener("mobux:theme", onThemeChange);
if ("ontouchstart" in window || navigator.maxTouchPoints > 0) {
overlay.style.pointerEvents = "auto";
}
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();
});
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);
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) {
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() {
ensureInputBar().show();
},
onHSwipe: (dir) => core.switchWindow(dir),
onLongPress: showCmdList,
onSwipeUp: showCmdList,
});
let readerGestures = null;
function mountReaderGestures() {
if (readerGestures) return;
readerGestures = createGestureRecognizer(
readerEl,
{
onReconnect: () => core.reconnect(),
onLongPress: showCmdList,
onSwipeUp: showCmdList,
onHSwipe: (dir) => core.switchWindow(dir),
onTap: () => {},
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;
}
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);
let inputBar = null;
function ensureInputBar() {
if (!inputBar) {
inputBar = createInputBar(core.term, (d) => core.send(d));
}
return inputBar;
}
if (isMobile) {
ensureInputBar();
}
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 (_) {
}
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");
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 });
}
const viewToggleBtn = document.getElementById("viewToggleBtn");
if (viewToggleBtn) {
viewToggleBtn.addEventListener("mousedown", (e) => e.preventDefault());
viewToggleBtn.addEventListener("click", (e) => {
e.preventDefault();
swapView(currentView === "xterm" ? "reader" : "xterm");
});
}
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 (_) {
}
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: {
inject: (str) => {
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));
},
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));
},
readerAwaitRender: () => reader.awaitNextRender(),
bufferLength: () => core.getActiveBuffer().length,
isAlternate: () => {
if (core.term?._sterk?.buffer) {
return (
core.term._sterk.buffer.alternate === core.term._sterk.buffer.active
);
}
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,
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(),
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,
},
};
const bootDefault = storedDefaultView();
if (bootDefault === "reader") {
setTimeout(() => applyView("reader", { persist: false }), 0);
}
updateToggleLabel();
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));
}
});
}
let booted = false;
(async () => {
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; }
}
await core.reloadHistory();
core.connect();
booted = true;
const w = windowFromUrl(location.href);
if (w != null) {
setTimeout(() => selectWindow(w), 500);
}
})();
window.addEventListener("resize", () => core.resize());
setTimeout(() => core.resize(), 100);
setInterval(() => core.refreshPanes(), 5000);
function autoReconnect() {
if (!booted) return;
core.reconnect();
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") autoReconnect();
});
window.addEventListener("online", autoReconnect);
window.addEventListener("pageshow", autoReconnect);
window.addEventListener("pagehide", () => {
core.intentionalClose = true;
});
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();
});
}
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;
window.dispatchEvent(new Event("resize"));
}
};
vv.addEventListener("resize", trackKeyboard);
vv.addEventListener("scroll", trackKeyboard);
}
{
const TAP_MOVE_PX = 10; const TAP_MAX_MS = 250; 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();
}
});
termEl.addEventListener("pointercancel", () => {
tracking = false;
});
}