commit 8d64c86b1048d0d44333b36459c7ad763af0e2da Author: Muhammad Aizat Date: Fri Sep 11 01:29:42 2026 +0800 cc-sidepanel: Brave/Chrome MV3 side-panel extension for a live Claude Code seat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faff97a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bridge.log +extension/Archived/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5dd4b8 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# CC Sidepanel + +A Brave/Chrome **MV3 side-panel extension** that puts a live Claude Code seat in your browser sidebar — type in the sidebar, it lands in the real terminal pane. Two-way. + +Built by Monica (bridge + extension skeleton), parser and styling pass by Selene, 2026-09-11. + +## Pieces + +- **`bridge.py`** — a localhost HTTP shim on `:8988`. Browser extensions can't reach a unix socket, so this proxies JSON to `injectd.py`'s socket at `/tmp/injectd.sock` (ops: `seats`, `screen`, `inject`). CORS is locked to one `chrome-extension://` origin — no wildcard. +- **`extension/`** — MV3 side panel. Polls `/screen` every 1.5s, parses the raw kitty pane into typed blocks (user / assistant / tool / thinking / live-heartbeat / meta), and sends text back via `/inject`. + +## Dependencies / pre-reqs + +- `injectd.py` is **house infrastructure, not part of this repo.** The bridge expects the socket `/tmp/injectd.sock` to already exist (created by injectd, which runs separately and is shared by every seat). +- The extension ID is deliberately **not** in the code. `bridge.py` reads `CCSIDEPANEL_EXT_ID` from the environment and refuses to start if it's unset (no silent fallback). + +## Setup + +1. Load the extension unpacked from `extension/` at `brave://extensions`. +2. Copy its ID from the `brave://extensions` page. +3. Create the env file: + + ```bash + cp cc-sidepanel.env.example /etc/cc-sidepanel.env + # fill in CCSIDEPANEL_EXT_ID= + ``` + +4. Start/restart the bridge: + + ```bash + systemctl restart cc-sidepanel-bridge # unit: /etc/systemd/system/cc-sidepanel-bridge.service + ``` + + The systemd unit runs as `juxtapo` and reads config via `EnvironmentFile=/etc/cc-sidepanel.env` — both files live **outside** the repo (documented here, not committed). + +## Gotcha: reloading = new ID + +Reloading the extension from a **different path** gives it a **new ID**, and the panel gets CORS-blocked with no visible error. If the panel stops working after a reload, copy the new ID, update `/etc/cc-sidepanel.env`, and restart the unit. + +## License + +House project — MIT. \ No newline at end of file diff --git a/bridge.py b/bridge.py new file mode 100644 index 0000000..3f7da9d --- /dev/null +++ b/bridge.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +cc-sidepanel bridge — localhost HTTP shim so a browser extension +(no unix-socket access) can talk to injectd.py's unix socket. + +Endpoints (GET/POST, JSON): + GET /seats -> injectd {op: seats} + GET /screen?seat=X&lines=60 -> injectd {op: screen} + POST /inject {seat, text} -> injectd {op: inject} + +CORS is locked to chrome-extension:// origins only (set via env +CCSIDEPANEL_EXT_ID, no wildcard fallback — error loud if unset). +""" +import json +import os +import socket +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +INJECTD_SOCK = "/tmp/injectd.sock" +PORT = int(os.environ.get("CCSIDEPANEL_PORT", "8988")) +EXT_ID = os.environ.get("CCSIDEPANEL_EXT_ID") + +if not EXT_ID: + print("CCSIDEPANEL_EXT_ID not set — refusing to guess an allowed origin. " + "Load the unpacked extension, copy its ID from brave://extensions, " + "export CCSIDEPANEL_EXT_ID= before starting bridge.py.", file=sys.stderr) + sys.exit(1) + +ALLOWED_ORIGIN = "chrome-extension://{}".format(EXT_ID) + + +def injectd_call(req): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + s.connect(INJECTD_SOCK) + except (FileNotFoundError, ConnectionRefusedError) as exc: + return {"ok": False, "error": "injectd not reachable at {}: {}".format(INJECTD_SOCK, exc)} + s.sendall((json.dumps(req) + "\n").encode()) + data = b"" + while not data.endswith(b"\n"): + chunk = s.recv(65536) + if not chunk: + break + data += chunk + s.close() + return json.loads(data.decode()) + + +class Handler(BaseHTTPRequestHandler): + def _cors(self): + self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + def _json(self, obj, status=200): + body = json.dumps(obj).encode() + self.send_response(status) + self._cors() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): + self.send_response(204) + self._cors() + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + if parsed.path == "/seats": + return self._json(injectd_call({"op": "seats"})) + if parsed.path == "/screen": + seat = qs.get("seat", [None])[0] + if not seat: + return self._json({"ok": False, "error": "seat required"}, 400) + lines = int(qs.get("lines", ["60"])[0]) + return self._json(injectd_call({"op": "screen", "seat": seat, "lines": lines})) + self._json({"ok": False, "error": "unknown path"}, 404) + + def do_POST(self): + parsed = urlparse(self.path) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw.decode()) + except json.JSONDecodeError: + return self._json({"ok": False, "error": "bad json"}, 400) + if parsed.path == "/inject": + seat, text = payload.get("seat"), payload.get("text") + if not seat or not text: + return self._json({"ok": False, "error": "seat and text required"}, 400) + return self._json(injectd_call({"op": "inject", "seat": seat, "text": text})) + self._json({"ok": False, "error": "unknown path"}, 404) + + def log_message(self, fmt, *args): + pass # keep stdout quiet; run under a real logger if this graduates + + +if __name__ == "__main__": + print("cc-sidepanel bridge on :{} -> {} (allowed origin: {})".format( + PORT, INJECTD_SOCK, ALLOWED_ORIGIN)) + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/cc-sidepanel.env.example b/cc-sidepanel.env.example new file mode 100644 index 0000000..bb0a992 --- /dev/null +++ b/cc-sidepanel.env.example @@ -0,0 +1,3 @@ +# Copy to /etc/cc-sidepanel.env and fill in your actual extension ID. +# Find it at brave://extensions (or chrome://extensions) after loading the extension unpacked. +CCSIDEPANEL_EXT_ID=your-extension-id-here diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..a4d5ed1 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,3 @@ +chrome.sidePanel + .setPanelBehavior({ openPanelOnActionClick: true }) + .catch((e) => console.error("cc-sidepanel: setPanelBehavior failed", e)); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..cf6362b --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 3, + "name": "CC Sidepanel", + "version": "0.1.0", + "description": "Live window into a Claude Code seat, right in the browser sidebar.", + "action": { "default_title": "CC Sidepanel" }, + "side_panel": { "default_path": "sidepanel.html" }, + "background": { "service_worker": "background.js" }, + "permissions": ["sidePanel", "storage"], + "host_permissions": ["http://127.0.0.1:8988/*"] +} diff --git a/extension/sidepanel.html b/extension/sidepanel.html new file mode 100644 index 0000000..3160d80 --- /dev/null +++ b/extension/sidepanel.html @@ -0,0 +1,102 @@ + + + + +CC Sidepanel + + + +
+ + + +
+
+
—
+
connecting…
+
+ + +
+ + + diff --git a/extension/sidepanel.js b/extension/sidepanel.js new file mode 100644 index 0000000..58dbf9a --- /dev/null +++ b/extension/sidepanel.js @@ -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();