Update status bar and track progress

This commit is contained in:
JOJO 2026-02-28 18:25:29 +08:00
parent 136a503846
commit 6d2d4857f3
26 changed files with 15596 additions and 32 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,19 @@
{
"id": "1042413d-b4ee-4586-85e5-eb80879d9fbe",
"title": "新对话",
"created_at": "2026-02-28T04:33:13.020Z",
"updated_at": "2026-02-28T04:33:13.020Z",
"metadata": {
"model_key": "kimi-k2.5",
"model_id": "kimi-k2.5",
"thinking_mode": true,
"allow_mode": "full_access",
"token_usage": {
"prompt": 0,
"completion": 0,
"total": 0
},
"cwd": "/Users/jojo/Desktop/easyagent"
},
"messages": []
}

View File

@ -0,0 +1,19 @@
{
"id": "1be5b6a9-769a-4276-96b6-185627fc308a",
"title": "新对话",
"created_at": "2026-02-28T04:35:06.703Z",
"updated_at": "2026-02-28T04:35:10.259Z",
"metadata": {
"model_key": "kimi-k2.5",
"model_id": "kimi-k2.5",
"thinking_mode": true,
"allow_mode": "read_only",
"token_usage": {
"prompt": 0,
"completion": 0,
"total": 0
},
"cwd": "/Users/jojo/Desktop/easyagent"
},
"messages": []
}

View File

@ -0,0 +1,19 @@
{
"id": "3a4b4cce-feac-49e7-8aee-66fe2881d076",
"title": "新对话",
"created_at": "2026-02-28T04:16:47.305Z",
"updated_at": "2026-02-28T04:16:50.825Z",
"metadata": {
"model_key": "kimi-k2.5",
"model_id": "kimi-k2.5",
"thinking_mode": true,
"allow_mode": "read_only",
"token_usage": {
"prompt": 0,
"completion": 0,
"total": 0
},
"cwd": "/Users/jojo/Desktop/easyagent"
},
"messages": []
}

View File

@ -0,0 +1,19 @@
{
"id": "cb1e445c-e7f5-4b70-bbf9-6114c441af47",
"title": "新对话",
"created_at": "2026-02-28T04:25:19.837Z",
"updated_at": "2026-02-28T04:25:22.770Z",
"metadata": {
"model_key": "kimi-k2.5",
"model_id": "kimi-k2.5",
"thinking_mode": true,
"allow_mode": "read_only",
"token_usage": {
"prompt": 0,
"completion": 0,
"total": 0
},
"cwd": "/Users/jojo/Desktop/easyagent"
},
"messages": []
}

View File

@ -0,0 +1,19 @@
{
"id": "e24b0466-9fed-4da5-b8b8-627154fe1d76",
"title": "新对话",
"created_at": "2026-02-28T04:32:28.441Z",
"updated_at": "2026-02-28T04:32:31.209Z",
"metadata": {
"model_key": "kimi-k2.5",
"model_id": "kimi-k2.5",
"thinking_mode": true,
"allow_mode": "read_only",
"token_usage": {
"prompt": 0,
"completion": 0,
"total": 0
},
"cwd": "/Users/jojo/Desktop/easyagent"
},
"messages": []
}

52
.easyagent/index.json Normal file
View File

@ -0,0 +1,52 @@
{
"3a4b4cce-feac-49e7-8aee-66fe2881d076": {
"title": "新对话",
"created_at": "2026-02-28T04:16:47.305Z",
"updated_at": "2026-02-28T04:16:50.825Z",
"total_messages": 0,
"total_tools": 0,
"thinking_mode": true,
"run_mode": "thinking",
"model_key": "kimi-k2.5"
},
"cb1e445c-e7f5-4b70-bbf9-6114c441af47": {
"title": "新对话",
"created_at": "2026-02-28T04:25:19.837Z",
"updated_at": "2026-02-28T04:25:22.770Z",
"total_messages": 0,
"total_tools": 0,
"thinking_mode": true,
"run_mode": "thinking",
"model_key": "kimi-k2.5"
},
"e24b0466-9fed-4da5-b8b8-627154fe1d76": {
"title": "新对话",
"created_at": "2026-02-28T04:32:28.441Z",
"updated_at": "2026-02-28T04:32:31.209Z",
"total_messages": 0,
"total_tools": 0,
"thinking_mode": true,
"run_mode": "thinking",
"model_key": "kimi-k2.5"
},
"1042413d-b4ee-4586-85e5-eb80879d9fbe": {
"title": "新对话",
"created_at": "2026-02-28T04:33:13.020Z",
"updated_at": "2026-02-28T04:33:13.020Z",
"total_messages": 0,
"total_tools": 0,
"thinking_mode": true,
"run_mode": "thinking",
"model_key": "kimi-k2.5"
},
"1be5b6a9-769a-4276-96b6-185627fc308a": {
"title": "新对话",
"created_at": "2026-02-28T04:35:06.703Z",
"updated_at": "2026-02-28T04:35:10.259Z",
"total_messages": 0,
"total_tools": 0,
"thinking_mode": true,
"run_mode": "thinking",
"model_key": "kimi-k2.5"
}
}

