cc-sidepanel: Brave/Chrome MV3 side-panel extension for a live Claude Code seat
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
bridge.log
|
||||||
|
extension/Archived/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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=<your-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.
|
||||||
@@ -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=<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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
chrome.sidePanel
|
||||||
|
.setPanelBehavior({ openPanelOnActionClick: true })
|
||||||
|
.catch((e) => console.error("cc-sidepanel: setPanelBehavior failed", e));
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CC Sidepanel</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 10px; background: #16141c; color: #eae6f5;
|
||||||
|
font: 13px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
display: flex; flex-direction: column; height: 100vh; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
select, textarea, button {
|
||||||
|
font: inherit; background: #221f2b; color: #eae6f5;
|
||||||
|
border: 1px solid #3a3548; border-radius: 6px; padding: 6px;
|
||||||
|
}
|
||||||
|
#seat-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||||
|
#seat { flex: 1; }
|
||||||
|
#refresh { cursor: pointer; }
|
||||||
|
#screen {
|
||||||
|
flex: 1; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
|
||||||
|
background: #0e0c13; border: 1px solid #2c2836; border-radius: 6px;
|
||||||
|
padding: 8px; margin-bottom: 8px; font-size: 12px;
|
||||||
|
}
|
||||||
|
#input-row { display: flex; gap: 6px; }
|
||||||
|
#text { flex: 1; resize: none; height: 44px; }
|
||||||
|
#send { cursor: pointer; background: #6b4fd6; border-color: #6b4fd6; padding: 0 14px; }
|
||||||
|
#status { font-size: 11px; opacity: .6; margin-bottom: 6px; }
|
||||||
|
#ctx {
|
||||||
|
font-size: 11px; color: #9d8fd0; margin-bottom: 6px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parsed blocks */
|
||||||
|
.blk { margin: 0 0 8px; padding-left: 8px; border-left: 2px solid transparent; }
|
||||||
|
.blk .tag {
|
||||||
|
display: block; font-size: 10px; letter-spacing: .05em;
|
||||||
|
text-transform: lowercase; opacity: .75; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.blk .body { display: block; }
|
||||||
|
|
||||||
|
.blk.user {
|
||||||
|
border-left-color: #a78bfa;
|
||||||
|
background: linear-gradient(90deg, #241d3d 0%, #1b1729 100%);
|
||||||
|
border-radius: 0 5px 5px 0; padding: 6px 8px 6px 9px;
|
||||||
|
color: #ddd2ff; font-weight: 600;
|
||||||
|
}
|
||||||
|
.blk.user .tag { color: #b9a5f5; font-weight: 700; opacity: .9; }
|
||||||
|
|
||||||
|
.blk.assistant { border-left-color: #3f8f7a; }
|
||||||
|
.blk.assistant .tag { color: #6ec4a8; }
|
||||||
|
|
||||||
|
.blk.tool {
|
||||||
|
border-left-color: #7a6a3f; background: #191621;
|
||||||
|
border-radius: 0 4px 4px 0; padding: 4px 6px 4px 8px;
|
||||||
|
font-size: 11px; color: #c8bda2;
|
||||||
|
}
|
||||||
|
.blk.tool .tag { color: #d6b25f; }
|
||||||
|
.blk.tool .body { opacity: .8; }
|
||||||
|
|
||||||
|
.blk.thinking {
|
||||||
|
border-left-color: #4a4459; font-size: 11px; font-style: italic;
|
||||||
|
color: #8b83a0; opacity: .8;
|
||||||
|
}
|
||||||
|
.blk.thinking .tag { color: #6f6885; }
|
||||||
|
|
||||||
|
.blk.plain { color: #a29ab5; }
|
||||||
|
.blk.meta { font-size: 10px; opacity: .35; text-align: right; margin-bottom: 6px; }
|
||||||
|
|
||||||
|
/* The live heartbeat — the seat is thinking RIGHT NOW. Pure gold, and it
|
||||||
|
breathes, so a glance tells you working vs finished without reading it. */
|
||||||
|
.blk.pulse {
|
||||||
|
color: #ffd700; font-weight: 700; font-size: 11.5px;
|
||||||
|
letter-spacing: .02em; text-shadow: 0 0 8px rgba(255, 215, 0, .35);
|
||||||
|
animation: pulse-breathe 1.6s ease-in-out infinite;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
@keyframes pulse-breathe {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: .55; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.blk.pulse { animation: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="seat-row">
|
||||||
|
<select id="seat"><option>loading seats…</option></select>
|
||||||
|
<button id="refresh" title="refresh seat list">⟳</button>
|
||||||
|
<button id="compact-toggle" title="showing parsed blocks — click for raw pane">🧹</button>
|
||||||
|
</div>
|
||||||
|
<div id="ctx"></div>
|
||||||
|
<div id="status">—</div>
|
||||||
|
<div id="screen">connecting…</div>
|
||||||
|
<div id="input-row">
|
||||||
|
<textarea id="text" placeholder="type, enter to send"></textarea>
|
||||||
|
<button id="send">send</button>
|
||||||
|
</div>
|
||||||
|
<script src="sidepanel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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