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/README.md +529 -0
- package/cli/args.mjs +113 -0
- package/cli/clack.mjs +111 -0
- package/cli/clipboard.mjs +320 -0
- package/cli/files.mjs +247 -0
- package/cli/index.mjs +299 -0
- package/cli/itermPaste.mjs +147 -0
- package/cli/persist.mjs +205 -0
- package/cli/repl.mjs +3141 -0
- package/cli/sdk.mjs +341 -0
- package/cli/sessionTransfer.mjs +118 -0
- package/cli/turn.mjs +751 -0
- package/cli/ui.mjs +138 -0
- package/cli.mjs +5 -0
- package/dist/index.cjs +4860 -0
- package/dist/index.d.cts +3479 -0
- package/dist/index.d.ts +3479 -0
- package/dist/index.js +4795 -0
- package/package.json +68 -0
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
|
+
}
|