10990
1.txt Normal file

File diff suppressed because it is too large Load Diff

87
2.txt Normal file
View File

@ -0,0 +1,87 @@
收到,我先不再改代码。下面是根据本次对话记录整理出来的修改清单(仅基于聊天内容,不查看当前文件;如果某条在中途被回滚/覆盖,已在备注里说明)。
整体新增/变更汇总(按功能模块)
1. 底部状态栏 + 保留空行 ✓
- 新增“永久底部状态栏”概念:最底行显示提示信息,上一行留空;输入行位于其上方。
- 左侧文案在输入时为“输入/查看所有指令”运行时变为“按下Esc停止”。
- 右侧显示“当前上下文 0/256k”且数字>1000时显示 1.1k 格式。
- 需要窗口缩放时自适应,不增加新底层显示行。
2. ESC 取消行为与记录落盘时机
- 按 Esc 需要立即中断运行:
- 若模型思考中:显示“∙ 停止思考”,并在思考内容下方打印红色“已取消本次响应”,上下各留一行空行。
- 若工具执行中:立即停止工具执行,工具结果为红色“任务被用户取消”,且不再继续执行工具结果处理。
- 数据落盘更细:用户输入后、模型完整输出后(不含工具)、工具调用后、工具结果后都应写入内存+落盘。
- 取消时如果输出未完成,未完成内容不入记录。
3. 工具输出显示
- “任务被用户取消”不带“└”或“失败:”前缀;左侧对齐。
- 工具执行完后输出与下一行 prompt 之间空一行。
- “取消响应”也需红色且左侧对齐,上一行留空。
4. /new 与 /resume token 复位
- /new 需要把右下角“当前上下文”重置(逻辑同 /resume
- 修复了 /new 未被识别的问题(与命令菜单注入有关)。
5. 只读权限提示文案
- 只读模式提示文案最后一句改为“请告知当前无权限需要用户输入 /allow 切换权限”。
- 只读模式下工具结果文案也需更新。
6. /allow、/model 等“Update”提示清理
- /allow /model /thinking 等菜单里不显示“Update …”。
7. /model 交互逻辑
- 需求最终定为:
- 选择模型后,隔一行显示“模型已切换为: xxx”
- 隔一行再显示思考模式选择;
- 选择思考模式后,在选项下面隔一行显示“思考模式: xxx”
- 不再要求清除菜单残留(因为清理和状态栏冲突)。
8. 命令菜单 / 无效指令
- 当 / 输入无匹配结果:
- 菜单显示“无效的指令”(替换 No results found
- 按 Enter 后不发送给模型,只显示“无效的命令“/app””。
9. 多模态(图片/视频)
- 输入路径或拖拽可自动插入蓝色 token [图片 #1] / [视频 #1]。
- 删除时 token 作为整体删除。
- 最多 9 张图片、1 个视频。
- 发送时:文本里包含 [图片 #n] 显示;实际请求使用 image_url/video_url base64 发送。
- 识别单引号路径 '/path/to/file.png'。
- 修复“只发送文本而未发送真实图片”的问题,最终要求按示例把图片作为 content list 的一部分发送。
10. 文件编辑工具创建文件
- 编辑文件时自动创建不存在的文件与目录,不再报错 ENOENT。
11. 提示信息空行规范
- 所有提示信息(如“已创建新对话/权限切换/模型切换/思考模式切换”)上下各空一行。
- Banner 与 prompt 之间需保持空行。
12. 思考完成标记
- 思考完成行使用 ∙ 而不是 ○。
- ∙ 行与思考内容整体左移一个空格以对齐工具调用的 • 前缀。
13. /system.txt 增加模型变量
- system prompt 里加入当前使用模型(用请求模型名称)。
14. 取消时错误显示
- 在思考时按 Esc不能重复显示思考内容只显示“停止思考 + 已取消本次响应”。
15. 运行指令取消空行
- 工具取消输出行与后续 prompt 之间需空一行。

4094
edited_records.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,9 @@
你是 easyagent一个极简终端智能体。 你是 EasyAgent一个极简终端智能体。
目标:帮助用户完成开发任务,优先高信息密度输出。 目标:帮助用户完成开发任务,优先高信息密度输出。
输出限制:禁止使用 Markdownmd格式内容无法渲染必须使用纯文字格式输出。 输出限制:禁止使用 Markdownmd格式内容无法渲染必须使用纯文字格式输出。
- 当前时间:{current_time} - 当前时间:{current_time}
- 当前模型:{model_id}
- 工作区路径:{path} - 工作区路径:{path}
- 系统信息:{system} - 系统信息:{system}
- 终端类型:{terminal} - 终端类型:{terminal}

BIN
src/.DS_Store vendored

Binary file not shown.

View File

@ -144,7 +144,7 @@ async function handleCommand(input, ctx) {
value: 'full_access', value: 'full_access',
}, },
]; ];
const selected = await runSelect({ rl, message: 'Update Permissions', choices, pageSize: 6 }); const selected = await runSelect({ rl, message: '', choices, pageSize: 6 });
if (selected) { if (selected) {
state.allowMode = selected; state.allowMode = selected;
console.log(`运行模式已切换为: ${state.allowMode}`); console.log(`运行模式已切换为: ${state.allowMode}`);
@ -157,19 +157,24 @@ async function handleCommand(input, ctx) {
const modelChoices = [ const modelChoices = [
{ name: `1. Kimi${state.modelKey === 'kimi-k2.5' ? ' (current)' : ''}`, value: 'kimi-k2.5' }, { name: `1. Kimi${state.modelKey === 'kimi-k2.5' ? ' (current)' : ''}`, value: 'kimi-k2.5' },
]; ];
const model = await runSelect({ rl, message: 'Update Model', choices: modelChoices, pageSize: 6 }); const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 });
if (!model) return { exit: false }; if (!model) return { exit: false };
state.modelKey = model; state.modelKey = model;
state.modelId = config.model_id || 'kimi-k2.5'; state.modelId = config.model_id || 'kimi-k2.5';
console.log('');
console.log(`模型已切换为: ${state.modelKey}`);
console.log('');
const thinkingChoices = [ const thinkingChoices = [
{ name: `1. Fast${!state.thinkingMode ? ' (current)' : ''}`, value: 'fast' }, { name: `1. Fast${!state.thinkingMode ? ' (current)' : ''}`, value: 'fast' },
{ name: `2. Thinking${state.thinkingMode ? ' (current)' : ''}`, value: 'thinking' }, { name: `2. Thinking${state.thinkingMode ? ' (current)' : ''}`, value: 'thinking' },
]; ];
const mode = await runSelect({ rl, message: 'Update Thinking Mode', choices: thinkingChoices, pageSize: 6 }); const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 });
if (mode) { if (mode) {
state.thinkingMode = mode === 'thinking'; state.thinkingMode = mode === 'thinking';
console.log(`模型已切换为: ${state.modelKey} | 思考模式: ${mode}`); console.log('');
console.log(`思考模式: ${mode}`);
console.log('');
persist(); persist();
} }
return { exit: false }; return { exit: false };

