The panel had no picker before this. 8 category tabs plus a recents tab, table lifted verbatim from celest-chat's 07-emoji.js so the house row (💜🌙🐝🔫) carries over. Offline by construction: no library, no fetch, the panel's CSP would block a set anyway. Inserts at the caret rather than appending, and the pad stays open so glyphs can be stacked. Recents (last 24) live in localStorage under `ccsidepanel-emoji-recent`; the pad opens on that tab once it has entries. `#emoji-pad[hidden] { display: none }` is spelled out because an ID selector outranks the UA's `[hidden]` rule — without it the pad never closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
310 lines
14 KiB
JavaScript
310 lines
14 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
|
||
// ── Emoji pad ───────────────────────────────────────────────────────────────
|
||
// Category table lifted verbatim from celest-chat's 07-emoji.js — same house,
|
||
// same taste, and the "house" row only means something in this room. Offline by
|
||
// construction: no library, no fetch, the panel's CSP would block a set anyway.
|
||
const EMOJI = [
|
||
["🐝","house", "💜 🌙 🐝 🔫 ✨ 🕯️ 🐙 🌻 🖤 🩵 🌸 🔥 ⚡ 🎯 🧠 🪄 🛠️ 🧻 📬 🚪 🏠 ♾️ 🌌 🫖 🦁 🐺 💧 🌳 🗼 🎰"],
|
||
["😀","faces", "😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤗 🤭 🤫 🤔 🤨 😐 😑 😶 😏 😒 🙄 😬 😮💨 🤥 😌 😔 😪 🤤 😴 🥱 😵💫 🤐 🥴 🤢 🤮 🤧 🥵 🥶 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 😤 😡 😠 🤬 😈 💀 🤡"],
|
||
["🫶","hands", "👋 🤚 🖐️ ✋ 🖖 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👀 👁️ 🧠 🫀 🫁 🦷 👅 👄"],
|
||
["🐈","animals","🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🙈 🙉 🙊 🐔 🐧 🐦 🐤 🦆 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🪲 🦋 🐌 🐞 🐜 🕷️ 🦂 🐢 🐍 🦎 🐙 🦑 🦐 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🦓 🦍 🐘 🦛 🐫 🦒 🦘 🐈 🐕 🕊️"],
|
||
["🍜","food", "🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🥑 🥦 🥕 🌽 🌶️ 🥔 🍠 🥐 🍞 🥖 🧀 🥚 🍳 🥞 🧇 🥓 🍔 🍟 🍕 🌭 🥪 🌮 🌯 🥙 🍜 🍲 🍛 🍣 🍱 🥟 🍤 🍙 🍚 🍥 🥠 🍦 🍰 🎂 🍫 🍬 🍭 🍮 ☕ 🍵 🧋 🥤 🍺 🍻 🥂 🍷 🥃"],
|
||
["🚀","things", "⌨️ 🖥️ 🖱️ 💻 📱 🕹️ 💾 💿 📀 🔌 🔋 🪫 📷 🎥 📺 📻 🎙️ 🎚️ 🎛️ ⏱️ ⏰ 🕰️ 🔍 🔎 🔒 🔓 🔑 🗝️ 🔨 🪓 ⛏️ 🛠️ 🗡️ ⚔️ 🔧 🪛 🔩 ⚙️ 🧰 🧲 💣 🧨 🪤 🧪 🧬 🔬 🔭 📡 💉 💊 🚀 🛸 ✈️ 🚁 🚗 🏍️ 🚲 ⛵ 🛰️"],
|
||
["🌍","nature", "🌍 🌎 🌏 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘 🌙 🌚 🌛 🌜 ☀️ 🌝 🌞 ⭐ 🌟 💫 ✨ ☄️ 🪐 🌌 ☁️ ⛅ 🌤️ 🌥️ 🌦️ 🌧️ ⛈️ 🌩️ 🌨️ ❄️ ☃️ 🌬️ 🌪️ 🌫️ 🌊 💧 🔥 🌈 🌸 🌺 🌻 🌼 🌷 🌹 🥀 🌿 🍀 🍁 🍂 🌾 🌵 🌲 🌳 🌴"],
|
||
["🔣","symbols","❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 ✅ ❌ ⭕ 🚫 ⛔ ⚠️ 🔴 🟠 🟡 🟢 🔵 🟣 ⚫ ⚪ 🔺 🔻 🔶 🔷 ⬛ ⬜ ▶️ ⏸️ ⏹️ 🔁 🔂 🔀 ➕ ➖ ✖️ ➗ ♾️ ❓ ❗ 💯 🔔 🔕 ⏳ ⌛ 🎯 🏆 🥇 🎁 🎉"],
|
||
];
|
||
const RECENT_KEY = "ccsidepanel-emoji-recent", RECENT_MAX = 24;
|
||
|
||
const emojiPad = document.getElementById("emoji-pad");
|
||
const emojiBtn = document.getElementById("emoji-btn");
|
||
const emojiCats = document.getElementById("emoji-cats");
|
||
const emojiGrid = document.getElementById("emoji-grid");
|
||
const emojiLbl = document.getElementById("emoji-lbl");
|
||
|
||
function recents() {
|
||
try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; } catch (e) { return []; }
|
||
}
|
||
function remember(ch) {
|
||
const list = [ch, ...recents().filter(x => x !== ch)].slice(0, RECENT_MAX);
|
||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch (e) {}
|
||
}
|
||
|
||
function insertEmoji(ch) {
|
||
// Insert at the caret, not at the end — appending mangles a half-typed line.
|
||
const start = textEl.selectionStart ?? textEl.value.length;
|
||
const end = textEl.selectionEnd ?? textEl.value.length;
|
||
textEl.value = textEl.value.slice(0, start) + ch + textEl.value.slice(end);
|
||
const caret = start + ch.length;
|
||
textEl.focus();
|
||
textEl.setSelectionRange(caret, caret);
|
||
}
|
||
|
||
function paintEmoji(list, label) {
|
||
emojiGrid.innerHTML = "";
|
||
emojiLbl.textContent = list.length ? label : `${label} · nothing yet`;
|
||
for (const ch of list) {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.textContent = ch;
|
||
b.addEventListener("click", () => { insertEmoji(ch); remember(ch); }); // pad stays open
|
||
emojiGrid.appendChild(b);
|
||
}
|
||
}
|
||
|
||
function showCat(i) {
|
||
[...emojiCats.children].forEach((b, k) => b.classList.toggle("on", k === i));
|
||
if (i === 0) return paintEmoji(recents(), "recent");
|
||
const [, name, set] = EMOJI[i - 1];
|
||
paintEmoji(set.split(" ").filter(Boolean), name);
|
||
}
|
||
|
||
// tab 0 is recents, then one per category
|
||
[["🕘", "recent"], ...EMOJI.map(c => [c[0], c[1]])].forEach(([icon, name], i) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.textContent = icon;
|
||
b.title = name;
|
||
b.addEventListener("click", () => showCat(i));
|
||
emojiCats.appendChild(b);
|
||
});
|
||
|
||
function toggleEmoji(show) {
|
||
emojiPad.hidden = show === undefined ? !emojiPad.hidden : !show;
|
||
if (!emojiPad.hidden) { showCat(recents().length ? 0 : 1); textEl.focus(); }
|
||
}
|
||
|
||
emojiBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleEmoji(); });
|
||
document.addEventListener("click", (e) => {
|
||
if (!emojiPad.hidden && !emojiPad.contains(e.target)) toggleEmoji(false);
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && !emojiPad.hidden) toggleEmoji(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();
|