opencode-ai / static /index.html
Bjo53's picture
Add: free scroll, keyboard resize, modifier keys box
17fd1e0 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<title>OpenCode AI Terminal</title>
<meta name="theme-color" content="#27c93f"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<meta name="apple-mobile-web-app-title" content="OpenCode"/>
<meta name="description" content="Full screen web terminal"/>
<link rel="manifest" href="/static/manifest.json"/>
<link rel="apple-touch-icon" href="/static/icon-192.png"/>
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png"/>
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png"/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; background: #0d1117; font-family: 'JetBrains Mono', monospace; }
#pass-screen {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: #0d1117; display: flex; align-items: center; justify-content: center;
z-index: 9999; flex-direction: column; gap: 16px;
}
#pass-screen.hidden { display: none; }
#pass-screen input {
background: #161b22; border: 1px solid #30363d; color: #e6edf3;
padding: 12px 20px; font-size: 16px; border-radius: 8px; width: 300px;
font-family: 'JetBrains Mono', monospace; text-align: center;
}
#pass-screen button {
background: #27c93f; color: #000; border: none; padding: 10px 30px;
font-size: 14px; border-radius: 8px; cursor: pointer; font-weight: bold;
}
#pass-screen .err { color: #ff5f56; font-size: 12px; }
#install-banner {
display: none; position: fixed; bottom: 0; left: 0; width: 100%;
background: #161b22; border-top: 1px solid #30363d; padding: 12px 16px;
z-index: 10000; flex-direction: row; align-items: center; justify-content: space-between;
}
#install-banner.show { display: flex; }
#install-banner span { color: #e6edf3; font-size: 13px; }
#install-banner button {
background: #27c93f; color: #000; border: none; padding: 8px 20px;
font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: bold;
}
#install-banner .close { background: none; color: #768390; padding: 8px; }
#status-bar {
display: none; background: #161b22; padding: 6px 12px; border-bottom: 1px solid #30363d;
gap: 10px; align-items: center; font-size: 11px;
}
#status-bar.active { display: flex; }
#status-dot {
width: 8px; height: 8px; border-radius: 50%; background: #ff5f56;
transition: background 0.3s;
}
#status-dot.connected { background: #27c93f; }
#status-dot.reconnecting { background: #ffbd2e; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
#status-text { color: #768390; }
#session-info { color: #58a6ff; margin-left: auto; }
#offline-banner {
display: none; background: #ff5f56; color: #fff; text-align: center;
padding: 8px; font-size: 12px;
}
#offline-banner.visible { display: block; }
#toolbar {
display: none; background: #161b22; padding: 6px 12px; border-bottom: 1px solid #30363d;
gap: 8px; align-items: center;
}
#toolbar.active { display: flex; }
#toolbar button {
background: #27c93f22; border: 1px solid #27c93f55; border-radius: 6px;
color: #27c93f; font-size: 11px; padding: 5px 12px; cursor: pointer;
font-family: 'JetBrains Mono', monospace;
}
#toolbar button:hover { background: #27c93f44; }
#toolbar .sep { width: 1px; height: 20px; background: #30363d; }
#terminal-container {
width: 100%; display: none; overflow: hidden;
height: calc(100vh - 36px);
}
@media (max-width: 768px) {
#terminal-container { height: var(--term-height, 50vh); }
}
.xterm { padding: 4px; height: 100%; touch-action: pan-y; }
.xterm-viewport { overflow-y: auto !important; -webkit-overflow-scrolling: touch !important; }
/* Modifier keys floating box */
#mod-box {
display: none; position: fixed; bottom: 8px; right: 8px; z-index: 9998;
background: #1c2128ee; border: 1px solid #30363d; border-radius: 10px;
padding: 6px; gap: 4px; flex-wrap: wrap; max-width: 140px;
justify-content: center; backdrop-filter: blur(8px);
}
#mod-box.active { display: flex; }
#mod-toggle {
position: fixed; bottom: 8px; right: 8px; z-index: 9997;
width: 28px; height: 28px; border-radius: 50%;
background: #1c212888; border: 1px solid #30363d55;
color: #768390; font-size: 10px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
#mod-toggle.on { background: #27c93f44; color: #27c93f; border-color: #27c93f55; }
.mod-btn {
background: #0d1117; border: 1px solid #30363d; border-radius: 5px;
color: #e6edf3; font-size: 10px; padding: 5px 8px; cursor: pointer;
font-family: 'JetBrains Mono', monospace; min-width: 32px; text-align: center;
user-select: none; -webkit-user-select: none;
}
.mod-btn:active, .mod-btn.on { background: #27c93f44; color: #27c93f; border-color: #27c93f; }
#ctx-menu {
display: none; position: fixed; background: #1c2128; border: 1px solid #30363d;
border-radius: 8px; padding: 4px; z-index: 9999; min-width: 160px;
}
#ctx-menu button {
display: block; width: 100%; background: none; border: none; color: #e6edf3;
padding: 8px 12px; text-align: left; cursor: pointer; border-radius: 4px;
font-family: 'JetBrains Mono', monospace; font-size: 12px;
}
#ctx-menu button:hover { background: #27c93f22; color: #27c93f; }
</style>
</head>
<body>
<div id="pass-screen">
<input id="pass-input" type="password" placeholder="Enter password" autofocus/>
<button id="pass-btn">Unlock</button>
<div class="err" id="pass-err"></div>
</div>
<div id="install-banner">
<span>Install OpenCode Terminal</span>
<div>
<button id="install-btn">Install</button>
<button class="close" id="install-close"></button>
</div>
</div>
<div id="offline-banner">&#x26A0; OFFLINE — Reconnecting automatically...</div>
<div id="status-bar">
<div id="status-dot"></div>
<span id="status-text">Connecting...</span>
<span id="session-info"></span>
</div>
<div id="toolbar">
<button id="btn-copy">📋 Copy</button>
<button id="btn-paste">📄 Paste</button>
<button id="btn-selectall">📝 Select All</button>
<div class="sep"></div>
<button id="btn-clear">🗑 Clear</button>
</div>
<div id="terminal-container"></div>
<!-- Modifier keys toggle button (tiny, corner) -->
<button id="mod-toggle" title="Modifier keys"></button>
<!-- Modifier keys panel -->
<div id="mod-box">
<button class="mod-btn" data-key="ctrl">Ctrl</button>
<button class="mod-btn" data-key="alt">Alt</button>
<button class="mod-btn" data-key="esc">Esc</button>
<button class="mod-btn" data-key="tab">Tab</button>
<button class="mod-btn" data-key="up"></button>
<button class="mod-btn" data-key="down"></button>
<button class="mod-btn" data-key="left"></button>
<button class="mod-btn" data-key="right"></button>
<button class="mod-btn" data-key="bs"></button>
<button class="mod-btn" data-key="del">Del</button>
<button class="mod-btn" data-key="home">Home</button>
<button class="mod-btn" data-key="end">End</button>
<button class="mod-btn" data-key="pgup">PgUp</button>
<button class="mod-btn" data-key="pgdn">PgDn</button>
</div>
<div id="ctx-menu">
<button id="ctx-copy">📋 Copy</button>
<button id="ctx-paste">📄 Paste</button>
<button id="ctx-selectall">📝 Select All</button>
<button id="ctx-clear">🗑 Clear</button>
</div>
<script>
// PWA
let deferredPrompt;
const installBanner = document.getElementById('install-banner');
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; installBanner.classList.add('show'); });
document.getElementById('install-btn').onclick = async () => { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt = null; installBanner.classList.remove('show'); } };
document.getElementById('install-close').onclick = () => installBanner.classList.remove('show');
window.addEventListener('appinstalled', () => { installBanner.classList.remove('show'); deferredPrompt = null; });
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/static/sw.js');
// Session
const SESSION_KEY = "opencode_session_id";
let PASS_HASH = "", term = null, ws = null, reconnectAttempts = 0, isOnline = navigator.onLine, lastActivity = Date.now();
let activeMods = new Set();
fetch("/api/pass").then(r => r.json()).then(d => { PASS_HASH = d.pass; });
document.getElementById("pass-btn").onclick = () => unlock();
document.getElementById("pass-input").addEventListener("keydown", e => { if (e.key === "Enter") unlock(); });
function unlock() {
const val = document.getElementById("pass-input").value;
if (val === PASS_HASH && PASS_HASH !== "") {
document.getElementById("pass-screen").classList.add("hidden");
document.getElementById("status-bar").classList.add("active");
document.getElementById("toolbar").classList.add("active");
document.getElementById("terminal-container").style.display = "block";
initTerminal();
} else {
document.getElementById("pass-err").textContent = "Wrong password";
}
}
function setStatus(state, text) {
document.getElementById("status-dot").className = state;
document.getElementById("status-text").textContent = text;
}
function setOnline() {
isOnline = true;
document.getElementById("offline-banner").classList.remove("visible");
if (!ws || ws.readyState !== WebSocket.OPEN) reconnect();
}
function setOffline() {
isOnline = false;
document.getElementById("offline-banner").classList.add("visible");
setStatus("reconnecting", "Offline...");
}
window.addEventListener("online", setOnline);
window.addEventListener("offline", setOffline);
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) try { ws.send(JSON.stringify({type:"ping"})); } catch(e) {}
if (Date.now() - lastActivity > 30000 && (!ws || ws.readyState !== WebSocket.OPEN)) reconnect();
}, 10000);
// Mobile keyboard handling
function setTermHeight() {
const vh = window.innerHeight;
const barH = document.getElementById('status-bar').offsetHeight + document.getElementById('toolbar').offsetHeight;
document.getElementById('terminal-container').style.setProperty('--term-height', (vh - barH - 40) + 'px');
document.getElementById('terminal-container').style.height = (vh - barH) + 'px';
if (fitAddon) fitAddon.fit();
}
let fitAddon = null;
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', monospace",
scrollback: 50000,
allowProposedApi: true,
smoothScrollDuration: 50,
theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#27c93f', selectionBackground: '#27c93f44' }
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal-container"));
setTermHeight();
term.focus();
function getWsUrl() {
const p = location.protocol === "https:" ? "wss" : "ws";
return `${p}://${location.host}/ws`;
}
function getSavedSession() { return localStorage.getItem(SESSION_KEY); }
function saveSession(sid) { localStorage.setItem(SESSION_KEY, sid); }
function connect() {
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
setStatus("reconnecting", reconnectAttempts > 0 ? `Reconnecting (${reconnectAttempts})...` : "Connecting...");
try { ws = new WebSocket(getWsUrl()); } catch(e) { setStatus("", "Failed"); scheduleReconnect(); return; }
ws.onopen = () => {
lastActivity = Date.now(); reconnectAttempts = 0;
setStatus("connected", "Connected");
const saved = getSavedSession();
ws.send(JSON.stringify({ type: "attach", session_id: saved || undefined }));
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
term.focus();
};
ws.onmessage = (e) => {
lastActivity = Date.now();
try {
const msg = JSON.parse(e.data);
if (msg.type === "session_info") {
saveSession(msg.session_id);
document.getElementById("session-info").textContent = `Session: ${msg.session_id}`;
} else if (msg.type === "output") {
term.write(msg.data);
}
} catch(err) { term.write(e.data); }
};
ws.onerror = () => setStatus("", "Error");
ws.onclose = () => { setStatus("", "Disconnected"); scheduleReconnect(); };
}
function scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 15000);
reconnectAttempts++;
setTimeout(() => { if (isOnline) connect(); }, delay);
}
window.reconnect = () => { reconnectAttempts = 0; connect(); };
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data }));
});
term.onResize(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
});
window.addEventListener("resize", () => { setTermHeight(); });
document.addEventListener("visibilitychange", () => {
if (!document.hidden && (!ws || ws.readyState !== WebSocket.OPEN)) reconnect();
});
// Touch scroll for mobile
const termEl = document.getElementById("terminal-container");
termEl.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) e.stopPropagation();
}, { passive: true });
connect();
}
// Mobile keyboard: resize when viewport changes (keyboard open/close)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
if (term) { setTermHeight(); term.focus(); }
});
}
window.addEventListener('resize', () => { if (term) setTermHeight(); });
// Modifier keys
const modToggle = document.getElementById('mod-toggle');
const modBox = document.getElementById('mod-box');
modToggle.onclick = () => {
modBox.classList.toggle('active');
modToggle.classList.toggle('on');
};
const keyMap = {
ctrl: '\x03', // Ctrl+C as default, or hold for next key
alt: '\x1b',
esc: '\x1b',
tab: '\t',
up: '\x1b[A',
down: '\x1b[B',
left: '\x1b[D',
right: '\x1b[C',
bs: '\x7f',
del: '\x1b[3~',
home: '\x1b[H',
end: '\x1b[F',
pgup: '\x1b[5~',
pgdn: '\x1b[6~'
};
document.querySelectorAll('.mod-btn').forEach(btn => {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
const key = btn.dataset.key;
const code = keyMap[key];
if (code && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: code }));
btn.classList.add('on');
setTimeout(() => btn.classList.remove('on'), 200);
term.focus();
}
});
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const key = btn.dataset.key;
const code = keyMap[key];
if (code && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data: code }));
btn.classList.add('on');
setTimeout(() => btn.classList.remove('on'), 200);
term.focus();
}
});
});
// Toolbar
document.getElementById("btn-copy").onclick = () => {
const sel = term?.getSelection();
if (sel) navigator.clipboard.writeText(sel);
};
document.getElementById("btn-paste").onclick = async () => {
try {
const text = await navigator.clipboard.readText();
if (text && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: text }));
} catch(e) {}
};
document.getElementById("btn-selectall").onclick = () => term?.selectAll();
document.getElementById("btn-clear").onclick = () => term?.clear();
// Context menu
const ctxMenu = document.getElementById("ctx-menu");
document.getElementById("terminal-container").addEventListener("contextmenu", (e) => {
e.preventDefault();
ctxMenu.style.display = "block";
ctxMenu.style.left = `${e.pageX}px`;
ctxMenu.style.top = `${e.pageY}px`;
});
document.addEventListener("click", () => ctxMenu.style.display = "none");
document.getElementById("ctx-copy").onclick = () => { const s = term?.getSelection(); if (s) navigator.clipboard.writeText(s); };
document.getElementById("ctx-paste").onclick = async () => { try { const t = await navigator.clipboard.readText(); if (t && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: t })); } catch(e) {} };
document.getElementById("ctx-selectall").onclick = () => term?.selectAll();
document.getElementById("ctx-clear").onclick = () => term?.clear();
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (!term) return;
if (e.ctrlKey && e.key === "c") { const s = term.getSelection(); if (s) { navigator.clipboard.writeText(s); e.preventDefault(); } }
if (e.ctrlKey && e.key === "v") { e.preventDefault(); navigator.clipboard.readText().then(t => { if (t && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: t })); }); }
if (e.ctrlKey && e.key === "a") { e.preventDefault(); term.selectAll(); }
});
</script>
</body>
</html>