| 1 | import * as vscode from "vscode"; |
| 2 | import { AcpClient, PermissionRequest, PermissionResponse, SessionUpdate } from "./acp/client"; |
| 3 | import { defaultAgentKey, listAgents, permissionMode, resolveAgent } from "./agents"; |
| 4 | |
| 5 | /** |
| 6 | * The siGit chat webview. Owns a single AcpClient at a time, streams agent |
| 7 | * output into the webview, and bridges file reads/writes and permission |
| 8 | * requests back to VS Code. |
| 9 | */ |
| 10 | export class ChatViewProvider implements vscode.WebviewViewProvider { |
| 11 | public static readonly viewType = "sigit.chat"; |
| 12 | |
| 13 | private view: vscode.WebviewView | undefined; |
| 14 | private client: AcpClient | undefined; |
| 15 | private activeAgentKey: string; |
| 16 | private starting: Promise<void> | undefined; |
| 17 | |
| 18 | constructor(private readonly context: vscode.ExtensionContext) { |
| 19 | this.activeAgentKey = defaultAgentKey(); |
| 20 | } |
| 21 | |
| 22 | resolveWebviewView(webviewView: vscode.WebviewView): void { |
| 23 | this.view = webviewView; |
| 24 | webviewView.webview.options = { |
| 25 | enableScripts: true, |
| 26 | localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, "media")] |
| 27 | }; |
| 28 | webviewView.webview.html = this.getHtml(webviewView.webview); |
| 29 | |
| 30 | webviewView.webview.onDidReceiveMessage((message) => { |
| 31 | switch (message?.type) { |
| 32 | case "prompt": |
| 33 | void this.handlePrompt(String(message.text ?? "")); |
| 34 | break; |
| 35 | case "cancel": |
| 36 | this.client?.cancel(); |
| 37 | break; |
| 38 | case "ready": |
| 39 | this.postStatus(`Agent: ${this.activeAgentKey}`); |
| 40 | break; |
| 41 | default: |
| 42 | break; |
| 43 | } |
| 44 | }); |
| 45 | |
| 46 | webviewView.onDidDispose(() => { |
| 47 | this.disposeClient(); |
| 48 | this.view = undefined; |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | reveal(): void { |
| 53 | this.view?.show?.(true); |
| 54 | } |
| 55 | |
| 56 | /** Start a fresh session, discarding any existing client. */ |
| 57 | async newSession(): Promise<void> { |
| 58 | this.disposeClient(); |
| 59 | this.post({ type: "clear" }); |
| 60 | await this.ensureClient(); |
| 61 | this.postStatus("New session started"); |
| 62 | } |
| 63 | |
| 64 | /** Restart the active agent process. */ |
| 65 | async restart(): Promise<void> { |
| 66 | this.disposeClient(); |
| 67 | this.postStatus("Restarting agent…"); |
| 68 | await this.ensureClient(); |
| 69 | this.postStatus(`Agent restarted: ${this.activeAgentKey}`); |
| 70 | } |
| 71 | |
| 72 | /** Pick a configured agent — or browse the registry — and switch to it. */ |
| 73 | async selectAgent(): Promise<void> { |
| 74 | const browse = "$(cloud-download) Browse registry…"; |
| 75 | const agents = listAgents(); |
| 76 | const picked = await vscode.window.showQuickPick( |
| 77 | [ |
| 78 | ...agents.map((agent) => ({ |
| 79 | label: agent.name, |
| 80 | description: agent.key, |
| 81 | detail: `${agent.command} ${agent.args.join(" ")}`.trim(), |
| 82 | key: agent.key |
| 83 | })), |
| 84 | { label: browse, description: "", detail: "Discover and install ACP agents", key: undefined } |
| 85 | ], |
| 86 | { placeHolder: "Select an ACP agent" } |
| 87 | ); |
| 88 | if (!picked) { |
| 89 | return; |
| 90 | } |
| 91 | if (picked.key === undefined) { |
| 92 | await vscode.commands.executeCommand("sigit.browseRegistry"); |
| 93 | return; |
| 94 | } |
| 95 | await this.useAgent(picked.key); |
| 96 | } |
| 97 | |
| 98 | /** Switch the active agent to `key`, restarting the session. */ |
| 99 | async useAgent(key: string): Promise<void> { |
| 100 | this.activeAgentKey = key; |
| 101 | this.disposeClient(); |
| 102 | this.post({ type: "clear" }); |
| 103 | await this.ensureClient(); |
| 104 | this.postStatus(`Agent: ${resolveAgent(key).name}`); |
| 105 | } |
| 106 | |
| 107 | private async handlePrompt(text: string): Promise<void> { |
| 108 | const trimmed = text.trim(); |
| 109 | if (!trimmed) { |
| 110 | return; |
| 111 | } |
| 112 | try { |
| 113 | await this.ensureClient(); |
| 114 | } catch (err) { |
| 115 | this.reportAgentError(err as Error, resolveAgent(this.activeAgentKey).command); |
| 116 | return; |
| 117 | } |
| 118 | if (!this.client) { |
| 119 | this.postError("No agent available."); |
| 120 | return; |
| 121 | } |
| 122 | this.post({ type: "user", text: trimmed }); |
| 123 | this.post({ type: "busy", busy: true }); |
| 124 | try { |
| 125 | const stopReason = await this.client.prompt(trimmed); |
| 126 | this.post({ type: "turnEnd", stopReason }); |
| 127 | } catch (err) { |
| 128 | this.postError(`Prompt failed: ${(err as Error).message}`); |
| 129 | } finally { |
| 130 | this.post({ type: "busy", busy: false }); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /** Spawn the active agent and open a session if not already running. */ |
| 135 | private ensureClient(): Promise<void> { |
| 136 | if (this.client) { |
| 137 | return Promise.resolve(); |
| 138 | } |
| 139 | if (this.starting) { |
| 140 | return this.starting; |
| 141 | } |
| 142 | this.starting = this.startClient().finally(() => { |
| 143 | this.starting = undefined; |
| 144 | }); |
| 145 | return this.starting; |
| 146 | } |
| 147 | |
| 148 | private async startClient(): Promise<void> { |
| 149 | const agent = resolveAgent(this.activeAgentKey); |
| 150 | this.activeAgentKey = agent.key; |
| 151 | const cwd = this.workspaceFolder(); |
| 152 | |
| 153 | const client = new AcpClient({ |
| 154 | requestPermission: (request) => this.handlePermission(request), |
| 155 | readTextFile: (params) => this.readFile(params), |
| 156 | writeTextFile: (params) => this.writeFile(params) |
| 157 | }); |
| 158 | |
| 159 | client.on("update", (update: SessionUpdate) => this.handleUpdate(update)); |
| 160 | client.on("stderr", (chunk: string) => this.post({ type: "log", text: chunk })); |
| 161 | client.on("error", (err: Error) => this.reportAgentError(err, agent.command)); |
| 162 | client.on("exit", (code: number | null) => { |
| 163 | this.postStatus(`Agent exited${code === null ? "" : ` (code ${code})`}`); |
| 164 | if (this.client === client) { |
| 165 | this.client = undefined; |
| 166 | } |
| 167 | }); |
| 168 | |
| 169 | try { |
| 170 | client.spawn({ command: agent.command, args: agent.args, cwd, env: agent.env }); |
| 171 | await client.initialize(cwd); |
| 172 | } catch (err) { |
| 173 | client.dispose(); |
| 174 | throw err; |
| 175 | } |
| 176 | |
| 177 | this.client = client; |
| 178 | this.postStatus(`Connected to ${agent.name}`); |
| 179 | } |
| 180 | |
| 181 | private handleUpdate(update: SessionUpdate): void { |
| 182 | const inner = update.update; |
| 183 | if (!inner || typeof inner.sessionUpdate !== "string") { |
| 184 | return; |
| 185 | } |
| 186 | switch (inner.sessionUpdate) { |
| 187 | case "agent_message_chunk": |
| 188 | this.post({ type: "assistant", text: this.contentText(inner.content) }); |
| 189 | break; |
| 190 | case "agent_thought_chunk": |
| 191 | this.post({ type: "thought", text: this.contentText(inner.content) }); |
| 192 | break; |
| 193 | case "tool_call": |
| 194 | case "tool_call_update": |
| 195 | this.post({ |
| 196 | type: "tool", |
| 197 | // May be undefined on an update; the webview keeps the prior label |
| 198 | // and only mutates status/progress in that case. |
| 199 | title: (inner.title as string | undefined) ?? (inner.kind as string | undefined), |
| 200 | status: (inner.status as string) ?? "", |
| 201 | toolCallId: inner.toolCallId as string | undefined |
| 202 | }); |
| 203 | break; |
| 204 | default: |
| 205 | break; |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | private async handlePermission(request: PermissionRequest): Promise<PermissionResponse> { |
| 210 | const mode = permissionMode(); |
| 211 | const options = request.options ?? []; |
| 212 | const allowOption = options.find((o) => /allow|yes|approve/i.test(o.name ?? o.optionId)); |
| 213 | const denyOption = options.find((o) => /deny|no|reject|cancel/i.test(o.name ?? o.optionId)); |
| 214 | |
| 215 | if (mode === "allow" && allowOption) { |
| 216 | return { outcome: { outcome: "selected", optionId: allowOption.optionId } }; |
| 217 | } |
| 218 | if (mode === "deny") { |
| 219 | return denyOption |
| 220 | ? { outcome: { outcome: "selected", optionId: denyOption.optionId } } |
| 221 | : { outcome: { outcome: "cancelled" } }; |
| 222 | } |
| 223 | |
| 224 | // prompt mode → ask the user |
| 225 | const label = this.permissionLabel(request); |
| 226 | if (options.length > 0) { |
| 227 | const choice = await vscode.window.showInformationMessage( |
| 228 | label, |
| 229 | { modal: true }, |
| 230 | ...options.map((o) => o.name ?? o.optionId) |
| 231 | ); |
| 232 | if (!choice) { |
| 233 | return { outcome: { outcome: "cancelled" } }; |
| 234 | } |
| 235 | const selected = options.find((o) => (o.name ?? o.optionId) === choice); |
| 236 | return selected |
| 237 | ? { outcome: { outcome: "selected", optionId: selected.optionId } } |
| 238 | : { outcome: { outcome: "cancelled" } }; |
| 239 | } |
| 240 | |
| 241 | const choice = await vscode.window.showInformationMessage( |
| 242 | label, |
| 243 | { modal: true }, |
| 244 | "Allow", |
| 245 | "Deny" |
| 246 | ); |
| 247 | if (choice === "Allow" && allowOption) { |
| 248 | return { outcome: { outcome: "selected", optionId: allowOption.optionId } }; |
| 249 | } |
| 250 | return { outcome: { outcome: "cancelled" } }; |
| 251 | } |
| 252 | |
| 253 | private permissionLabel(request: PermissionRequest): string { |
| 254 | const tc = request.toolCall as { title?: string; kind?: string } | undefined; |
| 255 | const what = tc?.title ?? tc?.kind ?? "perform an action"; |
| 256 | return `The agent wants to ${what}. Allow?`; |
| 257 | } |
| 258 | |
| 259 | private async readFile(params: { path: string }): Promise<string> { |
| 260 | const uri = this.resolveUri(params.path); |
| 261 | const bytes = await vscode.workspace.fs.readFile(uri); |
| 262 | return Buffer.from(bytes).toString("utf8"); |
| 263 | } |
| 264 | |
| 265 | private async writeFile(params: { path: string; content: string }): Promise<void> { |
| 266 | const uri = this.resolveUri(params.path); |
| 267 | await vscode.workspace.fs.writeFile(uri, Buffer.from(params.content, "utf8")); |
| 268 | } |
| 269 | |
| 270 | private resolveUri(p: string): vscode.Uri { |
| 271 | if (p.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(p)) { |
| 272 | return vscode.Uri.file(p); |
| 273 | } |
| 274 | const folder = vscode.workspace.workspaceFolders?.[0]; |
| 275 | if (folder) { |
| 276 | return vscode.Uri.joinPath(folder.uri, p); |
| 277 | } |
| 278 | return vscode.Uri.file(p); |
| 279 | } |
| 280 | |
| 281 | private workspaceFolder(): string { |
| 282 | return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); |
| 283 | } |
| 284 | |
| 285 | private contentText(content: unknown): string { |
| 286 | if (typeof content === "string") { |
| 287 | return content; |
| 288 | } |
| 289 | if (Array.isArray(content)) { |
| 290 | return content.map((c) => this.contentText(c)).join(""); |
| 291 | } |
| 292 | if (content && typeof content === "object") { |
| 293 | const c = content as { type?: string; text?: string }; |
| 294 | if (typeof c.text === "string") { |
| 295 | return c.text; |
| 296 | } |
| 297 | } |
| 298 | return ""; |
| 299 | } |
| 300 | |
| 301 | private post(message: Record<string, unknown>): void { |
| 302 | void this.view?.webview.postMessage(message); |
| 303 | } |
| 304 | |
| 305 | private postStatus(text: string): void { |
| 306 | this.post({ type: "status", text }); |
| 307 | } |
| 308 | |
| 309 | private postError(text: string): void { |
| 310 | this.post({ type: "error", text }); |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Surface an agent failure in the chat and, for a missing binary, raise a |
| 315 | * modal with one-click actions (open settings / install docs). ENOENT here |
| 316 | * almost always means the `command` isn't on the PATH the editor inherited. |
| 317 | */ |
| 318 | private reportAgentError(err: Error, command: string): void { |
| 319 | const code = (err as { code?: string }).code; |
| 320 | const notFound = code === "AGENT_NOT_FOUND" || code === "ENOENT"; |
| 321 | if (notFound) { |
| 322 | const message = |
| 323 | `Couldn't launch the "${command}" agent — the executable wasn't found. ` + |
| 324 | `Install it from code.sigit.si and ensure it's on your PATH, or set an ` + |
| 325 | `absolute path in the "sigit.agents" setting.`; |
| 326 | this.postError(message); |
| 327 | void this.offerAgentSetup(message); |
| 328 | return; |
| 329 | } |
| 330 | this.postError(`Agent error: ${err.message}`); |
| 331 | } |
| 332 | |
| 333 | private async offerAgentSetup(message: string): Promise<void> { |
| 334 | const openSettings = "Open Settings"; |
| 335 | const installGuide = "Install Guide"; |
| 336 | const choice = await vscode.window.showErrorMessage(message, openSettings, installGuide); |
| 337 | if (choice === openSettings) { |
| 338 | await vscode.commands.executeCommand("workbench.action.openSettings", "sigit.agents"); |
| 339 | } else if (choice === installGuide) { |
| 340 | await vscode.env.openExternal(vscode.Uri.parse("https://code.sigit.si")); |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | private disposeClient(): void { |
| 345 | this.client?.dispose(); |
| 346 | this.client = undefined; |
| 347 | } |
| 348 | |
| 349 | private getHtml(webview: vscode.Webview): string { |
| 350 | const nonce = getNonce(); |
| 351 | const scriptUri = webview.asWebviewUri( |
| 352 | vscode.Uri.joinPath(this.context.extensionUri, "media", "main.js") |
| 353 | ); |
| 354 | const styleUri = webview.asWebviewUri( |
| 355 | vscode.Uri.joinPath(this.context.extensionUri, "media", "main.css") |
| 356 | ); |
| 357 | const logoUri = webview.asWebviewUri( |
| 358 | vscode.Uri.joinPath(this.context.extensionUri, "media", "icon.png") |
| 359 | ); |
| 360 | const csp = [ |
| 361 | `default-src 'none'`, |
| 362 | `img-src ${webview.cspSource}`, |
| 363 | `style-src ${webview.cspSource}`, |
| 364 | `script-src 'nonce-${nonce}'`, |
| 365 | `font-src ${webview.cspSource}` |
| 366 | ].join("; "); |
| 367 | |
| 368 | return /* html */ `<!DOCTYPE html> |
| 369 | <html lang="en"> |
| 370 | <head> |
| 371 | <meta charset="UTF-8" /> |
| 372 | <meta http-equiv="Content-Security-Policy" content="${csp}" /> |
| 373 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 374 | <link href="${styleUri}" rel="stylesheet" /> |
| 375 | <title>siGit Chat</title> |
| 376 | </head> |
| 377 | <body> |
| 378 | <header class="brand"> |
| 379 | <img class="brand-logo" src="${logoUri}" alt="" /> |
| 380 | <span class="brand-name">siGit Code</span> |
| 381 | </header> |
| 382 | <div id="messages" class="messages"></div> |
| 383 | <div id="status" class="status"></div> |
| 384 | <form id="composer" class="composer"> |
| 385 | <textarea id="input" class="input" rows="2" placeholder="Ask the on-device agent…"></textarea> |
| 386 | <button id="send" class="send" type="submit">Send</button> |
| 387 | </form> |
| 388 | <script nonce="${nonce}" src="${scriptUri}"></script> |
| 389 | </body> |
| 390 | </html>`; |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | function getNonce(): string { |
| 395 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| 396 | let text = ""; |
| 397 | for (let i = 0; i < 32; i++) { |
| 398 | text += chars.charAt(Math.floor(Math.random() * chars.length)); |
| 399 | } |
| 400 | return text; |
| 401 | } |