From bc7ca9982bb21ea13f4d6676666726295df82e8a Mon Sep 17 00:00:00 2001 From: centdix Date: Fri, 20 Feb 2026 23:07:32 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20dev-dashboard=20=E2=80=94=20brows?= =?UTF-8?q?er-based=20workmux=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web dashboard (Bun + xterm.js) that wraps workmux CLI commands and renders tmux windows in embedded browser terminals. Replaces direct tmux navigation with a sidebar-based UI at localhost:5111. - Bun HTTP server with REST API for worktree CRUD (add/rm/open/close/send) - Bun.Terminal PTY API to attach to tmux grouped sessions per worktree - xterm.js frontend with WebSocket bridge for real-time terminal I/O - Scrollback buffer for reconnection, ResizeObserver for dynamic fitting - Add direnv allow to worktree-env post-create hook for nix devshell Co-Authored-By: Claude Opus 4.6 --- dev-dashboard/.gitignore | 3 + dev-dashboard/package.json | 17 ++ dev-dashboard/public/index.html | 63 +++++++ dev-dashboard/public/style.css | 288 ++++++++++++++++++++++++++++ dev-dashboard/src/client/app.ts | 321 ++++++++++++++++++++++++++++++++ dev-dashboard/src/server.ts | 213 +++++++++++++++++++++ dev-dashboard/src/terminal.ts | 125 +++++++++++++ dev-dashboard/src/workmux.ts | 118 ++++++++++++ dev-dashboard/tsconfig.json | 15 ++ scripts/worktree-env | 6 + 10 files changed, 1169 insertions(+) create mode 100644 dev-dashboard/.gitignore create mode 100644 dev-dashboard/package.json create mode 100644 dev-dashboard/public/index.html create mode 100644 dev-dashboard/public/style.css create mode 100644 dev-dashboard/src/client/app.ts create mode 100644 dev-dashboard/src/server.ts create mode 100644 dev-dashboard/src/terminal.ts create mode 100644 dev-dashboard/src/workmux.ts create mode 100644 dev-dashboard/tsconfig.json diff --git a/dev-dashboard/.gitignore b/dev-dashboard/.gitignore new file mode 100644 index 0000000000..0956828758 --- /dev/null +++ b/dev-dashboard/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +public/dist/ +bun.lock diff --git a/dev-dashboard/package.json b/dev-dashboard/package.json new file mode 100644 index 0000000000..2617ccda08 --- /dev/null +++ b/dev-dashboard/package.json @@ -0,0 +1,17 @@ +{ + "name": "windmill-dev-dashboard", + "private": true, + "type": "module", + "scripts": { + "build": "bun build src/client/app.ts --outdir public/dist --minify", + "dev": "bun run build && bun --watch src/server.ts", + "start": "bun src/server.ts" + }, + "devDependencies": { + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@types/bun": "latest", + "typescript": "^5.0.0" + } +} diff --git a/dev-dashboard/public/index.html b/dev-dashboard/public/index.html new file mode 100644 index 0000000000..373b431233 --- /dev/null +++ b/dev-dashboard/public/index.html @@ -0,0 +1,63 @@ + + + + + + Windmill Dev Dashboard + + + + +
+ +
+
+ Select a worktree + +
+
+
+

Select a worktree from the sidebar to connect

+
+
+
+ + + +
+

New Worktree

+ + +
+ + +
+
+
+ + +
+

Send Prompt

+ +
+ + +
+
+
+ + + + diff --git a/dev-dashboard/public/style.css b/dev-dashboard/public/style.css new file mode 100644 index 0000000000..76780caee7 --- /dev/null +++ b/dev-dashboard/public/style.css @@ -0,0 +1,288 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #0d1117; + --bg-sidebar: #161b22; + --bg-topbar: #1c2128; + --bg-hover: #21262d; + --bg-active: #1f6feb33; + --border: #30363d; + --text: #e6edf3; + --text-muted: #8b949e; + --accent: #58a6ff; + --danger: #f85149; + --success: #3fb950; + --warning: #d29922; + --radius: 6px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; +} + +#app { + display: flex; + height: 100vh; +} + +/* Sidebar */ +#sidebar { + width: 260px; + min-width: 260px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h1 { + font-size: 16px; + font-weight: 600; +} + +.sidebar-header button { + width: 32px; + height: 32px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + color: var(--accent); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sidebar-header button:hover { + background: var(--bg-hover); +} + +#worktree-list { + list-style: none; + overflow-y: auto; + flex: 1; + padding: 8px; +} + +#worktree-list li { + padding: 10px 12px; + border-radius: var(--radius); + cursor: pointer; + margin-bottom: 2px; + display: flex; + flex-direction: column; + gap: 4px; +} + +#worktree-list li:hover { + background: var(--bg-hover); +} + +#worktree-list li.active { + background: var(--bg-active); + border: 1px solid var(--accent); +} + +.wt-branch { + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-meta { + display: flex; + gap: 8px; + font-size: 11px; + color: var(--text-muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 4px; + vertical-align: middle; +} + +.status-dot.working { background: var(--success); } +.status-dot.waiting { background: var(--warning); } +.status-dot.stopped { background: var(--text-muted); } +.status-dot.error { background: var(--danger); } + +/* Main */ +#main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + min-height: 48px; +} + +#wt-name { + font-size: 14px; + font-weight: 600; +} + +#wt-actions { + display: flex; + gap: 8px; + align-items: center; +} + +#wt-actions.hidden { + display: none; +} + +#wt-status-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: 12px; + background: var(--bg-hover); +} + +button { + padding: 6px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 12px; + cursor: pointer; +} + +button:hover { + background: var(--bg-hover); +} + +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +button.primary:hover { + opacity: 0.9; +} + +button.danger { + color: var(--danger); + border-color: var(--danger); +} + +button.danger:hover { + background: #f8514922; +} + +/* Terminal */ +#terminal-container { + flex: 1; + display: none; + padding: 4px; + overflow: hidden; +} + +#terminal-container.visible { + display: block; +} + +#placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 14px; +} + +#placeholder.hidden { + display: none; +} + +/* Dialogs */ +dialog { + background: var(--bg-sidebar); + color: var(--text); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + max-width: 440px; + width: 90%; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.6); +} + +dialog h2 { + font-size: 16px; + margin-bottom: 16px; +} + +dialog label { + display: block; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 12px; +} + +dialog input, +dialog textarea { + display: block; + width: 100%; + margin-top: 4px; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: inherit; +} + +dialog input:focus, +dialog textarea:focus { + outline: none; + border-color: var(--accent); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* xterm overrides */ +.xterm { + height: 100%; +} diff --git a/dev-dashboard/src/client/app.ts b/dev-dashboard/src/client/app.ts new file mode 100644 index 0000000000..51046e7c55 --- /dev/null +++ b/dev-dashboard/src/client/app.ts @@ -0,0 +1,321 @@ +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import "@xterm/xterm/css/xterm.css"; + +// --- Types --- + +interface WorktreeInfo { + branch: string; + agent: string; + mux: string; + path: string; + status: string; + elapsed: string; + title: string; +} + +// --- State --- + +let worktrees: WorktreeInfo[] = []; +let selectedWorktree: string | null = null; +let ws: WebSocket | null = null; +let term: Terminal | null = null; +let fitAddon: FitAddon | null = null; +let resizeObserver: ResizeObserver | null = null; + +// --- DOM refs --- + +const worktreeList = document.getElementById("worktree-list") as HTMLUListElement; +const wtName = document.getElementById("wt-name") as HTMLSpanElement; +const wtActions = document.getElementById("wt-actions") as HTMLDivElement; +const wtStatusBadge = document.getElementById("wt-status-badge") as HTMLSpanElement; +const terminalContainer = document.getElementById("terminal-container") as HTMLDivElement; +const placeholder = document.getElementById("placeholder") as HTMLDivElement; + +const btnNew = document.getElementById("btn-new") as HTMLButtonElement; +const btnOpen = document.getElementById("btn-open") as HTMLButtonElement; +const btnClose = document.getElementById("btn-close") as HTMLButtonElement; +const btnSend = document.getElementById("btn-send") as HTMLButtonElement; +const btnRemove = document.getElementById("btn-remove") as HTMLButtonElement; + +const newDialog = document.getElementById("new-dialog") as HTMLDialogElement; +const sendDialog = document.getElementById("send-dialog") as HTMLDialogElement; + +// --- API helpers --- + +async function api(path: string, opts?: RequestInit): Promise { + const res = await fetch(`/api/${path}`, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data as T; +} + +// --- Worktree list --- + +async function refreshWorktrees(): Promise { + try { + worktrees = await api("worktrees"); + renderWorktreeList(); + } catch (err) { + console.error("Failed to refresh worktrees:", err); + } +} + +function statusDotClass(agent: string): string { + if (agent === "working") return "working"; + if (agent === "waiting") return "waiting"; + if (agent === "error") return "error"; + return "stopped"; +} + +function renderWorktreeList(): void { + worktreeList.innerHTML = ""; + for (const wt of worktrees) { + const li = document.createElement("li"); + if (wt.branch === selectedWorktree) li.classList.add("active"); + + const isMain = wt.path === "(here)" || wt.branch === "main"; + + li.innerHTML = ` + ${escapeHtml(wt.branch)} + + ${escapeHtml(wt.agent || "none")} + ${wt.mux && wt.mux !== "-" ? `mux: ${escapeHtml(wt.mux)}` : ""} + ${isMain ? "main" : ""} + + `; + + li.addEventListener("click", () => selectWorktree(wt.branch)); + worktreeList.appendChild(li); + } +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +// --- Terminal --- + +function selectWorktree(branch: string): void { + if (selectedWorktree === branch) return; + selectedWorktree = branch; + + // Update sidebar selection + renderWorktreeList(); + + // Update top bar + const wt = worktrees.find(w => w.branch === branch); + wtName.textContent = branch; + wtActions.classList.remove("hidden"); + wtStatusBadge.textContent = wt?.status || wt?.agent || ""; + + // Only connect terminal for non-main worktrees that have a tmux window + const isMain = wt?.path === "(here)" || branch === "main"; + if (isMain) { + disconnectTerminal(); + placeholder.classList.remove("hidden"); + placeholder.querySelector("p")!.textContent = "Main worktree — use workmux to manage"; + terminalContainer.classList.remove("visible"); + return; + } + + connectTerminal(branch); +} + +function connectTerminal(worktree: string): void { + disconnectTerminal(); + + // Show terminal container + placeholder.classList.add("hidden"); + terminalContainer.classList.add("visible"); + + // Create terminal + term = new Terminal({ + cursorBlink: true, + theme: { + background: "#0d1117", + foreground: "#e6edf3", + cursor: "#58a6ff", + selectionBackground: "#264f78", + }, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace", + fontSize: 13, + scrollback: 10000, + }); + + fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); + term.open(terminalContainer); + + // Delay fit() so the container has its final dimensions after display:none → block + requestAnimationFrame(() => { + fitAddon?.fit(); + }); + + // WebSocket connection + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(worktree)}`); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case "scrollback": + case "output": + term?.write(msg.data); + break; + case "exit": + term?.writeln(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m`); + break; + case "error": + term?.writeln(`\r\n\x1b[31m[Error: ${msg.message}]\x1b[0m`); + break; + } + } catch { + // Ignore malformed messages + } + }; + + ws.onopen = () => { + // Send actual fitted dimensions once connected + if (term && fitAddon) { + fitAddon.fit(); + ws!.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + } + }; + + ws.onclose = () => { + term?.writeln("\r\n\x1b[90m[Disconnected]\x1b[0m"); + }; + + // Terminal input → WebSocket + term.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data })); + } + }); + + // Handle resize + resizeObserver = new ResizeObserver(() => { + fitAddon?.fit(); + if (term && ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + } + }); + resizeObserver.observe(terminalContainer); +} + +function disconnectTerminal(): void { + resizeObserver?.disconnect(); + resizeObserver = null; + ws?.close(); + ws = null; + term?.dispose(); + term = null; + fitAddon = null; + terminalContainer.innerHTML = ""; +} + +// --- Actions --- + +btnNew.addEventListener("click", () => { + (document.getElementById("new-branch") as HTMLInputElement).value = ""; + (document.getElementById("new-prompt") as HTMLTextAreaElement).value = ""; + newDialog.showModal(); +}); + +newDialog.querySelector("form")!.addEventListener("submit", async (e) => { + e.preventDefault(); + const branch = (document.getElementById("new-branch") as HTMLInputElement).value.trim(); + const prompt = (document.getElementById("new-prompt") as HTMLTextAreaElement).value.trim(); + + if (!branch) return; + + try { + newDialog.close(); + await api("worktrees", { + method: "POST", + body: JSON.stringify({ branch, prompt: prompt || undefined }), + }); + await refreshWorktrees(); + selectWorktree(branch); + } catch (err) { + alert(`Failed to create worktree: ${err instanceof Error ? err.message : err}`); + } +}); + +btnOpen.addEventListener("click", async () => { + if (!selectedWorktree) return; + try { + await api(`worktrees/${encodeURIComponent(selectedWorktree)}/open`, { method: "POST" }); + await refreshWorktrees(); + // Reconnect terminal since tmux window is now open + connectTerminal(selectedWorktree); + } catch (err) { + alert(`Failed to open: ${err instanceof Error ? err.message : err}`); + } +}); + +btnClose.addEventListener("click", async () => { + if (!selectedWorktree) return; + try { + await api(`worktrees/${encodeURIComponent(selectedWorktree)}/close`, { method: "POST" }); + disconnectTerminal(); + placeholder.classList.remove("hidden"); + placeholder.querySelector("p")!.textContent = "Worktree closed"; + terminalContainer.classList.remove("visible"); + await refreshWorktrees(); + } catch (err) { + alert(`Failed to close: ${err instanceof Error ? err.message : err}`); + } +}); + +btnSend.addEventListener("click", () => { + (document.getElementById("send-prompt") as HTMLTextAreaElement).value = ""; + sendDialog.showModal(); +}); + +sendDialog.querySelector("form")!.addEventListener("submit", async (e) => { + e.preventDefault(); + const prompt = (document.getElementById("send-prompt") as HTMLTextAreaElement).value.trim(); + if (!prompt || !selectedWorktree) return; + + try { + sendDialog.close(); + await api(`worktrees/${encodeURIComponent(selectedWorktree)}/send`, { + method: "POST", + body: JSON.stringify({ prompt }), + }); + } catch (err) { + alert(`Failed to send prompt: ${err instanceof Error ? err.message : err}`); + } +}); + +btnRemove.addEventListener("click", async () => { + if (!selectedWorktree) return; + if (!confirm(`Remove worktree "${selectedWorktree}"? This also deletes the branch.`)) return; + + try { + disconnectTerminal(); + await api(`worktrees/${encodeURIComponent(selectedWorktree)}`, { method: "DELETE" }); + selectedWorktree = null; + wtName.textContent = "Select a worktree"; + wtActions.classList.add("hidden"); + placeholder.classList.remove("hidden"); + placeholder.querySelector("p")!.textContent = "Select a worktree from the sidebar to connect"; + terminalContainer.classList.remove("visible"); + await refreshWorktrees(); + } catch (err) { + alert(`Failed to remove: ${err instanceof Error ? err.message : err}`); + } +}); + +// --- Init --- + +refreshWorktrees(); +setInterval(refreshWorktrees, 5000); diff --git a/dev-dashboard/src/server.ts b/dev-dashboard/src/server.ts new file mode 100644 index 0000000000..e4cfd04207 --- /dev/null +++ b/dev-dashboard/src/server.ts @@ -0,0 +1,213 @@ +import { file } from "bun"; +import * as path from "path"; +import { + listWorktrees, + getStatus, + addWorktree, + removeWorktree, + openWorktree, + closeWorktree, + sendPrompt, +} from "./workmux"; +import { + attach, + detach, + write, + resize, + getScrollback, + setCallbacks, + clearCallbacks, +} from "./terminal"; + +const PORT = parseInt(process.env.DASHBOARD_PORT || "5111"); +const PUBLIC_DIR = path.join(import.meta.dir, "..", "public"); + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(message: string, status = 500): Response { + return jsonResponse({ error: message }, status); +} + +async function serveStatic(pathname: string): Promise { + if (pathname === "/" || pathname === "") pathname = "/index.html"; + + const filePath = path.join(PUBLIC_DIR, pathname); + + if (!filePath.startsWith(PUBLIC_DIR)) { + return new Response("Forbidden", { status: 403 }); + } + + const f = file(filePath); + if (await f.exists()) { + return new Response(f); + } + return new Response("Not Found", { status: 404 }); +} + +interface WsData { + worktree: string; +} + +function makeCallbacks(ws: { send: (data: string) => void; readyState: number }) { + return { + onData: (data: string) => { + if (ws.readyState <= 1) { + ws.send(JSON.stringify({ type: "output", data })); + } + }, + onExit: (exitCode: number) => { + if (ws.readyState <= 1) { + ws.send(JSON.stringify({ type: "exit", exitCode })); + } + }, + }; +} + +Bun.serve({ + port: PORT, + + async fetch(req, server) { + const url = new URL(req.url); + + const wsMatch = url.pathname.match(/^\/ws\/(.+)$/); + if (wsMatch) { + const worktree = decodeURIComponent(wsMatch[1]); + const upgraded = server.upgrade(req, { data: { worktree } }); + if (upgraded) return undefined as unknown as Response; + return new Response("WebSocket upgrade failed", { status: 400 }); + } + + if (url.pathname.startsWith("/api/")) { + return handleApi(req, url); + } + + return serveStatic(url.pathname); + }, + + websocket: { + async open(ws) { + const { worktree } = ws.data; + const cols = 120; + const rows = 30; + + try { + await attach(worktree, cols, rows); + + const { onData, onExit } = makeCallbacks(ws); + setCallbacks(worktree, onData, onExit); + + const scrollback = getScrollback(worktree); + if (scrollback) { + ws.send(JSON.stringify({ type: "scrollback", data: scrollback })); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + ws.send(JSON.stringify({ type: "error", message })); + ws.close(); + } + }, + + message(ws, message) { + try { + const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message)); + const { worktree } = ws.data; + + switch (msg.type) { + case "input": + write(worktree, msg.data); + break; + case "resize": + resize(worktree, msg.cols, msg.rows); + break; + } + } catch { + // Ignore malformed messages + } + }, + + async close(ws) { + clearCallbacks(ws.data.worktree); + await detach(ws.data.worktree); + }, + }, +}); + +async function handleApi(req: Request, url: URL): Promise { + const method = req.method; + const parts = url.pathname.slice(5).split("/").filter(Boolean); + + try { + // GET /api/worktrees + if (parts[0] === "worktrees" && parts.length === 1 && method === "GET") { + const [worktrees, status] = await Promise.all([listWorktrees(), getStatus()]); + const merged = worktrees.map(wt => { + const st = status.find(s => + s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch) + ); + return { ...wt, status: st?.status ?? "", elapsed: st?.elapsed ?? "", title: st?.title ?? "" }; + }); + return jsonResponse(merged); + } + + // POST /api/worktrees + if (parts[0] === "worktrees" && parts.length === 1 && method === "POST") { + const body = await req.json() as { branch?: string; prompt?: string; autoName?: boolean }; + if (!body.branch && !body.autoName) { + return errorResponse("branch is required (or use autoName)", 400); + } + const result = await addWorktree(body.branch || "", { + prompt: body.prompt, + autoName: body.autoName, + }); + return jsonResponse({ message: result }, 201); + } + + // DELETE /api/worktrees/:name + if (parts[0] === "worktrees" && parts.length === 2 && method === "DELETE") { + const name = decodeURIComponent(parts[1]); + return jsonResponse({ message: await removeWorktree(name) }); + } + + // POST /api/worktrees/:name/open + if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "open" && method === "POST") { + const name = decodeURIComponent(parts[1]); + return jsonResponse({ message: await openWorktree(name) }); + } + + // POST /api/worktrees/:name/close + if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "close" && method === "POST") { + const name = decodeURIComponent(parts[1]); + return jsonResponse({ message: await closeWorktree(name) }); + } + + // POST /api/worktrees/:name/send + if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "send" && method === "POST") { + const name = decodeURIComponent(parts[1]); + const body = await req.json() as { prompt?: string }; + if (!body.prompt) { + return errorResponse("prompt is required", 400); + } + return jsonResponse({ message: await sendPrompt(name, body.prompt) }); + } + + // GET /api/worktrees/:name/status + if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "status" && method === "GET") { + const name = decodeURIComponent(parts[1]); + const status = await getStatus(); + const match = status.find(s => s.worktree.includes(name)); + return jsonResponse(match ?? { status: "unknown" }); + } + + return errorResponse("Not Found", 404); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse(message); + } +} + +console.log(`Dev Dashboard running at http://localhost:${PORT}`); diff --git a/dev-dashboard/src/terminal.ts b/dev-dashboard/src/terminal.ts new file mode 100644 index 0000000000..cd0965a083 --- /dev/null +++ b/dev-dashboard/src/terminal.ts @@ -0,0 +1,125 @@ +import { getTmuxSession } from "./workmux"; + +interface TerminalSession { + proc: ReturnType; + groupedSessionName: string; + scrollback: string[]; + onData: ((data: string) => void) | null; + onExit: ((exitCode: number) => void) | null; +} + +const MAX_SCROLLBACK = 5000; +const sessions = new Map(); +let sessionCounter = 0; + +function groupedName(): string { + return `wm-dash-${++sessionCounter}`; +} + +export async function attach( + worktreeName: string, + cols: number, + rows: number +): Promise { + if (sessions.has(worktreeName)) { + await detach(worktreeName); + } + + const tmuxSession = await getTmuxSession(); + const gName = groupedName(); + const windowTarget = `wm-${worktreeName}`; + + // Create a grouped tmux session (independent sizing) and attach to the worktree's window + const cmd = [ + `tmux new-session -d -s "${gName}" -t "${tmuxSession}"`, + `tmux select-window -t "${gName}:${windowTarget}"`, + `exec tmux attach-session -t "${gName}"`, + ].join(" && "); + + const session: TerminalSession = { + proc: null as any, + groupedSessionName: gName, + scrollback: [], + onData: null, + onExit: null, + }; + + sessions.set(worktreeName, session); + + const proc = Bun.spawn(["bash", "-c", cmd], { + terminal: { + cols, + rows, + name: "xterm-256color", + data(_terminal, data) { + const str = typeof data === "string" ? data : new TextDecoder().decode(data); + session.scrollback.push(str); + if (session.scrollback.length > MAX_SCROLLBACK) { + session.scrollback.shift(); + } + session.onData?.(str); + }, + exit() {}, + }, + }); + + session.proc = proc; + + proc.exited.then((exitCode) => { + session.onExit?.(exitCode); + sessions.delete(worktreeName); + try { + Bun.spawnSync(["tmux", "kill-session", "-t", gName]); + } catch { + // Session may already be gone + } + }); + + return worktreeName; +} + +export async function detach(worktreeName: string): Promise { + const session = sessions.get(worktreeName); + if (!session) return; + + session.proc.kill(); + sessions.delete(worktreeName); + + try { + Bun.spawnSync(["tmux", "kill-session", "-t", session.groupedSessionName]); + } catch { + // Already gone + } +} + +export function write(worktreeName: string, data: string): void { + sessions.get(worktreeName)?.proc.terminal?.write(data); +} + +export function resize(worktreeName: string, cols: number, rows: number): void { + sessions.get(worktreeName)?.proc.terminal?.resize(cols, rows); +} + +export function getScrollback(worktreeName: string): string { + return sessions.get(worktreeName)?.scrollback.join("") ?? ""; +} + +export function setCallbacks( + worktreeName: string, + onData: (data: string) => void, + onExit: (exitCode: number) => void +): void { + const session = sessions.get(worktreeName); + if (session) { + session.onData = onData; + session.onExit = onExit; + } +} + +export function clearCallbacks(worktreeName: string): void { + const session = sessions.get(worktreeName); + if (session) { + session.onData = null; + session.onExit = null; + } +} diff --git a/dev-dashboard/src/workmux.ts b/dev-dashboard/src/workmux.ts new file mode 100644 index 0000000000..c8922a4ffa --- /dev/null +++ b/dev-dashboard/src/workmux.ts @@ -0,0 +1,118 @@ +import { $ } from "bun"; + +export interface Worktree { + branch: string; + agent: string; + mux: string; + unmerged: string; + path: string; +} + +export interface WorktreeStatus { + worktree: string; + status: string; + elapsed: string; + title: string; +} + +function parseTable(output: string, mapper: (cols: string[]) => T): T[] { + const lines = output.trim().split("\n").filter(Boolean); + if (lines.length < 2) return []; + + const headerLine = lines[0]; + + // Find column positions based on header spacing + const colStarts: number[] = []; + let inSpace = true; + for (let i = 0; i < headerLine.length; i++) { + if (headerLine[i] !== " " && inSpace) { + colStarts.push(i); + inSpace = false; + } else if (headerLine[i] === " " && !inSpace) { + inSpace = true; + } + } + + return lines.slice(1).map(line => { + const cols = colStarts.map((start, idx) => { + const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length; + return line.slice(start, end).trim(); + }); + return mapper(cols); + }); +} + +export async function listWorktrees(): Promise { + const result = await $`workmux list`.text(); + return parseTable(result, (cols) => ({ + branch: cols[0] ?? "", + agent: cols[1] ?? "", + mux: cols[2] ?? "", + unmerged: cols[3] ?? "", + path: cols[4] ?? "", + })); +} + +export async function getStatus(): Promise { + const result = await $`workmux status`.text(); + return parseTable(result, (cols) => ({ + worktree: cols[0] ?? "", + status: cols[1] ?? "", + elapsed: cols[2] ?? "", + title: cols[3] ?? "", + })); +} + +async function runChecked(args: string[]): Promise { + const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error(`${args.join(" ")} failed: ${stderr || stdout}`); + } + return stdout.trim(); +} + +export async function addWorktree( + branch: string, + opts?: { prompt?: string; autoName?: boolean } +): Promise { + const args: string[] = ["workmux", "add"]; + if (opts?.autoName) args.push("-A"); + if (opts?.prompt) args.push("-p", opts.prompt); + args.push(branch); + return runChecked(args); +} + +export async function removeWorktree(name: string): Promise { + return runChecked(["workmux", "rm", "--force", name]); +} + +export async function openWorktree(name: string): Promise { + return runChecked(["workmux", "open", name]); +} + +export async function closeWorktree(name: string): Promise { + return runChecked(["workmux", "close", name]); +} + +export async function sendPrompt(name: string, prompt: string): Promise { + return runChecked(["workmux", "send", name, prompt]); +} + +export async function getTmuxSession(): Promise { + try { + const result = await $`tmux list-windows -a -F "#{session_name}:#{window_name}"`.text(); + for (const line of result.trim().split("\n")) { + const [session, window] = line.split(":"); + if (window?.startsWith("wm-")) { + return session!; + } + } + } catch { + // No tmux server running + } + return "0"; +} diff --git a/dev-dashboard/tsconfig.json b/dev-dashboard/tsconfig.json new file mode 100644 index 0000000000..727d86246b --- /dev/null +++ b/dev-dashboard/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/client/**/*.ts"] +} diff --git a/scripts/worktree-env b/scripts/worktree-env index 57561e7f77..097ada196d 100755 --- a/scripts/worktree-env +++ b/scripts/worktree-env @@ -50,6 +50,12 @@ EOF echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port" +# --- Allow direnv so the nix devshell activates in pane commands --- +if command -v direnv &>/dev/null && [ -f .envrc ]; then + direnv allow + echo "direnv allowed" +fi + # --- Create matching windmill-ee-private worktree --- # Check parent directory first (sibling to worktree root), then fall back to home parent_dir="$(cd "$(pwd)/.." && pwd)"