View File

@ -20,6 +20,7 @@ const { createConversation, updateConversation } = require('../storage/conversat
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 } = require('../utils/colors');
const { createIndentedWriter } = require('../ui/indented_writer'); const { createIndentedWriter } = require('../ui/indented_writer');
const { createStatusBar } = require('../ui/status_bar');
const WORKSPACE = process.cwd(); const WORKSPACE = process.cwd();
const WORKSPACE_NAME = path.basename(WORKSPACE); const WORKSPACE_NAME = path.basename(WORKSPACE);
@ -46,8 +47,11 @@ renderBanner({
workspace: WORKSPACE, workspace: WORKSPACE,
conversationId: state.conversation?.id, conversationId: state.conversation?.id,
}); });
console.log('');
console.log('');
let rl = null; let rl = null;
let statusBar = null;
let commandMenuActive = false; let commandMenuActive = false;
let menuSearchTerm = ''; let menuSearchTerm = '';
let menuLastSearchTerm = ''; let menuLastSearchTerm = '';
@ -60,6 +64,12 @@ if (process.stdin.isTTY) process.stdin.setRawMode(true);
initReadline(); initReadline();
statusBar = createStatusBar({
getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0,
maxTokens: '256k',
});
statusBar.render();
process.stdin.on('keypress', (str, key) => { process.stdin.on('keypress', (str, key) => {
if (commandMenuActive) { if (commandMenuActive) {
if (key && key.name === 'backspace' && menuSearchTerm === '') { if (key && key.name === 'backspace' && menuSearchTerm === '') {
@ -119,7 +129,7 @@ process.stdin.on('keypress', (str, key) => {
readline.clearLine(process.stdout, 0); readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0); readline.cursorTo(process.stdout, 0);
} }
rl.prompt(true); promptWithStatus(true);
if (menuInjectedCommand) { if (menuInjectedCommand) {
const injected = menuInjectedCommand; const injected = menuInjectedCommand;
menuInjectedCommand = null; menuInjectedCommand = null;
@ -143,7 +153,7 @@ function initReadline() {
process.stdin.resume(); process.stdin.resume();
rl.setPrompt(PROMPT); rl.setPrompt(PROMPT);
rl.prompt(); promptWithStatus();
rl.on('line', async (line) => { rl.on('line', async (line) => {
if (rl) { if (rl) {
@ -173,14 +183,14 @@ function initReadline() {
menuLastSearchTerm = ''; menuLastSearchTerm = '';
} }
if (!input) { if (!input) {
rl.prompt(); promptWithStatus();
return; return;
} }
if (input.startsWith('/')) { if (input.startsWith('/')) {
const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE }); const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE });
if (result && result.exit) return; if (result && result.exit) return;
rl.prompt(); promptWithStatus();
return; return;
} }
@ -190,10 +200,11 @@ function initReadline() {
state.messages.push({ role: 'user', content: line }); state.messages.push({ role: 'user', content: line });
persistConversation(); persistConversation();
await runAssistantLoop(); await runAssistantLoop();
rl.prompt(); promptWithStatus();
}); });
rl.on('close', () => { rl.on('close', () => {
if (statusBar) statusBar.destroy();
process.stdout.write('\n'); process.stdout.write('\n');
process.exit(0); process.exit(0);
}); });
@ -224,6 +235,19 @@ function persistConversation() {
}); });
} }
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() { function buildApiMessages() {
const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode }); const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode });
const messages = [{ role: 'system', content: system }]; const messages = [{ role: 'system', content: system }];
@ -256,6 +280,7 @@ async function runAssistantLoop() {
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');
if (statusBar) statusBar.setMode('running');
while (continueLoop) { while (continueLoop) {
hideCursor(); hideCursor();
const spinner = new Spinner(' ', firstLoop ? 1 : 0); const spinner = new Spinner(' ', firstLoop ? 1 : 0);
@ -418,4 +443,5 @@ async function runAssistantLoop() {
continueLoop = false; continueLoop = false;
} }
showCursor(); showCursor();
if (statusBar) statusBar.setMode('input');
} }

View File

@ -37,7 +37,7 @@ function buildSystemPrompt(basePrompt, opts) {
const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
let prompt = basePrompt; let prompt = basePrompt;
const allowModeValue = opts.allowMode === 'read_only' const allowModeValue = opts.allowMode === 'read_only'
? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用请告知当前无权限需要请求切换权限。` ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用请告知当前无权限需要用户输入 /allow 切换权限。`
: opts.allowMode; : opts.allowMode;
const replacements = { const replacements = {
current_time: localIso, current_time: localIso,
@ -48,6 +48,7 @@ function buildSystemPrompt(basePrompt, opts) {
allow_mode: allowModeValue, allow_mode: allowModeValue,
permissions: allowModeValue, permissions: allowModeValue,
git: getGitInfo(opts.workspace), git: getGitInfo(opts.workspace),
model_id: opts.modelId || '',
}; };
for (const [key, value] of Object.entries(replacements)) { for (const [key, value] of Object.entries(replacements)) {
const token = `{${key}}`; const token = `{${key}}`;

View File

@ -7,7 +7,7 @@ function createState(config, workspace) {
modelKey: config.default_model_key || 'kimi-k2.5', modelKey: config.default_model_key || 'kimi-k2.5',
modelId: config.model_id || 'kimi-k2.5', modelId: config.model_id || 'kimi-k2.5',
thinkingMode: true, thinkingMode: true,
tokenUsage: 0, tokenUsage: { prompt: 0, completion: 0, total: 0 },
conversation: null, conversation: null,
messages: [], messages: [],
}; };

View File

@ -2,7 +2,7 @@
const { getModelProfile } = require('./model_profiles'); const { getModelProfile } = require('./model_profiles');
async function* streamChat({ config, messages, tools, thinkingMode }) { async function* streamChat({ config, messages, tools, thinkingMode, abortSignal }) {
const profile = getModelProfile(config); const profile = getModelProfile(config);
const url = `${profile.base_url}/chat/completions`; const url = `${profile.base_url}/chat/completions`;
const headers = { const headers = {
@ -31,8 +31,14 @@ async function* streamChat({ config, messages, tools, thinkingMode }) {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: abortSignal,
}); });
} catch (err) { } catch (err) {
if (abortSignal && abortSignal.aborted) {
const error = new Error('请求已取消');
error.code = 'aborted';
throw error;
}
const cause = err && err.cause ? ` (${err.cause.code || err.cause.message || err.cause})` : ''; const cause = err && err.cause ? ` (${err.cause.code || err.cause.message || err.cause})` : '';
throw new Error(`请求失败: ${err.message || err}${cause}`); throw new Error(`请求失败: ${err.message || err}${cause}`);
} }
@ -47,6 +53,11 @@ async function* streamChat({ config, messages, tools, thinkingMode }) {
let buffer = ''; let buffer = '';
while (true) { while (true) {
if (abortSignal && abortSignal.aborted) {
const error = new Error('请求已取消');
error.code = 'aborted';
throw error;
}
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });

View File

@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { toISO } = require('../utils/time'); const { toISO } = require('../utils/time');
const { normalizeTokenUsage } = require('../utils/token_usage');
function getWorkspaceStore(workspace) { function getWorkspaceStore(workspace) {
const base = path.join(workspace, '.easyagent'); const base = path.join(workspace, '.easyagent');
@ -33,9 +34,18 @@ function saveIndex(indexFile, index) {
function extractTitle(messages) { function extractTitle(messages) {
for (const msg of messages) { for (const msg of messages) {
if (msg.role === 'user' && typeof msg.content === 'string' && msg.content.trim()) { if (msg.role === 'user') {
const content = msg.content.trim(); let content = '';
return content.length > 50 ? `${content.slice(0, 50)}...` : content; if (typeof msg.content === 'string') {
content = msg.content.trim();
} else if (Array.isArray(msg.content)) {
content = msg.content
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text)
.join(' ')
.trim();
}
if (content) return content.length > 50 ? `${content.slice(0, 50)}...` : content;
} }
} }
return '新对话'; return '新对话';
@ -82,7 +92,7 @@ function createConversation(workspace, metadata = {}) {
model_id: metadata.model_id || 'kimi-k2.5', model_id: metadata.model_id || 'kimi-k2.5',
thinking_mode: !!metadata.thinking_mode, thinking_mode: !!metadata.thinking_mode,
allow_mode: metadata.allow_mode || 'full_access', allow_mode: metadata.allow_mode || 'full_access',
token_usage: metadata.token_usage || 0, token_usage: normalizeTokenUsage(metadata.token_usage),
cwd: metadata.cwd || '', cwd: metadata.cwd || '',
}, },
messages: [], messages: [],
@ -123,6 +133,9 @@ function updateConversation(workspace, conversation, messages, metadataUpdates =
updated.metadata = { updated.metadata = {
...conversation.metadata, ...conversation.metadata,
...metadataUpdates, ...metadataUpdates,
token_usage: metadataUpdates && Object.prototype.hasOwnProperty.call(metadataUpdates, 'token_usage')
? normalizeTokenUsage(metadataUpdates.token_usage)
: normalizeTokenUsage(conversation.metadata?.token_usage),
}; };
saveConversation(workspace, updated); saveConversation(workspace, updated);
return updated; return updated;

View File

@ -138,7 +138,7 @@ function buildToolContent(result) {
return null; return null;
} }
async function executeTool({ workspace, config, allowMode, toolCall }) { async function executeTool({ workspace, config, allowMode, toolCall, abortSignal }) {
const name = toolCall.function.name; const name = toolCall.function.name;
let args = {}; let args = {};
try { try {
@ -153,7 +153,7 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
} }
if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) { if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) {
const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用先请求切换权限。'; const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用告知当前无权限需要用户输入 /allow 切换权限。';
return { return {
success: false, success: false,
tool: name, tool: name,
@ -162,11 +162,21 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
}; };
} }
if (abortSignal && abortSignal.aborted) {
return {
success: false,
tool: name,
error: '任务被用户取消',
formatted: '任务被用户取消',
raw: { success: false, error: '任务被用户取消', cancelled: true },
};
}
let raw; let raw;
if (name === 'read_file') raw = readFileTool(workspace, args); if (name === 'read_file') raw = readFileTool(workspace, args);
else if (name === 'edit_file') raw = editFileTool(workspace, args); else if (name === 'edit_file') raw = editFileTool(workspace, args);
else if (name === 'run_command') raw = await runCommandTool(workspace, args); else if (name === 'run_command') raw = await runCommandTool(workspace, args, abortSignal);
else if (name === 'web_search') raw = await webSearchTool(config, args); else if (name === 'web_search') raw = await webSearchTool(config, args, abortSignal);
else if (name === 'extract_webpage') { else if (name === 'extract_webpage') {
if (!args.mode) args.mode = 'read'; if (!args.mode) args.mode = 'read';
const targetPath = args.target_path ? (path.isAbsolute(args.target_path) ? args.target_path : path.join(workspace, args.target_path)) : null; const targetPath = args.target_path ? (path.isAbsolute(args.target_path) ? args.target_path : path.join(workspace, args.target_path)) : null;
@ -174,10 +184,10 @@ async function executeTool({ workspace, config, allowMode, toolCall }) {
if (!targetPath || !targetPath.toLowerCase().endsWith('.md')) { if (!targetPath || !targetPath.toLowerCase().endsWith('.md')) {
raw = { success: false, error: 'target_path 必须是 .md 文件' }; raw = { success: false, error: 'target_path 必须是 .md 文件' };
} else { } else {
raw = await extractWebpageTool(config, args, targetPath); raw = await extractWebpageTool(config, args, targetPath, abortSignal);
} }
} else { } else {
raw = await extractWebpageTool(config, args, targetPath); raw = await extractWebpageTool(config, args, targetPath, abortSignal);
} }
} else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args); } else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args);
else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args); else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args);

View File

@ -45,19 +45,26 @@ function editFileTool(workspace, args) {
const oldString = args.old_string ?? ''; const oldString = args.old_string ?? '';
const newString = args.new_string ?? ''; const newString = args.new_string ?? '';
try { try {
let creating = false;
if (!fs.existsSync(target)) { if (!fs.existsSync(target)) {
return { success: false, error: '文件不存在' }; creating = true;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, '', 'utf8');
} }
const stat = fs.statSync(target); const stat = fs.statSync(target);
if (!stat.isFile()) { if (!stat.isFile()) {
return { success: false, error: '目标不是文件' }; return { success: false, error: '目标不是文件' };
} }
const original = fs.readFileSync(target, 'utf8'); const original = fs.readFileSync(target, 'utf8');
if (!original.includes(oldString)) { if (!creating && oldString === '') {
return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' };
}
if (!creating && !original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' }; return { success: false, error: 'old_string 未匹配到内容' };
} }
const updated = original.split(oldString).join(newString); const updated = creating ? newString : original.split(oldString).join(newString);
const replacements = original.split(oldString).length - 1; let replacements = creating ? 0 : original.split(oldString).length - 1;
if (creating && newString) replacements = 1;
fs.writeFileSync(target, updated, 'utf8'); fs.writeFileSync(target, updated, 'utf8');
const diff = diffSummary(original, updated); const diff = diffSummary(original, updated);
return { return {

View File

@ -9,14 +9,21 @@ function resolvePath(workspace, p) {
return path.join(workspace, p); return path.join(workspace, p);
} }
function runCommandTool(workspace, args) { function runCommandTool(workspace, args, abortSignal) {
return new Promise((resolve) => { return new Promise((resolve) => {
const cmd = args.command; const cmd = args.command;
const timeoutSec = Number(args.timeout || 0); const timeoutSec = Number(args.timeout || 0);
const cwd = resolvePath(workspace, args.working_dir || '.'); const cwd = resolvePath(workspace, args.working_dir || '.');
if (!cmd) return resolve({ success: false, error: 'command 不能为空' }); if (!cmd) return resolve({ success: false, error: 'command 不能为空' });
if (abortSignal && abortSignal.aborted) {
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
}
const timeoutMs = Math.max(0, timeoutSec) * 1000; const timeoutMs = Math.max(0, timeoutSec) * 1000;
exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => { let finished = false;
const child = exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => {
if (finished) return;
finished = true;
if (abortSignal) abortSignal.removeEventListener('abort', onAbort);
const output = [stdout, stderr].filter(Boolean).join(''); const output = [stdout, stderr].filter(Boolean).join('');
if (err) { if (err) {
const isTimeout = err.killed && err.signal === 'SIGTERM'; const isTimeout = err.killed && err.signal === 'SIGTERM';
@ -31,6 +38,15 @@ function runCommandTool(workspace, args) {
} }
resolve({ success: true, status: 'ok', output }); resolve({ success: true, status: 'ok', output });
}); });
const onAbort = () => {
if (finished) return;
finished = true;
try {
child.kill('SIGTERM');
} catch (_) {}
return resolve({ success: false, error: '任务被用户取消', cancelled: true });
};
if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true });
}); });
} }

View File

@ -1,6 +1,9 @@
'use strict'; 'use strict';
async function webSearchTool(config, args) { async function webSearchTool(config, args, abortSignal) {
if (abortSignal && abortSignal.aborted) {
return { success: false, error: '任务被用户取消', cancelled: true };
}
const body = { const body = {
api_key: config.tavily_api_key, api_key: config.tavily_api_key,
query: args.query, query: args.query,
@ -18,6 +21,7 @@ async function webSearchTool(config, args) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: abortSignal,
}); });
const json = await res.json(); const json = await res.json();
if (!res.ok) { if (!res.ok) {
@ -25,11 +29,17 @@ async function webSearchTool(config, args) {
} }
return { success: true, ...json, query: args.query, searched_at: new Date().toISOString() }; return { success: true, ...json, query: args.query, searched_at: new Date().toISOString() };
} catch (err) { } catch (err) {
if (err && err.name === 'AbortError') {
return { success: false, error: '任务被用户取消', cancelled: true };
}
return { success: false, error: err.message || String(err) }; return { success: false, error: err.message || String(err) };
} }
} }
async function extractWebpageTool(config, args, savePath) { async function extractWebpageTool(config, args, savePath, abortSignal) {
if (abortSignal && abortSignal.aborted) {
return { success: false, error: '任务被用户取消', cancelled: true };
}
const body = { const body = {
api_key: config.tavily_api_key, api_key: config.tavily_api_key,
urls: [args.url], urls: [args.url],
@ -40,6 +50,7 @@ async function extractWebpageTool(config, args, savePath) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: abortSignal,
}); });
const json = await res.json(); const json = await res.json();
if (!res.ok) { if (!res.ok) {
@ -53,6 +64,9 @@ async function extractWebpageTool(config, args, savePath) {
} }
return { success: true, content, url: args.url }; return { success: true, content, url: args.url };
} catch (err) { } catch (err) {
if (err && err.name === 'AbortError') {
return { success: false, error: '任务被用户取消', cancelled: true };
}
return { success: false, error: err.message || String(err) }; return { success: false, error: err.message || String(err) };
} }
} }

