| 1 | // @ts-check |
| 2 | (function () { |
| 3 | const vscode = acquireVsCodeApi(); |
| 4 | |
| 5 | const messagesEl = document.getElementById("messages"); |
| 6 | const statusEl = document.getElementById("status"); |
| 7 | const formEl = document.getElementById("composer"); |
| 8 | const inputEl = /** @type {HTMLTextAreaElement} */ (document.getElementById("input")); |
| 9 | const sendEl = /** @type {HTMLButtonElement} */ (document.getElementById("send")); |
| 10 | |
| 11 | // The assistant streams in chunks; keep a handle to the current bubble so we |
| 12 | // can append to it rather than creating a new one per chunk. |
| 13 | let currentAssistant = null; |
| 14 | let busy = false; |
| 15 | |
| 16 | // Tool calls arrive as tool_call -> tool_call_update sharing one toolCallId. |
| 17 | // Track each row by id so updates mutate it in place instead of stacking. |
| 18 | const toolEls = new Map(); |
| 19 | |
| 20 | function scrollToBottom() { |
| 21 | messagesEl.scrollTop = messagesEl.scrollHeight; |
| 22 | } |
| 23 | |
| 24 | function addMessage(role, text) { |
| 25 | const el = document.createElement("div"); |
| 26 | el.className = "message message-" + role; |
| 27 | const body = document.createElement("div"); |
| 28 | body.className = "bubble"; |
| 29 | body.textContent = text; |
| 30 | el.appendChild(body); |
| 31 | messagesEl.appendChild(el); |
| 32 | scrollToBottom(); |
| 33 | return body; |
| 34 | } |
| 35 | |
| 36 | function appendAssistant(text) { |
| 37 | if (!currentAssistant) { |
| 38 | currentAssistant = addMessage("assistant", ""); |
| 39 | } |
| 40 | currentAssistant.textContent += text; |
| 41 | scrollToBottom(); |
| 42 | } |
| 43 | |
| 44 | function endAssistant() { |
| 45 | currentAssistant = null; |
| 46 | } |
| 47 | |
| 48 | function parsePercent(title) { |
| 49 | const m = /\((\d+)%\)/.exec(title || ""); |
| 50 | if (!m) { |
| 51 | return null; |
| 52 | } |
| 53 | const n = parseInt(m[1], 10); |
| 54 | return isNaN(n) ? null : Math.max(0, Math.min(100, n)); |
| 55 | } |
| 56 | |
| 57 | function stripPercent(title) { |
| 58 | return (title || "").replace(/\s*\(\d+%\)/, "").trim(); |
| 59 | } |
| 60 | |
| 61 | // Render (or update in place) a tool-call row keyed by toolCallId. A title |
| 62 | // like "Downloading … (42%)" is shown as a label plus a progress bar. |
| 63 | function renderTool(message) { |
| 64 | const key = message.toolCallId || null; |
| 65 | let entry = key ? toolEls.get(key) : null; |
| 66 | |
| 67 | if (!entry) { |
| 68 | const el = document.createElement("div"); |
| 69 | el.className = "message message-tool"; |
| 70 | const bubble = document.createElement("div"); |
| 71 | bubble.className = "bubble"; |
| 72 | const labelEl = document.createElement("div"); |
| 73 | labelEl.className = "tool-label"; |
| 74 | const progress = document.createElement("div"); |
| 75 | progress.className = "tool-progress"; |
| 76 | progress.hidden = true; |
| 77 | const bar = document.createElement("div"); |
| 78 | bar.className = "tool-progress-bar"; |
| 79 | progress.appendChild(bar); |
| 80 | bubble.appendChild(labelEl); |
| 81 | bubble.appendChild(progress); |
| 82 | el.appendChild(bubble); |
| 83 | messagesEl.appendChild(el); |
| 84 | entry = { el, labelEl, progress, bar, title: "", status: "" }; |
| 85 | if (key) { |
| 86 | toolEls.set(key, entry); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Updates may omit fields; only overwrite what we were given. |
| 91 | if (typeof message.title === "string" && message.title) { |
| 92 | entry.title = message.title; |
| 93 | } |
| 94 | if (typeof message.status === "string" && message.status) { |
| 95 | entry.status = message.status; |
| 96 | } |
| 97 | |
| 98 | const pct = parsePercent(entry.title); |
| 99 | const text = (pct === null ? entry.title : stripPercent(entry.title)) || "tool"; |
| 100 | entry.labelEl.textContent = text + (entry.status ? " — " + entry.status : ""); |
| 101 | entry.el.className = "message message-tool" + (entry.status ? " tool-" + entry.status : ""); |
| 102 | |
| 103 | if (pct === null) { |
| 104 | entry.progress.hidden = true; |
| 105 | } else { |
| 106 | entry.progress.hidden = false; |
| 107 | entry.bar.style.width = pct + "%"; |
| 108 | } |
| 109 | scrollToBottom(); |
| 110 | } |
| 111 | |
| 112 | function setStatus(text) { |
| 113 | statusEl.textContent = text; |
| 114 | } |
| 115 | |
| 116 | function setBusy(value) { |
| 117 | busy = value; |
| 118 | sendEl.disabled = value; |
| 119 | sendEl.textContent = value ? "…" : "Send"; |
| 120 | } |
| 121 | |
| 122 | function send() { |
| 123 | const text = inputEl.value.trim(); |
| 124 | if (!text || busy) { |
| 125 | return; |
| 126 | } |
| 127 | endAssistant(); |
| 128 | vscode.postMessage({ type: "prompt", text }); |
| 129 | inputEl.value = ""; |
| 130 | } |
| 131 | |
| 132 | formEl.addEventListener("submit", (e) => { |
| 133 | e.preventDefault(); |
| 134 | send(); |
| 135 | }); |
| 136 | |
| 137 | inputEl.addEventListener("keydown", (e) => { |
| 138 | // Enter sends; Shift+Enter inserts a newline. |
| 139 | if (e.key === "Enter" && !e.shiftKey) { |
| 140 | e.preventDefault(); |
| 141 | send(); |
| 142 | } |
| 143 | }); |
| 144 | |
| 145 | window.addEventListener("message", (event) => { |
| 146 | const message = event.data; |
| 147 | switch (message.type) { |
| 148 | case "user": |
| 149 | endAssistant(); |
| 150 | addMessage("user", message.text); |
| 151 | break; |
| 152 | case "assistant": |
| 153 | appendAssistant(message.text || ""); |
| 154 | break; |
| 155 | case "thought": |
| 156 | addMessage("thought", message.text || ""); |
| 157 | break; |
| 158 | case "tool": |
| 159 | renderTool(message); |
| 160 | break; |
| 161 | case "status": |
| 162 | setStatus(message.text || ""); |
| 163 | break; |
| 164 | case "error": |
| 165 | endAssistant(); |
| 166 | addMessage("error", message.text || "Error"); |
| 167 | break; |
| 168 | case "log": |
| 169 | // Agent stderr — surface quietly in the status line. |
| 170 | if (message.text) { |
| 171 | setStatus(String(message.text).trim().split("\n").pop() || ""); |
| 172 | } |
| 173 | break; |
| 174 | case "busy": |
| 175 | setBusy(!!message.busy); |
| 176 | break; |
| 177 | case "turnEnd": |
| 178 | endAssistant(); |
| 179 | setBusy(false); |
| 180 | break; |
| 181 | case "clear": |
| 182 | messagesEl.innerHTML = ""; |
| 183 | toolEls.clear(); |
| 184 | endAssistant(); |
| 185 | break; |
| 186 | default: |
| 187 | break; |
| 188 | } |
| 189 | }); |
| 190 | |
| 191 | vscode.postMessage({ type: "ready" }); |
| 192 | })(); |