Raw
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 an agent from the registry and switch to it. */
73 async selectAgent(): Promise<void> {
74 const agents = listAgents();
75 const picked = await vscode.window.showQuickPick(
76 agents.map((agent) => ({
77 label: agent.name,
78 description: agent.key,
79 detail: `${agent.command} ${agent.args.join(" ")}`.trim(),
80 agent
81 })),
82 { placeHolder: "Select an ACP agent" }
83 );
84 if (!picked) {
85 return;
86 }
87 this.activeAgentKey = picked.agent.key;
88 this.disposeClient();
89 this.post({ type: "clear" });
90 await this.ensureClient();
91 this.postStatus(`Agent: ${picked.agent.name}`);
92 }
93
94 private async handlePrompt(text: string): Promise<void> {
95 const trimmed = text.trim();
96 if (!trimmed) {
97 return;
98 }
99 try {
100 await this.ensureClient();
101 } catch (err) {
102 this.postError(`Failed to start agent: ${(err as Error).message}`);
103 return;
104 }
105 if (!this.client) {
106 this.postError("No agent available.");
107 return;
108 }
109 this.post({ type: "user", text: trimmed });
110 this.post({ type: "busy", busy: true });
111 try {
112 const stopReason = await this.client.prompt(trimmed);
113 this.post({ type: "turnEnd", stopReason });
114 } catch (err) {
115 this.postError(`Prompt failed: ${(err as Error).message}`);
116 } finally {
117 this.post({ type: "busy", busy: false });
118 }
119 }
120
121 /** Spawn the active agent and open a session if not already running. */
122 private ensureClient(): Promise<void> {
123 if (this.client) {
124 return Promise.resolve();
125 }
126 if (this.starting) {
127 return this.starting;
128 }
129 this.starting = this.startClient().finally(() => {
130 this.starting = undefined;
131 });
132 return this.starting;
133 }
134
135 private async startClient(): Promise<void> {
136 const agent = resolveAgent(this.activeAgentKey);
137 this.activeAgentKey = agent.key;
138 const cwd = this.workspaceFolder();
139
140 const client = new AcpClient({
141 requestPermission: (request) => this.handlePermission(request),
142 readTextFile: (params) => this.readFile(params),
143 writeTextFile: (params) => this.writeFile(params)
144 });
145
146 client.on("update", (update: SessionUpdate) => this.handleUpdate(update));
147 client.on("stderr", (chunk: string) => this.post({ type: "log", text: chunk }));
148 client.on("error", (err: Error) => this.postError(`Agent error: ${err.message}`));
149 client.on("exit", (code: number | null) => {
150 this.postStatus(`Agent exited${code === null ? "" : ` (code ${code})`}`);
151 if (this.client === client) {
152 this.client = undefined;
153 }
154 });
155
156 try {
157 client.spawn({ command: agent.command, args: agent.args, cwd, env: agent.env });
158 await client.initialize(cwd);
159 } catch (err) {
160 client.dispose();
161 throw err;
162 }
163
164 this.client = client;
165 this.postStatus(`Connected to ${agent.name}`);
166 }
167
168 private handleUpdate(update: SessionUpdate): void {
169 const inner = update.update;
170 if (!inner || typeof inner.sessionUpdate !== "string") {
171 return;
172 }
173 switch (inner.sessionUpdate) {
174 case "agent_message_chunk":
175 this.post({ type: "assistant", text: this.contentText(inner.content) });
176 break;
177 case "agent_thought_chunk":
178 this.post({ type: "thought", text: this.contentText(inner.content) });
179 break;
180 case "tool_call":
181 case "tool_call_update":
182 this.post({
183 type: "tool",
184 title: (inner.title as string) ?? (inner.kind as string) ?? "tool",
185 status: (inner.status as string) ?? "",
186 toolCallId: inner.toolCallId as string | undefined
187 });
188 break;
189 default:
190 break;
191 }
192 }
193
194 private async handlePermission(request: PermissionRequest): Promise<PermissionResponse> {
195 const mode = permissionMode();
196 const options = request.options ?? [];
197 const allowOption = options.find((o) => /allow|yes|approve/i.test(o.name ?? o.optionId));
198 const denyOption = options.find((o) => /deny|no|reject|cancel/i.test(o.name ?? o.optionId));
199
200 if (mode === "allow" && allowOption) {
201 return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
202 }
203 if (mode === "deny") {
204 return denyOption
205 ? { outcome: { outcome: "selected", optionId: denyOption.optionId } }
206 : { outcome: { outcome: "cancelled" } };
207 }
208
209 // prompt mode → ask the user
210 const label = this.permissionLabel(request);
211 if (options.length > 0) {
212 const choice = await vscode.window.showInformationMessage(
213 label,
214 { modal: true },
215 ...options.map((o) => o.name ?? o.optionId)
216 );
217 if (!choice) {
218 return { outcome: { outcome: "cancelled" } };
219 }
220 const selected = options.find((o) => (o.name ?? o.optionId) === choice);
221 return selected
222 ? { outcome: { outcome: "selected", optionId: selected.optionId } }
223 : { outcome: { outcome: "cancelled" } };
224 }
225
226 const choice = await vscode.window.showInformationMessage(
227 label,
228 { modal: true },
229 "Allow",
230 "Deny"
231 );
232 if (choice === "Allow" && allowOption) {
233 return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
234 }
235 return { outcome: { outcome: "cancelled" } };
236 }
237
238 private permissionLabel(request: PermissionRequest): string {
239 const tc = request.toolCall as { title?: string; kind?: string } | undefined;
240 const what = tc?.title ?? tc?.kind ?? "perform an action";
241 return `The agent wants to ${what}. Allow?`;
242 }
243
244 private async readFile(params: { path: string }): Promise<string> {
245 const uri = this.resolveUri(params.path);
246 const bytes = await vscode.workspace.fs.readFile(uri);
247 return Buffer.from(bytes).toString("utf8");
248 }
249
250 private async writeFile(params: { path: string; content: string }): Promise<void> {
251 const uri = this.resolveUri(params.path);
252 await vscode.workspace.fs.writeFile(uri, Buffer.from(params.content, "utf8"));
253 }
254
255 private resolveUri(p: string): vscode.Uri {
256 if (p.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(p)) {
257 return vscode.Uri.file(p);
258 }
259 const folder = vscode.workspace.workspaceFolders?.[0];
260 if (folder) {
261 return vscode.Uri.joinPath(folder.uri, p);
262 }
263 return vscode.Uri.file(p);
264 }
265
266 private workspaceFolder(): string {
267 return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
268 }
269
270 private contentText(content: unknown): string {
271 if (typeof content === "string") {
272 return content;
273 }
274 if (Array.isArray(content)) {
275 return content.map((c) => this.contentText(c)).join("");
276 }
277 if (content && typeof content === "object") {
278 const c = content as { type?: string; text?: string };
279 if (typeof c.text === "string") {
280 return c.text;
281 }
282 }
283 return "";
284 }
285
286 private post(message: Record<string, unknown>): void {
287 void this.view?.webview.postMessage(message);
288 }
289
290 private postStatus(text: string): void {
291 this.post({ type: "status", text });
292 }
293
294 private postError(text: string): void {
295 this.post({ type: "error", text });
296 }
297
298 private disposeClient(): void {
299 this.client?.dispose();
300 this.client = undefined;
301 }
302
303 private getHtml(webview: vscode.Webview): string {
304 const nonce = getNonce();
305 const scriptUri = webview.asWebviewUri(
306 vscode.Uri.joinPath(this.context.extensionUri, "media", "main.js")
307 );
308 const styleUri = webview.asWebviewUri(
309 vscode.Uri.joinPath(this.context.extensionUri, "media", "main.css")
310 );
311 const logoUri = webview.asWebviewUri(
312 vscode.Uri.joinPath(this.context.extensionUri, "media", "icon.png")
313 );
314 const csp = [
315 `default-src 'none'`,
316 `img-src ${webview.cspSource}`,
317 `style-src ${webview.cspSource}`,
318 `script-src 'nonce-${nonce}'`,
319 `font-src ${webview.cspSource}`
320 ].join("; ");
321
322 return /* html */ `<!DOCTYPE html>
323 <html lang="en">
324 <head>
325 <meta charset="UTF-8" />
326 <meta http-equiv="Content-Security-Policy" content="${csp}" />
327 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
328 <link href="${styleUri}" rel="stylesheet" />
329 <title>siGit Chat</title>
330 </head>
331 <body>
332 <header class="brand">
333 <img class="brand-logo" src="${logoUri}" alt="" />
334 <span class="brand-name">siGit Code</span>
335 </header>
336 <div id="messages" class="messages"></div>
337 <div id="status" class="status"></div>
338 <form id="composer" class="composer">
339 <textarea id="input" class="input" rows="2" placeholder="Ask the on-device agent…"></textarea>
340 <button id="send" class="send" type="submit">Send</button>
341 </form>
342 <script nonce="${nonce}" src="${scriptUri}"></script>
343 </body>
344 </html>`;
345 }
346 }
347
348 function getNonce(): string {
349 const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
350 let text = "";
351 for (let i = 0; i < 32; i++) {
352 text += chars.charAt(Math.floor(Math.random() * chars.length));
353 }
354 return text;
355 }