feat: add dev-dashboard — browser-based workmux frontend

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 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-20 23:07:32 +00:00
parent d772083573
commit bc7ca9982b
10 changed files with 1169 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
public/dist/
bun.lock
+17
View File
@@ -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"
}
}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Windmill Dev Dashboard</title>
<link rel="stylesheet" href="/dist/app.css">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<aside id="sidebar">
<div class="sidebar-header">
<h1>Windmill</h1>
<button id="btn-new" title="New Worktree">+</button>
</div>
<ul id="worktree-list"></ul>
</aside>
<main id="main">
<div id="top-bar">
<span id="wt-name">Select a worktree</span>
<div id="wt-actions" class="hidden">
<span id="wt-status-badge"></span>
<button id="btn-open" title="Open tmux window">Open</button>
<button id="btn-close" title="Close tmux window">Close</button>
<button id="btn-send" title="Send prompt to agent">Send</button>
<button id="btn-remove" class="danger" title="Remove worktree">Remove</button>
</div>
</div>
<div id="terminal-container"></div>
<div id="placeholder">
<p>Select a worktree from the sidebar to connect</p>
</div>
</main>
</div>
<!-- Dialogs -->
<dialog id="new-dialog">
<form method="dialog">
<h2>New Worktree</h2>
<label>Branch name<input type="text" id="new-branch" required placeholder="my-feature"></label>
<label>Prompt (optional)<textarea id="new-prompt" rows="3" placeholder="Fix the login bug..."></textarea></label>
<div class="dialog-actions">
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
<button type="submit" class="primary">Create</button>
</div>
</form>
</dialog>
<dialog id="send-dialog">
<form method="dialog">
<h2>Send Prompt</h2>
<label>Prompt<textarea id="send-prompt" rows="4" required placeholder="Implement the feature..."></textarea></label>
<div class="dialog-actions">
<button type="button" onclick="this.closest('dialog').close()">Cancel</button>
<button type="submit" class="primary">Send</button>
</div>
</form>
</dialog>
<script type="module" src="/dist/app.js"></script>
</body>
</html>
+288
View File
@@ -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%;
}
+321
View File
@@ -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<T = unknown>(path: string, opts?: RequestInit): Promise<T> {
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<void> {
try {
worktrees = await api<WorktreeInfo[]>("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 = `
<span class="wt-branch">${escapeHtml(wt.branch)}</span>
<span class="wt-meta">
<span><span class="status-dot ${statusDotClass(wt.agent)}"></span>${escapeHtml(wt.agent || "none")}</span>
${wt.mux && wt.mux !== "-" ? `<span>mux: ${escapeHtml(wt.mux)}</span>` : ""}
${isMain ? "<span>main</span>" : ""}
</span>
`;
li.addEventListener("click", () => selectWorktree(wt.branch));
worktreeList.appendChild(li);
}
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// --- 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);
+213
View File
@@ -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<Response> {
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<WsData>({
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<Response> {
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}`);
+125
View File
@@ -0,0 +1,125 @@
import { getTmuxSession } from "./workmux";
interface TerminalSession {
proc: ReturnType<typeof Bun.spawn>;
groupedSessionName: string;
scrollback: string[];
onData: ((data: string) => void) | null;
onExit: ((exitCode: number) => void) | null;
}
const MAX_SCROLLBACK = 5000;
const sessions = new Map<string, TerminalSession>();
let sessionCounter = 0;
function groupedName(): string {
return `wm-dash-${++sessionCounter}`;
}
export async function attach(
worktreeName: string,
cols: number,
rows: number
): Promise<string> {
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<void> {
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;
}
}
+118
View File
@@ -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<T>(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<Worktree[]> {
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<WorktreeStatus[]> {
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<string> {
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<string> {
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<string> {
return runChecked(["workmux", "rm", "--force", name]);
}
export async function openWorktree(name: string): Promise<string> {
return runChecked(["workmux", "open", name]);
}
export async function closeWorktree(name: string): Promise<string> {
return runChecked(["workmux", "close", name]);
}
export async function sendPrompt(name: string, prompt: string): Promise<string> {
return runChecked(["workmux", "send", name, prompt]);
}
export async function getTmuxSession(): Promise<string> {
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";
}
+15
View File
@@ -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"]
}
+6
View File
@@ -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)"