Raw
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 }