View File

@ -14,6 +14,14 @@ const COMMAND_CHOICES = [
{ value: '/exit', desc: '退出程序' }, { value: '/exit', desc: '退出程序' },
]; ];
function hasCommandMatch(term) {
if (!term) return false;
const t = term.toLowerCase();
return COMMAND_CHOICES.some(
(c) => c.value.toLowerCase().includes(t) || c.desc.toLowerCase().includes(t)
);
}
async function openCommandMenu(options) { async function openCommandMenu(options) {
const { rl, prompt, pageSize, colorEnabled, resetAnsi, onInput, abortSignal } = options; const { rl, prompt, pageSize, colorEnabled, resetAnsi, onInput, abortSignal } = options;
let latestInput = ''; let latestInput = '';
@ -37,6 +45,7 @@ async function openCommandMenu(options) {
message: (text) => text, message: (text) => text,
searchTerm: (text) => `/${(text || '').replace(/^\/+/, '')}`, searchTerm: (text) => `/${(text || '').replace(/^\/+/, '')}`,
highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text), highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text),
error: () => '无效的指令',
keysHelpTip: () => '', keysHelpTip: () => '',
description: (text) => text, description: (text) => text,
answer: () => '', answer: () => '',
@ -77,4 +86,4 @@ async function openCommandMenu(options) {
} }
} }
module.exports = { openCommandMenu }; module.exports = { openCommandMenu, hasCommandMatch };

