@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.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/extension.js
ADDED
|
@@ -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 }
|