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