• Edited src/ui/spinner.js (+14 -0) 16 this.leadingWritten = false; 17 + this.thinkingLineReady = false; 18 } ⋮ 26 this.active = true; 27 + this.thinkingLineReady = false; 28 if (this.leadingLines > 0 && !this.leadingWritten) { ⋮ 35 36 + ensureThinkingLine() { 37 + if (this.thinkingLineReady) return; 38 + process.stdout.write('\n'); 39 + this.thinkingLineReady = true; 40 + readline.moveCursor(process.stdout, 0, -1); 41 + readline.cursorTo(process.stdout, 0); 42 + } 43 + 44 render() { ⋮ 64 if (hasThinkingLine) { 65 + this.ensureThinkingLine(); 66 const width = process.stdout.columns || 80; ⋮ 102 if (hasThinkingLine) { 103 + this.ensureThinkingLine(); 104 const width = process.stdout.columns || 80; ⋮ 126 } 127 + this.thinkingLineReady = false; 128 } ⋮ 133 this.active = false; 134 + this.thinkingLineReady = false; 135 readline.clearLine(process.stdout, 0); • Edited src/tools/edit_file.js (+7 -0) 47 try { 48 + if (!fs.existsSync(target)) { 49 + return { success: false, error: '文件不存在' }; 50 + } 51 + const stat = fs.statSync(target); 52 + if (!stat.isFile()) { 53 + return { success: false, error: '目标不是文件' }; 54 + } 55 const original = fs.readFileSync(target, 'utf8'); • Edited src/ui/tool_display.js (+33 -2) 21 22 +function splitCommandLines(command) { 23 + const text = String(command ?? ''); 24 + const parts = text.split(/\r?\n/); 25 + if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop(); 26 + return parts.length ? parts : ['']; 27 +} 28 + 29 +function buildRunCommandLines(label, command) { 30 + const parts = splitCommandLines(command); 31 + const firstLine = parts[0] ?? ''; 32 + const startLine = `${label} ${firstLine}`; 33 + if (parts.length <= 1) { 34 + return { startLine, finalLine: startLine }; 35 + } 36 + const preview = parts.slice(1, 3); 37 + const lines = [startLine, ...preview.map((line) => ` ${line}`), ` 总指令${parts.length}行`]; 38 + return { startLine, finalLine: lines.join('\n') }; 39 +} 40 + 41 function buildStartLine(name, args) { ⋮ 43 if (name === 'run_command') { 25 - return `${label} ${args.command} (timeout=${args.timeout}s)`; 44 + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; 45 + const { startLine } = buildRunCommandLines(label, command); 46 + return `${startLine} (timeout=${args.timeout}s)`; 47 } ⋮ 70 71 +function buildFinalLine(name, args) { 72 + if (name === 'run_command') { 73 + const label = blue(toolNameMap(name)); 74 + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; 75 + const { finalLine } = buildRunCommandLines(label, command); 76 + return `${finalLine} (timeout=${args.timeout}s)`; 77 + } 78 + return buildStartLine(name, args); 79 +} 80 + 81 function startToolDisplay(line) { ⋮ 112 const tail = lines.slice(-5); 82 - const summary = '运行完成'; 113 + const summary = '运行结果'; 114 return [summary, ...tail]; • Edited src/ui/tool_display.js (+6 -6) 28 29 -function buildRunCommandLines(label, command) { 29 +function buildRunCommandLines(label, command, suffix = '') { 30 const parts = splitCommandLines(command); 31 const firstLine = parts[0] ?? ''; 32 - const startLine = `${label} ${firstLine}`; 32 + const startLine = `${label} ${firstLine}${suffix}`; 33 if (parts.length <= 1) { ⋮ 44 const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; 45 - const { startLine } = buildRunCommandLines(label, command); 46 - return `${startLine} (timeout=${args.timeout}s)`; 45 + const { startLine } = buildRunCommandLines(label, command, ` (timeout=${args.timeout}s)`); 46 + return startLine; 47 } ⋮ 74 const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; 75 - const { finalLine } = buildRunCommandLines(label, command); 76 - return `${finalLine} (timeout=${args.timeout}s)`; 75 + const { finalLine } = buildRunCommandLines(label, command, ` (timeout=${args.timeout}s)`); 76 + return finalLine; 77 } • Edited src/ui/tool_display.js (+1 -1) 194 195 -module.exports = { buildStartLine, startToolDisplay, formatResultLines, printResultLines }; 195 +module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines }; • Edited src/cli/index.js (+5 -4) 16 const { Spinner, truncateThinking } = require('../ui/spinner'); 17 -const { buildStartLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); 17 +const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); 18 const { createConversation, updateConversation } = require('../storage/conversation_store'); ⋮ 341 try { args = JSON.parse(call.function.arguments || '{}'); } catch (_) {} 342 - const line = buildStartLine(call.function.name, args); 343 - const indicator = startToolDisplay(line); 342 + const startLine = buildStartLine(call.function.name, args); 343 + const finalLine = buildFinalLine(call.function.name, args); 344 + const indicator = startToolDisplay(startLine); 345 const toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 345 - indicator.stop(line); 346 + indicator.stop(finalLine); 347 const resultLines = formatResultLines(call.function.name, args, toolResult.raw || { success: toolResult.success, error: t oolResult.error }); • Edited src/cli/commands.js (+2 -2) 7 const { runResumeMenu } = require('../ui/resume_menu'); 8 -const { buildStartLine, formatResultLines, printResultLines } = require('../ui/tool_display'); 8 +const { buildStartLine, buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display'); 9 const { createIndentedWriter } = require('../ui/indented_writer'); ⋮ 275 } 276 - const line = buildStartLine(name, args); 276 + const line = buildFinalLine(name, args); 277 // 静态回放:直接输出完成态 • Edited src/cli/commands.js (+1 -1) 7 const { runResumeMenu } = require('../ui/resume_menu'); 8 -const { buildStartLine, buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display'); 8 +const { buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display'); 9 const { createIndentedWriter } = require('../ui/indented_writer'); • Edited src/ui/tool_display.js (+56 -4) 7 const DOT_OFF = '◦'; 8 +const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g; 9 ⋮ 22 23 +function stripAnsi(text) { 24 + return String(text ?? '').replace(ANSI_REGEX, ''); 25 +} 26 + 27 +function truncatePlain(text, maxCols) { 28 + const str = String(text ?? ''); 29 + if (maxCols <= 0) return ''; 30 + if (str.length <= maxCols) return str; 31 + if (maxCols <= 3) return str.slice(0, maxCols); 32 + return `${str.slice(0, maxCols - 3)}...`; 33 +} 34 + 35 +function truncateVisible(text, maxCols) { 36 + const str = String(text ?? ''); 37 + if (maxCols <= 0) return ''; 38 + if (stripAnsi(str).length <= maxCols) return str; 39 + const useEllipsis = maxCols > 3; 40 + const limit = maxCols - (useEllipsis ? 3 : 0); 41 + let out = ''; 42 + let visible = 0; 43 + const ansiRegex = new RegExp(ANSI_REGEX.source, 'y'); 44 + for (let i = 0; i < str.length && visible < limit; ) { 45 + ansiRegex.lastIndex = i; 46 + const match = ansiRegex.exec(str); 47 + if (match && match.index === i) { 48 + out += match[0]; 49 + i += match[0].length; 50 + continue; 51 + } 52 + out += str[i]; 53 + visible += 1; 54 + i += 1; 55 + } 56 + if (useEllipsis) out += '...'; 57 + if (str.includes('\x1b[')) out += '\x1b[0m'; 58 + return out; 59 +} 60 + 61 +function normalizePreviewLine(line) { 62 + return String(line ?? '').replace(/^\s+/, ''); 63 +} 64 + 65 function splitCommandLines(command) { ⋮ 72 function buildRunCommandLines(label, command, suffix = '') { 73 + const width = Number(process.stdout.columns) || 80; 74 const parts = splitCommandLines(command); 75 const firstLine = parts[0] ?? ''; 32 - const startLine = `${label} ${firstLine}${suffix}`; 76 + const labelLen = stripAnsi(label).length; 77 + const suffixLen = stripAnsi(suffix).length; 78 + const available = width - labelLen - 1 - suffixLen; 79 + const commandLine = available > 0 ? truncatePlain(firstLine, available) : ''; 80 + const startLine = truncateVisible(`${label} ${commandLine}${suffix}`, width); 81 if (parts.length <= 1) { ⋮ 83 } 36 - const preview = parts.slice(1, 3); 37 - const lines = [startLine, ...preview.map((line) => ` ${line}`), ` 总指令${parts.length}行`]; 84 + const preview = parts.slice(1, 3).map(normalizePreviewLine); 85 + const lines = [startLine, ...preview.map((line) => ` ${truncatePlain(line, Math.max(0, width - 2))}`), ` 总指令 ${parts.length}行` ]; 86 return { startLine, finalLine: lines.join('\n') }; ⋮ 130 let on = false; 131 + const rawLine = String(line ?? '').replace(/\r?\n/g, ' '); 132 function render() { ⋮ 135 const dot = on ? DOT_ON : DOT_OFF; 87 - process.stdout.write(`${dot} ${line}`); 136 + const width = Number(process.stdout.columns) || 80; 137 + const maxLine = Math.max(0, width - 2); 138 + const displayLine = truncateVisible(rawLine, maxLine); 139 + process.stdout.write(`${dot} ${displayLine}`); 140 } • Edited src/ui/tool_display.js (+17 -6) 131 const rawLine = String(line ?? '').replace(/\r?\n/g, ' '); 132 + let lastLines = 1; 133 + function clearPrevious() { 134 + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); 135 + for (let i = 0; i < lastLines; i += 1) { 136 + readline.clearLine(process.stdout, 0); 137 + if (i < lastLines - 1) readline.moveCursor(process.stdout, 0, 1); 138 + } 139 + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); 140 + readline.cursorTo(process.stdout, 0); 141 + } 142 function render() { 133 - readline.clearLine(process.stdout, 0); 134 - readline.cursorTo(process.stdout, 0); 143 + clearPrevious(); 144 const dot = on ? DOT_ON : DOT_OFF; 145 const width = Number(process.stdout.columns) || 80; 137 - const maxLine = Math.max(0, width - 2); 146 + const maxLine = Math.max(0, width - 3); 147 const displayLine = truncateVisible(rawLine, maxLine); 139 - process.stdout.write(`${dot} ${displayLine}`); 148 + const lineText = `${dot} ${displayLine}`; 149 + process.stdout.write(lineText); 150 + const lineLen = stripAnsi(lineText).length; 151 + lastLines = Math.max(1, Math.ceil(lineLen / Math.max(1, width))); 152 } ⋮ 160 clearInterval(timer); 149 - readline.clearLine(process.stdout, 0); 150 - readline.cursorTo(process.stdout, 0); 161 + clearPrevious(); 162 process.stdout.write(`${DOT_ON} ${finalLine}\n`); • Edited src/ui/tool_display.js (+384 -12) 8 const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g; 9 +const COMBINING_RANGES = [ 10 + [0x0300, 0x036f], 11 + [0x0483, 0x0489], 12 + [0x0591, 0x05bd], 13 + [0x05bf, 0x05bf], 14 + [0x05c1, 0x05c2], 15 + [0x05c4, 0x05c5], 16 + [0x05c7, 0x05c7], 17 + [0x0610, 0x061a], 18 + [0x064b, 0x065f], 19 + [0x0670, 0x0670], 20 + [0x06d6, 0x06dc], 21 + [0x06df, 0x06e4], 22 + [0x06e7, 0x06e8], 23 + [0x06ea, 0x06ed], 24 + [0x0711, 0x0711], 25 + [0x0730, 0x074a], 26 + [0x07a6, 0x07b0], 27 + [0x07eb, 0x07f3], 28 + [0x0816, 0x0819], 29 + [0x081b, 0x0823], 30 + [0x0825, 0x0827], 31 + [0x0829, 0x082d], 32 + [0x0859, 0x085b], 33 + [0x0900, 0x0902], 34 + [0x093a, 0x093a], 35 + [0x093c, 0x093c], 36 + [0x0941, 0x0948], 37 + [0x094d, 0x094d], 38 + [0x0951, 0x0957], 39 + [0x0962, 0x0963], 40 + [0x0981, 0x0981], 41 + [0x09bc, 0x09bc], 42 + [0x09c1, 0x09c4], 43 + [0x09cd, 0x09cd], 44 + [0x09e2, 0x09e3], 45 + [0x0a01, 0x0a02], 46 + [0x0a3c, 0x0a3c], 47 + [0x0a41, 0x0a42], 48 + [0x0a47, 0x0a48], 49 + [0x0a4b, 0x0a4d], 50 + [0x0a51, 0x0a51], 51 + [0x0a70, 0x0a71], 52 + [0x0a75, 0x0a75], 53 + [0x0a81, 0x0a82], 54 + [0x0abc, 0x0abc], 55 + [0x0ac1, 0x0ac5], 56 + [0x0ac7, 0x0ac8], 57 + [0x0acd, 0x0acd], 58 + [0x0ae2, 0x0ae3], 59 + [0x0b01, 0x0b01], 60 + [0x0b3c, 0x0b3c], 61 + [0x0b3f, 0x0b3f], 62 + [0x0b41, 0x0b44], 63 + [0x0b4d, 0x0b4d], 64 + [0x0b56, 0x0b56], 65 + [0x0b62, 0x0b63], 66 + [0x0b82, 0x0b82], 67 + [0x0bc0, 0x0bc0], 68 + [0x0bcd, 0x0bcd], 69 + [0x0c00, 0x0c00], 70 + [0x0c3e, 0x0c40], 71 + [0x0c46, 0x0c48], 72 + [0x0c4a, 0x0c4d], 73 + [0x0c55, 0x0c56], 74 + [0x0c62, 0x0c63], 75 + [0x0c81, 0x0c81], 76 + [0x0cbc, 0x0cbc], 77 + [0x0cbf, 0x0cbf], 78 + [0x0cc6, 0x0cc6], 79 + [0x0ccc, 0x0ccd], 80 + [0x0ce2, 0x0ce3], 81 + [0x0d00, 0x0d01], 82 + [0x0d3b, 0x0d3c], 83 + [0x0d41, 0x0d44], 84 + [0x0d4d, 0x0d4d], 85 + [0x0d62, 0x0d63], 86 + [0x0dca, 0x0dca], 87 + [0x0dd2, 0x0dd4], 88 + [0x0dd6, 0x0dd6], 89 + [0x0e31, 0x0e31], 90 + [0x0e34, 0x0e3a], 91 + [0x0e47, 0x0e4e], 92 + [0x0eb1, 0x0eb1], 93 + [0x0eb4, 0x0ebc], 94 + [0x0ec8, 0x0ecd], 95 + [0x0f18, 0x0f19], 96 + [0x0f35, 0x0f35], 97 + [0x0f37, 0x0f37], 98 + [0x0f39, 0x0f39], 99 + [0x0f71, 0x0f7e], 100 + [0x0f80, 0x0f84], 101 + [0x0f86, 0x0f87], 102 + [0x0f8d, 0x0f97], 103 + [0x0f99, 0x0fbc], 104 + [0x0fc6, 0x0fc6], 105 + [0x102d, 0x1030], 106 + [0x1032, 0x1037], 107 + [0x1039, 0x103a], 108 + [0x103d, 0x103e], 109 + [0x1058, 0x1059], 110 + [0x105e, 0x1060], 111 + [0x1071, 0x1074], 112 + [0x1082, 0x1082], 113 + [0x1085, 0x1086], 114 + [0x108d, 0x108d], 115 + [0x109d, 0x109d], 116 + [0x135d, 0x135f], 117 + [0x1712, 0x1714], 118 + [0x1732, 0x1734], 119 + [0x1752, 0x1753], 120 + [0x1772, 0x1773], 121 + [0x17b4, 0x17b5], 122 + [0x17b7, 0x17bd], 123 + [0x17c6, 0x17c6], 124 + [0x17c9, 0x17d3], 125 + [0x17dd, 0x17dd], 126 + [0x180b, 0x180d], 127 + [0x1885, 0x1886], 128 + [0x18a9, 0x18a9], 129 + [0x1920, 0x1922], 130 + [0x1927, 0x1928], 131 + [0x1932, 0x1932], 132 + [0x1939, 0x193b], 133 + [0x1a17, 0x1a18], 134 + [0x1a1b, 0x1a1b], 135 + [0x1a56, 0x1a56], 136 + [0x1a58, 0x1a5e], 137 + [0x1a60, 0x1a60], 138 + [0x1a62, 0x1a62], 139 + [0x1a65, 0x1a6c], 140 + [0x1a73, 0x1a7c], 141 + [0x1a7f, 0x1a7f], 142 + [0x1ab0, 0x1ace], 143 + [0x1b00, 0x1b03], 144 + [0x1b34, 0x1b34], 145 + [0x1b36, 0x1b3a], 146 + [0x1b3c, 0x1b3c], 147 + [0x1b42, 0x1b42], 148 + [0x1b6b, 0x1b73], 149 + [0x1b80, 0x1b81], 150 + [0x1ba2, 0x1ba5], 151 + [0x1ba8, 0x1ba9], 152 + [0x1bab, 0x1bad], 153 + [0x1be6, 0x1be6], 154 + [0x1be8, 0x1be9], 155 + [0x1bed, 0x1bed], 156 + [0x1bef, 0x1bf1], 157 + [0x1c2c, 0x1c33], 158 + [0x1c36, 0x1c37], 159 + [0x1cd0, 0x1cd2], 160 + [0x1cd4, 0x1ce0], 161 + [0x1ce2, 0x1ce8], 162 + [0x1ced, 0x1ced], 163 + [0x1cf4, 0x1cf4], 164 + [0x1cf8, 0x1cf9], 165 + [0x1dc0, 0x1df9], 166 + [0x1dfb, 0x1dff], 167 + [0x200b, 0x200f], 168 + [0x202a, 0x202e], 169 + [0x2060, 0x2064], 170 + [0x2066, 0x206f], 171 + [0x20d0, 0x20f0], 172 + [0x2cef, 0x2cf1], 173 + [0x2d7f, 0x2d7f], 174 + [0x2de0, 0x2dff], 175 + [0x302a, 0x302f], 176 + [0x3099, 0x309a], 177 + [0xa66f, 0xa672], 178 + [0xa674, 0xa67d], 179 + [0xa69e, 0xa69f], 180 + [0xa6f0, 0xa6f1], 181 + [0xa802, 0xa802], 182 + [0xa806, 0xa806], 183 + [0xa80b, 0xa80b], 184 + [0xa825, 0xa826], 185 + [0xa8c4, 0xa8c5], 186 + [0xa8e0, 0xa8f1], 187 + [0xa926, 0xa92d], 188 + [0xa947, 0xa951], 189 + [0xa980, 0xa982], 190 + [0xa9b3, 0xa9b3], 191 + [0xa9b6, 0xa9b9], 192 + [0xa9bc, 0xa9bc], 193 + [0xa9e5, 0xa9e5], 194 + [0xaa29, 0xaa2e], 195 + [0xaa31, 0xaa32], 196 + [0xaa35, 0xaa36], 197 + [0xaa43, 0xaa43], 198 + [0xaa4c, 0xaa4c], 199 + [0xaa7c, 0xaa7c], 200 + [0xaab0, 0xaab0], 201 + [0xaab2, 0xaab4], 202 + [0xaab7, 0xaab8], 203 + [0xaabe, 0xaabf], 204 + [0xaac1, 0xaac1], 205 + [0xaaec, 0xaaed], 206 + [0xaaf6, 0xaaf6], 207 + [0xabe5, 0xabe5], 208 + [0xabe8, 0xabe8], 209 + [0xabed, 0xabed], 210 + [0xfb1e, 0xfb1e], 211 + [0xfe00, 0xfe0f], 212 + [0xfe20, 0xfe2f], 213 + [0xfeff, 0xfeff], 214 + [0xfff9, 0xfffb], 215 + [0x101fd, 0x101fd], 216 + [0x102e0, 0x102e0], 217 + [0x10376, 0x1037a], 218 + [0x10a01, 0x10a03], 219 + [0x10a05, 0x10a06], 220 + [0x10a0c, 0x10a0f], 221 + [0x10a38, 0x10a3a], 222 + [0x10a3f, 0x10a3f], 223 + [0x10ae5, 0x10ae6], 224 + [0x10d24, 0x10d27], 225 + [0x10eab, 0x10eac], 226 + [0x10f46, 0x10f50], 227 + [0x11001, 0x11001], 228 + [0x11038, 0x11046], 229 + [0x1107f, 0x11081], 230 + [0x110b3, 0x110b6], 231 + [0x110b9, 0x110ba], 232 + [0x110c2, 0x110c2], 233 + [0x11100, 0x11102], 234 + [0x11127, 0x1112b], 235 + [0x1112d, 0x11134], 236 + [0x11173, 0x11173], 237 + [0x11180, 0x11181], 238 + [0x111b6, 0x111be], 239 + [0x111c9, 0x111cc], 240 + [0x1122f, 0x11231], 241 + [0x11234, 0x11234], 242 + [0x11236, 0x11237], 243 + [0x1123e, 0x1123e], 244 + [0x112df, 0x112df], 245 + [0x112e3, 0x112ea], 246 + [0x11300, 0x11301], 247 + [0x1133b, 0x1133c], 248 + [0x11340, 0x11340], 249 + [0x11366, 0x1136c], 250 + [0x11370, 0x11374], 251 + [0x11438, 0x1143f], 252 + [0x11442, 0x11444], 253 + [0x11446, 0x11446], 254 + [0x1145e, 0x1145e], 255 + [0x114b3, 0x114b8], 256 + [0x114ba, 0x114ba], 257 + [0x114bf, 0x114c0], 258 + [0x114c2, 0x114c3], 259 + [0x115b2, 0x115b5], 260 + [0x115bc, 0x115bd], 261 + [0x115bf, 0x115c0], 262 + [0x115dc, 0x115dd], 263 + [0x11633, 0x1163a], 264 + [0x1163d, 0x1163d], 265 + [0x1163f, 0x11640], 266 + [0x116ab, 0x116ab], 267 + [0x116ad, 0x116ad], 268 + [0x116b0, 0x116b5], 269 + [0x116b7, 0x116b7], 270 + [0x1171d, 0x1171f], 271 + [0x11722, 0x11725], 272 + [0x11727, 0x1172b], 273 + [0x1182f, 0x11837], 274 + [0x11839, 0x1183a], 275 + [0x1193b, 0x1193c], 276 + [0x1193e, 0x1193e], 277 + [0x11943, 0x11943], 278 + [0x119d4, 0x119d7], 279 + [0x119da, 0x119db], 280 + [0x119e0, 0x119e0], 281 + [0x11a01, 0x11a0a], 282 + [0x11a33, 0x11a38], 283 + [0x11a3b, 0x11a3e], 284 + [0x11a47, 0x11a47], 285 + [0x11a51, 0x11a56], 286 + [0x11a59, 0x11a5b], 287 + [0x11a8a, 0x11a96], 288 + [0x11a98, 0x11a99], 289 + [0x11c30, 0x11c36], 290 + [0x11c38, 0x11c3d], 291 + [0x11c3f, 0x11c3f], 292 + [0x11c92, 0x11ca7], 293 + [0x11caa, 0x11cb0], 294 + [0x11cb2, 0x11cb3], 295 + [0x11cb5, 0x11cb6], 296 + [0x11d31, 0x11d36], 297 + [0x11d3a, 0x11d3a], 298 + [0x11d3c, 0x11d3d], 299 + [0x11d3f, 0x11d45], 300 + [0x11d47, 0x11d47], 301 + [0x11d90, 0x11d91], 302 + [0x11ef3, 0x11ef4], 303 + [0x16af0, 0x16af4], 304 + [0x16b30, 0x16b36], 305 + [0x16f4f, 0x16f4f], 306 + [0x16f8f, 0x16f92], 307 + [0x16fe4, 0x16fe4], 308 + [0x1bc9d, 0x1bc9e], 309 + [0x1cf00, 0x1cf2d], 310 + [0x1cf30, 0x1cf46], 311 + [0x1d167, 0x1d169], 312 + [0x1d17b, 0x1d182], 313 + [0x1d185, 0x1d18b], 314 + [0x1d1aa, 0x1d1ad], 315 + [0x1d242, 0x1d244], 316 + [0xe0100, 0xe01ef], 317 +]; 318 ⋮ 335 336 +function isCombining(codePoint) { 337 + for (const [start, end] of COMBINING_RANGES) { 338 + if (codePoint >= start && codePoint <= end) return true; 339 + } 340 + return false; 341 +} 342 + 343 +function isFullWidth(codePoint) { 344 + if (codePoint >= 0x1100 && ( 345 + codePoint <= 0x115f || 346 + codePoint === 0x2329 || 347 + codePoint === 0x232a || 348 + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || 349 + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || 350 + (codePoint >= 0xf900 && codePoint <= 0xfaff) || 351 + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || 352 + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || 353 + (codePoint >= 0xff00 && codePoint <= 0xff60) || 354 + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || 355 + (codePoint >= 0x20000 && codePoint <= 0x3fffd) 356 + )) { 357 + return true; 358 + } 359 + return false; 360 +} 361 + 362 +function visibleWidth(text) { 363 + const str = stripAnsi(text); 364 + let width = 0; 365 + for (let i = 0; i < str.length; i += 1) { 366 + const codePoint = str.codePointAt(i); 367 + if (codePoint > 0xffff) i += 1; 368 + if (codePoint === 0) continue; 369 + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 370 + if (isCombining(codePoint)) continue; 371 + width += isFullWidth(codePoint) ? 2 : 1; 372 + } 373 + return width; 374 +} 375 + 376 function truncatePlain(text, maxCols) { ⋮ 378 if (maxCols <= 0) return ''; 30 - if (str.length <= maxCols) return str; 31 - if (maxCols <= 3) return str.slice(0, maxCols); 32 - return `${str.slice(0, maxCols - 3)}...`; 379 + if (visibleWidth(str) <= maxCols) return str; 380 + const useEllipsis = maxCols > 3; 381 + const limit = maxCols - (useEllipsis ? 3 : 0); 382 + let out = ''; 383 + let width = 0; 384 + for (let i = 0; i < str.length && width < limit; i += 1) { 385 + const codePoint = str.codePointAt(i); 386 + if (codePoint > 0xffff) i += 1; 387 + if (codePoint === 0) continue; 388 + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 389 + if (isCombining(codePoint)) continue; 390 + const nextWidth = isFullWidth(codePoint) ? 2 : 1; 391 + if (width + nextWidth > limit) break; 392 + out += String.fromCodePoint(codePoint); 393 + width += nextWidth; 394 + } 395 + if (useEllipsis) out += '...'; 396 + return out; 397 } ⋮ 401 if (maxCols <= 0) return ''; 38 - if (stripAnsi(str).length <= maxCols) return str; 402 + if (visibleWidth(str) <= maxCols) return str; 403 const useEllipsis = maxCols > 3; ⋮ 405 let out = ''; 42 - let visible = 0; 406 + let width = 0; 407 const ansiRegex = new RegExp(ANSI_REGEX.source, 'y'); 44 - for (let i = 0; i < str.length && visible < limit; ) { 408 + for (let i = 0; i < str.length && width < limit; ) { 409 ansiRegex.lastIndex = i; ⋮ 415 } 52 - out += str[i]; 53 - visible += 1; 54 - i += 1; 416 + const codePoint = str.codePointAt(i); 417 + const char = String.fromCodePoint(codePoint); 418 + if (codePoint > 0xffff) i += 2; 419 + else i += 1; 420 + if (codePoint === 0) continue; 421 + if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 422 + if (isCombining(codePoint)) continue; 423 + const nextWidth = isFullWidth(codePoint) ? 2 : 1; 424 + if (width + nextWidth > limit) break; 425 + out += char; 426 + width += nextWidth; 427 } ⋮ 447 const firstLine = parts[0] ?? ''; 76 - const labelLen = stripAnsi(label).length; 77 - const suffixLen = stripAnsi(suffix).length; 448 + const labelLen = visibleWidth(label); 449 + const suffixLen = visibleWidth(suffix); 450 const available = width - labelLen - 1 - suffixLen; ⋮ 521 process.stdout.write(lineText); 150 - const lineLen = stripAnsi(lineText).length; 522 + const lineLen = visibleWidth(lineText); 523 lastLines = Math.max(1, Math.ceil(lineLen / Math.max(1, width))); • Edited prompts/system.txt (+1 -0) 2 目标:帮助用户完成开发任务,优先高信息密度输出。 3 +输出限制:禁止使用 Markdown(md)格式,内容无法渲染,必须使用纯文字格式输出。 • Edited src/core/context.js (+16 -11) 34 const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; 35 - const lines = [ 36 - basePrompt.trim(), 37 - '', 38 - `时间: ${now.toISOString()}`, 39 - `工作区: ${opts.workspace}`, 40 - `系统: ${systemName} (${os.release()})`, 41 - `终端: ${getTerminalType()}`, 42 - `权限: ${opts.allowMode}`, 43 - `Git: ${getGitInfo(opts.workspace)}`, 44 - ]; 45 - return lines.join('\n'); 35 + const replacements = { 36 + current_time: now.toISOString(), 37 + path: opts.workspace, 38 + workspace: opts.workspace, 39 + system: `${systemName} (${os.release()})`, 40 + terminal: getTerminalType(), 41 + allow_mode: opts.allowMode, 42 + permissions: opts.allowMode, 43 + git: getGitInfo(opts.workspace), 44 + }; 45 + let prompt = basePrompt; 46 + for (const [key, value] of Object.entries(replacements)) { 47 + const token = `{${key}}`; 48 + prompt = prompt.split(token).join(String(value)); 49 + } 50 + return prompt.trim(); 51 } • Edited prompts/system.txt (+7 -0) 3 输出限制:禁止使用 Markdown(md)格式,内容无法渲染,必须使用纯文字格式输出。 4 + 5 +- 当前时间:{current_time} 6 +- 工作区路径:{path} 7 +- 系统信息:{system} 8 +- 终端类型:{terminal} 9 +- 权限:{allow_mode} 10 +- Git:{git} • Edited src/core/context.js (+4 -1) 32 const now = new Date(); 33 + const tzOffset = now.getTimezoneOffset(); 34 + const localMs = now.getTime() - tzOffset * 60 * 1000; 35 + const localIso = new Date(localMs).toISOString().slice(0, 16); 36 const platform = os.platform(); ⋮ 38 const replacements = { 36 - current_time: now.toISOString(), 39 + current_time: localIso, 40 path: opts.workspace, • Edited prompts/system.txt (+1 -0) 9 - 权限:{allow_mode} 10 +- 只读限制说明:{allow_note} 11 - Git:{git} • Edited src/core/context.js (+14 -1) 35 const localIso = new Date(localMs).toISOString().slice(0, 16); 36 + const allowNote = opts.allowMode === 'read_only' 37 + ? '已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限,需要请求切换权限。' 38 + : ''; 39 const platform = os.platform(); 40 const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; 41 + let prompt = basePrompt; 42 + if (allowNote) { 43 + prompt = prompt.split('{allow_note}').join(allowNote); 44 + } else { 45 + const marker = '__ALLOW_NOTE__'; 46 + prompt = prompt.split('{allow_note}').join(marker); 47 + prompt = prompt 48 + .split('\n') 49 + .filter((line) => !line.includes(marker)) 50 + .join('\n'); 51 + } 52 const replacements = { ⋮ 61 }; 48 - let prompt = basePrompt; 62 for (const [key, value] of Object.entries(replacements)) { • Edited src/tools/dispatcher.js (+3 -2) 155 if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) { 156 + const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请先请求切换权限。'; 157 return { ⋮ 159 tool: name, 159 - error: '当前为只读模式,禁止修改文件或运行指令', 160 - formatted: '当前为只读模式,禁止修改文件或运行指令', 160 + error: note, 161 + formatted: note, 162 }; • Edited prompts/system.txt (+0 -1) 9 - 权限:{allow_mode} 10 -- 只读限制说明:{allow_note} 10 - Git:{git} • Edited src/core/context.js (+5 -15) 35 const localIso = new Date(localMs).toISOString().slice(0, 16); 36 - const allowNote = opts.allowMode === 'read_only' 37 - ? '已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限,需要请求切换权限。' 38 - : ''; 36 const platform = os.platform(); ⋮ 38 let prompt = basePrompt; 42 - if (allowNote) { 43 - prompt = prompt.split('{allow_note}').join(allowNote); 44 - } else { 45 - const marker = '__ALLOW_NOTE__'; 46 - prompt = prompt.split('{allow_note}').join(marker); 47 - prompt = prompt 48 - .split('\n') 49 - .filter((line) => !line.includes(marker)) 50 - .join('\n'); 51 - } 39 + const allowModeValue = opts.allowMode === 'read_only' 40 + ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要请求切换权限。` 41 + : opts.allowMode; 42 const replacements = { ⋮ 47 terminal: getTerminalType(), 58 - allow_mode: opts.allowMode, 59 - permissions: opts.allowMode, 48 + allow_mode: allowModeValue, 49 + permissions: allowModeValue, 50 git: getGitInfo(opts.workspace), • Edited src/ui/select_prompt.js (+3 -1) 25 }, 26 - }); 26 + }, { clearPromptOnDone: true }); 27 + readline.clearLine(process.stdout, 0); 28 + readline.cursorTo(process.stdout, 0); 29 return value; • Edited src/ui/tool_display.js (+1 -410) 7 const DOT_OFF = '◦'; 8 -const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g; 9 -const COMBINING_RANGES = [ 10 - [0x0300, 0x036f], 11 - [0x0483, 0x0489], 12 - [0x0591, 0x05bd], 13 - [0x05bf, 0x05bf], 14 - [0x05c1, 0x05c2], 15 - [0x05c4, 0x05c5], 16 - [0x05c7, 0x05c7], 17 - [0x0610, 0x061a], 18 - [0x064b, 0x065f], 19 - [0x0670, 0x0670], 20 - [0x06d6, 0x06dc], 21 - [0x06df, 0x06e4], 22 - [0x06e7, 0x06e8], 23 - [0x06ea, 0x06ed], 24 - [0x0711, 0x0711], 25 - [0x0730, 0x074a], 26 - [0x07a6, 0x07b0], 27 - [0x07eb, 0x07f3], 28 - [0x0816, 0x0819], 29 - [0x081b, 0x0823], 30 - [0x0825, 0x0827], 31 - [0x0829, 0x082d], 32 - [0x0859, 0x085b], 33 - [0x0900, 0x0902], 34 - [0x093a, 0x093a], 35 - [0x093c, 0x093c], 36 - [0x0941, 0x0948], 37 - [0x094d, 0x094d], 38 - [0x0951, 0x0957], 39 - [0x0962, 0x0963], 40 - [0x0981, 0x0981], 41 - [0x09bc, 0x09bc], 42 - [0x09c1, 0x09c4], 43 - [0x09cd, 0x09cd], 44 - [0x09e2, 0x09e3], 45 - [0x0a01, 0x0a02], 46 - [0x0a3c, 0x0a3c], 47 - [0x0a41, 0x0a42], 48 - [0x0a47, 0x0a48], 49 - [0x0a4b, 0x0a4d], 50 - [0x0a51, 0x0a51], 51 - [0x0a70, 0x0a71], 52 - [0x0a75, 0x0a75], 53 - [0x0a81, 0x0a82], 54 - [0x0abc, 0x0abc], 55 - [0x0ac1, 0x0ac5], 56 - [0x0ac7, 0x0ac8], 57 - [0x0acd, 0x0acd], 58 - [0x0ae2, 0x0ae3], 59 - [0x0b01, 0x0b01], 60 - [0x0b3c, 0x0b3c], 61 - [0x0b3f, 0x0b3f], 62 - [0x0b41, 0x0b44], 63 - [0x0b4d, 0x0b4d], 64 - [0x0b56, 0x0b56], 65 - [0x0b62, 0x0b63], 66 - [0x0b82, 0x0b82], 67 - [0x0bc0, 0x0bc0], 68 - [0x0bcd, 0x0bcd], 69 - [0x0c00, 0x0c00], 70 - [0x0c3e, 0x0c40], 71 - [0x0c46, 0x0c48], 72 - [0x0c4a, 0x0c4d], 73 - [0x0c55, 0x0c56], 74 - [0x0c62, 0x0c63], 75 - [0x0c81, 0x0c81], 76 - [0x0cbc, 0x0cbc], 77 - [0x0cbf, 0x0cbf], 78 - [0x0cc6, 0x0cc6], 79 - [0x0ccc, 0x0ccd], 80 - [0x0ce2, 0x0ce3], 81 - [0x0d00, 0x0d01], 82 - [0x0d3b, 0x0d3c], 83 - [0x0d41, 0x0d44], 84 - [0x0d4d, 0x0d4d], 85 - [0x0d62, 0x0d63], 86 - [0x0dca, 0x0dca], 87 - [0x0dd2, 0x0dd4], 88 - [0x0dd6, 0x0dd6], 89 - [0x0e31, 0x0e31], 90 - [0x0e34, 0x0e3a], 91 - [0x0e47, 0x0e4e], 92 - [0x0eb1, 0x0eb1], 93 - [0x0eb4, 0x0ebc], 94 - [0x0ec8, 0x0ecd], 95 - [0x0f18, 0x0f19], 96 - [0x0f35, 0x0f35], 97 - [0x0f37, 0x0f37], 98 - [0x0f39, 0x0f39], 99 - [0x0f71, 0x0f7e], 100 - [0x0f80, 0x0f84], 101 - [0x0f86, 0x0f87], 102 - [0x0f8d, 0x0f97], 103 - [0x0f99, 0x0fbc], 104 - [0x0fc6, 0x0fc6], 105 - [0x102d, 0x1030], 106 - [0x1032, 0x1037], 107 - [0x1039, 0x103a], 108 - [0x103d, 0x103e], 109 - [0x1058, 0x1059], 110 - [0x105e, 0x1060], 111 - [0x1071, 0x1074], 112 - [0x1082, 0x1082], 113 - [0x1085, 0x1086], 114 - [0x108d, 0x108d], 115 - [0x109d, 0x109d], 116 - [0x135d, 0x135f], 117 - [0x1712, 0x1714], 118 - [0x1732, 0x1734], 119 - [0x1752, 0x1753], 120 - [0x1772, 0x1773], 121 - [0x17b4, 0x17b5], 122 - [0x17b7, 0x17bd], 123 - [0x17c6, 0x17c6], 124 - [0x17c9, 0x17d3], 125 - [0x17dd, 0x17dd], 126 - [0x180b, 0x180d], 127 - [0x1885, 0x1886], 128 - [0x18a9, 0x18a9], 129 - [0x1920, 0x1922], 130 - [0x1927, 0x1928], 131 - [0x1932, 0x1932], 132 - [0x1939, 0x193b], 133 - [0x1a17, 0x1a18], 134 - [0x1a1b, 0x1a1b], 135 - [0x1a56, 0x1a56], 136 - [0x1a58, 0x1a5e], 137 - [0x1a60, 0x1a60], 138 - [0x1a62, 0x1a62], 139 - [0x1a65, 0x1a6c], 140 - [0x1a73, 0x1a7c], 141 - [0x1a7f, 0x1a7f], 142 - [0x1ab0, 0x1ace], 143 - [0x1b00, 0x1b03], 144 - [0x1b34, 0x1b34], 145 - [0x1b36, 0x1b3a], 146 - [0x1b3c, 0x1b3c], 147 - [0x1b42, 0x1b42], 148 - [0x1b6b, 0x1b73], 149 - [0x1b80, 0x1b81], 150 - [0x1ba2, 0x1ba5], 151 - [0x1ba8, 0x1ba9], 152 - [0x1bab, 0x1bad], 153 - [0x1be6, 0x1be6], 154 - [0x1be8, 0x1be9], 155 - [0x1bed, 0x1bed], 156 - [0x1bef, 0x1bf1], 157 - [0x1c2c, 0x1c33], 158 - [0x1c36, 0x1c37], 159 - [0x1cd0, 0x1cd2], 160 - [0x1cd4, 0x1ce0], 161 - [0x1ce2, 0x1ce8], 162 - [0x1ced, 0x1ced], 163 - [0x1cf4, 0x1cf4], 164 - [0x1cf8, 0x1cf9], 165 - [0x1dc0, 0x1df9], 166 - [0x1dfb, 0x1dff], 167 - [0x200b, 0x200f], 168 - [0x202a, 0x202e], 169 - [0x2060, 0x2064], 170 - [0x2066, 0x206f], 171 - [0x20d0, 0x20f0], 172 - [0x2cef, 0x2cf1], 173 - [0x2d7f, 0x2d7f], 174 - [0x2de0, 0x2dff], 175 - [0x302a, 0x302f], 176 - [0x3099, 0x309a], 177 - [0xa66f, 0xa672], 178 - [0xa674, 0xa67d], 179 - [0xa69e, 0xa69f], 180 - [0xa6f0, 0xa6f1], 181 - [0xa802, 0xa802], 182 - [0xa806, 0xa806], 183 - [0xa80b, 0xa80b], 184 - [0xa825, 0xa826], 185 - [0xa8c4, 0xa8c5], 186 - [0xa8e0, 0xa8f1], 187 - [0xa926, 0xa92d], 188 - [0xa947, 0xa951], 189 - [0xa980, 0xa982], 190 - [0xa9b3, 0xa9b3], 191 - [0xa9b6, 0xa9b9], 192 - [0xa9bc, 0xa9bc], 193 - [0xa9e5, 0xa9e5], 194 - [0xaa29, 0xaa2e], 195 - [0xaa31, 0xaa32], 196 - [0xaa35, 0xaa36], 197 - [0xaa43, 0xaa43], 198 - [0xaa4c, 0xaa4c], 199 - [0xaa7c, 0xaa7c], 200 - [0xaab0, 0xaab0], 201 - [0xaab2, 0xaab4], 202 - [0xaab7, 0xaab8], 203 - [0xaabe, 0xaabf], 204 - [0xaac1, 0xaac1], 205 - [0xaaec, 0xaaed], 206 - [0xaaf6, 0xaaf6], 207 - [0xabe5, 0xabe5], 208 - [0xabe8, 0xabe8], 209 - [0xabed, 0xabed], 210 - [0xfb1e, 0xfb1e], 211 - [0xfe00, 0xfe0f], 212 - [0xfe20, 0xfe2f], 213 - [0xfeff, 0xfeff], 214 - [0xfff9, 0xfffb], 215 - [0x101fd, 0x101fd], 216 - [0x102e0, 0x102e0], 217 - [0x10376, 0x1037a], 218 - [0x10a01, 0x10a03], 219 - [0x10a05, 0x10a06], 220 - [0x10a0c, 0x10a0f], 221 - [0x10a38, 0x10a3a], 222 - [0x10a3f, 0x10a3f], 223 - [0x10ae5, 0x10ae6], 224 - [0x10d24, 0x10d27], 225 - [0x10eab, 0x10eac], 226 - [0x10f46, 0x10f50], 227 - [0x11001, 0x11001], 228 - [0x11038, 0x11046], 229 - [0x1107f, 0x11081], 230 - [0x110b3, 0x110b6], 231 - [0x110b9, 0x110ba], 232 - [0x110c2, 0x110c2], 233 - [0x11100, 0x11102], 234 - [0x11127, 0x1112b], 235 - [0x1112d, 0x11134], 236 - [0x11173, 0x11173], 237 - [0x11180, 0x11181], 238 - [0x111b6, 0x111be], 239 - [0x111c9, 0x111cc], 240 - [0x1122f, 0x11231], 241 - [0x11234, 0x11234], 242 - [0x11236, 0x11237], 243 - [0x1123e, 0x1123e], 244 - [0x112df, 0x112df], 245 - [0x112e3, 0x112ea], 246 - [0x11300, 0x11301], 247 - [0x1133b, 0x1133c], 248 - [0x11340, 0x11340], 249 - [0x11366, 0x1136c], 250 - [0x11370, 0x11374], 251 - [0x11438, 0x1143f], 252 - [0x11442, 0x11444], 253 - [0x11446, 0x11446], 254 - [0x1145e, 0x1145e], 255 - [0x114b3, 0x114b8], 256 - [0x114ba, 0x114ba], 257 - [0x114bf, 0x114c0], 258 - [0x114c2, 0x114c3], 259 - [0x115b2, 0x115b5], 260 - [0x115bc, 0x115bd], 261 - [0x115bf, 0x115c0], 262 - [0x115dc, 0x115dd], 263 - [0x11633, 0x1163a], 264 - [0x1163d, 0x1163d], 265 - [0x1163f, 0x11640], 266 - [0x116ab, 0x116ab], 267 - [0x116ad, 0x116ad], 268 - [0x116b0, 0x116b5], 269 - [0x116b7, 0x116b7], 270 - [0x1171d, 0x1171f], 271 - [0x11722, 0x11725], 272 - [0x11727, 0x1172b], 273 - [0x1182f, 0x11837], 274 - [0x11839, 0x1183a], 275 - [0x1193b, 0x1193c], 276 - [0x1193e, 0x1193e], 277 - [0x11943, 0x11943], 278 - [0x119d4, 0x119d7], 279 - [0x119da, 0x119db], 280 - [0x119e0, 0x119e0], 281 - [0x11a01, 0x11a0a], 282 - [0x11a33, 0x11a38], 283 - [0x11a3b, 0x11a3e], 284 - [0x11a47, 0x11a47], 285 - [0x11a51, 0x11a56], 286 - [0x11a59, 0x11a5b], 287 - [0x11a8a, 0x11a96], 288 - [0x11a98, 0x11a99], 289 - [0x11c30, 0x11c36], 290 - [0x11c38, 0x11c3d], 291 - [0x11c3f, 0x11c3f], 292 - [0x11c92, 0x11ca7], 293 - [0x11caa, 0x11cb0], 294 - [0x11cb2, 0x11cb3], 295 - [0x11cb5, 0x11cb6], 296 - [0x11d31, 0x11d36], 297 - [0x11d3a, 0x11d3a], 298 - [0x11d3c, 0x11d3d], 299 - [0x11d3f, 0x11d45], 300 - [0x11d47, 0x11d47], 301 - [0x11d90, 0x11d91], 302 - [0x11ef3, 0x11ef4], 303 - [0x16af0, 0x16af4], 304 - [0x16b30, 0x16b36], 305 - [0x16f4f, 0x16f4f], 306 - [0x16f8f, 0x16f92], 307 - [0x16fe4, 0x16fe4], 308 - [0x1bc9d, 0x1bc9e], 309 - [0x1cf00, 0x1cf2d], 310 - [0x1cf30, 0x1cf46], 311 - [0x1d167, 0x1d169], 312 - [0x1d17b, 0x1d182], 313 - [0x1d185, 0x1d18b], 314 - [0x1d1aa, 0x1d1ad], 315 - [0x1d242, 0x1d244], 316 - [0xe0100, 0xe01ef], 317 -]; 8 +const { stripAnsi, visibleWidth, truncatePlain, truncateVisible } = require('../utils/text_width'); 9 ⋮ 20 return map[name] || name; 330 -} 331 - 332 -function stripAnsi(text) { 333 - return String(text ?? '').replace(ANSI_REGEX, ''); 334 -} 335 - 336 -function isCombining(codePoint) { 337 - for (const [start, end] of COMBINING_RANGES) { 338 - if (codePoint >= start && codePoint <= end) return true; 339 - } 340 - return false; 341 -} 342 - 343 -function isFullWidth(codePoint) { 344 - if (codePoint >= 0x1100 && ( 345 - codePoint <= 0x115f || 346 - codePoint === 0x2329 || 347 - codePoint === 0x232a || 348 - (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || 349 - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || 350 - (codePoint >= 0xf900 && codePoint <= 0xfaff) || 351 - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || 352 - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || 353 - (codePoint >= 0xff00 && codePoint <= 0xff60) || 354 - (codePoint >= 0xffe0 && codePoint <= 0xffe6) || 355 - (codePoint >= 0x20000 && codePoint <= 0x3fffd) 356 - )) { 357 - return true; 358 - } 359 - return false; 360 -} 361 - 362 -function visibleWidth(text) { 363 - const str = stripAnsi(text); 364 - let width = 0; 365 - for (let i = 0; i < str.length; i += 1) { 366 - const codePoint = str.codePointAt(i); 367 - if (codePoint > 0xffff) i += 1; 368 - if (codePoint === 0) continue; 369 - if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 370 - if (isCombining(codePoint)) continue; 371 - width += isFullWidth(codePoint) ? 2 : 1; 372 - } 373 - return width; 374 -} 375 - 376 -function truncatePlain(text, maxCols) { 377 - const str = String(text ?? ''); 378 - if (maxCols <= 0) return ''; 379 - if (visibleWidth(str) <= maxCols) return str; 380 - const useEllipsis = maxCols > 3; 381 - const limit = maxCols - (useEllipsis ? 3 : 0); 382 - let out = ''; 383 - let width = 0; 384 - for (let i = 0; i < str.length && width < limit; i += 1) { 385 - const codePoint = str.codePointAt(i); 386 - if (codePoint > 0xffff) i += 1; 387 - if (codePoint === 0) continue; 388 - if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 389 - if (isCombining(codePoint)) continue; 390 - const nextWidth = isFullWidth(codePoint) ? 2 : 1; 391 - if (width + nextWidth > limit) break; 392 - out += String.fromCodePoint(codePoint); 393 - width += nextWidth; 394 - } 395 - if (useEllipsis) out += '...'; 396 - return out; 21 } 22 399 -function truncateVisible(text, maxCols) { 400 - const str = String(text ?? ''); 401 - if (maxCols <= 0) return ''; 402 - if (visibleWidth(str) <= maxCols) return str; 403 - const useEllipsis = maxCols > 3; 404 - const limit = maxCols - (useEllipsis ? 3 : 0); 405 - let out = ''; 406 - let width = 0; 407 - const ansiRegex = new RegExp(ANSI_REGEX.source, 'y'); 408 - for (let i = 0; i < str.length && width < limit; ) { 409 - ansiRegex.lastIndex = i; 410 - const match = ansiRegex.exec(str); 411 - if (match && match.index === i) { 412 - out += match[0]; 413 - i += match[0].length; 414 - continue; 415 - } 416 - const codePoint = str.codePointAt(i); 417 - const char = String.fromCodePoint(codePoint); 418 - if (codePoint > 0xffff) i += 2; 419 - else i += 1; 420 - if (codePoint === 0) continue; 421 - if (codePoint < 32 || (codePoint >= 0x7f && codePoint <= 0x9f)) continue; 422 - if (isCombining(codePoint)) continue; 423 - const nextWidth = isFullWidth(codePoint) ? 2 : 1; 424 - if (width + nextWidth > limit) break; 425 - out += char; 426 - width += nextWidth; 427 - } 428 - if (useEllipsis) out += '...'; 429 - if (str.includes('\x1b[')) out += '\x1b[0m'; 430 - return out; 431 -} 23 • Edited src/cli/index.js (+6 -4) 16 const { Spinner, truncateThinking } = require('../ui/spinner'); 17 +const { renderBanner } = require('../ui/banner'); 18 const { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines } = require('../ui/tool_display'); ⋮ 41 41 -console.log(''); 42 -console.log('eagent'); 43 -console.log(`model: ${state.modelKey} | allow: ${state.allowMode} | cwd: ${WORKSPACE_NAME}`); 44 -console.log(''); 42 +renderBanner({ 43 + modelKey: state.modelKey, 44 + workspace: WORKSPACE, 45 + conversationId: state.conversation?.id, 46 +}); 47 • Edited src/ui/banner.js (+19 -13) 5 6 -function renderBanner({ modelKey, workspace, conversationId }) { 6 +function renderBox({ title, lines }) { 7 const cols = Number(process.stdout.columns) || 80; 8 - const title = `>_ Welcome to ${bold('EasyAgent')}`; 9 - const lines = [ 10 - `model: ${modelKey || ''}`, 11 - `path: ${workspace || ''}`, 12 - `conversation: ${conversationId || 'none'}`, 13 - ]; 8 + const safeTitle = title ? String(title) : ''; 9 + const safeLines = Array.isArray(lines) ? lines.map((line) => String(line)) : []; 10 if (cols < 20) { 11 console.log(''); 16 - console.log(title); 17 - lines.forEach((line) => console.log(line)); 12 + if (safeTitle) console.log(safeTitle); 13 + safeLines.forEach((line) => console.log(line)); 14 console.log(''); ⋮ 16 } 21 - const contentWidth = Math.max(visibleWidth(title), ...lines.map(visibleWidth)); 17 + const contentWidth = Math.max(visibleWidth(safeTitle), ...safeLines.map(visibleWidth)); 18 const innerWidth = Math.max(10, Math.min(cols - 2, contentWidth + 2)); ⋮ 23 console.log(top); 28 - console.log(renderLine(title)); 29 - lines.forEach((line) => console.log(renderLine(line))); 24 + if (safeTitle) console.log(renderLine(safeTitle)); 25 + safeLines.forEach((line) => console.log(renderLine(line))); 26 console.log(top); ⋮ 29 34 -module.exports = { renderBanner }; 30 +function renderBanner({ modelKey, workspace, conversationId }) { 31 + const title = `>_ Welcome to ${bold('EasyAgent')}`; 32 + const lines = [ 33 + `model: ${modelKey || ''}`, 34 + `path: ${workspace || ''}`, 35 + `conversation: ${conversationId || 'none'}`, 36 + ]; 37 + renderBox({ title, lines }); 38 +} 39 + 40 +module.exports = { renderBanner, renderBox }; • Edited src/cli/commands.js (+11 -5) 8 const { buildFinalLine, formatResultLines, printResultLines } = require('../ui/tool_display'); 9 +const { renderBox } = require('../ui/banner'); 10 const { createIndentedWriter } = require('../ui/indented_writer'); ⋮ 178 if (cmd === '/status') { 178 - console.log(`model: ${state.modelKey} | 思考: ${state.thinkingMode ? 'thinking' : 'fast'}`); 179 - console.log(`workspace: ${workspace}`); 180 - console.log(`allow: ${state.allowMode}`); 181 - console.log(`conversation: ${state.conversation?.id || 'none'}`); 182 - console.log(`token usage: ${state.tokenUsage}`); 179 + const title = 'Status'; 180 + const lines = [ 181 + `model: ${state.modelKey}`, 182 + `thinking: ${state.thinkingMode ? 'thinking' : 'fast'}`, 183 + `workspace: ${workspace}`, 184 + `allow: ${state.allowMode}`, 185 + `conversation: ${state.conversation?.id || 'none'}`, 186 + `token usage: ${state.tokenUsage}`, 187 + ]; 188 + renderBox({ title, lines }); 189 return { exit: false }; • Edited src/cli/index.js (+1 -1) 25 const USERNAME = os.userInfo().username || 'user'; 26 -const PROMPT = `${USERNAME}@${WORKSPACE_NAME} `; 26 +const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `; 27 • Edited src/core/state.js (+1 -1) 9 thinkingMode: true, 10 - tokenUsage: 0, 10 + tokenUsage: { prompt: 0, completion: 0, total: 0 }, 11 conversation: null, • Edited src/storage/conversation_store.js (+5 -1) 6 const { toISO } = require('../utils/time'); 7 +const { normalizeTokenUsage } = require('../utils/token_usage'); 8 ⋮ 85 allow_mode: metadata.allow_mode || 'full_access', 85 - token_usage: metadata.token_usage || 0, 86 + token_usage: normalizeTokenUsage(metadata.token_usage), 87 cwd: metadata.cwd || '', ⋮ 126 ...metadataUpdates, 127 + token_usage: metadataUpdates && Object.prototype.hasOwnProperty.call(metadataUpdates, 'token_usage') 128 + ? normalizeTokenUsage(metadataUpdates.token_usage) 129 + : normalizeTokenUsage(conversation.metadata?.token_usage), 130 }; • Edited src/cli/commands.js (+7 -3) 11 const { cyan, green } = require('../utils/colors'); 12 +const { normalizeTokenUsage } = require('../utils/token_usage'); 13 const { Spinner } = require('../ui/spinner'); ⋮ 79 state.allowMode = conv.metadata?.allow_mode || state.allowMode; 79 - state.tokenUsage = conv.metadata?.token_usage || 0; 80 + state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); 81 console.log(`已加载对话: ${conv.id}`); ⋮ 129 state.allowMode = conv.metadata?.allow_mode || state.allowMode; 129 - state.tokenUsage = conv.metadata?.token_usage || 0; 130 + state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); 131 console.log(`已加载对话: ${conv.id}`); ⋮ 179 if (cmd === '/status') { 180 + const usage = normalizeTokenUsage(state.tokenUsage); 181 const title = 'Status'; ⋮ 187 `conversation: ${state.conversation?.id || 'none'}`, 186 - `token usage: ${state.tokenUsage}`, 188 + `tokens(in): ${usage.prompt}`, 189 + `tokens(out): ${usage.completion}`, 190 + `tokens(total): ${usage.total}`, 191 ]; • Edited src/cli/index.js (+16 -2) 19 const { createConversation, updateConversation } = require('../storage/conversation_store'); 20 +const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage'); 21 const { gray, cyan, green } = require('../utils/colors'); ⋮ 238 let usageTotal = null; 239 + let usagePrompt = null; 240 + let usageCompletion = null; 241 let firstContent = true; ⋮ 258 if (!choice) continue; 256 - if (chunk.usage && chunk.usage.total_tokens) usageTotal = chunk.usage.total_tokens; 259 + const usage = (choice && (choice.usage || choice.delta?.usage)) || chunk.usage; 260 + if (usage) { 261 + if (Number.isFinite(usage.prompt_tokens)) usagePrompt = usage.prompt_tokens; 262 + if (Number.isFinite(usage.completion_tokens)) usageCompletion = usage.completion_tokens; 263 + if (Number.isFinite(usage.total_tokens)) usageTotal = usage.total_tokens; 264 + } 265 const delta = choice.delta || {}; ⋮ 332 325 - if (usageTotal) state.tokenUsage = usageTotal; 333 + if (usageTotal || usagePrompt || usageCompletion) { 334 + state.tokenUsage = applyUsage(normalizeTokenUsage(state.tokenUsage), { 335 + prompt_tokens: usagePrompt, 336 + completion_tokens: usageCompletion, 337 + total_tokens: usageTotal, 338 + }); 339 + } 340 • Edited src/cli/index.js (+1 -1) 332 333 - if (usageTotal || usagePrompt || usageCompletion) { 333 + if (usageTotal !== null || usagePrompt !== null || usageCompletion !== null) { 334 state.tokenUsage = applyUsage(normalizeTokenUsage(state.tokenUsage), { • Edited src/cli/commands.js (+3 -3) 187 `conversation: ${state.conversation?.id || 'none'}`, 188 - `tokens(in): ${usage.prompt}`, 189 - `tokens(out): ${usage.completion}`, 190 - `tokens(total): ${usage.total}`, 188 + `输入token: ${usage.prompt}`, 189 + `输出token: ${usage.completion}`, 190 + `当前上下文总token: ${usage.total}`, 191 ]; • Edited src/cli/index.js (+5 -1) 51 let menuSearchTerm = ''; 52 +let menuLastSearchTerm = ''; 53 let menuJustClosedAt = 0; ⋮ 94 menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 95 + menuLastSearchTerm = menuSearchTerm; 96 drainStdin(); ⋮ 135 const tooOld = Date.now() - menuJustClosedAt > 800; 134 - const normalizedMenu = String(menuSearchTerm).trim().replace(/^\/+/, ''); 136 + const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, ''); 137 const normalizedInput = input.replace(/^\/+/, ''); ⋮ 139 menuJustClosedAt = 0; 140 + menuLastSearchTerm = ''; 141 rl.prompt(); ⋮ 144 menuJustClosedAt = 0; 145 + menuLastSearchTerm = ''; 146 } • Edited src/cli/index.js (+7 -0) 72 commandMenuActive = true; 73 + if (rl) { 74 + rl.pause(); 75 + rl.line = ''; 76 + rl.cursor = 0; 77 + readline.clearLine(process.stdout, 0); 78 + readline.cursorTo(process.stdout, 0); 79 + } 80 menuSearchTerm = ''; • Edited src/cli/index.js (+5 -0) 147 menuLastSearchTerm = ''; 148 + if (process.stdout.isTTY) { 149 + readline.moveCursor(process.stdout, 0, -1); 150 + readline.clearLine(process.stdout, 0); 151 + readline.cursorTo(process.stdout, 0); 152 + } 153 rl.prompt(); • Edited src/cli/index.js (+44 -0) 27 const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `; 28 +const DEBUG_LOG_ENABLED = process.env.EAGENT_DEBUG === '1'; 29 +const DEBUG_LOG_PATH = path.join(WORKSPACE, 'eagent-debug.log'); 30 + 31 +function debugLog(event, data = {}) { 32 + if (!DEBUG_LOG_ENABLED) return; 33 + try { 34 + const payload = { 35 + ts: new Date().toISOString(), 36 + event, 37 + ...data, 38 + }; 39 + fs.appendFileSync(DEBUG_LOG_PATH, `${JSON.stringify(payload)}\n`); 40 + } catch (_) {} 41 +} 42 ⋮ 61 }); 62 +debugLog('startup', { prompt: PROMPT, pid: process.pid }); 63 ⋮ 79 if (key && key.name === 'backspace' && menuSearchTerm === '') { 80 + debugLog('menu_backspace_abort', { str, key: key.name }); 81 if (menuAbortController && !menuAbortController.signal.aborted) { ⋮ 87 if (str === '/' && (rl.line === '' || rl.line === '/')) { 88 + debugLog('menu_open_requested', { str, rl_line: rl.line }); 89 commandMenuActive = true; ⋮ 106 menuSearchTerm = input || ''; 107 + debugLog('menu_input', { input: menuSearchTerm }); 108 }, ⋮ 111 .then((result) => { 112 + debugLog('menu_result', { result }); 113 if (result && result.chosen && !result.cancelled) { ⋮ 117 .finally(() => { 118 + debugLog('menu_close_before', { 119 + injected: menuInjectedCommand, 120 + lastTerm: menuLastSearchTerm, 121 + searchTerm: menuSearchTerm, 122 + }); 123 commandMenuActive = false; ⋮ 136 menuInjectedCommand = null; 137 + debugLog('menu_inject', { injected }); 138 setImmediate(() => injectLine(injected)); 139 } 140 + debugLog('menu_close_after', { 141 + justClosedAt: menuJustClosedAt, 142 + lastTerm: menuLastSearchTerm, 143 + }); 144 }); ⋮ 164 rl.on('line', async (line) => { 165 + debugLog('line', { 166 + line, 167 + commandMenuActive, 168 + menuJustClosedAt, 169 + menuLastSearchTerm, 170 + menuSearchTerm, 171 + }); 172 if (commandMenuActive) { ⋮ 180 const normalizedInput = input.replace(/^\/+/, ''); 181 + const willConsume = !tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu); 182 + debugLog('menu_close_check', { 183 + tooOld, 184 + normalizedMenu, 185 + normalizedInput, 186 + willConsume, 187 + }); 188 if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { ⋮ 237 if (!rl) return; 238 + debugLog('inject_line', { text }); 239 rl.write(text); • Edited src/cli/index.js (+5 -1) 171 }); 172 + if (rl) { 173 + rl.line = ''; 174 + rl.cursor = 0; 175 + } 176 if (commandMenuActive) { 173 - rl.prompt(); 177 + debugLog('line_ignored_menu', { line }); 178 return; • Edited src/cli/index.js (+3 -0) 130 menuSearchTerm = ''; 131 + if (process.stdout.isTTY) { 132 + readline.clearScreenDown(process.stdout); 133 + } 134 readline.clearLine(process.stdout, 0); • Edited src/cli/index.js (+27 -3) 29 const DEBUG_LOG_PATH = path.join(WORKSPACE, 'eagent-debug.log'); 30 +const MENU_PAGE_SIZE = 6; 31 ⋮ 62 }); 62 -debugLog('startup', { prompt: PROMPT, pid: process.pid }); 63 +debugLog('startup', { 64 + prompt: PROMPT, 65 + pid: process.pid, 66 + tty: process.stdout.isTTY, 67 + columns: process.stdout.columns, 68 + rows: process.stdout.rows, 69 +}); 70 ⋮ 94 if (str === '/' && (rl.line === '' || rl.line === '/')) { 88 - debugLog('menu_open_requested', { str, rl_line: rl.line }); 95 + debugLog('menu_open_requested', { 96 + str, 97 + rl_line: rl.line, 98 + rl_cursor: rl.cursor, 99 + tty: process.stdout.isTTY, 100 + columns: process.stdout.columns, 101 + rows: process.stdout.rows, 102 + }); 103 commandMenuActive = true; ⋮ 115 prompt: PROMPT, 102 - pageSize: 6, 116 + pageSize: MENU_PAGE_SIZE, 117 colorEnabled: process.stdout.isTTY, ⋮ 135 searchTerm: menuSearchTerm, 136 + tty: process.stdout.isTTY, 137 + columns: process.stdout.columns, 138 + rows: process.stdout.rows, 139 }); ⋮ 148 if (process.stdout.isTTY) { 149 + debugLog('menu_close_clear', { method: 'clearScreenDown' }); 150 readline.clearScreenDown(process.stdout); ⋮ 153 readline.cursorTo(process.stdout, 0); 154 + debugLog('prompt', { reason: 'menu_close' }); 155 rl.prompt(true); ⋮ 217 if (process.stdout.isTTY) { 218 + debugLog('menu_close_clear', { method: 'moveCursorUp+clearLine' }); 219 readline.moveCursor(process.stdout, 0, -1); ⋮ 222 } 223 + debugLog('prompt', { reason: 'menu_close_consume' }); 224 rl.prompt(); ⋮ 230 if (!input) { 231 + debugLog('prompt', { reason: 'empty_line' }); 232 rl.prompt(); ⋮ 238 if (result && result.exit) return; 239 + debugLog('prompt', { reason: 'command_done', command: input }); 240 rl.prompt(); ⋮ 249 await runAssistantLoop(); 250 + debugLog('prompt', { reason: 'assistant_done' }); 251 rl.prompt(); • Edited src/cli/index.js (+6 -1) 154 debugLog('prompt', { reason: 'menu_close' }); 155 - rl.prompt(true); 155 + if (!menuInjectedCommand) { 156 + debugLog('prompt', { reason: 'menu_close' }); 157 + rl.prompt(true); 158 + } else { 159 + debugLog('prompt', { reason: 'menu_close_skip_for_inject' }); 160 + } 161 if (menuInjectedCommand) { • Edited src/cli/index.js (+11 -7) 151 } 152 - readline.clearLine(process.stdout, 0); 153 - readline.cursorTo(process.stdout, 0); 154 - debugLog('prompt', { reason: 'menu_close' }); 155 - if (!menuInjectedCommand) { 156 - debugLog('prompt', { reason: 'menu_close' }); 157 - rl.prompt(true); 152 + // Clear possible echoes from the base readline line (current + previous line). 153 + if (process.stdout.isTTY) { 154 + readline.clearLine(process.stdout, 0); 155 + readline.cursorTo(process.stdout, 0); 156 + readline.moveCursor(process.stdout, 0, -1); 157 + readline.clearLine(process.stdout, 0); 158 + readline.cursorTo(process.stdout, 0); 159 } else { 159 - debugLog('prompt', { reason: 'menu_close_skip_for_inject' }); 160 + readline.clearLine(process.stdout, 0); 161 + readline.cursorTo(process.stdout, 0); 162 } 163 + debugLog('prompt', { reason: menuInjectedCommand ? 'menu_close_before_inject' : 'menu_close' }); 164 + rl.prompt(true); 165 if (menuInjectedCommand) { • Edited src/cli/index.js (+0 -68) 27 const PROMPT = `${USERNAME}@${WORKSPACE_NAME} % `; 28 -const DEBUG_LOG_ENABLED = process.env.EAGENT_DEBUG === '1'; 29 -const DEBUG_LOG_PATH = path.join(WORKSPACE, 'eagent-debug.log'); 28 const MENU_PAGE_SIZE = 6; 29 32 -function debugLog(event, data = {}) { 33 - if (!DEBUG_LOG_ENABLED) return; 34 - try { 35 - const payload = { 36 - ts: new Date().toISOString(), 37 - event, 38 - ...data, 39 - }; 40 - fs.appendFileSync(DEBUG_LOG_PATH, `${JSON.stringify(payload)}\n`); 41 - } catch (_) {} 42 -} 43 - 30 const config = ensureConfig(); ⋮ 47 conversationId: state.conversation?.id, 62 -}); 63 -debugLog('startup', { 64 - prompt: PROMPT, 65 - pid: process.pid, 66 - tty: process.stdout.isTTY, 67 - columns: process.stdout.columns, 68 - rows: process.stdout.rows, 48 }); ⋮ 65 if (key && key.name === 'backspace' && menuSearchTerm === '') { 87 - debugLog('menu_backspace_abort', { str, key: key.name }); 66 if (menuAbortController && !menuAbortController.signal.aborted) { ⋮ 72 if (str === '/' && (rl.line === '' || rl.line === '/')) { 95 - debugLog('menu_open_requested', { 96 - str, 97 - rl_line: rl.line, 98 - rl_cursor: rl.cursor, 99 - tty: process.stdout.isTTY, 100 - columns: process.stdout.columns, 101 - rows: process.stdout.rows, 102 - }); 73 commandMenuActive = true; ⋮ 90 menuSearchTerm = input || ''; 121 - debugLog('menu_input', { input: menuSearchTerm }); 91 }, ⋮ 94 .then((result) => { 126 - debugLog('menu_result', { result }); 95 if (result && result.chosen && !result.cancelled) { ⋮ 99 .finally(() => { 132 - debugLog('menu_close_before', { 133 - injected: menuInjectedCommand, 134 - lastTerm: menuLastSearchTerm, 135 - searchTerm: menuSearchTerm, 136 - tty: process.stdout.isTTY, 137 - columns: process.stdout.columns, 138 - rows: process.stdout.rows, 139 - }); 100 commandMenuActive = false; ⋮ 108 if (process.stdout.isTTY) { 149 - debugLog('menu_close_clear', { method: 'clearScreenDown' }); 109 readline.clearScreenDown(process.stdout); ⋮ 121 } 163 - debugLog('prompt', { reason: menuInjectedCommand ? 'menu_close_before_inject' : 'menu_close' }); 122 rl.prompt(true); ⋮ 125 menuInjectedCommand = null; 168 - debugLog('menu_inject', { injected }); 126 setImmediate(() => injectLine(injected)); 127 } 171 - debugLog('menu_close_after', { 172 - justClosedAt: menuJustClosedAt, 173 - lastTerm: menuLastSearchTerm, 174 - }); 128 }); ⋮ 148 rl.on('line', async (line) => { 196 - debugLog('line', { 197 - line, 198 - commandMenuActive, 199 - menuJustClosedAt, 200 - menuLastSearchTerm, 201 - menuSearchTerm, 202 - }); 149 if (rl) { ⋮ 153 if (commandMenuActive) { 208 - debugLog('line_ignored_menu', { line }); 154 return; ⋮ 160 const normalizedInput = input.replace(/^\/+/, ''); 216 - const willConsume = !tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu); 217 - debugLog('menu_close_check', { 218 - tooOld, 219 - normalizedMenu, 220 - normalizedInput, 221 - willConsume, 222 - }); 161 if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { ⋮ 164 if (process.stdout.isTTY) { 227 - debugLog('menu_close_clear', { method: 'moveCursorUp+clearLine' }); 165 readline.moveCursor(process.stdout, 0, -1); ⋮ 168 } 232 - debugLog('prompt', { reason: 'menu_close_consume' }); 169 rl.prompt(); ⋮ 175 if (!input) { 240 - debugLog('prompt', { reason: 'empty_line' }); 176 rl.prompt(); ⋮ 182 if (result && result.exit) return; 248 - debugLog('prompt', { reason: 'command_done', command: input }); 183 rl.prompt(); ⋮ 192 await runAssistantLoop(); 259 - debugLog('prompt', { reason: 'assistant_done' }); 193 rl.prompt(); ⋮ 210 if (!rl) return; 278 - debugLog('inject_line', { text }); 211 rl.write(text); • Edited src/cli/index.js (+62 -1) 22 const { createIndentedWriter } = require('../ui/indented_writer'); 23 +const { createStatusBar } = require('../ui/status_bar'); 24 ⋮ 57 let menuAbortController = null; 58 +let statusBar = null; 59 +let escPendingCancel = false; 60 +let activeStreamController = null; 61 ⋮ 66 67 +statusBar = createStatusBar({ 68 + getTokens: () => normalizeTokenUsage(state.tokenUsage).total || 0, 69 + maxTokens: '256k', 70 +}); 71 +statusBar.render(); 72 + 73 process.stdin.on('keypress', (str, key) => { ⋮ 81 } 82 + if (key && key.name === 'escape') { 83 + escPendingCancel = true; 84 + if (activeStreamController && typeof activeStreamController.abort === 'function') { 85 + activeStreamController.abort(); 86 + } 87 + return; 88 + } 89 if (str === '/' && (rl.line === '' || rl.line === '/')) { ⋮ 165 rl.on('line', async (line) => { 166 + if (statusBar) statusBar.render(); 167 if (rl) { ⋮ 170 } 171 + if (escPendingCancel) { 172 + escPendingCancel = false; 173 + rl.prompt(); 174 + return; 175 + } 176 if (commandMenuActive) { ⋮ 282 while (continueLoop) { 283 + if (statusBar) statusBar.setMode('running'); 284 hideCursor(); ⋮ 311 try { 288 - for await (const chunk of streamChat({ config, messages, tools, thinkingMode: state.thinkingMode })) { 312 + activeStreamController = new AbortController(); 313 + for await (const chunk of streamChat({ 314 + config, 315 + messages, 316 + tools, 317 + thinkingMode: state.thinkingMode, 318 + abortSignal: activeStreamController.signal, 319 + })) { 320 const choice = chunk.choices && chunk.choices[0]; ⋮ 372 } catch (err) { 373 + activeStreamController = null; 374 clearTimeout(thinkingDelay); ⋮ 380 showCursor(); 381 + if (escPendingCancel) { 382 + escPendingCancel = false; 383 + statusBar.setMode('input'); 384 + console.log('已取消本次响应'); 385 + return; 386 + } 387 console.log(`错误: ${err.message || err}`); ⋮ 390 391 + activeStreamController = null; 392 + 393 + if (escPendingCancel) { 394 + escPendingCancel = false; 395 + clearTimeout(thinkingDelay); 396 + if (state.thinkingMode) { 397 + spinner.stop('○'); 398 + } else { 399 + spinner.stopSilent(); 400 + } 401 + showCursor(); 402 + statusBar.setMode('input'); 403 + console.log('已取消本次响应'); 404 + return; 405 + } 406 + 407 clearTimeout(thinkingDelay); ⋮ 441 for (const call of toolCallList) { 442 + if (escPendingCancel) { 443 + escPendingCancel = false; 444 + statusBar.setMode('input'); 445 + console.log('工具被用户取消'); 446 + return; 447 + } 448 let args = {}; ⋮ 480 showCursor(); 481 + if (statusBar) statusBar.setMode('input'); 482 } • Edited src/model/client.js (+7 -1) 4 5 -async function* streamChat({ config, messages, tools, thinkingMode }) { 5 +async function* streamChat({ config, messages, tools, thinkingMode, abortSignal }) { 6 const profile = getModelProfile(config); ⋮ 33 body: JSON.stringify(payload), 34 + signal: abortSignal, 35 }); 36 } catch (err) { 37 + if (abortSignal && abortSignal.aborted) { 38 + const error = new Error('请求已取消'); 39 + error.code = 'aborted'; 40 + throw error; 41 + } 42 const cause = err && err.cause ? ` (${err.cause.code || err.cause.message || err.cause})` : ''; • Edited src/cli/index.js (+1 -0) 88 } 89 + if (statusBar) statusBar.render(); 90 if (str === '/' && (rl.line === '' || rl.line === '/')) { • Edited src/cli/index.js (+0 -2) 213 userWriter.writeLine(`${cyan('用户:')}${line}`); 214 - state.messages.push({ role: 'user', content: line }); 215 - persistConversation(); 214 await runAssistantLoop(); • Edited src/cli/index.js (+11 -1) 280 const showCursor = () => process.stdout.write('\x1b[?25h'); 281 + let localMessages = null; 282 while (continueLoop) { ⋮ 309 309 - const messages = buildApiMessages(); 310 + if (!localMessages) { 311 + localMessages = buildApiMessages(); 312 + } 313 + const messages = localMessages; 314 try { ⋮ 429 430 + const userMsg = localMessages && localMessages[localMessages.length - 1]; 431 + if (userMsg && userMsg.role === 'user') { 432 + state.messages.push(userMsg); 433 + persistConversation(); 434 + } 435 + 436 const toolCallList = Object.keys(toolCalls) • Edited src/cli/index.js (+20 -7) 213 userWriter.writeLine(`${cyan('用户:')}${line}`); 214 - await runAssistantLoop(); 214 + await runAssistantLoop(line); 215 rl.prompt(); ⋮ 275 276 -async function runAssistantLoop() { 276 +async function runAssistantLoop(userInput) { 277 let continueLoop = true; ⋮ 311 localMessages = buildApiMessages(); 312 + if (userInput) { 313 + localMessages.push({ role: 'user', content: userInput }); 314 + } 315 } ⋮ 449 if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || ''; 447 - state.messages.push(assistantMsg); 448 - persistConversation(); 450 + const toolMsgs = []; 451 ⋮ 464 const toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 465 + if (escPendingCancel) { 466 + escPendingCancel = false; 467 + indicator.stop(finalLine); 468 + statusBar.setMode('input'); 469 + console.log('工具被用户取消'); 470 + return; 471 + } 472 indicator.stop(finalLine); ⋮ 475 467 - const toolMsg = { 476 + toolMsgs.push({ 477 role: 'tool', ⋮ 480 tool_raw: toolResult.raw, 472 - }; 481 + }); 482 + } 483 + 484 + state.messages.push(assistantMsg); 485 + for (const toolMsg of toolMsgs) { 486 state.messages.push(toolMsg); 474 - persistConversation(); 487 } 488 + persistConversation(); 489 continueLoop = true; • Edited src/cli/index.js (+0 -0) • Edited src/model/client.js (+5 -0) 55 while (true) { 56 + if (abortSignal && abortSignal.aborted) { 57 + const error = new Error('请求已取消'); 58 + error.code = 'aborted'; 59 + throw error; 60 + } 61 const { value, done } = await reader.read(); • Edited src/cli/index.js (+2 -0) 369 assistantContent += delta.content; 370 + if (statusBar) statusBar.render(); 371 } ⋮ 475 printResultLines(resultLines); 476 + if (statusBar) statusBar.render(); 477 • Edited src/cli/index.js (+4 -1) 60 let activeStreamController = null; 61 +let isRunning = false; 62 ⋮ 82 } 82 - if (key && key.name === 'escape') { 83 + if (key && key.name === 'escape' && isRunning) { 84 escPendingCancel = true; ⋮ 282 let localMessages = null; 283 + isRunning = true; 284 while (continueLoop) { ⋮ 507 if (statusBar) statusBar.setMode('input'); 508 + isRunning = false; 509 } • Edited src/cli/index.js (+10 -6) 283 isRunning = true; 284 + const finishRun = () => { 285 + if (statusBar) statusBar.setMode('input'); 286 + isRunning = false; 287 + }; 288 while (continueLoop) { ⋮ 395 escPendingCancel = false; 392 - statusBar.setMode('input'); 396 + finishRun(); 397 console.log('已取消本次响应'); ⋮ 400 console.log(`错误: ${err.message || err}`); 401 + finishRun(); 402 return; ⋮ 415 showCursor(); 411 - statusBar.setMode('input'); 416 + finishRun(); 417 console.log('已取消本次响应'); ⋮ 462 escPendingCancel = false; 458 - statusBar.setMode('input'); 463 + finishRun(); 464 console.log('工具被用户取消'); ⋮ 475 indicator.stop(finalLine); 471 - statusBar.setMode('input'); 476 + finishRun(); 477 console.log('工具被用户取消'); ⋮ 511 showCursor(); 507 - if (statusBar) statusBar.setMode('input'); 508 - isRunning = false; 512 + finishRun(); 513 } • Edited src/cli/index.js (+5 -2) 282 let localMessages = null; 283 + let userPersisted = false; 284 isRunning = true; ⋮ 318 localMessages = buildApiMessages(); 318 - if (userInput) { 319 + if (userInput && !userPersisted) { 320 localMessages.push({ role: 'user', content: userInput }); ⋮ 442 const userMsg = localMessages && localMessages[localMessages.length - 1]; 442 - if (userMsg && userMsg.role === 'user') { 443 + if (userMsg && userMsg.role === 'user' && !userPersisted) { 444 state.messages.push(userMsg); 445 persistConversation(); 446 + userPersisted = true; 447 } ⋮ 499 persistConversation(); 500 + localMessages = null; 501 continueLoop = true; • Edited src/ui/status_bar.js (+18 -2) 18 let enabled = true; 19 + let scrollApplied = false; 20 ⋮ 24 const rows = process.stdout.rows || 24; 25 + if (rows < 2) return; 26 const left = mode === 'running' ? '按下Esc停止' : '输入/查看所有指令'; ⋮ 37 const line = `${displayLeft}${' '.repeat(Math.max(1, gap))}${right}`; 38 + const safeLine = line.slice(0, cols).padEnd(cols, ' '); 39 40 process.stdout.write('\x1b7'); // save cursor 41 + process.stdout.write(`\x1b[1;${rows - 1}r`); // reserve last line 42 + scrollApplied = true; 43 readline.cursorTo(process.stdout, 0, rows - 1); 44 readline.clearLine(process.stdout, 0); 40 - process.stdout.write(line.slice(0, cols)); 45 + process.stdout.write(safeLine); 46 process.stdout.write('\x1b8'); // restore cursor ⋮ 59 60 + const destroy = () => { 61 + if (!process.stdout.isTTY) return; 62 + if (scrollApplied) { 63 + process.stdout.write('\x1b[r'); // reset scroll region 64 + scrollApplied = false; 65 + } 66 + const rows = process.stdout.rows || 24; 67 + readline.cursorTo(process.stdout, 0, rows - 1); 68 + readline.clearLine(process.stdout, 0); 69 + }; 70 + 71 if (process.stdout.isTTY) { ⋮ 74 59 - return { render, setMode, setEnabled }; 75 + return { render, setMode, setEnabled, destroy }; 76 } • Edited src/cli/index.js (+1 -0) 219 rl.on('close', () => { 220 + if (statusBar) statusBar.destroy(); 221 process.stdout.write('\n'); • Edited src/ui/status_bar.js (+19 -5) 19 let scrollApplied = false; 20 + let lastRows = null; 21 ⋮ 25 const rows = process.stdout.rows || 24; 25 - if (rows < 2) return; 26 + if (rows < 3) return; 27 const left = mode === 'running' ? '按下Esc停止' : '输入/查看所有指令'; ⋮ 41 process.stdout.write('\x1b7'); // save cursor 41 - process.stdout.write(`\x1b[1;${rows - 1}r`); // reserve last line 42 - scrollApplied = true; 42 + if (!scrollApplied || lastRows !== rows) { 43 + process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines 44 + scrollApplied = true; 45 + lastRows = rows; 46 + } 47 + // keep one blank line above status line 48 + readline.cursorTo(process.stdout, 0, rows - 2); 49 + readline.clearLine(process.stdout, 0); 50 + // render status line on last row 51 readline.cursorTo(process.stdout, 0, rows - 1); ⋮ 74 const rows = process.stdout.rows || 24; 67 - readline.cursorTo(process.stdout, 0, rows - 1); 68 - readline.clearLine(process.stdout, 0); 75 + if (rows >= 2) { 76 + readline.cursorTo(process.stdout, 0, rows - 2); 77 + readline.clearLine(process.stdout, 0); 78 + } 79 + if (rows >= 1) { 80 + readline.cursorTo(process.stdout, 0, rows - 1); 81 + readline.clearLine(process.stdout, 0); 82 + } 83 }; • Edited src/ui/status_bar.js (+7 -17) 3 const readline = require('readline'); 4 - 5 -function stripAnsi(text) { 6 - return String(text).replace(/\x1b\[[0-9;]*m/g, ''); 7 -} 8 - 9 -function truncateToWidth(text, width) { 10 - if (width <= 0) return ''; 11 - const plain = stripAnsi(text); 12 - if (plain.length <= width) return text; 13 - return plain.slice(0, Math.max(0, width - 1)) + '…'; 14 -} 4 +const { visibleWidth, truncateVisible, padEndVisible } = require('../utils/text_width'); 5 ⋮ 19 const right = `当前上下文token ${total}/${maxTokens}`; 30 - const leftPlain = stripAnsi(left); 31 - const rightPlain = stripAnsi(right); 32 - let gap = cols - leftPlain.length - rightPlain.length; 20 + const leftWidth = visibleWidth(left); 21 + const rightWidth = visibleWidth(right); 22 + let gap = cols - leftWidth - rightWidth; 23 let displayLeft = left; 24 if (gap < 1) { 35 - displayLeft = truncateToWidth(left, Math.max(1, cols - rightPlain.length - 1)); 36 - gap = cols - stripAnsi(displayLeft).length - rightPlain.length; 25 + displayLeft = truncateVisible(left, Math.max(1, cols - rightWidth - 1)); 26 + gap = cols - visibleWidth(displayLeft) - rightWidth; 27 } 28 const line = `${displayLeft}${' '.repeat(Math.max(1, gap))}${right}`; 39 - const safeLine = line.slice(0, cols).padEnd(cols, ' '); 29 + const safeLine = padEndVisible(truncateVisible(line, cols), cols); 30 • Edited src/ui/status_bar.js (+0 -7) 36 } 37 - // keep one blank line above status line 38 - readline.cursorTo(process.stdout, 0, rows - 2); 39 - readline.clearLine(process.stdout, 0); 37 // render status line on last row ⋮ 61 const rows = process.stdout.rows || 24; 65 - if (rows >= 2) { 66 - readline.cursorTo(process.stdout, 0, rows - 2); 67 - readline.clearLine(process.stdout, 0); 68 - } 62 if (rows >= 1) { • Edited src/ui/status_bar.js (+1 -1) 32 if (!scrollApplied || lastRows !== rows) { 33 - process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines 33 + process.stdout.write(`\x1b[1;${rows - 1}r`); // reserve last line 34 scrollApplied = true; • Edited src/ui/status_bar.js (+4 -1) 32 if (!scrollApplied || lastRows !== rows) { 33 - process.stdout.write(`\x1b[1;${rows - 1}r`); // reserve last line 33 + process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines 34 scrollApplied = true; ⋮ 36 } 37 + // keep one blank spacer line above status line 38 + readline.cursorTo(process.stdout, 0, rows - 2); 39 + readline.clearLine(process.stdout, 0); 40 // render status line on last row • Edited src/ui/status_bar.js (+4 -0) 64 const rows = process.stdout.rows || 24; 65 + if (rows >= 2) { 66 + readline.cursorTo(process.stdout, 0, rows - 2); 67 + readline.clearLine(process.stdout, 0); 68 + } 69 if (rows >= 1) { • Edited src/cli/index.js (+13 -0) 62 63 +function promptWithStatus(force = false) { 64 + if (!rl) return; 65 + if (statusBar) statusBar.render(); 66 + if (process.stdout.isTTY) { 67 + const rows = process.stdout.rows || 24; 68 + if (rows >= 3) { 69 + readline.cursorTo(process.stdout, 0, rows - 3); 70 + readline.clearLine(process.stdout, 0); 71 + } 72 + } 73 + rl.prompt(force); 74 +} 75 + 76 readline.emitKeypressEvents(process.stdin); • Edited src/cli/index.js (+7 -7) 153 } 154 - rl.prompt(true); 154 + promptWithStatus(true); 155 if (menuInjectedCommand) { ⋮ 177 rl.setPrompt(PROMPT); 178 - rl.prompt(); 178 + promptWithStatus(); 179 ⋮ 187 escPendingCancel = false; 188 - rl.prompt(); 188 + promptWithStatus(); 189 return; ⋮ 206 } 207 - rl.prompt(); 207 + promptWithStatus(); 208 return; ⋮ 213 if (!input) { 214 - rl.prompt(); 214 + promptWithStatus(); 215 return; ⋮ 220 if (result && result.exit) return; 221 - rl.prompt(); 221 + promptWithStatus(); 222 return; ⋮ 228 await runAssistantLoop(line); 229 - rl.prompt(); 229 + promptWithStatus(); 230 }); • Edited src/cli/index.js (+1 -1) 67 const rows = process.stdout.rows || 24; 68 - if (rows >= 3) { 68 + if (rows >= 8) { 69 readline.cursorTo(process.stdout, 0, rows - 3); • Edited src/cli/index.js (+1 -0) 49 }); 50 +console.log(''); 51 • Edited src/cli/index.js (+1 -1) 68 const rows = process.stdout.rows || 24; 69 - if (rows >= 8) { 69 + if (rows >= 9) { 70 readline.cursorTo(process.stdout, 0, rows - 3); • Edited src/cli/index.js (+1 -0) 50 console.log(''); 51 +console.log(''); 52 • Edited src/cli/index.js (+1 -0) 302 if (statusBar) statusBar.setMode('input'); 303 + if (statusBar) statusBar.render(); 304 isRunning = false; • Edited src/cli/index.js (+1 -1) 66 if (!rl) return; 67 - if (statusBar) statusBar.render(); 67 if (process.stdout.isTTY) { ⋮ 74 rl.prompt(force); 75 + if (statusBar) statusBar.render(); 76 } • Edited src/tools/edit_file.js (+1 -1) 60 const replacements = original.split(oldString).length - 1; 61 - fs.writeFileSync(target, updated, 'utf8'); 61 + fs.writeFileSync(target, updated, { encoding: 'utf8', flag: 'r+' }); 62 const diff = diffSummary(original, updated); • Edited src/ui/status_bar.js (+1 -0) 32 if (!scrollApplied || lastRows !== rows) { 33 + process.stdout.write('\x1b[r'); // reset scroll region 34 process.stdout.write(`\x1b[1;${rows - 2}r`); // reserve last 2 lines • Edited src/tools/edit_file.js (+1 -1) 60 const replacements = original.split(oldString).length - 1; 61 - fs.writeFileSync(target, updated, { encoding: 'utf8', flag: 'r+' }); 61 + fs.writeFileSync(target, updated, 'utf8'); 62 const diff = diffSummary(original, updated); • Edited src/tools/edit_file.js (+7 -4) 47 try { 48 + let creating = false; 49 if (!fs.existsSync(target)) { 49 - return { success: false, error: '文件不存在' }; 50 + creating = true; 51 + fs.mkdirSync(path.dirname(target), { recursive: true }); 52 } ⋮ 57 const original = fs.readFileSync(target, 'utf8'); 56 - if (!original.includes(oldString)) { 58 + if (!creating && !original.includes(oldString)) { 59 return { success: false, error: 'old_string 未匹配到内容' }; 60 } 59 - const updated = original.split(oldString).join(newString); 60 - const replacements = original.split(oldString).length - 1; 61 + const updated = creating ? newString : original.split(oldString).join(newString); 62 + let replacements = creating ? 0 : original.split(oldString).length - 1; 63 + if (creating && newString) replacements = 1; 64 fs.writeFileSync(target, updated, 'utf8'); • Edited src/tools/edit_file.js (+4 -0) 51 fs.mkdirSync(path.dirname(target), { recursive: true }); 52 + fs.writeFileSync(target, '', 'utf8'); 53 } ⋮ 58 const original = fs.readFileSync(target, 'utf8'); 59 + if (!creating && oldString === '') { 60 + return { success: false, error: 'old_string 不能为空,请从 read_file 内容中精确复制' }; 61 + } 62 if (!creating && !original.includes(oldString)) { • Edited src/cli/index.js (+5 -5) 330 if (!showThinkingLabel) return ''; 331 - return { label: ' 思考中...', thinking: thinkingBuffer, colorThinking: true }; 331 + return { label: '思考中...', thinking: thinkingBuffer, colorThinking: true }; 332 }); ⋮ 380 if (state.thinkingMode) { 381 - spinner.stop(thinkingActive ? '○ 思考完成' : '○'); 381 + spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); 382 } else { ⋮ 406 if (state.thinkingMode) { 407 - spinner.stop('○'); 407 + spinner.stop('∙'); 408 } else { ⋮ 428 if (state.thinkingMode) { 429 - spinner.stop('○'); 429 + spinner.stop('∙'); 430 } else { ⋮ 441 if (state.thinkingMode) { 442 - spinner.stop(thinkingActive ? '○ 思考完成' : '○'); 442 + spinner.stop(thinkingActive ? '∙ 思考完成' : '∙'); 443 } else { • Edited src/cli/index.js (+3 -1) 229 userWriter.writeLine(`${cyan('用户:')}${line}`); 230 - await runAssistantLoop(line); 230 + state.messages.push({ role: 'user', content: line }); 231 + persistConversation(); 232 + await runAssistantLoop(); 233 promptWithStatus(); • Edited src/cli/index.js (+2 -10) 294 295 -async function runAssistantLoop(userInput) { 295 +async function runAssistantLoop() { 296 let continueLoop = true; ⋮ 299 const showCursor = () => process.stdout.write('\x1b[?25h'); 300 - let localMessages = null; 301 - let userPersisted = false; 300 isRunning = true; ⋮ 333 336 - if (!localMessages) { 337 - localMessages = buildApiMessages(); 338 - if (userInput && !userPersisted) { 339 - localMessages.push({ role: 'user', content: userInput }); 340 - } 341 - } 342 - const messages = localMessages; 334 + const messages = buildApiMessages(); 335 try { • Edited src/cli/index.js (+0 -7) 452 453 - const userMsg = localMessages && localMessages[localMessages.length - 1]; 454 - if (userMsg && userMsg.role === 'user' && !userPersisted) { 455 - state.messages.push(userMsg); 456 - persistConversation(); 457 - userPersisted = true; 458 - } 459 - 453 const toolCallList = Object.keys(toolCalls) • Edited src/cli/index.js (+29 -16) 463 if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || ''; 464 - const toolMsgs = []; 464 + state.messages.push(assistantMsg); 465 + persistConversation(); 466 467 for (const call of toolCallList) { 467 - if (escPendingCancel) { 468 - escPendingCancel = false; 469 - finishRun(); 470 - console.log('工具被用户取消'); 471 - return; 472 - } 468 let args = {}; ⋮ 472 const indicator = startToolDisplay(startLine); 478 - const toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 473 if (escPendingCancel) { ⋮ 475 indicator.stop(finalLine); 476 + const cancelledRaw = { success: false, error: '任务被用户取消' }; 477 + const resultLines = formatResultLines(call.function.name, args, cancelledRaw); 478 + printResultLines(resultLines); 479 + state.messages.push({ 480 + role: 'tool', 481 + tool_call_id: call.id, 482 + content: '任务被用户取消', 483 + tool_raw: cancelledRaw, 484 + }); 485 + persistConversation(); 486 finishRun(); 483 - console.log('工具被用户取消'); 487 return; 488 } 489 + let toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 490 + if (escPendingCancel) { 491 + escPendingCancel = false; 492 + toolResult = { 493 + success: false, 494 + tool: call.function.name, 495 + error: '任务被用户取消', 496 + raw: { success: false, error: '任务被用户取消' }, 497 + formatted: '任务被用户取消', 498 + }; 499 + } 500 indicator.stop(finalLine); ⋮ 504 491 - toolMsgs.push({ 505 + state.messages.push({ 506 role: 'tool', ⋮ 510 }); 511 + persistConversation(); 512 + if (toolResult.raw && toolResult.raw.success === false && toolResult.error === '任务被用户取消') { 513 + finishRun(); 514 + return; 515 + } 516 } 517 499 - state.messages.push(assistantMsg); 500 - for (const toolMsg of toolMsgs) { 501 - state.messages.push(toolMsg); 502 - } 503 - persistConversation(); 504 - localMessages = null; 518 continueLoop = true; • Edited src/ui/tool_display.js (+1 -0) 132 const msg = raw && raw.error ? raw.error : '执行失败'; 133 + if (msg === '任务被用户取消') return [red(msg)]; 134 return [red(`失败: ${msg}`)]; • Edited src/tools/run_command.js (+18 -2) 11 12 -function runCommandTool(workspace, args) { 12 +function runCommandTool(workspace, args, abortSignal) { 13 return new Promise((resolve) => { ⋮ 17 if (!cmd) return resolve({ success: false, error: 'command 不能为空' }); 18 + if (abortSignal && abortSignal.aborted) { 19 + return resolve({ success: false, error: '任务被用户取消', cancelled: true }); 20 + } 21 const timeoutMs = Math.max(0, timeoutSec) * 1000; 19 - exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => { 22 + let finished = false; 23 + const child = exec(cmd, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: true }, (err, stdout, stderr) => { 24 + if (finished) return; 25 + finished = true; 26 + if (abortSignal) abortSignal.removeEventListener('abort', onAbort); 27 const output = [stdout, stderr].filter(Boolean).join(''); ⋮ 40 }); 41 + const onAbort = () => { 42 + if (finished) return; 43 + finished = true; 44 + try { 45 + child.kill('SIGTERM'); 46 + } catch (_) {} 47 + return resolve({ success: false, error: '任务被用户取消', cancelled: true }); 48 + }; 49 + if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true }); 50 }); • Edited src/tools/web_search.js (+16 -2) 2 3 -async function webSearchTool(config, args) { 3 +async function webSearchTool(config, args, abortSignal) { 4 + if (abortSignal && abortSignal.aborted) { 5 + return { success: false, error: '任务被用户取消', cancelled: true }; 6 + } 7 const body = { ⋮ 23 body: JSON.stringify(body), 24 + signal: abortSignal, 25 }); ⋮ 31 } catch (err) { 32 + if (err && err.name === 'AbortError') { 33 + return { success: false, error: '任务被用户取消', cancelled: true }; 34 + } 35 return { success: false, error: err.message || String(err) }; ⋮ 38 32 -async function extractWebpageTool(config, args, savePath) { 39 +async function extractWebpageTool(config, args, savePath, abortSignal) { 40 + if (abortSignal && abortSignal.aborted) { 41 + return { success: false, error: '任务被用户取消', cancelled: true }; 42 + } 43 const body = { ⋮ 52 body: JSON.stringify(body), 53 + signal: abortSignal, 54 }); ⋮ 66 } catch (err) { 67 + if (err && err.name === 'AbortError') { 68 + return { success: false, error: '任务被用户取消', cancelled: true }; 69 + } 70 return { success: false, error: err.message || String(err) }; • Edited src/tools/dispatcher.js (+15 -5) 140 141 -async function executeTool({ workspace, config, allowMode, toolCall }) { 141 +async function executeTool({ workspace, config, allowMode, toolCall, abortSignal }) { 142 const name = toolCall.function.name; ⋮ 164 165 + if (abortSignal && abortSignal.aborted) { 166 + return { 167 + success: false, 168 + tool: name, 169 + error: '任务被用户取消', 170 + formatted: '任务被用户取消', 171 + raw: { success: false, error: '任务被用户取消', cancelled: true }, 172 + }; 173 + } 174 + 175 let raw; ⋮ 177 else if (name === 'edit_file') raw = editFileTool(workspace, args); 168 - else if (name === 'run_command') raw = await runCommandTool(workspace, args); 169 - else if (name === 'web_search') raw = await webSearchTool(config, args); 178 + else if (name === 'run_command') raw = await runCommandTool(workspace, args, abortSignal); 179 + else if (name === 'web_search') raw = await webSearchTool(config, args, abortSignal); 180 else if (name === 'extract_webpage') { ⋮ 186 } else { 177 - raw = await extractWebpageTool(config, args, targetPath); 187 + raw = await extractWebpageTool(config, args, targetPath, abortSignal); 188 } 189 } else { 180 - raw = await extractWebpageTool(config, args, targetPath); 190 + raw = await extractWebpageTool(config, args, targetPath, abortSignal); 191 } • Edited src/cli/index.js (+4 -0) 63 let isRunning = false; 64 +let activeToolController = null; 65 ⋮ 103 } 104 + if (activeToolController && typeof activeToolController.abort === 'function') { 105 + activeToolController.abort(); 106 + } 107 return; • Edited src/cli/index.js (+11 -3) 479 indicator.stop(finalLine); 480 - const cancelledRaw = { success: false, error: '任务被用户取消' }; 480 + const cancelledRaw = { success: false, error: '任务被用户取消', cancelled: true }; 481 const resultLines = formatResultLines(call.function.name, args, cancelledRaw); ⋮ 492 } 493 - let toolResult = await executeTool({ workspace: WORKSPACE, config, allowMode: state.allowMode, toolCall: call }); 493 + activeToolController = new AbortController(); 494 + let toolResult = await executeTool({ 495 + workspace: WORKSPACE, 496 + config, 497 + allowMode: state.allowMode, 498 + toolCall: call, 499 + abortSignal: activeToolController.signal, 500 + }); 501 + activeToolController = null; 502 if (escPendingCancel) { ⋮ 507 error: '任务被用户取消', 500 - raw: { success: false, error: '任务被用户取消' }, 508 + raw: { success: false, error: '任务被用户取消', cancelled: true }, 509 formatted: '任务被用户取消', • Edited src/cli/index.js (+1 -1) 523 persistConversation(); 524 - if (toolResult.raw && toolResult.raw.success === false && toolResult.error === '任务被用户取消') { 524 + if (toolResult.raw && toolResult.raw.success === false && toolResult.raw.error === '任务被用户取消') { 525 finishRun(); • Edited src/ui/tool_display.js (+1 -0) 219 }); 220 + process.stdout.write('\n'); 221 } • Edited src/cli/index.js (+4 -6) 403 clearTimeout(thinkingDelay); 404 - if (state.thinkingMode) { 404 + if (escPendingCancel) { 405 + spinner.stopSilent(); 406 + } else if (state.thinkingMode) { 407 spinner.stop('∙'); ⋮ 427 clearTimeout(thinkingDelay); 426 - if (state.thinkingMode) { 427 - spinner.stop('∙'); 428 - } else { 429 - spinner.stopSilent(); 430 - } 428 + spinner.stopSilent(); 429 showCursor(); • Edited src/cli/index.js (+8 -3) 20 const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage'); 21 -const { gray, cyan, green } = require('../utils/colors'); 21 +const { gray, cyan, green, red } = require('../utils/colors'); 22 const { createIndentedWriter } = require('../ui/indented_writer'); ⋮ 78 79 +function printCancelLine() { 80 + console.log(''); 81 + process.stdout.write(` └ ${red('已取消本次响应')}\n\n`); 82 +} 83 + 84 readline.emitKeypressEvents(process.stdin); ⋮ 419 finishRun(); 415 - console.log('已取消本次响应'); 420 + printCancelLine(); 421 return; ⋮ 435 finishRun(); 431 - console.log('已取消本次响应'); 436 + printCancelLine(); 437 return; • Edited src/cli/index.js (+1 -1) 80 console.log(''); 81 - process.stdout.write(` └ ${red('已取消本次响应')}\n\n`); 81 + process.stdout.write(` ${red('已取消本次响应')}\n\n`); 82 } • Edited src/ui/status_bar.js (+7 -1) 18 const total = typeof getTokens === 'function' ? getTokens() : 0; 19 - const right = `当前上下文token ${total}/${maxTokens}`; 19 + const formatCount = (value) => { 20 + const num = Number(value) || 0; 21 + if (num < 1000) return String(num); 22 + const k = Math.round((num / 1000) * 10) / 10; 23 + return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`; 24 + }; 25 + const right = `当前上下文 ${formatCount(total)}/${maxTokens}`; 26 const leftWidth = visibleWidth(left); • Edited prompts/system.txt (+1 -0) 5 - 当前时间:{current_time} 6 +- 当前模型:{model_id} 7 - 工作区路径:{path} • Edited src/core/context.js (+1 -0) 50 git: getGitInfo(opts.workspace), 51 + model_id: opts.modelId || '', 52 }; • Edited src/cli/index.js (+1 -1) 277 function buildApiMessages() { 278 - const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode }); 278 + const system = buildSystemPrompt(systemPrompt, { workspace: WORKSPACE, allowMode: state.allowMode, modelId: state.modelId || state.modelKey }); 279 const messages = [{ role: 'system', content: system }]; • Edited src/core/context.js (+1 -1) 39 const allowModeValue = opts.allowMode === 'read_only' 40 - ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要请求切换权限。` 40 + ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。` 41 : opts.allowMode; • Edited src/tools/dispatcher.js (+1 -1) 155 if (allowMode === 'read_only' && (name === 'edit_file' || name === 'run_command')) { 156 - const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请先请求切换权限。'; 156 + const note = '当前为只读模式,已禁用 edit_file、run_command。若需使用,请告知当前无权限需要用户输入 /allow 切换权限。'; 157 return { • Edited src/storage/conversation_store.js (+12 -3) 36 for (const msg of messages) { 37 - if (msg.role === 'user' && typeof msg.content === 'string' && msg.content.trim()) { 38 - const content = msg.content.trim(); 39 - return content.length > 50 ? `${content.slice(0, 50)}...` : content; 37 + if (msg.role === 'user') { 38 + let content = ''; 39 + if (typeof msg.content === 'string') { 40 + content = msg.content.trim(); 41 + } else if (Array.isArray(msg.content)) { 42 + content = msg.content 43 + .filter((part) => part && part.type === 'text' && typeof part.text === 'string') 44 + .map((part) => part.text) 45 + .join(' ') 46 + .trim(); 47 + } 48 + if (content) return content.length > 50 ? `${content.slice(0, 50)}...` : content; 49 } • Edited src/cli/index.js (+132 -1) 20 const { applyUsage, normalizeTokenUsage } = require('../utils/token_usage'); 21 -const { gray, cyan, green, red } = require('../utils/colors'); 21 +const { gray, cyan, green, red, blue } = require('../utils/colors'); 22 const { createIndentedWriter } = require('../ui/indented_writer'); 23 const { createStatusBar } = require('../ui/status_bar'); 24 +const { visibleWidth } = require('../utils/text_width'); 25 +const { readMediafileTool } = require('../tools/read_mediafile'); 26 ⋮ 66 let activeToolController = null; 67 +let currentMedia = { tokens: [], text: '' }; 68 + 69 +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.heic']); 70 +const VIDEO_EXTS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']); 71 + 72 +function getPathExt(p) { 73 + const idx = p.lastIndexOf('.'); 74 + if (idx === -1) return ''; 75 + return p.slice(idx).toLowerCase(); 76 +} 77 + 78 +function decodeEscapedPath(p) { 79 + return p.replace(/\\ /g, ' ').replace(/\\\\/g, '\\'); 80 +} 81 + 82 +function findMediaMatches(line) { 83 + const matches = []; 84 + const quoted = /'([^']+)'/g; 85 + let m; 86 + while ((m = quoted.exec(line)) !== null) { 87 + matches.push({ raw: m[0], path: m[1], index: m.index }); 88 + } 89 + const extGroup = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])) 90 + .map((e) => e.replace('.', '\\.')) 91 + .join('|'); 92 + const unquoted = new RegExp(`/((?:\\\\ |[^\\s])+?)\\.(${extGroup})`, 'g'); 93 + while ((m = unquoted.exec(line)) !== null) { 94 + const raw = `/${m[1]}.${m[2]}`; 95 + matches.push({ raw, path: raw, index: m.index }); 96 + } 97 + matches.sort((a, b) => a.index - b.index); 98 + return matches; 99 +} 100 + 101 +function applyMediaTokens(line) { 102 + if (!line) return { line: '', tokens: [] }; 103 + const matches = findMediaMatches(line); 104 + if (!matches.length) return { line, tokens: [] }; 105 + 106 + let images = 0; 107 + let videos = 0; 108 + let cursor = 0; 109 + let out = ''; 110 + const tokens = []; 111 + 112 + for (const match of matches) { 113 + if (match.index < cursor) continue; 114 + const before = line.slice(cursor, match.index); 115 + out += before; 116 + const decoded = decodeEscapedPath(match.path); 117 + const ext = getPathExt(decoded); 118 + const isImage = IMAGE_EXTS.has(ext); 119 + const isVideo = VIDEO_EXTS.has(ext); 120 + if (!isImage && !isVideo) { 121 + out += match.raw; 122 + cursor = match.index + match.raw.length; 123 + continue; 124 + } 125 + if (isImage && images >= 9) { 126 + out += match.raw; 127 + cursor = match.index + match.raw.length; 128 + continue; 129 + } 130 + if (isVideo && videos >= 1) { 131 + out += match.raw; 132 + cursor = match.index + match.raw.length; 133 + continue; 134 + } 135 + const token = isImage ? `[图片 #${images + 1}]` : `[视频 #${videos + 1}]`; 136 + tokens.push({ token, path: decoded, type: isImage ? 'image' : 'video' }); 137 + out += token; 138 + if (isImage) images += 1; 139 + if (isVideo) videos += 1; 140 + cursor = match.index + match.raw.length; 141 + } 142 + out += line.slice(cursor); 143 + return { line: out, tokens }; 144 +} 145 + 146 +function colorizeTokens(line) { 147 + return line.replace(/\[(图片|视频) #\d+\]/g, (t) => blue(t)); 148 +} 149 + 150 +function refreshInputLine() { 151 + if (!rl || !process.stdout.isTTY || commandMenuActive) return; 152 + const line = rl.line || ''; 153 + const colorLine = colorizeTokens(line); 154 + readline.clearLine(process.stdout, 0); 155 + readline.cursorTo(process.stdout, 0); 156 + process.stdout.write(PROMPT + colorLine); 157 + const cursorCol = visibleWidth(PROMPT) + visibleWidth(line.slice(0, rl.cursor || 0)); 158 + readline.cursorTo(process.stdout, cursorCol); 159 +} 160 + 161 +function buildUserContent(line, tokens) { 162 + if (!tokens.length) return line; 163 + const parts = []; 164 + const tokenRe = /\[(图片|视频) #\d+\]/g; 165 + let last = 0; 166 + let m; 167 + while ((m = tokenRe.exec(line)) !== null) { 168 + if (m.index > last) { 169 + const text = line.slice(last, m.index); 170 + if (text) parts.push({ type: 'text', text }); 171 + } 172 + const token = m[0]; 173 + const info = tokens.find((t) => t.token === token); 174 + if (info) { 175 + const media = readMediafileTool(WORKSPACE, { path: info.path }); 176 + if (media && media.success) { 177 + const url = `data:${media.mime};base64,${media.b64}`; 178 + parts.push({ 179 + type: media.type === 'image' ? 'image_url' : 'video_url', 180 + [media.type === 'image' ? 'image_url' : 'video_url']: { url }, 181 + }); 182 + } else { 183 + parts.push({ type: 'text', text: token }); 184 + } 185 + } else { 186 + parts.push({ type: 'text', text: token }); 187 + } 188 + last = tokenRe.lastIndex; 189 + } 190 + if (last < line.length) { 191 + const tail = line.slice(last); 192 + if (tail) parts.push({ type: 'text', text: tail }); 193 + } 194 + return parts; 195 +} 196 • Edited src/cli/index.js (+11 -0) 244 } 245 + if (!isRunning) { 246 + const applied = applyMediaTokens(rl.line || ''); 247 + if (applied.line !== (rl.line || '')) { 248 + rl.line = applied.line; 249 + rl.cursor = rl.line.length; 250 + currentMedia = { tokens: applied.tokens, text: applied.line }; 251 + } else if (currentMedia.text !== applied.line) { 252 + currentMedia = { tokens: applied.tokens, text: applied.line }; 253 + } 254 + refreshInputLine(); 255 + } 256 if (statusBar) statusBar.render(); • Edited src/cli/index.js (+8 -3) 346 } 347 - const input = line.trim(); 347 + const applied = applyMediaTokens(line); 348 + const normalizedLine = applied.line; 349 + currentMedia = { tokens: applied.tokens, text: normalizedLine }; 350 + const input = normalizedLine.trim(); 351 if (menuJustClosedAt) { ⋮ 382 const userWriter = createIndentedWriter(' '); 380 - userWriter.writeLine(`${cyan('用户:')}${line}`); 381 - state.messages.push({ role: 'user', content: line }); 383 + const displayLine = colorizeTokens(normalizedLine); 384 + userWriter.writeLine(`${cyan('用户:')}${displayLine}`); 385 + const content = buildUserContent(normalizedLine, currentMedia.tokens); 386 + state.messages.push({ role: 'user', content }); 387 persistConversation(); • Edited src/cli/index.js (+1 -1) 89 const extGroup = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])) 90 - .map((e) => e.replace('.', '\\.')) 90 + .map((e) => e.slice(1)) 91 .join('|'); • Edited src/cli/index.js (+7 -0) 31 const MENU_PAGE_SIZE = 6; 32 +const SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']); 33 ⋮ 197 198 +function isSlashCommand(line) { 199 + if (!line || line[0] !== '/') return false; 200 + const cmd = line.split(/\s+/)[0]; 201 + return SLASH_COMMANDS.has(cmd); 202 +} 203 + 204 function promptWithStatus(force = false) { • Edited src/cli/index.js (+2 -1) 252 if (!isRunning) { 253 - const applied = applyMediaTokens(rl.line || ''); 253 + const rawLine = rl.line || ''; 254 + const applied = isSlashCommand(rawLine) ? { line: rawLine, tokens: [] } : applyMediaTokens(rawLine); 255 if (applied.line !== (rl.line || '')) { • Edited src/cli/index.js (+1 -1) 354 } 355 - const applied = applyMediaTokens(line); 355 + const applied = isSlashCommand(line) ? { line, tokens: [] } : applyMediaTokens(line); 356 const normalizedLine = applied.line; • Edited src/cli/index.js (+5 -0) 117 const decoded = decodeEscapedPath(match.path); 118 + if (!fs.existsSync(decoded)) { 119 + out += match.raw; 120 + cursor = match.index + match.raw.length; 121 + continue; 122 + } 123 const ext = getPathExt(decoded); • Edited src/cli/index.js (+92 -52) 68 let currentMedia = { tokens: [], text: '' }; 69 +let pendingSlashTimer = null; 70 ⋮ 167 168 +function removeTokenAtCursor(line, cursor) { 169 + const tokenRe = /\[(图片|视频) #\d+\]/g; 170 + let m; 171 + while ((m = tokenRe.exec(line)) !== null) { 172 + const start = m.index; 173 + const end = m.index + m[0].length; 174 + if (cursor > start && cursor <= end) { 175 + const nextLine = line.slice(0, start) + line.slice(end); 176 + return { line: nextLine, cursor: start, removed: true }; 177 + } 178 + } 179 + return { line, cursor, removed: false }; 180 +} 181 + 182 function buildUserContent(line, tokens) { ⋮ 261 } 262 + if (!isRunning && key && key.name === 'backspace') { 263 + const line = rl.line || ''; 264 + const cursor = rl.cursor || 0; 265 + const updated = removeTokenAtCursor(line, cursor); 266 + if (updated.removed) { 267 + rl.line = updated.line; 268 + rl.cursor = updated.cursor; 269 + refreshInputLine(); 270 + return; 271 + } 272 + } 273 if (key && key.name === 'escape' && isRunning) { ⋮ 282 } 283 + if (pendingSlashTimer && (str !== '/' || (rl.line && rl.line !== '/'))) { 284 + clearTimeout(pendingSlashTimer); 285 + pendingSlashTimer = null; 286 + } 287 if (!isRunning) { ⋮ 300 if (str === '/' && (rl.line === '' || rl.line === '/')) { 271 - commandMenuActive = true; 272 - if (rl) { 273 - rl.pause(); 274 - rl.line = ''; 275 - rl.cursor = 0; 276 - readline.clearLine(process.stdout, 0); 277 - readline.cursorTo(process.stdout, 0); 301 + if (pendingSlashTimer) { 302 + clearTimeout(pendingSlashTimer); 303 + pendingSlashTimer = null; 304 } 279 - menuSearchTerm = ''; 280 - menuAbortController = new AbortController(); 281 - void openCommandMenu({ 282 - rl, 283 - prompt: PROMPT, 284 - pageSize: MENU_PAGE_SIZE, 285 - colorEnabled: process.stdout.isTTY, 286 - resetAnsi: '\x1b[0m', 287 - onInput: (input) => { 288 - menuSearchTerm = input || ''; 289 - }, 290 - abortSignal: menuAbortController.signal, 291 - }) 292 - .then((result) => { 293 - if (result && result.chosen && !result.cancelled) { 294 - menuInjectedCommand = result.chosen; 295 - } 296 - }) 297 - .finally(() => { 298 - commandMenuActive = false; 299 - menuAbortController = null; 300 - menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 301 - menuLastSearchTerm = menuSearchTerm; 302 - drainStdin(); 303 - rl.line = ''; 304 - rl.cursor = 0; 305 - menuSearchTerm = ''; 306 - if (process.stdout.isTTY) { 307 - readline.clearScreenDown(process.stdout); 308 - } 309 - // Clear possible echoes from the base readline line (current + previous line). 310 - if (process.stdout.isTTY) { 311 - readline.clearLine(process.stdout, 0); 312 - readline.cursorTo(process.stdout, 0); 313 - readline.moveCursor(process.stdout, 0, -1); 314 - readline.clearLine(process.stdout, 0); 315 - readline.cursorTo(process.stdout, 0); 316 - } else { 305 + pendingSlashTimer = setTimeout(() => { 306 + pendingSlashTimer = null; 307 + if (rl.line === '' || rl.line === '/') { 308 + commandMenuActive = true; 309 + if (rl) { 310 + rl.pause(); 311 + rl.line = ''; 312 + rl.cursor = 0; 313 readline.clearLine(process.stdout, 0); ⋮ 315 } 320 - promptWithStatus(true); 321 - if (menuInjectedCommand) { 322 - const injected = menuInjectedCommand; 323 - menuInjectedCommand = null; 324 - setImmediate(() => injectLine(injected)); 325 - } 326 - }); 316 + menuSearchTerm = ''; 317 + menuAbortController = new AbortController(); 318 + void openCommandMenu({ 319 + rl, 320 + prompt: PROMPT, 321 + pageSize: MENU_PAGE_SIZE, 322 + colorEnabled: process.stdout.isTTY, 323 + resetAnsi: '\x1b[0m', 324 + onInput: (input) => { 325 + menuSearchTerm = input || ''; 326 + }, 327 + abortSignal: menuAbortController.signal, 328 + }) 329 + .then((result) => { 330 + if (result && result.chosen && !result.cancelled) { 331 + menuInjectedCommand = result.chosen; 332 + } 333 + }) 334 + .finally(() => { 335 + commandMenuActive = false; 336 + menuAbortController = null; 337 + menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 338 + menuLastSearchTerm = menuSearchTerm; 339 + drainStdin(); 340 + rl.line = ''; 341 + rl.cursor = 0; 342 + menuSearchTerm = ''; 343 + if (process.stdout.isTTY) { 344 + readline.clearScreenDown(process.stdout); 345 + } 346 + // Clear possible echoes from the base readline line (current + previous line). 347 + if (process.stdout.isTTY) { 348 + readline.clearLine(process.stdout, 0); 349 + readline.cursorTo(process.stdout, 0); 350 + readline.moveCursor(process.stdout, 0, -1); 351 + readline.clearLine(process.stdout, 0); 352 + readline.cursorTo(process.stdout, 0); 353 + } else { 354 + readline.clearLine(process.stdout, 0); 355 + readline.cursorTo(process.stdout, 0); 356 + } 357 + promptWithStatus(true); 358 + if (menuInjectedCommand) { 359 + const injected = menuInjectedCommand; 360 + menuInjectedCommand = null; 361 + setImmediate(() => injectLine(injected)); 362 + } 363 + }); 364 + } 365 + }, 80); 366 + return; 367 } • Edited src/cli/index.js (+9 -1) 399 } 400 - const applied = isSlashCommand(line) ? { line, tokens: [] } : applyMediaTokens(line); 400 + let applied = { line, tokens: [] }; 401 + if (!isSlashCommand(line)) { 402 + const hasToken = /\[(图片|视频) #\d+\]/.test(line); 403 + if (hasToken && currentMedia.tokens.length && currentMedia.text === line) { 404 + applied = { line, tokens: currentMedia.tokens }; 405 + } else { 406 + applied = applyMediaTokens(line); 407 + } 408 + } 409 const normalizedLine = applied.line; • Edited src/cli/index.js (+8 -1) 80 function decodeEscapedPath(p) { 81 - return p.replace(/\\ /g, ' ').replace(/\\\\/g, '\\'); 81 + let text = String(p || ''); 82 + if (text.startsWith('file://')) { 83 + text = text.replace(/^file:\/\//, ''); 84 + } 85 + try { 86 + text = decodeURIComponent(text); 87 + } catch (_) {} 88 + return text.replace(/\\ /g, ' ').replace(/\\\\/g, '\\'); 89 } • Edited src/cli/index.js (+0 -0) • Edited src/cli/index.js (+9 -29) 190 if (!tokens.length) return line; 191 - const parts = []; 192 - const tokenRe = /\[(图片|视频) #\d+\]/g; 193 - let last = 0; 194 - let m; 195 - while ((m = tokenRe.exec(line)) !== null) { 196 - if (m.index > last) { 197 - const text = line.slice(last, m.index); 198 - if (text) parts.push({ type: 'text', text }); 199 - } 200 - const token = m[0]; 201 - const info = tokens.find((t) => t.token === token); 202 - if (info) { 203 - const media = readMediafileTool(WORKSPACE, { path: info.path }); 204 - if (media && media.success) { 205 - const url = `data:${media.mime};base64,${media.b64}`; 206 - parts.push({ 207 - type: media.type === 'image' ? 'image_url' : 'video_url', 208 - [media.type === 'image' ? 'image_url' : 'video_url']: { url }, 209 - }); 210 - } else { 211 - parts.push({ type: 'text', text: token }); 212 - } 213 - } else { 214 - parts.push({ type: 'text', text: token }); 191 + const parts = [{ type: 'text', text: line }]; 192 + for (const info of tokens) { 193 + const media = readMediafileTool(WORKSPACE, { path: info.path }); 194 + if (media && media.success) { 195 + const url = `data:${media.mime};base64,${media.b64}`; 196 + parts.push({ 197 + type: media.type === 'image' ? 'image_url' : 'video_url', 198 + [media.type === 'image' ? 'image_url' : 'video_url']: { url }, 199 + }); 200 } 216 - last = tokenRe.lastIndex; 217 - } 218 - if (last < line.length) { 219 - const tail = line.slice(last); 220 - if (tail) parts.push({ type: 'text', text: tail }); 201 } • Edited src/cli/index.js (+1 -0) 433 const content = buildUserContent(normalizedLine, currentMedia.tokens); 434 + console.log('DEBUG_USER_CONTENT', JSON.stringify(content, null, 2)); 435 state.messages.push({ role: 'user', content }); • Edited src/cli/index.js (+19 -0) 68 let currentMedia = { tokens: [], text: '' }; 69 +let suppressSlashMenuUntil = 0; 70 let pendingSlashTimer = null; ⋮ 232 233 +process.stdin.on('data', (chunk) => { 234 + if (isRunning || commandMenuActive || !rl) return; 235 + const text = chunk ? chunk.toString() : ''; 236 + if (!text) return; 237 + const looksLikePath = text.includes('file://') || /\.(png|jpe?g|gif|webp|bmp|tiff|heic|mp4|mov|avi|mkv|webm|m4v)\b/i.test( text); 238 + if (!looksLikePath) return; 239 + suppressSlashMenuUntil = Date.now() + 200; 240 + setImmediate(() => { 241 + const applied = applyMediaTokens(rl.line || ''); 242 + if (applied.tokens.length) { 243 + rl.line = applied.line; 244 + rl.cursor = rl.line.length; 245 + currentMedia = { tokens: applied.tokens, text: applied.line }; 246 + refreshInputLine(); 247 + } 248 + }); 249 +}); 250 + 251 initReadline(); • Edited src/cli/index.js (+3 -0) 288 } 289 + if (str === '/' && Date.now() < suppressSlashMenuUntil) { 290 + return; 291 + } 292 if (pendingSlashTimer && (str !== '/' || (rl.line && rl.line !== '/'))) { • Edited src/cli/index.js (+0 -0) • Edited src/cli/index.js (+9 -1) 297 const rawLine = rl.line || ''; 298 - const applied = isSlashCommand(rawLine) ? { line: rawLine, tokens: [] } : applyMediaTokens(rawLine); 298 + let applied = { line: rawLine, tokens: [] }; 299 + if (!isSlashCommand(rawLine)) { 300 + const hasToken = /\[(图片|视频) #\d+\]/.test(rawLine); 301 + if (hasToken && currentMedia.tokens.length && currentMedia.text === rawLine) { 302 + applied = { line: rawLine, tokens: currentMedia.tokens }; 303 + } else { 304 + applied = applyMediaTokens(rawLine); 305 + } 306 + } 307 if (applied.line !== (rl.line || '')) { • Edited src/cli/index.js (+0 -1) 463 const content = buildUserContent(normalizedLine, currentMedia.tokens); 464 - console.log('DEBUG_USER_CONTENT', JSON.stringify(content, null, 2)); 464 state.messages.push({ role: 'user', content }); • Edited src/cli/index.js (+3 -0) 305 } 306 + if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { 307 + applied = { line: rawLine, tokens: currentMedia.tokens }; 308 + } 309 } • Edited src/cli/index.js (+3 -0) 427 } 428 + if (hasToken && !applied.tokens.length && currentMedia.tokens.length) { 429 + applied = { line, tokens: currentMedia.tokens }; 430 + } 431 } • Edited src/cli/commands.js (+2 -1) 58 allow_mode: state.allowMode, 59 - token_usage: state.tokenUsage, 59 + token_usage: { prompt: 0, completion: 0, total: 0 }, 60 cwd: workspace, ⋮ 63 state.messages = []; 64 + state.tokenUsage = { prompt: 0, completion: 0, total: 0 }; 65 console.log(`已创建新对话: ${conv.id}`); • Edited src/cli/commands.js (+2 -2) 53 if (cmd === '/new') { 54 + state.tokenUsage = normalizeTokenUsage({ prompt: 0, completion: 0, total: 0 }); 55 const conv = createConversation(workspace, { ⋮ 59 allow_mode: state.allowMode, 59 - token_usage: { prompt: 0, completion: 0, total: 0 }, 60 + token_usage: state.tokenUsage, 61 cwd: workspace, ⋮ 64 state.messages = []; 64 - state.tokenUsage = { prompt: 0, completion: 0, total: 0 }; 65 console.log(`已创建新对话: ${conv.id}`); • Edited src/cli/index.js (+1 -1) 458 if (input.startsWith('/')) { 459 - const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE }); 459 + const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); 460 if (result && result.exit) return; • Edited src/cli/commands.js (+2 -1) 27 async function handleCommand(input, ctx) { 28 - const { rl, state, config, workspace } = ctx; 28 + const { rl, state, config, workspace, statusBar } = ctx; 29 const persist = () => { ⋮ 66 persist(); 67 + if (statusBar) statusBar.render(); 68 return { exit: false }; • Edited src/cli/index.js (+18 -0) 31 const MENU_PAGE_SIZE = 6; 32 +const DEBUG_LOG_DIR = path.join(WORKSPACE, 'logs'); 33 +const DEBUG_LOG_PATH = path.join(DEBUG_LOG_DIR, 'cli-debug.log'); 34 +const DEBUG_LOG_ENABLED = true; 35 const SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']); ⋮ 232 233 +function debugLog(event, data = {}) { 234 + if (!DEBUG_LOG_ENABLED) return; 235 + try { 236 + if (!fs.existsSync(DEBUG_LOG_DIR)) fs.mkdirSync(DEBUG_LOG_DIR, { recursive: true }); 237 + const payload = { 238 + ts: new Date().toISOString(), 239 + event, 240 + ...data, 241 + }; 242 + fs.appendFileSync(DEBUG_LOG_PATH, `${JSON.stringify(payload)}\n`); 243 + } catch (_) {} 244 +} 245 + 246 readline.emitKeypressEvents(process.stdin); ⋮ 273 statusBar.render(); 274 +debugLog('statusbar_init', { token_total: normalizeTokenUsage(state.tokenUsage).total || 0 }); 275 ⋮ 476 const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); 477 + debugLog('after_command', { cmd: input, token_total: normalizeTokenUsage(state.tokenUsage).total || 0 }); 478 if (result && result.exit) return; • Edited src/cli/commands.js (+3 -0) 67 if (statusBar) statusBar.render(); 68 + if (typeof ctx.debugLog === 'function') { 69 + ctx.debugLog('command_new', { token_total: state.tokenUsage.total || 0 }); 70 + } 71 return { exit: false }; • Edited src/cli/index.js (+1 -1) 475 if (input.startsWith('/')) { 476 - const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); 476 + const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar, debugLog }); 477 debugLog('after_command', { cmd: input, token_total: normalizeTokenUsage(state.tokenUsage).total || 0 }); • Edited src/cli/index.js (+7 -0) 276 process.stdin.on('keypress', (str, key) => { 277 + debugLog('keypress', { str, key: key && key.name, line: rl ? rl.line : '' }); 278 if (commandMenuActive) { ⋮ 345 if (rl.line === '' || rl.line === '/') { 346 + debugLog('menu_open'); 347 commandMenuActive = true; ⋮ 368 .then((result) => { 369 + debugLog('menu_result', { result }); 370 if (result && result.chosen && !result.cancelled) { ⋮ 374 .finally(() => { 375 + debugLog('menu_close', { injected: menuInjectedCommand, line: rl ? rl.line : '' }); 376 commandMenuActive = false; ⋮ 401 menuInjectedCommand = null; 402 + debugLog('menu_inject', { injected }); 403 setImmediate(() => injectLine(injected)); ⋮ 428 rl.on('line', async (line) => { 429 + debugLog('line', { line }); 430 if (statusBar) statusBar.render(); ⋮ 481 if (input.startsWith('/')) { 482 + debugLog('slash_command', { input }); 483 const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar, debugLog }); • Edited src/cli/index.js (+20 -15) 65 let menuAbortController = null; 66 +let menuJustClosedInjected = false; 67 let statusBar = null; ⋮ 377 commandMenuActive = false; 377 - menuAbortController = null; 378 - menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 379 - menuLastSearchTerm = menuSearchTerm; 378 + menuAbortController = null; 379 + menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 380 + menuJustClosedInjected = !!menuInjectedCommand; 381 + menuLastSearchTerm = menuSearchTerm; 382 drainStdin(); ⋮ 460 if (menuJustClosedAt) { 459 - const tooOld = Date.now() - menuJustClosedAt > 800; 460 - const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, ''); 461 - const normalizedInput = input.replace(/^\/+/, ''); 462 - if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { 463 - menuJustClosedAt = 0; 464 - menuLastSearchTerm = ''; 465 - if (process.stdout.isTTY) { 466 - readline.moveCursor(process.stdout, 0, -1); 467 - readline.clearLine(process.stdout, 0); 468 - readline.cursorTo(process.stdout, 0); 461 + if (!menuJustClosedInjected) { 462 + const tooOld = Date.now() - menuJustClosedAt > 800; 463 + const normalizedMenu = String(menuLastSearchTerm).trim().replace(/^\/+/, ''); 464 + const normalizedInput = input.replace(/^\/+/, ''); 465 + if (!tooOld && (!normalizedMenu ? input === '' : normalizedInput === normalizedMenu)) { 466 + menuJustClosedAt = 0; 467 + menuLastSearchTerm = ''; 468 + if (process.stdout.isTTY) { 469 + readline.moveCursor(process.stdout, 0, -1); 470 + readline.clearLine(process.stdout, 0); 471 + readline.cursorTo(process.stdout, 0); 472 + } 473 + promptWithStatus(); 474 + return; 475 } 470 - promptWithStatus(); 471 - return; 476 } ⋮ 478 menuLastSearchTerm = ''; 479 + menuJustClosedInjected = false; 480 } • Edited src/cli/index.js (+1 -25) 31 const MENU_PAGE_SIZE = 6; 32 -const DEBUG_LOG_DIR = path.join(WORKSPACE, 'logs'); 33 -const DEBUG_LOG_PATH = path.join(DEBUG_LOG_DIR, 'cli-debug.log'); 34 -const DEBUG_LOG_ENABLED = true; 32 const SLASH_COMMANDS = new Set(['/new', '/resume', '/allow', '/model', '/status', '/compact', '/config', '/help', '/exit']); ⋮ 230 234 -function debugLog(event, data = {}) { 235 - if (!DEBUG_LOG_ENABLED) return; 236 - try { 237 - if (!fs.existsSync(DEBUG_LOG_DIR)) fs.mkdirSync(DEBUG_LOG_DIR, { recursive: true }); 238 - const payload = { 239 - ts: new Date().toISOString(), 240 - event, 241 - ...data, 242 - }; 243 - fs.appendFileSync(DEBUG_LOG_PATH, `${JSON.stringify(payload)}\n`); 244 - } catch (_) {} 245 -} 231 ⋮ 259 statusBar.render(); 275 -debugLog('statusbar_init', { token_total: normalizeTokenUsage(state.tokenUsage).total || 0 }); 260 261 process.stdin.on('keypress', (str, key) => { 278 - debugLog('keypress', { str, key: key && key.name, line: rl ? rl.line : '' }); 262 if (commandMenuActive) { ⋮ 329 if (rl.line === '' || rl.line === '/') { 347 - debugLog('menu_open'); 330 commandMenuActive = true; ⋮ 351 .then((result) => { 370 - debugLog('menu_result', { result }); 352 if (result && result.chosen && !result.cancelled) { ⋮ 356 .finally(() => { 376 - debugLog('menu_close', { injected: menuInjectedCommand, line: rl ? rl.line : '' }); 357 commandMenuActive = false; ⋮ 383 menuInjectedCommand = null; 404 - debugLog('menu_inject', { injected }); 384 setImmediate(() => injectLine(injected)); ⋮ 409 rl.on('line', async (line) => { 431 - debugLog('line', { line }); 410 if (statusBar) statusBar.render(); ⋮ 464 if (input.startsWith('/')) { 487 - debugLog('slash_command', { input }); 488 - const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar, debugLog }); 489 - debugLog('after_command', { cmd: input, token_total: normalizeTokenUsage(state.tokenUsage).total || 0 }); 465 + const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); 466 if (result && result.exit) return; • Edited src/cli/commands.js (+0 -3) 67 if (statusBar) statusBar.render(); 68 - if (typeof ctx.debugLog === 'function') { 69 - ctx.debugLog('command_new', { token_total: state.tokenUsage.total || 0 }); 70 - } 68 return { exit: false }; • Edited src/cli/index.js (+9 -0) 230 231 +function stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, thinkingMode) { 232 + if (!spinner) return; 233 + if (thinkingMode && (thinkingActive || showThinkingLabel)) { 234 + spinner.stop('∙ 停止思考'); 235 + } else { 236 + spinner.stopSilent(); 237 + } 238 +} 239 + 240 • Edited src/cli/index.js (+2 -2) 655 if (escPendingCancel) { 656 - spinner.stopSilent(); 656 + stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode); 657 } else if (state.thinkingMode) { ⋮ 678 clearTimeout(thinkingDelay); 679 - spinner.stopSilent(); 679 + stopSpinnerForCancel(spinner, thinkingActive, showThinkingLabel, state.thinkingMode); 680 showCursor(); • Edited src/cli/commands.js (+18 -10) 26 27 +function printNotice(message) { 28 + console.log(''); 29 + console.log(message); 30 + console.log(''); 31 +} 32 + 33 async function handleCommand(input, ctx) { ⋮ 70 state.messages = []; 65 - console.log(`已创建新对话: ${conv.id}`); 71 + printNotice(`已创建新对话: ${conv.id}`); 72 persist(); ⋮ 80 if (!conv) { 75 - console.log('未找到对话'); 81 + printNotice('未找到对话'); 82 return { exit: false }; ⋮ 88 state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); 83 - console.log(`已加载对话: ${conv.id}`); 89 + printNotice(`已加载对话: ${conv.id}`); 90 renderConversation(state.messages); ⋮ 96 if (!items.length) { 91 - console.log('暂无对话记录'); 97 + printNotice('暂无对话记录'); 98 return { exit: false }; ⋮ 102 if (!filtered.length) { 97 - console.log('暂无可恢复的对话'); 103 + printNotice('暂无可恢复的对话'); 104 return { exit: false }; ⋮ 130 if (!conv) { 125 - console.log('未找到对话'); 131 + printNotice('未找到对话'); 132 return { exit: false }; ⋮ 138 state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); 133 - console.log(`已加载对话: ${conv.id}`); 139 + printNotice(`已加载对话: ${conv.id}`); 140 renderConversation(state.messages); ⋮ 158 state.allowMode = selected; 153 - console.log(`运行模式已切换为: ${state.allowMode}`); 159 + printNotice(`运行模式已切换为: ${state.allowMode}`); 160 persist(); ⋮ 180 state.thinkingMode = mode === 'thinking'; 175 - console.log(`模型已切换为: ${state.modelKey} | 思考模式: ${mode}`); 181 + printNotice(`模型已切换为: ${state.modelKey} | 思考模式: ${mode}`); 182 persist(); ⋮ 204 if (cmd === '/config') { 205 + console.log(''); 206 console.log(`base_url: ${config.base_url}`); ⋮ 208 console.log(`apikey: ${maskKey(config.api_key)}`); 209 + console.log(''); 210 return { exit: false }; ⋮ 246 spinner.stop('○'); 239 - console.log(`压缩完成:${oldId} -> ${state.conversation.id}`); 247 + printNotice(`压缩完成:${oldId} -> ${state.conversation.id}`); 248 persist(); • Edited src/cli/commands.js (+3 -3) 155 ]; 156 - const selected = await runSelect({ rl, message: 'Update Permissions', choices, pageSize: 6 }); 156 + const selected = await runSelect({ rl, message: '', choices, pageSize: 6 }); 157 if (selected) { ⋮ 168 ]; 169 - const model = await runSelect({ rl, message: 'Update Model', choices: modelChoices, pageSize: 6 }); 169 + const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); 170 if (!model) return { exit: false }; ⋮ 177 ]; 178 - const mode = await runSelect({ rl, message: 'Update Thinking Mode', choices: thinkingChoices, pageSize: 6 }); 178 + const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 }); 179 if (mode) { • Edited src/cli/index.js (+6 -0) 47 48 +function printNotice(message) { 49 + console.log(''); 50 + console.log(message); 51 + console.log(''); 52 +} 53 + 54 renderBanner({ • Edited src/cli/index.js (+5 -1) 478 479 - if (input.startsWith('/')) { 479 + if (input.startsWith('/') && isSlashCommand(input)) { 480 const result = await handleCommand(input, { rl, state, config, workspace: WORKSPACE, statusBar }); ⋮ 485 486 + if (input.startsWith('/') && !isSlashCommand(input)) { 487 + printNotice(`无效的命令“${input}”`); 488 + } 489 + 490 console.log(''); • Edited src/ui/command_menu.js (+5 -3) 19 let latestInput = ''; 20 + let lastCount = 0; 21 ⋮ 61 : COMMAND_CHOICES; 62 + lastCount = filtered.length; 63 return filtered.map((c) => ({ name: format(c), value: c.value })); ⋮ 65 }, abortSignal ? { signal: abortSignal, clearPromptOnDone: true } : { clearPromptOnDone: true }); 64 - return { chosen, term: latestInput, cancelled: false }; 66 + return { chosen, term: latestInput, cancelled: false, noResults: lastCount === 0 }; 67 } catch (err) { 68 if (err && (err.name === 'AbortPromptError' || err.name === 'CancelPromptError')) { 67 - return { chosen: null, term: latestInput, cancelled: true }; 69 + return { chosen: null, term: latestInput, cancelled: true, noResults: lastCount === 0 }; 70 } 71 console.log('指令菜单不可用,请先安装依赖: npm i @inquirer/search'); 70 - return { chosen: null, term: '', cancelled: true }; 72 + return { chosen: null, term: '', cancelled: true, noResults: false }; 73 } finally { • Edited src/cli/index.js (+11 -0) 368 menuInjectedCommand = result.chosen; 369 + return; 370 + } 371 + if ( 372 + result && 373 + !result.cancelled && 374 + !result.chosen && 375 + result.noResults && 376 + typeof result.term === 'string' && 377 + result.term.trim().length > 0 378 + ) { 379 + menuInjectedCommand = `/${result.term.trim()}`; 380 } • Edited src/ui/command_menu.js (+12 -6) 16 17 +function hasCommandMatch(term) { 18 + if (!term) return false; 19 + const t = term.toLowerCase(); 20 + return COMMAND_CHOICES.some( 21 + (c) => c.value.toLowerCase().includes(t) || c.desc.toLowerCase().includes(t) 22 + ); 23 +} 24 + 25 async function openCommandMenu(options) { ⋮ 27 let latestInput = ''; 20 - let lastCount = 0; 28 ⋮ 68 : COMMAND_CHOICES; 62 - lastCount = filtered.length; 69 return filtered.map((c) => ({ name: format(c), value: c.value })); ⋮ 71 }, abortSignal ? { signal: abortSignal, clearPromptOnDone: true } : { clearPromptOnDone: true }); 66 - return { chosen, term: latestInput, cancelled: false, noResults: lastCount === 0 }; 72 + return { chosen, term: latestInput, cancelled: false }; 73 } catch (err) { 74 if (err && (err.name === 'AbortPromptError' || err.name === 'CancelPromptError')) { 69 - return { chosen: null, term: latestInput, cancelled: true, noResults: lastCount === 0 }; 75 + return { chosen: null, term: latestInput, cancelled: true }; 76 } 77 console.log('指令菜单不可用,请先安装依赖: npm i @inquirer/search'); 72 - return { chosen: null, term: '', cancelled: true, noResults: false }; 78 + return { chosen: null, term: '', cancelled: true }; 79 } finally { ⋮ 87 82 -module.exports = { openCommandMenu }; 88 +module.exports = { openCommandMenu, hasCommandMatch }; • Edited src/cli/index.js (+12 -12) 13 const { executeTool } = require('../tools/dispatcher'); 14 -const { openCommandMenu } = require('../ui/command_menu'); 14 +const { openCommandMenu, hasCommandMatch } = require('../ui/command_menu'); 15 const { handleCommand } = require('./commands'); ⋮ 281 } 282 + return; 283 + } 284 + if (key && key.name === 'return') { 285 + const term = String(menuSearchTerm || '').replace(/^\/+/, '').trim(); 286 + if (term && !hasCommandMatch(term)) { 287 + menuInjectedCommand = `/${term}`; 288 + if (menuAbortController && !menuAbortController.signal.aborted) { 289 + menuAbortController.abort(); 290 + } 291 + } 292 + return; 293 } ⋮ 379 menuInjectedCommand = result.chosen; 369 - return; 370 - } 371 - if ( 372 - result && 373 - !result.cancelled && 374 - !result.chosen && 375 - result.noResults && 376 - typeof result.term === 'string' && 377 - result.term.trim().length > 0 378 - ) { 379 - menuInjectedCommand = `/${result.term.trim()}`; 380 } • Edited src/ui/command_menu.js (+1 -0) 47 highlight: (text) => (colorEnabled ? `\x1b[94m${text}${resetAnsi}` : text), 48 + error: () => '无效的指令', 49 keysHelpTip: () => '', • Edited src/cli/index.js (+7 -2) 69 let menuJustClosedInjected = false; 70 +let menuInvalidCommand = ''; 71 let statusBar = null; ⋮ 287 if (term && !hasCommandMatch(term)) { 287 - menuInjectedCommand = `/${term}`; 288 + menuInvalidCommand = `/${term}`; 289 if (menuAbortController && !menuAbortController.signal.aborted) { ⋮ 385 menuAbortController = null; 385 - menuJustClosedAt = menuInjectedCommand ? Date.now() : 0; 386 + menuJustClosedAt = menuInjectedCommand || menuInvalidCommand ? Date.now() : 0; 387 menuJustClosedInjected = !!menuInjectedCommand; ⋮ 406 } 407 + if (menuInvalidCommand) { 408 + printNotice(`无效的命令“${menuInvalidCommand}”`); 409 + menuInvalidCommand = ''; 410 + } 411 promptWithStatus(true); • Edited src/ui/select_prompt.js (+2 -2) 4 5 -async function runSelect({ rl, message, choices, pageSize = 6 }) { 5 +async function runSelect({ rl, message, choices, pageSize = 6, clearPromptOnDone = true }) { 6 rl.pause(); ⋮ 25 }, 26 - }, { clearPromptOnDone: true }); 26 + }, { clearPromptOnDone }); 27 readline.clearLine(process.stdout, 0); • Edited src/cli/commands.js (+8 -1) 168 ]; 169 - const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); 169 + const model = await runSelect({ 170 + rl, 171 + message: '', 172 + choices: modelChoices, 173 + pageSize: 6, 174 + clearPromptOnDone: false, 175 + }); 176 if (!model) return { exit: false }; ⋮ 178 state.modelId = config.model_id || 'kimi-k2.5'; 179 + console.log(''); 180 • Edited src/ui/select_prompt.js (+2 -2) 4 5 -async function runSelect({ rl, message, choices, pageSize = 6, clearPromptOnDone = true }) { 5 +async function runSelect({ rl, message, choices, pageSize = 6 }) { 6 rl.pause(); ⋮ 25 }, 26 - }, { clearPromptOnDone }); 26 + }, { clearPromptOnDone: true }); 27 readline.clearLine(process.stdout, 0); • Edited src/cli/commands.js (+1 -8) 168 ]; 169 - const model = await runSelect({ 170 - rl, 171 - message: '', 172 - choices: modelChoices, 173 - pageSize: 6, 174 - clearPromptOnDone: false, 175 - }); 169 + const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); 170 if (!model) return { exit: false }; ⋮ 172 state.modelId = config.model_id || 'kimi-k2.5'; 179 - console.log(''); 173 • Edited src/ui/select_prompt.js (+3 -0) 26 }, { clearPromptOnDone: true }); 27 + if (process.stdout.isTTY) { 28 + readline.clearScreenDown(process.stdout); 29 + } 30 readline.clearLine(process.stdout, 0); • Edited src/ui/select_prompt.js (+28 -0) 2 3 +const fs = require('fs'); 4 +const path = require('path'); 5 const readline = require('readline'); 6 7 +function debugLog(event, data = {}) { 8 + if (process.env.EAGENT_DEBUG !== '1') return; 9 + try { 10 + const dir = path.join(process.cwd(), 'logs'); 11 + fs.mkdirSync(dir, { recursive: true }); 12 + const payload = { 13 + ts: new Date().toISOString(), 14 + event, 15 + ...data, 16 + }; 17 + fs.appendFileSync(path.join(dir, 'cli-debug.log'), `${JSON.stringify(payload)}\n`); 18 + } catch (_) {} 19 +} 20 + 21 async function runSelect({ rl, message, choices, pageSize = 6 }) { ⋮ 26 readline.cursorTo(process.stdout, 0); 27 + debugLog('select_open', { 28 + message: message || '', 29 + choices: Array.isArray(choices) ? choices.length : 0, 30 + pageSize, 31 + tty: !!process.stdout.isTTY, 32 + rows: process.stdout.rows, 33 + columns: process.stdout.columns, 34 + }); 35 ⋮ 37 const { default: select } = await import('@inquirer/select'); 38 + debugLog('select_render'); 39 const value = await select({ ⋮ 51 }, { clearPromptOnDone: true }); 52 + debugLog('select_done', { value: value || null }); 53 if (process.stdout.isTTY) { ⋮ 57 readline.cursorTo(process.stdout, 0); 58 + debugLog('select_cleared'); 59 return value; ⋮ 65 } catch (_) {} 66 + debugLog('select_resume'); 67 } • Edited src/ui/select_prompt.js (+4 -0) 26 readline.cursorTo(process.stdout, 0); 27 + if (process.stdout.isTTY) { 28 + // Allow the select prompt to use the full screen; status bar will reapply later. 29 + process.stdout.write('\x1b[r'); 30 + } 31 debugLog('select_open', { • Edited src/ui/select_prompt.js (+2 -2) 27 if (process.stdout.isTTY) { 28 - // Allow the select prompt to use the full screen; status bar will reapply later. 29 - process.stdout.write('\x1b[r'); 28 + process.stdout.write('\x1b7'); // save cursor position 29 } ⋮ 56 if (process.stdout.isTTY) { 57 + process.stdout.write('\x1b8'); // restore cursor position 58 readline.clearScreenDown(process.stdout); • Edited src/cli/commands.js (+2 -1) 172 state.modelId = config.model_id || 'kimi-k2.5'; 173 + printNotice(`模型已切换为: ${state.modelKey}`); 174 ⋮ 181 state.thinkingMode = mode === 'thinking'; 181 - printNotice(`模型已切换为: ${state.modelKey} | 思考模式: ${mode}`); 182 + printNotice(`思考模式: ${mode}`); 183 persist(); • Edited src/cli/commands.js (+8 -0) 165 if (cmd === '/model') { 166 + if (statusBar) statusBar.setEnabled(false); 167 + try { 168 const modelChoices = [ ⋮ 186 } 187 + } finally { 188 + if (statusBar) { 189 + statusBar.setEnabled(true); 190 + statusBar.render(); 191 + } 192 + } 193 return { exit: false }; • Edited src/ui/select_prompt.js (+1 -0) 28 process.stdout.write('\x1b7'); // save cursor position 29 + process.stdout.write('\x1b[r'); // reset scroll region for full-screen menus 30 } • Edited src/ui/select_prompt.js (+1 -2) 27 if (process.stdout.isTTY) { 28 - process.stdout.write('\x1b7'); // save cursor position 28 process.stdout.write('\x1b[r'); // reset scroll region for full-screen menus ⋮ 56 if (process.stdout.isTTY) { 58 - process.stdout.write('\x1b8'); // restore cursor position 57 + process.stdout.write('\x1b[r'); 58 readline.clearScreenDown(process.stdout); • Edited src/ui/select_prompt.js (+9 -7) 20 21 -async function runSelect({ rl, message, choices, pageSize = 6 }) { 21 +async function runSelect({ rl, message, choices, pageSize = 6, clearOnDone = true }) { 22 rl.pause(); ⋮ 53 }, 54 - }, { clearPromptOnDone: true }); 54 + }, { clearPromptOnDone: clearOnDone }); 55 debugLog('select_done', { value: value || null }); 56 - if (process.stdout.isTTY) { 57 - process.stdout.write('\x1b[r'); 58 - readline.clearScreenDown(process.stdout); 56 + if (clearOnDone) { 57 + if (process.stdout.isTTY) { 58 + process.stdout.write('\x1b[r'); 59 + readline.clearScreenDown(process.stdout); 60 + } 61 + readline.clearLine(process.stdout, 0); 62 + readline.cursorTo(process.stdout, 0); 63 } 60 - readline.clearLine(process.stdout, 0); 61 - readline.cursorTo(process.stdout, 0); 64 debugLog('select_cleared'); • Edited src/cli/commands.js (+14 -2) 170 ]; 171 - const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); 171 + const model = await runSelect({ 172 + rl, 173 + message: '', 174 + choices: modelChoices, 175 + pageSize: 6, 176 + clearOnDone: false, 177 + }); 178 if (!model) return { exit: false }; ⋮ 186 ]; 181 - const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 }); 187 + const mode = await runSelect({ 188 + rl, 189 + message: '', 190 + choices: thinkingChoices, 191 + pageSize: 6, 192 + clearOnDone: false, 193 + }); 194 if (mode) { • Edited src/ui/select_prompt.js (+7 -9) 20 21 -async function runSelect({ rl, message, choices, pageSize = 6, clearOnDone = true }) { 21 +async function runSelect({ rl, message, choices, pageSize = 6 }) { 22 rl.pause(); ⋮ 53 }, 54 - }, { clearPromptOnDone: clearOnDone }); 54 + }, { clearPromptOnDone: true }); 55 debugLog('select_done', { value: value || null }); 56 - if (clearOnDone) { 57 - if (process.stdout.isTTY) { 58 - process.stdout.write('\x1b[r'); 59 - readline.clearScreenDown(process.stdout); 60 - } 61 - readline.clearLine(process.stdout, 0); 62 - readline.cursorTo(process.stdout, 0); 56 + if (process.stdout.isTTY) { 57 + process.stdout.write('\x1b[r'); 58 + readline.clearScreenDown(process.stdout); 59 } 60 + readline.clearLine(process.stdout, 0); 61 + readline.cursorTo(process.stdout, 0); 62 debugLog('select_cleared'); • Edited src/cli/commands.js (+2 -14) 170 ]; 171 - const model = await runSelect({ 172 - rl, 173 - message: '', 174 - choices: modelChoices, 175 - pageSize: 6, 176 - clearOnDone: false, 177 - }); 171 + const model = await runSelect({ rl, message: '', choices: modelChoices, pageSize: 6 }); 172 if (!model) return { exit: false }; ⋮ 180 ]; 187 - const mode = await runSelect({ 188 - rl, 189 - message: '', 190 - choices: thinkingChoices, 191 - pageSize: 6, 192 - clearOnDone: false, 193 - }); 181 + const mode = await runSelect({ rl, message: '', choices: thinkingChoices, pageSize: 6 }); 182 if (mode) {