08a7051Chat panel with a local Claude Code bridge: app-state, YAML, run and playback toolsJeremy Magland 1// The chat panel: connection status, streamed replies, tool activity, and the input box.
2import type { ChatEvent } from "../chat/client.ts";
4function escapeHtml(s: string) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
6/** Minimal markdown: fenced code, inline code, bold, bullet lists, paragraphs. */
7export function renderMarkdown(src: string): string {
8 const parts = src.split(/```/);
9 let out = "";
10 parts.forEach((part, i) => {
11 if (i % 2 === 1) { const body = part.replace(/^[a-z]*\n/, ""); out += `<pre><code>${escapeHtml(body)}</code></pre>`; return; }
12 const blocks = part.split(/\n{2,}/);
13 for (const b of blocks) {
14 const lines = b.split("\n").filter((l) => l.length);
15 if (!lines.length) continue;
16 const inline = (t: string) => escapeHtml(t).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
17 if (lines.every((l) => /^\s*[-*] /.test(l))) out += `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`;
18 else if (lines.every((l) => /^\s*\d+\. /.test(l))) out += `<ol>${lines.map((l) => `<li>${inline(l.replace(/^\s*\d+\. /, ""))}</li>`).join("")}</ol>`;
19 else if (/^#{1,3} /.test(lines[0]) && lines.length === 1) out += `<h4>${inline(lines[0].replace(/^#+ /, ""))}</h4>`;
20 else out += `<p>${lines.map(inline).join("<br>")}</p>`;
21 }
22 });
23 return out;
24}
26export class ChatPanel {
27 private readonly log: HTMLElement; private readonly status: HTMLElement; private readonly input: HTMLTextAreaElement;
28 private readonly sendBtn: HTMLButtonElement; private readonly stopBtn: HTMLButtonElement; private readonly help: HTMLElement;
29 private current: { el: HTMLElement; text: string } | null = null;
30 private working = false; private connected = false;
31 onSend: ((text: string) => void) | null = null; onInterrupt: (() => void) | null = null; onReset: (() => void) | null = null;
33 constructor(root: HTMLElement, bridgeUrl: string) {
34 root.innerHTML = `
35 <div class="chat-head"><span class="dot"></span><span class="chat-status">not connected</span>
36 <span class="spacer"></span><button class="chat-new" title="start a new session">New</button><button class="chat-close" title="hide the panel">×</button></div>
37 <div class="chat-help"></div>
38 <div class="chat-log"></div>
39 <div class="chat-input"><textarea rows="2" placeholder="Ask Claude to set up a cymbal, run it, or explain a result"></textarea>
40 <div class="chat-buttons"><button class="chat-stop" disabled>Stop</button><button class="chat-send primary">Send</button></div></div>`;
41 this.log = root.querySelector(".chat-log")!; this.status = root.querySelector(".chat-status")!; this.help = root.querySelector(".chat-help")!;
42 this.input = root.querySelector("textarea")!; this.sendBtn = root.querySelector(".chat-send")!; this.stopBtn = root.querySelector(".chat-stop")!;
43 this.help.innerHTML = `Chat with Claude Code about this app. It needs the local bridge running on your machine:<br>
44 <code>git clone https://vault1.magland.org/proof-of-concept/cymsimweb && cd cymsimweb && npm install && npm run bridge</code><br>
45 It uses your Claude Code login and connects at <code>${escapeHtml(bridgeUrl)}</code> (change with <code>?bridge=ws://host:port</code>).`;
46 this.sendBtn.onclick = () => this.submit();
47 this.stopBtn.onclick = () => this.onInterrupt?.();
48 root.querySelector<HTMLButtonElement>(".chat-new")!.onclick = () => { this.onReset?.(); this.log.replaceChildren(); this.current = null; this.system("new session"); };
49 root.querySelector<HTMLButtonElement>(".chat-close")!.onclick = () => root.dispatchEvent(new CustomEvent("chat-close", { bubbles: true }));
50 this.input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.submit(); } });
51 this.updateButtons();
52 }
54 focus() { this.input.focus(); }
56 private submit() {
57 const text = this.input.value.trim();
58 if (!text || !this.connected) return;
59 this.input.value = "";
60 this.append("user", text);
61 this.current = null;
62 this.onSend?.(text);
63 }
65 private append(cls: string, text: string): HTMLElement {
66 const el = document.createElement("div"); el.className = `msg ${cls}`;
67 if (cls === "assistant") el.innerHTML = renderMarkdown(text); else el.textContent = text;
68 this.log.appendChild(el); this.log.scrollTop = this.log.scrollHeight;
69 return el;
70 }
71 private system(text: string) { this.append("system", text); }
73 private updateButtons() {
74 this.sendBtn.disabled = !this.connected; this.stopBtn.disabled = !this.working;
75 this.status.textContent = !this.connected ? "not connected" : this.working ? "Claude is working ..." : "connected";
76 this.status.parentElement!.querySelector(".dot")!.className = `dot ${!this.connected ? "off" : this.working ? "busy" : "on"}`;
77 this.help.style.display = this.connected ? "none" : "";
78 }
80 handle(e: ChatEvent) {
81 switch (e.type) {
82 case "connection": this.connected = e.state === "open"; if (e.state === "closed") this.working = false; break;
83 case "hello": this.system(`bridge ready (model: ${e.model})`); break;
84 case "init": break;
85 case "status": this.working = e.state === "working"; if (!this.working) this.current = null; break;
86 case "block_start": this.current = null; break;
87 case "delta":
88 if (!this.current) this.current = { el: this.append("assistant", ""), text: "" };
89 this.current.text += e.text; this.current.el.innerHTML = renderMarkdown(this.current.text); this.log.scrollTop = this.log.scrollHeight;
90 break;
91 case "tool_use": this.current = null; this.append("tool", `${e.name} ${e.input}`); break;
92 case "tool_call": break;
93 case "result": if (!e.ok) this.system(`ended with ${e.error}`); break;
94 case "reset": this.system("session reset"); break;
95 case "error": this.system(`error: ${e.message}`); break;
96 }
97 this.updateButtons();
98 }
99}