cc-sidepanel: Brave/Chrome MV3 side-panel extension for a live Claude Code seat
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user