const BRIDGE = "http://127.0.0.1:8988"; const seatSel = document.getElementById("seat"); const screenEl = document.getElementById("screen"); const statusEl = document.getElementById("status"); const ctxEl = document.getElementById("ctx"); const textEl = document.getElementById("text"); const sendBtn = document.getElementById("send"); const refreshBtn = document.getElementById("refresh"); let pollTimer = null; function setStatus(msg, isErr) { statusEl.textContent = msg; statusEl.style.color = isErr ? "#e06565" : ""; } async function loadSeats() { try { const r = await fetch(`${BRIDGE}/seats`); const j = await r.json(); if (!j.ok) throw new Error(j.error || "seats failed"); const prev = seatSel.value; seatSel.innerHTML = ""; for (const s of j.seats || []) { const opt = document.createElement("option"); opt.value = s.seat || s.name || s; opt.textContent = opt.value; seatSel.appendChild(opt); } if (prev && [...seatSel.options].some(o => o.value === prev)) seatSel.value = prev; setStatus(`bridge ok · ${seatSel.options.length} seat(s)`); restartPoll(); } catch (e) { setStatus(`bridge unreachable — is bridge.py running on :8988? (${e.message})`, true); } } // ── Pane parsing ──────────────────────────────────────────────────────────── // The pane is a rendered TUI, not a transcript, so every block is recognised by // the glyph Claude Code prints in front of it. Anything we can't place stays // visible as a plain line — never dropped, or the panel would silently eat // output it didn't understand. const RE = { user: /^❯\s?(.*)$/, bullet: /^[●✢✳✽*]\s+(.*)$/, // A tool head can wrap mid-arguments, so the OPENING paren is the signal — // requiring the closing one silently demotes every long Bash(...) to prose. toolHead: /^([A-Z][A-Za-z_]*)\((.*?)\)?\s*$/, working: /^\S*…\s*\(\d+[\ds\w\s]*[·)]/, expand: /^\s{2,}(.+?)\s+\(ctrl\+o to expand\)\s*$/, thought: /^Thought for\b/, toolRes: /^\s*⎿\s?(.*)$/, spinner: /^[✻✽✢·✶✳*]\s+\S+ for \d+\w*\s*(?:·\s*(.*))?$/, sep: /^[─━═\s]{10,}$/, status: /^\s*(?:📁|🧠|⚡|💜|🗓️|🌙|⏸|ad·|🎮)/, indent: /^\s{2}(?!\s*⎿)(.*)$/, }; // The one statusline row abang wants kept: context left + the rank bar. const CTX_RE = /🧠\s*(\d+)%\s*(\[[^\]]*\])\s*(\S+\s*tok)/; const RANK_RE = /🎮\s*([▰▱]+)\s*(\S+\s*x\d+)/; function parsePane(lines) { const blocks = []; let ctx = null; let cur = null; const flush = () => { if (cur && cur.text.trim()) blocks.push(cur); cur = null; }; const open = (kind, text, title) => { flush(); cur = { kind, text, title }; }; for (const raw of lines) { const line = raw.replace(/\s+$/, ""); if (RE.status.test(line)) { const c = line.match(CTX_RE); if (c) { const r = line.match(RANK_RE); ctx = { pct: c[1], bar: c[2], tok: c[3], rankBar: r ? r[1] : "", rank: r ? r[2] : "" }; } continue; // rest of the statusline widget } if (!line.trim() || RE.sep.test(line)) { flush(); continue; } let m; if ((m = line.match(RE.spinner))) { flush(); blocks.push({ kind: "meta", text: m[1] || line.trim() }); continue; } if ((m = line.match(RE.user))) { if (!m[1].trim()) { flush(); continue; } // the live input box, not a message open("user", m[1]); continue; } if ((m = line.match(RE.toolRes))) { if (cur && cur.kind === "tool") cur.text += "\n" + m[1]; else open("tool", m[1], "result"); continue; } if ((m = line.match(RE.expand))) { const summary = m[1]; if (RE.thought.test(summary)) { open("thinking", summary); continue; } const head = summary.match(/^([A-Za-z]\S*)\s+(.*)$/); open("tool", head ? head[2] : summary, head ? head[1] : null); continue; } if ((m = line.match(RE.bullet))) { if (RE.working.test(m[1])) { flush(); blocks.push({ kind: "pulse", text: m[1] }); continue; } const t = m[1].match(RE.toolHead); if (t) open("tool", t[2], t[1]); else open("assistant", m[1]); continue; } if (cur && (m = line.match(RE.indent))) { // Only the FIRST tool call of a turn gets a bullet; the rest arrive as // bare indented `Tool(...)` heads, so check before treating as prose. const t = m[1].match(RE.toolHead); if (t) open("tool", t[2], t[1]); else cur.text += "\n" + m[1]; continue; } open("plain", line.trim()); } flush(); return { blocks, ctx }; } const LABEL = { user: "❯ abang", assistant: "claude", tool: "tool", thinking: "thinking", plain: "", meta: "" }; function render(blocks) { screenEl.innerHTML = ""; for (const b of blocks) { const el = document.createElement("div"); el.className = `blk ${b.kind}`; if (b.kind === "meta" || b.kind === "pulse") { el.textContent = b.text; } else { const label = b.kind === "tool" ? (b.title || "tool") : LABEL[b.kind]; if (label) { const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = label; el.appendChild(tag); } const body = document.createElement("span"); body.className = "body"; body.textContent = b.text; el.appendChild(body); } screenEl.appendChild(el); } } function renderCtx(ctx) { if (!ctx) { ctxEl.textContent = ""; return; } const rank = ctx.rank ? ` ⚡ ${ctx.rankBar} ${ctx.rank}` : ""; ctxEl.textContent = `🧠 ${ctx.pct}% ${ctx.bar} ${ctx.tok}${rank}`; } let pretty = true; let lastPane = null; // skip the re-render when the pane hasn't moved async function pollScreen() { const seat = seatSel.value; if (!seat) return; try { const r = await fetch(`${BRIDGE}/screen?seat=${encodeURIComponent(seat)}&lines=200`); const j = await r.json(); if (!j.ok) { setStatus(j.error || "screen failed", true); return; } const rawLines = Array.isArray(j.lines) ? j.lines : (j.lines || "").toString().split("\n"); const { blocks, ctx } = parsePane(rawLines); renderCtx(ctx); // ctx chip is cheap, always fresh // Rebuilding identical DOM every 1.5s drops the user's text selection and // fights their scroll. The pane is usually unchanged between polls. const fingerprint = `${pretty}\n${rawLines.join("\n")}`; if (fingerprint === lastPane) return; lastPane = fingerprint; const atBottom = screenEl.scrollTop + screenEl.clientHeight >= screenEl.scrollHeight - 10; if (pretty) render(blocks); else screenEl.textContent = rawLines.join("\n"); if (atBottom) screenEl.scrollTop = screenEl.scrollHeight; } catch (e) { setStatus(`poll failed: ${e.message}`, true); } } function restartPoll() { if (pollTimer) clearInterval(pollTimer); lastPane = null; pollScreen(); pollTimer = setInterval(pollScreen, 1500); } async function sendText() { const seat = seatSel.value; const text = textEl.value; if (!seat || !text.trim()) return; sendBtn.disabled = true; try { const r = await fetch(`${BRIDGE}/inject`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seat, text }), }); const j = await r.json(); if (!j.ok) throw new Error(j.error || "inject failed"); textEl.value = ""; setTimeout(pollScreen, 300); } catch (e) { setStatus(`send failed: ${e.message}`, true); } finally { sendBtn.disabled = false; } } sendBtn.addEventListener("click", sendText); textEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendText(); } }); refreshBtn.addEventListener("click", loadSeats); document.getElementById("compact-toggle").addEventListener("click", (e) => { pretty = !pretty; e.target.style.opacity = pretty ? "1" : ".4"; e.target.title = pretty ? "showing parsed blocks — click for raw pane" : "showing raw pane"; pollScreen(); }); seatSel.addEventListener("change", restartPoll); loadSeats();