#!/usr/bin/env node 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const readline = require('readline'); const { ensureConfig } = require('../config'); const { createState } = require('../core/state'); const { buildSystemPrompt } = require('../core/context'); const { getModelByKey } = require('../config'); const { streamChat } = require('../model/client'); const { executeTool } = require('../tools/dispatcher'); const { openCommandMenu, hasCommandMatch } = require('../ui/command_menu'); const { handleCommand } = require('./commands'); const { Spinner, truncateThinking } = require('../ui/spinner'); const { renderBanner } = require('../ui/banner'); const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); const { createConversation, updateConversation } = require('../storage/conversation_store'); const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('../utils/token_usage'); const { gray, cyan, green, red, blue } = require('../utils/colors'); const { createIndentedWriter } = require('../ui/indented_writer'); const { createStatusBar } = require('../ui/status_bar'); const { visibleWidth } = require('../utils/text_width'); const { readMediafileTool } = require('../tools/read_mediafile'); const WORKSPACE = process.cwd(); const WORKSPACE_NAME = path.basename(WORKSPACE); const USERNAME = os.userInfo().username || 'user'; const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `; const MENU_PAGE_SIZE = 6; const SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']); const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.heic']); const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']); const config = ensureConfig(); if (!config.valid_models || config.valid_models.length === 0) { console.log(''); console.log(`未找到可用模型,请先在 ${config.path} 填写完整模型信息。`); console.log(''); process.exit(1); } const state = createState(config, WORKSPACE); state.conversation = createConversation(WORKSPACE, { model_key: state.modelKey, model_id: state.modelId, thinking_mode: state.thinkingMode, allow_mode: state.allowMode, token_usage: state.tokenUsage, cwd: WORKSPACE, }); const systemPrompt = fs.readFileSync(path.join(__dirname, '../../prompts/system.txt'), 'utf8'); const tools = JSON.parse(fs.readFileSync(path.join(__dirname, '../../doc/tools.json'), 'utf8')); renderBanner({ modelKey: state.modelKey, workspace: WORKSPACE, conversationId: state.conversation?.id, }); console.log(''); console.log(''); let rl = null; let statusBar = null; let isRunning = false; let escPendingCancel = false; let activeStreamController = null; let activeToolController = null; let currentMedia = { tokens: [], text: '' }; let commandMenuActive = false; let menuSearchTerm = ''; let menuLastSearchTerm = ''; let menuJustClosedAt = 0; let menuInjectedCommand = null; let menuAbortController = null; let menuJustClosedInjected = false; let suppressSlashMenuUntil = 0; let pendingSlashTimer = null; function printNotice(message) { console.log(''); console.log(message); console.log(''); } function printNoticeInline(message) { console.log(message); } function getPathExt(p) { const idx = p.lastIndexOf('.'); if (idx === -1) return ''; return p.slice(idx).toLowerCase(); } function decodeEscapedPath(p) { let text = String(p || ''); if (text.startsWith('file://')) { text = text.replace(/^file:\/\//, ''); } try { text = decodeURIComponent(text); } catch (_) {} return text.replace(/\\ /g, ' ').replace(/\\\\/g, '\\'); } function findMediaMatches(line) { const matches = []; const quoted = /'([^']+)'/g; let m; while ((m = quoted.exec(line)) !== null) { matches.push({ raw: m[0], path: m[1], index: m.index }); } const extGroup = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])) .map((e) => e.replace('.', '\\.')) .join('|'); const unquoted = new RegExp(`/((?:\\\\ |[^\\s])+?)\\.(${extGroup})`, 'g'); while ((m = unquoted.exec(line)) !== null) { const raw = `/${m[1]}.${m[2]}`; matches.push({ raw, path: raw, index: m.index }); } matches.sort((a, b) => a.index - b.index); return matches; } function applyMediaTokens(line) { if (!line) return { line: '', tokens: [] }; const matches = findMediaMatches(line); if (!matches.length) return { line, tokens: [] }; let images = 0; let videos = 0; let cursor = 0; let out = ''; const tokens = []; for (const match of matches) { if (match.index < cursor) continue; const before = line.slice(cursor, match.index); out += before; const decoded = decodeEscapedPath(match.path); if (!fs.existsSync(decoded)) { out += match.raw; cursor = match.index + match.raw.length; continue; } const ext = getPathExt(decoded); const isImage = IMAGE_EXTS.has(ext); const isVideo = VIDEO_EXTS.has(ext); if (!isImage && !isVideo) { out += match.raw; cursor = match.index + match.raw.length; continue; } if (isImage && images >= 9) { out += match.raw; cursor = match.index + match.raw.length; continue; } if (isVideo && videos >= 1) { out += match.raw; cursor = match.index + match.raw.length; continue; } const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`; tokens.push({ token, path: decoded, type: isImage ? 'image' : 'video' }); out += token; if (isImage) images += 1; if (isVideo) videos += 1; cursor = match.index + match.raw.length; } out += line.slice(cursor); return { line: out, tokens }; } function applySingleMediaPath(line, rawPath, tokens) { if (!line || !rawPath) return { line, tokens }; const idx = line.indexOf(rawPath); if (idx === -1) return { line, tokens }; const decoded = decodeEscapedPath(rawPath); if (!fs.existsSync(decoded)) return { line, tokens }; const ext = getPathExt(decoded); const isImage = IMAGE_EXTS.has(ext); const isVideo = VIDEO_EXTS.has(ext); if (!isImage && !isVideo) return { line, tokens }; const images = tokens.filter((t) => t.type === 'image').length; const videos = tokens.filter((t) => t.type === 'video').length; if (isImage && images >= 9) return { line, tokens }; if (isVideo && videos >= 1) return { line, tokens }; const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`; const nextLine = line.slice(0, idx) + token + line.slice(idx + rawPath.length); const nextTokens = tokens.concat([{ token, path: decoded, type: isImage ? 'image' : 'video' }]); return { line: nextLine, tokens: nextTokens }; } function colorizeTokens(line) { return line.replace(/\[(图片|视频) #\d+\]/g, (t) => blue(t)); } function refreshInputLine() { if (!rl || !process.stdout.isTTY || commandMenuActive) return; const line = rl.line || ''; const colorLine = colorizeTokens(line); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); process.stdout.write(PROMPT + colorLine); const cursorCol = visibleWidth(PROMPT) + visibleWidth(line.slice(0, rl.cursor || 0)); readline.cursorTo(process.stdout, cursorCol); } function removeTokenAtCursor(line, cursor, allowStart = false) { const tokenRe = /\[(图片|视频) #\d+\]/g; let m; while ((m = tokenRe.exec(line)) !== null) { const start = m.index; const end = m.index + m[0].length; if ((allowStart ? cursor >= start : cursor > start) && cursor <= end) { const nextLine = line.slice(0, start) + line.slice(end); return { line: nextLine, cursor: start, removed: true }; } } return { line, cursor, removed: false }; } function isSlashCommand(line) { if (!line || line[0] !== '/') return false; const cmd = line.split(/\s+/)[0]; return SLASH_COMMANDS.has(cmd); } readline.emitKeypressEvents(process.stdin); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (chunk) => { if (isRunning || commandMenuActive || !rl) return; const text = chunk ? chunk.toString() : ''; if (!text) return; const looksLikePath = text.includes('file://') || /\.(png|jpe?g|gif|webp|bmp|tiff|heic|mp4|mov|avi|mkv|webm|m4v)\b/i.test(text); if (!looksLikePath) return; suppressSlashMenuUntil = Date.now() + 200; setImmediate(() => { const line = rl.line || ''; const raw = text.replace(/\r?\n/g, '').trim(); const decoded = raw ? decodeEscapedPath(raw) : ''; let applied = applyMediaTokens(line); if (raw) { if (line.includes(raw)) { applied = applySingleMediaPath(applied.line, raw, applied.tokens); } else if (decoded && line.includes(decoded)) { applied = applySingleMediaPath(applied.line, decoded, applied.tokens); } } if (applied.tokens.length) { rl.line = applied.line; rl.cursor = rl.line.length; currentMedia = { tokens: applied.tokens, text: applied.line }; refreshInputLine(); } }); }); initReadline(); statusBar = createStatusBar({ getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0, getMaxTokens: () => { const model = getModelByKey(config, state.modelKey); return model && Number.isFinite(model.max_context) ? model.max_context : null; }, }); statusBar.render(); process.stdin.on('keypress', (str, key) => { if (commandMenuActive) { if (key && key.name === 'backspace' && menuSearchTerm === '') { if (menuAbortController && !menuAbortController.signal.aborted) { menuAbortController.abort(); } } if (key && key.name === 'return') { const term = String(menuSearchTerm || '').replace(/^\/+/, '').trim(); if (term && !hasCommandMatch(term)) { menuInjectedCommand = `/${term}`; if (menuAbortController && !menuAbortController.signal.aborted) { menuAbortController.abort(); } } } return; } if (!isRunning && key && (key.name === 'backspace' || key.name === 'delete')) { const line = rl.line || ''; const cursor = rl.cursor || 0; const updated = removeTokenAtCursor(line, cursor, key.name === 'delete'); if (updated.removed) { rl.line = updated.line; rl.cursor = updated.cursor; refreshInputLine(); if (statusBar) statusBar.render(); return; } if (statusBar) statusBar.render(); } if (key && key.name === 'escape' && isRunning) { escPendingCancel = true; if (activeStreamController && typeof activeStreamController.abort === 'function') { activeStreamController.abort(); } if (activeToolController && typeof activeToolController.abort === 'function') { activeToolController.abort(); } return; } if (str === '/' && Date.now() < suppressSlashMenuUntil) { return; } if (pendingSlashTimer && (str !== '/' || (rl.line && rl.line !== '/'))) { clearTimeout(pendingSlashTimer); pendingSlashTimer = null; } if (!isRunning) { const rawLine = rl.line || ''; let applied = { line: rawLine, tokens: [] }; if (!isSlashCommand(rawLine)) { const hasToken = /\[(图片|视频) #\d+\]/.test(rawLine); if (hasToken && currentMedia.tokens.length && currentMedia.text === rawLine) { applied = { line: rawLine, tokens: currentMedia.tokens }; } else { applied = applyMediaTokens(rawLine); } if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { applied = { line: rawLine, tokens: currentMedia.tokens }; } } if (applied.line !== (rl.line || '')) { rl.line = applied.line; rl.cursor = rl.line.length; currentMedia = { tokens: applied.tokens, text: applied.line }; } else if (currentMedia.text !== applied.line) { currentMedia = { tokens: applied.tokens, text: applied.line }; } refreshInputLine(); } if (statusBar) statusBar.render(); if (str === '/' && (rl.line === '' || rl.line === '/')) { if (pendingSlashTimer) { clearTimeout(pendingSlashTimer); pendingSlashTimer = null; } pendingSlashTimer = setTimeout(() => { pendingSlashTimer = null; if (rl.line === '' || rl.line === '/') { commandMenuActive = true; if (rl) { rl.pause(); rl.line = ''; rl.cursor = 0; readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); } menuSearchTerm = ''; menuAbortController = new AbortController(); void openCommandMenu({ rl, prompt: PROMPT, pageSize: MENU_PAGE_SIZE, colorEnabled: process.stdout.isTTY, resetAnsi: '\x1b[0m', onInput: (input) => { menuSearchTerm = input || ''; }, abortSignal: menuAbortController.signal, }) .then((result) => { if (result && result.chosen && !result.cancelled) { menuInjectedCommand = result.chosen; } }) .finally(() => { commandMenuActive = false; menuAbortController = null; menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; menuJustClosedInjected = !!menuInjectedCommand; menuLastSearchTerm = menuSearchTerm; drainStdin(); rl.line = ''; rl.cursor = 0; menuSearchTerm = ''; if (process.stdout.isTTY) { readline.clearScreenDown(process.stdout); } // Clear possible echoes from the base readline line (current + previous line). if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); } else { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); } promptWithStatus(true); if (menuInjectedCommand) { const injected = menuInjectedCommand; menuInjectedCommand = null; setImmediate(() => injectLine(injected)); } }); } }, 80); return; } }); function initReadline() { if (rl) { try { rl.removeAllListeners(); } catch (_) {} } rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, }); process.stdin.resume(); rl.setPrompt(PROMPT); promptWithStatus(); rl.on('line', async (line) => { if (rl) { rl.line = ''; rl.cursor = 0; } if (commandMenuActive) { return; } let applied = { line, tokens: [] }; if (!isSlashCommand(line)) { const hasToken = /\[(图片|视频) #\d+\]/.test(line); if (hasToken && currentMedia.tokens.length && currentMedia.text === line) { applied = { line, tokens: currentMedia.tokens }; } else { applied = applyMediaTokens(line); } if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { applied = { line, tokens: currentMedia.tokens }; } } const normalizedLine = applied.line; currentMedia = { tokens: applied.tokens, text: normalizedLine }; const input = normalizedLine.trim(); if (menuJustClosedAt) { if (!menuJustClosedInjected) { const tooOld = Date.now() - menuJustClosedAt > 800; const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, ''); const normalizedInput = input.replace(/^\/+/, ''); if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { menuJustClosedAt = 0; menuLastSearchTerm = ''; if (process.stdout.isTTY) { readline.moveCursor(process.stdout, 0, -1); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); } promptWithStatus(); return; } } menuJustClosedAt = 0; menuLastSearchTerm = ''; menuJustClosedInjected = false; } if (!input) { promptWithStatus(); return; } if (input.startsWith('/') && isSlashCommand(input)) { const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); if (result && result.exit) return; promptWithStatus(); return; } if (input.startsWith('/') && !isSlashCommand(input)) { printNoticeInline(`无效的命令“${input}”`); promptWithStatus(); return; } console.log(''); const userWriter = createIndentedWriter(' '); const displayLine = colorizeTokens(normalizedLine); userWriter.writeLine(`${cyan('用户:')}${displayLine}`); const content = buildUserContent(normalizedLine, currentMedia.tokens); state.messages.push({ role: 'user', content }); persistConversation(); await runAssistantLoop(); promptWithStatus(); }); rl.on('close', () => { if (statusBar) statusBar.destroy(); process.stdout.write('\n'); process.exit(0); }); } function drainStdin() { if (!process.stdin.isTTY) return; process.stdin.pause(); while (process.stdin.read() !== null) {} process.stdin.resume(); } function injectLine(text) { if (!rl) return; rl.write(text); rl.write('\n'); } function persistConversation() { if (!state.conversation) return; state.conversation = updateConversation(WORKSPACE, state.conversation, state.messages, { model_key: state.modelKey, model_id: state.modelId, thinking_mode: state.thinkingMode, allow_mode: state.allowMode, token_usage: state.tokenUsage, cwd: WORKSPACE, }); } function promptWithStatus(force = false) { if (!rl) return; if (process.stdout.isTTY) { const rows = process.stdout.rows || 24; if (rows >= 3) { readline.cursorTo(process.stdout, 0, rows - 3); readline.clearLine(process.stdout, 0); } } rl.prompt(force); if (statusBar) statusBar.render(); } function buildApiMessages() { const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode, modelId: state.modelId || state.modelKey, }); const messages = [{ role: 'system', content: system }]; for (const msg of state.messages) { const m = { role: msg.role }; if (msg.role === 'tool') { m.tool_call_id = msg.tool_call_id; m.content = msg.content; } else if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) { m.content = msg.content || null; m.tool_calls = msg.tool_calls; if (Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { m.reasoning_content = msg.reasoning_content; } else if (state.thinkingMode) { m.reasoning_content = ''; } } else { m.content = msg.content; if (msg.role === 'assistant' && Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { m.reasoning_content = msg.reasoning_content; } } messages.push(m); } return messages; } function buildUserContent(line, tokens) { if (!tokens.length) return line; const parts = [{ type: 'text', text: line }]; for (const info of tokens) { const media = readMediafileTool(WORKSPACE, { path: info.path }); if (media && media.success) { const url = `data:${media.mime};base64,${media.b64}`; parts.push({ type: media.type === 'image' ? 'image_url' : 'video_url', [media.type === 'image' ? 'image_url' : 'video_url']: { url }, }); } } return parts; } function printCancelLine() { console.log(''); process.stdout.write(` ${red('已取消本次响应')}\n\n`); } function stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, thinkingMode) { if (!spinner) return; if (thinkingMode && (thinkingActive || showThinkingLabel)) { spinner.stop('∙ 停止思考'); } else { spinner.stopSilent(); } } async function runAssistantLoop() { let continueLoop = true; let firstLoop = true; const hideCursor = () => process.stdout.write('\x1b[?25l'); const showCursor = () => process.stdout.write('\x1b[?25h'); isRunning = true; escPendingCancel = false; if (statusBar) statusBar.setMode('running'); try { while (continueLoop) { hideCursor(); const spinner = new Spinner(' ', firstLoop ? 1 : 0); firstLoop = false; let thinkingBuffer = ''; let fullThinkingBuffer = ''; let thinkingActive = false; let showThinkingLabel = false; let gotAnswer = false; let assistantContent = ''; let toolCalls = {}; let usageTotal = null; let usagePrompt = null; let usageCompletion = null; let firstContent = true; let assistantWriter = null; let cancelled = false; const thinkingDelay = setTimeout(() => { if (state.thinkingMode) showThinkingLabel = true; }, 400); spinner.start(() => { if (!state.thinkingMode) return ''; if (!showThinkingLabel) return ''; return { label: ' 思考中...', thinking: thinkingBuffer, colorThinking: true }; }); const messages = buildApiMessages(); const streamController = new AbortController(); activeStreamController = streamController; try { const currentContextTokens = normalizeTokenUsage(state.tokenUsage).total || 0; for await (const chunk of streamChat({ config, modelKey: state.modelKey, messages, tools, thinkingMode: state.thinkingMode, currentContextTokens, abortSignal: streamController.signal, })) { const choice = chunk.choices && chunk.choices[0]; const usage = (chunk && chunk.usage) || (choice && choice.usage) || (choice && choice.delta && choice.delta.usage); const normalizedUsage = normalizeUsagePayload(usage); if (normalizedUsage) { if (Number.isFinite(normalizedUsage.prompt_tokens)) usagePrompt = normalizedUsage.prompt_tokens; if (Number.isFinite(normalizedUsage.completion_tokens)) usageCompletion = normalizedUsage.completion_tokens; if (Number.isFinite(normalizedUsage.total_tokens)) usageTotal = normalizedUsage.total_tokens; } if (!choice) continue; const delta = choice.delta || {}; if (delta.reasoning_content || delta.reasoning_details || choice.reasoning_details) { thinkingActive = true; showThinkingLabel = true; let rc = ''; if (delta.reasoning_content) { rc = delta.reasoning_content; } else if (delta.reasoning_details) { if (Array.isArray(delta.reasoning_details)) { rc = delta.reasoning_details.map((d) => d.text || '').join(''); } else if (typeof delta.reasoning_details === 'string') { rc = delta.reasoning_details; } else if (delta.reasoning_details && typeof delta.reasoning_details.text === 'string') { rc = delta.reasoning_details.text; } } else if (choice.reasoning_details) { if (Array.isArray(choice.reasoning_details)) { rc = choice.reasoning_details.map((d) => d.text || '').join(''); } else if (typeof choice.reasoning_details === 'string') { rc = choice.reasoning_details; } else if (choice.reasoning_details && typeof choice.reasoning_details.text === 'string') { rc = choice.reasoning_details.text; } } fullThinkingBuffer += rc; thinkingBuffer = truncateThinking(fullThinkingBuffer); } if (delta.tool_calls) { delta.tool_calls.forEach((tc) => { const idx = tc.index; if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id, type: tc.type, function: { name: '', arguments: '' } }; if (tc.function?.name) toolCalls[idx].function.name = tc.function.name; if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments; }); } if (delta.content) { if (!gotAnswer) { clearTimeout(thinkingDelay); if (state.thinkingMode) { spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); } else { spinner.stopSilent(); } console.log(''); assistantWriter = createIndentedWriter(' '); assistantWriter.write(`${green('Eagent:')}`); gotAnswer = true; } if (!gotAnswer) return; if (!assistantWriter) assistantWriter = createIndentedWriter(' '); assistantWriter.write(delta.content); assistantContent += delta.content; } if (choice.finish_reason) { if (choice.finish_reason === 'tool_calls') { continueLoop = true; } } } } catch (err) { if (err && (err.code === 'aborted' || err.name === 'AbortError' || escPendingCancel)) { cancelled = true; } else { clearTimeout(thinkingDelay); if (state.thinkingMode) { spinner.stop('∙'); } else { spinner.stopSilent(); } showCursor(); console.log(`错误: ${err.message || err}`); return; } } activeStreamController = null; clearTimeout(thinkingDelay); if (cancelled) { if (gotAnswer) { spinner.stopSilent(); } else { stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode); } showCursor(); printCancelLine(); return; } if (!gotAnswer) { if (state.thinkingMode) { spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); } else { spinner.stopSilent(); } } else { process.stdout.write('\n\n'); } showCursor(); if (usageTotal !== null || usagePrompt !== null || usageCompletion !== null) { state.tokenUsage = applyUsage(normalizeTokenUsage(state.tokenUsage), { prompt_tokens: usagePrompt, completion_tokens: usageCompletion, total_tokens: usageTotal, }); } const toolCallList = Object.keys(toolCalls) .map((k) => Number(k)) .sort((a, b) => a - b) .map((k) => toolCalls[k]); if (toolCallList.length) { const assistantMsg = { role: 'assistant', content: assistantContent || null, tool_calls: toolCallList, }; if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || ''; state.messages.push(assistantMsg); persistConversation(); for (const call of toolCallList) { let args = {}; try { args = JSON.parse(call.function.arguments || '{}'); } catch (_) {} const startLine = buildStartLine(call.function.name, args); const finalLine = buildFinalLine(call.function.name, args); const indicator = startToolDisplay(startLine); const toolController = new AbortController(); activeToolController = toolController; const toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call, abortSignal: toolController.signal, }); activeToolController = null; indicator.stop(finalLine); const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: toolResult.error }); printResultLines(resultLines); const toolMsg = { role: 'tool', tool_call_id: call.id, content: toolResult.tool_content || toolResult.formatted, tool_raw: toolResult.raw, }; state.messages.push(toolMsg); persistConversation(); if (escPendingCancel || (toolResult.raw && toolResult.raw.cancelled)) { return; } } continueLoop = true; hideCursor(); continue; } if (assistantContent) { const msg = { role: 'assistant', content: assistantContent }; if (state.thinkingMode) msg.reasoning_content = fullThinkingBuffer || ''; state.messages.push(msg); persistConversation(); } continueLoop = false; } } finally { showCursor(); isRunning = false; escPendingCancel = false; activeStreamController = null; activeToolController = null; if (statusBar) statusBar.setMode('input'); } }