Handle ESC cancellation and tool aborts

This commit is contained in:
JOJO 2026-02-28 18:37:12 +08:00
parent 6d2d4857f3
commit 858298c070

View File

@ -18,7 +18,7 @@ const { renderBanner } = require('../ui/banner');
const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display');
const { createConversation, updateConversation } = require('../storage/conversation_store'); const { createConversation, updateConversation } = require('../storage/conversation_store');
const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage'); const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage');
const { gray, cyan, green } = require('../utils/colors'); const { gray, cyan, green, red } = require('../utils/colors');
const { createIndentedWriter } = require('../ui/indented_writer'); const { createIndentedWriter } = require('../ui/indented_writer');
const { createStatusBar } = require('../ui/status_bar'); const { createStatusBar } = require('../ui/status_bar');
@ -52,6 +52,10 @@ console.log('');
let rl = null; let rl = null;
let statusBar = null; let statusBar = null;
let isRunning = false;
let escPendingCancel = false;
let activeStreamController = null;
let activeToolController = null;
let commandMenuActive = false; let commandMenuActive = false;
let menuSearchTerm = ''; let menuSearchTerm = '';
let menuLastSearchTerm = ''; let menuLastSearchTerm = '';
@ -79,6 +83,16 @@ process.stdin.on('keypress', (str, key) => {
} }
return; return;
} }
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 === '/' && (rl.line === '' || rl.line === '/')) { if (str === '/' && (rl.line === '' || rl.line === '/')) {
commandMenuActive = true; commandMenuActive = true;
if (rl) { if (rl) {
@ -275,12 +289,29 @@ function buildApiMessages() {
return messages; return messages;
} }
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() { async function runAssistantLoop() {
let continueLoop = true; let continueLoop = true;
let firstLoop = true; let firstLoop = true;
const hideCursor = () => process.stdout.write('\x1b[?25l'); const hideCursor = () => process.stdout.write('\x1b[?25l');
const showCursor = () => process.stdout.write('\x1b[?25h'); const showCursor = () => process.stdout.write('\x1b[?25h');
isRunning = true;
escPendingCancel = false;
if (statusBar) statusBar.setMode('running'); if (statusBar) statusBar.setMode('running');
try {
while (continueLoop) { while (continueLoop) {
hideCursor(); hideCursor();
const spinner = new Spinner(' ', firstLoop ? 1 : 0); const spinner = new Spinner(' ', firstLoop ? 1 : 0);
@ -297,6 +328,7 @@ async function runAssistantLoop() {
let usageCompletion = null; let usageCompletion = null;
let firstContent = true; let firstContent = true;
let assistantWriter = null; let assistantWriter = null;
let cancelled = false;
const thinkingDelay = setTimeout(() => { const thinkingDelay = setTimeout(() => {
if (state.thinkingMode) showThinkingLabel = true; if (state.thinkingMode) showThinkingLabel = true;
@ -309,8 +341,10 @@ async function runAssistantLoop() {
}); });
const messages = buildApiMessages(); const messages = buildApiMessages();
const streamController = new AbortController();
activeStreamController = streamController;
try { try {
for await (const chunk of streamChat({ config, messages, tools, thinkingMode: state.thinkingMode })) { for await (const chunk of streamChat({ config, messages, tools, thinkingMode: state.thinkingMode, abortSignal: streamController.signal })) {
const choice = chunk.choices && chunk.choices[0]; const choice = chunk.choices && chunk.choices[0];
if (!choice) continue; if (!choice) continue;
const usage = (choice && (choice.usage || choice.delta?.usage)) || chunk.usage; const usage = (choice && (choice.usage || choice.delta?.usage)) || chunk.usage;
@ -364,6 +398,9 @@ async function runAssistantLoop() {
} }
} }
} catch (err) { } catch (err) {
if (err && (err.code === 'aborted' || err.name === 'AbortError' || escPendingCancel)) {
cancelled = true;
} else {
clearTimeout(thinkingDelay); clearTimeout(thinkingDelay);
if (state.thinkingMode) { if (state.thinkingMode) {
spinner.stop('○'); spinner.stop('○');
@ -374,8 +411,16 @@ async function runAssistantLoop() {
console.log(`错误: ${err.message || err}`); console.log(`错误: ${err.message || err}`);
return; return;
} }
}
activeStreamController = null;
clearTimeout(thinkingDelay); clearTimeout(thinkingDelay);
if (cancelled) {
stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode);
showCursor();
printCancelLine();
return;
}
if (!gotAnswer) { if (!gotAnswer) {
if (state.thinkingMode) { if (state.thinkingMode) {
spinner.stop(thinkingActive ? '○ 思考完成' : '○'); spinner.stop(thinkingActive ? '○ 思考完成' : '○');
@ -415,7 +460,16 @@ async function runAssistantLoop() {
const startLine = buildStartLine(call.function.name, args); const startLine = buildStartLine(call.function.name, args);
const finalLine = buildFinalLine(call.function.name, args); const finalLine = buildFinalLine(call.function.name, args);
const indicator = startToolDisplay(startLine); const indicator = startToolDisplay(startLine);
const toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 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); indicator.stop(finalLine);
const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: toolResult.error }); const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: toolResult.error });
printResultLines(resultLines); printResultLines(resultLines);
@ -428,6 +482,9 @@ async function runAssistantLoop() {
}; };
state.messages.push(toolMsg); state.messages.push(toolMsg);
persistConversation(); persistConversation();
if (escPendingCancel || (toolResult.raw && toolResult.raw.cancelled)) {
return;
}
} }
continueLoop = true; continueLoop = true;
hideCursor(); hideCursor();
@ -442,6 +499,12 @@ async function runAssistantLoop() {
} }
continueLoop = false; continueLoop = false;
} }
} finally {
showCursor(); showCursor();
isRunning = false;
escPendingCancel = false;
activeStreamController = null;
activeToolController = null;
if (statusBar) statusBar.setMode('input'); if (statusBar) statusBar.setMode('input');
}
} }