@davait/aipictury 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
@@ -0,0 +1,2483 @@
1
+ 'use strict'
2
+ // AIPictury in VS Code: a coding agent in the side bar (and in as many editor tabs as you
3
+ // open), and the generation studio in a tab. Which model does which job is configured on
4
+ // the AIPictury server, so every editor that points at the same server behaves the same.
5
+
6
+ const vscode = require('vscode')
7
+ const fs = require('fs')
8
+ const os = require('os')
9
+ const path = require('path')
10
+ const { execFile } = require('child_process')
11
+ const gpuServer = require('./gpuServer')
12
+ const gpuRun = require('./gpuRun')
13
+ const agent = require('./agent')
14
+ const memory = require('./memory')
15
+ const files = require('./files')
16
+ const shell = require('./shell')
17
+ const mcp = require('./mcp')
18
+ const voice = require('./voice')
19
+ const { RemoteLink } = require('./remote')
20
+ const context = require('./context')
21
+ const { showAbout } = require('./about')
22
+ const { stripInvisible } = require('./text')
23
+ const sessions = require('./sessions')
24
+ const log = require('./log')
25
+ const workbench = require('./workbench')
26
+ const dialogs = require('./dialogs')
27
+ const userHooks = require('./hooks')
28
+ const scheduler = require('./scheduler')
29
+ const usageLog = require('./usage')
30
+ const prefs = require('./prefs')
31
+
32
+ const SCHEME = 'aipictury-proposed'
33
+ const VERSION = require('./package.json').version
34
+
35
+ /** Any short request to the server. A status check must never be able to hang the
36
+ * panel, so it is given a deadline and a failure is simply "not answering". */
37
+ async function ask(route, ms = 10_000) {
38
+ const controller = new AbortController()
39
+ const timer = setTimeout(() => controller.abort(), ms)
40
+ try {
41
+ const response = await fetch(`${serverUrl()}${route}`, { signal: controller.signal })
42
+ if (!response.ok) throw new Error(`the server answered ${response.status}: ${(await response.text().catch(() => '')).slice(0, 300)}`)
43
+ return await response.json()
44
+ } finally {
45
+ clearTimeout(timer)
46
+ }
47
+ }
48
+
49
+ /** The GPU server (gpuServer.js: this machine's setting, else the account's choice). */
50
+ function serverUrl() {
51
+ return gpuServer.url()
52
+ }
53
+
54
+ const config = () => vscode.workspace.getConfiguration('aipictury')
55
+
56
+ /** A random string for the webview's content security policy. */
57
+ function nonce() {
58
+ return Array.from({ length: 24 },
59
+ () => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 62)]
60
+ ).join('')
61
+ }
62
+
63
+ const workspaceRoot = () => vscode.workspace.workspaceFolders?.[0]?.uri
64
+ let TRUST_DECLINED = null // project hooks the user said "not now" to, until VS Code restarts
65
+
66
+ /** Removes a proposal's copy — a moment later: VS Code's local history copies a file as it is
67
+ * saved, and a copy gone mid-way fills its log with errors. */
68
+ // The folder goes with its last file (rmdir refuses one that is not empty).
69
+ function dropReview(file) {
70
+ setTimeout(() => fs.promises.unlink(file).catch(() => {}).then(() => fs.promises.rmdir(path.dirname(file)).catch(() => {})), 3000).unref?.()
71
+ }
72
+
73
+ /** Proposal copies left by a VS Code that closed during a review: removed at the next start. */
74
+ function sweepReviews() {
75
+ const dir = path.join(os.tmpdir(), 'aipictury-review')
76
+ try {
77
+ for (const name of fs.readdirSync(dir)) {
78
+ const file = path.join(dir, name)
79
+ if (Date.now() - fs.statSync(file).mtimeMs > 3_600_000) fs.rmSync(file, { force: true })
80
+ }
81
+ fs.rmdirSync(dir) // empty now: nothing left in the temp folder
82
+ } catch { /* none, or still in use */ }
83
+ }
84
+
85
+ /** Adds text to a message, whether its content is text or parts (text and pictures). */
86
+ function appendText(message, text) {
87
+ if (typeof message.content === 'string') message.content += text
88
+ else if (Array.isArray(message.content)) message.content.push({ type: 'text', text })
89
+ else message.content = text
90
+ }
91
+
92
+ // ---------------------------------------------------------------- the agent panel
93
+
94
+ const STORE = 'aipictury.conversations'
95
+ const LIVE = 'aipictury.live' // the conversation on screen, and whether it was mid-run
96
+ const MAX_ATTACH = 10 // files per message, as in the web assistant
97
+ const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'pdf', 'txt', 'md', 'json', 'csv',
98
+ 'log', 'yaml', 'yml', 'py', 'js', 'jsx', 'ts', 'tsx', 'sh', 'sql', 'html', 'css', 'xml', 'toml',
99
+ 'c', 'h', 'cpp', 'rs', 'go', 'java']
100
+ const CHECKPOINTS = 100 // file snapshots kept per conversation, as Claude Code keeps
101
+ const RESTORE_WINDOW = 10 * 60_000 // a side-bar chat comes back after a reload if this recent
102
+ const CONTINUE_WINDOW = 60 * 60_000 // and a run cut by the reload continues if this recent
103
+
104
+ // Commands typed after /, handled here without the model. Custom commands from the
105
+ // project's .aipictury/commands or .claude/commands and skills are listed after these.
106
+ const SLASH = [
107
+ ['clear', 'Start a new conversation', ''],
108
+ ['compact', 'Summarize the conversation so far, to free context', '[what to focus on]'],
109
+ ['plan', 'Switch to Plan mode — and plan a task, if given one', '[task]'],
110
+ ['rewind', 'Restore the code or the conversation to an earlier message', ''],
111
+ ['btw', 'Ask a side question without adding it to the conversation', '<question>'],
112
+ ['copy', 'Copy the latest answer (or the nth latest) to the clipboard', '[n]'],
113
+ ['export', 'Export the conversation as Markdown', '[file]'],
114
+ ['todos', 'Show the agent\'s to-do list', ''],
115
+ ['tasks', 'Commands running in the background — see or stop them', ''],
116
+ ['status', 'Version, server, model, mode, context, MCP servers, instructions', ''],
117
+ ['usage', 'Tokens and time this conversation has used', ''],
118
+ ['model', 'Switch the model', '[name]'],
119
+ ['effort', 'How hard the model reasons: low, medium, high, max or ultra (Ultracode)', '[level]'],
120
+ ['mcp', 'MCP servers and their tools', ''],
121
+ ['login', 'Sign in to your AIPictury account (in the browser)', ''],
122
+ ['logout', 'Sign out of your AIPictury account on this machine', ''],
123
+ ['account', 'Your AIPictury account: what is kept with it, sign out, delete the cloud copy', ''],
124
+ ['remote-control', 'Carry this conversation on from a browser or a phone (a private link); stop ends it', '[stop]'],
125
+ ['init', 'Write an AIPICTURY.md that explains this codebase to the agent', ''],
126
+ ['review', 'Review the uncommitted changes', ''],
127
+ ['memory', 'The rules the agent always follows', ''],
128
+ ['permissions', 'What the agent may do without asking, must ask about, or may never do', ''],
129
+ ['hooks', 'Your commands that run as the agent works (Claude Code\'s format)', ''],
130
+ ['loop', 'Do something again and again: /loop 10m <prompt>, /loop <prompt>, /loop stop', '[interval] <prompt>'],
131
+ ['jobs', 'The prompts scheduled in this conversation', ''],
132
+ ['sandbox', 'Keep commands to the workspace (and off the internet)', ''],
133
+ ['bug', 'Report a problem — saved here, and sent to your server if you want', '[what happened]'],
134
+ ['feedback', 'Send an idea or a wish', '[what you would like]'],
135
+ ['rename', 'Rename this conversation', '<title>'],
136
+ ['resume', 'Reopen an earlier conversation', ''],
137
+ ['skills', 'The skills the project provides — which the model sees', ''],
138
+ ['plugin', 'Plugins and marketplaces — Claude Code\'s format', ''],
139
+ ['agents', 'The helpers at work, and the commands in the background', ''],
140
+ ['pages', 'The pages the agent published on your server', ''],
141
+ ['output-style', 'How the agent writes its answers (Default, Explanatory, Learning, yours)', ''],
142
+ ['focus', 'Focus view: hide tool steps and reasoning', ''],
143
+ ['config', 'Open the AIPictury settings', ''],
144
+ ['about', 'This agent\'s version, and the server\'s models and GPUs', ''],
145
+ ['help', 'What the agent can do, and how', ''],
146
+ ]
147
+
148
+ const INIT_PROMPT = 'Analyze this codebase and create an AIPICTURY.md file at the workspace root that '
149
+ + 'will be given to future coding agents working here. Include: the commands to build, lint and test '
150
+ + '(and how to run a single test); the high-level architecture — the big picture that takes reading '
151
+ + 'several files to understand; conventions the code follows. Do not list every file or repeat what '
152
+ + 'is obvious. If there is already an AIPICTURY.md, AGENTS.md, CLAUDE.md, README or Cursor/Copilot '
153
+ + 'rules, fold what matters from them in. If AIPICTURY.md exists, improve it instead. Start it with: '
154
+ + '"# AIPICTURY.md" and a line saying the file guides AI coding agents working in this repository.'
155
+
156
+ const REVIEW_PROMPT = 'Review the uncommitted changes in this repository (git status and git diff, '
157
+ + 'staged and unstaged). Look for bugs, missed edge cases, security problems, and code that does not '
158
+ + 'match the conventions around it. Report what you find with report_findings — most serious first, '
159
+ + 'each with its file and line, what goes wrong and the fix — then sum it up in a sentence or two. '
160
+ + 'Do not change any file.'
161
+
162
+ class AgentPanel {
163
+ constructor(context, { kind = 'sidebar', registry } = {}) {
164
+ this.context = context
165
+ this.kind = kind
166
+ this.registry = registry
167
+ this.history = []
168
+ this.session = { allowAll: false }
169
+ this.host = null // the WebviewView or WebviewPanel showing this conversation
170
+ this.abort = null
171
+ this.id = null // which stored conversation this is, once it has content
172
+ this.model = context.globalState.get('aipictury.model') || null // override, or the role default
173
+ this.transcript = [] // what was on screen, kept so a reopened chat is whole
174
+ this.queue = [] // messages sent while a run is going: {message, text, files}
175
+ this.attachments = [] // files waiting to go with the next message (at most MAX_ATTACH)
176
+ this.models = [] // what each installed model can do, from the server
177
+ this.created = null
178
+ this.title = null // set by /rename; otherwise the first question
179
+ this.checkpoints = [] // per question: the files as they were before the agent changed them
180
+ this.approvals = new Map()
181
+ this.todos = []
182
+ this.jobs = [] // this conversation's scheduled prompts (/loop, the cron tools, wake-ups)
183
+ this.btw = [] // the side thread: /btw questions and answers, never in the conversation
184
+ this.usage = { turns: 0, prompt: 0, output: 0, seconds: 0 }
185
+ this.contextDismissed = null
186
+ this.focused = false
187
+ }
188
+
189
+ get view() { return this.host }
190
+ get visible() { return !!this.host?.visible }
191
+
192
+ /** Conversations belong to the project they were had in. */
193
+ /** This folder's conversations on disk, shared with the other windows and the CLI. */
194
+ get store() { return sessions.storeFor(workspaceRoot()?.fsPath) }
195
+
196
+ /** Every stored conversation, whole (the lists use store.list(), which has no history). */
197
+ get stored() { return this.store.list().map((m) => this.store.get(m.id)).filter(Boolean) }
198
+
199
+ get conversationTitle() {
200
+ const first = this.history.find((m) => m.role === 'user')
201
+ return this.title || this.aiTitle || (first ? String(first.content).replace(/\[[^\]]*\]\n?/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) : 'New conversation')
202
+ }
203
+
204
+ /** Writes the current exchange into the list, newest first. */
205
+ async remember() {
206
+ if (!this.history.length) return
207
+ const first = this.history.find((m) => m.role === 'user')
208
+ if (!first) return
209
+ this.id = this.id || `c${Date.now().toString(36)}`
210
+ this.created = this.created || Date.now()
211
+ // Two halves, and both are needed: `history` is the exact context the model is
212
+ // given, so the conversation can be carried on; `transcript` is what was on the
213
+ // screen — answers, reasoning, and what each tool did — so reopening shows it whole.
214
+ const entry = {
215
+ id: this.id,
216
+ title: this.conversationTitle,
217
+ renamed: !!this.title,
218
+ aiTitle: this.aiTitle || undefined,
219
+ created: this.created,
220
+ updated: Date.now(),
221
+ server: serverUrl(),
222
+ model: this.lastModel || null,
223
+ history: this.history,
224
+ transcript: this.transcript,
225
+ todos: this.todos,
226
+ jobs: this.jobs?.length ? this.jobs : undefined,
227
+ btw: this.btw?.length ? this.btw : undefined,
228
+ usage: this.usage,
229
+ }
230
+ this.store.save(entry)
231
+ await this.saveCheckpoints()
232
+ if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${entry.title.slice(0, 30)}`
233
+ this.postSession()
234
+ }
235
+
236
+ /** After the first answer: a title written by the model already loaded (the server makes
237
+ * none rather than load one). One you gave it, with /rename or from the list, always wins. */
238
+ async suggestTitle({ fetchImpl = fetch } = {}) {
239
+ if (this.title || this.aiTitle || !this.id) return null
240
+ const questions = this.history.filter((m) => m.role === 'user')
241
+ const answer = [...this.transcript].reverse().find((t) => t.kind === 'assistant' && String(t.text || '').trim())
242
+ if (questions.length !== 1 || !answer) return null
243
+ const id = this.id
244
+ const controller = new AbortController()
245
+ const timer = setTimeout(() => controller.abort(), 40_000)
246
+ try {
247
+ const response = await fetchImpl(`${serverUrl()}/api/agent/title`, { method: 'POST', signal: controller.signal,
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ text: `${this.conversationTitle}\n\n${String(answer.text).slice(0, 1500)}`,
250
+ model: this.lastModel || this.model || undefined }) })
251
+ const { title } = response.ok ? await response.json() : {}
252
+ // Still the same conversation, and not renamed meanwhile.
253
+ if (!title || this.id !== id || this.title) return null
254
+ this.aiTitle = String(title).slice(0, 80)
255
+ await this.remember()
256
+ return this.aiTitle
257
+ } finally { clearTimeout(timer) }
258
+ }
259
+
260
+ /** Saves a little later, at most every second — for saving at every step of a run. */
261
+ rememberSoon() {
262
+ if (this.saveTimer) return
263
+ this.saveTimer = setTimeout(() => { this.saveTimer = null; this.remember().catch(() => {}) }, 1000)
264
+ }
265
+
266
+ // ---- checkpoints: files as they were, per question ----
267
+ /** Where checkpoints were kept before conversations moved to disk (read once, when moving). */
268
+ oldCheckpointsDir() {
269
+ const dir = this.context.storageUri || this.context.globalStorageUri
270
+ return dir ? path.join(dir.fsPath, 'checkpoints') : null
271
+ }
272
+
273
+ async saveCheckpoints() {
274
+ if (this.id) this.store.saveCheckpoints(this.id, this.checkpoints.slice(-CHECKPOINTS))
275
+ }
276
+
277
+ loadCheckpoints(id) { return this.store.loadCheckpoints(id) }
278
+
279
+ /** Reopens a stored conversation, rebuilt in the panel. */
280
+ async open(id, { focus = true } = {}) {
281
+ const found = this.store.get(id)
282
+ if (!found) return false
283
+ if (this.abort) { vscode.window.showInformationMessage('Stop the agent before switching conversations.'); return false }
284
+ await this.remember() // do not lose the one on screen
285
+ this.id = found.id
286
+ this.created = found.created || found.updated
287
+ this.history = found.history
288
+ this.title = found.renamed ? found.title : null
289
+ this.aiTitle = found.aiTitle || null
290
+ this.todos = found.todos || []
291
+ this.jobs = found.jobs || [] // a reopened conversation keeps its schedule
292
+ this.btw = found.btw || [] // and its side thread
293
+ this.usage = found.usage || { turns: 0, prompt: 0, output: 0, seconds: 0 }
294
+ this.checkpoints = this.loadCheckpoints(found.id)
295
+ this.session = { allowAll: false } // a reopened conversation asks again
296
+ // Older entries predate the stored transcript; rebuild what can be rebuilt.
297
+ this.transcript = found.transcript || fromHistory(found.history)
298
+ if (found.unread) this.store.patch(found.id, { unread: undefined }) // seen now
299
+ this.startSource = 'resume' // for the user's SessionStart hooks, at the next run
300
+ if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${String(found.title).slice(0, 30)}`
301
+ this.post({ type: 'restore', title: found.title, turns: this.transcript, todos: this.todos })
302
+ if (this.btw.length) this.post({ type: 'btwThread', items: this.btw })
303
+ this.postSession()
304
+ this.registry?.sessionsView?.refresh()
305
+ if (focus && this.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
306
+ return true
307
+ }
308
+
309
+ /** Shows this conversation in a view (the side bar) or a panel (an editor tab). */
310
+ attach(host) {
311
+ this.host = host
312
+ const webview = host.webview
313
+ webview.options = {
314
+ enableScripts: true,
315
+ localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')],
316
+ }
317
+ webview.html = this.html(webview)
318
+ const subs = [webview.onDidReceiveMessage((message) => this.onMessage(message).catch((error) => {
319
+ log.error(`panel message ${message?.type}`, error)
320
+ this.post({ type: 'error', text: `Something went wrong: ${error.message}` })
321
+ }))]
322
+ // The prompt cache note is checked against the server every 10s while the panel
323
+ // is on screen (a render can unload the model at any moment); between checks the
324
+ // panel counts down every second on its own.
325
+ const poll = setInterval(() => { if (host.visible) this.refreshCache() }, 10_000)
326
+ const visibility = host.onDidChangeVisibility
327
+ ? host.onDidChangeVisibility(() => this.onVisible())
328
+ : host.onDidChangeViewState(() => { this.onVisible(); if (host.active) this.registry?.activate(this) })
329
+ subs.push(visibility)
330
+ // Scheduled prompts: looked at every 15 s, while this panel is open.
331
+ const ticker = setInterval(() => this.tickJobs(), 15_000)
332
+ host.onDidDispose(() => {
333
+ clearInterval(poll); clearInterval(ticker); subs.forEach((s) => s.dispose())
334
+ clearInterval(this.gpuWatch); this.gpuWatch = null
335
+ this.abort?.abort()
336
+ this.recording?.cancel()
337
+ if (this.remote) this.registry?.account?.cloud?.dropRemote?.(this.remote.id).catch(() => {})
338
+ this.remote?.stop()
339
+ this.remember().catch(() => {})
340
+ this.registry?.remove(this)
341
+ })
342
+ }
343
+
344
+ onVisible() {
345
+ if (!this.host?.visible) return
346
+ this.refreshCache()
347
+ this.setBadge(null)
348
+ }
349
+
350
+ /** A dot on the icon when the agent needs you (a question) or finished while you were away. */
351
+ setBadge(reason) {
352
+ this.registry?.sessionsView?.refresh() // the Sessions view shows what each one is doing
353
+ // The status bar and a system notification say it too — whether or not the panel shows.
354
+ if (reason === 'waiting') {
355
+ this.fireHook('Notification', { message: `AIPictury is waiting for you: ${this.conversationTitle}` }).catch(() => {})
356
+ workbench.setState('waiting')
357
+ workbench.notify('AIPictury is waiting for you', this.conversationTitle)
358
+ } else if (reason === 'finished') {
359
+ workbench.setState(this.registry?.all().some((p) => p.abort) ? 'working' : 'finished')
360
+ workbench.notify('AIPictury has finished', this.conversationTitle)
361
+ if (this.id && !this.host?.visible) this.store.patch(this.id, { unread: true })
362
+ } else {
363
+ workbench.setState(this.registry?.all().some((p) => p.abort) ? 'working' : 'idle')
364
+ }
365
+ if (!this.host) return
366
+ if (reason && this.host.visible) return
367
+ if (this.kind === 'sidebar') {
368
+ this.host.badge = reason ? { value: 1, tooltip: reason === 'waiting' ? 'The agent is waiting for you' : 'The agent has finished' } : undefined
369
+ } else {
370
+ const title = String(this.host.title || 'AIPictury').replace(/^[●○] /, '')
371
+ this.host.title = reason ? `${reason === 'waiting' ? '●' : '○'} ${title}` : title
372
+ }
373
+ }
374
+
375
+ /** Asks the server whether the agent's model is still loaded — its prompt cache warm —
376
+ * and for how long, as Ollama itself will unload it. */
377
+ async refreshCache() {
378
+ if (this.abort) return // mid-run: the model is in use, so warm
379
+ try {
380
+ // An empty model means the agent role's default, as the server reads it.
381
+ const response = await fetch(`${serverUrl()}/api/agent/cache?model=${encodeURIComponent(this.model || '')}`,
382
+ { signal: AbortSignal.timeout(10_000) })
383
+ if (!response.ok) return
384
+ const state = await response.json()
385
+ this.post({ type: 'cache', warm: state.warm, seconds: state.seconds_left })
386
+ } catch { /* offline: the status line already says so */ }
387
+ }
388
+
389
+ post(message) { this.host?.webview.postMessage(message) }
390
+
391
+ postMode() {
392
+ // Bypass is offered only when allowDangerouslySkipPermissions is on.
393
+ const modes = Object.fromEntries(Object.entries(agent.MODES)
394
+ .filter(([id]) => id !== 'bypass' || config().get('allowDangerouslySkipPermissions', false)))
395
+ this.post({ type: 'mode', current: agent.modeNow(), modes,
396
+ reasoning: config().get('showThinking', true), effort: agent.effortOf(config().get('effort', 'high')),
397
+ focusView: config().get('focusView', false), enterSends: !config().get('useCtrlEnterToSend', false),
398
+ scrollOnSend: config().get('scrollToBottomOnSend', true), onboarding: !config().get('hideOnboarding', false) })
399
+ }
400
+
401
+ postSession() {
402
+ this.post({ type: 'session', id: this.id, title: this.conversationTitle, kind: this.kind })
403
+ }
404
+
405
+ postCommands() {
406
+ const custom = context.commands().map((c) => ({ name: c.name, description: c.description, hint: c.hint, custom: true }))
407
+ const skills = context.skills().map((s) => ({ name: s.name, description: `skill — ${s.description}`, skill: true }))
408
+ this.post({ type: 'commands', items: [
409
+ ...SLASH.map(([name, description, hint]) => ({ name, description, hint })), ...custom, ...skills] })
410
+ }
411
+
412
+ async onMessage(message) {
413
+ switch (message.type) {
414
+ case 'ready': {
415
+ this.postAccount(this.registry?.account?.user?.() ?? null)
416
+ this.refreshStatus(); this.postMode(); this.refreshCache(); this.postCommands(); this.postSession()
417
+ editorTracker.post(this)
418
+ // Reloaded while a question was open: the agent is still waiting for it.
419
+ if (this.pendingAsk) this.post({ type: 'askUser', id: this.pendingAsk.id, questions: this.pendingAsk.questions })
420
+ for (const [id, p] of this.approvals) this.post({ type: 'approval', id, request: p.request })
421
+ if (this.pendingPlan) this.post({ type: 'plan', id: this.pendingPlan.id, plan: this.pendingPlan.plan })
422
+ if (this.todos.length) this.post({ type: 'todos', items: this.todos })
423
+ this.post({ type: 'tasks', items: shell.taskList() })
424
+ // A dialog opened before the panel was ready (or open across a reload) is shown now.
425
+ if (this.openDialog) this.post({ type: 'dialog', spec: this.openDialog.spec })
426
+ return undefined
427
+ }
428
+ case 'ask': return this.ask(message.text)
429
+ case 'stop': this.abort?.abort(); return undefined
430
+ case 'answer': {
431
+ // The chooser was answered (or closed): hand it to the agent that is waiting.
432
+ const pending = this.pendingAsk
433
+ if (pending && pending.id === message.id) {
434
+ this.pendingAsk = null
435
+ pending.resolve(message.dismissed ? { dismissed: true } : { answers: message.answers || [] })
436
+ }
437
+ return undefined
438
+ }
439
+ case 'approval': {
440
+ const pending = this.approvals.get(message.id)
441
+ if (!pending) return undefined
442
+ this.approvals.delete(message.id)
443
+ let edited
444
+ if (pending.review) {
445
+ // The proposal was opened in VS Code's diff, where it can be edited before accepting.
446
+ const doc = vscode.workspace.textDocuments.find((d) => d.uri.fsPath === pending.review)
447
+ if (doc?.isDirty) await doc.save()
448
+ try { edited = fs.readFileSync(pending.review, 'utf8') } catch { /* gone */ }
449
+ dropReview(pending.review)
450
+ }
451
+ pending.resolve({ decision: message.decision, feedback: message.feedback, keep: message.keep,
452
+ prefix: message.prefix, destination: message.destination, edited: edited !== undefined && edited !== pending.request.after ? edited : undefined })
453
+ return undefined
454
+ }
455
+ case 'reviewDiff': return this.reviewDiff(message.id)
456
+ case 'openUrl':
457
+ if (/^https?:\/\//.test(String(message.url))) await vscode.env.openExternal(vscode.Uri.parse(message.url))
458
+ return undefined
459
+ case 'userFile': {
460
+ let uri
461
+ try { uri = require('./paths').resolve(message.path) } catch (error) { this.say(error.message); return undefined }
462
+ if (message.action === 'open') await vscode.commands.executeCommand('vscode.open', uri)
463
+ if (message.action === 'reveal') await vscode.commands.executeCommand('revealFileInOS', uri)
464
+ if (message.action === 'save') {
465
+ const to = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(os.homedir(), path.basename(uri.fsPath))) })
466
+ if (to) { await vscode.workspace.fs.copy(uri, to, { overwrite: true }); this.say(`saved a copy: ${to.fsPath}`) }
467
+ }
468
+ return undefined
469
+ }
470
+ case 'onboarding':
471
+ if (message.action === 'hide') {
472
+ await config().update('hideOnboarding', true, vscode.ConfigurationTarget.Global)
473
+ this.say('Hidden — the walkthrough stays in the command palette: AIPictury: Open Walkthrough')
474
+ }
475
+ if (message.action === 'walkthrough') await vscode.commands.executeCommand('aipictury.openWalkthrough')
476
+ return undefined
477
+ case 'planAnswer': {
478
+ const pending = this.pendingPlan
479
+ if (!pending || pending.id !== message.id) return undefined
480
+ this.pendingPlan = null
481
+ let plan
482
+ if (pending.doc && !pending.doc.isClosed) plan = pending.doc.getText()
483
+ pending.resolve({ approved: !!message.approved, mode: message.mode, feedback: message.feedback, plan })
484
+ return undefined
485
+ }
486
+ case 'openPlan':
487
+ // The plan as a Markdown file to edit and comment on, line by line (plans.js).
488
+ await this.registry?.plans?.open(this)
489
+ return undefined
490
+ case 'revise':
491
+ // "Change" on a decision Auto mode took: said to the agent as the user's next message.
492
+ return this.ask(message.text)
493
+ case 'unqueue':
494
+ this.queue.splice(message.index, 1)
495
+ this.postQueue()
496
+ return undefined
497
+ case 'reset': return this.reset()
498
+ case 'openStudio': return vscode.commands.executeCommand('aipictury.openStudio')
499
+ case 'exportFile': {
500
+ // A table as CSV, a diagram as SVG, from an answer: saved where the user says.
501
+ const name = String(message.name || 'export.txt').replace(/[^\w.-]/g, '_').slice(0, 80)
502
+ const base = workspaceRoot() || vscode.Uri.file(require('os').homedir())
503
+ const to = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.joinPath(base, name), saveLabel: 'Save' })
504
+ if (!to) return undefined
505
+ await vscode.workspace.fs.writeFile(to, new TextEncoder().encode(String(message.text || '')))
506
+ this.say(`saved ${vscode.workspace.asRelativePath(to)}`)
507
+ return undefined
508
+ }
509
+ case 'attach': return this.pickFiles()
510
+ case 'dictate': return this.dictate(message.action)
511
+ case 'signIn': return this.registry?.account?.signIn()
512
+ case 'account': return this.openAccount()
513
+ case 'remoteMenu': {
514
+ if (!this.remote) return undefined
515
+ const pick = await vscode.window.showQuickPick([{ label: '$(copy) Copy the link', id: 'copy' }, { label: '$(debug-stop) Stop remote control', id: 'stop' }],
516
+ { title: `Remote control — ${this.remote.viewers} viewer(s)`, placeHolder: this.remote.url.replace(/\.[^.]+$/, '.…') })
517
+ if (pick?.id === 'copy') { await vscode.env.clipboard.writeText(this.remote.url); this.say('the link is copied', 'remote') }
518
+ if (pick?.id === 'stop') await this.remoteControl('stop')
519
+ return undefined
520
+ }
521
+ case 'pasteFile': return this.addFile(message.name, Buffer.from(message.data, 'base64'))
522
+ case 'dropUris': return this.dropUris(message.uris || [])
523
+ case 'removeAttachment':
524
+ this.attachments = this.attachments.filter((a) => a.id !== message.id)
525
+ return this.postAttachments()
526
+ case 'thought': this.pendingThought = message; return undefined
527
+ case 'openDiff': return agent.openSideBySide(message.key)
528
+ case 'openImage': return vscode.env.openExternal(vscode.Uri.parse(message.src))
529
+ case 'setModel':
530
+ this.model = message.model || null
531
+ this.context.globalState.update('aipictury.model', this.model)
532
+ return undefined
533
+ case 'setEffort':
534
+ await config().update('effort', agent.effortOf(message.effort), vscode.ConfigurationTarget.Global)
535
+ return this.postMode()
536
+ case 'compact': return this.shortcut('/compact')
537
+ case 'enableReasoning':
538
+ // Reasoning had been switched off in the settings: the Thinking button turns it back on.
539
+ await config().update('showThinking', undefined, vscode.ConfigurationTarget.Global)
540
+ return this.postMode()
541
+ case 'setMode':
542
+ await config().update('approval', message.mode, vscode.ConfigurationTarget.Global)
543
+ return this.postMode()
544
+ case 'toggleFocusView':
545
+ await config().update('focusView', !config().get('focusView', false), vscode.ConfigurationTarget.Global)
546
+ return undefined
547
+ case 'openSettings': return vscode.commands.executeCommand('aipictury.openSettings')
548
+ case 'openExternal':
549
+ // A link from a web search: only http(s), opened in the browser.
550
+ if (/^https?:\/\//i.test(String(message.url || ''))) return vscode.env.openExternal(vscode.Uri.parse(message.url))
551
+ return undefined
552
+ case 'openFile': return openWorkspaceFile(message.path, message.line)
553
+ case 'copy':
554
+ await vscode.env.clipboard.writeText(String(message.text || ''))
555
+ this.post({ type: 'toast', text: 'Copied' })
556
+ return undefined
557
+ case 'mentionQuery':
558
+ this.post({ type: 'mentions', query: message.query, items: await mentionCandidates(message.query) })
559
+ return undefined
560
+ case 'dismissContext':
561
+ this.contextDismissed = editorTracker.key()
562
+ editorTracker.post(this)
563
+ return undefined
564
+ case 'dialogAction': return this.onDialogAction(message)
565
+ case 'rewind': return this.rewind(message.index, message.action)
566
+ case 'retry': {
567
+ // Ask again: back to before the last question, and the question sent once more.
568
+ if (this.abort) return undefined
569
+ const index = this.transcript.map((t) => t.kind).lastIndexOf('user')
570
+ if (index < 0) return undefined
571
+ const question = this.transcript[index].text
572
+ await this.rewind(index, 'conversation', { quiet: true })
573
+ return this.ask(question)
574
+ }
575
+ case 'stopTask':
576
+ try { shell.killShell({ shell_id: message.id }) } catch { /* already gone */ }
577
+ this.post({ type: 'tasks', items: shell.taskList() })
578
+ return undefined
579
+ case 'focus': this.focused = !!message.focused; if (this.focused) this.registry?.activate(this); return undefined
580
+ case 'command': return this.ask(`/${message.name}${message.args ? ` ${message.args}` : ''}`)
581
+ default: return undefined
582
+ }
583
+ }
584
+
585
+ async reset() {
586
+ if (this.remote) await this.remoteControl('stop') // the link was for the conversation that ends here
587
+ if (this.abort) this.abort.abort()
588
+ await this.remember()
589
+ if (this.id) this.fireHook('SessionEnd', { reason: 'clear' }).catch(() => {})
590
+ this.startSource = 'clear'
591
+ this.history = []
592
+ this.transcript = []
593
+ this.id = null
594
+ this.created = null
595
+ this.title = null
596
+ this.aiTitle = null
597
+ this.todos = []
598
+ this.jobs = []
599
+ this.btw = []
600
+ this.checkpoints = []
601
+ this.usage = { turns: 0, prompt: 0, output: 0, seconds: 0 }
602
+ this.session = { allowAll: false }
603
+ this.post({ type: 'cleared' })
604
+ this.postSession()
605
+ this.refreshStatus()
606
+ if (this.kind === 'tab' && this.host) this.host.title = 'AIPictury Agent'
607
+ this.context.workspaceState.update(LIVE, null)
608
+ }
609
+
610
+ info(title, lines) { this.post({ type: 'info', title, lines }) }
611
+ say(text, reason = 'remembered') { this.post({ type: 'nudge', reason, text }) }
612
+
613
+ /** The GPU is another app's: the message back to the input, who has it said, and — watched
614
+ * every few seconds — when it is free. */
615
+ gpuBusy(gpu, messages = []) {
616
+ const hold = gpuRun.busyFor(gpu)
617
+ const text = messages.map((m) => (typeof m.content === 'string' ? m.content : '')).filter(Boolean).join('\n\n')
618
+ if (text) this.post({ type: 'draft', text })
619
+ this.post({ type: 'gpu', gpu, busy: !!hold, said: hold?.said || 'another app' })
620
+ if (this.gpuWatch) return
621
+ this.gpuWatch = setInterval(async () => {
622
+ const now = await gpuRun.state(serverUrl())
623
+ if (gpuRun.busyFor(now)) return
624
+ clearInterval(this.gpuWatch); this.gpuWatch = null
625
+ this.post({ type: 'gpu', gpu: now, busy: false })
626
+ }, 5000)
627
+ }
628
+
629
+ /** `#` and `/` shortcuts, handled without the model — instant and certain.
630
+ * # rule remember for this project (.aipictury/memory.md)
631
+ * ## rule remember for you, everywhere
632
+ * /command see SLASH above; a custom command expands into a prompt instead.
633
+ * Returns true when handled, or { prompt } when it stands for a prompt to the agent. */
634
+ async shortcut(text) {
635
+ const say = (note) => this.say(note)
636
+ if (/^#/.test(text) && /\S/.test(text.replace(/^#+/, ''))) this.post({ type: 'user', text })
637
+ if (/^##\s*\S/.test(text)) {
638
+ say(await memory.addUserRule(serverUrl(), text.replace(/^##\s*/, '')))
639
+ return true
640
+ }
641
+ if (/^#\s*\S/.test(text)) {
642
+ try { say(await memory.addProjectRule(text.replace(/^#\s*/, ''))) } catch (e) { say(e.message) }
643
+ return true
644
+ }
645
+ const m = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/.exec(text)
646
+ if (!m) return false
647
+ const [, name, rawArgs = ''] = m
648
+ const args = rawArgs.trim()
649
+ switch (name) {
650
+ case 'clear': case 'new': case 'reset': await this.reset(); return true
651
+ case 'memory': await this.openMemory(); return true
652
+ case 'permissions': case 'allowed-tools': await this.openPermissions(); return true
653
+ case 'hooks': await this.openHooks(); return true
654
+ case 'loop': {
655
+ // /loop 5m <prompt>: every 5 minutes. /loop <prompt>: the agent paces itself (schedule_wakeup).
656
+ // /loop stop: no more.
657
+ if (/^stop\b/i.test(args)) {
658
+ const before = this.jobs.length
659
+ this.jobs = this.jobs.filter((j) => j.kind !== 'loop' && j.kind !== 'wakeup')
660
+ this.rememberSoon()
661
+ say(before === this.jobs.length ? 'no loop is running' : 'the loop is stopped')
662
+ return true
663
+ }
664
+ const m = /^(\S+)\s+([\s\S]+)$/.exec(args)
665
+ const every = m && scheduler.interval(m[1])
666
+ if (!args) { say('/loop 10m <what to do> — or /loop <what to do> and the agent chooses when to come back; /loop stop'); return true }
667
+ if (every) {
668
+ this.jobs.push(scheduler.job(this.jobs, { schedule: m[1], prompt: m[2], kind: 'loop' }))
669
+ this.rememberSoon()
670
+ say(`looping every ${m[1]} — /loop stop to end it`)
671
+ return { prompt: m[2] }
672
+ }
673
+ return { prompt: `${args}\n\n[This is a loop: when you have done it, use schedule_wakeup to come back to it — choose the delay from what you are waiting for. Stop when it no longer needs doing.]` }
674
+ }
675
+ case 'jobs': case 'schedule': {
676
+ this.info('Scheduled prompts', this.jobs.length ? this.jobs.map((j) => scheduler.describe(j))
677
+ : ['None. /loop 10m <prompt>, or ask the agent to schedule something.'])
678
+ return true
679
+ }
680
+ case 'sandbox': await this.openSandbox(); return true
681
+ case 'bug': case 'feedback': await this.reportProblem(name, args); return true
682
+ case 'compact': return this.compact(args)
683
+ case 'plan':
684
+ if (args === 'open') {
685
+ if (!this.pendingPlan) say('no plan is waiting for your answer')
686
+ else await this.registry?.plans?.open(this)
687
+ return true
688
+ }
689
+ await config().update('approval', 'plan', vscode.ConfigurationTarget.Global)
690
+ this.postMode()
691
+ if (!args) { say('Plan mode: the agent investigates and proposes a plan; nothing is changed until you approve it'); return true }
692
+ return { prompt: args }
693
+ case 'rewind': await this.rewindPicker(); return true
694
+ case 'btw': await this.sideQuestion(args); return true
695
+ case 'remote-control': await this.remoteControl(args === 'stop' ? 'stop' : 'start'); return true
696
+ case 'login': await this.registry?.account?.signIn(); return true
697
+ case 'logout': await this.registry?.account?.signOut(); return true
698
+ case 'account': await this.openAccount(); return true
699
+ case 'copy': {
700
+ const n = Math.max(1, Number(args) || 1)
701
+ const answers = this.transcript.filter((t) => t.kind === 'assistant' && String(t.text || '').trim())
702
+ const pick = answers[answers.length - n]
703
+ if (!pick) { say('no answer to copy yet'); return true }
704
+ await vscode.env.clipboard.writeText(pick.text)
705
+ say(n === 1 ? 'copied the latest answer' : `copied the answer ${n} from the end`)
706
+ return true
707
+ }
708
+ case 'export': await this.exportConversation(args); return true
709
+ case 'todos':
710
+ if (!this.todos.length) say('no to-do list in this conversation yet')
711
+ else this.post({ type: 'todos', items: this.todos, show: true })
712
+ return true
713
+ case 'tasks': case 'bashes': {
714
+ const tasks = shell.taskList()
715
+ this.post({ type: 'tasks', items: tasks, show: true })
716
+ if (!tasks.length) say('no commands are running in the background')
717
+ return true
718
+ }
719
+ case 'status': await this.status(); return true
720
+ case 'usage': case 'cost': case 'context': await this.showUsage(); return true
721
+ case 'model': {
722
+ if (!args) { this.post({ type: 'openModelMenu' }); return true }
723
+ const found = (this.models || []).find((x) => x.name === args || x.name.startsWith(args))
724
+ if (!found) { say(`no model called ${args}`); return true }
725
+ this.model = found.name
726
+ this.context.globalState.update('aipictury.model', this.model)
727
+ this.refreshStatus()
728
+ say(`model: ${found.name}`)
729
+ return true
730
+ }
731
+ case 'effort': {
732
+ if (!agent.EFFORT[args]) { say(`effort is ${agent.effortOf(config().get('effort', 'high'))} — /effort low, medium, high, max or ultra`); return true }
733
+ await config().update('effort', args, vscode.ConfigurationTarget.Global)
734
+ this.postMode()
735
+ say(`effort: ${args}`)
736
+ return true
737
+ }
738
+ case 'mcp': await this.mcpStatus(); return true
739
+ case 'init': return { prompt: INIT_PROMPT }
740
+ case 'review': return { prompt: args ? `${REVIEW_PROMPT}\n\nFocus on: ${args}` : REVIEW_PROMPT }
741
+ case 'rename': {
742
+ if (!args) { await vscode.commands.executeCommand('aipictury.renameConversation'); return true }
743
+ this.title = args.slice(0, 80)
744
+ await this.remember()
745
+ say(`renamed: ${this.title}`)
746
+ return true
747
+ }
748
+ case 'resume': case 'history': await showHistory(this); return true
749
+ case 'skills': await this.openSkills(); return true
750
+ case 'plugin': case 'plugins': await this.openPlugins(); return true
751
+ case 'agents': await this.openAgents(); return true
752
+ case 'pages': await this.openPages(); return true
753
+ case 'output-style': case 'output-styles': await this.openOutputStyle(); return true
754
+ case 'focus':
755
+ await config().update('focusView', !config().get('focusView', false), vscode.ConfigurationTarget.Global)
756
+ return true
757
+ case 'config': case 'settings':
758
+ await vscode.commands.executeCommand('workbench.action.openSettings', 'aipictury')
759
+ return true
760
+ case 'help': case 'about': showAbout(this.context, { model: () => this.model, account: () => this.registry?.account }); return true
761
+ default: {
762
+ const prompt = context.expandCommand(text)
763
+ if (prompt) {
764
+ // Claude Code's command format: allowed-tools hold while this command's run lasts,
765
+ // and !`command` lines are run first and replaced by their output.
766
+ const meta = context.commandMeta(text)
767
+ const folder = workspaceRoot()?.fsPath
768
+ const permissions = require('./permissions')
769
+ const allowed = (command) => {
770
+ const verdict = permissions.decide('run_command', { command }, { folder })
771
+ if (verdict.decision === 'deny') return false
772
+ const granted = permissions.decide('run_command', { command }, { rules: { allow: meta.allowedTools, ask: [], deny: [] } }).decision === 'allow'
773
+ return granted || verdict.decision === 'allow' || shell.isReadOnly(command)
774
+ }
775
+ this.session.commandAllow = meta.allowedTools
776
+ return { prompt: await context.runInline(prompt, { cwd: folder, allowed }) }
777
+ }
778
+ const skill = context.skills().find((s) => s.name === name)
779
+ if (skill) return { prompt: `Use the ${skill.name} skill${args ? ` on: ${args}` : ''}.` }
780
+ return false
781
+ }
782
+ }
783
+ }
784
+
785
+ async compact(focus = '') {
786
+ // One summary at a time, and never under a running agent: both change the history.
787
+ const say = (note) => this.say(note)
788
+ if (this.compacting) return true
789
+ if (this.abort) { say('the agent is working — summarize once it has finished'); return true }
790
+ if (this.history.length < 3) { say('nothing to summarize yet'); return true }
791
+ this.compacting = true
792
+ await this.fireHook('PreCompact', { trigger: 'manual', custom_instructions: focus || '' }).catch(() => {})
793
+ this.startSource = 'compact'
794
+ const before = agent.estimateTokens(this.history)
795
+ const started = Date.now()
796
+ const progress = (detail) => this.post({ type: 'nudge', reason: 'summarizing', ...detail })
797
+ progress({ phase: 'start', tokens: before })
798
+ let ok = false
799
+ let error = ''
800
+ try {
801
+ const next = await agent.summarizeEarlier(serverUrl(),
802
+ [{ role: 'system', content: '' }, ...this.history], { model: this.model || undefined, force: true, focus,
803
+ onProgress: (p) => progress({ phase: 'progress', ...p }) })
804
+ if (next) {
805
+ this.history = next.slice(1)
806
+ await this.remember()
807
+ ok = true
808
+ } else {
809
+ error = 'the conversation is already short — nothing to summarize'
810
+ }
811
+ } catch (e) {
812
+ error = `could not summarize: ${e.message}`
813
+ } finally {
814
+ this.compacting = false
815
+ }
816
+ const after = agent.estimateTokens(this.history)
817
+ progress({ phase: 'done', ok, error, before, after, seconds: Math.round((Date.now() - started) / 1000) })
818
+ if (ok) this.post({ type: 'context', used: after })
819
+ return true
820
+ }
821
+
822
+ /** /btw: an answer beside the conversation, never in it. */
823
+ async sideQuestion(question) {
824
+ if (!question) { this.say('ask something after /btw, e.g. /btw what does parseConfig return?'); return }
825
+ if (/^clear$/i.test(question.trim())) {
826
+ this.btw = []
827
+ this.rememberSoon()
828
+ this.post({ type: 'btwCleared' })
829
+ this.say('the side thread is cleared')
830
+ return
831
+ }
832
+ if (this.abort) { this.say('/btw can be asked once the agent has finished this step'); return }
833
+ const id = `btw-${Date.now()}`
834
+ this.post({ type: 'btw', id, question, phase: 'start' })
835
+ const controller = new AbortController()
836
+ this.btwAbort = controller
837
+ try {
838
+ const answer = await agent.sideQuestion(serverUrl(), this.history, question,
839
+ { model: this.model || undefined, session: this.session, signal: controller.signal, thread: this.btw,
840
+ onDelta: (d) => this.post({ type: 'btw', id, delta: d }) })
841
+ this.post({ type: 'btw', id, phase: 'done', answer })
842
+ // A thread: the next /btw follows on from this one — the last 20, kept with the conversation.
843
+ this.btw = [...this.btw, { id, question, answer, at: Date.now() }].slice(-20)
844
+ this.rememberSoon()
845
+ } catch (error) {
846
+ this.post({ type: 'btw', id, phase: 'done', error: error.message })
847
+ } finally {
848
+ this.btwAbort = null
849
+ }
850
+ }
851
+
852
+ async exportConversation(target) {
853
+ if (!this.transcript.length) { this.say('nothing to export yet'); return }
854
+ const md = toMarkdown(this.conversationTitle, this.transcript)
855
+ let destination = target
856
+ if (!destination) {
857
+ const choice = await vscode.window.showQuickPick(['Copy to the clipboard', 'Save to a file…'], { title: 'Export the conversation' })
858
+ if (!choice) return
859
+ if (choice.startsWith('Copy')) { await vscode.env.clipboard.writeText(md); this.say('the conversation is on the clipboard, as Markdown'); return }
860
+ const uri = await vscode.window.showSaveDialog({ defaultUri: workspaceRoot() && vscode.Uri.joinPath(workspaceRoot(),
861
+ `${this.conversationTitle.replace(/[^\w-]+/g, '-').slice(0, 40)}.md`), filters: { Markdown: ['md'], Text: ['txt'] } })
862
+ if (!uri) return
863
+ await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(md))
864
+ this.say(`exported to ${vscode.workspace.asRelativePath(uri)}`)
865
+ return
866
+ }
867
+ const uri = path.isAbsolute(destination) ? vscode.Uri.file(destination) : vscode.Uri.joinPath(workspaceRoot(), destination)
868
+ await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(md))
869
+ this.say(`exported to ${vscode.workspace.asRelativePath(uri)}`)
870
+ }
871
+
872
+ async status() {
873
+ let online = false
874
+ let serverVersion = ''
875
+ let roles = {}
876
+ try { const c = await ask('/api/vscode/config'); online = true; roles = c.roles || {}; serverVersion = c.server?.version || '' } catch { /* offline */ }
877
+ const servers = await mcp.ensure({ trust: (list, print) => this.trustMcp(list, print) }).catch(() => mcp.status())
878
+ const instructions = context.instructions().map((f) => f.name)
879
+ const tasks = shell.taskList()
880
+ this.info('Status', [
881
+ `**AIPictury** ${VERSION} · server ${serverUrl()} — ${online ? `online${serverVersion ? ` (${serverVersion})` : ''}` : 'not answering'}`,
882
+ `**Model** ${this.model || roles.agent || '—'}${this.model ? ' (chosen here)' : ' (the server\'s agent role)'}`,
883
+ `**Mode** ${agent.MODES[agent.modeNow()]?.label} · **effort** ${agent.effortOf(config().get('effort', 'high'))}`
884
+ + ` · **reasoning** ${config().get('showThinking', true) ? 'on' : 'off'}`,
885
+ `**Context** ${agent.estimateTokens(this.history).toLocaleString()} tokens (estimated) of ${agent.TARGET_TOKENS.toLocaleString()} before auto-compact`,
886
+ `**Instructions** ${instructions.length ? instructions.join(', ') : 'none (run /init to write AIPICTURY.md)'}`,
887
+ `**MCP** ${servers.length ? servers.map((s) => `${s.name} — ${s.state}${s.state === 'connected' ? ` (${s.tools.length} tools)` : s.error ? `: ${s.error}` : ''}`).join('; ') : 'no servers (.mcp.json)'}`,
888
+ `**Skills** ${context.skills().length} · **custom commands** ${context.commands().length}`,
889
+ `**Background** ${tasks.filter((t) => t.running).length} running`,
890
+ ])
891
+ }
892
+
893
+ /** /usage: this conversation, today, or this week — by tool, helper, MCP server, skill. */
894
+ showUsage() {
895
+ let tab = 'conversation'
896
+ const spec = () => dialogs.usageSpec(tab, { conversation: this.usage, context: agent.estimateTokens(this.history) })
897
+ return this.showDialog(spec(), async (action, message) => { if (action === 'tab') { tab = message.value; return spec() } return undefined })
898
+ }
899
+
900
+ /** /mcp: every server, by scope, with its state — on/off, reconnect, add, remove. */
901
+ async mcpStatus() {
902
+ const folder = workspaceRoot()?.fsPath
903
+ const trust = (servers, print) => this.trustMcp(servers, print)
904
+ await mcp.ensure({ trust }).catch(() => {})
905
+ return this.showDialog(dialogs.mcpSpec(folder), dialogs.mcpHandler(folder, { trust }))
906
+ }
907
+
908
+ /** Before a project's own MCP servers connect (they start programs from the repository). */
909
+ async trustMcp(servers, print) {
910
+ const folder = workspaceRoot()?.fsPath || ''
911
+ const trusted = this.context.globalState.get('aipictury.trustedMcp', {})
912
+ if (trusted[folder] === print) return true
913
+ TRUST_DECLINED ||= new Set()
914
+ if (TRUST_DECLINED.has(`mcp:${folder}:${print}`)) return false
915
+ const lines = servers.map((x) => `• ${x.name}: ${x.config.command ? [x.config.command, ...(x.config.args || [])].join(' ') : x.config.url}`)
916
+ const yes = 'Trust and connect'
917
+ const ok = await this.confirm('This project has MCP servers: programs it starts, and tools the agent can use.',
918
+ `${lines.slice(0, 12).join('\n')}\n\nConnect them only if you trust this project. /mcp lists them.`, yes)
919
+ if (ok) await this.context.globalState.update('aipictury.trustedMcp', { ...trusted, [folder]: print })
920
+ else TRUST_DECLINED.add(`mcp:${folder}:${print}`)
921
+ return ok
922
+ }
923
+
924
+ /** Can the model this message goes to see images itself? If not, a vision model
925
+ * describes them first — the way the web assistant handles it. */
926
+ canSee() {
927
+ const name = this.model || this.roles?.agent
928
+ return !!this.models.find((m) => m.name === name)?.vision
929
+ }
930
+
931
+ async pickFiles() {
932
+ const room = MAX_ATTACH - this.attachments.length
933
+ if (room <= 0) {
934
+ vscode.window.showInformationMessage(`At most ${MAX_ATTACH} files per message.`)
935
+ return
936
+ }
937
+ const picked = await vscode.window.showOpenDialog({
938
+ canSelectMany: true, openLabel: 'Attach',
939
+ defaultUri: workspaceRoot(),
940
+ filters: { 'Images, PDFs and text': ATTACH_TYPES, 'All files': ['*'] },
941
+ })
942
+ for (const uri of (picked || []).slice(0, room)) {
943
+ await this.addFile(uri.path.split('/').pop(), await vscode.workspace.fs.readFile(uri))
944
+ }
945
+ if ((picked || []).length > room) {
946
+ vscode.window.showInformationMessage(`Only ${room} more file(s) fit — ${MAX_ATTACH} per message.`)
947
+ }
948
+ }
949
+
950
+ /** Files dragged in from the Explorer (with Shift): inside the workspace they become
951
+ * @-mentions — the agent reads them — and anything else is attached. */
952
+ async dropUris(uris) {
953
+ const mentions = []
954
+ for (const raw of uris.slice(0, MAX_ATTACH)) {
955
+ let uri
956
+ try { uri = vscode.Uri.parse(raw) } catch { continue }
957
+ const folder = vscode.workspace.getWorkspaceFolder(uri)
958
+ if (folder) {
959
+ const rel = vscode.workspace.asRelativePath(uri, false)
960
+ let dir = false
961
+ try { dir = !!((await vscode.workspace.fs.stat(uri)).type & vscode.FileType.Directory) } catch { /* gone */ }
962
+ mentions.push(`@${rel}${dir ? '/' : ''}`)
963
+ } else if (uri.scheme === 'file') {
964
+ try { await this.addFile(path.basename(uri.fsPath), await vscode.workspace.fs.readFile(uri)) } catch { /* unreadable */ }
965
+ }
966
+ }
967
+ if (mentions.length) this.post({ type: 'insert', text: `${mentions.join(' ')} ` })
968
+ }
969
+
970
+ /** Turns one file into something the model can read, on the server. */
971
+ async addFile(name, bytes) {
972
+ if (this.attachments.length >= MAX_ATTACH) return
973
+ const id = `a${Date.now().toString(36)}${this.attachments.length}`
974
+ this.attachments.push({ id, name, kind: 'loading' })
975
+ this.postAttachments()
976
+ try {
977
+ Object.assign(this.attachments.find((a) => a.id === id) || {}, await this.convert(name, bytes, !this.canSee()))
978
+ } catch (error) {
979
+ this.attachments = this.attachments.filter((a) => a.id !== id)
980
+ vscode.window.showWarningMessage(`Could not attach ${name}: ${error.message}`)
981
+ }
982
+ this.postAttachments()
983
+ }
984
+
985
+ /** A PDF as text, a picture as itself or described: the server's conversion. */
986
+ async convert(name, bytes, describe, pages) {
987
+ const form = new FormData()
988
+ form.append('file', new Blob([bytes]), name)
989
+ form.append('describe', String(describe))
990
+ if (pages) form.append('pages', String(pages))
991
+ const response = await fetch(`${serverUrl()}/api/agent/attach`,
992
+ { method: 'POST', body: form, signal: AbortSignal.timeout(120_000) })
993
+ const body = await response.json().catch(() => ({}))
994
+ if (!response.ok) throw new Error(body.detail || `the server answered ${response.status}`)
995
+ return body
996
+ }
997
+
998
+ /** read_file on a PDF or a picture: converted as an attachment is. */
999
+ async readSpecial(uri, args) {
1000
+ const bytes = await vscode.workspace.fs.readFile(uri)
1001
+ const name = path.basename(uri.fsPath)
1002
+ const body = await this.convert(name, bytes, !this.canSee(), args.pages)
1003
+ if (body.kind === 'image' && body.image) return { text: `${args.path}: the picture is attached below — look at it.`, images: [body.image] }
1004
+ const text = String(body.text || '')
1005
+ const shown = text.length > 120_000 ? `${text.slice(0, 120_000)}\n… [the rest of ${args.path} is not shown — ${text.length} characters]` : text
1006
+ // A PDF's scanned pages come as pictures beside its text: looked at, not read.
1007
+ return body.images?.length ? { text: shown, images: body.images } : shown
1008
+ }
1009
+
1010
+ postAttachments() {
1011
+ this.post({ type: 'attachments', max: MAX_ATTACH, items: this.attachments.map((a) => ({
1012
+ id: a.id, name: a.name, kind: a.kind,
1013
+ thumb: a.kind === 'image' ? `data:image/*;base64,${a.image}` : undefined })) })
1014
+ }
1015
+
1016
+ /** Tells the panel which model is answering, so the user is never guessing. */
1017
+ async refreshStatus() {
1018
+ try {
1019
+ const cfg = await ask('/api/vscode/config')
1020
+ this.post({ type: 'status', online: true, roles: cfg.roles, server: serverUrl() })
1021
+ // Only models that can call tools can drive the agent; the picker shows those.
1022
+ this.models = cfg.models || []
1023
+ this.roles = cfg.roles || {}
1024
+ const coding = (cfg.models || []).filter((m) => m.tools).map((m) => m.name)
1025
+ // If the stored choice is gone (a model was removed), fall back to the role default.
1026
+ if (this.model && !coding.includes(this.model)) this.model = null
1027
+ const current = this.model || cfg.roles.agent
1028
+ this.post({ type: 'models', models: coding, current, fallback: cfg.roles.agent })
1029
+ } catch (error) {
1030
+ // Why it cannot be used, as the web app says it: none set, your account refused, or no answer.
1031
+ const server = serverUrl()
1032
+ const refused = /answered 401/.test(error.message) ? (/"detail":\s*"([^"]+)"/.exec(error.message)?.[1] || 'refused') : null
1033
+ this.post({ type: 'status', online: false, server, why: !server ? 'none' : refused ? 'refused' : 'unreachable', detail: refused })
1034
+ }
1035
+ }
1036
+
1037
+ /** The user's message with whatever is attached, mentioned or selected, ready for the conversation. */
1038
+ async buildMessage(rawText) {
1039
+ const cleaned = stripInvisible(rawText)
1040
+ if (cleaned.removed) this.say(`removed ${cleaned.removed} invisible character${cleaned.removed === 1 ? '' : 's'} from your message`)
1041
+ const text = cleaned.text
1042
+ const attached = this.attachments.filter((a) => a.kind !== 'loading')
1043
+ this.attachments = []
1044
+ this.postAttachments()
1045
+ const parts = [text]
1046
+ const mentioned = await resolveMentions(text, this.session)
1047
+ if (mentioned.length) parts.push(`Files the user referenced (already read for you):\n\n${mentioned.join('\n\n')}`)
1048
+ const texts = attached.filter((a) => a.kind === 'text').map((a) => a.text)
1049
+ if (texts.length) parts.push(`Attached files:\n\n${texts.join('\n\n')}`)
1050
+ const where = editorTracker.contextFor(this)
1051
+ if (where) parts.push(where)
1052
+ const images = attached.filter((a) => a.kind === 'image').map((a) => a.image)
1053
+ const message = { role: 'user', content: parts.join('\n\n') }
1054
+ if (images.length) message.images = images
1055
+ return { message, text, files: attached.map((a) => ({ name: a.name, kind: a.kind })) }
1056
+ }
1057
+
1058
+ postQueue() { this.post({ type: 'queue', items: this.queue.map((q) => q.text) }) }
1059
+
1060
+ /** While this conversation is worked from elsewhere (remote control): the account's devices
1061
+ * are told — a step waiting for a yes, a run done (the server's push.py; a click opens it). */
1062
+ notifyAway(title, body) {
1063
+ if (!this.remote?.url) return
1064
+ const server = serverUrl()
1065
+ fetch(`${server}/api/push/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
1066
+ body: JSON.stringify({ title, body: String(body).slice(0, 300), link: this.remote.url, tag: `remote-${this.id || 'new'}` }),
1067
+ signal: AbortSignal.timeout(15_000) }).catch(() => {})
1068
+ }
1069
+
1070
+ /** Remote control: this conversation, carried on from a browser (remote.js). */
1071
+ async remoteControl(action, { asked = false } = {}) {
1072
+ if (action === 'stop') {
1073
+ if (!this.remote) { this.say('remote control is not on', 'remote'); return }
1074
+ this.remote.stop()
1075
+ this.registry?.account?.cloud?.dropRemote?.(this.remote.id).catch(() => {})
1076
+ this.remote = null
1077
+ this.post({ type: 'remote', on: false })
1078
+ this.say('remote control ended — the link no longer works', 'remote')
1079
+ return
1080
+ }
1081
+ if (!this.remote) {
1082
+ this.remote = new RemoteLink(this, serverUrl(), {
1083
+ webApp: config().get('webApp', 'https://aipictury.com'),
1084
+ token: () => this.registry?.account?.account.idToken(),
1085
+ log: (line) => log.warn(line),
1086
+ onViewers: (n) => this.post({ type: 'remote', on: true, viewers: n, url: this.remote?.url }),
1087
+ }).start()
1088
+ }
1089
+ if (!asked) await vscode.env.clipboard.writeText(this.remote.url) // asked from the web: it has the link
1090
+ this.post({ type: 'remote', on: true, viewers: this.remote.viewers, url: this.remote.url })
1091
+ // Listed with the account, so aipictury.com offers to control it (Live in VS Code).
1092
+ const title = this.title || String(this.history.find((m) => m.role === 'user')?.content || 'A conversation').split('\n')[0]
1093
+ this.registry?.account?.cloud?.putRemote?.(this.remote.id, { url: this.remote.url, title: title.slice(0, 200),
1094
+ device: require('os').hostname().slice(0, 80), conversation: this.id || '' }).catch((e) => log.warn(`remote not listed with the account: ${e.message}`))
1095
+ if (asked) { this.say('remote control is on — asked from aipictury.com, where it is open now. /remote-control stop ends it.', 'remote'); return }
1096
+ this.say(`remote control is on — the link is copied: open it in a browser or on your phone (on your network). Anyone with it `
1097
+ + 'can drive this conversation; yes or no to a step is given there, rules and modes stay here. /remote-control stop ends it.', 'remote')
1098
+ }
1099
+
1100
+ /** Dictation: recorded here (webviews have no microphone), written by the server's Whisper,
1101
+ * and put into the input — never sent. 'toggle' starts or stops; 'cancel' discards. */
1102
+ async dictate(action) {
1103
+ const say = (state, extra = {}) => {
1104
+ this.post({ type: 'dictation', state, text: extra.text })
1105
+ if (extra.error) this.say(extra.error, 'dictation') // a note in the conversation, as other notices
1106
+ }
1107
+ if (action === 'toggle') action = this.recording ? 'stop' : 'start'
1108
+ if (action === 'cancel') { this.recording?.cancel(); this.recording = null; say('idle'); return }
1109
+ if (action === 'start') {
1110
+ if (this.recording) return
1111
+ try { this.recording = new voice.Recording().start() } catch (error) { say('idle', { error: error.message }); return }
1112
+ log.info(`dictation: recording with ${this.recording.how.tool}`)
1113
+ say('recording')
1114
+ return
1115
+ }
1116
+ const rec = this.recording
1117
+ if (!rec) return
1118
+ this.recording = null
1119
+ say('writing')
1120
+ try {
1121
+ const file = await rec.stop()
1122
+ const r = await voice.transcribe(serverUrl(), file)
1123
+ log.info(`dictation: ${r.duration} s written in ${r.seconds} s (${r.language})`)
1124
+ say('idle', r.text ? { text: r.text } : { error: 'Nothing was heard — try again, closer to the microphone.' })
1125
+ } catch (error) { say('idle', { error: `Not written: ${error.message}` }) }
1126
+ }
1127
+
1128
+ /** Queued messages, handed to the running agent at its next step — as Claude Code
1129
+ * does: the model reads them before its next move and corrects course. */
1130
+ /** Delivers the scheduled prompts that are due: a new run when idle, else between steps. */
1131
+ tickJobs(now = Date.now()) {
1132
+ if (!this.jobs?.length) return []
1133
+ const { ready, jobs } = scheduler.due(this.jobs, now)
1134
+ if (!ready.length) return []
1135
+ this.jobs = jobs
1136
+ this.rememberSoon()
1137
+ for (const j of ready) {
1138
+ this.post({ type: 'nudge', reason: 'scheduled', text: `⏰ ${j.id}${j.recurring ? ` (every ${j.schedule})` : ''}: ${j.prompt.slice(0, 80)}` })
1139
+ ;(this.events ||= []).push({ role: 'user', content: `[A scheduled prompt (${j.id}, ${j.recurring ? `every ${j.schedule}` : 'once'}) — do this now:]\n${j.prompt}` })
1140
+ }
1141
+ if (!this.abort && !this.compacting) this.start(this.events.splice(0)).catch((error) => log.warn('scheduled prompt', error))
1142
+ return ready
1143
+ }
1144
+
1145
+ /** The agent's (and /loop's) way into this conversation's schedule. */
1146
+ schedulerHooks() {
1147
+ return { get: () => this.jobs || [], set: (jobs) => { this.jobs = jobs; this.rememberSoon() } }
1148
+ }
1149
+
1150
+ /** A background helper reported: an event for the agent, as a monitor's lines are. */
1151
+ onHelperDone(h, text) {
1152
+ this.post({ type: 'nudge', reason: 'helper', text: `${h.id}${h.name ? ` (${h.name})` : ''} has reported` })
1153
+ ;(this.events ||= []).push({ role: 'user', content: `[The background helper ${h.id}${h.name ? ` (${h.name})` : ''} has finished:]\n${text}` })
1154
+ if (!this.abort && !this.compacting) this.start(this.events.splice(0)).catch((error) => log.warn('helper report', error))
1155
+ }
1156
+
1157
+ /** A monitor's new lines: between the agent's steps while it works, or a new run when it is idle. */
1158
+ onMonitorLines(task, lines, { ended } = {}) {
1159
+ const what = task.monitor?.description ? ` (${task.monitor.description})` : ''
1160
+ this.post({ type: 'nudge', reason: 'monitor', text: `${task.id}${what}: ${lines.length} new line${lines.length === 1 ? '' : 's'}${ended ? ' — it has stopped' : ''}` })
1161
+ this.post({ type: 'tasks', items: shell.taskList() })
1162
+ ;(this.events ||= []).push({ role: 'user', content: `[An event from the monitor ${task.id}${what} — its new output:]\n${lines.join('\n')}` })
1163
+ if (!this.abort && !this.compacting) this.start(this.events.splice(0)).catch((error) => log.warn('monitor event', error))
1164
+ }
1165
+
1166
+ takeQueued() {
1167
+ const events = (this.events || []).splice(0)
1168
+ const taken = this.queue.splice(0)
1169
+ if (taken.length) this.postQueue()
1170
+ return [...events, ...taken.map((q) => {
1171
+ this.transcript.push({ kind: 'user', text: q.text, files: q.files, at: Date.now() })
1172
+ this.post({ type: 'user', text: q.text, files: q.files, at: Date.now() })
1173
+ return { ...q.message, content: '[The user sent this while you were working — take it '
1174
+ + `into account from here on.]\n${q.message.content}` }
1175
+ })]
1176
+ }
1177
+
1178
+ /** Signed in or not: the panel shows the sign-in screen when not. */
1179
+ postAccount(user) {
1180
+ this.post({ type: 'account', user: user ? { email: user.email || '', name: user.displayName || '' } : null })
1181
+ }
1182
+
1183
+ async ask(raw) {
1184
+ const text = String(raw || '').trim()
1185
+ if (!text) return
1186
+ // AIPictury needs the account: signed out, nothing reaches the agent.
1187
+ if (this.registry?.account && !this.registry.account.signedIn()) {
1188
+ this.postAccount(null)
1189
+ this.post({ type: 'draft', text })
1190
+ return
1191
+ }
1192
+ // The history is being rewritten: nothing joins it until the summary is in.
1193
+ if (this.compacting) {
1194
+ this.say('one moment — the conversation is being summarized')
1195
+ this.post({ type: 'draft', text })
1196
+ return
1197
+ }
1198
+ if (this.abort) {
1199
+ // A run is going. A rule is saved on the spot; a command waits; anything else is
1200
+ // queued for the agent's next step.
1201
+ if (/^#/.test(text) && await this.shortcut(text)) return
1202
+ if (text.startsWith('/') && !/^\/btw\b/.test(text)) { this.say(`${text.split(/\s/)[0]} can run once the agent has finished`); return }
1203
+ if (/^\/btw\b/.test(text)) { this.say('/btw can be asked once the agent has finished this step'); return }
1204
+ this.queue.push(await this.buildMessage(text))
1205
+ this.postQueue()
1206
+ // Mid-generation, the answer being written is cut so the model hears this now;
1207
+ // mid-tool, the tool finishes its step first and the message goes in after it.
1208
+ this.steer?.abort()
1209
+ return
1210
+ }
1211
+ let prompt = text
1212
+ const handled = await this.shortcut(text)
1213
+ if (handled === true) return
1214
+ if (handled && handled.prompt) prompt = handled.prompt
1215
+ if (!vscode.workspace.workspaceFolders?.length) {
1216
+ this.post({ type: 'error', text: 'Open a folder first — the agent works inside a workspace.' })
1217
+ return
1218
+ }
1219
+ // The user's UserPromptSubmit hooks: they can stop the message (it goes back to the input,
1220
+ // with their reason) or add context for the model.
1221
+ const submitted = await this.fireHook('UserPromptSubmit', { prompt })
1222
+ if (submitted.block || submitted.stop) {
1223
+ this.post({ type: 'error', text: `A UserPromptSubmit hook stopped this message: ${[...submitted.reasons, submitted.stopReason].filter(Boolean).join('; ')}` })
1224
+ this.post({ type: 'draft', text })
1225
+ return
1226
+ }
1227
+ const built = await this.buildMessage(prompt)
1228
+ if (submitted.context.length) appendText(built.message, `\n\n[Context from your hooks]\n${submitted.context.join('\n')}`)
1229
+ const checkpoint = this.newCheckpoint()
1230
+ this.transcript.push({ kind: 'user', text, files: built.files, at: Date.now(), checkpoint })
1231
+ this.post({ type: 'user', text, files: built.files, at: Date.now(), index: this.transcript.length - 1 })
1232
+ await this.start([built.message])
1233
+ }
1234
+
1235
+ newCheckpoint() {
1236
+ const id = `k${Date.now().toString(36)}`
1237
+ // What the conversation's own state was when the question was asked, so going back to
1238
+ // it (rewind, fork) brings back the to-dos and figures of then, not of a future undone.
1239
+ this.checkpoints.push({ id, at: Date.now(), history: this.history.length, transcript: this.transcript.length, files: {},
1240
+ todos: JSON.parse(JSON.stringify(this.todos || [])), usage: { ...this.usage } })
1241
+ if (this.checkpoints.length > CHECKPOINTS) this.checkpoints.shift()
1242
+ return id
1243
+ }
1244
+
1245
+ /** The files the agent changes are kept as they were, per question — for rewinding. */
1246
+ onFileChange(relPath, before) {
1247
+ const cp = this.checkpoints[this.checkpoints.length - 1]
1248
+ if (cp && !(relPath in cp.files)) cp.files[relPath] = before
1249
+ }
1250
+
1251
+ /** Every file the agent changed from checkpoint `at` onwards, as it was then. */
1252
+ snapshotFrom(at) {
1253
+ const restore = {}
1254
+ for (const cp of this.checkpoints.slice(at)) {
1255
+ for (const [p, before] of Object.entries(cp.files)) if (!(p in restore)) restore[p] = before
1256
+ }
1257
+ return restore
1258
+ }
1259
+
1260
+ async restoreFiles(snapshot) {
1261
+ let restored = 0
1262
+ const skipped = []
1263
+ for (const [p, before] of Object.entries(snapshot)) {
1264
+ try {
1265
+ const uri = vscode.Uri.joinPath(workspaceRoot(), p)
1266
+ let link = false
1267
+ try { link = fs.lstatSync(uri.fsPath).isSymbolicLink() } catch { /* gone */ }
1268
+ if (link) { skipped.push(p); continue }
1269
+ if (before === null) await vscode.workspace.fs.delete(uri).then(() => {}, () => {})
1270
+ else await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(before))
1271
+ restored += 1
1272
+ } catch { skipped.push(p) }
1273
+ }
1274
+ return { restored, skipped }
1275
+ }
1276
+
1277
+ /** Shows a dialog over the conversation. `handler(action, message)` answers each action
1278
+ * with the dialog as it now is, or null to close it; undefined leaves it as it is. */
1279
+ dialog(spec, handler) {
1280
+ this.openDialog = { id: spec.id, handler, spec }
1281
+ this.post({ type: 'dialog', spec })
1282
+ }
1283
+
1284
+ /** Brings the panel into view, then opens a dialog in it. */
1285
+ async showDialog(spec, handler) {
1286
+ if (this.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
1287
+ else this.host?.reveal?.()
1288
+ this.dialog(await spec, handler)
1289
+ }
1290
+
1291
+ async openAccount() {
1292
+ const ui = require('./accountUi')
1293
+ return this.showDialog(ui.accountSpec(this.registry.account), ui.accountHandler(this.registry.account))
1294
+ }
1295
+
1296
+ async openPages() { return this.showDialog(await dialogs.pagesSpec(serverUrl()), dialogs.pagesHandler(serverUrl())) }
1297
+ openAgents() { return this.showDialog(dialogs.agentsSpec(this), dialogs.agentsHandler(this)) }
1298
+ openPlugins() {
1299
+ const folder = workspaceRoot()?.fsPath
1300
+ // What a plugin brings is read at once: the / list, and its MCP servers.
1301
+ const changed = () => { this.postCommands(); mcp.ensure({ trust: (l, pr) => this.trustMcp(l, pr) }).catch(() => {}) }
1302
+ return this.showDialog(dialogs.pluginsSpec(folder), dialogs.pluginsHandler(folder, { changed }))
1303
+ }
1304
+ openSkills() { const folder = workspaceRoot()?.fsPath; return this.showDialog(dialogs.skillsSpec(folder), dialogs.skillsHandler(folder)) }
1305
+ openOutputStyle() { const folder = workspaceRoot()?.fsPath; return this.showDialog(dialogs.outputStyleSpec(folder), dialogs.outputStyleHandler(folder)) }
1306
+ openSandbox() { return this.showDialog(dialogs.sandboxSpec(), dialogs.sandboxHandler()) }
1307
+ openHooks() { const folder = workspaceRoot()?.fsPath; return this.showDialog(dialogs.hooksSpec(folder), dialogs.hooksHandler(folder)) }
1308
+ openPermissions() { const folder = workspaceRoot()?.fsPath; return this.showDialog(dialogs.permissionsSpec(folder), dialogs.permissionsHandler(folder)) }
1309
+ openMemory() { return this.showDialog(dialogs.memorySpec(serverUrl()), dialogs.memoryHandler(serverUrl())) }
1310
+ openInstructions() { return this.showDialog(dialogs.instructionsSpec(), dialogs.instructionsHandler(this)) }
1311
+ openSlashCommands() { return this.showDialog(dialogs.slashSpec(SLASH), dialogs.slashHandler(this, SLASH)) }
1312
+ reportProblem(kind = 'bug', description = '') {
1313
+ return this.showDialog(dialogs.reportSpec(kind, description), dialogs.reportHandler({ server: serverUrl(), version: VERSION }))
1314
+ }
1315
+
1316
+ async onDialogAction(message) {
1317
+ const open = this.openDialog
1318
+ if (!open || message.dialog !== open.id) return // an old dialog's click
1319
+ if (message.action === 'close') { this.openDialog = null; return }
1320
+ let next
1321
+ try {
1322
+ next = await open.handler(message.action, message)
1323
+ } catch (error) {
1324
+ log.error(`dialog ${open.id}: ${message.action}`, error)
1325
+ next = { ...open.spec, form: open.spec.form ? { ...open.spec.form, error: error.message } : undefined,
1326
+ note: open.spec.form ? open.spec.note : `Could not do that: ${error.message}` }
1327
+ }
1328
+ if (next === null) { this.openDialog = null; this.post({ type: 'dialogClose' }); return }
1329
+ if (next) { open.spec = next; this.post({ type: 'dialog', spec: next }) }
1330
+ }
1331
+
1332
+ /** The conversation's own state as it was at a checkpoint: its to-do list and figures.
1333
+ * A checkpoint older than this (saved before they were recorded) leaves none. */
1334
+ backTo(cp) {
1335
+ this.todos = cp?.todos ? JSON.parse(JSON.stringify(cp.todos)) : []
1336
+ this.usage = cp?.usage ? { ...cp.usage } : { turns: 0, prompt: 0, output: 0, seconds: 0 }
1337
+ }
1338
+
1339
+ /** Before a project's own hooks run (they run commands on this machine): asked once per
1340
+ * workspace, and again when they change. "Not now" holds until VS Code restarts. */
1341
+ async trustHooks(groups, print) {
1342
+ const folder = workspaceRoot()?.fsPath || ''
1343
+ const trusted = this.context.globalState.get('aipictury.trustedHooks', {})
1344
+ if (trusted[folder] === print) return true
1345
+ TRUST_DECLINED ||= new Set()
1346
+ if (TRUST_DECLINED.has(`${folder}:${print}`)) return false
1347
+ const commands = groups.flatMap((g) => (g.hooks || []).map((h) => `• ${h.command} (${path.relative(folder, g.file) || g.file})`))
1348
+ const yes = 'Trust and run them'
1349
+ const ok = await this.confirm('This project has hooks: commands that run on your machine while the agent works.',
1350
+ `${commands.slice(0, 12).join('\n')}${commands.length > 12 ? '\n…' : ''}\n\nRun them only if you trust this project. You can change them in AIPictury: Hooks.`, yes)
1351
+ if (ok) await this.context.globalState.update('aipictury.trustedHooks', { ...trusted, [folder]: print })
1352
+ else TRUST_DECLINED.add(`${folder}:${print}`)
1353
+ return ok
1354
+ }
1355
+
1356
+ /** Fires one of the user's hooks for this conversation. */
1357
+ fireHook(event, payload, extra = {}) {
1358
+ return userHooks.fire(event, payload, { folder: workspaceRoot()?.fsPath, sessionId: this.id || '',
1359
+ trust: (g, print) => this.trustHooks(g, print), log: (line) => log.warn(line), ...extra })
1360
+ }
1361
+
1362
+ /** A modal yes/no in VS Code; true only for an explicit yes. Replaceable in tests. */
1363
+ async confirm(title, detail, yes) {
1364
+ return (await vscode.window.showWarningMessage(title, { modal: true, detail }, yes)) === yes
1365
+ }
1366
+
1367
+ /** The rewind menu on a question: restore the code, the conversation, or fork from it. */
1368
+ async rewind(index, action, { quiet = false } = {}) {
1369
+ if (this.abort) { this.say('stop the agent before rewinding'); return }
1370
+ const turn = this.transcript[index]
1371
+ if (!turn || turn.kind !== 'user') return
1372
+ const at = this.checkpoints.findIndex((c) => c.id === turn.checkpoint)
1373
+ const cp = this.checkpoints[at]
1374
+ const code = action === 'code' || action === 'both' || action === 'fork-code'
1375
+ if (code && !cp) { this.say('no file changes were recorded for that message'); return }
1376
+ // Nothing is restored, cut or forked without the user saying yes to exactly what will
1377
+ // happen. `quiet` is the Retry button's own rewind, which is not a menu choice.
1378
+ if (!quiet && !(await this.confirm(...rewindQuestion(action, turn.text, code ? Object.keys(this.snapshotFrom(at)) : [])))) {
1379
+ this.say('nothing was changed')
1380
+ return
1381
+ }
1382
+ let note = ''
1383
+ if (code) {
1384
+ const { restored, skipped } = await this.restoreFiles(this.snapshotFrom(at))
1385
+ note = restored || skipped.length
1386
+ ? `restored ${restored} file${restored === 1 ? '' : 's'} to how they were before that message`
1387
+ + `${skipped.length ? ` (skipped ${skipped.join(', ')})` : ''} — changes made by commands are not tracked`
1388
+ : 'the agent had changed no files since that message'
1389
+ }
1390
+ if (action === 'conversation' || action === 'both') {
1391
+ const historyAt = cp ? cp.history : this.history.findIndex((m) => m.role === 'user' && String(m.content).includes(turn.text.slice(0, 40)))
1392
+ if (historyAt < 0) { this.say('that message can no longer be found in the conversation'); return }
1393
+ this.history = this.history.slice(0, historyAt)
1394
+ this.transcript = this.transcript.slice(0, index)
1395
+ this.checkpoints = this.checkpoints.slice(0, Math.max(0, at))
1396
+ this.backTo(cp)
1397
+ await this.remember()
1398
+ this.post({ type: 'restore', turns: this.transcript, todos: this.todos })
1399
+ this.post({ type: 'context', used: agent.estimateTokens(this.history) })
1400
+ if (quiet) return
1401
+ this.post({ type: 'draft', text: turn.text })
1402
+ this.say(note ? `${note}; the conversation is back to before that message` : 'the conversation is back to before that message — edit and resend')
1403
+ return
1404
+ }
1405
+ if (action === 'fork' || action === 'fork-code') {
1406
+ await this.remember()
1407
+ const historyAt = cp ? cp.history : this.history.length
1408
+ const forked = { history: this.history.slice(0, historyAt), transcript: this.transcript.slice(0, index),
1409
+ checkpoints: this.checkpoints.slice(0, Math.max(0, at)) }
1410
+ this.id = null
1411
+ this.created = null
1412
+ this.title = `${this.conversationTitle.slice(0, 60)} (fork)`
1413
+ this.history = forked.history
1414
+ this.transcript = forked.transcript
1415
+ this.checkpoints = forked.checkpoints
1416
+ this.session = { allowAll: false }
1417
+ this.backTo(cp)
1418
+ if (this.history.length) await this.remember()
1419
+ this.post({ type: 'restore', turns: this.transcript, todos: this.todos })
1420
+ this.post({ type: 'context', used: agent.estimateTokens(this.history) })
1421
+ this.post({ type: 'draft', text: turn.text })
1422
+ this.say(`${note ? `${note}. ` : ''}forked: a new conversation from before that message — the original is kept in history`)
1423
+ return
1424
+ }
1425
+ this.say(note)
1426
+ }
1427
+
1428
+ async rewindPicker() {
1429
+ const questions = this.transcript.map((t, i) => ({ t, i })).filter(({ t }) => t.kind === 'user')
1430
+ if (!questions.length) { this.say('nothing to rewind yet'); return }
1431
+ const chosen = await vscode.window.showQuickPick(questions.reverse().map(({ t, i }) => ({
1432
+ label: String(t.text).slice(0, 80), description: t.at ? ago(t.at) : '', index: i })), { title: 'Rewind to before which message?' })
1433
+ if (!chosen) return
1434
+ const action = await vscode.window.showQuickPick([
1435
+ { label: 'Restore code and conversation', action: 'both' },
1436
+ { label: 'Restore conversation', description: 'keep the code as it is', action: 'conversation' },
1437
+ { label: 'Restore code', description: 'keep the conversation', action: 'code' },
1438
+ { label: 'Fork conversation from here', description: 'a new conversation; the code as it is', action: 'fork' },
1439
+ { label: 'Fork conversation and restore code', action: 'fork-code' },
1440
+ ], { title: 'Rewind' })
1441
+ if (action) await this.rewind(chosen.index, action.action)
1442
+ }
1443
+
1444
+ /** Shows the model's questions in place of the input and waits for the answer.
1445
+ * Resolves null when the run is stopped while they are open. */
1446
+ askUser(questions, signal) {
1447
+ return new Promise((resolve) => {
1448
+ const id = `ask-${Date.now()}`
1449
+ const done = (value) => {
1450
+ signal?.removeEventListener('abort', onStop)
1451
+ this.setBadge(null)
1452
+ resolve(value)
1453
+ }
1454
+ const onStop = () => {
1455
+ if (this.pendingAsk?.id !== id) return
1456
+ this.pendingAsk = null
1457
+ this.post({ type: 'askClosed', id })
1458
+ done(null)
1459
+ }
1460
+ this.pendingAsk = { id, resolve: done, questions }
1461
+ signal?.addEventListener('abort', onStop, { once: true })
1462
+ this.post({ type: 'askUser', id, questions })
1463
+ this.setBadge('waiting')
1464
+ })
1465
+ }
1466
+
1467
+ /** Asks, in the panel, whether a change or a command may go ahead. */
1468
+ approve(request, signal) {
1469
+ return new Promise((resolve) => {
1470
+ const id = `ap-${Date.now().toString(36)}-${this.approvals.size}`
1471
+ const done = (value) => {
1472
+ signal?.removeEventListener('abort', onStop)
1473
+ this.setBadge(null)
1474
+ resolve(value)
1475
+ }
1476
+ const onStop = () => {
1477
+ const pending = this.approvals.get(id)
1478
+ if (!pending) return
1479
+ this.approvals.delete(id)
1480
+ if (pending.review) dropReview(pending.review) // the copy under review
1481
+ this.post({ type: 'approvalClosed', id })
1482
+ done(null)
1483
+ }
1484
+ this.approvals.set(id, { request, resolve: done })
1485
+ signal?.addEventListener('abort', onStop, { once: true })
1486
+ this.post({ type: 'approval', id, request })
1487
+ this.setBadge('waiting')
1488
+ this.notifyAway('AIPictury is waiting for you', `${request.title || request.tool || 'A step'} needs your yes or no`)
1489
+ })
1490
+ }
1491
+
1492
+ /** Opens a proposed change in VS Code's diff, its right side editable: what the user
1493
+ * leaves there when they accept is what gets written. */
1494
+ async reviewDiff(id) {
1495
+ const pending = this.approvals.get(id)
1496
+ if (!pending || pending.request.kind !== 'edit') return
1497
+ const dir = path.join(os.tmpdir(), 'aipictury-review')
1498
+ fs.mkdirSync(dir, { recursive: true })
1499
+ const file = pending.review || path.join(dir, `${Date.now().toString(36)}-${path.basename(pending.request.path)}`)
1500
+ if (!pending.review) fs.writeFileSync(file, pending.request.after ?? '')
1501
+ pending.review = file
1502
+ const current = vscode.Uri.joinPath(workspaceRoot(), pending.request.path)
1503
+ let left = current
1504
+ try { await vscode.workspace.fs.stat(current) } catch {
1505
+ left = vscode.Uri.parse(`${SCHEME}:/${pending.request.path}?empty`)
1506
+ agent.proposedContents.set(left.toString(), '')
1507
+ }
1508
+ await vscode.commands.executeCommand('vscode.diff', left, vscode.Uri.file(file),
1509
+ `${pending.request.path} ← proposed (edit it, then Accept in the panel)`, { preview: false })
1510
+ }
1511
+
1512
+ /** Plan mode's plan, shown for approval. */
1513
+ approvePlan(plan, signal) {
1514
+ return new Promise((resolve) => {
1515
+ const id = `plan-${Date.now()}`
1516
+ const done = (value) => { signal?.removeEventListener('abort', onStop); this.setBadge(null); resolve(value) }
1517
+ const onStop = () => {
1518
+ if (this.pendingPlan?.id !== id) return
1519
+ this.pendingPlan = null
1520
+ this.post({ type: 'planClosed', id })
1521
+ done(null)
1522
+ }
1523
+ this.pendingPlan = { id, plan, resolve: done }
1524
+ signal?.addEventListener('abort', onStop, { once: true })
1525
+ this.post({ type: 'plan', id, plan })
1526
+ this.setBadge('waiting')
1527
+ })
1528
+ }
1529
+
1530
+ markLive(running) {
1531
+ if (!this.id || this.kind !== 'sidebar') return
1532
+ this.context.workspaceState.update(LIVE, { id: this.id, running, at: Date.now() })
1533
+ }
1534
+
1535
+ /** Runs the agent on the conversation with these new user messages appended. */
1536
+ async start(messages) {
1537
+ // Every run starts here — a message, a scheduled prompt, a monitor, a helper's report, remote
1538
+ // control: none runs for a machine signed out of AIPictury.
1539
+ if (this.registry?.account && !this.registry.account.signedIn()) { this.postAccount(null); return undefined }
1540
+ // One run at a time on the GPU: another app holding it, nothing starts — the message goes
1541
+ // back to the input, the panel says who has it and when it is free.
1542
+ const gpuNow = await gpuRun.state(serverUrl())
1543
+ if (gpuRun.busyFor(gpuNow)) { this.gpuBusy(gpuNow, messages); return undefined }
1544
+ // The conversation's first run: the user's SessionStart hooks may give it context.
1545
+ const source = this.startSource || (!this.history.some((m) => m.role === 'user') ? 'startup' : null)
1546
+ if (source && messages.length) {
1547
+ const started = await this.fireHook('SessionStart', { source })
1548
+ this.startSource = null
1549
+ if (started.context.length) appendText(messages[0], `\n\n[Context from your hooks]\n${started.context.join('\n')}`)
1550
+ }
1551
+ this.history.push(...messages)
1552
+ this.post({ type: 'begin' })
1553
+ const decisions = [] // choices Auto mode took without asking, shown at the end
1554
+ const runStarted = Date.now()
1555
+ this.abort = new AbortController()
1556
+ this.markLive(true)
1557
+ workbench.setState('working')
1558
+ this.registry?.sessionsView?.refresh()
1559
+ await syncCommandEnv().catch((error) => log.warn('command environment', error))
1560
+ log.info(`run started in ${this.id || 'a new conversation'} (${agent.modeNow()} mode, model ${this.model || 'the role default'})`)
1561
+ await this.remember()
1562
+ let stopped // set by whichever way the run ends
1563
+ try {
1564
+ const result = await agent.run({
1565
+ server: serverUrl(),
1566
+ model: this.model || undefined,
1567
+ window: this.models.find((m) => m.name === (this.model || this.roles?.agent))?.window,
1568
+ history: this.history,
1569
+ session: this.session,
1570
+ signal: this.abort.signal,
1571
+ onDelta: (delta) => this.post({ type: 'delta', text: delta }),
1572
+ onThinking: (delta) => this.post({ type: 'thinking', text: delta }),
1573
+ onSpeed: (speed) => this.post({ type: 'speed', ...speed }),
1574
+ onTool: (event) => {
1575
+ // One row per call, updated in place as it runs, kept with its output.
1576
+ const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
1577
+ const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
1578
+ if (existing) Object.assign(existing, event)
1579
+ else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
1580
+ this.post({ type: 'tool', ...event })
1581
+ if (event.tasks) this.post({ type: 'tasks', items: event.tasks })
1582
+ if (event.status === 'done') {
1583
+ this.rememberSoon()
1584
+ // For /usage: what this turn used — tools, and which skill or helper.
1585
+ const t = (this.turnUse ||= { tools: [], skills: [], helpers: [] })
1586
+ t.tools.push(event.tool)
1587
+ if (event.tool === 'skill' && event.args?.name) t.skills.push(event.args.name)
1588
+ if (event.tool === 'task') t.helpers.push(event.args?.subagent_type || 'explore')
1589
+ }
1590
+ },
1591
+ onNudge: (reason, detail) => this.post({ type: 'nudge', reason, ...(detail || {}) }),
1592
+ takeQueued: () => this.takeQueued(),
1593
+ onSteerable: (steer) => { this.steer = steer },
1594
+ askUser: (questions, signal) => this.askUser(questions, signal),
1595
+ approve: (request, signal) => this.approve(request, signal),
1596
+ approvePlan: (plan, signal) => this.approvePlan(plan, signal),
1597
+ onTodos: (todos) => { this.todos = todos; this.post({ type: 'todos', items: todos }) },
1598
+ onTasks: (tasks) => this.post({ type: 'tasks', items: tasks }),
1599
+ onFileChange: (p, before) => this.onFileChange(p, before),
1600
+ readSpecial: (uri, args) => this.readSpecial(uri, args),
1601
+ trustHooks: (groups, print) => this.trustHooks(groups, print),
1602
+ trustMcp: (servers, print) => this.trustMcp(servers, print),
1603
+ onMonitor: (task, lines, info) => this.onMonitorLines(task, lines, info),
1604
+ schedule: this.schedulerHooks(),
1605
+ onHelperDone: (h, text) => this.onHelperDone(h, text),
1606
+ // Saved at every step, so a crash, a reload or closing VS Code loses nothing.
1607
+ onMessages: (messages) => { this.history = messages.map(withoutImages); this.markLive(true); this.rememberSoon() },
1608
+ onDecision: (picks) => decisions.push(...picks),
1609
+ onWaiting: (event) => this.post({ type: 'waiting', seconds: event.waiting, phase: event.phase }),
1610
+ onTurn: ({ text: said, thinking, meta, calls }) => {
1611
+ if (String(said || '').trim() || String(thinking || '').trim()) {
1612
+ this.transcript.push({ kind: 'assistant', text: said, thinking, meta, at: Date.now(),
1613
+ thoughtSeconds: this.pendingThought?.seconds, thoughtTokens: this.pendingThought?.tokens })
1614
+ this.pendingThought = null
1615
+ }
1616
+ if (meta?.model) this.lastModel = meta.model
1617
+ this.usage.turns += 1
1618
+ this.usage.prompt += meta?.context_used || 0
1619
+ this.usage.output += meta?.eval_tokens || meta?.tokens || 0
1620
+ const used = this.turnUse || { tools: [], skills: [], helpers: [] }
1621
+ this.turnUse = null
1622
+ usageLog.record({ conversation: this.id, models: meta?.model ? [meta.model] : [], context: meta?.context_used || 0,
1623
+ read: meta?.prompt_read ?? null, output: meta?.eval_tokens || meta?.tokens || 0,
1624
+ genSeconds: meta?.eval_tps && meta?.eval_tokens ? meta.eval_tokens / meta.eval_tps : 0, ...used })
1625
+ if (meta?.eval_tps && meta?.eval_tokens) this.usage.genSeconds = (this.usage.genSeconds || 0) + meta.eval_tokens / meta.eval_tps
1626
+ this.post({ type: 'turn', text: said, calls: calls.length, meta })
1627
+ },
1628
+ })
1629
+ if (result.busy && !result.messages.length) { // taken by another app in the meantime
1630
+ this.history.splice(this.history.length - messages.length, messages.length)
1631
+ this.post({ type: 'end', stopped: true })
1632
+ this.gpuBusy(result.gpu, messages)
1633
+ stopped = true
1634
+ return undefined
1635
+ }
1636
+ this.history = result.messages.map(withoutImages)
1637
+ // An error no longer costs the run: the history above already holds every tool
1638
+ // call and result, so "continue" picks up from the last step, not from scratch.
1639
+ const error = result.error
1640
+ && `${result.error}\n\nEverything up to this point is kept — say **continue** to pick up from the last step.`
1641
+ if (result.error) this.transcript.push({ kind: 'error', text: error, at: Date.now() })
1642
+ this.post({ type: 'end', meta: result.meta, exhausted: result.exhausted,
1643
+ stopped: result.stopped, stuck: result.stuck, error })
1644
+ // What Auto mode decided on its own, each open to change — option C.
1645
+ if (decisions.length) {
1646
+ this.transcript.push({ kind: 'decisions', items: decisions })
1647
+ this.post({ type: 'decisions', items: decisions })
1648
+ }
1649
+ stopped = !!result.stopped
1650
+ if (!stopped) this.notifyAway(result.error ? 'AIPictury stopped on an error' : 'AIPictury is done',
1651
+ result.error ? String(result.error).split('\n')[0] : (this.title || 'The run finished'))
1652
+ } catch (error) {
1653
+ stopped = error.name === 'AbortError'
1654
+ if (!stopped) log.error('run failed', error)
1655
+ this.post({ type: 'end', stopped, error: stopped ? undefined : error.message })
1656
+ } finally {
1657
+ this.abort = null
1658
+ clearTimeout(this.saveTimer); this.saveTimer = null
1659
+ this.usage.seconds += (Date.now() - runStarted) / 1000
1660
+ log.info(`run ended after ${Math.round((Date.now() - runStarted) / 1000)} s${stopped ? ' (stopped)' : ''}`)
1661
+ if (this.session) delete this.session.commandAllow // a command's allowed-tools end with its run
1662
+ await this.remember().catch(() => {})
1663
+ this.markLive(false)
1664
+ this.refreshCache() // the turn just reset Ollama's timer
1665
+ this.setBadge('finished')
1666
+ if (!stopped) this.suggestTitle().catch((error) => log.warn('no title', error))
1667
+ }
1668
+ // Messages queued after the agent's last step. Stopped: they go back to the input to
1669
+ // edit, as in Claude Code. Otherwise they start the next run straight away.
1670
+ if (this.queue.length) {
1671
+ const left = this.queue.splice(0)
1672
+ this.postQueue()
1673
+ if (stopped) {
1674
+ this.post({ type: 'draft', text: left.map((q) => q.text).join('\n\n') })
1675
+ } else {
1676
+ for (const q of left) {
1677
+ this.transcript.push({ kind: 'user', text: q.text, files: q.files, at: Date.now(), checkpoint: this.newCheckpoint() })
1678
+ this.post({ type: 'user', text: q.text, files: q.files, at: Date.now(), index: this.transcript.length - 1 })
1679
+ }
1680
+ await this.start(left.map((q) => q.message))
1681
+ }
1682
+ }
1683
+ // A monitor spoke after the agent's last step: it carries on with that.
1684
+ if (!stopped && this.events?.length && !this.abort) await this.start(this.events.splice(0))
1685
+ }
1686
+
1687
+ /** After a window reload: the side bar's conversation comes back when it was recent, and
1688
+ * a run the reload cut off carries on from its last step — as Claude Code does. */
1689
+ async resumeAfterReload() {
1690
+ const live = this.context.workspaceState.get(LIVE)
1691
+ if (!live?.id || Date.now() - live.at > RESTORE_WINDOW && !live.running) return
1692
+ if (!(await this.open(live.id, { focus: false }))) return
1693
+ if (live.running && Date.now() - live.at < CONTINUE_WINDOW && config().get('continueAfterReload', true)) {
1694
+ this.say('the window was reloaded while the agent was working — continuing from its last step')
1695
+ this.transcript.push({ kind: 'user', text: '(continuing after the window reloaded)', at: Date.now(), checkpoint: this.newCheckpoint() })
1696
+ await this.start([{ role: 'user', content: '[The editor window was reloaded while you were working. Nothing you '
1697
+ + 'did is lost — the conversation above is complete. Continue the task from where you left off.]' }])
1698
+ }
1699
+ }
1700
+
1701
+ html(webview) {
1702
+ const uri = (...parts) =>
1703
+ webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'media', ...parts))
1704
+ const key = nonce()
1705
+ return `<!doctype html>
1706
+ <html lang="en">
1707
+ <head>
1708
+ <meta charset="utf-8">
1709
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none';
1710
+ img-src ${webview.cspSource} data: https: http:;
1711
+ style-src ${webview.cspSource} 'unsafe-inline';
1712
+ script-src 'nonce-${key}';
1713
+ font-src ${webview.cspSource};">
1714
+ <link rel="stylesheet" href="${uri('vendor', 'katex.css')}">
1715
+ <link rel="stylesheet" href="${uri('rich-markdown.css')}">
1716
+ <link rel="stylesheet" href="${uri('panel.css')}">
1717
+ <title>AIPictury Agent</title>
1718
+ </head>
1719
+ <body>
1720
+ <div id="status" class="status"></div>
1721
+ <div id="thread" class="thread" role="log" aria-label="Conversation" tabindex="0"></div>
1722
+ <div id="announce" class="sr-only" aria-live="polite"></div>
1723
+ <div id="working" class="working" hidden aria-live="polite">
1724
+ <span class="star">✻</span><span class="word">Working…</span><span class="time"></span>
1725
+ </div>
1726
+ <div id="queue" class="queue" hidden></div>
1727
+ <div id="chooser" class="chooser" hidden role="dialog" aria-label="The agent asks you to decide"></div>
1728
+ <div id="slash" class="slash-menu" hidden role="listbox"></div>
1729
+ <div id="dialog" class="dialog" hidden></div>
1730
+ <div class="composer">
1731
+ <div id="todos" class="todos" hidden></div>
1732
+ <div id="atts" class="atts" hidden></div>
1733
+ <div id="editor-ctx" class="editor-ctx" hidden></div>
1734
+ <textarea id="input" rows="1" placeholder="Ask the agent to build, find or fix something… (@ to mention a file, / for commands)"></textarea>
1735
+ <div class="composer-actions">
1736
+ <button id="attach" class="pick attach" title="Attach images, PDFs or files (up to 10) — or paste / drop them here" aria-label="Attach"><svg class="ico" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg></button>
1737
+ <button id="ctx" class="pick ctx" title="Context used" hidden>
1738
+ <svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true">
1739
+ <circle class="ctx-bg" cx="10" cy="10" r="7"></circle>
1740
+ <circle class="ctx-fg" cx="10" cy="10" r="7"></circle>
1741
+ </svg>
1742
+ </button>
1743
+ <span id="cache" class="cache-note" hidden><svg class="ico" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span class="mins"></span></span>
1744
+ <div class="mode-picker model-picker">
1745
+ <button id="model" class="pick" title="Model this message is sent to"></button>
1746
+ <div id="model-menu" class="pick-menu" hidden></div>
1747
+ </div>
1748
+ <button id="think" class="pick toggle" title="Expand or collapse the model's reasoning"><svg class="ico" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v16M5.1 8l13.8 8M5.1 16l13.8-8"/></svg><span class="label">Thinking</span></button>
1749
+ <span id="speed" class="speed" hidden title="Generation speed"></span>
1750
+ <button id="remote" class="pick remote" hidden aria-label="Remote control"><svg class="ico" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span class="remote-n"></span></button>
1751
+ <button id="mic" class="pick mic" title="Dictate — click and speak; click again to write it here (Esc discards)" aria-label="Dictate" aria-pressed="false"><svg class="ico" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg><span class="mic-time"></span></button>
1752
+ <span class="composer-spacer"></span>
1753
+ <div class="mode-picker">
1754
+ <button id="mode" class="pick" title="What the agent may do on its own — Shift+Tab to cycle"></button>
1755
+ <div id="mode-menu" class="pick-menu" hidden></div>
1756
+ </div>
1757
+ <button id="send" class="pick-send" title="Send (Enter)" aria-label="Send"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 13V3.5M3.5 8 8 3.5 12.5 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
1758
+ <button id="stop" class="pick-send stop" title="Stop (Esc)" aria-label="Stop" hidden><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg></button>
1759
+ </div>
1760
+ </div>
1761
+ <script type="module" nonce="${key}" src="${uri('panel.js')}"></script>
1762
+ </body>
1763
+ </html>`
1764
+ }
1765
+ }
1766
+
1767
+ /** Pictures are sent once; the kept conversation notes where they were. */
1768
+ function withoutImages(m) {
1769
+ if (!m.images) return m
1770
+ const { images, ...rest } = m
1771
+ return { ...rest, content: `${rest.content}\n[${images.length} image(s) were attached here]` }
1772
+ }
1773
+
1774
+ /** Rebuilds a rough transcript from the model context alone, for conversations saved
1775
+ * before the transcript itself was kept. */
1776
+ function fromHistory(history) {
1777
+ const turns = []
1778
+ for (const message of history) {
1779
+ if (message.role === 'user') {
1780
+ turns.push({ kind: 'user', text: String(message.content || '') })
1781
+ } else if (message.role === 'assistant') {
1782
+ if (String(message.content || '').trim()) {
1783
+ turns.push({ kind: 'assistant', text: message.content })
1784
+ }
1785
+ for (const call of message.tool_calls || []) {
1786
+ turns.push({ kind: 'tool', tool: call.function.name,
1787
+ args: call.function.arguments || {}, status: 'done' })
1788
+ }
1789
+ }
1790
+ }
1791
+ return turns
1792
+ }
1793
+
1794
+ /** The conversation as Markdown, as /export writes it. */
1795
+ function toMarkdown(title, transcript) {
1796
+ const out = [`# ${title}`, '', `_Exported from AIPictury ${VERSION} on ${new Date().toISOString().slice(0, 16).replace('T', ' ')}_`, '']
1797
+ for (const t of transcript) {
1798
+ if (t.kind === 'user') out.push(`## You`, '', t.text, '')
1799
+ else if (t.kind === 'assistant') {
1800
+ if (String(t.thinking || '').trim()) out.push('<details><summary>Reasoning</summary>', '', t.thinking.trim(), '', '</details>', '')
1801
+ if (String(t.text || '').trim()) out.push('## Agent', '', t.text.trim(), '')
1802
+ } else if (t.kind === 'tool') {
1803
+ const a = t.args || {}
1804
+ out.push(`> **${t.tool}** ${a.command || a.path || a.pattern || a.query || a.url || ''} — ${t.status}`, '')
1805
+ } else if (t.kind === 'error') out.push(`> **Error:** ${t.text}`, '')
1806
+ }
1807
+ return out.join('\n')
1808
+ }
1809
+
1810
+ // ---------------------------------------------------------------- panels: side bar and tabs
1811
+
1812
+ class Registry {
1813
+ constructor(context) {
1814
+ this.context = context
1815
+ this.sidebar = new AgentPanel(context, { kind: 'sidebar', registry: this })
1816
+ this.tabs = new Set()
1817
+ this.last = this.sidebar
1818
+ }
1819
+
1820
+ all() { return [this.sidebar, ...this.tabs] }
1821
+
1822
+ /** Asked from the web: the panel with this conversation shares it by remote control — opened
1823
+ * first when it is this project's but not on screen. Another project's: some other window's. */
1824
+ async shareAsked(conversation) {
1825
+ let panel = this.all().find((p) => p?.id === conversation)
1826
+ if (!panel) {
1827
+ const idle = this.all().find((p) => p && !p.abort && p.store?.get?.(conversation))
1828
+ if (!idle || !(await idle.open(conversation))) return false
1829
+ panel = idle
1830
+ }
1831
+ await panel.remoteControl('start', { asked: true })
1832
+ return true
1833
+ }
1834
+ activate(panel) { this.last = panel }
1835
+ remove(panel) {
1836
+ this.tabs.delete(panel)
1837
+ if (this.last === panel) this.last = this.sidebar
1838
+ workbench.tabClosed(panel.id) // for Cmd/Ctrl+Shift+T
1839
+ this.sessionsView?.refresh() // no longer open
1840
+ }
1841
+ active() { return this.last?.host ? this.last : this.sidebar }
1842
+
1843
+ /** A new conversation in an editor tab — several can run side by side. */
1844
+ openTab(existing) {
1845
+ const host = existing || vscode.window.createWebviewPanel('aipictury.agentTab', 'AIPictury Agent',
1846
+ { viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }, { enableScripts: true, retainContextWhenHidden: true })
1847
+ host.iconPath = vscode.Uri.joinPath(this.context.extensionUri, 'media', 'icon.svg')
1848
+ const panel = new AgentPanel(this.context, { kind: 'tab', registry: this })
1849
+ panel.attach(host)
1850
+ this.tabs.add(panel)
1851
+ this.last = panel
1852
+ return panel
1853
+ }
1854
+
1855
+ broadcast(message) { for (const p of this.all()) p.post(message) }
1856
+ }
1857
+
1858
+ // ---------------------------------------------------------------- what the user is looking at
1859
+
1860
+ /** A files.exclude glob as a regular expression: ** any folders, * within one name. */
1861
+ function globToRegExp(glob) {
1862
+ let re = ''
1863
+ for (let i = 0; i < glob.length; i += 1) {
1864
+ const c = glob[i]
1865
+ if (c === '*' && glob[i + 1] === '*') { re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += glob[i + 2] === '/' ? 2 : 1 }
1866
+ else if (c === '*') re += '[^/]*'
1867
+ else if (c === '?') re += '[^/]'
1868
+ else re += /[.+^$(){}|[\]\\]/.test(c) ? `\\${c}` : c
1869
+ }
1870
+ return new RegExp(`^${re}$`)
1871
+ }
1872
+
1873
+ /** The file open in the editor and the selection in it: shown in the composer, and sent
1874
+ * with each message unless dismissed — as Claude Code attaches the open file. Text from a
1875
+ * file git ignores, or VS Code's files.exclude hides, is withheld: only its name is sent. */
1876
+ const editorTracker = {
1877
+ registry: null,
1878
+ timer: null,
1879
+ ignored: new Map(),
1880
+
1881
+ state() {
1882
+ const editor = vscode.window.activeTextEditor
1883
+ if (!editor || editor.document.uri.scheme !== 'file' || !vscode.workspace.getWorkspaceFolder(editor.document.uri)) return null
1884
+ const rel = vscode.workspace.asRelativePath(editor.document.uri, false)
1885
+ const sel = editor.selection
1886
+ const lines = sel.isEmpty ? null : [sel.start.line + 1, sel.end.line + (sel.end.character === 0 && sel.end.line > sel.start.line ? 0 : 1)]
1887
+ return { path: rel, lines, language: editor.document.languageId, text: sel.isEmpty ? '' : editor.document.getText(sel) }
1888
+ },
1889
+
1890
+ key() { const s = this.state(); return s ? `${s.path}#${s.lines?.join('-') || ''}` : null },
1891
+
1892
+ isIgnored(rel) {
1893
+ if (this.ignored.has(rel)) return this.ignored.get(rel)
1894
+ this.ignored.set(rel, false)
1895
+ const root = workspaceRoot()?.fsPath
1896
+ if (root && config().get('respectGitIgnore', true)) {
1897
+ execFile('git', ['check-ignore', '-q', rel], { cwd: root, timeout: 3000 }, (error) => {
1898
+ this.ignored.set(rel, !error)
1899
+ this.update()
1900
+ })
1901
+ }
1902
+ // What VS Code itself hides (files.exclude) or leaves out of searches (search.exclude).
1903
+ const excluded = { ...vscode.workspace.getConfiguration('files').get('exclude', {}),
1904
+ ...vscode.workspace.getConfiguration('search').get('exclude', {}) }
1905
+ if (Object.entries(excluded).some(([glob, on]) => on === true && globToRegExp(glob).test(rel))) {
1906
+ this.ignored.set(rel, true)
1907
+ }
1908
+
1909
+ return this.ignored.get(rel)
1910
+ },
1911
+
1912
+ post(panel) {
1913
+ const s = this.state()
1914
+ const dismissed = !!s && panel.contextDismissed === this.key()
1915
+ panel.post({ type: 'editorContext', file: s?.path || null, lines: s?.lines || null,
1916
+ withheld: !!s && this.isIgnored(s.path), dismissed, attachOpenFile: config().get('attachOpenFile', true) })
1917
+ },
1918
+
1919
+ update() {
1920
+ clearTimeout(this.timer)
1921
+ this.timer = setTimeout(() => { for (const p of this.registry?.all() || []) {
1922
+ if (p.contextDismissed && p.contextDismissed !== this.key()) p.contextDismissed = null
1923
+ this.post(p)
1924
+ } }, 150)
1925
+ },
1926
+
1927
+ /** What goes with a message about where the user is. */
1928
+ contextFor(panel) {
1929
+ const s = this.state()
1930
+ if (!s || panel.contextDismissed === this.key()) return ''
1931
+ const withSelection = !!s.lines && s.text.trim()
1932
+ if (!withSelection && !config().get('attachOpenFile', true)) return ''
1933
+ if (this.isIgnored(s.path)) return `[The user has ${s.path} open — its contents are withheld (ignored by git, files.exclude or search.exclude).]`
1934
+ if (!withSelection) return `[The user has ${s.path} open in the editor.]`
1935
+ const text = s.text.length > 20_000 ? `${s.text.slice(0, 20_000)}\n…` : s.text
1936
+ return `[The user has ${s.path} open, with lines ${s.lines[0]}–${s.lines[1]} selected:]\n\`\`\`${s.language}\n${text}\n\`\`\``
1937
+ },
1938
+ }
1939
+
1940
+ // ---------------------------------------------------------------- @-mentions
1941
+
1942
+ let fileCache = { at: 0, list: [] }
1943
+
1944
+ async function workspaceFiles() {
1945
+ if (Date.now() - fileCache.at < 30_000 && fileCache.list.length) return fileCache.list
1946
+ const found = await vscode.workspace.findFiles('**/*', '**/{node_modules,.git,.venv,__pycache__,dist,build,.next,target}/**', 8000)
1947
+ const list = found.map((u) => vscode.workspace.asRelativePath(u, false)).sort()
1948
+ fileCache = { at: Date.now(), list }
1949
+ return list
1950
+ }
1951
+
1952
+ /** How well `path` matches what was typed: letters in order, ends of words and the file
1953
+ * name counting most — "authsv" finds "src/auth/service.ts". */
1954
+ function fuzzyScore(query, candidate) {
1955
+ const q = query.toLowerCase()
1956
+ const c = candidate.toLowerCase()
1957
+ if (!q) return 1
1958
+ const name = c.slice(c.lastIndexOf('/', c.length - 2) + 1)
1959
+ if (name.startsWith(q)) return 1000 - c.length
1960
+ if (c.includes(q)) return 800 - c.indexOf(q) - c.length / 10
1961
+ let score = 0
1962
+ let at = 0
1963
+ for (const ch of q) {
1964
+ const i = c.indexOf(ch, at)
1965
+ if (i === -1) return 0
1966
+ score += i === at ? 5 : /[/._-]/.test(c[i - 1] || '/') ? 4 : 1
1967
+ at = i + 1
1968
+ }
1969
+ return score * 10 - c.length / 5
1970
+ }
1971
+
1972
+ async function mentionCandidates(query) {
1973
+ const q = String(query || '').replace(/^@/, '')
1974
+ const out = []
1975
+ if ('terminal:'.startsWith(q.toLowerCase()) || q.toLowerCase().startsWith('terminal:')) {
1976
+ for (const t of vscode.window.terminals) out.push({ path: `terminal:${t.name}`, kind: 'terminal', score: 2000 })
1977
+ }
1978
+ const list = await workspaceFiles().catch(() => [])
1979
+ const folders = new Set()
1980
+ for (const f of list) {
1981
+ const parts = f.split('/')
1982
+ for (let i = 1; i < parts.length; i += 1) folders.add(`${parts.slice(0, i).join('/')}/`)
1983
+ }
1984
+ for (const f of [...folders, ...list]) {
1985
+ const score = fuzzyScore(q, f)
1986
+ if (score > 0) out.push({ path: f, kind: f.endsWith('/') ? 'folder' : 'file', score: score + (f.endsWith('/') ? -5 : 0) })
1987
+ }
1988
+ return out.sort((a, b) => b.score - a.score).slice(0, 12).map(({ path: p, kind }) => ({ path: p, kind }))
1989
+ }
1990
+
1991
+ // The output of commands run in the editor's terminals, for @terminal:name.
1992
+ const terminalOutput = new Map()
1993
+
1994
+ function watchTerminals(add) {
1995
+ if (!vscode.window.onDidStartTerminalShellExecution) return
1996
+ add(vscode.window.onDidStartTerminalShellExecution(async (event) => {
1997
+ const name = event.terminal.name
1998
+ let text = `$ ${event.execution.commandLine?.value || ''}\n`
1999
+ try {
2000
+ for await (const chunk of event.execution.read()) {
2001
+ text += chunk
2002
+ if (text.length > 40_000) text = text.slice(-40_000)
2003
+ terminalOutput.set(name, text)
2004
+ }
2005
+ } catch { /* the terminal closed */ }
2006
+ terminalOutput.set(name, text)
2007
+ }))
2008
+ }
2009
+
2010
+ /** The content behind each @-mention in a message: files (a line range with #L5-10),
2011
+ * folders (their files), and a terminal's recent output. Files read this way count as
2012
+ * read, so the agent can edit them straight away. */
2013
+ async function resolveMentions(text, session) {
2014
+ const found = [...String(text).matchAll(/(^|\s)@((?:terminal:[^\s]+)|[^\s@]+)/g)].map((m) => m[2].replace(/[.,;:!?)]+$/, ''))
2015
+ const out = []
2016
+ const seen = new Set()
2017
+ for (const ref of found.slice(0, 12)) {
2018
+ if (seen.has(ref)) continue
2019
+ seen.add(ref)
2020
+ if (ref.startsWith('terminal:')) {
2021
+ const name = ref.slice('terminal:'.length)
2022
+ const output = terminalOutput.get(name)
2023
+ out.push(output ? `<terminal name="${name}">\n${output.slice(-20_000)}\n</terminal>`
2024
+ : `(@${ref}: no output captured from that terminal yet — commands run in it after this window opened are captured)`)
2025
+ continue
2026
+ }
2027
+ const m = /^(.+?)(?:#L?(\d+)(?:-L?(\d+))?)?$/.exec(ref)
2028
+ const rel = m[1]
2029
+ try {
2030
+ const uri = vscode.Uri.joinPath(workspaceRoot(), rel)
2031
+ const info = await vscode.workspace.fs.stat(uri)
2032
+ if (info.type & vscode.FileType.Directory) {
2033
+ const listing = await require('./tools').IMPLEMENTATIONS.list_files({ path: rel })
2034
+ out.push(`<folder path="${rel}">\n${listing}\n</folder>`)
2035
+ } else {
2036
+ const start = m[2] ? Number(m[2]) : undefined
2037
+ const end = m[3] ? Number(m[3]) : start
2038
+ const body = await files.readFile({ path: rel, offset: start, limit: start ? end - start + 1 : 2000 }, { session })
2039
+ out.push(`<file path="${rel}"${start ? ` lines="${start}-${end}"` : ''}>\n${body}\n</file>`)
2040
+ }
2041
+ } catch { /* not a path: an @ in ordinary text */ }
2042
+ }
2043
+ return out
2044
+ }
2045
+
2046
+ /** Opens a workspace file, at a line when given — paths with spaces or accents included,
2047
+ * which Claude Code's panel failed to open (its links stayed percent-encoded). */
2048
+ async function openWorkspaceFile(p, line) {
2049
+ const root = workspaceRoot()
2050
+ if (!root || !p) return
2051
+ let rel = String(p)
2052
+ try { rel = decodeURIComponent(rel) } catch { /* not encoded */ }
2053
+ const uri = path.isAbsolute(rel) ? vscode.Uri.file(rel) : vscode.Uri.joinPath(root, rel)
2054
+ const options = { preview: true }
2055
+ if (line) options.selection = new vscode.Range(Number(line) - 1, 0, Number(line) - 1, 0)
2056
+ // vscode.open picks the right editor: text, or the image and video previews.
2057
+ return vscode.commands.executeCommand('vscode.open', uri, options)
2058
+ }
2059
+
2060
+ // ---------------------------------------------------------------- the studio tab
2061
+
2062
+ /** The studio is the web app at aipictury.com, in the browser: it cannot be framed here (it
2063
+ * refuses frames, against clickjacking, and Google sign-in needs a real window). */
2064
+ function openStudio() {
2065
+ const web = String(config().get('webApp', 'https://aipictury.com') || 'https://aipictury.com').replace(/\/+$/, '')
2066
+ return vscode.env.openExternal(vscode.Uri.parse(web))
2067
+ }
2068
+
2069
+ // ---------------------------------------------------------------- editor commands
2070
+
2071
+ /** The selection, with enough around it for the model to make sense of it. */
2072
+ function selectionContext() {
2073
+ const editor = vscode.window.activeTextEditor
2074
+ if (!editor || editor.selection.isEmpty) return null
2075
+ return {
2076
+ path: vscode.workspace.asRelativePath(editor.document.uri),
2077
+ language: editor.document.languageId,
2078
+ text: editor.document.getText(editor.selection),
2079
+ line: editor.selection.start.line + 1,
2080
+ }
2081
+ }
2082
+
2083
+ async function askAboutSelection(panel, instruction) {
2084
+ const selected = selectionContext()
2085
+ if (!selected) {
2086
+ vscode.window.showInformationMessage('Select some code first.')
2087
+ return
2088
+ }
2089
+ if (panel.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2090
+ panel.ask(`${instruction}\n\n\`${selected.path}\` from line ${selected.line}:\n\n`
2091
+ + `\`\`\`${selected.language}\n${selected.text}\n\`\`\``)
2092
+ }
2093
+
2094
+ /** A commit message written from what is actually staged. */
2095
+ async function commitMessage() {
2096
+ const folders = vscode.workspace.workspaceFolders
2097
+ if (!folders?.length) return
2098
+ const diff = await new Promise((done) => {
2099
+ execFile('git', ['diff', '--cached'],
2100
+ { cwd: folders[0].uri.fsPath, maxBuffer: 4_000_000, timeout: 20_000 },
2101
+ (error, stdout) => done(error ? '' : stdout))
2102
+ })
2103
+ if (!diff.trim()) {
2104
+ vscode.window.showInformationMessage('Nothing is staged, so there is nothing to describe.')
2105
+ return
2106
+ }
2107
+ const message = await vscode.window.withProgress(
2108
+ { location: vscode.ProgressLocation.SourceControl, title: 'Writing a commit message…',
2109
+ cancellable: true },
2110
+ async (_progress, token) => {
2111
+ const controller = new AbortController()
2112
+ token.onCancellationRequested(() => controller.abort())
2113
+ const { text } = await agent.turn(serverUrl(), [
2114
+ { role: 'system', content: 'Write a git commit message for the diff. A short subject line '
2115
+ + 'in the imperative, under 72 characters; then a blank line and a few bullets only if '
2116
+ + 'the change needs them. No preamble, no code fences — just the message.' },
2117
+ { role: 'user', content: diff.slice(0, 120_000) },
2118
+ ], { role: 'commit', signal: controller.signal })
2119
+ return text.trim()
2120
+ }).catch((error) => {
2121
+ vscode.window.showWarningMessage(`No commit message: ${error.message}`)
2122
+ return ''
2123
+ })
2124
+ if (!message) return
2125
+
2126
+ // Put it where the user is already typing, rather than in a dialog.
2127
+ const git = vscode.extensions.getExtension('vscode.git')?.exports
2128
+ const repository = git?.getAPI(1)?.repositories?.[0]
2129
+ if (repository) {
2130
+ repository.inputBox.value = message
2131
+ vscode.window.showInformationMessage('Commit message written into the Source Control box.')
2132
+ } else {
2133
+ await vscode.env.clipboard.writeText(message)
2134
+ vscode.window.showInformationMessage('Commit message copied to the clipboard.')
2135
+ }
2136
+ }
2137
+
2138
+ /** The conversations had in this project, most recent first — search, reopen, rename,
2139
+ * open in a tab, or delete. */
2140
+ async function showHistory(panel) {
2141
+ await panel.remember()
2142
+ const conversations = panel.store.list().filter((c) => !c.archived)
2143
+ if (!conversations.length) {
2144
+ vscode.window.showInformationMessage('No conversations yet in this project.')
2145
+ return
2146
+ }
2147
+ const buttons = [
2148
+ { iconPath: new vscode.ThemeIcon('edit'), tooltip: 'Rename', action: 'rename' },
2149
+ { iconPath: new vscode.ThemeIcon('link-external'), tooltip: 'Open in a new tab', action: 'tab' },
2150
+ { iconPath: new vscode.ThemeIcon('trash'), tooltip: 'Delete', action: 'delete' },
2151
+ ]
2152
+ const items = () => panel.store.list().filter((c) => !c.archived).map((c) => ({
2153
+ label: c.title,
2154
+ description: ago(c.updated),
2155
+ detail: `${c.questions ?? '?'} question(s)${c.model ? ` · ${c.model}` : ''}`,
2156
+ id: c.id,
2157
+ buttons,
2158
+ }))
2159
+
2160
+ const picker = vscode.window.createQuickPick()
2161
+ picker.items = items()
2162
+ picker.title = 'AIPictury conversations'
2163
+ picker.placeholder = 'Search, or pick one to reopen'
2164
+ picker.matchOnDescription = true
2165
+ picker.matchOnDetail = true
2166
+
2167
+ picker.onDidTriggerItemButton(async (event) => {
2168
+ const action = event.button.action
2169
+ if (action === 'delete') {
2170
+ panel.store.remove(event.item.id)
2171
+ } else if (action === 'rename') {
2172
+ const title = await vscode.window.showInputBox({ title: 'Rename the conversation', value: event.item.label })
2173
+ if (title) {
2174
+ panel.store.patch(event.item.id, { title, renamed: true })
2175
+ if (panel.id === event.item.id) panel.title = title
2176
+ }
2177
+ picker.show()
2178
+ } else if (action === 'tab') {
2179
+ picker.hide()
2180
+ await panel.registry.openTab().open(event.item.id, { focus: false })
2181
+ return
2182
+ }
2183
+ picker.items = items()
2184
+ if (!picker.items.length) picker.hide()
2185
+ })
2186
+ picker.onDidAccept(async () => {
2187
+ const chosen = picker.selectedItems[0]
2188
+ picker.hide()
2189
+ if (chosen) await panel.open(chosen.id)
2190
+ })
2191
+ picker.onDidHide(() => picker.dispose())
2192
+ picker.show()
2193
+ }
2194
+
2195
+ /** What a rewind choice will do, said plainly for the confirmation: [title, detail, button]. */
2196
+ function rewindQuestion(action, text, files) {
2197
+ const quoted = `“${String(text).replace(/\s+/g, ' ').trim().slice(0, 60)}${String(text).length > 60 ? '…' : ''}”`
2198
+ const restore = files.length
2199
+ ? `The ${files.length} file${files.length === 1 ? '' : 's'} the agent changed since then will be restored to how they were: `
2200
+ + `${files.slice(0, 5).join(', ')}${files.length > 5 ? ', …' : ''}. Changes made by commands are not tracked and stay.`
2201
+ : 'The agent changed no files since then, so no file will change.'
2202
+ const keepFiles = 'Your files are not touched.'
2203
+ const todos = 'The to-do list and the context figures go back to how they were then.'
2204
+ switch (action) {
2205
+ case 'fork': return [`Fork the conversation from before ${quoted}?`,
2206
+ `A new conversation starts from before that message, with your message back in the input. The original conversation is kept in history. ${todos} ${keepFiles}`, 'Fork']
2207
+ case 'fork-code': return [`Fork the conversation and rewind the code to before ${quoted}?`,
2208
+ `A new conversation starts from before that message; the original is kept in history. ${todos} ${restore}`, 'Fork and rewind code']
2209
+ case 'code': return [`Rewind the code to before ${quoted}?`, `${restore} The conversation stays as it is.`, 'Rewind code']
2210
+ case 'conversation': return [`Rewind the conversation to before ${quoted}?`,
2211
+ `That message and everything after it are removed from this conversation, and your message goes back to the input. ${todos} ${keepFiles}`, 'Rewind conversation']
2212
+ default: return [`Rewind the code and the conversation to before ${quoted}?`,
2213
+ `That message and everything after it are removed from this conversation. ${todos} ${restore}`, 'Rewind both']
2214
+ }
2215
+ }
2216
+
2217
+ function ago(when) {
2218
+ const minutes = Math.round((Date.now() - when) / 60000)
2219
+ if (minutes < 1) return 'just now'
2220
+ if (minutes < 60) return `${minutes} min ago`
2221
+ const hours = Math.round(minutes / 60)
2222
+ if (hours < 24) return `${hours} h ago`
2223
+ return `${Math.round(hours / 24)} d ago`
2224
+ }
2225
+
2226
+ // ---------------------------------------------------------------- activation
2227
+
2228
+ /** 1.5.2's Thinking button switched the model's reasoning off (showThinking = false).
2229
+ * Since 1.5.3 that button only folds the reasoning, so an "off" it left behind had no way
2230
+ * back — and without reasoning the model repeats itself and describes changes instead of
2231
+ * making them. Undone once; an "off" chosen later in the settings is respected. */
2232
+ async function undoOldThinkingSwitch(context) {
2233
+ if (context.globalState.get('aipictury.reasoningRestored')) return
2234
+ const settings = vscode.workspace.getConfiguration('aipictury')
2235
+ if (settings.inspect('showThinking')?.globalValue === false) {
2236
+ await settings.update('showThinking', undefined, vscode.ConfigurationTarget.Global)
2237
+ }
2238
+ await context.globalState.update('aipictury.reasoningRestored', true)
2239
+ }
2240
+
2241
+ let registry = null
2242
+
2243
+ /** 1.6.0 made Qwen3.8-27B Heretic the default everywhere. A picker left on a previous
2244
+ * default would keep the old model for good; that choice is cleared once, so the server's
2245
+ * default applies. A model chosen on purpose (any other) is kept. */
2246
+ const OLD_DEFAULTS = ['qwen38-uncensored', 'qwen38-uncensored:latest', 'gemma4:12b', 'gptoss-neo', 'gptoss-neo:latest',
2247
+ 'qwen3.5:27b']
2248
+ function adoptNewDefaultModel(context) {
2249
+ if (context.globalState.get('aipictury.defaults160')) return Promise.resolve(false)
2250
+ const cleared = OLD_DEFAULTS.includes(context.globalState.get('aipictury.model'))
2251
+ // Decided now, before any panel reads the choice; saved in the background.
2252
+ return Promise.all([cleared ? context.globalState.update('aipictury.model', undefined) : null,
2253
+ context.globalState.update('aipictury.defaults160', true)]).then(() => cleared)
2254
+ }
2255
+
2256
+ /** What every command the agent runs gets added to its environment: the Python environment the
2257
+ * Python extension selected (usePythonEnvironment), the settings files' `env`, and the
2258
+ * environmentVariables setting. Refreshed before each run, so a change applies to the next one. */
2259
+ async function syncCommandEnv() {
2260
+ const root = workspaceRoot()?.fsPath
2261
+ try { shell.setExtraEnv('files', prefs.load(root).merged.env || {}) } catch { shell.setExtraEnv('files', {}) }
2262
+ const vars = config().get('environmentVariables', []) || []
2263
+ shell.setExtraEnv('setting', Object.fromEntries(vars.filter((v) => v && v.name).map((v) => [String(v.name), String(v.value ?? '')])))
2264
+ shell.setExtraEnv('python', config().get('usePythonEnvironment', true) ? await pythonEnvironment() : null)
2265
+ }
2266
+
2267
+ /** The interpreter the Python extension has selected for this workspace, if it is installed. */
2268
+ async function pythonEnvironment() {
2269
+ const ext = vscode.extensions.getExtension('ms-python.python')
2270
+ if (!ext) return null
2271
+ try {
2272
+ const api = ext.isActive ? ext.exports : await ext.activate()
2273
+ const envs = api?.environments
2274
+ if (!envs?.getActiveEnvironmentPath) return null
2275
+ const folder = vscode.workspace.workspaceFolders?.[0]
2276
+ const resolved = await envs.resolveEnvironment(envs.getActiveEnvironmentPath(folder?.uri))
2277
+ const exe = resolved?.executable?.uri?.fsPath
2278
+ if (!exe) return null
2279
+ return { bin: path.dirname(exe), venv: resolved.environment?.folderUri?.fsPath || null }
2280
+ } catch (error) {
2281
+ log.warn('the Python environment could not be read', error)
2282
+ return null
2283
+ }
2284
+ }
2285
+
2286
+ /** Conversations kept in VS Code's workspace state (before 1.7) move to disk once. The old
2287
+ * copy is left where it was, untouched, as a fallback. */
2288
+ function moveConversationsToDisk(context) {
2289
+ if (context.workspaceState.get('aipictury.onDisk')) return 0
2290
+ const store = sessions.storeFor(workspaceRoot()?.fsPath)
2291
+ const dir = context.storageUri || context.globalStorageUri
2292
+ const moved = store.migrate(context.workspaceState.get(STORE, []), dir ? path.join(dir.fsPath, 'checkpoints') : null)
2293
+ context.workspaceState.update('aipictury.onDisk', true)
2294
+ return moved
2295
+ }
2296
+
2297
+ function activate(context) {
2298
+ try { moveConversationsToDisk(context) } catch { /* tried again next start */ }
2299
+ syncCommandEnv().catch(() => { /* again before each run */ })
2300
+ undoOldThinkingSwitch(context).catch(() => { /* the setting stays as it was */ })
2301
+ const adopting = adoptNewDefaultModel(context).catch(() => false)
2302
+ registry = new Registry(context)
2303
+ const panel = registry.sidebar
2304
+ // A panel made before the choice was cleared drops the old default too.
2305
+ adopting.then((cleared) => { if (cleared) { panel.model = null; panel.refreshStatus() } })
2306
+ editorTracker.registry = registry
2307
+ const add = (disposable) => context.subscriptions.push(disposable)
2308
+ const active = () => registry.active()
2309
+ // OAuth for remote MCP servers: tokens in VS Code's secret storage; the browser comes back
2310
+ // through vscode://aipictury.aipictury/oauth (workbench.uriHandler).
2311
+ mcp.configureAuth({
2312
+ store: { get: (k) => context.secrets.get(k), set: (k, v) => context.secrets.store(k, v), delete: (k) => context.secrets.delete(k) },
2313
+ open: (url) => vscode.env.openExternal(vscode.Uri.parse(url, true)),
2314
+ redirectUri: async () => (await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://aipictury.aipictury/oauth`))).toString(true),
2315
+ })
2316
+ require('./review').register(context, registry) // change by change, in the proposal's diff
2317
+ registry.plans = require('./plans').register(context, registry) // the plan as a commentable document
2318
+ sweepReviews()
2319
+ registry.sessionsView = require('./sessionsView').register(context, { registry, activePanel: active,
2320
+ store: () => sessions.storeFor(workspaceRoot()?.fsPath), server: serverUrl })
2321
+ // The AIPictury account: needed to use the extension; what is yours is kept with it.
2322
+ registry.account = require('./accountUi').register(context, { registry, sessionsView: registry.sessionsView, serverUrl })
2323
+ add(vscode.commands.registerCommand('aipictury.signIn', () => registry.account.signIn()))
2324
+ add(vscode.commands.registerCommand('aipictury.signOut', () => registry.account.signOut()))
2325
+ add(vscode.commands.registerCommand('aipictury.account', () => active().openAccount()))
2326
+
2327
+ add(vscode.window.registerWebviewViewProvider('aipictury.agent', {
2328
+ resolveWebviewView(view) {
2329
+ panel.attach(view)
2330
+ panel.resumeAfterReload().catch(() => {})
2331
+ },
2332
+ }, { webviewOptions: { retainContextWhenHidden: true } }))
2333
+ // Agent tabs come back after a reload, with their conversations.
2334
+ add(vscode.window.registerWebviewPanelSerializer('aipictury.agentTab', {
2335
+ async deserializeWebviewPanel(host, state) {
2336
+ const tab = registry.openTab(host)
2337
+ if (state?.id) await tab.open(state.id, { focus: false })
2338
+ },
2339
+ }))
2340
+ add(vscode.workspace.registerTextDocumentContentProvider(SCHEME, agent.proposedProvider))
2341
+
2342
+ add(vscode.commands.registerCommand('aipictury.openStudio', () => openStudio()))
2343
+ add(vscode.commands.registerCommand('aipictury.newChat', () => panel.reset()))
2344
+ add(vscode.commands.registerCommand('aipictury.openInNewTab', () => registry.openTab()))
2345
+ add(vscode.commands.registerCommand('aipictury.history', () => showHistory(active())))
2346
+ add(vscode.commands.registerCommand('aipictury.memory', () => active().openMemory()))
2347
+ add(vscode.commands.registerCommand('aipictury.openWalkthrough', () =>
2348
+ vscode.commands.executeCommand('workbench.action.openWalkthrough', 'aipictury.aipictury#start', false)))
2349
+ add(vscode.commands.registerCommand('aipictury.permissions', () => active().openPermissions()))
2350
+ add(vscode.commands.registerCommand('aipictury.hooks', () => active().openHooks()))
2351
+ add(vscode.commands.registerCommand('aipictury.plugins', () => active().openPlugins()))
2352
+ add(vscode.commands.registerCommand('aipictury.agents', () => active().openAgents()))
2353
+ add(vscode.commands.registerCommand('aipictury.pages', () => active().openPages()))
2354
+ add(vscode.commands.registerCommand('aipictury.skills', () => active().openSkills()))
2355
+ add(vscode.commands.registerCommand('aipictury.outputStyle', () => active().openOutputStyle()))
2356
+ add(vscode.commands.registerCommand('aipictury.sandbox', () => active().openSandbox()))
2357
+ add(vscode.commands.registerCommand('aipictury.instructions', () => active().openInstructions()))
2358
+ add(vscode.commands.registerCommand('aipictury.slashCommands', () => active().openSlashCommands()))
2359
+ add(vscode.commands.registerCommand('aipictury.reportProblem', () => active().reportProblem()))
2360
+ // About AIPictury agent: this agent's version and the connected server — its models and GPUs.
2361
+ add(vscode.commands.registerCommand('aipictury.help', () => showAbout(context, { model: () => active().model, account: () => registry.account })))
2362
+ add(vscode.commands.registerCommand('aipictury.rewind', () => active().rewindPicker()))
2363
+ add(vscode.commands.registerCommand('aipictury.exportConversation', () => active().exportConversation()))
2364
+ add(vscode.commands.registerCommand('aipictury.status', () => active().status()))
2365
+ add(vscode.commands.registerCommand('aipictury.showTasks', () => active().shortcut('/tasks')))
2366
+ add(vscode.commands.registerCommand('aipictury.renameConversation', async () => {
2367
+ const target = active()
2368
+ const title = await vscode.window.showInputBox({ title: 'Rename the conversation', value: target.conversationTitle })
2369
+ if (title) { target.title = title; await target.remember() }
2370
+ }))
2371
+ add(vscode.commands.registerCommand('aipictury.showLogs', () => log.show()))
2372
+ add(workbench.statusBar())
2373
+ add(workbench.watchTabs())
2374
+ add(vscode.window.registerUriHandler(workbench.uriHandler(registry)))
2375
+ add(vscode.commands.registerCommand('aipictury.openInSideBar', () => vscode.commands.executeCommand('aipictury.agent.focus')))
2376
+ add(vscode.commands.registerCommand('aipictury.newConversation', async () => {
2377
+ const panel = await workbench.openPreferred(registry)
2378
+ if (panel.kind === 'sidebar' && panel.history.length) await panel.shortcut('/clear')
2379
+ }))
2380
+ add(vscode.commands.registerCommand('aipictury.reopenClosedSession', () => workbench.reopenClosed(registry)))
2381
+ add(vscode.commands.registerCommand('aipictury.focusLastMessage', async () => {
2382
+ const target = active()
2383
+ if (target.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2384
+ else target.host?.reveal?.()
2385
+ target.post({ type: 'focusLast' })
2386
+ }))
2387
+ add(vscode.commands.registerCommand('aipictury.markUnread', () => {
2388
+ const target = active()
2389
+ if (target.id) target.store.patch(target.id, { unread: true })
2390
+ }))
2391
+ add(vscode.commands.registerCommand('aipictury.toggleFocusView', () =>
2392
+ config().update('focusView', !config().get('focusView', false), vscode.ConfigurationTarget.Global)))
2393
+ // Cmd/Ctrl+Esc: into the agent's input, or back to the editor when already there.
2394
+ add(vscode.commands.registerCommand('aipictury.focusInput', async () => {
2395
+ const target = active()
2396
+ if (target.focused) {
2397
+ target.focused = false
2398
+ await vscode.commands.executeCommand('workbench.action.focusActiveEditorGroup')
2399
+ return
2400
+ }
2401
+ if (target.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2402
+ else target.host.reveal()
2403
+ target.post({ type: 'focusInput' })
2404
+ }))
2405
+ // Alt+K: the current file and selection, as @file#5-10, into the agent's input.
2406
+ add(vscode.commands.registerCommand('aipictury.remoteControl', async () => {
2407
+ const target = active()
2408
+ if (target.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2409
+ else target.host.reveal()
2410
+ await target.remoteControl(target.remote ? 'stop' : 'start')
2411
+ }))
2412
+ add(vscode.commands.registerCommand('aipictury.dictate', async () => {
2413
+ const target = active()
2414
+ if (target.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2415
+ else target.host.reveal()
2416
+ await target.dictate('toggle')
2417
+ }))
2418
+ add(vscode.commands.registerCommand('aipictury.insertMention', async () => {
2419
+ const s = editorTracker.state()
2420
+ if (!s) { vscode.window.showInformationMessage('Open a file of this workspace first.'); return }
2421
+ const target = active()
2422
+ if (target.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
2423
+ else target.host.reveal()
2424
+ target.post({ type: 'insert', text: `@${s.path}${s.lines ? `#${s.lines[0]}-${s.lines[1]}` : ''} ` })
2425
+ }))
2426
+ add(vscode.commands.registerCommand('aipictury.explainSelection',
2427
+ () => askAboutSelection(active(), 'Explain this code: what it does, and anything that looks wrong.')))
2428
+ add(vscode.commands.registerCommand('aipictury.refactorSelection',
2429
+ () => askAboutSelection(active(), 'Refactor this code and apply the change to the file.')))
2430
+ add(vscode.commands.registerCommand('aipictury.writeTests',
2431
+ () => askAboutSelection(active(), 'Write tests for this code and put them in the right file.')))
2432
+ add(vscode.commands.registerCommand('aipictury.commitMessage', commitMessage))
2433
+ add(vscode.commands.registerCommand('aipictury.openSettings',
2434
+ () => vscode.env.openExternal(vscode.Uri.parse(`${serverUrl()}/#settings`))))
2435
+ add(vscode.commands.registerCommand('aipictury.setServer', async () => {
2436
+ const value = await vscode.window.showInputBox({
2437
+ title: 'AIPictury GPU server, for this machine',
2438
+ value: gpuServer.explicit(),
2439
+ prompt: 'Its address on this machine — or leave it empty to use the one chosen with your account at aipictury.com',
2440
+ })
2441
+ if (value !== undefined) {
2442
+ await config().update('server', value.trim() ? value.trim().replace(/\/+$/, '') : undefined, vscode.ConfigurationTarget.Global)
2443
+ for (const p of registry.all()) p.refreshStatus()
2444
+ }
2445
+ }))
2446
+
2447
+ add(vscode.window.onDidChangeActiveTextEditor(() => editorTracker.update()))
2448
+ add(vscode.window.onDidChangeTextEditorSelection(() => editorTracker.update()))
2449
+ add(vscode.workspace.onDidCreateFiles(() => { fileCache.at = 0 }))
2450
+ add(vscode.workspace.onDidDeleteFiles(() => { fileCache.at = 0 }))
2451
+ watchTerminals(add)
2452
+
2453
+ add(vscode.workspace.onDidChangeConfiguration((event) => {
2454
+ for (const p of registry.all()) {
2455
+ if (event.affectsConfiguration('aipictury.server')) p.refreshStatus()
2456
+ if (['approval', 'showThinking', 'effort', 'focusView', 'useCtrlEnterToSend', 'scrollToBottomOnSend', 'hideOnboarding', 'allowDangerouslySkipPermissions']
2457
+ .some((k) => event.affectsConfiguration(`aipictury.${k}`))) p.postMode()
2458
+ if (event.affectsConfiguration('aipictury.attachOpenFile')) editorTracker.update()
2459
+ }
2460
+ }))
2461
+
2462
+ // A new panel in the side bar is easy to miss. Offer it once, then never again.
2463
+ if (!context.globalState.get('aipictury.greeted')) {
2464
+ context.globalState.update('aipictury.greeted', true)
2465
+ vscode.window.showInformationMessage(
2466
+ 'AIPictury is installed: a coding agent on your own GPU.', 'Open it')
2467
+ .then((choice) => {
2468
+ if (choice === 'Open it') vscode.commands.executeCommand('aipictury.agent.focus')
2469
+ })
2470
+ }
2471
+ return { registry }
2472
+ }
2473
+
2474
+ // Anything the agent left running — a dev server, MCP servers — goes when VS Code does,
2475
+ // and every conversation on screen is saved first.
2476
+ async function deactivate() {
2477
+ agent.stopBackground()
2478
+ mcp.closeAll()
2479
+ await require('./browser').close()
2480
+ await Promise.all((registry?.all() || []).map((p) => p.remember().catch(() => {})))
2481
+ }
2482
+
2483
+ module.exports = { syncCommandEnv, moveConversationsToDisk, rewindQuestion, activate, deactivate, undoOldThinkingSwitch, adoptNewDefaultModel, fuzzyScore, toMarkdown, resolveMentions, mentionCandidates, INIT_PROMPT, REVIEW_PROMPT }