Files
cc-sidepanel/extension/sidepanel.js
T
juxtapoandClaude Opus 5 f8ef6e6981 cc-sidepanel: emoji picker, 475 glyphs from celest-chat's table
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>
2026-09-13 02:02:25 +08:00

310 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();