@davait/aipictury 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/dialogs.js ADDED
@@ -0,0 +1,803 @@
1
+ 'use strict'
2
+ // The dialogs that open from the panel — Memory, Instructions, Slash commands, Report a
3
+ // problem — built on the panel's one dialog component. Each is a spec (what to show) and a
4
+ // handler (what each click does, returning the next spec, or null to close).
5
+ const fs = require('fs')
6
+ const os = require('os')
7
+ const path = require('path')
8
+ const vscode = require('vscode')
9
+ const memory = require('./memory')
10
+ const context = require('./context')
11
+ const feedback = require('./feedback')
12
+ const log = require('./log')
13
+ const prefs = require('./prefs')
14
+ const permissions = require('./permissions')
15
+ const sandboxing = require('./sandbox')
16
+ const mcp = require('./mcp')
17
+ const plugins = require('./plugins')
18
+ const usageLog = require('./usage')
19
+
20
+ const config = () => vscode.workspace.getConfiguration('aipictury')
21
+ const root = () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || null
22
+ const lines = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').length } catch { return 0 } }
23
+
24
+ async function openFile(file, content) {
25
+ if (!fs.existsSync(file) && content !== undefined) {
26
+ fs.mkdirSync(path.dirname(file), { recursive: true })
27
+ fs.writeFileSync(file, content)
28
+ }
29
+ await vscode.window.showTextDocument(vscode.Uri.file(file), { preview: false })
30
+ }
31
+
32
+ // ---- Memory ----
33
+
34
+ async function memorySpec(server, note) {
35
+ const [project, user] = await Promise.all([memory.projectRules(), memory.userRules(server)])
36
+ const forget = [{ id: 'forget', label: 'Forget', danger: true }]
37
+ return {
38
+ id: 'memory',
39
+ title: 'Memory',
40
+ note: note || 'Rules the agent always follows. Tip: start a message with # to remember something for this project, ## for everywhere.',
41
+ sections: [
42
+ { title: 'Remembering', items: [{ id: 'auto', label: 'Save a rule when you ask for one',
43
+ detail: 'When you say "always…" or "from now on…", the agent remembers it. Off: rules are added only by you, here.',
44
+ toggle: { on: config().get('autoMemory', true) } }] },
45
+ { title: 'This project', note: 'Kept in .aipictury/memory.md — commit it to share them.',
46
+ empty: 'No rules for this project yet.',
47
+ items: project.map((r) => ({ id: `p:${r}`, label: r, actions: forget })) },
48
+ { title: 'You, everywhere', note: 'Kept on your AIPictury server: every editor and the web app follow them.',
49
+ empty: 'No rules yet — or the server cannot be reached.',
50
+ items: user.map((r) => ({ id: `u:${r.id}`, label: r.text, actions: forget })) },
51
+ { title: 'File', items: [{ id: 'open', label: 'Open .aipictury/memory.md', open: true }] },
52
+ ],
53
+ form: {
54
+ title: 'Add a rule',
55
+ fields: [
56
+ { id: 'text', label: 'Rule', placeholder: 'e.g. Use pnpm, never npm' },
57
+ { id: 'scope', label: 'Applies to', value: 'project',
58
+ options: [{ value: 'project', label: 'This project' }, { value: 'user', label: 'Everywhere' }] },
59
+ ],
60
+ submit: { id: 'add', label: 'Remember' },
61
+ },
62
+ }
63
+ }
64
+
65
+ function memoryHandler(server) {
66
+ return async (action, message) => {
67
+ const item = String(message.item || '')
68
+ if (action === 'toggle' && item === 'auto') {
69
+ await config().update('autoMemory', !!message.value, vscode.ConfigurationTarget.Global)
70
+ return memorySpec(server, message.value ? 'The agent saves rules you ask it to remember.' : 'Only you add rules now.')
71
+ }
72
+ if (action === 'forget') {
73
+ if (item.startsWith('p:')) await memory.removeProjectRule(item.slice(2))
74
+ else if (item.startsWith('u:')) await memory.deleteUserRule(server, item.slice(2))
75
+ return memorySpec(server, 'Forgotten.')
76
+ }
77
+ if (action === 'open') {
78
+ const uri = memory.projectUri()
79
+ if (!uri) throw new Error('open a folder first')
80
+ await openFile(uri.fsPath, '# Rules for this project\n\n')
81
+ return null
82
+ }
83
+ if (action === 'add') {
84
+ const text = String(message.fields?.text || '').trim()
85
+ if (!text) throw new Error('write the rule first')
86
+ const said = message.fields?.scope === 'user' ? await memory.addUserRule(server, text) : await memory.addProjectRule(text)
87
+ return memorySpec(server, said || 'Remembered.')
88
+ }
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ // ---- Instructions: AIPICTURY.md, AGENTS.md, CLAUDE.md… ----
94
+
95
+ function instructionFiles() {
96
+ const dir = root()
97
+ const files = [{ id: 'home', name: '~/.aipictury/AIPICTURY.md', file: path.join(os.homedir(), '.aipictury', 'AIPICTURY.md'),
98
+ detail: 'yours, in every project' }]
99
+ if (dir) {
100
+ for (const name of context.INSTRUCTION_FILES) {
101
+ files.push({ id: name, name, file: path.join(dir, name),
102
+ detail: /CLAUDE/.test(name) ? 'written for Claude Code — read here too' : name === 'AGENTS.md' ? 'the shared agents file' : 'this project' })
103
+ }
104
+ }
105
+ return files
106
+ }
107
+
108
+ function instructionsSpec(note) {
109
+ const items = instructionFiles().map((f) => {
110
+ const n = lines(f.file)
111
+ const mine = f.id === 'home' || f.id === 'AIPICTURY.md'
112
+ return { id: f.id, label: f.name, detail: `${f.detail} · ${n ? `${n} lines, followed` : 'not there'}`,
113
+ badge: n ? 'in use' : undefined, badgeKind: n ? 'ok' : undefined, open: !!n,
114
+ actions: n ? [{ id: 'open', label: 'Open' }]
115
+ : mine ? [{ id: 'create', label: 'Create' }, ...(f.id === 'AIPICTURY.md' ? [{ id: 'init', label: 'Have the agent write it' }] : [])] : [] }
116
+ })
117
+ return {
118
+ id: 'instructions',
119
+ title: 'Instructions',
120
+ note: note || 'Files the agent reads at the start of every step and always follows. Edits apply from the next step.',
121
+ sections: [{ items }],
122
+ }
123
+ }
124
+
125
+ function instructionsHandler(panel) {
126
+ return async (action, message) => {
127
+ const f = instructionFiles().find((x) => x.id === message.item)
128
+ if (!f) return undefined
129
+ if (action === 'open') { await openFile(f.file); return null }
130
+ if (action === 'create') {
131
+ await openFile(f.file, `# ${path.basename(f.file)}\n\nThis file guides AI coding agents working ${f.id === 'home' ? 'in every project' : 'in this repository'}.\n`)
132
+ return null
133
+ }
134
+ if (action === 'init') { panel.post({ type: 'draft', text: '/init' }); return null }
135
+ return undefined
136
+ }
137
+ }
138
+
139
+ // ---- Slash commands ----
140
+
141
+ const COMMAND_NAME = /^[\w.-]{1,40}$/
142
+
143
+ function slashSpec(builtIn, note) {
144
+ const use = [{ id: 'use', label: 'Use' }]
145
+ return {
146
+ id: 'slash',
147
+ title: 'Slash commands',
148
+ note: note || 'Type / in the input to use one. Your own commands are prompts kept as Markdown files.',
149
+ sections: [
150
+ { title: 'Built in', items: builtIn.map(([name, description, hint]) => ({ id: `b:${name}`, label: `/${name}${hint ? ` ${hint}` : ''}`, detail: description, actions: use })) },
151
+ { title: 'Yours', note: 'In .aipictury/commands/ (or .claude/commands/) of the project, or of your home folder. $ARGUMENTS stands for what follows the command.',
152
+ empty: 'None yet — add one below.',
153
+ items: context.commands().map((c) => ({ id: `c:${c.name}`, label: `/${c.name}${c.hint ? ` ${c.hint}` : ''}`, detail: c.description,
154
+ badge: /[\\/]\.claude[\\/]/.test(c.file) ? 'Claude Code' : undefined, actions: [...use, { id: 'edit', label: 'Edit' }] })) },
155
+ { title: 'Skills', empty: 'No skills. A skill is .aipictury/skills/<name>/SKILL.md, with a name and a description.',
156
+ items: context.skills().map((s) => ({ id: `s:${s.name}`, label: `/${s.name}`, detail: s.description, actions: [...use, { id: 'edit', label: 'Edit' }] })) },
157
+ ],
158
+ form: {
159
+ title: 'New command',
160
+ fields: [
161
+ { id: 'name', label: 'Name', placeholder: 'e.g. test-changed' },
162
+ { id: 'description', label: 'What it does', placeholder: 'e.g. Run the tests for the files I changed' },
163
+ { id: 'prompt', label: 'The prompt', multiline: true, rows: 4, placeholder: 'e.g. Find the tests for the files changed in git diff and run them. $ARGUMENTS' },
164
+ { id: 'where', label: 'Kept', value: 'project', options: [{ value: 'project', label: 'In this project' }, { value: 'user', label: 'For me, everywhere' }] },
165
+ ],
166
+ submit: { id: 'create', label: 'Create' },
167
+ },
168
+ }
169
+ }
170
+
171
+ function slashHandler(panel, builtIn) {
172
+ return async (action, message) => {
173
+ const item = String(message.item || '')
174
+ const name = item.slice(2)
175
+ if (action === 'use') { panel.post({ type: 'draft', text: `/${name} ` }); return null }
176
+ if (action === 'edit') {
177
+ const found = item.startsWith('c:') ? context.commands().find((c) => c.name === name) : context.skills().find((s) => s.name === name)
178
+ if (found) await openFile(found.file)
179
+ return null
180
+ }
181
+ if (action === 'create') {
182
+ const f = message.fields || {}
183
+ const clean = String(f.name || '').trim().replace(/^\//, '')
184
+ if (!COMMAND_NAME.test(clean)) throw new Error('a name is letters, digits, - _ or . (up to 40)')
185
+ if (builtIn.some(([b]) => b === clean)) throw new Error(`/${clean} is built in — choose another name`)
186
+ const prompt = String(f.prompt || '').trim()
187
+ if (!prompt) throw new Error('write the prompt the command stands for')
188
+ const base = f.where === 'user' ? os.homedir() : root()
189
+ if (!base) throw new Error('open a folder first, or keep it for you everywhere')
190
+ const file = path.join(base, '.aipictury', 'commands', `${clean}.md`)
191
+ if (fs.existsSync(file)) throw new Error(`/${clean} already exists — edit it instead`)
192
+ const description = String(f.description || '').trim().replace(/\n/g, ' ')
193
+ fs.mkdirSync(path.dirname(file), { recursive: true })
194
+ fs.writeFileSync(file, `${description ? `---\ndescription: ${description}\n---\n\n` : ''}${prompt}\n`)
195
+ panel.postCommands?.()
196
+ return slashSpec(builtIn, `Created /${clean} (${path.relative(base, file)}).`)
197
+ }
198
+ return undefined
199
+ }
200
+ }
201
+
202
+ // ---- Permissions: the allow, ask and deny rules of every settings file ----
203
+
204
+ const KINDS = [
205
+ { id: 'allow', label: 'Allow', note: 'Done without asking — in any mode but Plan.' },
206
+ { id: 'ask', label: 'Ask', note: 'Always asked first, even in Auto mode and for reads.' },
207
+ { id: 'deny', label: 'Deny', note: 'Never done, whatever the mode. Deny beats ask beats allow.' },
208
+ ]
209
+ const LEVEL_NAMES = { user: 'You, everywhere', project: 'This project (shared)', local: 'This project, just you' }
210
+
211
+ function permissionsSpec(folder, { tab = 'allow', note } = {}) {
212
+ const merged = prefs.load(folder).merged.permissions
213
+ const kind = KINDS.find((k) => k.id === tab) || KINDS[0]
214
+ const items = (merged[kind.id] || []).map((r) => ({
215
+ id: JSON.stringify([r.level, r.rule, r.file]),
216
+ label: r.rule,
217
+ detail: `${LEVEL_NAMES[r.level] || r.level} · ${r.file.replace(os.homedir(), '~')}`,
218
+ badge: r.editable ? undefined : 'Claude Code', badgeKind: r.editable ? undefined : 'info',
219
+ readOnly: !r.editable, readOnlyNote: r.editable ? undefined : 'read-only here',
220
+ actions: [{ id: 'remove', label: 'Remove', danger: true }, { id: 'openFile', label: 'Open file', always: true }],
221
+ }))
222
+ return {
223
+ id: 'permissions',
224
+ title: 'Permissions',
225
+ note: note || 'Rules in Claude Code\'s syntax: Bash(npm run test:*), Read(./.env), Edit(src/**), WebFetch(domain:github.com), mcp__server. Claude Code\'s own files are read too, never written.',
226
+ tabs: KINDS.map((k) => ({ id: k.id, label: `${k.label} (${(merged[k.id] || []).length})` })),
227
+ tab: kind.id,
228
+ sections: [{ note: kind.note, empty: `No ${kind.label.toLowerCase()} rules yet.`, items }],
229
+ form: {
230
+ title: `Add a rule to ${kind.label}`,
231
+ fields: [
232
+ { id: 'rule', label: 'Rule', placeholder: kind.id === 'deny' ? 'e.g. Read(./.env)' : 'e.g. Bash(npm run test:*)' },
233
+ { id: 'level', label: 'Kept', value: 'local', options: [{ value: 'local', label: LEVEL_NAMES.local },
234
+ { value: 'project', label: LEVEL_NAMES.project }, { value: 'user', label: LEVEL_NAMES.user }] },
235
+ ],
236
+ submit: { id: 'add', label: 'Add rule' },
237
+ },
238
+ }
239
+ }
240
+
241
+ function permissionsHandler(folder) {
242
+ let tab = 'allow'
243
+ const spec = (note) => permissionsSpec(folder, { tab, note })
244
+ return async (action, message) => {
245
+ if (action === 'tab') { tab = message.value; return spec() }
246
+ if (action === 'add') {
247
+ const rule = String(message.fields?.rule || '').trim()
248
+ if (!permissions.parseRule(rule) || !rule) throw new Error('write a rule like Bash(npm test:*) or Read(./.env)')
249
+ if (!folder && message.fields?.level !== 'user') throw new Error('open a folder for a project rule, or keep it for you everywhere')
250
+ const file = prefs.addRule(folder, message.fields?.level || 'local', tab, rule)
251
+ return spec(`Added ${rule} to ${tab} (${path.basename(file)}).`)
252
+ }
253
+ const [level, rule, file] = (() => { try { return JSON.parse(message.item || '[]') } catch { return [] } })()
254
+ if (action === 'openFile' && file) { await openFile(file); return null }
255
+ if (action === 'remove' && rule) {
256
+ const source = prefs.sources(folder).find((x) => x.file === file)
257
+ if (!source?.editable) throw new Error('that rule is in Claude Code\'s file — change it there')
258
+ prefs.removeRule(folder, level, tab, rule)
259
+ return spec(`Removed ${rule}.`)
260
+ }
261
+ return undefined
262
+ }
263
+ }
264
+
265
+ // ---- Hooks: commands that run at moments of the agent's work ----
266
+
267
+ const HOOK_EVENTS = [
268
+ ['PreToolUse', 'Before a tool runs — can block it (exit 2) or decide allow/ask/deny'],
269
+ ['PostToolUse', 'After a tool ran — what it says reaches the model'],
270
+ ['UserPromptSubmit', 'When you send a message — can stop it, or add context'],
271
+ ['Stop', 'When the agent is about to finish — can make it carry on'],
272
+ ['SubagentStop', 'When a helper agent is about to report'],
273
+ ['SessionStart', 'When a conversation starts or resumes — its output is context'],
274
+ ['SessionEnd', 'When a conversation is cleared'],
275
+ ['Notification', 'When the agent waits for you'],
276
+ ['PreCompact', 'Before the conversation is summarized'],
277
+ ]
278
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse'])
279
+
280
+ function hooksSpec(folder, { tab = 'PreToolUse', note } = {}) {
281
+ const configured = prefs.load(folder).merged.hooks || {}
282
+ const [event, about] = HOOK_EVENTS.find(([e]) => e === tab) || HOOK_EVENTS[0]
283
+ const items = (configured[event] || []).flatMap((group) => (group.hooks || []).map((h) => ({
284
+ id: JSON.stringify([group.level, group.file, group.matcher ?? '', h.command]),
285
+ label: h.command,
286
+ detail: `${TOOL_EVENTS.has(event) ? `matcher ${group.matcher || '(every tool)'} · ` : ''}${h.timeout ? `${h.timeout} s · ` : ''}`
287
+ + `${LEVEL_NAMES[group.level] || group.level} · ${group.file.replace(os.homedir(), '~')}`,
288
+ badge: group.editable ? undefined : 'Claude Code', badgeKind: group.editable ? undefined : 'info',
289
+ readOnly: !group.editable, readOnlyNote: group.editable ? undefined : 'read-only here',
290
+ actions: [{ id: 'remove', label: 'Remove', danger: true }, { id: 'openFile', label: 'Open file', always: true }],
291
+ })))
292
+ const fields = [{ id: 'command', label: 'Command', placeholder: 'e.g. npx prettier --write "$(jq -r .tool_input.file_path)"' }]
293
+ if (TOOL_EVENTS.has(event)) fields.push({ id: 'matcher', label: 'For tools (a regular expression)', placeholder: 'e.g. Bash or Edit|Write — empty for every tool' })
294
+ fields.push({ id: 'timeout', label: 'Timeout (seconds)', placeholder: '60' },
295
+ { id: 'level', label: 'Kept', value: 'local', options: [{ value: 'local', label: LEVEL_NAMES.local },
296
+ { value: 'project', label: LEVEL_NAMES.project }, { value: 'user', label: LEVEL_NAMES.user }] })
297
+ return {
298
+ id: 'hooks',
299
+ title: 'Hooks',
300
+ note: note || 'Your commands, run as the agent works — Claude Code\'s format, its files read too. A hook gets the event as JSON on stdin; exit 2 blocks, with stderr as the reason. A project\'s own hooks run only once you trust them.',
301
+ tabs: HOOK_EVENTS.map(([e]) => ({ id: e, label: `${e}${(configured[e] || []).length ? ` (${(configured[e] || []).reduce((n, g) => n + (g.hooks || []).length, 0)})` : ''}` })),
302
+ tab: event,
303
+ sections: [{ note: about, empty: `No ${event} hooks.`, items }],
304
+ form: { title: `Add a ${event} hook`, fields, submit: { id: 'add', label: 'Add hook' } },
305
+ }
306
+ }
307
+
308
+ function hooksHandler(folder) {
309
+ let tab = 'PreToolUse'
310
+ const spec = (note) => hooksSpec(folder, { tab, note })
311
+ return async (action, message) => {
312
+ if (action === 'tab') { tab = message.value; return spec() }
313
+ if (action === 'add') {
314
+ const f = message.fields || {}
315
+ const command = String(f.command || '').trim()
316
+ if (!command) throw new Error('write the command to run')
317
+ const matcher = TOOL_EVENTS.has(tab) ? String(f.matcher || '').trim() : ''
318
+ if (matcher) { try { new RegExp(matcher) } catch { throw new Error(`"${matcher}" is not a regular expression`) } }
319
+ const timeout = f.timeout ? Number(f.timeout) : undefined
320
+ if (timeout !== undefined && !(timeout > 0 && timeout <= 3600)) throw new Error('a timeout is 1 to 3600 seconds')
321
+ if (!folder && (f.level || 'local') !== 'user') throw new Error('open a folder for a project hook, or keep it for you everywhere')
322
+ prefs.edit(folder, f.level || 'local', (data) => {
323
+ const groups = ((data.hooks ||= {})[tab] ||= [])
324
+ let group = groups.find((g) => (g.matcher || '') === matcher)
325
+ if (!group) { group = matcher ? { matcher, hooks: [] } : { hooks: [] }; groups.push(group) }
326
+ group.hooks.push({ type: 'command', command, ...(timeout ? { timeout } : {}) })
327
+ })
328
+ return spec(`Added a ${tab} hook.`)
329
+ }
330
+ const [level, file, matcher, command] = (() => { try { return JSON.parse(message.item || '[]') } catch { return [] } })()
331
+ if (action === 'openFile' && file) { await openFile(file); return null }
332
+ if (action === 'remove' && command) {
333
+ const source = prefs.sources(folder).find((x) => x.file === file)
334
+ if (!source?.editable) throw new Error('that hook is in Claude Code\'s file — change it there')
335
+ prefs.edit(folder, level, (data) => {
336
+ const groups = data.hooks?.[tab] || []
337
+ for (const g of groups) if ((g.matcher || '') === matcher) g.hooks = (g.hooks || []).filter((h) => h.command !== command)
338
+ data.hooks[tab] = groups.filter((g) => (g.hooks || []).length)
339
+ if (!data.hooks[tab].length) delete data.hooks[tab]
340
+ })
341
+ return spec('Removed.')
342
+ }
343
+ return undefined
344
+ }
345
+ }
346
+
347
+ // ---- /usage ----
348
+
349
+ function usageSpec(tab = 'conversation', { conversation = {}, context = 0, records } = {}) {
350
+ const k = (n) => (n >= 10_000 ? `${Math.round(n / 1000)}k` : String(n))
351
+ const rows = (list, what) => list.slice(0, 12).map(([name, n]) => ({ id: `${what}:${name}`, label: name, detail: `${n} time${n === 1 ? '' : 's'}` }))
352
+ if (tab === 'conversation') {
353
+ const u = conversation
354
+ return { id: 'usage', title: 'Usage', tab, tabs: USAGE_TABS,
355
+ note: 'Everything runs on your own GPU: no cost, no plan limits.',
356
+ sections: [{ items: [
357
+ { id: 'turns', label: `${u.turns || 0} turns · ${Math.round(u.seconds || 0)} s of model time`, detail: `${k(u.prompt || 0)} prompt tokens · ${k(u.output || 0)} written${u.output && u.genSeconds ? ` · ${(u.output / u.genSeconds).toFixed(1)} tokens/s` : ''}` },
358
+ { id: 'context', label: `Context now: ~${k(context)} tokens`, detail: 'the conversation so far, as the model reads it' },
359
+ ] }] }
360
+ }
361
+ const s = usageLog.summary(tab === 'week' ? 'week' : 'today', records ? { records } : {})
362
+ return { id: 'usage', title: 'Usage', tab, tabs: USAGE_TABS,
363
+ note: `${s.turns} turns in ${s.conversations} conversation(s) · ${k(s.context)} prompt tokens (${s.read === 0 ? 'none' : k(s.read)} read anew) · ${k(s.output)} written · ${Math.round(s.genSeconds)} s writing`,
364
+ sections: [
365
+ { title: 'Worth knowing', empty: 'Nothing: the prompt cache served the conversations, and none ran out of room.', items: s.flags.map((f, i) => ({ id: `flag${i}`, label: f })) },
366
+ { title: 'Tools', empty: 'None used.', items: rows(s.tools, 'tool') },
367
+ { title: 'Helpers', empty: 'None.', items: rows(s.helpers, 'helper') },
368
+ { title: 'MCP servers', empty: 'None.', items: rows(s.mcp, 'mcp') },
369
+ { title: 'Skills', empty: 'None.', items: rows(s.skills, 'skill') },
370
+ ] }
371
+ }
372
+ const USAGE_TABS = [{ id: 'conversation', label: 'This conversation' }, { id: 'today', label: 'Today' }, { id: 'week', label: 'This week' }]
373
+
374
+ // ---- Published pages ----
375
+
376
+ async function pagesSpec(server, note, fetchImpl = fetch) {
377
+ let items = []
378
+ let problem = ''
379
+ try {
380
+ const r = await fetchImpl(`${server}/api/pages`)
381
+ if (!r.ok) throw new Error(`the server answered ${r.status}`)
382
+ items = (await r.json()).pages || []
383
+ } catch (error) { problem = error.message }
384
+ return {
385
+ id: 'pages', title: 'Published pages',
386
+ note: note || (problem ? `The server cannot be reached: ${problem}` : 'Pages the agent published on your server — private: only those with the link can open them.'),
387
+ sections: [{ empty: 'None yet — the agent publishes one with publish_page.', items: items.map((p) => ({ id: p.id, label: p.title,
388
+ detail: `${new Date((p.created || 0) * 1000).toLocaleString()} · ${Math.round((p.bytes || 0) / 1024)} KB · ${server}/p/${p.id}`,
389
+ actions: [{ id: 'open', label: 'Open', always: true }, { id: 'delete', label: 'Delete', danger: true }] })) }],
390
+ }
391
+ }
392
+
393
+ function pagesHandler(server, fetchImpl = fetch) {
394
+ return async (action, message) => {
395
+ if (action === 'open') { await vscode.env.openExternal(vscode.Uri.parse(`${server}/p/${message.item}`)); return undefined }
396
+ if (action === 'delete') {
397
+ const r = await fetchImpl(`${server}/api/pages/${encodeURIComponent(message.item)}`, { method: 'DELETE' })
398
+ if (!r.ok) throw new Error(`the server answered ${r.status}`)
399
+ return pagesSpec(server, 'Deleted — the link no longer opens it.', fetchImpl)
400
+ }
401
+ return undefined
402
+ }
403
+ }
404
+
405
+ // ---- The agent map: helpers and background commands ----
406
+
407
+ function agentsSpec(panel, note) {
408
+ const helpers = [...(panel.session?.helpers?.values() || [])]
409
+ const shell = require('./shell')
410
+ const commands = shell.taskList()
411
+ const secs = (from) => `${Math.round((Date.now() - from) / 1000)} s`
412
+ return {
413
+ id: 'agents', title: 'Agents',
414
+ note: note || 'The helpers of this conversation and the commands running in the background.',
415
+ sections: [
416
+ { title: 'Helpers', empty: 'No helpers yet — the agent starts them with the task tool.',
417
+ items: helpers.map((h) => ({ id: h.id, label: `${h.id}${h.name ? ` · ${h.name}` : ''} — ${h.role || h.type}`,
418
+ detail: `${h.status}${h.background ? ' (background)' : ''} · ${h.state.steps} steps · ${h.state.tokens} tokens · ${secs(h.started)}`
419
+ + `${h.state.last ? ` · now: ${h.state.last}` : ''} · ${h.description}`,
420
+ badge: h.status, badgeKind: h.status === 'running' ? 'info' : h.status === 'done' ? 'ok' : 'bad',
421
+ actions: [{ id: 'transcript', label: 'Transcript', always: true }, ...(h.status === 'running' ? [{ id: 'stop', label: 'Stop', danger: true }] : [])] })) },
422
+ { title: 'Background commands', empty: 'None.',
423
+ items: commands.map((c) => ({ id: c.id, label: `${c.id} — ${c.command.slice(0, 80)}`,
424
+ detail: c.running ? `running · ${c.seconds} s` : `exited: ${c.exit}`, actions: c.running ? [{ id: 'kill', label: 'Stop', danger: true }] : [] })) },
425
+ ],
426
+ }
427
+ }
428
+
429
+ function agentsHandler(panel) {
430
+ return async (action, message) => {
431
+ const h = panel.session?.helpers?.get(message.item)
432
+ if (action === 'stop' && h) { h.controller?.abort(); return agentsSpec(panel, `${h.id} stopped.`) }
433
+ if (action === 'kill') { require('./shell').killShell({ shell_id: message.item }); return agentsSpec(panel, `${message.item} stopped.`) }
434
+ if (action === 'transcript' && h) {
435
+ const lines = h.messages.slice(1).map((m) => (m.role === 'tool' ? `> **${m.tool_name}** →\n> ${String(m.content).slice(0, 2000).replace(/\n/g, '\n> ')}`
436
+ : `**${m.role === 'assistant' ? h.id : 'Task'}**: ${String(m.content || '').trim()}${m.tool_calls?.length ? `\n\n_calls: ${m.tool_calls.map((c) => c.function.name).join(', ')}_` : ''}`))
437
+ const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content: `# ${h.id}${h.name ? ` (${h.name})` : ''} — ${h.description}\n\n${lines.join('\n\n')}\n` })
438
+ await vscode.window.showTextDocument(doc, { preview: true })
439
+ return null
440
+ }
441
+ return undefined
442
+ }
443
+ }
444
+
445
+ // ---- Plugins and marketplaces (Claude Code's format) ----
446
+
447
+ function pluginsSpec(folder, { tab = 'installed', note } = {}) {
448
+ const on = new Set(plugins.enabled(folder).map((p) => p.id))
449
+ const have = plugins.installed()
450
+ const known = plugins.marketplaces()
451
+ const offered = plugins.available()
452
+ let sections
453
+ let form
454
+ if (tab === 'installed') {
455
+ sections = [{ empty: 'No plugins yet — add a marketplace, then install from Discover.',
456
+ items: Object.entries(have).map(([id, x]) => ({ id, label: id, detail: `${x.version ? `v${x.version} · ` : ''}${x.description}`,
457
+ toggle: { on: on.has(id) }, actions: [{ id: 'update', label: 'Update' }, { id: 'uninstall', label: 'Uninstall', danger: true }] })) }]
458
+ } else if (tab === 'discover') {
459
+ sections = [{ empty: Object.keys(known).length ? 'Every plugin is installed.' : 'Add a marketplace first (Marketplaces).',
460
+ items: offered.filter((p) => !p.installed).map((p) => ({ id: p.id, label: p.id, detail: `${p.category ? `${p.category} · ` : ''}${p.description}`.slice(0, 300),
461
+ actions: [{ id: 'install', label: 'Install for me' }, { id: 'installProject', label: 'For this project' }] })) }]
462
+ } else {
463
+ sections = [{ empty: 'No marketplace yet.', items: Object.entries(known).map(([name, m]) => ({ id: name, label: name,
464
+ detail: `${m.label} · ${offered.filter((p) => p.market === name).length} plugins`,
465
+ actions: [{ id: 'updateMarket', label: 'Update' }, { id: 'removeMarket', label: 'Remove', danger: true }] })) }]
466
+ form = { title: 'Add a marketplace', fields: [{ id: 'source', label: 'GitHub owner/repo, a git URL, or a folder', placeholder: 'e.g. anthropics/claude-code' }],
467
+ submit: { id: 'addMarket', label: 'Add' } }
468
+ }
469
+ return {
470
+ id: 'plugins', title: 'Plugins',
471
+ note: note || 'Commands, helper agents, skills, hooks and MCP servers, packaged — in Claude Code\'s format, so its plugins work here.',
472
+ tabs: [{ id: 'installed', label: `Installed (${Object.keys(have).length})` }, { id: 'discover', label: 'Discover' }, { id: 'marketplaces', label: `Marketplaces (${Object.keys(known).length})` }],
473
+ tab, sections, form,
474
+ }
475
+ }
476
+
477
+ function pluginsHandler(folder, { changed = () => {} } = {}) {
478
+ let tab = 'installed'
479
+ const spec = (note) => pluginsSpec(folder, { tab, note })
480
+ return async (action, message) => {
481
+ const id = String(message.item || '')
482
+ if (action === 'tab') { tab = message.value; return spec() }
483
+ if (action === 'toggle') { plugins.setEnabled(id, !!message.value, { folder, level: folder ? 'local' : 'user' }); changed(); return spec(`${id} is ${message.value ? 'on' : 'off'}.`) }
484
+ if (action === 'install' || action === 'installProject') {
485
+ const done = await plugins.install(id, { folder, level: action === 'install' ? 'user' : 'project' })
486
+ changed()
487
+ return spec(`Installed ${id}${done.version ? ` v${done.version}` : ''}${action === 'install' ? '' : ' for this project'}.`)
488
+ }
489
+ if (action === 'update') { const level = 'user'; await plugins.install(id, { folder, level }); changed(); return spec(`Updated ${id}.`) }
490
+ if (action === 'uninstall') { plugins.uninstall(id, { folder }); changed(); return spec(`Uninstalled ${id}.`) }
491
+ if (action === 'addMarket') {
492
+ const source = String(message.fields?.source || '').trim()
493
+ if (!source) throw new Error('write owner/repo, a git URL or a folder')
494
+ const m = await plugins.addMarketplace(source)
495
+ tab = 'discover'
496
+ return spec(`Added ${m.name}: ${m.plugins} plugins.`)
497
+ }
498
+ if (action === 'updateMarket') { const m = await plugins.updateMarketplace(id); return spec(`Updated ${m.name}: ${m.plugins} plugins.`) }
499
+ if (action === 'removeMarket') { plugins.removeMarketplace(id); changed(); return spec(`Removed ${id} and its plugins.`) }
500
+ return undefined
501
+ }
502
+ }
503
+
504
+ // ---- Skills: which the model sees, and how ----
505
+
506
+ const SHOW = [{ value: 'on', label: 'On' }, { value: 'name', label: 'Name only' }, { value: 'off', label: 'Off' }]
507
+
508
+ function skillsSpec(folder, note) {
509
+ const seen = context.skillVisibility()
510
+ return {
511
+ id: 'skills',
512
+ title: 'Skills',
513
+ note: note || 'Instructions (and files) for a kind of task, used when a task matches. On: the model sees the name and description; name only: just the name; off: hidden.',
514
+ sections: [{ empty: 'No skills yet — create one below, or add .aipictury/skills/<name>/SKILL.md (or .claude/skills/…).',
515
+ items: context.skills().map((x) => ({ id: x.name, label: x.name, detail: `${x.description || '(no description)'} · ${x.file.replace(os.homedir(), '~')}`,
516
+ badge: /[\\/]\.claude[\\/]/.test(x.file) ? 'Claude Code' : undefined, badgeKind: 'info',
517
+ select: { label: 'shown', value: seen[x.name] || 'on', options: SHOW, action: 'show' },
518
+ actions: [{ id: 'edit', label: 'Edit', always: true }] })) }],
519
+ form: { title: 'Create a skill', fields: [
520
+ { id: 'name', label: 'Name', placeholder: 'e.g. release-notes' },
521
+ { id: 'description', label: 'When to use it', placeholder: 'e.g. Writing release notes from the git log' },
522
+ { id: 'body', label: 'What to do', multiline: true, rows: 5, placeholder: 'The steps, the format, what to check…' },
523
+ { id: 'where', label: 'Kept', value: 'project', options: [{ value: 'project', label: 'In this project' }, { value: 'user', label: 'For me, everywhere' }] },
524
+ ], submit: { id: 'create', label: 'Create skill' } },
525
+ }
526
+ }
527
+
528
+ function skillsHandler(folder) {
529
+ return async (action, message) => {
530
+ if (action === 'show') {
531
+ prefs.edit(folder, folder ? 'local' : 'user', (data) => { data.skillVisibility = { ...(data.skillVisibility || {}), [message.item]: message.value } })
532
+ return skillsSpec(folder, `${message.item}: ${SHOW.find((x) => x.value === message.value)?.label.toLowerCase()}.`)
533
+ }
534
+ if (action === 'edit') { const x = context.skills().find((k) => k.name === message.item); if (x) await openFile(x.file); return null }
535
+ if (action === 'create') {
536
+ const f = message.fields || {}
537
+ const name = String(f.name || '').trim()
538
+ if (!/^[a-z0-9][\w-]{0,39}$/i.test(name)) throw new Error('a skill name is letters, digits, - or _ (up to 40)')
539
+ if (!String(f.description || '').trim()) throw new Error('say when to use it: the model decides from that')
540
+ const baseDir = f.where === 'user' ? os.homedir() : folder
541
+ if (!baseDir) throw new Error('open a folder, or keep it for you everywhere')
542
+ const file = path.join(baseDir, '.aipictury', 'skills', name, 'SKILL.md')
543
+ if (fs.existsSync(file)) throw new Error(`${name} already exists — edit it instead`)
544
+ fs.mkdirSync(path.dirname(file), { recursive: true })
545
+ fs.writeFileSync(file, `---\nname: ${name}\ndescription: ${String(f.description).trim().replace(/\n/g, ' ')}\n---\n\n${String(f.body || '').trim()}\n`)
546
+ return skillsSpec(folder, `Created ${name}.`)
547
+ }
548
+ return undefined
549
+ }
550
+ }
551
+
552
+ // ---- Output styles: how the agent writes ----
553
+
554
+ function outputStyleSpec(folder, note) {
555
+ const now = context.outputStyle().name
556
+ return {
557
+ id: 'output-style',
558
+ title: 'Output style',
559
+ note: note || `In use: ${now}. A style changes how the agent writes its answers, not what it can do.`,
560
+ sections: [{ items: context.outputStyles().map((x) => ({ id: x.name, label: x.name, detail: `${x.description || ''}${x.file ? ` · ${x.file.replace(os.homedir(), '~')}` : ' · built in'}`,
561
+ badge: x.name === now ? 'in use' : undefined, badgeKind: 'ok',
562
+ actions: [...(x.name === now ? [] : [{ id: 'use', label: 'Use' }]), ...(x.file ? [{ id: 'edit', label: 'Edit', always: true }] : [])] })) }],
563
+ form: { title: 'Build a custom style', fields: [
564
+ { id: 'name', label: 'Name', placeholder: 'e.g. Terse' },
565
+ { id: 'description', label: 'What it is', placeholder: 'e.g. Answers in as few words as possible' },
566
+ { id: 'body', label: 'How to write', multiline: true, rows: 5, placeholder: 'e.g. Answer in at most three sentences. No preamble, no recap.' },
567
+ ], submit: { id: 'create', label: 'Create and use' } },
568
+ }
569
+ }
570
+
571
+ function outputStyleHandler(folder) {
572
+ const use = (name) => prefs.edit(folder, folder ? 'local' : 'user', (data) => { if (name === 'Default') delete data.outputStyle; else data.outputStyle = name })
573
+ return async (action, message) => {
574
+ if (action === 'use') { use(message.item); return outputStyleSpec(folder, `${message.item} is in use from the next message.`) }
575
+ if (action === 'edit') { const x = context.outputStyles().find((k) => k.name === message.item); if (x?.file) await openFile(x.file); return null }
576
+ if (action === 'create') {
577
+ const f = message.fields || {}
578
+ const name = String(f.name || '').trim()
579
+ if (!/^[\w][\w -]{0,39}$/.test(name)) throw new Error('a style name is letters, digits, spaces, - or _ (up to 40)')
580
+ if (context.outputStyles().some((x) => x.name.toLowerCase() === name.toLowerCase())) throw new Error(`${name} already exists`)
581
+ if (!String(f.body || '').trim()) throw new Error('say how the agent should write')
582
+ if (!folder) throw new Error('open a folder first')
583
+ const file = path.join(folder, '.aipictury', 'output-styles', `${name.toLowerCase().replace(/\s+/g, '-')}.md`)
584
+ fs.mkdirSync(path.dirname(file), { recursive: true })
585
+ fs.writeFileSync(file, `---\nname: ${name}\ndescription: ${String(f.description || '').trim().replace(/\n/g, ' ')}\n---\n\n${String(f.body).trim()}\n`)
586
+ use(name)
587
+ return outputStyleSpec(folder, `Created ${name} — in use from the next message.`)
588
+ }
589
+ return undefined
590
+ }
591
+ }
592
+
593
+ // ---- MCP servers ----
594
+
595
+ const SCOPES = [['local', 'This project, just you'], ['project', 'This project, shared (.mcp.json)'], ['user', 'You, everywhere']]
596
+
597
+ /** "npx -y @org/server --flag 'a b'" → ['npx', '-y', '@org/server', '--flag', 'a b'] */
598
+ function splitCommandLine(text) {
599
+ const out = []
600
+ for (const m of String(text).matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3])
601
+ return out
602
+ }
603
+
604
+ function mcpSpec(folder, note) {
605
+ const live = new Map(mcp.status().map((x) => [x.name, x]))
606
+ const all = [...mcp.detailed(folder).values()]
607
+ return {
608
+ id: 'mcp',
609
+ title: 'MCP servers',
610
+ note: note || 'Tools from outside — a database, GitHub, a browser — in Claude Code\'s format. A project\'s servers start programs from the repository: they connect once you trust them.',
611
+ sections: SCOPES.map(([scope, title]) => ({
612
+ title,
613
+ empty: scope === 'project' ? 'None in .mcp.json.' : 'None.',
614
+ items: all.filter((x) => x.scope === scope).map((x) => {
615
+ const st = x.disabled ? { state: 'off' } : live.get(x.name) || { state: 'not connected' }
616
+ const what = x.config.command ? [x.config.command, ...(x.config.args || [])].join(' ') : x.config.url || ''
617
+ return {
618
+ id: x.name,
619
+ label: x.name,
620
+ detail: `${st.state === 'connected' ? `${st.tools.length} tools` : st.state}${st.error ? ` — ${st.error}` : ''} · ${what.slice(0, 80)} · ${x.origin}${x.file.startsWith('aipictury.') ? '' : ` · ${x.file.replace(os.homedir(), '~')}`}`,
621
+ badge: st.state === 'connected' ? 'connected' : st.state === 'failed' ? 'failed' : st.state === 'untrusted' ? 'not trusted' : st.state === 'needs-auth' ? 'sign in' : undefined,
622
+ badgeKind: st.state === 'connected' ? 'ok' : st.state === 'failed' ? 'bad' : 'info',
623
+ toggle: { on: !x.disabled },
624
+ actions: [
625
+ ...(st.state === 'untrusted' ? [{ id: 'trust', label: 'Trust', always: true }] : []),
626
+ ...(st.state === 'needs-auth' ? [{ id: 'signin', label: 'Sign in', always: true }] : []),
627
+ ...(x.config.url && st.state === 'connected' ? [{ id: 'signout', label: 'Sign out', always: true }] : []),
628
+ { id: 'reconnect', label: 'Reconnect', always: true },
629
+ ...(x.editable ? [{ id: 'remove', label: 'Remove', danger: true }] : []),
630
+ ...(x.file.startsWith('aipictury.') || x.origin === 'plugin' ? [] : [{ id: 'openFile', label: 'Open file', always: true }]),
631
+ ],
632
+ }
633
+ }),
634
+ })),
635
+ form: {
636
+ title: 'Add a server',
637
+ fields: [
638
+ { id: 'name', label: 'Name', placeholder: 'e.g. github' },
639
+ { id: 'target', label: 'Command, or URL', placeholder: 'e.g. npx -y @modelcontextprotocol/server-filesystem . — or https://…/mcp' },
640
+ { id: 'scope', label: 'Kept', value: 'local', options: SCOPES.map(([value, label]) => ({ value, label })) },
641
+ ],
642
+ submit: { id: 'add', label: 'Add and connect' },
643
+ },
644
+ }
645
+ }
646
+
647
+ function mcpHandler(folder, { trust } = {}) {
648
+ const refresh = async (note) => { await mcp.ensure({ trust }).catch(() => {}); return mcpSpec(folder, note) }
649
+ return async (action, message) => {
650
+ const name = String(message.item || '')
651
+ if (action === 'toggle') { mcp.setEnabled(folder, name, !!message.value); return refresh(`${name} is ${message.value ? 'on' : 'off'}.`) }
652
+ if (action === 'reconnect') { const e = await mcp.reconnect(name); return mcpSpec(folder, e ? `${name}: ${e.state}${e.error ? ` — ${e.error}` : ''}` : `${name} is off.`) }
653
+ if (action === 'remove') { mcp.remove(folder, name); return refresh(`Removed ${name}.`) }
654
+ if (action === 'trust') return refresh()
655
+ if (action === 'signin') { const e = await mcp.signIn(name); return mcpSpec(folder, `${name}: ${e?.state === 'connected' ? `signed in — ${e.tools.length} tools` : e?.error || 'not connected'}`) }
656
+ if (action === 'signout') { await mcp.signOut(name); return mcpSpec(folder, `Signed out of ${name}.`) }
657
+ if (action === 'openFile') { const x = mcp.detailed(folder).get(name); if (x) await openFile(x.file); return null }
658
+ if (action === 'add') {
659
+ const f = message.fields || {}
660
+ const id = String(f.name || '').trim()
661
+ const target = String(f.target || '').trim()
662
+ if (!id) throw new Error('give the server a name')
663
+ if (!target) throw new Error('write the command that starts it, or its URL')
664
+ const config = /^https?:\/\//i.test(target) ? { type: 'http', url: target }
665
+ : (() => { const [command, ...args] = splitCommandLine(target); return { command, args } })()
666
+ mcp.save(folder, f.scope || 'local', id, config)
667
+ const spec = await refresh()
668
+ const st = mcp.status().find((x) => x.name === id)
669
+ spec.note = `Added ${id}: ${st ? `${st.state}${st.state === 'connected' ? ` — ${st.tools.length} tools` : st.error ? ` — ${st.error}` : ''}` : 'saved'}.`
670
+ return spec
671
+ }
672
+ return undefined
673
+ }
674
+ }
675
+
676
+ // ---- The sandbox for commands ----
677
+
678
+ function sandboxSpec(note) {
679
+ const c = config()
680
+ const kind = sandboxing.available()
681
+ const toggle = (id, label, detail, key, on) => ({ id, label, detail, toggle: { on: c.get(key, on) } })
682
+ return {
683
+ id: 'sandbox',
684
+ title: 'Sandbox',
685
+ note: note || (kind ? `This machine has a sandbox (${kind === 'macos' ? 'sandbox-exec' : 'bubblewrap'}).`
686
+ : 'This machine has no sandbox (sandbox-exec on macOS, bubblewrap on Linux): with it on, every command is asked.'),
687
+ sections: [
688
+ { items: [
689
+ toggle('enabled', 'Run commands in the sandbox', 'They can write only inside the workspace and the temp folders.', 'sandbox.enabled', false),
690
+ toggle('allowNetwork', 'Let them reach the internet', 'Off: only this machine (localhost) is reachable.', 'sandbox.allowNetwork', false),
691
+ toggle('autoAllow', 'Run sandboxed commands without asking', 'Only with the internet off; deny and ask rules still apply.', 'sandbox.autoAllow', true),
692
+ ] },
693
+ { title: 'Left out of the sandbox', note: 'These commands run outside it, asked as usual.', empty: 'None.',
694
+ items: c.get('sandbox.excludedCommands', []).map((x) => ({ id: x, label: x, actions: [{ id: 'remove', label: 'Remove', danger: true }] })) },
695
+ ],
696
+ form: { title: 'Leave a command out', fields: [{ id: 'command', label: 'Command', placeholder: 'e.g. docker' }], submit: { id: 'add', label: 'Leave it out' } },
697
+ }
698
+ }
699
+
700
+ function sandboxHandler() {
701
+ const save = (key, value) => config().update(`sandbox.${key}`, value, vscode.ConfigurationTarget.Global)
702
+ return async (action, message) => {
703
+ if (action === 'toggle') { await save(message.item, !!message.value); return sandboxSpec() }
704
+ const list = config().get('sandbox.excludedCommands', [])
705
+ if (action === 'add') {
706
+ const command = String(message.fields?.command || '').trim()
707
+ if (!command) throw new Error('write the command, e.g. docker')
708
+ if (!list.includes(command)) await save('excludedCommands', [...list, command])
709
+ return sandboxSpec(`${command} runs outside the sandbox now.`)
710
+ }
711
+ if (action === 'remove') { await save('excludedCommands', list.filter((x) => x !== message.item)); return sandboxSpec() }
712
+ return undefined
713
+ }
714
+ }
715
+
716
+ // ---- Report a problem ----
717
+
718
+ /** The extension's own settings, for a report: redacted by feedback.save. */
719
+ function settingsSnapshot() {
720
+ const pkg = require('./package.json')
721
+ const out = {}
722
+ for (const key of Object.keys(pkg.contributes?.configuration?.properties || {})) {
723
+ out[key.replace(/^aipictury\./, '')] = vscode.workspace.getConfiguration().get(key)
724
+ }
725
+ return out
726
+ }
727
+
728
+ function reportSpec(kind = 'bug', description = '', note) {
729
+ return {
730
+ id: 'report',
731
+ title: kind === 'feedback' ? 'Send feedback' : 'Report a problem',
732
+ note: note || 'Saved on this machine with the versions, your settings and the last lines of the log — keys, tokens and passwords taken out. Sent to your AIPictury server only if you choose to.',
733
+ form: {
734
+ fields: [
735
+ { id: 'kind', label: 'Kind', value: kind, options: [{ value: 'bug', label: 'Something is wrong' }, { value: 'feedback', label: 'An idea or a wish' }] },
736
+ { id: 'description', label: 'What happened', multiline: true, rows: 6, value: description,
737
+ placeholder: 'What you did, what you expected, what happened instead' },
738
+ { id: 'send', label: 'Send it', value: 'server', options: [{ value: 'server', label: 'Save it here and send it to my AIPictury server' }, { value: 'local', label: 'Only save it here' }] },
739
+ ],
740
+ submit: { id: 'send', label: 'Save report' },
741
+ },
742
+ }
743
+ }
744
+
745
+ /** Posts a report to the server; resolves to its id, or throws with why not. */
746
+ async function sendReport(server, body) {
747
+ const controller = new AbortController()
748
+ const timer = setTimeout(() => controller.abort(), 10_000)
749
+ try {
750
+ const response = await fetch(`${server}/api/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
751
+ body: JSON.stringify(body), signal: controller.signal })
752
+ const data = await response.json().catch(() => ({}))
753
+ if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}`)
754
+ return data.id
755
+ } catch (error) {
756
+ throw new Error(error.name === 'AbortError' ? 'the server did not answer in 10 s' : error.message, { cause: error })
757
+ } finally { clearTimeout(timer) }
758
+ }
759
+
760
+ function reportHandler({ server, version, send = sendReport }) {
761
+ let saved = null
762
+ let sentId = null
763
+ return async (action, message) => {
764
+ if (action === 'send') {
765
+ const f = message.fields || {}
766
+ const description = String(f.description || '').trim()
767
+ if (!description) throw new Error('say what happened')
768
+ const kind = f.kind === 'feedback' ? 'feedback' : 'bug'
769
+ saved = feedback.save({ kind, description, versions: { extension: version, vscode: vscode.version },
770
+ settings: settingsSnapshot(), log: log.tail() })
771
+ log.info(`problem report saved: ${saved.file}`)
772
+ let sent = 'Kept only on this machine.'
773
+ if (f.send !== 'local') {
774
+ try { sentId = await send(server, saved.body); sent = `Sent to your AIPictury server as ${sentId}.` } catch (error) {
775
+ sent = `Not sent: ${error.message}. It is saved here — send it later from this folder.`
776
+ log.warn('problem report not sent', error)
777
+ }
778
+ }
779
+ return {
780
+ id: 'report', title: 'Report saved', note: `${sent} Nothing secret is in it.`,
781
+ sections: [{ items: [
782
+ { id: 'file', label: path.basename(saved.file), detail: saved.file, open: true,
783
+ actions: [{ id: 'open', label: 'Open' }, ...(sentId ? [{ id: 'withdraw', label: 'Withdraw it from the server', always: true }] : [])] },
784
+ { id: 'folder', label: 'The reports folder', detail: feedback.dir(), actions: [{ id: 'reveal', label: 'Show folder', always: true }] },
785
+ ] }],
786
+ }
787
+ }
788
+ if (action === 'open' && saved) { await openFile(saved.file); return null }
789
+ if (action === 'withdraw' && sentId) {
790
+ const response = await fetch(`${server}/api/feedback/${encodeURIComponent(sentId)}`, { method: 'DELETE' })
791
+ if (!response.ok) throw new Error(`the server answered ${response.status}`)
792
+ const gone = sentId
793
+ sentId = null
794
+ return { id: 'report', title: 'Report withdrawn', note: `${gone} is no longer on the server; the copy on this machine stays.`,
795
+ sections: [{ items: [{ id: 'file', label: path.basename(saved.file), detail: saved.file, open: true, actions: [{ id: 'open', label: 'Open' }] }] }] }
796
+ }
797
+ if (action === 'reveal') { await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(saved?.file || feedback.dir())); return undefined }
798
+ return undefined
799
+ }
800
+ }
801
+
802
+ module.exports = { usageSpec, pagesSpec, pagesHandler, agentsSpec, agentsHandler, pluginsSpec, pluginsHandler, skillsSpec, skillsHandler, outputStyleSpec, outputStyleHandler, mcpSpec, mcpHandler, splitCommandLine, sandboxSpec, sandboxHandler, hooksSpec, hooksHandler, permissionsSpec, permissionsHandler, memorySpec, memoryHandler, instructionsSpec, instructionsHandler, slashSpec, slashHandler,
803
+ reportSpec, reportHandler, sendReport, settingsSnapshot }