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>
This commit is contained in:
2026-02-24 17:03:43 +01:00
co-authored by Claude Opus 4.6
parent 29a858d7a8
commit 56a8ea4ba4
15 changed files with 1524 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
<!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>
+8
View File
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
File diff suppressed because one or more lines are too long