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 }