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