Files
clide/clide-web/clide_web/static/index.html
T
jpmschweitzerandClaude Opus 4.6 56a8ea4ba4 feat: add clide-web package replacing ttyd + zellij
Python web server that wraps Clide for browser access. Replaces the
previous ttyd (C binary) + zellij (Rust binary) stack with a single
FastAPI application using tmux for session persistence.

Key features:
- WebSocket ↔ PTY bridge via tmux attach
- Project switching via /projects/<name> URL routing
- Vendored xterm.js for offline LAN operation
- Auto-respawn on Clide exit (tmux pane-died hook)
- Setup wizard for first-run configuration
- No scrollbar (TUI manages its own scrolling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:03:43 +01:00

389 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clide</title>
<link rel="stylesheet" href="/static/vendor/xterm.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #21262f;
font-family: 'JetBrains Mono', monospace;
/* Kill all scrollbars */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* Kill xterm.js internal scrollbar — TUI handles its own scrolling */
.xterm-viewport {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.xterm-viewport::-webkit-scrollbar {
display: none !important;
}
/* Toolbar */
#toolbar {
height: 36px;
background: #292e38;
border-bottom: 1px solid #393e48;
display: flex;
align-items: center;
padding: 0 12px;
gap: 12px;
font-family: system-ui, -apple-system, sans-serif;
color: #e2e8f5;
font-size: 13px;
user-select: none;
}
#toolbar .logo {
font-weight: 600;
color: #00a3d2;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.5px;
}
#toolbar .separator {
width: 1px;
height: 20px;
background: #393e48;
}
#toolbar select {
background: #393e48;
color: #e2e8f5;
border: 1px solid #525762;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-family: system-ui, -apple-system, sans-serif;
cursor: pointer;
outline: none;
}
#toolbar select:hover {
border-color: #00a3d2;
}
#toolbar select:focus {
border-color: #00a3d2;
box-shadow: 0 0 0 1px #00a3d2;
}
#toolbar label {
font-size: 11px;
color: #898e9a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-spacer { flex: 1; }
#status {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
#status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #525762;
transition: background 0.3s;
}
#status .dot.connected { background: #00ab9a; }
#status .dot.reconnecting { background: #d08447; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Terminal container */
#terminal-container {
height: calc(100% - 36px);
width: 100%;
position: absolute;
top: 36px;
left: 0;
right: 0;
bottom: 0;
}
/* Reconnect overlay */
#overlay {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(33, 38, 47, 0.92);
z-index: 100;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 12px;
color: #e2e8f5;
font-family: system-ui, -apple-system, sans-serif;
}
#overlay .message {
font-size: 16px;
font-weight: 500;
}
#overlay .sub {
font-size: 13px;
color: #898e9a;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="logo">Clide</span>
<div class="separator"></div>
<div class="toolbar-group">
<label>Project</label>
<select id="project-select"><option value="">Loading...</option></select>
</div>
<div class="toolbar-spacer"></div>
<div id="status">
<div class="dot" id="status-dot"></div>
<span id="status-text">Connecting...</span>
</div>
</div>
<div id="terminal-container"></div>
<div id="overlay">
<div class="message">Reconnecting...</div>
<div class="sub">Session is preserved</div>
</div>
<script src="/static/vendor/xterm.min.js"></script>
<script src="/static/vendor/addon-fit.min.js"></script>
<script src="/static/vendor/addon-web-links.min.js"></script>
<script>
(function() {
"use strict";
// --- State ---
// Extract project from path: /projects/<name> or fallback to ?project=<name>
function getProjectFromUrl() {
var match = location.pathname.match(/^\/projects\/([^/]+)/);
if (match) return decodeURIComponent(match[1]);
return new URLSearchParams(location.search).get("project") || "";
}
let currentProject = getProjectFromUrl();
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
// --- DOM refs ---
const statusDot = document.getElementById("status-dot");
const statusText = document.getElementById("status-text");
const overlay = document.getElementById("overlay");
const projectSelect = document.getElementById("project-select");
// --- Terminal ---
const term = new Terminal({
fontFamily: "'JetBrains Mono', monospace",
fontSize: 14,
theme: {
background: "#21262f",
foreground: "#e2e8f5",
cursor: "#00a3d2",
cursorAccent: "#21262f",
selectionBackground: "rgba(0, 163, 210, 0.3)",
black: "#21262f",
red: "#f06c6f",
green: "#00ab9a",
yellow: "#d08447",
blue: "#00a3d2",
magenta: "#fa5f8b",
cyan: "#00a9b9",
white: "#e2e8f5",
brightBlack: "#393e48",
brightRed: "#f06c6f",
brightGreen: "#00ab9a",
brightYellow: "#d08447",
brightBlue: "#00a3d2",
brightMagenta: "#fa5f8b",
brightCyan: "#00a9b9",
brightWhite: "#e2e8f5",
},
cursorBlink: true,
allowProposedApi: true,
scrollback: 0,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(document.getElementById("terminal-container"));
fitAddon.fit();
// --- Status helpers ---
function setStatus(state, text) {
statusDot.className = "dot " + state;
statusText.textContent = text;
}
// --- WebSocket ---
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/projects/" + encodeURIComponent(currentProject) + "/ws";
ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
ws.onopen = function() {
setStatus("connected", currentProject || "Connected");
overlay.style.display = "none";
reconnectDelay = 1000;
// Send initial terminal size (slight delay to ensure xterm is rendered)
setTimeout(function() {
fitAddon.fit();
sendResize();
}, 50);
};
ws.onmessage = function(evt) {
let prefix, payload;
if (evt.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(evt.data);
if (bytes.length < 1) return;
prefix = String.fromCharCode(bytes[0]);
payload = bytes.slice(1);
if (prefix === "0") {
term.write(payload);
} else if (prefix === "1") {
handleControl(new TextDecoder().decode(payload));
}
} else {
// Text frame
prefix = evt.data[0];
payload = evt.data.slice(1);
if (prefix === "0") {
term.write(payload);
} else if (prefix === "1") {
handleControl(payload);
}
}
};
ws.onclose = function() {
setStatus("reconnecting", "Reconnecting...");
overlay.style.display = "flex";
reconnectTimer = setTimeout(function() {
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
connect();
}, reconnectDelay);
};
ws.onerror = function() {
// onclose will fire after this
};
}
// --- Terminal input → WebSocket ---
term.onData(function(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send("0" + data);
}
});
// --- Resize ---
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN) {
const dims = fitAddon.proposeDimensions();
if (dims) {
ws.send("2" + dims.cols + "," + dims.rows);
}
}
}
const resizeObserver = new ResizeObserver(function() {
fitAddon.fit();
sendResize();
});
resizeObserver.observe(document.getElementById("terminal-container"));
// --- Control messages ---
function handleControl(jsonStr) {
let msg;
try { msg = JSON.parse(jsonStr); } catch(e) { return; }
if (msg.type === "projects") {
populateProjects(msg.projects);
} else if (msg.type === "session_info") {
setStatus("connected", msg.project);
document.title = "Clide — " + msg.project;
} else if (msg.type === "error") {
term.write("\r\n\x1b[31mError: " + msg.message + "\x1b[0m\r\n");
}
}
// --- Project management ---
function populateProjects(projects) {
projectSelect.innerHTML = "";
projects.forEach(function(p) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
if (p === currentProject) opt.selected = true;
projectSelect.appendChild(opt);
});
}
projectSelect.addEventListener("change", function() {
currentProject = projectSelect.value;
history.pushState(null, "", "/projects/" + encodeURIComponent(currentProject));
document.title = "Clide — " + currentProject;
if (reconnectTimer) clearTimeout(reconnectTimer);
term.clear();
term.reset();
connect();
});
// --- Load projects and connect ---
fetch("/api/projects")
.then(function(r) { return r.json(); })
.then(function(data) {
populateProjects(data.projects);
// If no project selected, pick first available
if (!currentProject && data.projects.length > 0) {
currentProject = data.projects[0];
projectSelect.value = currentProject;
history.replaceState(null, "", "/projects/" + encodeURIComponent(currentProject));
}
if (currentProject) {
connect();
} else {
setStatus("", "No projects found");
term.write("\r\nNo projects found in configured projects directory.\r\n");
}
})
.catch(function() {
setStatus("reconnecting", "Server unreachable");
});
})();
</script>
</body>
</html>