const session = window.MOBUX_SESSION;
const termEl = document.getElementById("terminal");
const overlay = document.getElementById("touchOverlay");
const isMobile = window.innerWidth < 620;
const term = new Terminal({
cursorBlink: true,
fontSize: isMobile ? 14 : 15,
convertEol: false,
scrollback: 10000,
theme: { background: "#0f1115" },
});
term.open(termEl);
Object.defineProperty(term._core.coreMouseService, 'activeProtocol', {
set() {},
get() { return 'NONE'; },
configurable: true,
});
const buffers = term._core._bufferService.buffers;
buffers.activateAltBuffer = () => {};
buffers.activateNormalBuffer = () => {};
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
overlay.style.pointerEvents = 'auto';
}
const wsProto = location.protocol === "https:" ? "wss" : "ws";
let ws;
(async () => {
const loadingEl = document.getElementById('loading');
ws = new WebSocket(`${wsProto}://${location.host}/ws/${encodeURIComponent(session)}`);
ws.binaryType = "arraybuffer";
let revealed = false;
function reveal() {
if (revealed) return;
revealed = true;
term.scrollToBottom();
requestAnimationFrame(() => { loadingEl?.remove(); });
}
ws.onopen = () => {
sendResize();
refreshPanes();
setTimeout(reveal, 300);
};
ws.onmessage = async (ev) => {
if (typeof ev.data === "string") term.write(ev.data);
else if (ev.data instanceof ArrayBuffer) term.write(new Uint8Array(ev.data));
else if (ev.data instanceof Blob) term.write(new Uint8Array(await ev.data.arrayBuffer()));
reveal();
};
ws.onclose = () => term.writeln("\r\n\x1b[31m[disconnected]\x1b[0m");
ws.onerror = () => term.writeln("\r\n\x1b[31m[connection error]\x1b[0m");
term.onData((d) => { if (ws.readyState === WebSocket.OPEN) ws.send(d); });
})();
function sendResize() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const cw = term._core._renderService.dimensions?.css?.cell?.width || 9;
const ch = term._core._renderService.dimensions?.css?.cell?.height || 18;
const tb = document.querySelector('.term-toolbar')?.offsetHeight || 0;
const cols = Math.max(20, Math.floor(window.innerWidth / cw) - 1);
const rows = Math.max(10, Math.floor((window.innerHeight - tb) / ch) - 1);
term.resize(cols, rows);
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
window.addEventListener("resize", sendResize);
setTimeout(sendResize, 100);
const paneIndicator = document.getElementById("paneIndicator");
let panes = [];
let activeIndex = 0;
async function refreshPanes() {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(session)}/panes`);
if (!res.ok) return;
panes = await res.json();
activeIndex = panes.findIndex((p) => p.active);
if (activeIndex < 0) activeIndex = 0;
updatePaneUI();
} catch (e) {}
}
function updatePaneUI() {
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})`;
}
}
async function selectPane(direction) {
if (panes.length <= 1) return;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("\x02" + (direction > 0 ? "n" : "p"));
setTimeout(refreshPanes, 300);
}
}
setInterval(refreshPanes, 5000);
{
const AMP = 2.5;
const TAP_PX = 8;
const TAP_MS = 300;
const DTAP_MS = 400; const VEL_WINDOW = 100; const MIN_VEL = 0.15; const DECEL = 0.0015; const MAX_MOM_MS = 2500; const IOS_DECAY = 0.998; const FLICK_H_PX = 50; const FLICK_H_VEL = 0.3;
const xtermEl = termEl.querySelector('.xterm') || termEl;
let startX, startY, startTime;
let lastY, lastTime;
let gesture; let pinchStartDist = 0;
let pinchStartFontSize = 0;
let posSamples; let momId = null;
let wasTwoFinger = false;
let lastTapTime = 0;
function wheel(dy) {
xtermEl.dispatchEvent(new WheelEvent('wheel', {
deltaY: dy, deltaMode: 0, bubbles: true, cancelable: true,
}));
}
function stopMom() {
if (momId !== null) { cancelAnimationFrame(momId); momId = null; }
}
function calcVelocity() {
if (posSamples.length < 2) return 0;
const last = posSamples[posSamples.length - 1];
const cutoff = last.t - VEL_WINDOW;
let i = posSamples.length - 1;
while (i > 0 && posSamples[i - 1].t >= cutoff) i--;
const first = posSamples[i];
const dt = last.t - first.t;
if (dt < 10) return 0;
return (first.y - last.y) / dt;
}
function momentum() {
const v0 = calcVelocity();
if (Math.abs(v0) < MIN_VEL) return;
const speed = Math.abs(v0);
const dir = v0 > 0 ? 1 : -1;
const totalMs = Math.min(MAX_MOM_MS, (speed * 2) / DECEL);
const t0 = performance.now();
let prevT = t0;
function tick(now) {
const elapsed = now - t0;
if (elapsed >= totalMs) return;
const dt = now - prevT;
prevT = now;
const vNow = speed * Math.pow(IOS_DECAY, elapsed);
if (vNow < 0.03) return;
wheel(vNow * dt * dir * AMP);
momId = requestAnimationFrame(tick);
}
momId = requestAnimationFrame(tick);
}
overlay.addEventListener('touchstart', (e) => {
stopMom();
if (e.touches.length === 2) {
const fdx = e.touches[0].pageX - e.touches[1].pageX;
const fdy = e.touches[0].pageY - e.touches[1].pageY;
pinchStartDist = Math.hypot(fdx, fdy);
pinchStartFontSize = term.options.fontSize;
startY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
gesture = 'two';
wasTwoFinger = true;
return;
}
if (e.touches.length !== 1) { gesture = null; return; }
wasTwoFinger = false;
const t = e.touches[0];
startX = t.pageX; startY = t.pageY;
lastY = t.pageY;
startTime = lastTime = performance.now();
gesture = 'tap';
posSamples = [{ y: t.pageY, t: startTime }];
});
overlay.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length === 2 && (gesture === 'two' || gesture === 'pinch' || gesture === 'twopull')) {
const fdx = e.touches[0].pageX - e.touches[1].pageX;
const fdy = e.touches[0].pageY - e.touches[1].pageY;
const dist = Math.hypot(fdx, fdy);
const midY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
const pull = midY - startY;
const distChange = Math.abs(dist - pinchStartDist);
const pinchThreshold = pinchStartDist * 0.25;
const scale = dist / pinchStartDist;
if (gesture === 'two') {
if (Math.abs(scale - 1.0) > 0.25) {
gesture = 'pinch'; } else if (Math.abs(pull) > 30) {
gesture = 'twopull'; }
if (gesture === 'two') return;
}
if (gesture === 'pinch') {
const scale = dist / pinchStartDist;
const newSize = Math.round(Math.max(8, Math.min(32, pinchStartFontSize * scale)));
if (newSize !== term.options.fontSize) {
term.options.fontSize = newSize;
sendResize();
}
}
if (gesture === 'two') {
gesture = null;
return;
}
if (gesture === 'twopull') {
if (pull > 60) {
paneIndicator.textContent = '\u21bb Release to reload';
} else if (pull > 20) {
paneIndicator.textContent = '\u2193 Pull to reload...';
}
}
return;
}
if (e.touches.length !== 1 || !gesture || wasTwoFinger) return;
const y = e.touches[0].pageY;
const now = performance.now();
posSamples.push({ y, t: now });
const trim = now - VEL_WINDOW * 2;
while (posSamples.length > 2 && posSamples[0].t < trim) posSamples.shift();
if (gesture === 'tap') {
const dx = Math.abs(e.touches[0].pageX - startX);
const dy = Math.abs(y - startY);
if (dy > TAP_PX && dy >= dx) {
gesture = 'scroll';
} else if (dx > TAP_PX && dx > dy) {
gesture = 'hswipe';
} else {
return;
}
}
if (gesture === 'scroll') {
const dy = lastY - y;
wheel(dy * AMP);
lastY = y;
lastTime = now;
}
}, { passive: false });
overlay.addEventListener('touchend', (e) => {
if (gesture === 'two') {
gesture = null;
return;
}
if (gesture === 'twopull') {
const endY = e.changedTouches[0]?.pageY ?? startY;
const dy = endY - startY;
if (dy > 60) {
location.reload(true);
} else {
updatePaneUI();
}
gesture = null;
return;
}
if (gesture === 'pinch') {
gesture = null;
return;
}
if (gesture === 'tap' && (performance.now() - startTime) < TAP_MS) {
const now = performance.now();
if (now - lastTapTime < DTAP_MS) {
overlay.style.pointerEvents = 'none';
setTimeout(() => { overlay.style.pointerEvents = 'auto'; }, 500);
const el = document.elementFromPoint(startX, startY);
if (el) {
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: startX, clientY: startY }));
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: startX, clientY: startY }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: startX, clientY: startY }));
}
lastTapTime = 0;
} else {
lastTapTime = now;
}
} else if (gesture === 'scroll') {
momentum();
} else if (gesture === 'hswipe') {
const endX = e.changedTouches[0]?.pageX ?? startX;
const dx = endX - startX;
const dt = performance.now() - startTime;
const vel = Math.abs(dx) / dt;
if (Math.abs(dx) > FLICK_H_PX || vel > FLICK_H_VEL) {
selectPane(dx < 0 ? 1 : -1);
}
}
gesture = null;
});
overlay.addEventListener('touchcancel', () => { stopMom(); gesture = null; });
}
{
const pickList = document.getElementById('cmdPickList');
const overlayBg = document.getElementById('cmdOverlayBg');
const cmdBtn = document.getElementById('cmdBtn');
const cmdCloseBtn = document.getElementById('cmdCloseBtn');
function showCmdList() {
pickList.classList.add('visible');
overlayBg.classList.add('visible');
}
function hideCmdList() {
pickList.classList.remove('visible');
overlayBg.classList.remove('visible');
}
async function runTmuxCmd(command) {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(session)}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command }),
});
if (!res.ok) throw new Error(await res.text());
setTimeout(refreshPanes, 300);
} catch (e) {
term.writeln(`\r\n\x1b[31m[tmux error: ${e.message}]\x1b[0m`);
}
}
cmdBtn?.addEventListener('click', showCmdList);
cmdCloseBtn?.addEventListener('click', hideCmdList);
overlayBg?.addEventListener('click', hideCmdList);
pickList?.addEventListener('click', (e) => {
const item = e.target.closest('[data-cmd]');
if (!item) return;
const cmd = item.dataset.cmd;
hideCmdList();
runTmuxCmd(cmd);
});
let longPressTimer = null;
const LONGPRESS_MS = 600;
const LONGPRESS_MOVE_PX = 12;
let lpStartX = 0, lpStartY = 0;
overlay.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) { clearTimeout(longPressTimer); longPressTimer = null; return; }
lpStartX = e.touches[0].pageX;
lpStartY = e.touches[0].pageY;
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (navigator.vibrate) navigator.vibrate(30);
showCmdList();
gesture = null;
}, LONGPRESS_MS);
}, { passive: true });
overlay.addEventListener('touchmove', (e) => {
if (longPressTimer !== null && e.touches.length === 1) {
const dx = Math.abs(e.touches[0].pageX - lpStartX);
const dy = Math.abs(e.touches[0].pageY - lpStartY);
if (dx > LONGPRESS_MOVE_PX || dy > LONGPRESS_MOVE_PX) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
}, { passive: true });
overlay.addEventListener('touchend', () => {
if (longPressTimer !== null) { clearTimeout(longPressTimer); longPressTimer = null; }
}, { passive: true });
overlay.addEventListener('touchcancel', () => {
if (longPressTimer !== null) { clearTimeout(longPressTimer); longPressTimer = null; }
}, { passive: true });
}
async function sendVoiceText(text) {
const res = await fetch(`/api/sessions/${encodeURIComponent(session)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(await res.text());
}
const micBtn = document.getElementById("micBtn");
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
function manualSendFallback() {
const text = prompt("Speech recognition unavailable. Type command to send:");
if (!text || !text.trim()) return;
sendVoiceText(text.trim()).catch((e) => alert(`Send failed: ${e.message}`));
}
if (!SR) {
micBtn.title = "SpeechRecognition not available — click for text fallback";
micBtn.addEventListener("click", manualSendFallback);
} else {
const rec = new SR();
rec.lang = "en-US";
rec.interimResults = false;
rec.maxAlternatives = 1;
rec.continuous = false;
let listening = false;
rec.onstart = () => { listening = true; micBtn.textContent = "🎙️"; };
rec.onend = () => { listening = false; micBtn.textContent = "🎤"; };
rec.onerror = (e) => {
term.writeln(`\r\n\x1b[31m[speech error: ${e.error}]\x1b[0m`);
if (e.error === "not-allowed") alert("Microphone permission denied.");
else alert(`Speech error: ${e.error}`);
};
rec.onresult = async (event) => {
const text = event.results?.[0]?.[0]?.transcript?.trim();
if (!text) return;
term.writeln(`\r\n\x1b[36m[voice] ${text}\x1b[0m`);
try { await sendVoiceText(text); } catch (e) { alert(`Send failed: ${e.message}`); }
};
micBtn.addEventListener("click", (ev) => {
if (ev.shiftKey) { manualSendFallback(); return; }
if (listening) return;
try { rec.start(); } catch (e) { manualSendFallback(); }
});
}