goatchain 0.0.1

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/cli/repl.mjs ADDED
@@ -0,0 +1,3141 @@
1
+ import path from 'node:path'
2
+ import readline from 'node:readline'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { readFile, writeFile } from 'node:fs/promises'
5
+ import { ansi, clearScreen, deriveSessionSummary, deriveSessionTitle, formatOneLine, formatWhen, printHistory } from './ui.mjs'
6
+ import { getReplHelpText, buildProgram } from './args.mjs'
7
+ import { runTurnWithApprovals } from './turn.mjs'
8
+ import { confirmWithClack, multiselectWithClack, passwordWithClack, selectWithClack, textWithClack } from './clack.mjs'
9
+ import { ensureAllowedFile, expandFileMentions, listWorkspaceFiles } from './files.mjs'
10
+ import { readClipboardImageCandidatesDarwin, readClipboardTextDarwin, readClipboardTypesDarwin, readFinderSelectionPathsDarwin, readImageFileAsDataUrl } from './clipboard.mjs'
11
+ import { extractIterm2FilePastes } from './itermPaste.mjs'
12
+ import { buildSessionsExportBundle, parseSessionsImportJson, planSessionImports, selectSessionsById, sessionsToMarkdown } from './sessionTransfer.mjs'
13
+
14
+ const KNOWN_COMMANDS = [
15
+ 'help',
16
+ 'exit',
17
+ 'quit',
18
+ 'tools',
19
+ 'status',
20
+ 'model',
21
+ 'edit',
22
+ 'multiline',
23
+ 'ml',
24
+ 'params',
25
+ 'set',
26
+ 'unset',
27
+ 'api-key',
28
+ 'key',
29
+ 'base-url',
30
+ 'save',
31
+ 'new',
32
+ 'workspace',
33
+ 'files',
34
+ 'images',
35
+ 'settings',
36
+ 'output',
37
+ 'diag',
38
+ 'diagnostics',
39
+ 'sessions',
40
+ 'use',
41
+ 'rm',
42
+ 'delete',
43
+ 'clipboard',
44
+ ]
45
+
46
+ const KNOWN_MENTIONS = [
47
+ '@',
48
+ '@continue',
49
+ '@sessions',
50
+ '@workspace',
51
+ '@model',
52
+ '@settings',
53
+ '@file',
54
+ '@image',
55
+ '@img',
56
+ '@clear',
57
+ ]
58
+
59
+ function normalizeParamKey(key) {
60
+ const k = String(key || '').trim()
61
+ if (!k)
62
+ return ''
63
+ const lowered = k.toLowerCase()
64
+ if (lowered === 'maxtokens' || lowered === 'max_tokens')
65
+ return 'maxOutputTokens'
66
+ if (lowered === 'top_p' || lowered === 'topp')
67
+ return 'topP'
68
+ if (lowered === 'presence_penalty')
69
+ return 'presencePenalty'
70
+ if (lowered === 'frequency_penalty')
71
+ return 'frequencyPenalty'
72
+ if (lowered === 'timeout' || lowered === 'timeoutms')
73
+ return 'timeoutMs'
74
+ return k
75
+ }
76
+
77
+ function parseParamValue(key, valueRaw) {
78
+ const k = normalizeParamKey(key)
79
+ const v = String(valueRaw ?? '').trim()
80
+ if (!k)
81
+ return { ok: false, error: 'Missing key' }
82
+ if (v.length === 0)
83
+ return { ok: false, error: 'Missing value' }
84
+
85
+ const asNumber = (name) => {
86
+ const n = Number(v)
87
+ if (!Number.isFinite(n))
88
+ return { ok: false, error: `Invalid ${name}: ${v}` }
89
+ return { ok: true, value: n }
90
+ }
91
+
92
+ const asInt = (name) => {
93
+ const n = Number.parseInt(v, 10)
94
+ if (!Number.isFinite(n))
95
+ return { ok: false, error: `Invalid ${name}: ${v}` }
96
+ return { ok: true, value: n }
97
+ }
98
+
99
+ if (k === 'maxOutputTokens')
100
+ return asInt('maxTokens')
101
+ if (k === 'timeoutMs' || k === 'seed')
102
+ return asInt(k)
103
+ if (k === 'temperature' || k === 'topP' || k === 'presencePenalty' || k === 'frequencyPenalty')
104
+ return asNumber(k)
105
+
106
+ return { ok: false, error: `Unknown param: ${key}` }
107
+ }
108
+
109
+ function printStatus({ version, provider, modelId, sessionId, history, agent, baseUrl, requestDefaults, workspaceCwd, emoji }) {
110
+ const useEmoji = Boolean(emoji) && process.stdout.isTTY && process.env.TERM !== 'dumb'
111
+ const p = (e, s) => (useEmoji ? `${e} ${s}` : s)
112
+
113
+ const turns = Math.floor(history.length / 2)
114
+ const usage = agent.stats.totalUsage
115
+ const cfg = requestDefaults ?? {}
116
+ const cfgParts = [
117
+ typeof cfg.maxOutputTokens === 'number' ? `maxTokens=${cfg.maxOutputTokens}` : undefined,
118
+ typeof cfg.temperature === 'number' ? `temperature=${cfg.temperature}` : undefined,
119
+ typeof cfg.topP === 'number' ? `topP=${cfg.topP}` : undefined,
120
+ typeof cfg.presencePenalty === 'number' ? `presencePenalty=${cfg.presencePenalty}` : undefined,
121
+ typeof cfg.frequencyPenalty === 'number' ? `frequencyPenalty=${cfg.frequencyPenalty}` : undefined,
122
+ typeof cfg.seed === 'number' ? `seed=${cfg.seed}` : undefined,
123
+ typeof cfg.timeoutMs === 'number' ? `timeoutMs=${cfg.timeoutMs}` : undefined,
124
+ ].filter(Boolean)
125
+
126
+ process.stdout.write(
127
+ [
128
+ p('🧾', `version: ${version}`),
129
+ p('🗂️', `session: ${sessionId}`),
130
+ p('🤖', `model: ${provider}/${modelId}`),
131
+ p('🌐', `baseUrl: ${baseUrl ?? ''}`),
132
+ p('⚙️', `params: ${cfgParts.join(' ')}`),
133
+ p('🔁', `turns: ${turns}`),
134
+ p('💬', `messages: ${history.length}`),
135
+ p('📊', `usage: prompt=${usage.promptTokens} completion=${usage.completionTokens} total=${usage.totalTokens}`),
136
+ p('📁', `workspace: ${workspaceCwd}`),
137
+ '',
138
+ ].join('\n'),
139
+ )
140
+ }
141
+
142
+
143
+ function supportsAltScreen() {
144
+ if (!process.stdout.isTTY)
145
+ return false
146
+ if (process.env.TERM === 'dumb')
147
+ return false
148
+ if (process.env.GOATCHAIN_NO_ALT_SCREEN === '1')
149
+ return false
150
+ return true
151
+ }
152
+
153
+ function enterAltScreen() {
154
+ if (!supportsAltScreen())
155
+ return false
156
+ // Switch to alternate screen buffer + hide cursor.
157
+ process.stdout.write('\u001B[?1049h\u001B[2J\u001B[H\u001B[?25l')
158
+ return true
159
+ }
160
+
161
+ function exitAltScreen() {
162
+ if (!process.stdout.isTTY)
163
+ return
164
+ // Show cursor + reset attributes + return to normal buffer.
165
+ process.stdout.write('\u001B[?25h\u001B[0m\u001B[?1049l')
166
+ }
167
+
168
+ function editDistance(aRaw, bRaw) {
169
+ const a = String(aRaw ?? '')
170
+ const b = String(bRaw ?? '')
171
+ const n = a.length
172
+ const m = b.length
173
+ if (n === 0)
174
+ return m
175
+ if (m === 0)
176
+ return n
177
+
178
+ const prev = new Array(m + 1)
179
+ const curr = new Array(m + 1)
180
+ for (let j = 0; j <= m; j++)
181
+ prev[j] = j
182
+ for (let i = 1; i <= n; i++) {
183
+ curr[0] = i
184
+ const ai = a.charCodeAt(i - 1)
185
+ for (let j = 1; j <= m; j++) {
186
+ const cost = ai === b.charCodeAt(j - 1) ? 0 : 1
187
+ curr[j] = Math.min(
188
+ prev[j] + 1,
189
+ curr[j - 1] + 1,
190
+ prev[j - 1] + cost,
191
+ )
192
+ }
193
+ for (let j = 0; j <= m; j++)
194
+ prev[j] = curr[j]
195
+ }
196
+ return prev[m]
197
+ }
198
+
199
+ function isCommandToken(token) {
200
+ return /^[a-z0-9][a-z0-9_-]*$/.test(token)
201
+ }
202
+
203
+ export function resolveCommand(cmdRaw) {
204
+ const cmd = String(cmdRaw ?? '').trim().toLowerCase()
205
+ if (!cmd)
206
+ return { kind: 'empty', cmd: '' }
207
+ // Avoid suggesting slash commands for non-command-like inputs (e.g. CJK text),
208
+ // where edit-distance matching produces noisy results.
209
+ if (!isCommandToken(cmd))
210
+ return { kind: 'none', cmd: '' }
211
+ if (KNOWN_COMMANDS.includes(cmd))
212
+ return { kind: 'exact', cmd }
213
+
214
+ const prefix = KNOWN_COMMANDS.filter(c => c.startsWith(cmd))
215
+ if (prefix.length === 1)
216
+ return { kind: 'prefix', cmd: prefix[0], matches: prefix }
217
+ if (prefix.length > 1)
218
+ return { kind: 'ambiguous', cmd: '', matches: prefix }
219
+
220
+ const maxDist = cmd.length <= 4 ? 1 : 2
221
+ const fuzzy = KNOWN_COMMANDS
222
+ .filter(c => c[0] === cmd[0])
223
+ .map(c => ({ c, d: editDistance(cmd, c) }))
224
+ .filter(x => x.d <= maxDist)
225
+ .sort((a, b) => a.d - b.d || a.c.localeCompare(b.c))
226
+ .slice(0, 5)
227
+ .map(x => x.c)
228
+ if (fuzzy.length > 0)
229
+ return { kind: 'fuzzy', cmd: '', matches: fuzzy }
230
+
231
+ if (cmd.length >= 3) {
232
+ const contains = KNOWN_COMMANDS
233
+ .filter(c => c.includes(cmd))
234
+ .slice(0, 5)
235
+ if (contains.length > 0)
236
+ return { kind: 'contains', cmd: '', matches: contains }
237
+ }
238
+
239
+ return { kind: 'none', cmd: '' }
240
+ }
241
+
242
+ function getFilteredSessions(sessions, filter) {
243
+ const f = String(filter ?? '').trim().toLowerCase()
244
+ if (!f)
245
+ return sessions
246
+ return sessions.filter((s) => {
247
+ const hay = `${s.title ?? ''} ${s.summary ?? ''} ${s.sessionId ?? ''}`.toLowerCase()
248
+ return hay.includes(f)
249
+ })
250
+ }
251
+
252
+ function highlightFirst(text, needleRaw) {
253
+ const needle = String(needleRaw ?? '').trim()
254
+ if (!needle)
255
+ return String(text ?? '')
256
+ const s = String(text ?? '')
257
+ const lower = s.toLowerCase()
258
+ const idx = lower.indexOf(needle.toLowerCase())
259
+ if (idx < 0)
260
+ return s
261
+ return `${s.slice(0, idx)}${ansi.highlight(s.slice(idx, idx + needle.length))}${s.slice(idx + needle.length)}`
262
+ }
263
+
264
+ function renderSessionPicker({ sessions, currentSessionId, filter, alt, emoji }) {
265
+ if (alt)
266
+ clearScreen()
267
+ else
268
+ process.stdout.write('\n')
269
+
270
+ const useEmoji = Boolean(emoji) && process.stdout.isTTY && process.env.TERM !== 'dumb'
271
+ const f = String(filter ?? '').trim()
272
+ const filtered = getFilteredSessions(sessions, filter)
273
+
274
+ const cols = process.stdout.isTTY && typeof process.stdout.columns === 'number' && process.stdout.columns > 0
275
+ ? process.stdout.columns
276
+ : 120
277
+ const rows = process.stdout.isTTY && typeof process.stdout.rows === 'number' && process.stdout.rows > 0
278
+ ? process.stdout.rows
279
+ : 24
280
+
281
+ const headerRaw = `${useEmoji ? '🗂️ ' : ''}Sessions ${filtered.length}/${sessions.length} filter: ${f || '∅'} (# open · text filter · empty cancel)`
282
+ const header = formatOneLine(headerRaw, Math.max(20, cols - 2))
283
+
284
+ const maxShow = Math.min(filtered.length, Math.max(8, rows - 6))
285
+ const indexWidth = String(Math.min(filtered.length, maxShow) || 1).length
286
+ const metaBudget = Math.min(38, Math.max(26, Math.floor(cols * 0.25)))
287
+ const titleBudget = Math.min(52, Math.max(22, Math.floor(cols * 0.32)))
288
+ const summaryBudget = Math.max(0, cols - (6 + indexWidth + metaBudget + titleBudget))
289
+
290
+ const lines = [ansi.header(` ${header} `), '']
291
+ for (let i = 0; i < Math.min(filtered.length, maxShow); i++) {
292
+ const s = filtered[i]
293
+ const isActive = s.sessionId === currentSessionId
294
+ const activeMark = isActive ? ansi.green(useEmoji ? '▶️' : '▶') : ' '
295
+ const pinMark = s.pinned ? ansi.yellow('★') : ' '
296
+ const msgCount = Array.isArray(s.messages) ? s.messages.length : 0
297
+ const titleRaw = formatOneLine(s.title ?? 'New session', titleBudget)
298
+ const title = isActive ? ansi.bold(titleRaw) : highlightFirst(titleRaw, f)
299
+ const when = formatWhen(s.updatedAt)
300
+ const idShort = typeof s.sessionId === 'string' ? s.sessionId.slice(-8) : ''
301
+ const metaRaw = formatOneLine(`(${msgCount} msgs · ${when}${idShort ? ` · ${idShort}` : ''})`, metaBudget)
302
+ const meta = ansi.dim(metaRaw)
303
+
304
+ const summaryRaw = summaryBudget >= 20 ? formatOneLine(s.summary ?? '', summaryBudget) : ''
305
+ const summary = summaryRaw ? ` ${ansi.gray(`— ${summaryRaw}`)}` : ''
306
+ const idx = ansi.yellow(`[${String(i + 1).padStart(indexWidth, ' ')}]`)
307
+ lines.push(`${activeMark}${pinMark} ${idx} ${title} ${meta}${summary}`)
308
+ }
309
+ if (filtered.length > maxShow)
310
+ lines.push(ansi.dim(`… and ${filtered.length - maxShow} more (refine with filter text)`))
311
+
312
+ lines.push('')
313
+ process.stdout.write(`${lines.join('\n')}\n`)
314
+ return { filtered }
315
+ }
316
+
317
+ export async function runRepl(options) {
318
+ const {
319
+ version,
320
+ provider,
321
+ system,
322
+ agentRef,
323
+ modelRef,
324
+ toolsInfo,
325
+ requestDefaults,
326
+ baseUrlRef,
327
+ setBaseUrl,
328
+ setApiKey,
329
+ saveAll,
330
+ listSessions,
331
+ loadSessionById,
332
+ saveSessionByData,
333
+ renameSessionById,
334
+ setPinnedSessionsById,
335
+ deleteSessionById,
336
+ deleteSessionsById,
337
+ uiPrefs,
338
+ switchWorkspace,
339
+ getWorkspaceCwd,
340
+ } = options
341
+
342
+ const useEmoji = () => Boolean(uiPrefs?.emoji) && process.stdout.isTTY && process.env.TERM !== 'dumb'
343
+ const icon = s => (useEmoji() ? `${s} ` : '')
344
+ const label = (e, s) => (useEmoji() ? `${e}\u00A0${s}` : s)
345
+
346
+ const rl = readline.createInterface({
347
+ input: process.stdin,
348
+ output: process.stdout,
349
+ prompt: '> ',
350
+ completer: (lineRaw) => {
351
+ const line = String(lineRaw ?? '')
352
+ const trimmed = line.trimStart()
353
+ const isSingleToken = !/\s/.test(trimmed)
354
+ if (!isSingleToken)
355
+ return [[], line]
356
+
357
+ if (trimmed.startsWith('/')) {
358
+ const pref = trimmed.slice(1).toLowerCase()
359
+ const hits = KNOWN_COMMANDS
360
+ .filter(c => c.startsWith(pref))
361
+ .map(c => `/${c}`)
362
+ return [hits, line]
363
+ }
364
+
365
+ if (trimmed.startsWith('@')) {
366
+ const pref = trimmed.toLowerCase()
367
+ const hits = KNOWN_MENTIONS
368
+ .filter(m => m.startsWith(pref))
369
+ .filter(m => !(pref === '@' && m === '@'))
370
+ return [hits, line]
371
+ }
372
+
373
+ return [[], line]
374
+ },
375
+ })
376
+
377
+ let lineQueue = Promise.resolve()
378
+ let uiMode = /** @type {'normal' | 'select_session'} */ ('normal')
379
+ let sessionPicker = /** @type {null | { sessions: any[], filter?: string }} */ (null)
380
+ let sessionPickerAlt = false
381
+ let activeAbort = null
382
+ let busy = false
383
+ let clackDepth = 0
384
+ let commandPaletteInFlight = false
385
+ let exiting = false
386
+ let lastTurnMeta = /** @type {any} */ ({})
387
+ let workspaceFilesCache = /** @type {null | { at: number, files: string[] }} */ (null)
388
+ let workspaceImagesCache = /** @type {null | { at: number, files: string[] }} */ (null)
389
+ /** @type {Array<{ name: string, mime: string, bytes: number, dataUrl: string, source: 'paste' | 'path' }>} */
390
+ let pendingImages = []
391
+ let itermPasteBuffer = ''
392
+ let pasteImageCounter = 0
393
+ let lastClipboardAutoAttachAt = 0
394
+ /** @type {null | { name: string, startedAt: number }} */
395
+ let clipboardAttachInFlight = null
396
+ /** @type {null | { name: string, at: number }} */
397
+ let lastPastedImageName = null
398
+ let bracketedPasteActive = false
399
+ let bracketedPasteBuffer = ''
400
+ /** @type {null | { lines: string[] }} */
401
+ let multiline = null
402
+
403
+ const showInlineHint = (text) => {
404
+ if (!process.stdout.isTTY)
405
+ return
406
+ try {
407
+ rl.output.write(`\n${ansi.dim(text)}\n`)
408
+ rl.prompt(true)
409
+ }
410
+ catch {
411
+ // ignore
412
+ }
413
+ }
414
+
415
+ const useClack = () => {
416
+ const uiMode = (process.env.GOATCHAIN_UI ?? 'auto').toLowerCase()
417
+ return process.stdout.isTTY && uiMode !== 'legacy'
418
+ }
419
+
420
+ const workspaceCwd = () => {
421
+ if (typeof getWorkspaceCwd === 'function') {
422
+ const v = getWorkspaceCwd()
423
+ if (typeof v === 'string' && v.trim())
424
+ return v
425
+ }
426
+ return toolsInfo?.workspaceCwd ?? process.cwd()
427
+ }
428
+
429
+ const withClack = async (fn) => {
430
+ clackDepth++
431
+ const wasPaused = rl.paused
432
+ if (!wasPaused)
433
+ rl.pause()
434
+ try {
435
+ return await fn()
436
+ }
437
+ finally {
438
+ if (!wasPaused)
439
+ rl.resume()
440
+ clackDepth = Math.max(0, clackDepth - 1)
441
+ }
442
+ }
443
+
444
+ const getInlineCompletions = (lineRaw) => {
445
+ const line = String(lineRaw ?? '')
446
+ const trimmed = line.trimStart()
447
+ const isSingleToken = !/\s/.test(trimmed)
448
+ if (!isSingleToken)
449
+ return []
450
+ if (trimmed.startsWith('/')) {
451
+ const pref = trimmed.slice(1).toLowerCase()
452
+ return KNOWN_COMMANDS.filter(c => c.startsWith(pref)).map(c => `/${c}`)
453
+ }
454
+ if (trimmed.startsWith('@')) {
455
+ const pref = trimmed.toLowerCase()
456
+ return KNOWN_MENTIONS.filter(m => m.startsWith(pref))
457
+ .filter(m => !(pref === '@' && m === '@'))
458
+ }
459
+ return []
460
+ }
461
+
462
+ const humanBytes = (n) => {
463
+ const num = Number(n)
464
+ if (!Number.isFinite(num) || num < 0)
465
+ return ''
466
+ if (num < 1024)
467
+ return `${num} B`
468
+ if (num < 1024 * 1024)
469
+ return `${(num / 1024).toFixed(2)} KB`
470
+ return `${(num / (1024 * 1024)).toFixed(2)} MB`
471
+ }
472
+
473
+ const refreshPrompt = () => {
474
+ try {
475
+ rl._refreshLine?.()
476
+ }
477
+ catch {
478
+ // ignore
479
+ }
480
+ }
481
+
482
+ const attachTabCycler = () => {
483
+ if (!process.stdin.isTTY)
484
+ return
485
+ const orig = rl._ttyWrite?.bind(rl)
486
+ if (typeof orig !== 'function')
487
+ return
488
+
489
+ /** @type {{ prefix: string, options: string[], index: number, lastAt: number } | null} */
490
+ let state = null
491
+ let lastHintAt = 0
492
+
493
+ const reset = () => {
494
+ state = null
495
+ }
496
+
497
+ const cycle = () => {
498
+ const now = Date.now()
499
+ const token = rl.line.trimStart()
500
+ if (!token)
501
+ return false
502
+
503
+ if (!state || now - state.lastAt > 2500) {
504
+ const opts = getInlineCompletions(token)
505
+ if (opts.length === 0)
506
+ return false
507
+ state = { prefix: token, options: opts, index: -1, lastAt: now }
508
+ }
509
+ else {
510
+ // Keep cycling based on the original typed prefix (state.prefix),
511
+ // even though rl.line is being replaced with a completion candidate.
512
+ const opts = getInlineCompletions(state.prefix)
513
+ state.options = opts.length > 0 ? opts : state.options
514
+ state.lastAt = now
515
+ }
516
+
517
+ state.index = (state.index + 1) % state.options.length
518
+ const next = state.options[state.index] ?? token
519
+ rl.line = next
520
+ rl.cursor = rl.line.length
521
+ rl._refreshLine?.()
522
+ return true
523
+ }
524
+
525
+ const addPendingImages = (files, source) => {
526
+ if (!Array.isArray(files) || files.length === 0)
527
+ return
528
+ const extFromMime = (mime) => {
529
+ const m = String(mime ?? '')
530
+ if (m === 'image/png')
531
+ return '.png'
532
+ if (m === 'image/jpeg')
533
+ return '.jpg'
534
+ if (m === 'image/webp')
535
+ return '.webp'
536
+ if (m === 'image/gif')
537
+ return '.gif'
538
+ return ''
539
+ }
540
+ for (const f of files) {
541
+ if (!f || typeof f !== 'object')
542
+ continue
543
+ const rawName = typeof f.name === 'string' ? f.name : 'pasted-image'
544
+ const mime = typeof f.mime === 'string' ? f.mime : 'application/octet-stream'
545
+ const bytes = typeof f.bytes === 'number' ? f.bytes : 0
546
+ const dataUrl = typeof f.dataUrl === 'string' ? f.dataUrl : ''
547
+ if (!dataUrl)
548
+ continue
549
+ if (!mime.startsWith('image/')) {
550
+ queueMicrotask(() => showInlineHint(`Ignored non-image paste: ${rawName} (${mime})`))
551
+ continue
552
+ }
553
+ let name = rawName
554
+ if (source === 'paste') {
555
+ const lower = rawName.toLowerCase()
556
+ const duplicate = pendingImages.some(p => p && typeof p.name === 'string' && p.name.toLowerCase() === lower)
557
+ if (!rawName || rawName === 'pasted-image') {
558
+ const ext = extFromMime(mime)
559
+ name = `pasted-image-${++pasteImageCounter}${ext}`
560
+ }
561
+ else if (duplicate) {
562
+ const parsed = path.parse(rawName)
563
+ const ext = parsed.ext || extFromMime(mime)
564
+ const base = parsed.name || rawName
565
+ name = `${base}-${++pasteImageCounter}${ext}`
566
+ }
567
+ }
568
+ pendingImages.push({ name, mime, bytes, dataUrl, source })
569
+ }
570
+ setPromptForMode()
571
+ refreshPrompt()
572
+ }
573
+
574
+ const normalizeStandaloneImageName = (text) => {
575
+ let t = String(text ?? '').trim()
576
+ if (!t)
577
+ return null
578
+ if (t.length > 260)
579
+ return null
580
+ // Bracketed paste wrappers (common on modern terminals).
581
+ t = t.replace(/^\u001B\[200~/, '').replace(/\u001B\[201~$/, '').trim()
582
+ // Strip zero-width characters that sometimes appear in clipboard content.
583
+ t = t.replace(/[\u200B\u200C\u200D\uFEFF]/g, '')
584
+ if (t.includes('\n') || t.includes('\r'))
585
+ return null
586
+ if (t.includes('/') || t.includes('\\'))
587
+ return null
588
+ // Strip common quoting.
589
+ t = t.replace(/^["'`]+|["'`]+$/g, '').trim()
590
+ // Accept (and normalize) accidental spaces around dot: "name .png" -> "name.png".
591
+ const m = t.match(/^(.*?)(?:\s*\.\s*)(png|jpe?g|webp|gif|bmp|tiff?|heic)$/i)
592
+ if (!m)
593
+ return null
594
+ const base = String(m[1] ?? '').trim()
595
+ const ext = String(m[2] ?? '').trim().toLowerCase()
596
+ if (!base)
597
+ return null
598
+ return `${base}.${ext}`
599
+ }
600
+
601
+ const stripTokenOnceFromLine = (token) => {
602
+ const t = String(rl.line ?? '')
603
+ const tok = String(token ?? '').trim()
604
+ if (!tok)
605
+ return false
606
+ const idx = t.indexOf(tok)
607
+ if (idx < 0)
608
+ return false
609
+ const before = idx === 0 ? '' : t[idx - 1]
610
+ const after = idx + tok.length >= t.length ? '' : t[idx + tok.length]
611
+ const okBefore = !before || /\s|[,,。.!??::;;、()[\]{}"'“”‘’]/.test(before)
612
+ // If token is pasted at the start, allow stripping even if the user immediately continues typing.
613
+ const okAfter = idx === 0 || !after || /\s|[,,。.!??::;;、()[\]{}"'“”‘’]/.test(after)
614
+ if (!okBefore || !okAfter)
615
+ return false
616
+ const next = `${t.slice(0, idx)} ${t.slice(idx + tok.length)}`.replace(/\s{2,}/g, ' ').trimStart()
617
+ rl.line = next
618
+ rl.cursor = next.length
619
+ return true
620
+ }
621
+
622
+ const stripLeadingImageNameFromLine = () => {
623
+ const t = String(rl.line ?? '')
624
+ const m = t.match(/^\s*(.*?)(?:\s*\.\s*)(png|jpe?g|webp|gif|bmp|tiff?|heic)(?=\s|[,,。.!??::;;、()[\]{}"'“”‘’]|$)\s*/i)
625
+ if (!m)
626
+ return false
627
+ const whole = String(m[0] ?? '')
628
+ if (!whole)
629
+ return false
630
+ rl.line = t.slice(whole.length).trimStart()
631
+ rl.cursor = rl.line.length
632
+ return true
633
+ }
634
+
635
+ const maybeAutoAttachClipboardForPastedName = (pastedName) => {
636
+ const now = Date.now()
637
+ if (now - lastClipboardAutoAttachAt < 700)
638
+ return
639
+ lastClipboardAutoAttachAt = now
640
+
641
+ const nameTok = normalizeStandaloneImageName(pastedName)
642
+ if (!nameTok)
643
+ return
644
+ lastPastedImageName = { name: nameTok, at: Date.now() }
645
+ if (clipboardAttachInFlight)
646
+ return
647
+
648
+ clipboardAttachInFlight = { name: nameTok, startedAt: now }
649
+ setPromptForMode()
650
+ refreshPrompt()
651
+
652
+ queueMicrotask(async () => {
653
+ let didAttach = false
654
+ try {
655
+ const candidates = await readClipboardImageCandidatesDarwin()
656
+ if (!Array.isArray(candidates) || candidates.length === 0) {
657
+ // Clipboard is text-only; try resolving within the current workspace by basename.
658
+ const imgs = await getWorkspaceImages()
659
+ const lower = nameTok.toLowerCase()
660
+ const matches = imgs.filter(p => path.basename(String(p)).toLowerCase() === lower).slice(0, 8)
661
+ if (matches.length === 1) {
662
+ const rel = String(matches[0])
663
+ const abs = ensureAllowedFile(workspaceCwd(), rel)
664
+ const file = await readImageFileAsDataUrl(abs)
665
+ if (file?.dataUrl && String(file.mime).startsWith('image/')) {
666
+ addPendingImages([{
667
+ name: path.basename(rel),
668
+ mime: String(file.mime),
669
+ bytes: Buffer.isBuffer(file.buf) ? file.buf.byteLength : 0,
670
+ dataUrl: String(file.dataUrl),
671
+ }], 'path')
672
+ didAttach = true
673
+ if (stripTokenOnceFromLine(nameTok) || stripLeadingImageNameFromLine())
674
+ rl._refreshLine?.()
675
+ showInlineHint(`Attached image from workspace: ${path.basename(rel)}`)
676
+ }
677
+ }
678
+ if (didAttach)
679
+ return
680
+
681
+ // If user has the file selected in Finder, we can still attach it even when clipboard is text-only.
682
+ const sel = await readFinderSelectionPathsDarwin()
683
+ if (Array.isArray(sel) && sel.length > 0) {
684
+ const picked = sel.find(p => path.basename(String(p)).toLowerCase() === lower) ?? (sel.length === 1 ? sel[0] : null)
685
+ if (picked) {
686
+ const file = await readImageFileAsDataUrl(String(picked))
687
+ if (file?.dataUrl && String(file.mime).startsWith('image/')) {
688
+ addPendingImages([{
689
+ name: path.basename(String(picked)),
690
+ mime: String(file.mime),
691
+ bytes: Buffer.isBuffer(file.buf) ? file.buf.byteLength : 0,
692
+ dataUrl: String(file.dataUrl),
693
+ }], 'paste')
694
+ didAttach = true
695
+ if (stripTokenOnceFromLine(nameTok) || stripLeadingImageNameFromLine())
696
+ rl._refreshLine?.()
697
+ showInlineHint(`Attached image from Finder selection: ${path.basename(String(picked))}`)
698
+ }
699
+ }
700
+ }
701
+ return
702
+ }
703
+
704
+ const lower = nameTok.toLowerCase()
705
+ const fileCandidates = candidates.filter(c => c?.source === 'file' && c?.absPath)
706
+ const picked = fileCandidates.find(c => path.basename(String(c.absPath)).toLowerCase() === lower)
707
+ ?? (fileCandidates.length === 1 ? fileCandidates[0] : null)
708
+ ?? (candidates.length === 1 ? candidates[0] : null)
709
+ if (!picked)
710
+ return
711
+ if (!picked?.dataUrl || typeof picked.dataUrl !== 'string' || !picked.dataUrl.startsWith('data:image/'))
712
+ return
713
+
714
+ addPendingImages([{
715
+ name: path.basename(String(picked.absPath ?? nameTok)),
716
+ mime: String(picked.mime ?? 'image/png'),
717
+ bytes: Buffer.isBuffer(picked.buf) ? picked.buf.byteLength : 0,
718
+ dataUrl: String(picked.dataUrl),
719
+ }], 'paste')
720
+ didAttach = true
721
+
722
+ if (stripTokenOnceFromLine(nameTok) || stripLeadingImageNameFromLine()) {
723
+ rl._refreshLine?.()
724
+ }
725
+ showInlineHint(`Attached image: ${path.basename(String(picked.absPath ?? nameTok))}`)
726
+ }
727
+ catch {
728
+ // ignore
729
+ }
730
+ finally {
731
+ if (!didAttach) {
732
+ // If we saw something that looks like an image filename but couldn't attach, hint why.
733
+ queueMicrotask(() => showInlineHint(`${icon('💡')}Tip: clipboard is text-only. In Finder, select the file (not rename text) then Cmd+C; or drag & drop; or use @image.`))
734
+ }
735
+ clipboardAttachInFlight = null
736
+ setPromptForMode()
737
+ refreshPrompt()
738
+ }
739
+ })
740
+ }
741
+
742
+ rl._ttyWrite = (s, key) => {
743
+ if (key?.ctrl && key?.name === 'o') {
744
+ if (clackDepth > 0)
745
+ return
746
+ if (busy) {
747
+ queueMicrotask(() => showInlineHint('busy (wait for current turn to finish)'))
748
+ return
749
+ }
750
+ queueMicrotask(() => {
751
+ void openAttachmentsFlow()
752
+ })
753
+ return
754
+ }
755
+
756
+ if (key?.ctrl && key?.name === 'g') {
757
+ if (pendingImages.length > 0) {
758
+ pendingImages = []
759
+ itermPasteBuffer = ''
760
+ clipboardAttachInFlight = null
761
+ setPromptForMode()
762
+ refreshPrompt()
763
+ queueMicrotask(() => showInlineHint('Cleared pending attachments.'))
764
+ return
765
+ }
766
+ }
767
+
768
+ // Normalize bracketed paste: \x1b[200~ ... \x1b[201~
769
+ // Some terminals emit the wrappers separately / across multiple chunks.
770
+ if (typeof s === 'string' && (bracketedPasteActive || s.includes('\u001B[200~') || s.includes('\u001B[201~'))) {
771
+ reset()
772
+ const beforeLine = rl.line
773
+ const BP_START = '\u001B[200~'
774
+ const BP_END = '\u001B[201~'
775
+ let chunk = s
776
+ let forward = ''
777
+
778
+ while (chunk.length > 0) {
779
+ if (!bracketedPasteActive) {
780
+ const startIdx = chunk.indexOf(BP_START)
781
+ if (startIdx === -1) {
782
+ forward += chunk
783
+ chunk = ''
784
+ break
785
+ }
786
+ forward += chunk.slice(0, startIdx)
787
+ chunk = chunk.slice(startIdx + BP_START.length)
788
+ bracketedPasteActive = true
789
+ bracketedPasteBuffer = ''
790
+ continue
791
+ }
792
+
793
+ const endIdx = chunk.indexOf(BP_END)
794
+ if (endIdx === -1) {
795
+ bracketedPasteBuffer += chunk
796
+ forward += chunk
797
+ chunk = ''
798
+ break
799
+ }
800
+
801
+ const pastePart = chunk.slice(0, endIdx)
802
+ bracketedPasteBuffer += pastePart
803
+ forward += pastePart
804
+ chunk = chunk.slice(endIdx + BP_END.length)
805
+ bracketedPasteActive = false
806
+
807
+ // Paste finished: try auto-attach from clipboard/Finder/workspace using the full pasted content.
808
+ if (String(beforeLine ?? '').trim() === '') {
809
+ const tok = normalizeStandaloneImageName(bracketedPasteBuffer)
810
+ if (tok)
811
+ maybeAutoAttachClipboardForPastedName(tok)
812
+ }
813
+ bracketedPasteBuffer = ''
814
+ }
815
+
816
+ if (forward)
817
+ return orig(forward, key)
818
+ return
819
+ }
820
+
821
+ // Handle iTerm2 image paste: \x1b]1337;File=...:base64\x07
822
+ if (!key && typeof s === 'string') {
823
+ reset()
824
+ const beforeLine = rl.line
825
+ const extracted = extractIterm2FilePastes(s, itermPasteBuffer)
826
+ itermPasteBuffer = extracted.buffer
827
+ if (Array.isArray(extracted.files) && extracted.files.length > 0) {
828
+ queueMicrotask(() => addPendingImages(extracted.files, 'paste'))
829
+ }
830
+ // Only forward non-OSC text. If we are buffering an incomplete OSC sequence, suppress output.
831
+ if (extracted.text && (!itermPasteBuffer || extracted.text.trim().length > 0)) {
832
+ const forwarded = extracted.text
833
+ const res = orig(forwarded, key)
834
+ // If a standalone image filename got pasted on an empty line, try turning it into a clipboard attachment.
835
+ if (String(beforeLine ?? '').trim() === '') {
836
+ const tok = normalizeStandaloneImageName(forwarded)
837
+ if (tok)
838
+ maybeAutoAttachClipboardForPastedName(tok)
839
+ }
840
+ return res
841
+ }
842
+ if (itermPasteBuffer)
843
+ return
844
+ }
845
+
846
+ // Some terminals provide paste chunks with a key object; try to detect standalone image names anyway.
847
+ if (typeof s === 'string' && s.length > 1 && !key?.ctrl && !key?.meta && !key?.alt) {
848
+ reset()
849
+ const beforeLine = rl.line
850
+ const res = orig(s, key)
851
+ if (String(beforeLine ?? '').trim() === '') {
852
+ const tok = normalizeStandaloneImageName(s)
853
+ if (tok)
854
+ maybeAutoAttachClipboardForPastedName(tok)
855
+ }
856
+ return res
857
+ }
858
+
859
+ if (key?.ctrl && key?.name === 'k') {
860
+ if (clackDepth > 0 || commandPaletteInFlight)
861
+ return
862
+ if (busy) {
863
+ queueMicrotask(() => showInlineHint('busy (wait for current turn to finish)'))
864
+ return
865
+ }
866
+ queueMicrotask(() => {
867
+ void openCommandPalette()
868
+ })
869
+ return
870
+ }
871
+ // Hint when user starts a mention.
872
+ if (s === '@' && rl.line.trim() === '' && Date.now() - lastHintAt > 2000) {
873
+ lastHintAt = Date.now()
874
+ queueMicrotask(() => showInlineHint(`${icon('💡')}Mentions: @continue @sessions @workspace @file @image/@img @clear @model @settings (Tab to cycle)`))
875
+ }
876
+
877
+ // Hint when user starts a slash command.
878
+ if (s === '/' && rl.line.trim() === '' && Date.now() - lastHintAt > 2000) {
879
+ lastHintAt = Date.now()
880
+ queueMicrotask(() => showInlineHint(`${icon('💡')}Commands: /help /sessions /workspace /files /images /settings /output /diag /edit /multiline /model /set … (Tab to cycle)`))
881
+ }
882
+
883
+ if (key?.name === 'tab') {
884
+ if (cycle())
885
+ return
886
+ // fall through to default completion if we didn't handle it
887
+ }
888
+ else if (key?.name !== 'shift' && key?.name !== 'ctrl' && key?.name !== 'alt') {
889
+ reset()
890
+ }
891
+
892
+ return orig(s, key)
893
+ }
894
+ }
895
+
896
+ attachTabCycler()
897
+
898
+ const setPromptForMode = () => {
899
+ const base = uiMode === 'select_session'
900
+ ? 'session> '
901
+ : multiline
902
+ ? '... '
903
+ : '> '
904
+ if (uiMode !== 'select_session' && (pendingImages.length > 0 || clipboardAttachInFlight || multiline) && process.stdout.isTTY) {
905
+ const maxShow = 4
906
+ const parts = pendingImages.slice(0, maxShow).map((img, idx) => {
907
+ const size = img.bytes ? ` (${humanBytes(img.bytes)})` : ''
908
+ return `[${idx + 1}] ${img.name}${size}`
909
+ })
910
+ const more = pendingImages.length > maxShow ? ` …(+${pendingImages.length - maxShow})` : ''
911
+ const attaching = clipboardAttachInFlight ? `attaching: ${clipboardAttachInFlight.name}…` : ''
912
+ const header = pendingImages.length > 0
913
+ ? `images: ${parts.join(' ')}${more}`
914
+ : attaching
915
+ const hint = multiline ? 'multiline: .send · .cancel' : 'Ctrl+O manage · Ctrl+G clear'
916
+ const extra = multiline && pendingImages.length > 0 ? 'Ctrl+O manage · Ctrl+G clear' : ''
917
+ const line1 = header || hint
918
+ const line2 = header ? hint : extra
919
+ rl.setPrompt(line2 ? `${line1}\n${line2}\n${base}` : `${line1}\n${base}`)
920
+ return
921
+ }
922
+ rl.setPrompt(base)
923
+ }
924
+ setPromptForMode()
925
+
926
+ const leaveSessionPickerUi = () => {
927
+ if (sessionPickerAlt) {
928
+ exitAltScreen()
929
+ sessionPickerAlt = false
930
+ }
931
+ }
932
+
933
+ const exit = () => {
934
+ if (activeAbort)
935
+ activeAbort.abort('cli_exit')
936
+ leaveSessionPickerUi()
937
+ exiting = true
938
+ void saveAll().finally(() => rl.close())
939
+ }
940
+
941
+ process.on('SIGINT', () => {
942
+ if (activeAbort) {
943
+ activeAbort.abort('sigint')
944
+ return
945
+ }
946
+ process.stdout.write('\n')
947
+ exit()
948
+ })
949
+
950
+ rl.on('close', () => {
951
+ exiting = true
952
+ void lineQueue
953
+ .finally(() => saveAll())
954
+ .finally(() => {
955
+ process.stdout.write('\n')
956
+ process.exit(0)
957
+ })
958
+ })
959
+
960
+ const handleSlash = async (trimmed) => {
961
+ const [cmdRaw, ...rest] = trimmed.trim().split(/\s+/)
962
+ const cmdInput = cmdRaw.slice(1).toLowerCase()
963
+ let cmd = cmdInput
964
+ const arg = rest.join(' ').trim()
965
+
966
+ const agent = agentRef.get()
967
+ const model = modelRef.get()
968
+
969
+ const resetToNewSession = async () => {
970
+ options.session.sessionId = randomUUID()
971
+ options.session.createdAt = Date.now()
972
+ options.session.title = undefined
973
+ options.session.summary = undefined
974
+ options.session.pinned = false
975
+ options.session.history = []
976
+ await saveAll()
977
+ process.stdout.write(`${ansi.green(`${icon('🆕')}new session:`)} ${options.session.sessionId}\n`)
978
+ }
979
+
980
+ const useSessionById = async (id) => {
981
+ if (!id) {
982
+ process.stdout.write(`${icon('❌')}Missing sessionId (try /sessions)\n`)
983
+ return false
984
+ }
985
+ const s = await loadSessionById(id)
986
+ if (!s) {
987
+ process.stdout.write(`${icon('❌')}Session not found: ${id}\n`)
988
+ return false
989
+ }
990
+ leaveSessionPickerUi()
991
+ options.session.sessionId = s.sessionId
992
+ options.session.createdAt = s.createdAt ?? Date.now()
993
+ options.session.title = s.title
994
+ options.session.summary = s.summary
995
+ options.session.pinned = Boolean(s.pinned)
996
+ options.session.history = Array.isArray(s.messages) ? s.messages : []
997
+ if (s.modelId && typeof model?.setModelId === 'function')
998
+ model.setModelId(s.modelId)
999
+ await saveAll()
1000
+ uiMode = 'normal'
1001
+ sessionPicker = null
1002
+ setPromptForMode()
1003
+ process.stdout.write('\n')
1004
+ process.stdout.write(`${ansi.green(`${icon('🗂️')}using session:`)} ${options.session.sessionId}\n`)
1005
+ if (options.session.history.length > 0) {
1006
+ const title = options.session.title ?? deriveSessionTitle(options.session.history)
1007
+ const summary = options.session.summary ?? deriveSessionSummary(options.session.history)
1008
+ process.stdout.write(`${ansi.bold('Title:')} ${title}\n`)
1009
+ if (summary)
1010
+ process.stdout.write(`${ansi.bold('Summary:')} ${summary}\n`)
1011
+ printHistory(options.session.history, { maxMessages: 12, maxCharsPerMessage: 400, emoji: uiPrefs?.emoji })
1012
+ }
1013
+ return true
1014
+ }
1015
+
1016
+ const resolved = resolveCommand(cmdInput)
1017
+ if (resolved.kind === 'prefix')
1018
+ cmd = resolved.cmd
1019
+ else if (resolved.kind === 'ambiguous') {
1020
+ process.stdout.write(`${icon('❓')}ambiguous command: ${cmdRaw} (did you mean ${resolved.matches.map(m => `/${m}`).join(', ')}?)\n`)
1021
+ return
1022
+ }
1023
+
1024
+ if (cmd === 'help') {
1025
+ await openCommandPalette()
1026
+ return
1027
+ }
1028
+
1029
+ if (cmd === 'multiline' || cmd === 'ml') {
1030
+ if (busy) {
1031
+ process.stdout.write(`${icon('⏳')}busy (Ctrl+C to cancel)\n`)
1032
+ return
1033
+ }
1034
+ if (uiMode === 'select_session') {
1035
+ process.stdout.write(`${icon('ℹ️')}Exit session picker first.\n`)
1036
+ return
1037
+ }
1038
+ multiline = { lines: [] }
1039
+ setPromptForMode()
1040
+ showInlineHint('Multiline mode: type .send to send, .cancel to cancel.')
1041
+ return
1042
+ }
1043
+
1044
+ if (cmd === 'edit') {
1045
+ if (busy) {
1046
+ process.stdout.write(`${icon('⏳')}busy (Ctrl+C to cancel)\n`)
1047
+ return
1048
+ }
1049
+ if (uiMode === 'select_session') {
1050
+ process.stdout.write(`${icon('ℹ️')}Exit session picker first.\n`)
1051
+ return
1052
+ }
1053
+ await openEditorFlow()
1054
+ return
1055
+ }
1056
+
1057
+ if (cmd === 'exit' || cmd === 'quit') {
1058
+ exit()
1059
+ return
1060
+ }
1061
+
1062
+ if (cmd === 'tools') {
1063
+ const list = typeof agent?.tools?.list === 'function' ? agent.tools.list() : []
1064
+ process.stdout.write(`${icon('🧰')}${list.map(t => t.name).join(', ')}\n`)
1065
+ return
1066
+ }
1067
+
1068
+ if (cmd === 'status') {
1069
+ printStatus({
1070
+ version,
1071
+ provider,
1072
+ modelId: model.modelId,
1073
+ sessionId: options.session.sessionId,
1074
+ history: options.session.history,
1075
+ agent,
1076
+ baseUrl: baseUrlRef.get(),
1077
+ requestDefaults,
1078
+ workspaceCwd: workspaceCwd(),
1079
+ emoji: uiPrefs?.emoji,
1080
+ })
1081
+ return
1082
+ }
1083
+
1084
+ if (cmd === 'model') {
1085
+ if (!arg) {
1086
+ process.stdout.write(`${icon('🤖')}model: ${provider}/${model.modelId}\n`)
1087
+ return
1088
+ }
1089
+ if (typeof model?.setModelId === 'function')
1090
+ model.setModelId(arg)
1091
+ await saveAll()
1092
+ process.stdout.write(`${icon('🤖')}model set: ${provider}/${model.modelId}\n`)
1093
+ return
1094
+ }
1095
+
1096
+ if (cmd === 'params') {
1097
+ const cfg = requestDefaults ?? {}
1098
+ const cfgParts = [
1099
+ typeof cfg.maxOutputTokens === 'number' ? `maxTokens=${cfg.maxOutputTokens}` : undefined,
1100
+ typeof cfg.temperature === 'number' ? `temperature=${cfg.temperature}` : undefined,
1101
+ typeof cfg.topP === 'number' ? `topP=${cfg.topP}` : undefined,
1102
+ typeof cfg.presencePenalty === 'number' ? `presencePenalty=${cfg.presencePenalty}` : undefined,
1103
+ typeof cfg.frequencyPenalty === 'number' ? `frequencyPenalty=${cfg.frequencyPenalty}` : undefined,
1104
+ typeof cfg.seed === 'number' ? `seed=${cfg.seed}` : undefined,
1105
+ typeof cfg.timeoutMs === 'number' ? `timeoutMs=${cfg.timeoutMs}` : undefined,
1106
+ ].filter(Boolean)
1107
+ process.stdout.write(`${icon('⚙️')}${cfgParts.join(' ')}\n`)
1108
+ return
1109
+ }
1110
+
1111
+ if (cmd === 'set') {
1112
+ const [kRaw, ...vParts] = arg.split(/\s+/)
1113
+ const vRaw = vParts.join(' ')
1114
+ const parsed = parseParamValue(kRaw, vRaw)
1115
+ if (!parsed.ok) {
1116
+ process.stdout.write(`${icon('❌')}${parsed.error} (try /help)\n`)
1117
+ return
1118
+ }
1119
+ const k = normalizeParamKey(kRaw)
1120
+ requestDefaults[k] = parsed.value
1121
+ await saveAll()
1122
+ process.stdout.write(`${icon('⚙️')}set ${k}=${parsed.value}\n`)
1123
+ return
1124
+ }
1125
+
1126
+ if (cmd === 'unset') {
1127
+ const k = normalizeParamKey(arg)
1128
+ if (!k) {
1129
+ process.stdout.write(`${icon('❌')}Missing key (try /help)\n`)
1130
+ return
1131
+ }
1132
+ if (!(k in requestDefaults)) {
1133
+ process.stdout.write(`${icon('❌')}unknown key: ${arg}\n`)
1134
+ return
1135
+ }
1136
+ delete requestDefaults[k]
1137
+ await saveAll()
1138
+ process.stdout.write(`${icon('⚙️')}unset ${k}\n`)
1139
+ return
1140
+ }
1141
+
1142
+ if (cmd === 'api-key' || cmd === 'key') {
1143
+ if (!arg) {
1144
+ process.stdout.write(`${icon('❌')}Missing key (try /help)\n`)
1145
+ return
1146
+ }
1147
+ setApiKey(arg)
1148
+ if (typeof model?.resetClient === 'function')
1149
+ model.resetClient()
1150
+ await saveAll()
1151
+ process.stdout.write(`${icon('🔑')}api key set\n`)
1152
+ return
1153
+ }
1154
+
1155
+ if (cmd === 'base-url') {
1156
+ if (!arg) {
1157
+ process.stdout.write(`${icon('❌')}Missing url (try /help)\n`)
1158
+ return
1159
+ }
1160
+ await setBaseUrl(arg)
1161
+ process.stdout.write(`${icon('🌐')}baseUrl set: ${baseUrlRef.get() ?? ''}\n`)
1162
+ return
1163
+ }
1164
+
1165
+ if (cmd === 'save') {
1166
+ await saveAll()
1167
+ process.stdout.write(`${icon('💾')}saved\n`)
1168
+ return
1169
+ }
1170
+
1171
+ if (cmd === 'workspace') {
1172
+ await openWorkspaceFlow()
1173
+ return
1174
+ }
1175
+
1176
+ if (cmd === 'files') {
1177
+ await openFilesFlow()
1178
+ return
1179
+ }
1180
+
1181
+ if (cmd === 'images') {
1182
+ await openImagesFlow()
1183
+ return
1184
+ }
1185
+
1186
+ if (cmd === 'settings') {
1187
+ await openSettingsFlow()
1188
+ return
1189
+ }
1190
+
1191
+ if (cmd === 'output') {
1192
+ await openOutputFlow()
1193
+ return
1194
+ }
1195
+
1196
+ if (cmd === 'diag' || cmd === 'diagnostics') {
1197
+ await openDiagnosticsFlow()
1198
+ return
1199
+ }
1200
+
1201
+ if (cmd === 'clipboard') {
1202
+ const candidates = await readClipboardImageCandidatesDarwin()
1203
+ const types = await readClipboardTypesDarwin()
1204
+ const txt = await readClipboardTextDarwin()
1205
+ const sel = await readFinderSelectionPathsDarwin()
1206
+ const one = String(txt ?? '').split(/\r?\n/).find(Boolean) ?? ''
1207
+ if (types)
1208
+ process.stdout.write(`${icon('📋')}clipboard types: ${types}\n`)
1209
+ if (one)
1210
+ process.stdout.write(`${icon('📋')}clipboard text: ${formatOneLine(one, 120)}\n`)
1211
+ if (Array.isArray(sel) && sel.length > 0) {
1212
+ process.stdout.write(`${icon('🗂️')}finder selection: ${sel.slice(0, 3).map(p => path.basename(String(p))).join(', ')}\n`)
1213
+ }
1214
+
1215
+ if (!Array.isArray(candidates) || candidates.length === 0) {
1216
+ process.stdout.write(`${icon('📋')}clipboard: no attachable image found\n`)
1217
+ process.stdout.write(`${icon('💡')}tip: clipboard is text-only. If you have the file selected in Finder, try pasting again (CLI can use Finder selection); otherwise drag & drop or use @image.\n`)
1218
+ return
1219
+ }
1220
+ process.stdout.write(`${icon('🖼️')}clipboard images: ${candidates.length} candidate(s)\n`)
1221
+ for (const c of candidates.slice(0, 6)) {
1222
+ const src = c?.source ? String(c.source) : 'unknown'
1223
+ const p = c?.absPath ? path.basename(String(c.absPath)) : ''
1224
+ const mime = c?.mime ? String(c.mime) : ''
1225
+ const bytes = c?.buf && Buffer.isBuffer(c.buf) ? c.buf.byteLength : (typeof c?.bytes === 'number' ? c.bytes : 0)
1226
+ process.stdout.write(`- ${src}${p ? ` ${p}` : ''}${mime ? ` ${mime}` : ''}${bytes ? ` (${humanBytes(bytes)})` : ''}\n`)
1227
+ }
1228
+ if (candidates.length > 6)
1229
+ process.stdout.write(`… (+${candidates.length - 6})\n`)
1230
+ return
1231
+ }
1232
+
1233
+ if (cmd === 'new') {
1234
+ await resetToNewSession()
1235
+ leaveSessionPickerUi()
1236
+ uiMode = 'normal'
1237
+ sessionPicker = null
1238
+ setPromptForMode()
1239
+ return
1240
+ }
1241
+
1242
+ if (cmd === 'sessions') {
1243
+ const sessions = await listSessions()
1244
+ if (sessions.length === 0) {
1245
+ process.stdout.write(`${icon('ℹ️')}[no sessions]\n`)
1246
+ return
1247
+ }
1248
+
1249
+ const uiPref = (uiPrefs?.sessionsUi ?? process.env.GOATCHAIN_SESSIONS_UI ?? 'auto').toLowerCase()
1250
+ const canClack = process.stdout.isTTY && uiPref !== 'legacy' && useClack()
1251
+ if (canClack) {
1252
+ await withClack(async () => {
1253
+ const actionRes = await selectWithClack({
1254
+ message: 'Sessions',
1255
+ options: [
1256
+ { value: 'open', label: label('🗂️', 'Open a session') },
1257
+ { value: 'rename', label: label('✏️', 'Rename a session…') },
1258
+ { value: 'pin', label: label('📌', 'Pin session(s)…') },
1259
+ { value: 'unpin', label: label('📍', 'Unpin session(s)…') },
1260
+ { value: 'export', label: label('📤', 'Export session(s)…') },
1261
+ { value: 'import', label: label('📥', 'Import session(s) from JSON…') },
1262
+ { value: 'delete', label: label('🗑️', 'Delete session(s)…') },
1263
+ ],
1264
+ initialValue: 0,
1265
+ })
1266
+ if (!actionRes.ok)
1267
+ return
1268
+
1269
+ const cols = process.stdout.isTTY && typeof process.stdout.columns === 'number' && process.stdout.columns > 0
1270
+ ? process.stdout.columns
1271
+ : 120
1272
+ const titleBudget = Math.max(18, Math.min(52, Math.floor(cols * 0.4)))
1273
+ const summaryBudget = Math.max(0, cols - (8 + titleBudget + 22))
1274
+
1275
+ const optionsList = sessions.slice(0, 120).map((s) => {
1276
+ const msgCount = Array.isArray(s.messages) ? s.messages.length : 0
1277
+ const pin = s.pinned ? '★ ' : ''
1278
+ const title = `${pin}${formatOneLine(s.title ?? 'New session', Math.max(6, titleBudget - pin.length))}`
1279
+ const when = formatWhen(s.updatedAt)
1280
+ const idShort = typeof s.sessionId === 'string' ? s.sessionId.slice(-8) : ''
1281
+ const meta = formatOneLine(`${msgCount} msgs · ${when}${idShort ? ` · ${idShort}` : ''}`, 26)
1282
+ const summary = summaryBudget >= 18 ? formatOneLine(s.summary ?? '', summaryBudget) : ''
1283
+ const label = `${title} (${meta})${summary ? ` — ${summary}` : ''}`
1284
+ return { value: s.sessionId, label }
1285
+ })
1286
+
1287
+ if (actionRes.value === 'open') {
1288
+ const initialValue = sessions.findIndex(s => s.sessionId === options.session.sessionId)
1289
+ const res = await selectWithClack({
1290
+ message: 'Select a session',
1291
+ options: optionsList,
1292
+ initialValue: initialValue >= 0 ? initialValue : 0,
1293
+ })
1294
+ if (res.ok)
1295
+ await useSessionById(String(res.value))
1296
+ return
1297
+ }
1298
+
1299
+ if (actionRes.value === 'rename') {
1300
+ if (!renameSessionById) {
1301
+ process.stdout.write(`${icon('❌')}Rename is not configured (missing renameSessionById)\n`)
1302
+ return
1303
+ }
1304
+ const initialValue = sessions.findIndex(s => s.sessionId === options.session.sessionId)
1305
+ const picked = await selectWithClack({
1306
+ message: 'Select a session to rename',
1307
+ options: optionsList,
1308
+ initialValue: initialValue >= 0 ? initialValue : 0,
1309
+ })
1310
+ if (!picked.ok)
1311
+ return
1312
+
1313
+ const id = String(picked.value)
1314
+ const current = sessions.find(s => s.sessionId === id)
1315
+ const txt = await textWithClack({ message: 'New title', placeholder: current?.title ?? 'New session' })
1316
+ if (!txt.ok)
1317
+ return
1318
+ const title = String(txt.value ?? '').trim()
1319
+ await renameSessionById(id, title)
1320
+ if (id === options.session.sessionId) {
1321
+ options.session.title = title
1322
+ await saveAll()
1323
+ }
1324
+ process.stdout.write(`${ansi.green(`${icon('✏️')}renamed:`)} ${id}\n`)
1325
+ return
1326
+ }
1327
+
1328
+ if (actionRes.value === 'pin' || actionRes.value === 'unpin') {
1329
+ if (!setPinnedSessionsById) {
1330
+ process.stdout.write(`${icon('❌')}Pin is not configured (missing setPinnedSessionsById)\n`)
1331
+ return
1332
+ }
1333
+ const pick = await multiselectWithClack({
1334
+ message: actionRes.value === 'pin' ? 'Select sessions to pin' : 'Select sessions to unpin',
1335
+ options: optionsList,
1336
+ required: true,
1337
+ })
1338
+ if (!pick.ok)
1339
+ return
1340
+ const ids = Array.isArray(pick.value) ? pick.value.map(String) : []
1341
+ if (ids.length === 0)
1342
+ return
1343
+ const pinned = actionRes.value === 'pin'
1344
+ await setPinnedSessionsById(ids, pinned)
1345
+ if (ids.includes(options.session.sessionId)) {
1346
+ options.session.pinned = pinned
1347
+ await saveAll()
1348
+ }
1349
+ process.stdout.write(`${ansi.green(`${icon(pinned ? '📌' : '📍')}${pinned ? 'pinned:' : 'unpinned:'}`)} ${ids.length} session(s)\n`)
1350
+ return
1351
+ }
1352
+
1353
+ if (actionRes.value === 'export') {
1354
+ const pick = await multiselectWithClack({
1355
+ message: 'Select sessions to export',
1356
+ options: optionsList,
1357
+ required: true,
1358
+ })
1359
+ if (!pick.ok)
1360
+ return
1361
+ const ids = Array.isArray(pick.value) ? pick.value.map(String) : []
1362
+ if (ids.length === 0)
1363
+ return
1364
+
1365
+ const fmtRes = await selectWithClack({
1366
+ message: 'Export format',
1367
+ options: [
1368
+ { value: 'json', label: 'JSON (bundle)' },
1369
+ { value: 'md', label: 'Markdown' },
1370
+ ],
1371
+ initialValue: 0,
1372
+ })
1373
+ if (!fmtRes.ok)
1374
+ return
1375
+ const fmt = String(fmtRes.value)
1376
+
1377
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
1378
+ const defaultName = `sessions-export-${stamp}.${fmt === 'md' ? 'md' : 'json'}`
1379
+ const outRes = await textWithClack({ message: 'Output file (workspace-relative)', placeholder: defaultName })
1380
+ if (!outRes.ok)
1381
+ return
1382
+ const relOut = String(outRes.value ?? '').trim() || defaultName
1383
+ const absOut = path.resolve(workspaceCwd(), relOut)
1384
+ const rel = path.relative(workspaceCwd(), absOut)
1385
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
1386
+ process.stdout.write(`${icon('❌')}Export path must be inside the workspace\n`)
1387
+ return
1388
+ }
1389
+
1390
+ const confirmRes = await confirmWithClack({ message: `Write ${relOut}?`, initialValue: true })
1391
+ if (!confirmRes.ok || !confirmRes.value)
1392
+ return
1393
+
1394
+ const selected = selectSessionsById(sessions, ids)
1395
+ if (fmt === 'md') {
1396
+ await writeFile(absOut, sessionsToMarkdown(selected), 'utf8')
1397
+ }
1398
+ else {
1399
+ const bundle = buildSessionsExportBundle(selected)
1400
+ await writeFile(absOut, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8')
1401
+ }
1402
+
1403
+ process.stdout.write(`${ansi.green(`${icon('📤')}exported:`)} ${ids.length} session(s) -> ${relOut}\n`)
1404
+ return
1405
+ }
1406
+
1407
+ if (actionRes.value === 'import') {
1408
+ if (!saveSessionByData) {
1409
+ process.stdout.write(`${icon('❌')}Import is not configured (missing saveSessionByData)\n`)
1410
+ return
1411
+ }
1412
+ const inRes = await textWithClack({ message: 'JSON file to import (workspace-relative)', placeholder: 'sessions-export.json' })
1413
+ if (!inRes.ok)
1414
+ return
1415
+ const relIn = String(inRes.value ?? '').trim()
1416
+ if (!relIn)
1417
+ return
1418
+ const absIn = path.resolve(workspaceCwd(), relIn)
1419
+ const rel = path.relative(workspaceCwd(), absIn)
1420
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
1421
+ process.stdout.write(`${icon('❌')}Import path must be inside the workspace\n`)
1422
+ return
1423
+ }
1424
+ let parsed
1425
+ try {
1426
+ parsed = JSON.parse(await readFile(absIn, 'utf8'))
1427
+ }
1428
+ catch (err) {
1429
+ process.stdout.write(`${icon('❌')}Failed to read JSON: ${err instanceof Error ? err.message : String(err)}\n`)
1430
+ return
1431
+ }
1432
+
1433
+ const rawSessions = parseSessionsImportJson(parsed)
1434
+
1435
+ if (rawSessions.length === 0) {
1436
+ process.stdout.write(`${icon('ℹ️')}No sessions found in JSON\n`)
1437
+ return
1438
+ }
1439
+
1440
+ const conflictRes = await selectWithClack({
1441
+ message: 'On sessionId conflict',
1442
+ options: [
1443
+ { value: 'new_ids', label: 'Create new IDs' },
1444
+ { value: 'overwrite', label: 'Overwrite' },
1445
+ { value: 'skip', label: 'Skip conflicts' },
1446
+ ],
1447
+ initialValue: 0,
1448
+ })
1449
+ if (!conflictRes.ok)
1450
+ return
1451
+ const conflict = String(conflictRes.value)
1452
+
1453
+ const plan = await planSessionImports(rawSessions, {
1454
+ conflict,
1455
+ exists: async (id) => Boolean(await loadSessionById(id)),
1456
+ makeId: () => randomUUID(),
1457
+ })
1458
+
1459
+ for (const item of plan) {
1460
+ // eslint-disable-next-line no-await-in-loop
1461
+ await saveSessionByData(item.session)
1462
+ }
1463
+
1464
+ process.stdout.write(`${ansi.green(`${icon('📥')}imported:`)} ${plan.length} session(s)\n`)
1465
+ return
1466
+ }
1467
+
1468
+ if (actionRes.value === 'delete') {
1469
+ if (!deleteSessionsById && !deleteSessionById) {
1470
+ process.stdout.write(`${icon('❌')}Delete is not configured (missing deleteSessionsById)\n`)
1471
+ return
1472
+ }
1473
+
1474
+ const pick = await multiselectWithClack({
1475
+ message: 'Select sessions to delete',
1476
+ options: optionsList,
1477
+ required: true,
1478
+ })
1479
+ if (!pick.ok)
1480
+ return
1481
+
1482
+ const ids = Array.isArray(pick.value) ? pick.value.map(String) : []
1483
+ if (ids.length === 0)
1484
+ return
1485
+
1486
+ const confirmRes = await confirmWithClack({
1487
+ message: `Delete ${ids.length} session(s)? This cannot be undone.`,
1488
+ initialValue: false,
1489
+ })
1490
+ if (!confirmRes.ok || !confirmRes.value)
1491
+ return
1492
+
1493
+ if (deleteSessionsById) {
1494
+ await deleteSessionsById(ids)
1495
+ }
1496
+ else {
1497
+ for (const id of ids) {
1498
+ // eslint-disable-next-line no-await-in-loop
1499
+ await deleteSessionById(id)
1500
+ }
1501
+ }
1502
+
1503
+ process.stdout.write(`${ansi.yellow(`${icon('🗑️')}deleted:`)} ${ids.length} session(s)\n`)
1504
+
1505
+ if (ids.includes(options.session.sessionId)) {
1506
+ const remaining = await listSessions()
1507
+ if (remaining.length > 0)
1508
+ await useSessionById(remaining[0].sessionId)
1509
+ else
1510
+ await resetToNewSession()
1511
+ }
1512
+ return
1513
+ }
1514
+ })
1515
+ return
1516
+ }
1517
+
1518
+ uiMode = 'select_session'
1519
+ sessionPicker = { sessions, filter: '' }
1520
+ if (!sessionPickerAlt)
1521
+ sessionPickerAlt = enterAltScreen()
1522
+ setPromptForMode()
1523
+ renderSessionPicker({
1524
+ sessions,
1525
+ currentSessionId: options.session.sessionId,
1526
+ filter: '',
1527
+ alt: sessionPickerAlt,
1528
+ emoji: uiPrefs?.emoji,
1529
+ })
1530
+ return
1531
+ }
1532
+
1533
+ if (cmd === 'rm' || cmd === 'delete') {
1534
+ const ids = arg.split(/\s+/).filter(Boolean)
1535
+ if (ids.length === 0) {
1536
+ process.stdout.write(`${icon('ℹ️')}Usage: /rm <sessionId...>\n`)
1537
+ return
1538
+ }
1539
+ if (!deleteSessionsById && !deleteSessionById) {
1540
+ process.stdout.write(`${icon('❌')}Delete is not configured\n`)
1541
+ return
1542
+ }
1543
+ if (useClack() && process.stdout.isTTY) {
1544
+ const uniqueIds = [...new Set(ids)]
1545
+ const previews = await Promise.all(
1546
+ uniqueIds.map(async (id) => {
1547
+ try {
1548
+ const s = await loadSessionById(id)
1549
+ if (!s)
1550
+ return { id, missing: true }
1551
+ const msgs = Array.isArray(s.messages) ? s.messages : []
1552
+ const title = s.title ?? deriveSessionTitle(msgs)
1553
+ const summary = s.summary ?? deriveSessionSummary(msgs)
1554
+ return { id, title, summary, missing: false }
1555
+ }
1556
+ catch {
1557
+ return { id, missing: true }
1558
+ }
1559
+ }),
1560
+ )
1561
+ process.stdout.write(`${ansi.bold('About to delete:')}\n`)
1562
+ for (const p of previews.slice(0, 12)) {
1563
+ if (p.missing) {
1564
+ process.stdout.write(`- ${p.id} (not found)\n`)
1565
+ continue
1566
+ }
1567
+ const title = p.title ? formatOneLine(String(p.title), 80) : ''
1568
+ process.stdout.write(`- ${p.id}${title ? ` — ${title}` : ''}\n`)
1569
+ }
1570
+ if (previews.length > 12)
1571
+ process.stdout.write(`… (+${previews.length - 12})\n`)
1572
+ const confirmRes = await withClack(() => confirmWithClack({
1573
+ message: `Delete ${uniqueIds.length} session(s)? This cannot be undone.`,
1574
+ initialValue: false,
1575
+ }))
1576
+ if (!confirmRes.ok || !confirmRes.value)
1577
+ return
1578
+ }
1579
+ else {
1580
+ const q = `Delete ${ids.length} session(s)? [y/N] `
1581
+ const ans = await new Promise(resolve => rl.question(q, resolve))
1582
+ const ok = String(ans ?? '').trim().toLowerCase()
1583
+ if (!(ok === 'y' || ok === 'yes'))
1584
+ return
1585
+ }
1586
+ if (deleteSessionsById)
1587
+ await deleteSessionsById(ids)
1588
+ else {
1589
+ for (const id of ids) {
1590
+ // eslint-disable-next-line no-await-in-loop
1591
+ await deleteSessionById(id)
1592
+ }
1593
+ }
1594
+ process.stdout.write(`${ansi.yellow(`${icon('🗑️')}deleted:`)} ${ids.length} session(s)\n`)
1595
+ if (ids.includes(options.session.sessionId)) {
1596
+ const remaining = await listSessions()
1597
+ if (remaining.length > 0)
1598
+ await useSessionById(remaining[0].sessionId)
1599
+ else
1600
+ await resetToNewSession()
1601
+ }
1602
+ return
1603
+ }
1604
+
1605
+ if (cmd === 'use') {
1606
+ await useSessionById(arg)
1607
+ return
1608
+ }
1609
+
1610
+ const suggestion = resolveCommand(cmdInput)
1611
+ if (suggestion.kind === 'fuzzy' || suggestion.kind === 'contains') {
1612
+ process.stdout.write(`${icon('❓')}unknown command: ${cmdRaw} (did you mean ${suggestion.matches.map(m => `/${m}`).join(', ')}?)\n`)
1613
+ return
1614
+ }
1615
+ process.stdout.write(`${icon('❓')}unknown command: ${cmdRaw} (try /help)\n`)
1616
+ }
1617
+
1618
+ process.stdout.write(`${icon('💡')}Type /help for commands. Ctrl+K for palette. Ctrl+C to cancel/exit.\n`)
1619
+ if (options.session.history.length > 0) {
1620
+ const title = options.session.title ?? deriveSessionTitle(options.session.history)
1621
+ const summary = options.session.summary ?? deriveSessionSummary(options.session.history)
1622
+ process.stdout.write(`${icon('🗂️')}Restored session: ${options.session.sessionId}\n`)
1623
+ process.stdout.write(`Title: ${title}\n`)
1624
+ if (summary)
1625
+ process.stdout.write(`Summary: ${summary}\n`)
1626
+ printHistory(options.session.history, { maxMessages: 8, maxCharsPerMessage: 240, emoji: uiPrefs?.emoji })
1627
+ process.stdout.write('\n')
1628
+ }
1629
+ rl.prompt()
1630
+
1631
+ const rewriteAllFileMentions = (text, mode) => {
1632
+ const m = String(mode ?? 'summary').trim().toLowerCase()
1633
+ if (m !== 'path' && m !== 'summary' && m !== 'full')
1634
+ return String(text ?? '')
1635
+ return String(text ?? '').replace(/@file(?:\((?:path|summary|full)\))?:/g, `@file(${m}):`)
1636
+ }
1637
+
1638
+ const runChatTurn = async (inputText) => {
1639
+ busy = true
1640
+ activeAbort = new AbortController()
1641
+ const rawInput = String(inputText ?? '').trim()
1642
+ let input = rawInput
1643
+ let pushed = false
1644
+
1645
+ const stripTokenOnce = (text, token) => {
1646
+ const t = String(text ?? '')
1647
+ const tok = String(token ?? '').trim()
1648
+ if (!tok)
1649
+ return t
1650
+ // remove the first occurrence when it is token-delimited by whitespace or punctuation
1651
+ const idx = t.indexOf(tok)
1652
+ if (idx < 0)
1653
+ return t
1654
+ const before = idx === 0 ? '' : t[idx - 1]
1655
+ const after = idx + tok.length >= t.length ? '' : t[idx + tok.length]
1656
+ const okBefore = !before || /\s|[,,。.!??::;;、()[\]{}"'“”‘’]/.test(before)
1657
+ const okAfter = !after || /\s|[,,。.!??::;;、()[\]{}"'“”‘’]/.test(after)
1658
+ if (!okBefore || !okAfter)
1659
+ return t
1660
+ return `${t.slice(0, idx)} ${t.slice(idx + tok.length)}`.replace(/\s{2,}/g, ' ').trim()
1661
+ }
1662
+
1663
+ const maybeImageNameToken = () => {
1664
+ // heuristic: a single filename-ish token with image extension (no slashes), allow accidental spaces around dot.
1665
+ const m = rawInput.match(/(^|\s)([^\s/\\]+?)(?:\s*\.\s*)(png|jpg|jpeg|webp|gif|bmp|tiff?|heic)(?=\s|[,,。.!??::;;、()[\]{}"'“”‘’]|$)/i)
1666
+ if (!m)
1667
+ return null
1668
+ const base = String(m[2] ?? '').trim()
1669
+ const ext = String(m[3] ?? '').trim().toLowerCase()
1670
+ if (!base || !ext)
1671
+ return null
1672
+ // avoid triggering when user is already using explicit markers
1673
+ if (rawInput.includes('@image') || rawInput.includes('@img'))
1674
+ return null
1675
+ return `${base}.${ext}`
1676
+ }
1677
+
1678
+ // If the user pasted an image but only a filename got inserted, try to attach from clipboard.
1679
+ if (pendingImages.length === 0) {
1680
+ const tok = maybeImageNameToken()
1681
+ if (tok) {
1682
+ const candidates = await readClipboardImageCandidatesDarwin()
1683
+ const lower = tok.toLowerCase()
1684
+ const candidate = (Array.isArray(candidates) && candidates.length > 0)
1685
+ ? (candidates.find(c => c?.absPath && path.basename(String(c.absPath)).toLowerCase() === lower) ?? candidates[0])
1686
+ : null
1687
+ if (!candidate) {
1688
+ if (process.stdout.isTTY) {
1689
+ process.stdout.write(ansi.dim('(no image found in clipboard; send text only — use cmd+v in iTerm2 or @image to attach)\n'))
1690
+ }
1691
+ }
1692
+ else if (typeof candidate.dataUrl === 'string' && candidate.dataUrl.startsWith('data:image/')) {
1693
+ let allow = true
1694
+ if (process.stdout.isTTY) {
1695
+ if (useClack()) {
1696
+ await withClack(async () => {
1697
+ const res = await confirmWithClack({
1698
+ message: `Attach image from clipboard as “${tok}”?`,
1699
+ initialValue: true,
1700
+ })
1701
+ allow = Boolean(res.ok && res.value)
1702
+ })
1703
+ }
1704
+ else {
1705
+ const ans = await new Promise(resolve => rl.question(`Attach image from clipboard as "${tok}"? [Y/n] `, resolve))
1706
+ const ok = String(ans ?? '').trim().toLowerCase()
1707
+ allow = ok === '' || ok === 'y' || ok === 'yes'
1708
+ }
1709
+ }
1710
+ if (allow) {
1711
+ pendingImages.push({
1712
+ name: tok,
1713
+ mime: candidate.mime,
1714
+ bytes: Buffer.isBuffer(candidate.buf) ? candidate.buf.byteLength : 0,
1715
+ dataUrl: candidate.dataUrl,
1716
+ source: 'paste',
1717
+ })
1718
+ input = stripTokenOnce(input, tok)
1719
+ setPromptForMode()
1720
+ refreshPrompt()
1721
+ }
1722
+ }
1723
+ }
1724
+ }
1725
+
1726
+ if (input.includes('@file')) {
1727
+ try {
1728
+ let expanded = await expandFileMentions(workspaceCwd(), input, {
1729
+ maxTotalBytes: 220_000,
1730
+ maxBytesPerFile: 120_000,
1731
+ maxBytesPerFileSummary: 40_000,
1732
+ headLines: 80,
1733
+ tailLines: 30,
1734
+ })
1735
+
1736
+ const omitted = Array.isArray(expanded.omitted) ? expanded.omitted : []
1737
+ const hasBudgetOmit = omitted.some(o => o && o.reason === 'budget')
1738
+ const files = Array.isArray(expanded.files) ? expanded.files : []
1739
+ const hasFullTrunc = files.some(f => f && f.mode === 'full' && f.truncated)
1740
+
1741
+ if ((hasBudgetOmit || hasFullTrunc) && process.stdout.isTTY) {
1742
+ let decision = /** @type {'proceed'|'summary'|'path'|'cancel'} */ ('proceed')
1743
+ if (useClack()) {
1744
+ await withClack(async () => {
1745
+ const res = await selectWithClack({
1746
+ message: 'File attachments exceed budget',
1747
+ options: [
1748
+ { value: 'proceed', label: 'Proceed (some may be truncated/omitted)' },
1749
+ { value: 'summary', label: 'Downgrade to Summary' },
1750
+ { value: 'path', label: 'Paths only (no content)' },
1751
+ { value: 'cancel', label: 'Cancel send' },
1752
+ ],
1753
+ initialValue: 1,
1754
+ })
1755
+ if (!res.ok) {
1756
+ decision = 'cancel'
1757
+ return
1758
+ }
1759
+ decision = /** @type {any} */ (String(res.value))
1760
+ })
1761
+ }
1762
+ else {
1763
+ const ans = await new Promise(resolve => rl.question('Attachments exceed budget. Use Summary instead? [Y/n] ', resolve))
1764
+ const ok = String(ans ?? '').trim().toLowerCase()
1765
+ decision = ok === '' || ok === 'y' || ok === 'yes' ? 'summary' : 'proceed'
1766
+ }
1767
+
1768
+ if (decision === 'cancel') {
1769
+ busy = false
1770
+ activeAbort = null
1771
+ return
1772
+ }
1773
+
1774
+ if (decision === 'summary') {
1775
+ const rewritten = rewriteAllFileMentions(input, 'summary')
1776
+ expanded = await expandFileMentions(workspaceCwd(), rewritten, {
1777
+ maxTotalBytes: 220_000,
1778
+ maxBytesPerFile: 120_000,
1779
+ maxBytesPerFileSummary: 40_000,
1780
+ headLines: 80,
1781
+ tailLines: 30,
1782
+ })
1783
+ }
1784
+ else if (decision === 'path') {
1785
+ const rewritten = rewriteAllFileMentions(input, 'path')
1786
+ expanded = await expandFileMentions(workspaceCwd(), rewritten, {
1787
+ maxTotalBytes: 220_000,
1788
+ maxBytesPerFile: 120_000,
1789
+ maxBytesPerFileSummary: 40_000,
1790
+ headLines: 80,
1791
+ tailLines: 30,
1792
+ })
1793
+ }
1794
+ }
1795
+
1796
+ input = expanded.text
1797
+ const fileCount = Array.isArray(expanded.files) ? expanded.files.length : 0
1798
+ if (fileCount > 0)
1799
+ process.stdout.write(ansi.dim(`(${icon('📎')}attached ${fileCount} file(s))\n`))
1800
+ }
1801
+ catch (err) {
1802
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1803
+ busy = false
1804
+ activeAbort = null
1805
+ return
1806
+ }
1807
+ }
1808
+
1809
+ const hasImageMarkers = input.includes('@image') || input.includes('@img')
1810
+ const hasPendingImages = pendingImages.length > 0
1811
+
1812
+ /** @type {any} */
1813
+ let modelInput = input
1814
+ /** @type {string} */
1815
+ let historyContent = input
1816
+
1817
+ const consumedPending = hasPendingImages ? [...pendingImages] : []
1818
+
1819
+ if (hasImageMarkers || hasPendingImages) {
1820
+ try {
1821
+ let cleanedText = input
1822
+ /** @type {any[]} */
1823
+ let blocks = []
1824
+ /** @type {Array<{ relPath?: string, name?: string }>} */
1825
+ let markerImages = []
1826
+
1827
+ if (hasImageMarkers) {
1828
+ const expanded = await expandImageMentions(input)
1829
+ cleanedText = expanded.cleanedText
1830
+ markerImages = Array.isArray(expanded.images) ? expanded.images : []
1831
+ blocks = Array.isArray(expanded.blocks)
1832
+ ? expanded.blocks
1833
+ : (cleanedText ? [{ type: 'text', text: cleanedText }] : [])
1834
+ }
1835
+ else {
1836
+ blocks = cleanedText ? [{ type: 'text', text: cleanedText }] : []
1837
+ }
1838
+
1839
+ // Add pending images (from paste or other sources) without polluting the stored history.
1840
+ for (const img of consumedPending) {
1841
+ if (!img?.dataUrl)
1842
+ continue
1843
+ blocks.push({ type: 'image', source: { data: img.dataUrl } })
1844
+ }
1845
+
1846
+ const names = [
1847
+ ...markerImages.map(i => String(i?.relPath ?? i?.name ?? '').trim()).filter(Boolean),
1848
+ ...consumedPending.map(i => String(i?.name ?? '').trim()).filter(Boolean),
1849
+ ]
1850
+
1851
+ if (names.length > 0) {
1852
+ historyContent = [
1853
+ cleanedText,
1854
+ '[attached images]',
1855
+ ...names.map(n => `- ${n}`),
1856
+ ].filter(Boolean).join('\n')
1857
+ }
1858
+ else {
1859
+ historyContent = cleanedText
1860
+ }
1861
+
1862
+ modelInput = blocks.length > 0 ? blocks : cleanedText
1863
+
1864
+ const count = names.length
1865
+ if (count > 0)
1866
+ process.stdout.write(ansi.dim(`(${icon('🖼️')}attached ${count} image(s))\n`))
1867
+ }
1868
+ catch (err) {
1869
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1870
+ busy = false
1871
+ activeAbort = null
1872
+ return
1873
+ }
1874
+ }
1875
+
1876
+ // Persist a compact history (no base64), but send full multimodal content to the model.
1877
+ options.session.history.push({ role: 'user', content: historyContent })
1878
+ pushed = true
1879
+
1880
+ const prevPendingImages = pendingImages
1881
+ if (consumedPending.length > 0) {
1882
+ pendingImages = []
1883
+ setPromptForMode()
1884
+ }
1885
+
1886
+ try {
1887
+ const { messagesToAppend, meta } = await runTurnWithApprovals({
1888
+ agent: agentRef.get(),
1889
+ sessionId: options.session.sessionId,
1890
+ history: options.session.history.slice(0, -1),
1891
+ input: modelInput,
1892
+ signal: activeAbort.signal,
1893
+ approvalStrategy: 'high_risk',
1894
+ autoApprove: process.env.GOATCHAIN_AUTO_APPROVE === '1',
1895
+ askApproval: (q) => new Promise(resolve => rl.question(q, resolve)),
1896
+ askApprovalForTool: useClack() && process.stdout.isTTY
1897
+ ? async ({ toolName, riskLevel, display }) => {
1898
+ const risk = riskLevel ? ` (${riskLevel})` : ''
1899
+ const lines = display?.lines && Array.isArray(display.lines) ? display.lines : []
1900
+ process.stdout.write(`\n${ansi.bold('Approval required:')} ${String(toolName ?? 'tool')}${risk}\n`)
1901
+ for (const line of lines.slice(0, 10))
1902
+ process.stdout.write(`${ansi.dim(`- ${line}`)}\n`)
1903
+ if (lines.length > 10)
1904
+ process.stdout.write(`${ansi.dim(`… (+${lines.length - 10})`)}\n`)
1905
+ const res = await withClack(() => selectWithClack({
1906
+ message: 'Allow this tool?',
1907
+ options: [
1908
+ { value: 'approve', label: label('✅', 'Approve (once)') },
1909
+ { value: 'approve_all', label: label('🧷', 'Approve all tools (this turn)') },
1910
+ { value: 'deny', label: label('❌', 'Deny') },
1911
+ { value: 'abort', label: label('🛑', 'Abort turn') },
1912
+ ],
1913
+ initialValue: 2,
1914
+ }))
1915
+ if (!res.ok)
1916
+ return 'deny'
1917
+ return String(res.value)
1918
+ }
1919
+ : undefined,
1920
+ showTools: typeof uiPrefs?.showTools === 'boolean' ? uiPrefs.showTools : undefined,
1921
+ toolStyle: typeof uiPrefs?.toolStyle === 'string' ? uiPrefs.toolStyle : undefined,
1922
+ showStatus: typeof uiPrefs?.showStatus === 'boolean' ? uiPrefs.showStatus : undefined,
1923
+ showStatusLine: typeof uiPrefs?.showStatusLine === 'boolean' ? uiPrefs.showStatusLine : undefined,
1924
+ emoji: typeof uiPrefs?.emoji === 'boolean' ? uiPrefs.emoji : undefined,
1925
+ promptContinue: typeof uiPrefs?.promptContinue === 'boolean' ? uiPrefs.promptContinue : undefined,
1926
+ autoContinue: typeof uiPrefs?.autoContinue === 'boolean' ? uiPrefs.autoContinue : undefined,
1927
+ })
1928
+ lastTurnMeta = meta ?? lastTurnMeta
1929
+ options.session.history.push(...messagesToAppend)
1930
+ await saveAll()
1931
+ }
1932
+ catch (err) {
1933
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
1934
+ if (pushed)
1935
+ options.session.history.pop()
1936
+ if (consumedPending.length > 0) {
1937
+ pendingImages = prevPendingImages
1938
+ setPromptForMode()
1939
+ }
1940
+ }
1941
+ finally {
1942
+ busy = false
1943
+ activeAbort = null
1944
+ }
1945
+ }
1946
+
1947
+ const openEditorFlow = async () => {
1948
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi'
1949
+ const { tmpdir } = await import('node:os')
1950
+ const { spawnSync } = await import('node:child_process')
1951
+ const { mkdtemp, readFile, rm, writeFile } = await import('node:fs/promises')
1952
+
1953
+ const quote = (s) => {
1954
+ if (process.platform === 'win32')
1955
+ return `"${String(s).replace(/"/g, '\\"')}"`
1956
+ return `'${String(s).replace(/'/g, `'\\''`)}'`
1957
+ }
1958
+
1959
+ const dir = await mkdtemp(path.join(tmpdir(), 'goatchain-edit-'))
1960
+ const filePath = path.join(dir, 'message.md')
1961
+ await writeFile(filePath, '', 'utf8')
1962
+
1963
+ const wasPaused = rl.paused
1964
+ if (!wasPaused)
1965
+ rl.pause()
1966
+ try {
1967
+ const cmd = `${editor} ${quote(filePath)}`
1968
+ const res = spawnSync(cmd, { stdio: 'inherit', shell: true })
1969
+ if (typeof res.status === 'number' && res.status !== 0) {
1970
+ process.stdout.write(`${icon('❌')}Editor exited with status ${res.status}\n`)
1971
+ return
1972
+ }
1973
+ const text = String(await readFile(filePath, 'utf8') ?? '').replace(/\r\n/g, '\n').trim()
1974
+ if (!text) {
1975
+ showInlineHint('Canceled (empty message).')
1976
+ return
1977
+ }
1978
+ await runChatTurn(text)
1979
+ }
1980
+ finally {
1981
+ if (!wasPaused)
1982
+ rl.resume()
1983
+ await rm(dir, { recursive: true, force: true })
1984
+ }
1985
+ }
1986
+
1987
+ const openModelFlow = async () => {
1988
+ const model = modelRef.get()
1989
+ const current = model?.modelId ? String(model.modelId) : ''
1990
+ const rawPresets = String(process.env.GOATCHAIN_MODEL_PRESETS ?? '')
1991
+ const presets = rawPresets
1992
+ .split(',')
1993
+ .map(s => s.trim())
1994
+ .filter(Boolean)
1995
+ const defaults = ['deepseek-v3.1', 'gpt-4o', 'gpt-4o-mini']
1996
+ const list = [current, ...presets, ...defaults].filter(Boolean)
1997
+ const uniq = [...new Set(list)].slice(0, 20)
1998
+
1999
+ const opts = [
2000
+ ...uniq.map(v => ({ value: v, label: v })),
2001
+ { value: '__custom__', label: 'Custom…' },
2002
+ ]
2003
+ const picked = await selectWithClack({
2004
+ message: 'Select model',
2005
+ options: opts,
2006
+ initialValue: 0,
2007
+ })
2008
+ if (!picked.ok)
2009
+ return
2010
+
2011
+ let nextId = String(picked.value)
2012
+ if (nextId === '__custom__') {
2013
+ const txt = await textWithClack({ message: 'Model id', placeholder: 'e.g. gpt-4o-mini' })
2014
+ if (!txt.ok)
2015
+ return
2016
+ nextId = String(txt.value).trim()
2017
+ }
2018
+ if (!nextId)
2019
+ return
2020
+ if (typeof model?.setModelId === 'function')
2021
+ model.setModelId(nextId)
2022
+ await saveAll()
2023
+ process.stdout.write(`${icon('🤖')}model set: ${provider}/${model.modelId}\n`)
2024
+ }
2025
+
2026
+ const openSettingsFlow = async () => {
2027
+ const model = modelRef.get()
2028
+ if (!useClack()) {
2029
+ if (!process.stdout.isTTY) {
2030
+ process.stdout.write(
2031
+ [
2032
+ 'Settings:',
2033
+ '- /base-url <url>',
2034
+ '- /api-key <key>',
2035
+ '- /set maxTokens <n>',
2036
+ '- Set env GOATCHAIN_AUTO_APPROVE=1 to skip approval prompts.',
2037
+ '',
2038
+ ].join('\n'),
2039
+ )
2040
+ return
2041
+ }
2042
+
2043
+ const ans = await new Promise(resolve => rl.question('Settings: [1] base-url [2] api-key [3] maxTokens [4] autoApprove info (empty cancel): ', resolve))
2044
+ const pick = String(ans ?? '').trim()
2045
+ if (!pick)
2046
+ return
2047
+
2048
+ if (pick === '1' || pick.toLowerCase() === 'base-url') {
2049
+ const val = await new Promise(resolve => rl.question('Base URL: ', resolve))
2050
+ const url = String(val ?? '').trim()
2051
+ if (!url)
2052
+ return
2053
+ await setBaseUrl(url)
2054
+ process.stdout.write(`${icon('🌐')}baseUrl set: ${baseUrlRef.get() ?? ''}\n`)
2055
+ return
2056
+ }
2057
+
2058
+ if (pick === '2' || pick.toLowerCase() === 'api-key') {
2059
+ process.stdout.write('For security, use /api-key <key> (note: your shell history may record it) or set OPENAI_API_KEY.\n')
2060
+ return
2061
+ }
2062
+
2063
+ if (pick === '3' || pick.toLowerCase() === 'maxtokens') {
2064
+ const val = await new Promise(resolve => rl.question(`Max tokens (current: ${String(requestDefaults.maxOutputTokens ?? '') || '∅'}): `, resolve))
2065
+ const n = Number.parseInt(String(val ?? '').trim(), 10)
2066
+ if (!Number.isFinite(n) || n <= 0) {
2067
+ process.stdout.write('Invalid maxTokens\n')
2068
+ return
2069
+ }
2070
+ requestDefaults.maxOutputTokens = n
2071
+ await saveAll()
2072
+ process.stdout.write(`${icon('⚙️')}set maxTokens=${n}\n`)
2073
+ return
2074
+ }
2075
+
2076
+ if (pick === '4' || pick.toLowerCase() === 'autoapprove') {
2077
+ process.stdout.write('Set env GOATCHAIN_AUTO_APPROVE=1 to skip approval prompts.\n')
2078
+ return
2079
+ }
2080
+
2081
+ return
2082
+ }
2083
+
2084
+ const k = await selectWithClack({
2085
+ message: 'Settings',
2086
+ options: [
2087
+ { value: 'baseUrl', label: label('🌐', 'Base URL') },
2088
+ { value: 'apiKey', label: label('🔑', 'API key') },
2089
+ { value: 'maxTokens', label: label('⚙️', 'Max tokens') },
2090
+ { value: 'autoApprove', label: label('🧷', 'Auto approve tools (env)') },
2091
+ ],
2092
+ initialValue: 0,
2093
+ })
2094
+ if (!k.ok)
2095
+ return
2096
+
2097
+ if (k.value === 'baseUrl') {
2098
+ const txt = await textWithClack({ message: 'Base URL', placeholder: baseUrlRef.get() ?? 'https://…/v1' })
2099
+ if (!txt.ok)
2100
+ return
2101
+ const url = String(txt.value).trim()
2102
+ if (!url)
2103
+ return
2104
+ await setBaseUrl(url)
2105
+ process.stdout.write(`${icon('🌐')}baseUrl set: ${baseUrlRef.get() ?? ''}\n`)
2106
+ return
2107
+ }
2108
+
2109
+ if (k.value === 'apiKey') {
2110
+ const pwd = await passwordWithClack({ message: 'API key', placeholder: 'sk-…' })
2111
+ if (!pwd.ok)
2112
+ return
2113
+ const key = String(pwd.value).trim()
2114
+ if (!key)
2115
+ return
2116
+ setApiKey(key)
2117
+ if (typeof model?.resetClient === 'function')
2118
+ model.resetClient()
2119
+ await saveAll()
2120
+ process.stdout.write(`${icon('🔑')}api key set\n`)
2121
+ return
2122
+ }
2123
+
2124
+ if (k.value === 'maxTokens') {
2125
+ const txt = await textWithClack({ message: 'Max tokens', placeholder: String(requestDefaults.maxOutputTokens ?? '') })
2126
+ if (!txt.ok)
2127
+ return
2128
+ const n = Number.parseInt(String(txt.value).trim(), 10)
2129
+ if (!Number.isFinite(n) || n <= 0) {
2130
+ process.stdout.write('Invalid maxTokens\n')
2131
+ return
2132
+ }
2133
+ requestDefaults.maxOutputTokens = n
2134
+ await saveAll()
2135
+ process.stdout.write(`${icon('⚙️')}set maxTokens=${n}\n`)
2136
+ return
2137
+ }
2138
+
2139
+ if (k.value === 'autoApprove') {
2140
+ process.stdout.write('Set env GOATCHAIN_AUTO_APPROVE=1 to skip approval prompts.\n')
2141
+ }
2142
+ }
2143
+
2144
+ const openOutputFlow = async () => {
2145
+ if (!useClack()) {
2146
+ if (!process.stdout.isTTY) {
2147
+ process.stdout.write(
2148
+ [
2149
+ 'Output / UX:',
2150
+ '- Enable clack UI (default) or set GOATCHAIN_UI=legacy to disable.',
2151
+ '- Current settings are persisted in .goatchain config.',
2152
+ '',
2153
+ ].join('\n'),
2154
+ )
2155
+ return
2156
+ }
2157
+
2158
+ process.stdout.write(
2159
+ [
2160
+ 'Output / UX:',
2161
+ `1) showTools: ${uiPrefs?.showTools ? 'on' : 'off'}`,
2162
+ `2) toolStyle: ${uiPrefs?.toolStyle ?? 'inline'}`,
2163
+ `3) statusLine: ${uiPrefs?.showStatusLine ? 'on' : 'off'}`,
2164
+ `4) doneLine: ${uiPrefs?.showStatus ? 'on' : 'off'}`,
2165
+ `5) emoji: ${uiPrefs?.emoji ? 'on' : 'off'}`,
2166
+ `6) promptContinue: ${uiPrefs?.promptContinue ? 'on' : 'off'}`,
2167
+ `7) autoContinue: ${uiPrefs?.autoContinue ? 'on' : 'off'}`,
2168
+ '',
2169
+ ].join('\n'),
2170
+ )
2171
+
2172
+ const ans = await new Promise(resolve => rl.question('Toggle which? (1-7, empty cancel): ', resolve))
2173
+ const pick = String(ans ?? '').trim()
2174
+ if (!pick)
2175
+ return
2176
+
2177
+ if (pick === '1')
2178
+ uiPrefs.showTools = !uiPrefs.showTools
2179
+ else if (pick === '2') {
2180
+ const val = await new Promise(resolve => rl.question('Tool style [inline/box]: ', resolve))
2181
+ const style = String(val ?? '').trim().toLowerCase()
2182
+ if (style !== 'inline' && style !== 'box')
2183
+ return
2184
+ uiPrefs.toolStyle = style
2185
+ }
2186
+ else if (pick === '3')
2187
+ uiPrefs.showStatusLine = !uiPrefs.showStatusLine
2188
+ else if (pick === '4')
2189
+ uiPrefs.showStatus = !uiPrefs.showStatus
2190
+ else if (pick === '5')
2191
+ uiPrefs.emoji = !uiPrefs.emoji
2192
+ else if (pick === '6')
2193
+ uiPrefs.promptContinue = !uiPrefs.promptContinue
2194
+ else if (pick === '7')
2195
+ uiPrefs.autoContinue = !uiPrefs.autoContinue
2196
+ else
2197
+ return
2198
+
2199
+ await saveAll()
2200
+ process.stdout.write(`${icon('💾')}saved\n`)
2201
+ return
2202
+ }
2203
+
2204
+ const pick = await selectWithClack({
2205
+ message: 'Output / UX',
2206
+ options: [
2207
+ { value: 'showTools', label: label('🛠️', `Show tools: ${uiPrefs?.showTools ? 'on' : 'off'}`) },
2208
+ { value: 'toolStyle', label: label('🎛️', `Tool style: ${uiPrefs?.toolStyle ?? 'inline'}`) },
2209
+ { value: 'statusLine', label: label('…', `Status line: ${uiPrefs?.showStatusLine ? 'on' : 'off'}`) },
2210
+ { value: 'doneLine', label: label('✅', `Done line: ${uiPrefs?.showStatus ? 'on' : 'off'}`) },
2211
+ { value: 'emoji', label: label('🙂', `Emoji: ${uiPrefs?.emoji ? 'on' : 'off'}`) },
2212
+ { value: 'promptContinue', label: label('💬', `Prompt continue: ${uiPrefs?.promptContinue ? 'on' : 'off'}`) },
2213
+ { value: 'autoContinue', label: label('⏭️', `Auto continue: ${uiPrefs?.autoContinue ? 'on' : 'off'}`) },
2214
+ ],
2215
+ initialValue: 0,
2216
+ })
2217
+ if (!pick.ok)
2218
+ return
2219
+
2220
+ if (pick.value === 'showTools')
2221
+ uiPrefs.showTools = !uiPrefs.showTools
2222
+ else if (pick.value === 'statusLine')
2223
+ uiPrefs.showStatusLine = !uiPrefs.showStatusLine
2224
+ else if (pick.value === 'doneLine')
2225
+ uiPrefs.showStatus = !uiPrefs.showStatus
2226
+ else if (pick.value === 'emoji')
2227
+ uiPrefs.emoji = !uiPrefs.emoji
2228
+ else if (pick.value === 'promptContinue')
2229
+ uiPrefs.promptContinue = !uiPrefs.promptContinue
2230
+ else if (pick.value === 'autoContinue')
2231
+ uiPrefs.autoContinue = !uiPrefs.autoContinue
2232
+ else if (pick.value === 'toolStyle') {
2233
+ const style = await selectWithClack({
2234
+ message: 'Tool style',
2235
+ options: [
2236
+ { value: 'inline', label: 'inline (compact)' },
2237
+ { value: 'box', label: 'box (verbose)' },
2238
+ ],
2239
+ initialValue: uiPrefs.toolStyle === 'box' ? 1 : 0,
2240
+ })
2241
+ if (!style.ok)
2242
+ return
2243
+ uiPrefs.toolStyle = String(style.value)
2244
+ }
2245
+
2246
+ await saveAll()
2247
+ process.stdout.write(`${icon('💾')}saved\n`)
2248
+ }
2249
+
2250
+ const openDiagnosticsFlow = async () => {
2251
+ process.stdout.write(`${ansi.bold('Last turn:')}\n`)
2252
+ const stop = lastTurnMeta?.stopReason ? String(lastTurnMeta.stopReason) : ''
2253
+ const modelStop = lastTurnMeta?.modelStopReason ? String(lastTurnMeta.modelStopReason) : ''
2254
+ process.stdout.write(`stopReason: ${stop || '∅'}\n`)
2255
+ process.stdout.write(`modelStopReason: ${modelStop || '∅'}\n`)
2256
+ const tools = Array.isArray(lastTurnMeta?.tools) ? lastTurnMeta.tools : []
2257
+ if (tools.length > 0) {
2258
+ process.stdout.write(`${ansi.bold('tools:')}\n`)
2259
+ for (const t of tools.slice(-12)) {
2260
+ const name = t?.toolName ? String(t.toolName) : 'tool'
2261
+ const a = t?.argsSummary ? String(t.argsSummary) : ''
2262
+ const r = t?.resultSummary ? String(t.resultSummary) : ''
2263
+ process.stdout.write(`- ${name}${a ? ` ${a}` : ''}${r ? ` -> ${r}` : ''}\n`)
2264
+ }
2265
+ }
2266
+ process.stdout.write('\n')
2267
+ }
2268
+
2269
+ async function getWorkspaceFiles() {
2270
+ const now = Date.now()
2271
+ if (workspaceFilesCache && now - workspaceFilesCache.at < 30_000)
2272
+ return workspaceFilesCache.files
2273
+ const files = await listWorkspaceFiles(workspaceCwd(), { maxFiles: 8000 })
2274
+ workspaceFilesCache = { at: now, files }
2275
+ return files
2276
+ }
2277
+
2278
+ async function getWorkspaceImages() {
2279
+ const now = Date.now()
2280
+ if (workspaceImagesCache && now - workspaceImagesCache.at < 30_000)
2281
+ return workspaceImagesCache.files
2282
+ const files = await listWorkspaceFiles(workspaceCwd(), { maxFiles: 8000 })
2283
+ const imgs = files.filter((f) => {
2284
+ const lower = String(f).toLowerCase()
2285
+ return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.webp') || lower.endsWith('.gif')
2286
+ })
2287
+ workspaceImagesCache = { at: now, files: imgs }
2288
+ return imgs
2289
+ }
2290
+
2291
+ async function openFilesFlow() {
2292
+ if (!useClack()) {
2293
+ if (process.stdout.isTTY) {
2294
+ const modeAns = await new Promise(resolve => rl.question('Attach mode [path/summary/full] (default summary): ', resolve))
2295
+ const modeRaw = String(modeAns ?? '').trim().toLowerCase()
2296
+ const mode = modeRaw === 'path' || modeRaw === 'full' ? modeRaw : 'summary'
2297
+ const pathsAns = await new Promise(resolve => rl.question('File paths (space-separated, workspace-relative): ', resolve))
2298
+ const rawPaths = String(pathsAns ?? '').trim()
2299
+ if (!rawPaths)
2300
+ return
2301
+ const toks = rawPaths.split(/\s+/).filter(Boolean)
2302
+ const rels = []
2303
+ for (const tok of toks) {
2304
+ try {
2305
+ const abs = ensureAllowedFile(workspaceCwd(), tok)
2306
+ rels.push(path.relative(workspaceCwd(), abs))
2307
+ }
2308
+ catch (err) {
2309
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
2310
+ return
2311
+ }
2312
+ }
2313
+ const markers = rels.map(f => `@file(${mode}):${f}`).join(' ')
2314
+ const applyAns = await new Promise(resolve => rl.question('Apply [i]nsert / [s]end now? (default insert): ', resolve))
2315
+ const apply = String(applyAns ?? '').trim().toLowerCase()
2316
+ if (apply === 's' || apply === 'send') {
2317
+ const msgAns = await new Promise(resolve => rl.question('Message (optional): ', resolve))
2318
+ const line = `${String(msgAns ?? '').trim()} ${markers}`.trim()
2319
+ if (!line)
2320
+ return
2321
+ await runChatTurn(line)
2322
+ return
2323
+ }
2324
+
2325
+ const existing = String(rl.line ?? '').trim()
2326
+ const next = existing ? `${existing} ${markers}` : `${markers} `
2327
+ rl.line = next
2328
+ rl.cursor = rl.line.length
2329
+ rl._refreshLine?.()
2330
+ showInlineHint('Inserted @file markers (send the prompt to attach files).')
2331
+ return
2332
+ }
2333
+
2334
+ process.stdout.write(
2335
+ [
2336
+ 'Attach files:',
2337
+ '- Use @file(summary):path/to/file (default summary)',
2338
+ '- Or run @file to pick files (TTY + clack)',
2339
+ '',
2340
+ ].join('\n'),
2341
+ )
2342
+ return
2343
+ }
2344
+
2345
+ /** @type {null | { kind: 'insert', markers: string } | { kind: 'send', line: string }} */
2346
+ let nextAction = null
2347
+
2348
+ await withClack(async () => {
2349
+ const all = await getWorkspaceFiles()
2350
+ if (all.length === 0) {
2351
+ process.stdout.write('[no files found]\n')
2352
+ return
2353
+ }
2354
+
2355
+ const filterRes = await textWithClack({ message: 'Search files (substring)', placeholder: 'e.g. src/agent or cli/repl' })
2356
+ if (!filterRes.ok)
2357
+ return
2358
+ const filter = String(filterRes.value ?? '').trim().toLowerCase()
2359
+
2360
+ const filtered = filter
2361
+ ? all.filter(f => String(f).toLowerCase().includes(filter))
2362
+ : all
2363
+
2364
+ if (filtered.length === 0) {
2365
+ process.stdout.write('[no matches]\n')
2366
+ return
2367
+ }
2368
+
2369
+ const maxOptions = 240
2370
+ const sliced = filtered.slice(0, maxOptions)
2371
+ const picked = await multiselectWithClack({
2372
+ message: `Select files (${sliced.length}${filtered.length > maxOptions ? `/${filtered.length}` : ''})`,
2373
+ options: sliced.map(f => ({ value: f, label: f })),
2374
+ required: true,
2375
+ })
2376
+ if (!picked.ok)
2377
+ return
2378
+
2379
+ const files = Array.isArray(picked.value) ? picked.value.map(String).filter(Boolean) : []
2380
+ if (files.length === 0)
2381
+ return
2382
+
2383
+ const modeRes = await selectWithClack({
2384
+ message: 'Attach mode',
2385
+ options: [
2386
+ { value: 'path', label: 'Paths only' },
2387
+ { value: 'summary', label: 'Summary (head/tail excerpt)' },
2388
+ { value: 'full', label: 'Full (size-limited)' },
2389
+ ],
2390
+ initialValue: 1,
2391
+ })
2392
+ if (!modeRes.ok)
2393
+ return
2394
+
2395
+ const mode = String(modeRes.value)
2396
+ const markers = files.map(f => `@file(${mode}):${f}`).join(' ')
2397
+
2398
+ const apply = await selectWithClack({
2399
+ message: 'Apply',
2400
+ options: [
2401
+ { value: 'insert', label: 'Insert into prompt' },
2402
+ { value: 'send', label: 'Send now' },
2403
+ ],
2404
+ initialValue: 0,
2405
+ })
2406
+ if (!apply.ok)
2407
+ return
2408
+
2409
+ if (apply.value === 'send') {
2410
+ const msg = await textWithClack({ message: 'Message', placeholder: 'Optional message to send with attachments' })
2411
+ if (!msg.ok)
2412
+ return
2413
+ const line = `${String(msg.value ?? '').trim()} ${markers}`.trim()
2414
+ if (!line)
2415
+ return
2416
+ nextAction = { kind: 'send', line }
2417
+ return
2418
+ }
2419
+
2420
+ nextAction = { kind: 'insert', markers }
2421
+ })
2422
+
2423
+ if (!nextAction)
2424
+ return
2425
+
2426
+ if (nextAction.kind === 'send') {
2427
+ await runChatTurn(nextAction.line)
2428
+ return
2429
+ }
2430
+
2431
+ const existing = String(rl.line ?? '').trim()
2432
+ const next = existing ? `${existing} ${nextAction.markers}` : `${nextAction.markers} `
2433
+ rl.line = next
2434
+ rl.cursor = rl.line.length
2435
+ rl._refreshLine?.()
2436
+ showInlineHint('Inserted @file markers (send the prompt to attach files).')
2437
+ }
2438
+
2439
+ async function openWorkspaceFlow() {
2440
+ if (typeof switchWorkspace !== 'function') {
2441
+ process.stdout.write('Workspace switching is not configured (missing switchWorkspace)\n')
2442
+ return
2443
+ }
2444
+
2445
+ if (!useClack()) {
2446
+ const ans = await new Promise(resolve => rl.question(`Workspace path (current: ${workspaceCwd()}): `, resolve))
2447
+ const raw = String(ans ?? '').trim()
2448
+ if (!raw)
2449
+ return
2450
+ const abs = path.resolve(workspaceCwd(), raw)
2451
+ try {
2452
+ await switchWorkspace(abs)
2453
+ workspaceFilesCache = null
2454
+ workspaceImagesCache = null
2455
+ process.stdout.write(`${ansi.green('workspace:')} ${workspaceCwd()}\n`)
2456
+ }
2457
+ catch (err) {
2458
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
2459
+ }
2460
+ return
2461
+ }
2462
+
2463
+ await withClack(async () => {
2464
+ const txt = await textWithClack({ message: 'Workspace path', placeholder: workspaceCwd() })
2465
+ if (!txt.ok)
2466
+ return
2467
+ const raw = String(txt.value ?? '').trim()
2468
+ if (!raw)
2469
+ return
2470
+ const abs = path.resolve(workspaceCwd(), raw)
2471
+ const ok = await confirmWithClack({ message: `Switch to ${abs}?`, initialValue: true })
2472
+ if (!ok.ok || !ok.value)
2473
+ return
2474
+ try {
2475
+ await switchWorkspace(abs)
2476
+ workspaceFilesCache = null
2477
+ workspaceImagesCache = null
2478
+ process.stdout.write(`${ansi.green('workspace:')} ${workspaceCwd()}\n`)
2479
+ }
2480
+ catch (err) {
2481
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
2482
+ }
2483
+ })
2484
+ }
2485
+
2486
+ function mimeFromPath(p) {
2487
+ const lower = String(p ?? '').toLowerCase()
2488
+ if (lower.endsWith('.png'))
2489
+ return 'image/png'
2490
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
2491
+ return 'image/jpeg'
2492
+ if (lower.endsWith('.webp'))
2493
+ return 'image/webp'
2494
+ if (lower.endsWith('.gif'))
2495
+ return 'image/gif'
2496
+ return 'application/octet-stream'
2497
+ }
2498
+
2499
+ async function expandImageMentions(inputText) {
2500
+ const s = String(inputText ?? '')
2501
+ /** @type {Array<{ raw: string, filePath: string }>} */
2502
+ const mentions = []
2503
+ const re = /@(image|img):([^\s]+)/g
2504
+ let m
2505
+ while ((m = re.exec(s)) != null) {
2506
+ mentions.push({ raw: m[0], filePath: m[2] })
2507
+ }
2508
+ if (mentions.length === 0)
2509
+ return { cleanedText: s.trim(), images: [], blocks: null }
2510
+
2511
+ const uniq = new Map()
2512
+ for (const mm of mentions) {
2513
+ const key = mm.filePath
2514
+ if (!uniq.has(key))
2515
+ uniq.set(key, mm)
2516
+ }
2517
+
2518
+ const images = []
2519
+ for (const mm of uniq.values()) {
2520
+ const abs = ensureAllowedFile(workspaceCwd(), mm.filePath)
2521
+ // eslint-disable-next-line no-await-in-loop
2522
+ const buf = await readFile(abs)
2523
+ const max = 4 * 1024 * 1024
2524
+ if (buf.byteLength > max)
2525
+ throw new Error(`Image too large (${Math.round(buf.byteLength / 1024)} KB): ${mm.filePath}`)
2526
+ const b64 = buf.toString('base64')
2527
+ const mime = mimeFromPath(mm.filePath)
2528
+ const url = `data:${mime};base64,${b64}`
2529
+ images.push({ relPath: path.relative(workspaceCwd(), abs), absPath: abs, mime, bytes: buf.byteLength, url })
2530
+ }
2531
+
2532
+ let cleaned = s
2533
+ for (const mm of mentions) {
2534
+ cleaned = cleaned.replace(mm.raw, '').replace(/\s{2,}/g, ' ')
2535
+ }
2536
+ cleaned = cleaned.trim()
2537
+
2538
+ const blocks = []
2539
+ if (cleaned)
2540
+ blocks.push({ type: 'text', text: cleaned })
2541
+ for (const img of images) {
2542
+ blocks.push({ type: 'image', source: { data: img.url } })
2543
+ }
2544
+
2545
+ return { cleanedText: cleaned, images, blocks }
2546
+ }
2547
+
2548
+ async function openAttachmentsFlow() {
2549
+ if (clipboardAttachInFlight && pendingImages.length === 0) {
2550
+ showInlineHint('Attaching image from clipboard… (try again in a moment)')
2551
+ return
2552
+ }
2553
+ if (pendingImages.length === 0) {
2554
+ const recent = lastPastedImageName && Date.now() - lastPastedImageName.at < 60_000 ? lastPastedImageName : null
2555
+ if (!useClack() || !process.stdout.isTTY) {
2556
+ if (recent)
2557
+ showInlineHint(`No pending attachments. Last pasted: ${recent.name} (use @image, or paste/drag a full path)`)
2558
+ else
2559
+ showInlineHint('No pending attachments.')
2560
+ return
2561
+ }
2562
+
2563
+ await withClack(async () => {
2564
+ const action = await selectWithClack({
2565
+ message: recent ? `Attachments (last pasted: ${recent.name})` : 'Attachments',
2566
+ options: [
2567
+ { value: 'images', label: 'Attach image(s) from workspace…' },
2568
+ { value: 'path', label: 'Attach image by path…' },
2569
+ { value: 'cancel', label: 'Cancel' },
2570
+ ],
2571
+ initialValue: 0,
2572
+ })
2573
+ if (!action.ok || action.value === 'cancel')
2574
+ return
2575
+ if (action.value === 'images') {
2576
+ await openImagesFlow()
2577
+ return
2578
+ }
2579
+ if (action.value === 'path') {
2580
+ const txt = await textWithClack({
2581
+ message: 'Image path',
2582
+ placeholder: '/Users/you/Desktop/image.png (you can drag & drop here)',
2583
+ })
2584
+ if (!txt.ok)
2585
+ return
2586
+ const raw = String(txt.value ?? '').trim()
2587
+ if (!raw)
2588
+ return
2589
+ const abs = raw.startsWith('/') ? raw : ensureAllowedFile(workspaceCwd(), raw)
2590
+ const file = await readImageFileAsDataUrl(abs)
2591
+ if (!file?.dataUrl) {
2592
+ showInlineHint('Not a supported image path.')
2593
+ return
2594
+ }
2595
+ pendingImages.push({
2596
+ name: path.basename(abs),
2597
+ mime: String(file.mime),
2598
+ bytes: Buffer.isBuffer(file.buf) ? file.buf.byteLength : 0,
2599
+ dataUrl: String(file.dataUrl),
2600
+ source: raw.startsWith('/') ? 'paste' : 'path',
2601
+ })
2602
+ setPromptForMode()
2603
+ refreshPrompt()
2604
+ showInlineHint(`Attached image: ${path.basename(abs)}`)
2605
+ }
2606
+ })
2607
+ return
2608
+ }
2609
+
2610
+ if (!useClack()) {
2611
+ const q = `Remove which attachments? (e.g. 1,3) or 'a' to clear all: `
2612
+ const ans = await new Promise(resolve => rl.question(q, resolve))
2613
+ const raw = String(ans ?? '').trim().toLowerCase()
2614
+ if (!raw)
2615
+ return
2616
+ if (raw === 'a' || raw === 'all' || raw === 'clear') {
2617
+ pendingImages = []
2618
+ itermPasteBuffer = ''
2619
+ setPromptForMode()
2620
+ refreshPrompt()
2621
+ showInlineHint('Cleared pending attachments.')
2622
+ return
2623
+ }
2624
+ const nums = raw.split(/[, ]+/).map(x => Number.parseInt(x, 10)).filter(n => Number.isFinite(n))
2625
+ const idxs = new Set(nums.map(n => n - 1).filter(i => i >= 0 && i < pendingImages.length))
2626
+ if (idxs.size === 0)
2627
+ return
2628
+ pendingImages = pendingImages.filter((_, i) => !idxs.has(i))
2629
+ setPromptForMode()
2630
+ refreshPrompt()
2631
+ return
2632
+ }
2633
+
2634
+ await withClack(async () => {
2635
+ const opts = pendingImages.map((img, idx) => {
2636
+ const size = img.bytes ? ` (${humanBytes(img.bytes)})` : ''
2637
+ return { value: String(idx), label: `${img.name}${size}` }
2638
+ })
2639
+ const pick = await multiselectWithClack({
2640
+ message: 'Select attachments to remove',
2641
+ options: opts,
2642
+ required: false,
2643
+ })
2644
+ if (!pick.ok)
2645
+ return
2646
+ const values = Array.isArray(pick.value) ? pick.value.map(String) : []
2647
+ const idxs = new Set(values.map(v => Number.parseInt(v, 10)).filter(n => Number.isFinite(n)))
2648
+ if (idxs.size === 0)
2649
+ return
2650
+ pendingImages = pendingImages.filter((_, i) => !idxs.has(i))
2651
+ setPromptForMode()
2652
+ refreshPrompt()
2653
+ })
2654
+ }
2655
+
2656
+ async function openImagesFlow() {
2657
+ if (!useClack()) {
2658
+ if (process.stdout.isTTY) {
2659
+ const ans = await new Promise(resolve => rl.question('Image paths (space-separated; absolute or workspace-relative): ', resolve))
2660
+ const raw = String(ans ?? '').trim()
2661
+ if (!raw)
2662
+ return
2663
+ const toks = raw.split(/\s+/).filter(Boolean)
2664
+ const toAdd = []
2665
+ for (const tok of toks) {
2666
+ try {
2667
+ const abs = tok.startsWith('/') ? tok : ensureAllowedFile(workspaceCwd(), tok)
2668
+ // eslint-disable-next-line no-await-in-loop
2669
+ const file = await readImageFileAsDataUrl(abs)
2670
+ if (!file?.dataUrl) {
2671
+ showInlineHint('Not a supported image path.')
2672
+ return
2673
+ }
2674
+ toAdd.push({
2675
+ name: path.basename(abs),
2676
+ mime: String(file.mime),
2677
+ bytes: Buffer.isBuffer(file.buf) ? file.buf.byteLength : 0,
2678
+ dataUrl: String(file.dataUrl),
2679
+ source: tok.startsWith('/') ? 'paste' : 'path',
2680
+ })
2681
+ }
2682
+ catch (err) {
2683
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
2684
+ return
2685
+ }
2686
+ }
2687
+ if (toAdd.length === 0)
2688
+ return
2689
+ pendingImages.push(...toAdd)
2690
+ setPromptForMode()
2691
+ refreshPrompt()
2692
+ showInlineHint(`Attached ${toAdd.length} image(s).`)
2693
+
2694
+ const sendAns = await new Promise(resolve => rl.question('Send now? [y/N] ', resolve))
2695
+ const ok = String(sendAns ?? '').trim().toLowerCase()
2696
+ if (ok === 'y' || ok === 'yes') {
2697
+ const msgAns = await new Promise(resolve => rl.question('Message (optional): ', resolve))
2698
+ await runChatTurn(String(msgAns ?? '').trim())
2699
+ }
2700
+ return
2701
+ }
2702
+
2703
+ process.stdout.write(
2704
+ [
2705
+ 'Attach images:',
2706
+ '- Paste from iTerm2 to attach (cmd+v).',
2707
+ '- Or run @image to pick images (TTY + clack).',
2708
+ '- Ctrl+O manages pending attachments.',
2709
+ '',
2710
+ ].join('\n'),
2711
+ )
2712
+ return
2713
+ }
2714
+
2715
+ /** @type {null | { kind: 'attach' } | { kind: 'send', line: string }} */
2716
+ let nextAction = null
2717
+ /** @type {null | Array<{ name: string, mime: string, bytes: number, dataUrl: string, source: 'paste'|'path' }>} */
2718
+ let toAdd = null
2719
+
2720
+ await withClack(async () => {
2721
+ const all = await getWorkspaceImages()
2722
+ if (all.length === 0) {
2723
+ process.stdout.write('[no images found]\n')
2724
+ return
2725
+ }
2726
+
2727
+ const filterRes = await textWithClack({ message: 'Search images (substring)', placeholder: 'e.g. assets/logo.png' })
2728
+ if (!filterRes.ok)
2729
+ return
2730
+ const filter = String(filterRes.value ?? '').trim().toLowerCase()
2731
+
2732
+ const filtered = filter
2733
+ ? all.filter(f => String(f).toLowerCase().includes(filter))
2734
+ : all
2735
+
2736
+ if (filtered.length === 0) {
2737
+ process.stdout.write('[no matches]\n')
2738
+ return
2739
+ }
2740
+
2741
+ const maxOptions = 160
2742
+ const sliced = filtered.slice(0, maxOptions)
2743
+ const picked = await multiselectWithClack({
2744
+ message: `Select images (${sliced.length}${filtered.length > maxOptions ? `/${filtered.length}` : ''})`,
2745
+ options: sliced.map(f => ({ value: f, label: f })),
2746
+ required: true,
2747
+ })
2748
+ if (!picked.ok)
2749
+ return
2750
+
2751
+ const imgs = Array.isArray(picked.value) ? picked.value.map(String).filter(Boolean) : []
2752
+ if (imgs.length === 0)
2753
+ return
2754
+
2755
+ const loaded = []
2756
+ const max = 4 * 1024 * 1024
2757
+ for (const rel of imgs) {
2758
+ const abs = ensureAllowedFile(workspaceCwd(), rel)
2759
+ // eslint-disable-next-line no-await-in-loop
2760
+ const buf = await readFile(abs)
2761
+ if (buf.byteLength > max) {
2762
+ process.stdout.write(`Image too large: ${rel} (${humanBytes(buf.byteLength)})\n`)
2763
+ return
2764
+ }
2765
+ const b64 = buf.toString('base64')
2766
+ const mime = mimeFromPath(rel)
2767
+ const dataUrl = `data:${mime};base64,${b64}`
2768
+ loaded.push({ name: path.relative(workspaceCwd(), abs), mime, bytes: buf.byteLength, dataUrl, source: 'path' })
2769
+ }
2770
+ toAdd = loaded
2771
+
2772
+ const apply = await selectWithClack({
2773
+ message: 'Apply',
2774
+ options: [
2775
+ { value: 'attach', label: 'Attach (keep editing)' },
2776
+ { value: 'send', label: 'Attach and send now' },
2777
+ ],
2778
+ initialValue: 0,
2779
+ })
2780
+ if (!apply.ok)
2781
+ return
2782
+
2783
+ if (apply.value === 'send') {
2784
+ const msg = await textWithClack({ message: 'Message', placeholder: 'Optional message to send with images' })
2785
+ if (!msg.ok)
2786
+ return
2787
+ nextAction = { kind: 'send', line: String(msg.value ?? '').trim() }
2788
+ return
2789
+ }
2790
+
2791
+ nextAction = { kind: 'attach' }
2792
+ })
2793
+
2794
+ if (!nextAction)
2795
+ return
2796
+
2797
+ if (Array.isArray(toAdd) && toAdd.length > 0) {
2798
+ pendingImages.push(...toAdd)
2799
+ setPromptForMode()
2800
+ refreshPrompt()
2801
+ }
2802
+
2803
+ if (nextAction.kind === 'send') {
2804
+ await runChatTurn(nextAction.line)
2805
+ }
2806
+ }
2807
+
2808
+ async function openCommandPalette() {
2809
+ if (commandPaletteInFlight)
2810
+ return
2811
+ commandPaletteInFlight = true
2812
+ try {
2813
+ if (!useClack()) {
2814
+ buildProgram(version).outputHelp()
2815
+ process.stdout.write(getReplHelpText())
2816
+ return
2817
+ }
2818
+ await withClack(async () => {
2819
+ const res = await selectWithClack({
2820
+ message: 'Command palette',
2821
+ options: [
2822
+ { value: 'continue', label: label('▶️', 'Continue (implement now)') },
2823
+ { value: 'edit', label: label('📝', 'Compose message in editor…') },
2824
+ { value: 'multiline', label: label('✍️', 'Multiline input…') },
2825
+ { value: 'sessions', label: label('🗂️', 'Sessions…') },
2826
+ { value: 'workspace', label: label('📁', 'Switch workspace…') },
2827
+ { value: 'files', label: label('📎', 'Attach file(s)…') },
2828
+ { value: 'images', label: label('🖼️', 'Attach image(s)…') },
2829
+ { value: 'model', label: label('🤖', 'Switch model…') },
2830
+ { value: 'settings', label: label('⚙️', 'Settings…') },
2831
+ { value: 'output', label: label('🎛️', 'Output / UX…') },
2832
+ { value: 'diag', label: label('🧾', 'Diagnostics') },
2833
+ ],
2834
+ initialValue: 0,
2835
+ })
2836
+ if (!res.ok)
2837
+ return
2838
+
2839
+ if (res.value === 'continue') {
2840
+ const continuePrompt = '继续。不要只说计划:请立刻开始实现,使用 Write/Edit 创建或修改文件。'
2841
+ await runChatTurn(continuePrompt)
2842
+ return
2843
+ }
2844
+ if (res.value === 'edit') {
2845
+ await openEditorFlow()
2846
+ return
2847
+ }
2848
+ if (res.value === 'multiline') {
2849
+ multiline = { lines: [] }
2850
+ setPromptForMode()
2851
+ showInlineHint('Multiline mode: type .send to send, .cancel to cancel.')
2852
+ return
2853
+ }
2854
+ if (res.value === 'sessions') {
2855
+ await handleSlash('/sessions')
2856
+ return
2857
+ }
2858
+ if (res.value === 'workspace')
2859
+ return openWorkspaceFlow()
2860
+ if (res.value === 'files')
2861
+ return openFilesFlow()
2862
+ if (res.value === 'images')
2863
+ return openImagesFlow()
2864
+ if (res.value === 'model')
2865
+ return openModelFlow()
2866
+ if (res.value === 'settings')
2867
+ return openSettingsFlow()
2868
+ if (res.value === 'output')
2869
+ return openOutputFlow()
2870
+ if (res.value === 'diag')
2871
+ return openDiagnosticsFlow()
2872
+ })
2873
+ }
2874
+ finally {
2875
+ commandPaletteInFlight = false
2876
+ }
2877
+ }
2878
+
2879
+ const handleAt = async (raw) => {
2880
+ const atRaw = String(raw ?? '').trim()
2881
+ if (!atRaw.startsWith('@'))
2882
+ return false
2883
+
2884
+ const direct = atRaw.toLowerCase()
2885
+ if (direct === '@continue') {
2886
+ const continuePrompt = '继续。不要只说计划:请立刻开始实现,使用 Write/Edit 创建或修改文件。'
2887
+ await runChatTurn(continuePrompt)
2888
+ return true
2889
+ }
2890
+
2891
+ if (direct === '@clear') {
2892
+ pendingImages = []
2893
+ itermPasteBuffer = ''
2894
+ clipboardAttachInFlight = null
2895
+ setPromptForMode()
2896
+ refreshPrompt()
2897
+ showInlineHint('Cleared pending attachments.')
2898
+ return true
2899
+ }
2900
+
2901
+ if (direct === '@workspace') {
2902
+ await openWorkspaceFlow()
2903
+ return true
2904
+ }
2905
+
2906
+ if (direct === '@file') {
2907
+ await openFilesFlow()
2908
+ return true
2909
+ }
2910
+
2911
+ if (direct === '@image' || direct === '@img') {
2912
+ await openImagesFlow()
2913
+ return true
2914
+ }
2915
+
2916
+ if (!useClack()) {
2917
+ process.stdout.write(
2918
+ [
2919
+ 'Mentions:',
2920
+ '- @continue (continue and implement now)',
2921
+ '- @clear (clear pending attachments)',
2922
+ '- @workspace (switch workspace root)',
2923
+ '- @file (attach file(s) to the next prompt)',
2924
+ '- @image/@img (attach image(s) to the next prompt)',
2925
+ '- Use /help, /sessions, /model, /set, /base-url, /api-key',
2926
+ '',
2927
+ ].join('\n'),
2928
+ )
2929
+ return true
2930
+ }
2931
+
2932
+ await withClack(async () => {
2933
+ if (direct === '@sessions') {
2934
+ await handleSlash('/sessions')
2935
+ return
2936
+ }
2937
+ if (direct === '@model')
2938
+ return openModelFlow()
2939
+ if (direct === '@settings')
2940
+ return openSettingsFlow()
2941
+
2942
+ const action = await selectWithClack({
2943
+ message: 'Quick actions',
2944
+ options: [
2945
+ { value: 'continue', label: label('▶️', 'Continue (implement now)') },
2946
+ { value: 'edit', label: label('📝', 'Compose message in editor…') },
2947
+ { value: 'multiline', label: label('✍️', 'Multiline input…') },
2948
+ { value: 'clear', label: label('🧹', 'Clear pending attachments') },
2949
+ { value: 'sessions', label: label('🗂️', 'Sessions…') },
2950
+ { value: 'workspace', label: label('📁', 'Switch workspace…') },
2951
+ { value: 'files', label: label('📎', 'Attach file(s)…') },
2952
+ { value: 'images', label: label('🖼️', 'Attach image(s)…') },
2953
+ { value: 'model', label: label('🤖', 'Switch model…') },
2954
+ { value: 'settings', label: label('⚙️', 'Settings…') },
2955
+ { value: 'output', label: label('🎛️', 'Output / UX…') },
2956
+ ],
2957
+ initialValue: 0,
2958
+ })
2959
+ if (!action.ok)
2960
+ return
2961
+
2962
+ if (action.value === 'continue') {
2963
+ const continuePrompt = '继续。不要只说计划:请立刻开始实现,使用 Write/Edit 创建或修改文件。'
2964
+ await runChatTurn(continuePrompt)
2965
+ return
2966
+ }
2967
+ if (action.value === 'edit') {
2968
+ await openEditorFlow()
2969
+ return
2970
+ }
2971
+ if (action.value === 'multiline') {
2972
+ multiline = { lines: [] }
2973
+ setPromptForMode()
2974
+ showInlineHint('Multiline mode: type .send to send, .cancel to cancel.')
2975
+ return
2976
+ }
2977
+ if (action.value === 'clear') {
2978
+ pendingImages = []
2979
+ itermPasteBuffer = ''
2980
+ setPromptForMode()
2981
+ refreshPrompt()
2982
+ showInlineHint('Cleared pending attachments.')
2983
+ return
2984
+ }
2985
+ if (action.value === 'sessions')
2986
+ return handleSlash('/sessions')
2987
+ if (action.value === 'workspace')
2988
+ return openWorkspaceFlow()
2989
+ if (action.value === 'files')
2990
+ return openFilesFlow()
2991
+ if (action.value === 'images')
2992
+ return openImagesFlow()
2993
+ if (action.value === 'model')
2994
+ return openModelFlow()
2995
+ if (action.value === 'settings')
2996
+ return openSettingsFlow()
2997
+ if (action.value === 'output')
2998
+ return openOutputFlow()
2999
+ })
3000
+
3001
+ return true
3002
+ }
3003
+
3004
+ const handleLine = async (line) => {
3005
+ if (multiline) {
3006
+ if (busy) {
3007
+ process.stdout.write(`${icon('⏳')}busy (Ctrl+C to cancel)\n`)
3008
+ rl.prompt()
3009
+ return
3010
+ }
3011
+ const raw = String(line ?? '')
3012
+ const trimmed = raw.trim()
3013
+ if (trimmed === '.cancel') {
3014
+ multiline = null
3015
+ setPromptForMode()
3016
+ showInlineHint('Canceled multiline input.')
3017
+ rl.prompt()
3018
+ return
3019
+ }
3020
+ if (trimmed === '.send') {
3021
+ const text = multiline.lines.join('\n').replace(/\s+$/, '')
3022
+ multiline = null
3023
+ setPromptForMode()
3024
+ if (!text.trim()) {
3025
+ showInlineHint('Canceled (empty message).')
3026
+ rl.prompt()
3027
+ return
3028
+ }
3029
+ await runChatTurn(text)
3030
+ setPromptForMode()
3031
+ rl.prompt()
3032
+ return
3033
+ }
3034
+ multiline.lines.push(raw)
3035
+ rl.prompt()
3036
+ return
3037
+ }
3038
+
3039
+ const trimmed = line.trim()
3040
+ if (!trimmed) {
3041
+ if (uiMode === 'select_session') {
3042
+ leaveSessionPickerUi()
3043
+ uiMode = 'normal'
3044
+ sessionPicker = null
3045
+ setPromptForMode()
3046
+ }
3047
+ rl.prompt()
3048
+ return
3049
+ }
3050
+
3051
+ if (busy) {
3052
+ process.stdout.write(`${icon('⏳')}busy (Ctrl+C to cancel)\n`)
3053
+ rl.prompt()
3054
+ return
3055
+ }
3056
+
3057
+ // If the user is typing a mention (@...), don't suggest slash commands.
3058
+ if (uiMode === 'normal' && !trimmed.startsWith('/') && !trimmed.startsWith('@') && !/\s/.test(trimmed)) {
3059
+ const resolved = resolveCommand(trimmed)
3060
+ if (resolved.kind === 'exact') {
3061
+ process.stdout.write(`${icon('💡')}Tip: commands start with '/'. Did you mean /${resolved.cmd}?\n`)
3062
+ rl.prompt()
3063
+ return
3064
+ }
3065
+ if (resolved.kind === 'prefix') {
3066
+ process.stdout.write(`${icon('💡')}Tip: commands start with '/'. Did you mean /${resolved.cmd}?\n`)
3067
+ rl.prompt()
3068
+ return
3069
+ }
3070
+ if (resolved.kind === 'ambiguous') {
3071
+ process.stdout.write(`${icon('💡')}Tip: commands start with '/'. Possible matches: ${resolved.matches.map(m => `/${m}`).join(', ')}\n`)
3072
+ rl.prompt()
3073
+ return
3074
+ }
3075
+ if (resolved.kind === 'fuzzy' || resolved.kind === 'contains') {
3076
+ process.stdout.write(`${icon('💡')}Tip: commands start with '/'. Did you mean ${resolved.matches.map(m => `/${m}`).join(', ')}?\n`)
3077
+ rl.prompt()
3078
+ return
3079
+ }
3080
+ }
3081
+
3082
+ if (trimmed.startsWith('/')) {
3083
+ if (uiMode === 'select_session') {
3084
+ leaveSessionPickerUi()
3085
+ uiMode = 'normal'
3086
+ sessionPicker = null
3087
+ }
3088
+ await handleSlash(trimmed)
3089
+ setPromptForMode()
3090
+ rl.prompt()
3091
+ return
3092
+ }
3093
+
3094
+ if (uiMode === 'normal' && trimmed.startsWith('@')) {
3095
+ const lower = trimmed.toLowerCase()
3096
+ const handled = !/\s/.test(trimmed) && KNOWN_MENTIONS.includes(lower)
3097
+ ? await handleAt(lower)
3098
+ : false
3099
+ if (handled) {
3100
+ setPromptForMode()
3101
+ rl.prompt()
3102
+ return
3103
+ }
3104
+ }
3105
+
3106
+ if (uiMode === 'select_session' && sessionPicker) {
3107
+ const sessions = sessionPicker.sessions
3108
+ const filtered = getFilteredSessions(sessions, sessionPicker.filter)
3109
+
3110
+ const maybeIndex = Number.parseInt(trimmed, 10)
3111
+ if (Number.isFinite(maybeIndex) && maybeIndex >= 1 && maybeIndex <= filtered.length) {
3112
+ leaveSessionPickerUi()
3113
+ await handleSlash(`/use ${filtered[maybeIndex - 1].sessionId}`)
3114
+ rl.prompt()
3115
+ return
3116
+ }
3117
+
3118
+ sessionPicker.filter = trimmed
3119
+ renderSessionPicker({
3120
+ sessions,
3121
+ currentSessionId: options.session.sessionId,
3122
+ filter: sessionPicker.filter,
3123
+ alt: sessionPickerAlt,
3124
+ emoji: uiPrefs?.emoji,
3125
+ })
3126
+ rl.prompt()
3127
+ return
3128
+ }
3129
+
3130
+ await runChatTurn(trimmed)
3131
+ rl.prompt()
3132
+ }
3133
+
3134
+ rl.on('line', (line) => {
3135
+ if (exiting)
3136
+ return
3137
+ lineQueue = lineQueue.then(() => handleLine(line)).catch((err) => {
3138
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
3139
+ })
3140
+ })
3141
+ }