Spaces:
Running
Running
| <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 ; -webkit-overflow-scrolling: touch ; } | |
| /* 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">⚠ 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> | |