dave-code 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/bin/aiClient.js +658 -164
- package/bin/cliMenu.js +127 -185
- package/bin/commandRouter.js +50 -0
- package/bin/configManager.js +85 -20
- package/bin/contextManager.js +151 -0
- package/bin/index.js +809 -449
- package/bin/planManager.js +239 -0
- package/bin/runtimeEvents.js +62 -0
- package/bin/sessionManager.js +166 -0
- package/bin/terminalRenderer.js +283 -0
- package/bin/toolRuntime.js +1052 -0
- package/package.json +5 -2
package/bin/index.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
|
+
import crypto from 'crypto';
|
|
13
14
|
|
|
14
15
|
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
15
16
|
const packageVersion = pkg.version;
|
|
16
|
-
import {
|
|
17
|
+
import { spawn } from 'child_process';
|
|
17
18
|
import { getLogoLines } from './logoRenderer.js';
|
|
18
|
-
import { hasApiKey, getAIResponse, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
|
|
19
|
+
import { hasApiKey, getAIResponse, streamAIResponse, generateTitle, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
|
|
20
|
+
import { listSessions, saveSession, deleteSession } from './sessionManager.js';
|
|
19
21
|
import {
|
|
20
22
|
CONFIG_FILE,
|
|
21
23
|
getProfiles,
|
|
@@ -27,7 +29,8 @@ import {
|
|
|
27
29
|
setActiveEffort,
|
|
28
30
|
EFFORT_PRESETS,
|
|
29
31
|
isFirstLaunch,
|
|
30
|
-
setInitialized
|
|
32
|
+
setInitialized,
|
|
33
|
+
lastConfigError
|
|
31
34
|
} from './configManager.js';
|
|
32
35
|
import { runWelcomeAnimation } from './install.js';
|
|
33
36
|
import {
|
|
@@ -35,10 +38,25 @@ import {
|
|
|
35
38
|
secureInputPrompt,
|
|
36
39
|
chatInputPrompt,
|
|
37
40
|
waitForEnter,
|
|
38
|
-
startSpinner,
|
|
39
41
|
selectEffortMenu,
|
|
40
|
-
confirmPrompt
|
|
42
|
+
confirmPrompt,
|
|
43
|
+
textInputPrompt
|
|
41
44
|
} from './cliMenu.js';
|
|
45
|
+
import { prepareModelMessages, stripReasoningBlocks, getContextStats } from './contextManager.js';
|
|
46
|
+
import { parseToolCall, executeToolCall, resolveExistingWorkspacePath, resolveWorkspacePath, SUPPORTED_TOOLS } from './toolRuntime.js';
|
|
47
|
+
import { createRuntimeEvents } from './runtimeEvents.js';
|
|
48
|
+
import { createTerminalRenderer, sanitizeUntrustedText } from './terminalRenderer.js';
|
|
49
|
+
import { parseInputCommand } from './commandRouter.js';
|
|
50
|
+
import {
|
|
51
|
+
deletePlan,
|
|
52
|
+
findPlan,
|
|
53
|
+
listPlans,
|
|
54
|
+
planStatusLines,
|
|
55
|
+
renamePlan,
|
|
56
|
+
updatePlan,
|
|
57
|
+
upsertPlan,
|
|
58
|
+
validatePlanContent
|
|
59
|
+
} from './planManager.js';
|
|
42
60
|
|
|
43
61
|
// Localization Dictionary
|
|
44
62
|
const locales = {
|
|
@@ -71,13 +89,16 @@ const locales = {
|
|
|
71
89
|
helpLines: [
|
|
72
90
|
' /help - 显示此帮助信息',
|
|
73
91
|
' /clear - 清屏并重置主面板',
|
|
74
|
-
' /reset -
|
|
75
|
-
' /
|
|
76
|
-
' /
|
|
92
|
+
' /reset - 重置对话历史并新建会话',
|
|
93
|
+
' /history - 查看并载入历史聊天记录',
|
|
94
|
+
' /stats - 显示当前会话的 Token 使用统计 (别名: /tokens)',
|
|
95
|
+
' /lang - 切换中英文界面 (别名: /language)',
|
|
77
96
|
' /config - 编辑本地配置文件 (~/.dave-code-config.json)',
|
|
78
97
|
' /model - 快捷切换当前激活的服务配置',
|
|
79
98
|
' /api - 快捷修改当前配置的 API 密钥',
|
|
80
99
|
' /effort - 调整 AI 思考与阅读的努力程度 (low/medium/high/xhigh/max/ultracode)',
|
|
100
|
+
' /plan 名称: 需求 - 创建只读实施计划;/plan 管理已有计划',
|
|
101
|
+
' /code <需求或计划名> - 单次授权编程修改,仍逐项确认',
|
|
81
102
|
' /open - 打开文件或文件夹并在其中工作',
|
|
82
103
|
' /exit - 退出 Dave Code 代理'
|
|
83
104
|
],
|
|
@@ -97,7 +118,7 @@ const locales = {
|
|
|
97
118
|
保存并关闭文件后,请在下方按 \x1b[1;32m[回车 (Enter)]\x1b[0m 键重载配置。`,
|
|
98
119
|
reloadingConfigMsg: '\n\x1b[32m正在重载配置...\x1b[0m\n',
|
|
99
120
|
openDirSuccess: '已切换工作目录至: ',
|
|
100
|
-
openFileSuccess: '
|
|
121
|
+
openFileSuccess: '已在编辑器中打开现有文件。Dave 将按需读取最新内容。',
|
|
101
122
|
openPathError: '路径不存在或无法访问:',
|
|
102
123
|
openUsagePrompt: '使用方法:/open <文件或文件夹路径>'
|
|
103
124
|
},
|
|
@@ -130,13 +151,16 @@ const locales = {
|
|
|
130
151
|
helpLines: [
|
|
131
152
|
' /help - Show this help message',
|
|
132
153
|
' /clear - Clear screen and refresh home panel',
|
|
133
|
-
' /reset - Reset conversation history',
|
|
134
|
-
' /
|
|
135
|
-
' /
|
|
154
|
+
' /reset - Reset conversation history and start a new session',
|
|
155
|
+
' /history - View and resume past chat sessions',
|
|
156
|
+
' /stats - Show session token usage statistics (alias: /tokens)',
|
|
157
|
+
' /lang - Switch language between Chinese and English (alias: /language)',
|
|
136
158
|
' /config - Open and edit config file (~/.dave-code-config.json)',
|
|
137
159
|
' /model - Interactively switch active service profile',
|
|
138
160
|
' /api - Interactively set API key for active profile',
|
|
139
161
|
' /effort - Adjust AI thinking/reading effort level (low/medium/high/xhigh/max/ultracode)',
|
|
162
|
+
' /plan name: request - Create a read-only implementation plan; /plan manages plans',
|
|
163
|
+
' /code <request or plan> - Authorize one confirmed coding turn',
|
|
140
164
|
' /open - Open a file or directory to work in',
|
|
141
165
|
' /exit - Exit the Dave Code agent'
|
|
142
166
|
],
|
|
@@ -156,7 +180,7 @@ Please edit and save the file in your editor.
|
|
|
156
180
|
Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload configuration.`,
|
|
157
181
|
reloadingConfigMsg: '\n\x1b[32mReloading configuration...\x1b[0m\n',
|
|
158
182
|
openDirSuccess: 'Switched working directory to: ',
|
|
159
|
-
openFileSuccess: 'Opened file
|
|
183
|
+
openFileSuccess: 'Opened the existing file. Dave will read current content on demand.',
|
|
160
184
|
openPathError: 'Path does not exist or is not accessible: ',
|
|
161
185
|
openUsagePrompt: 'Usage: /open <file or folder path>'
|
|
162
186
|
}
|
|
@@ -165,6 +189,56 @@ Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload confi
|
|
|
165
189
|
let currentLang = 'cn';
|
|
166
190
|
let messages = [];
|
|
167
191
|
let activeOpenFile = null;
|
|
192
|
+
let workspaceRoot = process.cwd();
|
|
193
|
+
let workspaceReady = true;
|
|
194
|
+
let currentSessionId = null;
|
|
195
|
+
let currentSessionTitle = 'New Chat';
|
|
196
|
+
|
|
197
|
+
function initSession() {
|
|
198
|
+
currentSessionId = crypto.randomUUID();
|
|
199
|
+
currentSessionTitle = currentLang === 'cn' ? '新会话' : 'New Chat';
|
|
200
|
+
messages = [];
|
|
201
|
+
saveCurrentSession();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function saveCurrentSession() {
|
|
205
|
+
if (!currentSessionId) return;
|
|
206
|
+
if (messages.length === 0) return; // Prevent saving empty sessions
|
|
207
|
+
const sessionData = {
|
|
208
|
+
id: currentSessionId,
|
|
209
|
+
title: currentSessionTitle,
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
messages: messages,
|
|
212
|
+
workspaceRoot: workspaceReady ? workspaceRoot : null,
|
|
213
|
+
activeOpenFile: workspaceReady && activeOpenFile ? activeOpenFile : null
|
|
214
|
+
};
|
|
215
|
+
saveSession(currentSessionId, sessionData);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function redrawScreen(messageText = '') {
|
|
219
|
+
clearConsole();
|
|
220
|
+
drawHeader();
|
|
221
|
+
if (messageText) {
|
|
222
|
+
console.log(messageText);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cleanHistory = messages.filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
|
|
226
|
+
if (cleanHistory.length > 0) {
|
|
227
|
+
console.log(currentLang === 'cn' ? '\x1b[90m--- 当前对话内容 (Current Chat) ---\x1b[0m' : '\x1b[90m--- Current Chat Context ---\x1b[0m');
|
|
228
|
+
cleanHistory.forEach(m => {
|
|
229
|
+
let displayContent = sanitizeUntrustedText(stripReasoningBlocks(m.displayContent || m.content));
|
|
230
|
+
|
|
231
|
+
if (displayContent) {
|
|
232
|
+
if (m.role === 'user') {
|
|
233
|
+
console.log(`\x1b[36mUser\x1b[0m: ${displayContent}`);
|
|
234
|
+
} else {
|
|
235
|
+
console.log(`\x1b[1;38;2;250;100;30mDave\x1b[0m: ${displayContent}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
console.log('\x1b[90m─────────────────────────────────\x1b[0m\n');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
168
242
|
|
|
169
243
|
function getStringWidth(str) {
|
|
170
244
|
const cleanStr = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
@@ -199,6 +273,16 @@ function padLine(str, width) {
|
|
|
199
273
|
return str + ' '.repeat(width - strWidth);
|
|
200
274
|
}
|
|
201
275
|
|
|
276
|
+
function truncateMiddle(str, width) {
|
|
277
|
+
const text = String(str || '');
|
|
278
|
+
if (getStringWidth(text) <= width) return text;
|
|
279
|
+
if (width <= 6) return text.slice(0, width);
|
|
280
|
+
const keep = width - 3;
|
|
281
|
+
const left = Math.ceil(keep / 2);
|
|
282
|
+
const right = Math.floor(keep / 2);
|
|
283
|
+
return text.slice(0, left) + '...' + text.slice(-right);
|
|
284
|
+
}
|
|
285
|
+
|
|
202
286
|
function maskKey(key) {
|
|
203
287
|
if (!key) return '(None/Not Configured)';
|
|
204
288
|
if (key.length <= 8) return '********';
|
|
@@ -226,7 +310,7 @@ async function runConcurrentLimit(items, limit, fn) {
|
|
|
226
310
|
return Promise.all(results);
|
|
227
311
|
}
|
|
228
312
|
|
|
229
|
-
async function runFileDigestionWorkflow(filePath) {
|
|
313
|
+
async function runFileDigestionWorkflow(filePath, runtime = {}) {
|
|
230
314
|
const resolvedPath = path.resolve(filePath);
|
|
231
315
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
232
316
|
const lines = content.split('\n');
|
|
@@ -241,39 +325,24 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
241
325
|
const totalChunks = chunks.length;
|
|
242
326
|
let completedCount = 0;
|
|
243
327
|
let failedCount = 0;
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
suffix = ` \x1b[31m(${failedCount} failed)\x1b[0m`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
process.stdout.write(
|
|
266
|
-
`\r\x1b[K\x1b[35m[Ultracode Workflow]\x1b[0m Digesting ${path.basename(filePath)}: ` +
|
|
267
|
-
`${barColor}[${filledBar}${emptyBar}]\x1b[0m ${percentage}% ` +
|
|
268
|
-
`(${completedCount}/${totalChunks} chunks) ${completedCount === totalChunks ? '✔' : '\x1b[36m' + spinnerFrame + '\x1b[0m'}${suffix}`
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
process.stdout.write('\x1b[?25l');
|
|
273
|
-
const spinnerInterval = setInterval(() => {
|
|
274
|
-
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
|
275
|
-
drawProgressBar();
|
|
276
|
-
}, 80);
|
|
328
|
+
|
|
329
|
+
const emitProgress = () => {
|
|
330
|
+
const percent = totalChunks > 0 ? Math.round((completedCount / totalChunks) * 100) : 100;
|
|
331
|
+
runtime.emit?.('tool.progress', {
|
|
332
|
+
tool: runtime.toolName || 'READ_FILE',
|
|
333
|
+
toolCallId: runtime.toolCallId || '',
|
|
334
|
+
target: path.basename(filePath),
|
|
335
|
+
current: completedCount,
|
|
336
|
+
total: totalChunks,
|
|
337
|
+
percent,
|
|
338
|
+
failedCount,
|
|
339
|
+
label: runtime.lang === 'cn'
|
|
340
|
+
? `正在消化 ${path.basename(filePath)} · ${completedCount}/${totalChunks} 分片`
|
|
341
|
+
: `Digesting ${path.basename(filePath)} · ${completedCount}/${totalChunks} chunks`
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
emitProgress();
|
|
277
346
|
|
|
278
347
|
const tasks = chunks.map((chunkText, idx) => ({ chunkText, idx }));
|
|
279
348
|
|
|
@@ -291,20 +360,16 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
291
360
|
try {
|
|
292
361
|
const summary = await getAIResponse(promptMsg);
|
|
293
362
|
completedCount++;
|
|
294
|
-
|
|
363
|
+
emitProgress();
|
|
295
364
|
return { idx: task.idx, start, end, summary };
|
|
296
365
|
} catch (err) {
|
|
297
366
|
completedCount++;
|
|
298
367
|
failedCount++;
|
|
299
|
-
|
|
368
|
+
emitProgress();
|
|
300
369
|
return { idx: task.idx, start, end, summary: `[Error reading this segment: ${err.message}]` };
|
|
301
370
|
}
|
|
302
371
|
});
|
|
303
372
|
|
|
304
|
-
clearInterval(spinnerInterval);
|
|
305
|
-
drawProgressBar(); // final draw to show 100% complete
|
|
306
|
-
process.stdout.write('\n\x1b[?25h');
|
|
307
|
-
|
|
308
373
|
results.sort((a, b) => a.idx - b.idx);
|
|
309
374
|
|
|
310
375
|
let report = `=== ULTRACODE DIGESTION REPORT FOR "${path.basename(filePath)}" ===\n`;
|
|
@@ -323,24 +388,60 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
323
388
|
function drawHeader() {
|
|
324
389
|
const logoLines = getLogoLines();
|
|
325
390
|
const t = locales[currentLang];
|
|
391
|
+
const active = getActiveProfile();
|
|
392
|
+
const effort = getActiveEffort();
|
|
393
|
+
const contextStats = getContextStats(messages, effort);
|
|
394
|
+
const plans = workspaceReady ? planStatusLines(workspaceRoot, process.stdout.columns && process.stdout.columns < 100 ? 2 : 4) : [];
|
|
395
|
+
const leftColWidth = 45;
|
|
396
|
+
const rightColWidth = 47;
|
|
397
|
+
const boxWidth = 95;
|
|
398
|
+
const activeFileLabel = workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), rightColWidth - 13) : (currentLang === 'cn' ? '未选择' : 'None');
|
|
399
|
+
const workspaceLabel = workspaceReady
|
|
400
|
+
? truncateMiddle(sanitizeUntrustedText(workspaceRoot), rightColWidth - 11)
|
|
401
|
+
: (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open');
|
|
402
|
+
const contextLabel = `${contextStats.modelMessages}/${contextStats.savedMessages} msgs · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens} tokens${contextStats.compacted ? ' · compacted' : ''}`;
|
|
403
|
+
const terminalWidth = Math.max(36, process.stdout.columns || 100);
|
|
404
|
+
|
|
405
|
+
if (terminalWidth < 100) {
|
|
406
|
+
const contentWidth = Math.max(32, terminalWidth - 2);
|
|
407
|
+
const rule = '─'.repeat(contentWidth);
|
|
408
|
+
const compactStatus = [
|
|
409
|
+
`${currentLang === 'cn' ? '模型' : 'Model'}: ${active ? active.model : '(not configured)'}`,
|
|
410
|
+
`Effort: ${effort}`,
|
|
411
|
+
`${currentLang === 'cn' ? '目录' : 'Workspace'}: ${workspaceReady ? truncateMiddle(sanitizeUntrustedText(workspaceRoot), contentWidth - 5) : (currentLang === 'cn' ? '未关联,请使用 /open' : 'Not linked; use /open')}`,
|
|
412
|
+
`${currentLang === 'cn' ? '文件' : 'File'}: ${workspaceReady && activeOpenFile ? truncateMiddle(sanitizeUntrustedText(path.relative(workspaceRoot, activeOpenFile)), contentWidth - 5) : (currentLang === 'cn' ? '未选择' : 'None')}`,
|
|
413
|
+
`${currentLang === 'cn' ? '上下文' : 'Context'}: ${contextStats.modelMessages}/${contextStats.savedMessages} · ~${contextStats.estimatedTokens}/${contextStats.budgetTokens}${contextStats.compacted ? (currentLang === 'cn' ? ' · 已压缩' : ' · compacted') : ''}`,
|
|
414
|
+
`${currentLang === 'cn' ? '计划' : 'Plans'}: ${plans.length ? plans.join(' | ') : (currentLang === 'cn' ? '无' : 'None')}`
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
console.log(`\x1b[38;2;250;100;30m${rule}\x1b[0m`);
|
|
418
|
+
console.log(centerLine(`\x1b[1mDave Code v${packageVersion}\x1b[0m`, contentWidth));
|
|
419
|
+
for (const logoLine of logoLines) console.log(centerLine(logoLine, contentWidth));
|
|
420
|
+
console.log(`\x1b[38;2;250;100;30m${rule}\x1b[0m`);
|
|
421
|
+
for (const line of compactStatus) console.log(truncateMiddle(line, contentWidth));
|
|
422
|
+
console.log(`\x1b[90m${rule}\x1b[0m\n`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
326
425
|
|
|
327
426
|
const rightLines = [
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
427
|
+
currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m状态\x1b[0m' : '\x1b[1;38;2;250;100;30mStatus\x1b[0m',
|
|
428
|
+
`Model: ${active ? active.model : '(not configured)'}`,
|
|
429
|
+
`Effort: ${effort}`,
|
|
430
|
+
`Workspace: ${workspaceLabel}`,
|
|
431
|
+
`Active file: ${activeFileLabel}`,
|
|
432
|
+
`Context: ${contextLabel}`,
|
|
334
433
|
'',
|
|
434
|
+
currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m计划\x1b[0m' : '\x1b[1;38;2;250;100;30mPlans\x1b[0m',
|
|
435
|
+
...(plans.length ? plans : [currentLang === 'cn' ? '暂无计划' : 'No plans']),
|
|
335
436
|
'',
|
|
437
|
+
currentLang === 'cn' ? '\x1b[1;38;2;250;100;30m常用命令\x1b[0m' : '\x1b[1;38;2;250;100;30mCommands\x1b[0m',
|
|
438
|
+
'/plan /code /open',
|
|
439
|
+
'/help /history /model',
|
|
440
|
+
currentLang === 'cn' ? 'Ctrl+C 或 /exit 退出' : 'Ctrl+C or /exit to quit',
|
|
336
441
|
'',
|
|
337
|
-
''
|
|
442
|
+
currentLang === 'cn' ? '文件内容会按需读取并压缩上下文。' : 'Files are read on demand and compacted.'
|
|
338
443
|
];
|
|
339
444
|
|
|
340
|
-
const leftColWidth = 45;
|
|
341
|
-
const rightColWidth = 47;
|
|
342
|
-
const boxWidth = 95;
|
|
343
|
-
|
|
344
445
|
const boxColor = '\x1b[38;2;250;100;30m';
|
|
345
446
|
const resetColor = '\x1b[0m';
|
|
346
447
|
|
|
@@ -371,7 +472,7 @@ function drawHeader() {
|
|
|
371
472
|
leftContent = centerLine(`\x1b[90m${truncatedCwd}\x1b[0m`, leftColWidth);
|
|
372
473
|
}
|
|
373
474
|
|
|
374
|
-
const rightContent = padLine(rightLines[i] || '', rightColWidth);
|
|
475
|
+
const rightContent = padLine(truncateMiddle(rightLines[i] || '', rightColWidth), rightColWidth);
|
|
375
476
|
|
|
376
477
|
console.log(
|
|
377
478
|
boxColor + '│ ' + resetColor +
|
|
@@ -387,90 +488,33 @@ function drawHeader() {
|
|
|
387
488
|
|
|
388
489
|
}
|
|
389
490
|
|
|
390
|
-
|
|
391
|
-
constructor() {
|
|
392
|
-
this.actionsCount = 0;
|
|
393
|
-
this.actionTypes = {};
|
|
394
|
-
this.lastActionText = '';
|
|
395
|
-
this.spinnerIdx = 0;
|
|
396
|
-
this.spinnerInterval = null;
|
|
397
|
-
this.statusColor = '\x1b[35m';
|
|
398
|
-
}
|
|
491
|
+
let promptLoopRunning = false;
|
|
399
492
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
draw() {
|
|
424
|
-
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
425
|
-
const spinnerFrame = spinnerFrames[this.spinnerIdx];
|
|
426
|
-
|
|
427
|
-
const cleanActionText = this.lastActionText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
428
|
-
|
|
429
|
-
let displayAction = cleanActionText;
|
|
430
|
-
if (displayAction.length > 50) {
|
|
431
|
-
displayAction = displayAction.slice(0, 47) + '...';
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
process.stdout.write(
|
|
435
|
-
`\r\x1b[K\x1b[35m⚙ [Agent Actions]\x1b[0m ${this.actionsCount} actions | Last: ${displayAction} \x1b[36m${spinnerFrame}\x1b[0m`
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
stop() {
|
|
440
|
-
if (this.spinnerInterval) {
|
|
441
|
-
clearInterval(this.spinnerInterval);
|
|
442
|
-
this.spinnerInterval = null;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
finish() {
|
|
447
|
-
this.stop();
|
|
448
|
-
if (this.actionsCount > 0) {
|
|
449
|
-
const typesStr = Object.entries(this.actionTypes)
|
|
450
|
-
.map(([type, count]) => `${count} ${type.toLowerCase()}${count > 1 ? 's' : ''}`)
|
|
451
|
-
.join(', ');
|
|
452
|
-
const summaryText = `Completed ${this.actionsCount} actions (${typesStr})`;
|
|
453
|
-
process.stdout.write(`\r\x1b[K\x1b[32m✔ [Agent Actions]\x1b[0m ${summaryText}\n`);
|
|
493
|
+
async function promptUser() {
|
|
494
|
+
if (promptLoopRunning) return;
|
|
495
|
+
promptLoopRunning = true;
|
|
496
|
+
|
|
497
|
+
while (true) {
|
|
498
|
+
const effort = getActiveEffort();
|
|
499
|
+
const effortText = `● ${effort} · /effort`;
|
|
500
|
+
const promptWidth = Math.max(32, process.stdout.columns || 80);
|
|
501
|
+
const padding = ' '.repeat(Math.max(1, promptWidth - getStringWidth(effortText) - 1));
|
|
502
|
+
const colorCode = effort === 'ultracode' ? '\x1b[33m' : '\x1b[90m';
|
|
503
|
+
console.log(padding + colorCode + effortText + '\x1b[0m');
|
|
504
|
+
const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
|
|
505
|
+
const placeholder = activeOpenFile
|
|
506
|
+
? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
|
|
507
|
+
: (currentLang === 'cn' ? '输入问题或命令,例如 /help ...' : 'Enter a prompt or command, e.g. /help ...');
|
|
508
|
+
const input = await chatInputPrompt(placeholder, currentLang);
|
|
509
|
+
try {
|
|
510
|
+
await handleInput(input);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
process.stdout.write('\x1b[?25h');
|
|
513
|
+
console.log(`\n\x1b[31mUnexpected error:\x1b[0m ${error.message}\n`);
|
|
454
514
|
}
|
|
455
|
-
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
456
515
|
}
|
|
457
516
|
}
|
|
458
517
|
|
|
459
|
-
async function promptUser() {
|
|
460
|
-
const effort = getActiveEffort();
|
|
461
|
-
const effortText = `● ${effort} · /effort`;
|
|
462
|
-
const N = effort.length;
|
|
463
|
-
const padding = ' '.repeat(Math.max(1, 80 - N));
|
|
464
|
-
const colorCode = effort === 'ultracode' ? '\x1b[1;38;2;140;90;240m' : '\x1b[90m';
|
|
465
|
-
console.log(padding + colorCode + effortText + '\x1b[0m');
|
|
466
|
-
const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
|
|
467
|
-
const placeholder = activeOpenFile
|
|
468
|
-
? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
|
|
469
|
-
: (currentLang === 'cn' ? 'Try "how do I log an error?" / 输入提示词或命令...' : 'Try "how do I log an error?" / Enter prompt or command...');
|
|
470
|
-
const input = await chatInputPrompt(placeholder, currentLang);
|
|
471
|
-
handleInput(input);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
518
|
// ── Interactive Config Handlers ──
|
|
475
519
|
|
|
476
520
|
async function triggerProfileSwitch(t) {
|
|
@@ -491,9 +535,126 @@ async function triggerProfileSwitch(t) {
|
|
|
491
535
|
const chosenProfile = profiles[selectedIdx];
|
|
492
536
|
setActiveProfile(chosenProfile.model);
|
|
493
537
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
538
|
+
redrawScreen(`\n\x1b[32mActive model set to: ${chosenProfile.model}\x1b[0m\n`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function describeToolRequest(toolName, toolArg, lang) {
|
|
542
|
+
const cn = lang === 'cn';
|
|
543
|
+
const structuredValue = toolArg && typeof toolArg === 'object'
|
|
544
|
+
? (toolArg.path || toolArg.filePath || toolArg.source || toolArg.query || toolArg.command || '')
|
|
545
|
+
: toolArg;
|
|
546
|
+
const firstLine = String(structuredValue || '').split('\n', 1)[0].trim();
|
|
547
|
+
const target = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
|
548
|
+
const labels = {
|
|
549
|
+
LIST_DIR: cn ? `准备查看 ${target || '.'}` : `Preparing to list ${target || '.'}`,
|
|
550
|
+
READ_FILE: cn ? `准备读取 ${target}` : `Preparing to read ${target}`,
|
|
551
|
+
EDIT_FILE: cn ? `准备局部修改 ${target}` : `Preparing to edit ${target}`,
|
|
552
|
+
WRITE_FILE: cn ? `准备修改 ${target}` : `Preparing to change ${target}`,
|
|
553
|
+
SEARCH_GREP: cn ? `准备搜索 “${target}”` : `Preparing to search "${target}"`,
|
|
554
|
+
MAKE_DIR: cn ? `准备创建目录 ${target}` : `Preparing to create directory ${target}`,
|
|
555
|
+
MOVE_PATH: cn ? `准备移动 ${target}` : `Preparing to move ${target}`,
|
|
556
|
+
DELETE_PATH: cn ? `准备删除 ${target}` : `Preparing to delete ${target}`,
|
|
557
|
+
RUN_COMMAND: cn ? `准备运行命令 ${target}` : `Preparing to run ${target}`
|
|
558
|
+
};
|
|
559
|
+
return labels[toolName] || (cn ? `准备执行 ${toolName}` : `Preparing ${toolName}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function launchEditor(filePath) {
|
|
563
|
+
const command = process.platform === 'win32'
|
|
564
|
+
? { file: 'explorer.exe', args: [filePath] }
|
|
565
|
+
: process.platform === 'darwin'
|
|
566
|
+
? { file: 'open', args: [filePath] }
|
|
567
|
+
: { file: 'xdg-open', args: [filePath] };
|
|
568
|
+
const child = spawn(command.file, command.args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
569
|
+
child.on('error', () => {});
|
|
570
|
+
child.unref();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function handlePlanMenu() {
|
|
574
|
+
while (true) {
|
|
575
|
+
const plans = listPlans(workspaceRoot);
|
|
576
|
+
if (plans.length === 0) {
|
|
577
|
+
console.log(currentLang === 'cn'
|
|
578
|
+
? '\n\x1b[90m暂无计划。使用 /plan 名称: 需求 创建。\x1b[0m\n'
|
|
579
|
+
: '\n\x1b[90mNo plans. Use /plan name: request to create one.\x1b[0m\n');
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const selected = await selectMenu(
|
|
583
|
+
currentLang === 'cn' ? '工作区计划' : 'Workspace Plans',
|
|
584
|
+
currentLang === 'cn' ? '选择一个计划进行查看、重命名或删除。' : 'Select a plan to view, rename, or delete.',
|
|
585
|
+
plans.map(plan => ({ name: plan.name, desc: `${plan.status} · ${new Date(plan.updatedAt).toLocaleString()}` })),
|
|
586
|
+
0
|
|
587
|
+
);
|
|
588
|
+
if (selected === -1) return;
|
|
589
|
+
const plan = plans[selected];
|
|
590
|
+
const action = await selectMenu(
|
|
591
|
+
plan.name,
|
|
592
|
+
currentLang === 'cn' ? `状态: ${plan.status}` : `Status: ${plan.status}`,
|
|
593
|
+
currentLang === 'cn'
|
|
594
|
+
? [
|
|
595
|
+
{ name: '查看计划', desc: '显示完整的做什么与怎么做' },
|
|
596
|
+
{ name: '重命名', desc: '修改计划名称' },
|
|
597
|
+
{ name: '删除', desc: '永久删除计划' },
|
|
598
|
+
{ name: '返回', desc: '返回计划列表' }
|
|
599
|
+
]
|
|
600
|
+
: [
|
|
601
|
+
{ name: 'View Plan', desc: 'Show the complete what-and-how plan' },
|
|
602
|
+
{ name: 'Rename', desc: 'Change the plan name' },
|
|
603
|
+
{ name: 'Delete', desc: 'Permanently delete the plan' },
|
|
604
|
+
{ name: 'Back', desc: 'Return to the plan list' }
|
|
605
|
+
],
|
|
606
|
+
0
|
|
607
|
+
);
|
|
608
|
+
if (action === -1 || action === 3) continue;
|
|
609
|
+
if (action === 0) {
|
|
610
|
+
console.log(`\n\x1b[1;36m${sanitizeUntrustedText(plan.name)}\x1b[0m`);
|
|
611
|
+
console.log(`${sanitizeUntrustedText(plan.content)}\n`);
|
|
612
|
+
await waitForEnter(currentLang === 'cn' ? '按 Enter 返回计划列表。' : 'Press Enter to return to plans.');
|
|
613
|
+
} else if (action === 1) {
|
|
614
|
+
const nextName = await textInputPrompt(currentLang === 'cn' ? '新计划名称: ' : 'New plan name: ');
|
|
615
|
+
if (nextName) {
|
|
616
|
+
try {
|
|
617
|
+
renamePlan(workspaceRoot, plan.id, nextName);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.log(`\n\x1b[31m${sanitizeUntrustedText(error.message)}\x1b[0m\n`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} else if (action === 2) {
|
|
623
|
+
const allowed = await confirmPrompt(currentLang === 'cn'
|
|
624
|
+
? `删除计划 "${sanitizeUntrustedText(plan.name)}"?(y/n): `
|
|
625
|
+
: `Delete plan "${sanitizeUntrustedText(plan.name)}"? (y/n): `);
|
|
626
|
+
if (allowed) deletePlan(workspaceRoot, plan.id);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function sessionMatchesWorkspace(session, candidateRoot) {
|
|
632
|
+
const referencedFiles = new Set();
|
|
633
|
+
for (const message of session.messages || []) {
|
|
634
|
+
if (message.role !== 'assistant') continue;
|
|
635
|
+
const parsed = message.toolCall
|
|
636
|
+
? { toolName: message.toolCall.name, toolArg: message.toolCall.arguments }
|
|
637
|
+
: parseToolCall(message.content || '');
|
|
638
|
+
if (!parsed || parsed.error || !['READ_FILE', 'EDIT_FILE', 'WRITE_FILE'].includes(parsed.toolName)) continue;
|
|
639
|
+
const rawPath = parsed.toolArg && typeof parsed.toolArg === 'object'
|
|
640
|
+
? (parsed.toolArg.path || parsed.toolArg.filePath || '')
|
|
641
|
+
: parsed.toolArg;
|
|
642
|
+
let filePath = String(rawPath || '').split('\n', 1)[0].replace(/:(\d+)-(\d+)$/, '').trim();
|
|
643
|
+
if (filePath) referencedFiles.add(filePath);
|
|
644
|
+
if (referencedFiles.size >= 8) break;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let matches = 0;
|
|
648
|
+
for (const filePath of referencedFiles) {
|
|
649
|
+
try {
|
|
650
|
+
const resolved = resolveExistingWorkspacePath(filePath, candidateRoot);
|
|
651
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) matches++;
|
|
652
|
+
if (matches >= 2) return true;
|
|
653
|
+
} catch (error) {
|
|
654
|
+
// A missing reference simply means this is probably not the old workspace.
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
497
658
|
}
|
|
498
659
|
|
|
499
660
|
// ── Main REPL Input Handler ──
|
|
@@ -525,13 +686,152 @@ async function handleInput(input) {
|
|
|
525
686
|
}
|
|
526
687
|
|
|
527
688
|
if (trimmed === '/reset') {
|
|
528
|
-
|
|
689
|
+
initSession();
|
|
529
690
|
resetTokenUsage();
|
|
530
691
|
console.log(`\x1b[32m${t.resetMsg}\x1b[0m\n`);
|
|
531
692
|
promptUser();
|
|
532
693
|
return;
|
|
533
694
|
}
|
|
534
695
|
|
|
696
|
+
if (trimmed === '/history') {
|
|
697
|
+
while (true) {
|
|
698
|
+
const sessions = listSessions();
|
|
699
|
+
if (sessions.length === 0) {
|
|
700
|
+
console.log(currentLang === 'cn' ? '\n\x1b[31m暂无历史聊天记录!\x1b[0m\n' : '\n\x1b[31mNo chat history found!\x1b[0m\n');
|
|
701
|
+
promptUser();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const menuOptions = sessions.map(s => {
|
|
706
|
+
const dateStr = new Date(s.timestamp || Date.now()).toLocaleString(currentLang === 'cn' ? 'zh-CN' : 'en-US', {
|
|
707
|
+
month: 'short',
|
|
708
|
+
day: 'numeric',
|
|
709
|
+
hour: '2-digit',
|
|
710
|
+
minute: '2-digit'
|
|
711
|
+
});
|
|
712
|
+
const cleanMsgs = (s.messages || []).filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
|
|
713
|
+
const msgCount = cleanMsgs.length;
|
|
714
|
+
return {
|
|
715
|
+
name: sanitizeUntrustedText(s.title || (currentLang === 'cn' ? '未命名会话' : 'Untitled Session')),
|
|
716
|
+
desc: `${dateStr} | ${msgCount} ${currentLang === 'cn' ? '条对话' : 'messages'}`
|
|
717
|
+
};
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const titleText = currentLang === 'cn' ? '历史聊天记录 (Chat History)' : 'Chat History';
|
|
721
|
+
const descText = currentLang === 'cn' ? '选择一个历史聊天记录以继续或删除:' : 'Select a chat history to resume or delete:';
|
|
722
|
+
|
|
723
|
+
const currentIdx = sessions.findIndex(s => s.id === currentSessionId);
|
|
724
|
+
const selectedIdx = await selectMenu(titleText, descText, menuOptions, currentIdx !== -1 ? currentIdx : 0);
|
|
725
|
+
|
|
726
|
+
if (selectedIdx === -1) {
|
|
727
|
+
redrawScreen();
|
|
728
|
+
promptUser();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const chosenSession = sessions[selectedIdx];
|
|
733
|
+
|
|
734
|
+
const safeChosenTitle = sanitizeUntrustedText(chosenSession.title);
|
|
735
|
+
const actionTitle = currentLang === 'cn' ? `选择操作: "${safeChosenTitle}"` : `Actions for: "${safeChosenTitle}"`;
|
|
736
|
+
const actionDesc = currentLang === 'cn' ? '请选择您想要进行的操作:' : 'Please select an action:';
|
|
737
|
+
|
|
738
|
+
const actionOptions = currentLang === 'cn' ? [
|
|
739
|
+
{ name: '载入并继续对话 (Resume)', desc: '加载此会话的历史聊天内容并继续' },
|
|
740
|
+
{ name: '删除此会话 (Delete)', desc: '从本地磁盘永久删除此聊天记录' },
|
|
741
|
+
{ name: '取消并返回 (Cancel)', desc: '返回上级历史记录列表' }
|
|
742
|
+
] : [
|
|
743
|
+
{ name: 'Resume Conversation', desc: 'Load the messages and continue chatting' },
|
|
744
|
+
{ name: 'Delete Session', desc: 'Permanently remove this chat file from disk' },
|
|
745
|
+
{ name: 'Cancel & Back', desc: 'Go back to the sessions list' }
|
|
746
|
+
];
|
|
747
|
+
|
|
748
|
+
const actionIdx = await selectMenu(actionTitle, actionDesc, actionOptions, 0);
|
|
749
|
+
|
|
750
|
+
if (actionIdx === 0) {
|
|
751
|
+
currentSessionId = chosenSession.id;
|
|
752
|
+
currentSessionTitle = chosenSession.title;
|
|
753
|
+
messages = chosenSession.messages || [];
|
|
754
|
+
|
|
755
|
+
const savedWorkspace = chosenSession.workspaceRoot ? path.resolve(chosenSession.workspaceRoot) : null;
|
|
756
|
+
const savedWorkspaceUsable = savedWorkspace && fs.existsSync(savedWorkspace) && fs.statSync(savedWorkspace).isDirectory()
|
|
757
|
+
? savedWorkspace
|
|
758
|
+
: null;
|
|
759
|
+
const inferredWorkspace = !savedWorkspaceUsable && sessionMatchesWorkspace(chosenSession, workspaceRoot)
|
|
760
|
+
? path.resolve(workspaceRoot)
|
|
761
|
+
: null;
|
|
762
|
+
const restorableWorkspace = savedWorkspaceUsable || inferredWorkspace;
|
|
763
|
+
let workspaceMessage;
|
|
764
|
+
if (restorableWorkspace && fs.existsSync(restorableWorkspace) && fs.statSync(restorableWorkspace).isDirectory()) {
|
|
765
|
+
process.chdir(restorableWorkspace);
|
|
766
|
+
workspaceRoot = restorableWorkspace;
|
|
767
|
+
workspaceReady = true;
|
|
768
|
+
activeOpenFile = null;
|
|
769
|
+
if (chosenSession.activeOpenFile) {
|
|
770
|
+
try {
|
|
771
|
+
const restoredFile = resolveWorkspacePath(chosenSession.activeOpenFile, workspaceRoot);
|
|
772
|
+
if (fs.existsSync(restoredFile) && fs.statSync(restoredFile).isFile()) activeOpenFile = restoredFile;
|
|
773
|
+
} catch (error) {
|
|
774
|
+
// The saved active file may have been moved or deleted.
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
workspaceMessage = currentLang === 'cn'
|
|
778
|
+
? `\x1b[90m工作区: ${workspaceRoot}${inferredWorkspace ? '(已从历史验证)' : ''}\x1b[0m`
|
|
779
|
+
: `\x1b[90mWorkspace: ${workspaceRoot}${inferredWorkspace ? ' (verified from history)' : ''}\x1b[0m`;
|
|
780
|
+
saveCurrentSession();
|
|
781
|
+
} else {
|
|
782
|
+
workspaceReady = false;
|
|
783
|
+
activeOpenFile = null;
|
|
784
|
+
workspaceMessage = currentLang === 'cn'
|
|
785
|
+
? `\x1b[33m此旧会话没有可恢复的工作区。请先使用 /open <项目目录>。\x1b[0m`
|
|
786
|
+
: `\x1b[33mThis older session has no restorable workspace. Use /open <project-directory> first.\x1b[0m`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
redrawScreen(currentLang === 'cn'
|
|
790
|
+
? `\n\x1b[32m已成功载入会话: "${sanitizeUntrustedText(currentSessionTitle)}"\x1b[0m\n${workspaceMessage}\n`
|
|
791
|
+
: `\n\x1b[32mSuccessfully loaded session: "${sanitizeUntrustedText(currentSessionTitle)}"\x1b[0m\n${workspaceMessage}\n`
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const cleanHistory = messages.filter(m => m.role !== 'tool' && !m.toolCall && !m.content.includes('[System Context:') && !m.content.includes('[Tool Response for'));
|
|
795
|
+
const lastMessages = cleanHistory.slice(-6);
|
|
796
|
+
|
|
797
|
+
if (lastMessages.length > 0) {
|
|
798
|
+
console.log(currentLang === 'cn' ? '\x1b[90m--- 最近聊天内容预览 ---\x1b[0m' : '\x1b[90m--- Recent Chat Preview ---\x1b[0m');
|
|
799
|
+
lastMessages.forEach(m => {
|
|
800
|
+
let previewContent = sanitizeUntrustedText(stripReasoningBlocks(m.displayContent || m.content));
|
|
801
|
+
if (previewContent.length > 150) {
|
|
802
|
+
previewContent = previewContent.slice(0, 147) + '...';
|
|
803
|
+
}
|
|
804
|
+
if (m.role === 'user') {
|
|
805
|
+
console.log(`\x1b[36mUser\x1b[0m: ${previewContent}`);
|
|
806
|
+
} else {
|
|
807
|
+
console.log(`\x1b[1;38;2;250;100;30mDave\x1b[0m: ${previewContent}`);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
console.log('\x1b[90m────────────────────────\x1b[0m\n');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
promptUser();
|
|
814
|
+
return;
|
|
815
|
+
} else if (actionIdx === 1) {
|
|
816
|
+
const confirmMsg = currentLang === 'cn'
|
|
817
|
+
? `\x1b[31m警告:您确定要删除此会话 "${safeChosenTitle}" 吗?此操作无法撤销! (y/n): \x1b[0m`
|
|
818
|
+
: `\x1b[31mWarning: Are you sure you want to delete session "${safeChosenTitle}"? This cannot be undone! (y/n): \x1b[0m`;
|
|
819
|
+
|
|
820
|
+
const confirmed = await confirmPrompt(confirmMsg);
|
|
821
|
+
if (confirmed) {
|
|
822
|
+
deleteSession(chosenSession.id);
|
|
823
|
+
|
|
824
|
+
if (chosenSession.id === currentSessionId) {
|
|
825
|
+
initSession();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log(currentLang === 'cn' ? '\n\x1b[32m✔ 会话已成功删除!\x1b[0m\n' : '\n\x1b[32m✔ Session deleted successfully!\x1b[0m\n');
|
|
829
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
535
835
|
if (trimmed === '/stats' || trimmed === '/tokens') {
|
|
536
836
|
console.log(`\n\x1b[1;36mToken Usage Statistics / Token 使用统计:\x1b[0m`);
|
|
537
837
|
console.log(` \x1b[90mLast Request (单次请求):\x1b[0m`);
|
|
@@ -549,9 +849,7 @@ async function handleInput(input) {
|
|
|
549
849
|
if (trimmed === '/lang' || trimmed === '/language') {
|
|
550
850
|
currentLang = currentLang === 'cn' ? 'en' : 'cn';
|
|
551
851
|
const nextT = locales[currentLang];
|
|
552
|
-
|
|
553
|
-
drawHeader();
|
|
554
|
-
console.log(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
|
|
852
|
+
redrawScreen(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
|
|
555
853
|
promptUser();
|
|
556
854
|
return;
|
|
557
855
|
}
|
|
@@ -577,12 +875,9 @@ async function handleInput(input) {
|
|
|
577
875
|
const selected = await selectEffortMenu(currentEffort, currentLang);
|
|
578
876
|
if (selected) {
|
|
579
877
|
setActiveEffort(selected);
|
|
580
|
-
|
|
581
|
-
drawHeader();
|
|
582
|
-
console.log(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
|
|
878
|
+
redrawScreen(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
|
|
583
879
|
} else {
|
|
584
|
-
|
|
585
|
-
drawHeader();
|
|
880
|
+
redrawScreen();
|
|
586
881
|
}
|
|
587
882
|
promptUser();
|
|
588
883
|
return;
|
|
@@ -610,17 +905,22 @@ async function handleInput(input) {
|
|
|
610
905
|
|
|
611
906
|
// Pause prompt and wait for Enter key
|
|
612
907
|
await waitForEnter(t.openingConfigMsg);
|
|
613
|
-
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
908
|
+
|
|
909
|
+
// Give the editor a brief moment to flush the saved file, then read it again from disk.
|
|
910
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
911
|
+
const reloadedProfile = getActiveProfile();
|
|
912
|
+
const reloadMessage = reloadedProfile
|
|
913
|
+
? (currentLang === 'cn'
|
|
914
|
+
? `\n\x1b[32m配置已重载\x1b[0m\n\x1b[90m当前模型: ${reloadedProfile.model}\n配置文件: ${CONFIG_FILE}\x1b[0m\n`
|
|
915
|
+
: `\n\x1b[32mConfiguration reloaded\x1b[0m\n\x1b[90mActive model: ${reloadedProfile.model}\nConfig file: ${CONFIG_FILE}\x1b[0m\n`)
|
|
916
|
+
: `\n\x1b[31m${t.noActiveError}\x1b[0m\n\x1b[90m${CONFIG_FILE}\x1b[0m\n`;
|
|
917
|
+
redrawScreen(reloadMessage);
|
|
618
918
|
promptUser();
|
|
619
919
|
return;
|
|
620
920
|
}
|
|
621
921
|
|
|
622
922
|
// Handle /open <path>
|
|
623
|
-
if (
|
|
923
|
+
if (/^\/open(?:\s|$)/.test(trimmed)) {
|
|
624
924
|
const args = trimmed.slice(5).trim();
|
|
625
925
|
if (!args) {
|
|
626
926
|
console.log(`\n\x1b[31m${t.openUsagePrompt}\x1b[0m\n`);
|
|
@@ -639,10 +939,11 @@ async function handleInput(input) {
|
|
|
639
939
|
if (stats && stats.isDirectory()) {
|
|
640
940
|
try {
|
|
641
941
|
process.chdir(resolvedPath);
|
|
942
|
+
workspaceRoot = process.cwd();
|
|
943
|
+
workspaceReady = true;
|
|
642
944
|
activeOpenFile = null; // Switch to directory mode clears active file focus
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
console.log(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
|
|
945
|
+
saveCurrentSession();
|
|
946
|
+
redrawScreen(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
|
|
646
947
|
} catch (err) {
|
|
647
948
|
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
648
949
|
}
|
|
@@ -650,75 +951,36 @@ async function handleInput(input) {
|
|
|
650
951
|
return;
|
|
651
952
|
}
|
|
652
953
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
fs.writeFileSync(resolvedPath, '', 'utf8');
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
activeOpenFile = resolvedPath; // Track currently open file
|
|
662
|
-
|
|
663
|
-
// Read content
|
|
664
|
-
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
665
|
-
const lines = content.split('\n');
|
|
666
|
-
const activeEffort = getActiveEffort();
|
|
667
|
-
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
668
|
-
|
|
669
|
-
let contextContent = '';
|
|
670
|
-
if (activeEffort === 'ultracode') {
|
|
671
|
-
if (lines.length > 1000) {
|
|
672
|
-
const totalChunks = Math.ceil(lines.length / 2000);
|
|
673
|
-
const confirmMsg = `\n\x1b[35m[Ultracode]\x1b[0m File "${path.basename(resolvedPath)}" has ${lines.length} lines. Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
|
|
674
|
-
const proceed = await confirmPrompt(confirmMsg);
|
|
675
|
-
|
|
676
|
-
if (proceed) {
|
|
677
|
-
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
678
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
679
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). The file has been processed using the chunked digestion workflow. Below is the full digestion report. If you need to view raw code or text of specific lines, use the <<READ_FILE: ${relPath}:start-end>> tool (e.g., <<READ_FILE: ${relPath}:1200-1350>>).]\n\n${report}`;
|
|
680
|
-
} else {
|
|
681
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
682
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, digestion was skipped by user. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:1-500>>) to read other segments of the file if needed.]`;
|
|
683
|
-
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
684
|
-
}
|
|
685
|
-
} else if (lines.length > preset.maxReadLines) {
|
|
686
|
-
const truncatedContent = lines.slice(0, 100).join('\n');
|
|
687
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
688
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, only the first 100 lines are shown below. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:101-300>>) to read other segments of the file if needed.]\n\n${truncatedContent}`;
|
|
689
|
-
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated first 100 lines for AI context.\x1b[0m`);
|
|
690
|
-
} else {
|
|
691
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
692
|
-
}
|
|
954
|
+
if (!workspaceReady) {
|
|
955
|
+
if (stats && stats.isFile()) {
|
|
956
|
+
workspaceRoot = path.dirname(resolvedPath);
|
|
957
|
+
process.chdir(workspaceRoot);
|
|
958
|
+
workspaceReady = true;
|
|
693
959
|
} else {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
} else {
|
|
700
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
701
|
-
}
|
|
960
|
+
console.log(currentLang === 'cn'
|
|
961
|
+
? '\n\x1b[33m此会话尚未关联工作区,请先使用 /open <项目目录>。\x1b[0m\n'
|
|
962
|
+
: '\n\x1b[33mThis session is not linked to a workspace. Use /open <project-directory> first.\x1b[0m\n');
|
|
963
|
+
promptUser();
|
|
964
|
+
return;
|
|
702
965
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (!stats || !stats.isFile()) {
|
|
969
|
+
console.log(`\n\x1b[31m${t.openPathError}${sanitizeUntrustedText(resolvedPath)}\x1b[0m\n`);
|
|
970
|
+
promptUser();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
709
973
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
974
|
+
// Existing files can be focused, but /open never creates workspace content.
|
|
975
|
+
try {
|
|
976
|
+
const filePath = resolveWorkspacePath(args, workspaceRoot);
|
|
977
|
+
activeOpenFile = filePath; // Track currently open file
|
|
978
|
+
workspaceReady = true;
|
|
979
|
+
saveCurrentSession();
|
|
714
980
|
|
|
715
|
-
|
|
716
|
-
if (err && process.platform === 'win32') {
|
|
717
|
-
exec(`notepad "${resolvedPath}"`);
|
|
718
|
-
}
|
|
719
|
-
});
|
|
981
|
+
launchEditor(filePath);
|
|
720
982
|
|
|
721
|
-
console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${
|
|
983
|
+
console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${filePath}\x1b[0m\n`);
|
|
722
984
|
} catch (err) {
|
|
723
985
|
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
724
986
|
}
|
|
@@ -727,259 +989,347 @@ async function handleInput(input) {
|
|
|
727
989
|
return;
|
|
728
990
|
}
|
|
729
991
|
|
|
992
|
+
let agentMode = 'chat';
|
|
993
|
+
let agentPrompt = trimmed;
|
|
994
|
+
let historyDisplay = trimmed;
|
|
995
|
+
let executingPlan = null;
|
|
996
|
+
let pendingPlan = null;
|
|
997
|
+
|
|
998
|
+
if (/^\/(?:plan|code)(?:\s|$)/.test(trimmed)) {
|
|
999
|
+
if (!workspaceReady) {
|
|
1000
|
+
console.log(currentLang === 'cn'
|
|
1001
|
+
? '\n\x1b[33m请先使用 /open <项目目录> 关联工作区。\x1b[0m\n'
|
|
1002
|
+
: '\n\x1b[33mUse /open <project-directory> before planning or coding.\x1b[0m\n');
|
|
1003
|
+
promptUser();
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const command = parseInputCommand(trimmed, { findPlan: name => findPlan(workspaceRoot, name) });
|
|
1007
|
+
if (command.type === 'error') {
|
|
1008
|
+
console.log(`\n\x1b[31m${sanitizeUntrustedText(command.error)}\x1b[0m\n`);
|
|
1009
|
+
promptUser();
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (command.type === 'code.usage') {
|
|
1013
|
+
console.log(currentLang === 'cn'
|
|
1014
|
+
? '\n用法: /code <需求或计划名>,或 /code --prompt <需求>\n'
|
|
1015
|
+
: '\nUsage: /code <request or plan name>, or /code --prompt <request>\n');
|
|
1016
|
+
promptUser();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (command.type === 'plan.menu') {
|
|
1020
|
+
await handlePlanMenu();
|
|
1021
|
+
redrawScreen();
|
|
1022
|
+
promptUser();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (command.type === 'plan.create') {
|
|
1026
|
+
const existing = findPlan(workspaceRoot, command.name);
|
|
1027
|
+
let replace = false;
|
|
1028
|
+
if (existing) {
|
|
1029
|
+
replace = await confirmPrompt(currentLang === 'cn'
|
|
1030
|
+
? `计划 "${sanitizeUntrustedText(existing.name)}" 已存在,替换它?(y/n): `
|
|
1031
|
+
: `Plan "${sanitizeUntrustedText(existing.name)}" exists. Replace it? (y/n): `);
|
|
1032
|
+
if (!replace) {
|
|
1033
|
+
promptUser();
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
agentMode = 'plan';
|
|
1038
|
+
pendingPlan = { name: command.name, request: command.request, replace };
|
|
1039
|
+
agentPrompt = `Create the named implementation plan "${command.name}" for this request:\n${command.request}`;
|
|
1040
|
+
} else if (command.type === 'agent' && command.mode === 'code') {
|
|
1041
|
+
agentMode = 'code';
|
|
1042
|
+
executingPlan = command.plan;
|
|
1043
|
+
agentPrompt = executingPlan
|
|
1044
|
+
? `Execute the saved plan "${executingPlan.name}". Re-read current files before changing them.\n\n${executingPlan.content}`
|
|
1045
|
+
: command.prompt;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!workspaceReady) {
|
|
1050
|
+
console.log(currentLang === 'cn'
|
|
1051
|
+
? '\n\x1b[33m当前历史会话没有关联项目目录。请先输入 /open <项目目录>,再继续修改。\x1b[0m\n'
|
|
1052
|
+
: '\n\x1b[33mThis history session is not linked to a project directory. Run /open <project-directory> before continuing.\x1b[0m\n');
|
|
1053
|
+
promptUser();
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
730
1057
|
// Check if active profile and API key are configured
|
|
731
1058
|
if (!hasApiKey()) {
|
|
732
1059
|
console.log(`\n${t.noKeyError}\n`);
|
|
1060
|
+
if (lastConfigError) console.log(`\x1b[31m${sanitizeUntrustedText(lastConfigError)}\x1b[0m\n`);
|
|
733
1061
|
promptUser();
|
|
734
1062
|
return;
|
|
735
1063
|
}
|
|
736
1064
|
|
|
737
1065
|
// Push user message to history
|
|
738
|
-
messages.push({ role: 'user', content:
|
|
1066
|
+
messages.push({ role: 'user', content: agentPrompt, displayContent: historyDisplay });
|
|
1067
|
+
saveCurrentSession();
|
|
1068
|
+
|
|
1069
|
+
if (executingPlan) {
|
|
1070
|
+
updatePlan(workspaceRoot, executingPlan.id, { status: 'running', lastRun: { startedAt: Date.now() } });
|
|
1071
|
+
redrawScreen(currentLang === 'cn'
|
|
1072
|
+
? `\x1b[90m正在执行计划: ${sanitizeUntrustedText(executingPlan.name)}\x1b[0m\n`
|
|
1073
|
+
: `\x1b[90mExecuting plan: ${sanitizeUntrustedText(executingPlan.name)}\x1b[0m\n`);
|
|
1074
|
+
}
|
|
739
1075
|
|
|
740
1076
|
const activeEffort = getActiveEffort();
|
|
741
1077
|
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
742
1078
|
const maxLoops = preset.maxLoops;
|
|
743
|
-
|
|
744
|
-
|
|
1079
|
+
const turnTokenStart = {
|
|
1080
|
+
input: sessionTokenUsage.inputTokens,
|
|
1081
|
+
output: sessionTokenUsage.outputTokens
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const runtime = createRuntimeEvents();
|
|
1085
|
+
const renderer = createTerminalRenderer({ stdout: process.stdout, lang: currentLang });
|
|
1086
|
+
const unsubscribe = runtime.subscribe(event => renderer.handle(event));
|
|
1087
|
+
const abortController = new AbortController();
|
|
1088
|
+
let interrupted = false;
|
|
1089
|
+
const onTurnInterrupt = () => {
|
|
1090
|
+
interrupted = true;
|
|
1091
|
+
abortController.abort();
|
|
1092
|
+
};
|
|
1093
|
+
process.once('SIGINT', onTurnInterrupt);
|
|
745
1094
|
let loopCount = 0;
|
|
746
|
-
let
|
|
1095
|
+
let turnCompleted = false;
|
|
1096
|
+
let terminalEventSent = false;
|
|
1097
|
+
let compactionShown = false;
|
|
1098
|
+
let turnOutcome = 'stopped';
|
|
1099
|
+
let planActionCancelled = false;
|
|
1100
|
+
|
|
1101
|
+
runtime.emit('turn.started', {
|
|
1102
|
+
label: currentLang === 'cn' ? '思考中 · 正在理解问题' : 'Thinking · understanding the request'
|
|
1103
|
+
});
|
|
747
1104
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
if (loopCount === 0) messages.pop();
|
|
760
|
-
break;
|
|
761
|
-
}
|
|
1105
|
+
try {
|
|
1106
|
+
while (loopCount < maxLoops) {
|
|
1107
|
+
const modelMessages = prepareModelMessages(messages, activeEffort);
|
|
1108
|
+
const contextWasCompacted = modelMessages.some(message => String(message.content || '').startsWith('[Conversation context compacted:'));
|
|
1109
|
+
if (!compactionShown && contextWasCompacted) {
|
|
1110
|
+
runtime.emit('context.compacted', {
|
|
1111
|
+
omittedMessages: Math.max(1, messages.length - modelMessages.length + 1),
|
|
1112
|
+
keptMessages: modelMessages.length
|
|
1113
|
+
});
|
|
1114
|
+
compactionShown = true;
|
|
1115
|
+
}
|
|
762
1116
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1117
|
+
let activeReply = '';
|
|
1118
|
+
const nativeToolCalls = [];
|
|
1119
|
+
let modelWasTruncated = false;
|
|
1120
|
+
for await (const modelEvent of streamAIResponse(modelMessages, {
|
|
1121
|
+
activeOpenFile,
|
|
1122
|
+
maxReadLines: preset.maxReadLines,
|
|
1123
|
+
signal: abortController.signal,
|
|
1124
|
+
mode: agentMode,
|
|
1125
|
+
workspaceRoot
|
|
1126
|
+
})) {
|
|
1127
|
+
if (modelEvent.type === 'model.delta') {
|
|
1128
|
+
activeReply += modelEvent.data.text || '';
|
|
1129
|
+
} else if (modelEvent.type === 'model.tool_call') {
|
|
1130
|
+
nativeToolCalls.push(modelEvent.data);
|
|
1131
|
+
} else if (modelEvent.type === 'model.completed') {
|
|
1132
|
+
modelWasTruncated = modelEvent.data.truncated === true;
|
|
1133
|
+
runtime.emit(modelEvent.type, modelEvent.data);
|
|
1134
|
+
} else if (modelEvent.type === 'model.started') {
|
|
1135
|
+
runtime.emit('model.started', {
|
|
1136
|
+
...modelEvent.data,
|
|
1137
|
+
label: loopCount === 0
|
|
1138
|
+
? (currentLang === 'cn' ? '思考中 · 正在理解问题' : 'Thinking · understanding the request')
|
|
1139
|
+
: (currentLang === 'cn' ? '思考中 · 正在检查工具结果' : 'Thinking · checking tool results')
|
|
1140
|
+
});
|
|
1141
|
+
} else {
|
|
1142
|
+
runtime.emit(modelEvent.type, modelEvent.data);
|
|
773
1143
|
}
|
|
774
|
-
finalReply = finalReply.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
775
1144
|
}
|
|
776
|
-
|
|
777
|
-
|
|
1145
|
+
|
|
1146
|
+
if (modelWasTruncated) {
|
|
1147
|
+
runtime.emit('model.retry', {
|
|
1148
|
+
reason: 'output-truncated',
|
|
1149
|
+
label: currentLang === 'cn' ? '模型输出达到上限,正在要求其缩短并继续' : 'Model output was truncated; requesting a concise continuation'
|
|
1150
|
+
});
|
|
1151
|
+
messages.push({
|
|
1152
|
+
role: 'user',
|
|
1153
|
+
content: '[System notice: The previous model response reached its output limit and was not accepted. Continue concisely. If a tool is needed, request exactly one structured tool.]'
|
|
1154
|
+
});
|
|
1155
|
+
saveCurrentSession();
|
|
1156
|
+
loopCount++;
|
|
1157
|
+
continue;
|
|
778
1158
|
}
|
|
779
|
-
console.log(`\n\x1b[1;38;2;250;100;30mDave\x1b[0m: ${finalReply}\n`);
|
|
780
|
-
messages.push({ role: 'assistant', content: activeReply });
|
|
781
|
-
break;
|
|
782
|
-
}
|
|
783
1159
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
1160
|
+
const activeProfile = getActiveProfile();
|
|
1161
|
+
let parsedTool = null;
|
|
1162
|
+
if (nativeToolCalls.length > 1) {
|
|
1163
|
+
parsedTool = { error: 'The model requested multiple tools at once. Request exactly one tool per step.' };
|
|
1164
|
+
} else if (nativeToolCalls.length === 1) {
|
|
1165
|
+
const call = nativeToolCalls[0];
|
|
1166
|
+
parsedTool = SUPPORTED_TOOLS.has(call.name)
|
|
1167
|
+
? { toolName: call.name, toolArg: call.arguments || {}, native: true, id: call.id }
|
|
1168
|
+
: { error: `Unsupported structured tool: ${call.name}` };
|
|
1169
|
+
} else if ((activeProfile?.toolMode || 'native') === 'legacy') {
|
|
1170
|
+
parsedTool = parseToolCall(activeReply);
|
|
1171
|
+
if (parsedTool && !parsedTool.error && typeof parsedTool.toolArg === 'string' && parsedTool.toolArg.trim().startsWith('{')) {
|
|
1172
|
+
try {
|
|
1173
|
+
parsedTool.toolArg = JSON.parse(parsedTool.toolArg);
|
|
1174
|
+
} catch {
|
|
1175
|
+
parsedTool = { error: 'Legacy tool arguments must be valid JSON.' };
|
|
1176
|
+
}
|
|
792
1177
|
}
|
|
793
|
-
thoughtText = thoughtText.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
794
1178
|
}
|
|
795
|
-
if (
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
// Save Dave's intermediate tool call message to history
|
|
801
|
-
messages.push({ role: 'assistant', content: activeReply });
|
|
802
|
-
|
|
803
|
-
const toolName = match[1];
|
|
804
|
-
const toolArg = match[2];
|
|
805
|
-
|
|
806
|
-
let toolResult = '';
|
|
807
|
-
const statusColor = '\x1b[35m'; // purple for agent action
|
|
808
|
-
const resetColor = '\x1b[0m';
|
|
809
|
-
|
|
810
|
-
try {
|
|
811
|
-
if (toolName === 'LIST_DIR') {
|
|
812
|
-
const resolvedPath = path.resolve(toolArg.trim() || '.');
|
|
813
|
-
actionTracker.start(`Listing directory: ${path.basename(resolvedPath) || '.'}`);
|
|
814
|
-
|
|
815
|
-
const files = fs.readdirSync(resolvedPath);
|
|
816
|
-
toolResult = files.map(f => {
|
|
817
|
-
const stat = fs.statSync(path.join(resolvedPath, f));
|
|
818
|
-
return `${f} (${stat.isDirectory() ? 'Dir' : 'File, ' + stat.size + 'B'})`;
|
|
819
|
-
}).join('\n') || '(Empty directory)';
|
|
820
|
-
}
|
|
821
|
-
else if (toolName === 'READ_FILE') {
|
|
822
|
-
let filePath = toolArg.trim();
|
|
823
|
-
let startLine = 1;
|
|
824
|
-
let endLine = null;
|
|
825
|
-
|
|
826
|
-
// Parse range like: path/to/file:20-80
|
|
827
|
-
const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
|
|
828
|
-
if (rangeMatch) {
|
|
829
|
-
filePath = filePath.slice(0, -rangeMatch[0].length);
|
|
830
|
-
startLine = parseInt(rangeMatch[1], 10);
|
|
831
|
-
endLine = parseInt(rangeMatch[2], 10);
|
|
1179
|
+
if (!parsedTool) {
|
|
1180
|
+
let finalReply = sanitizeUntrustedText(stripReasoningBlocks(activeReply));
|
|
1181
|
+
if (!finalReply) {
|
|
1182
|
+
finalReply = currentLang === 'cn' ? '模型未返回可显示内容。' : 'The model returned no displayable content.';
|
|
832
1183
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const lines = content.split('\n');
|
|
839
|
-
const activeEffort = getActiveEffort();
|
|
840
|
-
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
841
|
-
|
|
842
|
-
if (rangeMatch) {
|
|
843
|
-
const sliced = lines.slice(startLine - 1, endLine);
|
|
844
|
-
toolResult = sliced.join('\n');
|
|
845
|
-
} else {
|
|
846
|
-
if (activeEffort === 'ultracode') {
|
|
847
|
-
if (lines.length > 1000) {
|
|
848
|
-
actionTracker.stop(); // Stop the dynamic status line before prompt
|
|
849
|
-
process.stdout.write('\n'); // Carriage return to next line for clean confirmation
|
|
850
|
-
const totalChunks = Math.ceil(lines.length / 2000);
|
|
851
|
-
const confirmMsg = `\x1b[35m[Ultracode]\x1b[0m AI requested to read "${path.basename(resolvedPath)}" (${lines.length} lines). Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
|
|
852
|
-
const proceed = await confirmPrompt(confirmMsg);
|
|
853
|
-
|
|
854
|
-
if (proceed) {
|
|
855
|
-
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
856
|
-
toolResult = `[File is large (${lines.length} lines). Triggered Ultracode Digestion Workflow. Below is the report. Use READ_FILE with ranges for specific parts if needed.]\n\n${report}`;
|
|
857
|
-
} else {
|
|
858
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
859
|
-
toolResult = `[Warning: File is large (${lines.length} lines) and digestion was skipped by user. You MUST read specific parts using range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 1-500) to find the information.]`;
|
|
860
|
-
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
861
|
-
}
|
|
862
|
-
} else if (lines.length > preset.maxReadLines) {
|
|
863
|
-
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
864
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
865
|
-
toolResult = `[Warning: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines. Use range constraints like <<READ_FILE: ${relPath}:start-end>> to read other sections.]\n\n${truncated}`;
|
|
866
|
-
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated to first ${preset.maxReadLines} lines.\x1b[0m`);
|
|
867
|
-
} else {
|
|
868
|
-
toolResult = content;
|
|
869
|
-
}
|
|
870
|
-
} else {
|
|
871
|
-
// Non-ultracode modes: Smart Incremental Reading (精读模式)
|
|
872
|
-
if (lines.length > preset.maxReadLines) {
|
|
873
|
-
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
874
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
875
|
-
toolResult = `[System Context: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines for this read. To read other sections, you MUST use range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 200-800) to find the information.]\n\n${truncated}`;
|
|
876
|
-
} else {
|
|
877
|
-
toolResult = content;
|
|
1184
|
+
if (pendingPlan) {
|
|
1185
|
+
const missingSections = validatePlanContent(finalReply);
|
|
1186
|
+
if (missingSections.length > 0) {
|
|
1187
|
+
if (loopCount + 1 >= maxLoops) {
|
|
1188
|
+
throw new Error(`Plan validation failed: missing ${missingSections.join(', ')}.`);
|
|
878
1189
|
}
|
|
1190
|
+
messages.push({ role: 'assistant', content: finalReply });
|
|
1191
|
+
messages.push({ role: 'user', content: `[Plan validation failed. Rewrite the complete plan. Missing: ${missingSections.join(', ')}. Every implementation step must state WHAT changes, HOW it is implemented, affected files/interfaces, and validation.]` });
|
|
1192
|
+
saveCurrentSession();
|
|
1193
|
+
loopCount++;
|
|
1194
|
+
continue;
|
|
879
1195
|
}
|
|
880
1196
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
const maxTotalMatches = 50;
|
|
901
|
-
|
|
902
|
-
function searchGrep(dir, q, depth = 0) {
|
|
903
|
-
if (depth > 5 || totalMatches >= maxTotalMatches) return;
|
|
904
|
-
const files = fs.readdirSync(dir);
|
|
905
|
-
for (const f of files) {
|
|
906
|
-
if (totalMatches >= maxTotalMatches) break;
|
|
907
|
-
if (f === 'node_modules' || f === '.git' || f === '.gemini' || f === 'package-lock.json') continue;
|
|
908
|
-
const fullPath = path.join(dir, f);
|
|
909
|
-
try {
|
|
910
|
-
const stat = fs.statSync(fullPath);
|
|
911
|
-
if (stat.isDirectory()) {
|
|
912
|
-
searchGrep(fullPath, q, depth + 1);
|
|
913
|
-
} else if (stat.isFile()) {
|
|
914
|
-
const ext = path.extname(f).toLowerCase();
|
|
915
|
-
const textExts = ['.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml', '.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs', '.java', '.ts'];
|
|
916
|
-
if (textExts.includes(ext) || stat.size < 100000) {
|
|
917
|
-
const content = fs.readFileSync(fullPath, 'utf8');
|
|
918
|
-
if (content.includes(q)) {
|
|
919
|
-
const lines = content.split('\n');
|
|
920
|
-
const fileMatches = [];
|
|
921
|
-
for (let i = 0; i < lines.length; i++) {
|
|
922
|
-
if (lines[i].includes(q)) {
|
|
923
|
-
fileMatches.push({ lineNum: i + 1, text: lines[i].trim() });
|
|
924
|
-
totalMatches++;
|
|
925
|
-
if (totalMatches >= maxTotalMatches) break;
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
if (fileMatches.length > 0) {
|
|
929
|
-
results.push({
|
|
930
|
-
file: path.relative(process.cwd(), fullPath),
|
|
931
|
-
matches: fileMatches
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
} catch (e) {
|
|
938
|
-
// Ignore unreadable files
|
|
1197
|
+
runtime.emit('model.delta', { text: finalReply });
|
|
1198
|
+
messages.push({ role: 'assistant', content: finalReply });
|
|
1199
|
+
saveCurrentSession();
|
|
1200
|
+
if (pendingPlan) {
|
|
1201
|
+
upsertPlan(workspaceRoot, {
|
|
1202
|
+
name: pendingPlan.name,
|
|
1203
|
+
request: pendingPlan.request,
|
|
1204
|
+
content: finalReply
|
|
1205
|
+
}, { replace: pendingPlan.replace });
|
|
1206
|
+
}
|
|
1207
|
+
turnCompleted = true;
|
|
1208
|
+
|
|
1209
|
+
if (currentSessionTitle === 'New Chat' || currentSessionTitle === '新会话' || currentSessionTitle === 'New Session') {
|
|
1210
|
+
const titleSessionId = currentSessionId;
|
|
1211
|
+
const titleMessages = prepareModelMessages(messages.map(message => ({ ...message })), activeEffort);
|
|
1212
|
+
generateTitle(titleMessages).then(newTitle => {
|
|
1213
|
+
if (currentSessionId === titleSessionId && newTitle && newTitle !== 'New Session') {
|
|
1214
|
+
currentSessionTitle = sanitizeUntrustedText(newTitle).slice(0, 80);
|
|
1215
|
+
saveCurrentSession();
|
|
939
1216
|
}
|
|
940
|
-
}
|
|
1217
|
+
}).catch(() => {});
|
|
941
1218
|
}
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
942
1221
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
});
|
|
954
|
-
toolResult = outputText;
|
|
955
|
-
} else {
|
|
956
|
-
toolResult = `No matches found for "${query}"`;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
else {
|
|
960
|
-
toolResult = `Unknown tool: ${toolName}`;
|
|
1222
|
+
if (parsedTool.error) {
|
|
1223
|
+
runtime.emit('tool.failed', {
|
|
1224
|
+
tool: 'TOOL_PARSE',
|
|
1225
|
+
displaySummary: currentLang === 'cn' ? '工具格式无法解析' : 'Could not parse tool call',
|
|
1226
|
+
error: parsedTool.error
|
|
1227
|
+
});
|
|
1228
|
+
messages.push({ role: 'user', content: `[Tool Response for TOOL_PARSE:\n${parsedTool.error}]` });
|
|
1229
|
+
saveCurrentSession();
|
|
1230
|
+
loopCount++;
|
|
1231
|
+
continue;
|
|
961
1232
|
}
|
|
962
|
-
} catch (err) {
|
|
963
|
-
toolResult = `Error executing tool ${toolName}: ${err.message}`;
|
|
964
|
-
console.log(`\n\x1b[31m[Agent Error] ${err.message}\x1b[0m\n`);
|
|
965
|
-
}
|
|
966
1233
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1234
|
+
const toolName = parsedTool.toolName;
|
|
1235
|
+
const toolArg = parsedTool.toolArg;
|
|
1236
|
+
const toolCallId = parsedTool.id || `${runtime.turnId}-tool-${loopCount + 1}`;
|
|
1237
|
+
runtime.emit('tool.requested', {
|
|
1238
|
+
tool: toolName,
|
|
1239
|
+
toolCallId,
|
|
1240
|
+
displaySummary: describeToolRequest(toolName, toolArg, currentLang)
|
|
1241
|
+
});
|
|
972
1242
|
|
|
973
|
-
|
|
1243
|
+
messages.push({
|
|
1244
|
+
role: 'assistant',
|
|
1245
|
+
content: '',
|
|
1246
|
+
toolCall: { id: toolCallId, name: toolName, arguments: toolArg }
|
|
1247
|
+
});
|
|
1248
|
+
saveCurrentSession();
|
|
1249
|
+
|
|
1250
|
+
const toolResponse = await executeToolCall({
|
|
1251
|
+
toolName,
|
|
1252
|
+
toolArg,
|
|
1253
|
+
toolCallId,
|
|
1254
|
+
workspaceRoot,
|
|
1255
|
+
emit: runtime.emit,
|
|
1256
|
+
requestPermission: async ({ prompt }) => {
|
|
1257
|
+
renderer.pause();
|
|
1258
|
+
try {
|
|
1259
|
+
return await confirmPrompt(`\x1b[33m${sanitizeUntrustedText(prompt)}\x1b[0m`);
|
|
1260
|
+
} finally {
|
|
1261
|
+
renderer.resume();
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
lang: currentLang,
|
|
1265
|
+
mode: agentMode,
|
|
1266
|
+
signal: abortController.signal,
|
|
1267
|
+
getActiveEffort,
|
|
1268
|
+
effortPresets: EFFORT_PRESETS,
|
|
1269
|
+
runFileDigestionWorkflow
|
|
1270
|
+
});
|
|
1271
|
+
const toolResult = toolResponse.modelResult || toolResponse.result;
|
|
1272
|
+
if (toolResponse.cancelled) planActionCancelled = true;
|
|
1273
|
+
messages.push({ role: 'tool', content: String(toolResult || ''), toolCallId, toolName });
|
|
1274
|
+
saveCurrentSession();
|
|
1275
|
+
if (interrupted) throw new Error('Request cancelled.');
|
|
1276
|
+
loopCount++;
|
|
1277
|
+
}
|
|
974
1278
|
|
|
975
|
-
|
|
1279
|
+
if (turnCompleted) {
|
|
1280
|
+
runtime.emit('turn.completed', { steps: loopCount });
|
|
1281
|
+
terminalEventSent = true;
|
|
1282
|
+
turnOutcome = planActionCancelled ? 'cancelled' : 'completed';
|
|
1283
|
+
} else if (loopCount >= maxLoops) {
|
|
1284
|
+
runtime.emit('turn.failed', {
|
|
1285
|
+
error: currentLang === 'cn'
|
|
1286
|
+
? `已达到 ${maxLoops} 步上限,请提高 effort 或缩小任务范围。`
|
|
1287
|
+
: `Reached the ${maxLoops}-step limit. Raise effort or narrow the task.`
|
|
1288
|
+
});
|
|
1289
|
+
terminalEventSent = true;
|
|
1290
|
+
turnOutcome = 'blocked';
|
|
1291
|
+
}
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
if (interrupted || abortController.signal.aborted) {
|
|
1294
|
+
runtime.emit('turn.cancelled', {
|
|
1295
|
+
reason: currentLang === 'cn' ? '已取消当前操作' : 'Current operation cancelled'
|
|
1296
|
+
});
|
|
1297
|
+
terminalEventSent = true;
|
|
1298
|
+
turnOutcome = 'cancelled';
|
|
1299
|
+
} else {
|
|
1300
|
+
if (loopCount === 0) {
|
|
1301
|
+
messages.pop();
|
|
1302
|
+
saveCurrentSession();
|
|
1303
|
+
}
|
|
1304
|
+
runtime.emit('turn.failed', {
|
|
1305
|
+
error: currentLang === 'cn' ? `请求失败:${error.message}` : `Request failed: ${error.message}`
|
|
1306
|
+
});
|
|
1307
|
+
terminalEventSent = true;
|
|
1308
|
+
turnOutcome = 'blocked';
|
|
1309
|
+
}
|
|
1310
|
+
} finally {
|
|
1311
|
+
process.removeListener('SIGINT', onTurnInterrupt);
|
|
1312
|
+
if (!terminalEventSent) runtime.emit('turn.cancelled', { reason: currentLang === 'cn' ? '本轮已停止' : 'Turn stopped' });
|
|
1313
|
+
renderer.dispose();
|
|
1314
|
+
unsubscribe();
|
|
1315
|
+
runtime.close();
|
|
976
1316
|
}
|
|
977
1317
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1318
|
+
if (executingPlan) {
|
|
1319
|
+
updatePlan(workspaceRoot, executingPlan.id, {
|
|
1320
|
+
status: turnOutcome === 'completed' ? 'completed' : turnOutcome === 'cancelled' ? 'ready' : 'blocked',
|
|
1321
|
+
lastRun: {
|
|
1322
|
+
...(executingPlan.lastRun || {}),
|
|
1323
|
+
finishedAt: Date.now(),
|
|
1324
|
+
outcome: turnOutcome
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
981
1327
|
}
|
|
982
1328
|
|
|
1329
|
+
sessionTokenUsage.lastInputTokens = Math.max(0, sessionTokenUsage.inputTokens - turnTokenStart.input);
|
|
1330
|
+
sessionTokenUsage.lastOutputTokens = Math.max(0, sessionTokenUsage.outputTokens - turnTokenStart.output);
|
|
1331
|
+
sessionTokenUsage.lastTotalTokens = sessionTokenUsage.lastInputTokens + sessionTokenUsage.lastOutputTokens;
|
|
1332
|
+
|
|
983
1333
|
if (sessionTokenUsage.lastTotalTokens > 0) {
|
|
984
1334
|
console.log(
|
|
985
1335
|
`\x1b[90mTokens: ${sessionTokenUsage.lastInputTokens.toLocaleString()} input, ` +
|
|
@@ -991,6 +1341,12 @@ async function handleInput(input) {
|
|
|
991
1341
|
);
|
|
992
1342
|
}
|
|
993
1343
|
|
|
1344
|
+
if (pendingPlan || executingPlan) {
|
|
1345
|
+
redrawScreen(currentLang === 'cn'
|
|
1346
|
+
? '\x1b[90m计划状态已更新。\x1b[0m\n'
|
|
1347
|
+
: '\x1b[90mPlan status updated.\x1b[0m\n');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
994
1350
|
promptUser();
|
|
995
1351
|
}
|
|
996
1352
|
|
|
@@ -1000,12 +1356,16 @@ async function startApp() {
|
|
|
1000
1356
|
clearConsole();
|
|
1001
1357
|
await runWelcomeAnimation();
|
|
1002
1358
|
setInitialized();
|
|
1003
|
-
|
|
1004
|
-
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
1359
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1005
1360
|
}
|
|
1361
|
+
initSession();
|
|
1006
1362
|
clearConsole();
|
|
1363
|
+
// The octopus workspace header remains part of every normal startup.
|
|
1007
1364
|
drawHeader();
|
|
1008
1365
|
promptUser();
|
|
1009
1366
|
}
|
|
1010
1367
|
|
|
1011
|
-
startApp().catch(
|
|
1368
|
+
startApp().catch(error => {
|
|
1369
|
+
process.stdout.write('\x1b[?25h');
|
|
1370
|
+
console.error(error);
|
|
1371
|
+
});
|