// The chat panel: connection status, streamed replies, tool activity, and the input box.
import type { ChatEvent } from "../chat/client.ts";
function escapeHtml(s: string) { return s.replace(/&/g, "&").replace(//g, ">"); }
/** Minimal markdown: fenced code, inline code, bold, bullet lists, paragraphs. */
export function renderMarkdown(src: string): string {
const parts = src.split(/```/);
let out = "";
parts.forEach((part, i) => {
if (i % 2 === 1) { const body = part.replace(/^[a-z]*\n/, ""); out += `
${escapeHtml(body)}
`; return; }
const blocks = part.split(/\n{2,}/);
for (const b of blocks) {
const lines = b.split("\n").filter((l) => l.length);
if (!lines.length) continue;
const inline = (t: string) => escapeHtml(t).replace(/`([^`]+)`/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1");
if (lines.every((l) => /^\s*[-*] /.test(l))) out += `${lines.map((l) => `- ${inline(l.replace(/^\s*[-*] /, ""))}
`).join("")}
`;
else if (lines.every((l) => /^\s*\d+\. /.test(l))) out += `${lines.map((l) => `- ${inline(l.replace(/^\s*\d+\. /, ""))}
`).join("")}
`;
else if (/^#{1,3} /.test(lines[0]) && lines.length === 1) out += `${inline(lines[0].replace(/^#+ /, ""))}
`;
else out += `${lines.map(inline).join("
")}
`;
}
});
return out;
}
export class ChatPanel {
private readonly log: HTMLElement; private readonly status: HTMLElement; private readonly input: HTMLTextAreaElement;
private readonly sendBtn: HTMLButtonElement; private readonly stopBtn: HTMLButtonElement; private readonly help: HTMLElement;
private current: { el: HTMLElement; text: string } | null = null;
private working = false; private connected = false;
onSend: ((text: string) => void) | null = null; onInterrupt: (() => void) | null = null; onReset: (() => void) | null = null;
constructor(root: HTMLElement, bridgeUrl: string) {
root.innerHTML = `
not connected
`;
this.log = root.querySelector(".chat-log")!; this.status = root.querySelector(".chat-status")!; this.help = root.querySelector(".chat-help")!;
this.input = root.querySelector("textarea")!; this.sendBtn = root.querySelector(".chat-send")!; this.stopBtn = root.querySelector(".chat-stop")!;
this.help.innerHTML = `Chat with Claude Code about this app. It needs the local bridge running on your machine:
git clone https://vault1.magland.org/proof-of-concept/cymsimweb && cd cymsimweb && npm install && npm run bridge
It uses your Claude Code login and connects at ${escapeHtml(bridgeUrl)} (change with ?bridge=ws://host:port).`;
this.sendBtn.onclick = () => this.submit();
this.stopBtn.onclick = () => this.onInterrupt?.();
root.querySelector(".chat-new")!.onclick = () => { this.onReset?.(); this.log.replaceChildren(); this.current = null; this.system("new session"); };
root.querySelector(".chat-close")!.onclick = () => root.dispatchEvent(new CustomEvent("chat-close", { bubbles: true }));
this.input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.submit(); } });
this.updateButtons();
}
focus() { this.input.focus(); }
private submit() {
const text = this.input.value.trim();
if (!text || !this.connected) return;
this.input.value = "";
this.append("user", text);
this.current = null;
this.onSend?.(text);
}
private append(cls: string, text: string): HTMLElement {
const el = document.createElement("div"); el.className = `msg ${cls}`;
if (cls === "assistant") el.innerHTML = renderMarkdown(text); else el.textContent = text;
this.log.appendChild(el); this.log.scrollTop = this.log.scrollHeight;
return el;
}
private system(text: string) { this.append("system", text); }
private updateButtons() {
this.sendBtn.disabled = !this.connected; this.stopBtn.disabled = !this.working;
this.status.textContent = !this.connected ? "not connected" : this.working ? "Claude is working ..." : "connected";
this.status.parentElement!.querySelector(".dot")!.className = `dot ${!this.connected ? "off" : this.working ? "busy" : "on"}`;
this.help.style.display = this.connected ? "none" : "";
}
handle(e: ChatEvent) {
switch (e.type) {
case "connection": this.connected = e.state === "open"; if (e.state === "closed") this.working = false; break;
case "hello": this.system(`bridge ready (model: ${e.model})`); break;
case "init": break;
case "status": this.working = e.state === "working"; if (!this.working) this.current = null; break;
case "block_start": this.current = null; break;
case "delta":
if (!this.current) this.current = { el: this.append("assistant", ""), text: "" };
this.current.text += e.text; this.current.el.innerHTML = renderMarkdown(this.current.text); this.log.scrollTop = this.log.scrollHeight;
break;
case "tool_use": this.current = null; this.append("tool", `${e.name} ${e.input}`); break;
case "tool_call": break;
case "result": if (!e.ok) this.system(`ended with ${e.error}`); break;
case "reset": this.system("session reset"); break;
case "error": this.system(`error: ${e.message}`); break;
}
this.updateButtons();
}
}