// 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 += ``; else if (lines.every((l) => /^\s*\d+\. /.test(l))) out += `
    ${lines.map((l) => `
  1. ${inline(l.replace(/^\s*\d+\. /, ""))}
  2. `).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(); } }