103
src/ui/status_bar.js Normal file
View File

@ -0,0 +1,103 @@
'use strict';
const readline = require('readline');
const { visibleWidth, truncatePlain } = require('../utils/text_width');
function truncateNoEllipsis(text, maxCols) {
const out = truncatePlain(text, maxCols);
if (out.endsWith('...') && visibleWidth(text) > maxCols) {
return out.slice(0, -3);
}
return out;
}
function createStatusBar({ getTokens, maxTokens }) {
let mode = 'input';
let enabled = true;
let scrollApplied = false;
let lastRows = null;
const render = () => {
if (!enabled || !process.stdout.isTTY) return;
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows || 24;
if (rows < 3) return;
const left = mode === 'running' ? '按下Esc停止' : '输入/查看所有指令';
const total = typeof getTokens === 'function' ? getTokens() : 0;
const formatCount = (value) => {
const num = Number(value) || 0;
if (num < 1000) return String(num);
const k = Math.round((num / 1000) * 10) / 10;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
};
const right = `当前上下文 ${formatCount(total)}/${maxTokens}`;
const rightTail = ` ${formatCount(total)}/${maxTokens}`;
const leftWidth = visibleWidth(left);
const rightWidth = visibleWidth(right);
const usableCols = Math.max(1, cols - 1);
let safeLine = '';
const availableLeft = usableCols - rightWidth - 1;
if (availableLeft >= 1) {
const displayLeft = truncateNoEllipsis(left, availableLeft);
const gap = usableCols - visibleWidth(displayLeft) - rightWidth;
safeLine = `${displayLeft}${' '.repeat(Math.max(0, gap))}${right}`;
} else {
safeLine = truncateNoEllipsis(right, usableCols);
}
process.stdout.write('\x1b7'); // save cursor
if (!scrollApplied || lastRows !== rows) {
process.stdout.write('\x1b[r'); // reset scroll region
process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines
scrollApplied = true;
lastRows = rows;
}
// keep one blank spacer line above status line
readline.cursorTo(process.stdout, 0, rows - 2);
readline.clearLine(process.stdout, 0);
// render status line on last row
readline.cursorTo(process.stdout, 0, rows - 1);
readline.clearLine(process.stdout, 0);
process.stdout.write('\x1b[?7l'); // disable auto-wrap
process.stdout.write(safeLine);
process.stdout.write('\x1b[?7h'); // re-enable auto-wrap
process.stdout.write('\x1b8'); // restore cursor
};
const setMode = (nextMode) => {
if (mode === nextMode) return;
mode = nextMode;
render();
};
const setEnabled = (nextEnabled) => {
enabled = nextEnabled;
if (enabled) render();
};
const destroy = () => {
if (!process.stdout.isTTY) return;
if (scrollApplied) {
process.stdout.write('\x1b[r'); // reset scroll region
scrollApplied = false;
}
const rows = process.stdout.rows || 24;
if (rows >= 2) {
readline.cursorTo(process.stdout, 0, rows - 2);
readline.clearLine(process.stdout, 0);
}
if (rows >= 1) {
readline.cursorTo(process.stdout, 0, rows - 1);
readline.clearLine(process.stdout, 0);
}
};
if (process.stdout.isTTY) {
process.stdout.on('resize', () => render());
}
return { render, setMode, setEnabled, destroy };
}
module.exports = { createStatusBar };

View File

@ -130,6 +130,7 @@ function startToolDisplay(line) {
function formatResultLines(name, args, raw) { function formatResultLines(name, args, raw) {
if (!raw || raw.success === false) { if (!raw || raw.success === false) {
const msg = raw && raw.error ? raw.error : '执行失败'; const msg = raw && raw.error ? raw.error : '执行失败';
if (msg === '任务被用户取消') return [red(msg)];
return [red(`失败: ${msg}`)]; return [red(`失败: ${msg}`)];
} }
if (name === 'run_command') { if (name === 'run_command') {
@ -216,6 +217,7 @@ function printResultLines(lines) {
process.stdout.write(` ${line}\n`); process.stdout.write(` ${line}\n`);
} }
}); });
process.stdout.write('\n');
} }
module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines }; module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines };

28
src/utils/token_usage.js Normal file
View File

@ -0,0 +1,28 @@
'use strict';
function normalizeTokenUsage(value) {
if (value && typeof value === 'object') {
return {
prompt: Number(value.prompt) || 0,
completion: Number(value.completion) || 0,
total: Number(value.total) || 0,
};
}
const num = Number(value);
return {
prompt: 0,
completion: 0,
total: Number.isFinite(num) ? num : 0,
};
}
function applyUsage(base, usage) {
const normalized = normalizeTokenUsage(base);
if (!usage) return normalized;
if (Number.isFinite(usage.prompt_tokens)) normalized.prompt += usage.prompt_tokens;
if (Number.isFinite(usage.completion_tokens)) normalized.completion += usage.completion_tokens;
if (Number.isFinite(usage.total_tokens)) normalized.total = usage.total_tokens;
return normalized;
}
module.exports = { normalizeTokenUsage, applyUsage };