dave-code 1.0.3 → 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 +98 -19
- package/bin/contextManager.js +151 -0
- package/bin/index.js +827 -450
- package/bin/install.js +3 -5
- 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 +3 -2
package/bin/index.js
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
|
-
import
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
const packageVersion = pkg.version;
|
|
17
|
+
import { spawn } from 'child_process';
|
|
14
18
|
import { getLogoLines } from './logoRenderer.js';
|
|
15
|
-
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';
|
|
16
21
|
import {
|
|
17
22
|
CONFIG_FILE,
|
|
18
23
|
getProfiles,
|
|
@@ -22,17 +27,36 @@ import {
|
|
|
22
27
|
openConfigFileInEditor,
|
|
23
28
|
getActiveEffort,
|
|
24
29
|
setActiveEffort,
|
|
25
|
-
EFFORT_PRESETS
|
|
30
|
+
EFFORT_PRESETS,
|
|
31
|
+
isFirstLaunch,
|
|
32
|
+
setInitialized,
|
|
33
|
+
lastConfigError
|
|
26
34
|
} from './configManager.js';
|
|
35
|
+
import { runWelcomeAnimation } from './install.js';
|
|
27
36
|
import {
|
|
28
37
|
selectMenu,
|
|
29
38
|
secureInputPrompt,
|
|
30
39
|
chatInputPrompt,
|
|
31
40
|
waitForEnter,
|
|
32
|
-
startSpinner,
|
|
33
41
|
selectEffortMenu,
|
|
34
|
-
confirmPrompt
|
|
42
|
+
confirmPrompt,
|
|
43
|
+
textInputPrompt
|
|
35
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';
|
|
36
60
|
|
|
37
61
|
// Localization Dictionary
|
|
38
62
|
const locales = {
|
|
@@ -65,13 +89,16 @@ const locales = {
|
|
|
65
89
|
helpLines: [
|
|
66
90
|
' /help - 显示此帮助信息',
|
|
67
91
|
' /clear - 清屏并重置主面板',
|
|
68
|
-
' /reset -
|
|
69
|
-
' /
|
|
70
|
-
' /
|
|
92
|
+
' /reset - 重置对话历史并新建会话',
|
|
93
|
+
' /history - 查看并载入历史聊天记录',
|
|
94
|
+
' /stats - 显示当前会话的 Token 使用统计 (别名: /tokens)',
|
|
95
|
+
' /lang - 切换中英文界面 (别名: /language)',
|
|
71
96
|
' /config - 编辑本地配置文件 (~/.dave-code-config.json)',
|
|
72
97
|
' /model - 快捷切换当前激活的服务配置',
|
|
73
98
|
' /api - 快捷修改当前配置的 API 密钥',
|
|
74
99
|
' /effort - 调整 AI 思考与阅读的努力程度 (low/medium/high/xhigh/max/ultracode)',
|
|
100
|
+
' /plan 名称: 需求 - 创建只读实施计划;/plan 管理已有计划',
|
|
101
|
+
' /code <需求或计划名> - 单次授权编程修改,仍逐项确认',
|
|
75
102
|
' /open - 打开文件或文件夹并在其中工作',
|
|
76
103
|
' /exit - 退出 Dave Code 代理'
|
|
77
104
|
],
|
|
@@ -91,7 +118,7 @@ const locales = {
|
|
|
91
118
|
保存并关闭文件后,请在下方按 \x1b[1;32m[回车 (Enter)]\x1b[0m 键重载配置。`,
|
|
92
119
|
reloadingConfigMsg: '\n\x1b[32m正在重载配置...\x1b[0m\n',
|
|
93
120
|
openDirSuccess: '已切换工作目录至: ',
|
|
94
|
-
openFileSuccess: '
|
|
121
|
+
openFileSuccess: '已在编辑器中打开现有文件。Dave 将按需读取最新内容。',
|
|
95
122
|
openPathError: '路径不存在或无法访问:',
|
|
96
123
|
openUsagePrompt: '使用方法:/open <文件或文件夹路径>'
|
|
97
124
|
},
|
|
@@ -124,13 +151,16 @@ const locales = {
|
|
|
124
151
|
helpLines: [
|
|
125
152
|
' /help - Show this help message',
|
|
126
153
|
' /clear - Clear screen and refresh home panel',
|
|
127
|
-
' /reset - Reset conversation history',
|
|
128
|
-
' /
|
|
129
|
-
' /
|
|
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)',
|
|
130
158
|
' /config - Open and edit config file (~/.dave-code-config.json)',
|
|
131
159
|
' /model - Interactively switch active service profile',
|
|
132
160
|
' /api - Interactively set API key for active profile',
|
|
133
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',
|
|
134
164
|
' /open - Open a file or directory to work in',
|
|
135
165
|
' /exit - Exit the Dave Code agent'
|
|
136
166
|
],
|
|
@@ -150,7 +180,7 @@ Please edit and save the file in your editor.
|
|
|
150
180
|
Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload configuration.`,
|
|
151
181
|
reloadingConfigMsg: '\n\x1b[32mReloading configuration...\x1b[0m\n',
|
|
152
182
|
openDirSuccess: 'Switched working directory to: ',
|
|
153
|
-
openFileSuccess: 'Opened file
|
|
183
|
+
openFileSuccess: 'Opened the existing file. Dave will read current content on demand.',
|
|
154
184
|
openPathError: 'Path does not exist or is not accessible: ',
|
|
155
185
|
openUsagePrompt: 'Usage: /open <file or folder path>'
|
|
156
186
|
}
|
|
@@ -159,6 +189,56 @@ Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload confi
|
|
|
159
189
|
let currentLang = 'cn';
|
|
160
190
|
let messages = [];
|
|
161
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
|
+
}
|
|
162
242
|
|
|
163
243
|
function getStringWidth(str) {
|
|
164
244
|
const cleanStr = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
@@ -193,6 +273,16 @@ function padLine(str, width) {
|
|
|
193
273
|
return str + ' '.repeat(width - strWidth);
|
|
194
274
|
}
|
|
195
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
|
+
|
|
196
286
|
function maskKey(key) {
|
|
197
287
|
if (!key) return '(None/Not Configured)';
|
|
198
288
|
if (key.length <= 8) return '********';
|
|
@@ -220,7 +310,7 @@ async function runConcurrentLimit(items, limit, fn) {
|
|
|
220
310
|
return Promise.all(results);
|
|
221
311
|
}
|
|
222
312
|
|
|
223
|
-
async function runFileDigestionWorkflow(filePath) {
|
|
313
|
+
async function runFileDigestionWorkflow(filePath, runtime = {}) {
|
|
224
314
|
const resolvedPath = path.resolve(filePath);
|
|
225
315
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
226
316
|
const lines = content.split('\n');
|
|
@@ -235,39 +325,24 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
235
325
|
const totalChunks = chunks.length;
|
|
236
326
|
let completedCount = 0;
|
|
237
327
|
let failedCount = 0;
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
suffix = ` \x1b[31m(${failedCount} failed)\x1b[0m`;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
process.stdout.write(
|
|
260
|
-
`\r\x1b[K\x1b[35m[Ultracode Workflow]\x1b[0m Digesting ${path.basename(filePath)}: ` +
|
|
261
|
-
`${barColor}[${filledBar}${emptyBar}]\x1b[0m ${percentage}% ` +
|
|
262
|
-
`(${completedCount}/${totalChunks} chunks) ${completedCount === totalChunks ? '✔' : '\x1b[36m' + spinnerFrame + '\x1b[0m'}${suffix}`
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
process.stdout.write('\x1b[?25l');
|
|
267
|
-
const spinnerInterval = setInterval(() => {
|
|
268
|
-
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
|
269
|
-
drawProgressBar();
|
|
270
|
-
}, 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();
|
|
271
346
|
|
|
272
347
|
const tasks = chunks.map((chunkText, idx) => ({ chunkText, idx }));
|
|
273
348
|
|
|
@@ -285,20 +360,16 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
285
360
|
try {
|
|
286
361
|
const summary = await getAIResponse(promptMsg);
|
|
287
362
|
completedCount++;
|
|
288
|
-
|
|
363
|
+
emitProgress();
|
|
289
364
|
return { idx: task.idx, start, end, summary };
|
|
290
365
|
} catch (err) {
|
|
291
366
|
completedCount++;
|
|
292
367
|
failedCount++;
|
|
293
|
-
|
|
368
|
+
emitProgress();
|
|
294
369
|
return { idx: task.idx, start, end, summary: `[Error reading this segment: ${err.message}]` };
|
|
295
370
|
}
|
|
296
371
|
});
|
|
297
372
|
|
|
298
|
-
clearInterval(spinnerInterval);
|
|
299
|
-
drawProgressBar(); // final draw to show 100% complete
|
|
300
|
-
process.stdout.write('\n\x1b[?25h');
|
|
301
|
-
|
|
302
373
|
results.sort((a, b) => a.idx - b.idx);
|
|
303
374
|
|
|
304
375
|
let report = `=== ULTRACODE DIGESTION REPORT FOR "${path.basename(filePath)}" ===\n`;
|
|
@@ -317,28 +388,64 @@ async function runFileDigestionWorkflow(filePath) {
|
|
|
317
388
|
function drawHeader() {
|
|
318
389
|
const logoLines = getLogoLines();
|
|
319
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
|
+
}
|
|
320
425
|
|
|
321
426
|
const rightLines = [
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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}`,
|
|
328
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']),
|
|
329
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',
|
|
330
441
|
'',
|
|
331
|
-
''
|
|
442
|
+
currentLang === 'cn' ? '文件内容会按需读取并压缩上下文。' : 'Files are read on demand and compacted.'
|
|
332
443
|
];
|
|
333
444
|
|
|
334
|
-
const leftColWidth = 45;
|
|
335
|
-
const rightColWidth = 47;
|
|
336
|
-
const boxWidth = 95;
|
|
337
|
-
|
|
338
445
|
const boxColor = '\x1b[38;2;250;100;30m';
|
|
339
446
|
const resetColor = '\x1b[0m';
|
|
340
447
|
|
|
341
|
-
const topBorder = boxColor + '┌── ' + resetColor +
|
|
448
|
+
const topBorder = boxColor + '┌── ' + resetColor + `\x1b[1mDave Code v${packageVersion}\x1b[0m` + boxColor + ' ' + '─'.repeat(boxWidth - 16 - packageVersion.length) + '┐' + resetColor;
|
|
342
449
|
const bottomBorder = boxColor + '└' + '─'.repeat(boxWidth) + '┘' + resetColor;
|
|
343
450
|
|
|
344
451
|
console.log(topBorder);
|
|
@@ -365,7 +472,7 @@ function drawHeader() {
|
|
|
365
472
|
leftContent = centerLine(`\x1b[90m${truncatedCwd}\x1b[0m`, leftColWidth);
|
|
366
473
|
}
|
|
367
474
|
|
|
368
|
-
const rightContent = padLine(rightLines[i] || '', rightColWidth);
|
|
475
|
+
const rightContent = padLine(truncateMiddle(rightLines[i] || '', rightColWidth), rightColWidth);
|
|
369
476
|
|
|
370
477
|
console.log(
|
|
371
478
|
boxColor + '│ ' + resetColor +
|
|
@@ -381,90 +488,33 @@ function drawHeader() {
|
|
|
381
488
|
|
|
382
489
|
}
|
|
383
490
|
|
|
384
|
-
|
|
385
|
-
constructor() {
|
|
386
|
-
this.actionsCount = 0;
|
|
387
|
-
this.actionTypes = {};
|
|
388
|
-
this.lastActionText = '';
|
|
389
|
-
this.spinnerIdx = 0;
|
|
390
|
-
this.spinnerInterval = null;
|
|
391
|
-
this.statusColor = '\x1b[35m';
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
start(actionText) {
|
|
395
|
-
this.actionsCount++;
|
|
396
|
-
this.lastActionText = actionText;
|
|
397
|
-
|
|
398
|
-
// Parse action type
|
|
399
|
-
let type = 'Action';
|
|
400
|
-
if (actionText.includes('Reading file')) type = 'Read';
|
|
401
|
-
else if (actionText.includes('Writing file')) type = 'Write';
|
|
402
|
-
else if (actionText.includes('Listing directory')) type = 'List';
|
|
403
|
-
else if (actionText.includes('Searching files')) type = 'Search';
|
|
404
|
-
this.actionTypes[type] = (this.actionTypes[type] || 0) + 1;
|
|
405
|
-
|
|
406
|
-
if (!this.spinnerInterval) {
|
|
407
|
-
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
408
|
-
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
409
|
-
this.spinnerInterval = setInterval(() => {
|
|
410
|
-
this.spinnerIdx = (this.spinnerIdx + 1) % spinnerFrames.length;
|
|
411
|
-
this.draw();
|
|
412
|
-
}, 80);
|
|
413
|
-
}
|
|
414
|
-
this.draw();
|
|
415
|
-
}
|
|
491
|
+
let promptLoopRunning = false;
|
|
416
492
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
finish() {
|
|
441
|
-
this.stop();
|
|
442
|
-
if (this.actionsCount > 0) {
|
|
443
|
-
const typesStr = Object.entries(this.actionTypes)
|
|
444
|
-
.map(([type, count]) => `${count} ${type.toLowerCase()}${count > 1 ? 's' : ''}`)
|
|
445
|
-
.join(', ');
|
|
446
|
-
const summaryText = `Completed ${this.actionsCount} actions (${typesStr})`;
|
|
447
|
-
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`);
|
|
448
514
|
}
|
|
449
|
-
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
450
515
|
}
|
|
451
516
|
}
|
|
452
517
|
|
|
453
|
-
async function promptUser() {
|
|
454
|
-
const effort = getActiveEffort();
|
|
455
|
-
const effortText = `● ${effort} · /effort`;
|
|
456
|
-
const N = effort.length;
|
|
457
|
-
const padding = ' '.repeat(Math.max(1, 80 - N));
|
|
458
|
-
const colorCode = effort === 'ultracode' ? '\x1b[1;38;2;140;90;240m' : '\x1b[90m';
|
|
459
|
-
console.log(padding + colorCode + effortText + '\x1b[0m');
|
|
460
|
-
const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
|
|
461
|
-
const placeholder = activeOpenFile
|
|
462
|
-
? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
|
|
463
|
-
: (currentLang === 'cn' ? 'Try "how do I log an error?" / 输入提示词或命令...' : 'Try "how do I log an error?" / Enter prompt or command...');
|
|
464
|
-
const input = await chatInputPrompt(placeholder, currentLang);
|
|
465
|
-
handleInput(input);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
518
|
// ── Interactive Config Handlers ──
|
|
469
519
|
|
|
470
520
|
async function triggerProfileSwitch(t) {
|
|
@@ -485,9 +535,126 @@ async function triggerProfileSwitch(t) {
|
|
|
485
535
|
const chosenProfile = profiles[selectedIdx];
|
|
486
536
|
setActiveProfile(chosenProfile.model);
|
|
487
537
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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;
|
|
491
658
|
}
|
|
492
659
|
|
|
493
660
|
// ── Main REPL Input Handler ──
|
|
@@ -519,13 +686,152 @@ async function handleInput(input) {
|
|
|
519
686
|
}
|
|
520
687
|
|
|
521
688
|
if (trimmed === '/reset') {
|
|
522
|
-
|
|
689
|
+
initSession();
|
|
523
690
|
resetTokenUsage();
|
|
524
691
|
console.log(`\x1b[32m${t.resetMsg}\x1b[0m\n`);
|
|
525
692
|
promptUser();
|
|
526
693
|
return;
|
|
527
694
|
}
|
|
528
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
|
+
|
|
529
835
|
if (trimmed === '/stats' || trimmed === '/tokens') {
|
|
530
836
|
console.log(`\n\x1b[1;36mToken Usage Statistics / Token 使用统计:\x1b[0m`);
|
|
531
837
|
console.log(` \x1b[90mLast Request (单次请求):\x1b[0m`);
|
|
@@ -543,9 +849,7 @@ async function handleInput(input) {
|
|
|
543
849
|
if (trimmed === '/lang' || trimmed === '/language') {
|
|
544
850
|
currentLang = currentLang === 'cn' ? 'en' : 'cn';
|
|
545
851
|
const nextT = locales[currentLang];
|
|
546
|
-
|
|
547
|
-
drawHeader();
|
|
548
|
-
console.log(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
|
|
852
|
+
redrawScreen(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
|
|
549
853
|
promptUser();
|
|
550
854
|
return;
|
|
551
855
|
}
|
|
@@ -571,12 +875,9 @@ async function handleInput(input) {
|
|
|
571
875
|
const selected = await selectEffortMenu(currentEffort, currentLang);
|
|
572
876
|
if (selected) {
|
|
573
877
|
setActiveEffort(selected);
|
|
574
|
-
|
|
575
|
-
drawHeader();
|
|
576
|
-
console.log(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
|
|
878
|
+
redrawScreen(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
|
|
577
879
|
} else {
|
|
578
|
-
|
|
579
|
-
drawHeader();
|
|
880
|
+
redrawScreen();
|
|
580
881
|
}
|
|
581
882
|
promptUser();
|
|
582
883
|
return;
|
|
@@ -604,17 +905,22 @@ async function handleInput(input) {
|
|
|
604
905
|
|
|
605
906
|
// Pause prompt and wait for Enter key
|
|
606
907
|
await waitForEnter(t.openingConfigMsg);
|
|
607
|
-
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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);
|
|
612
918
|
promptUser();
|
|
613
919
|
return;
|
|
614
920
|
}
|
|
615
921
|
|
|
616
922
|
// Handle /open <path>
|
|
617
|
-
if (
|
|
923
|
+
if (/^\/open(?:\s|$)/.test(trimmed)) {
|
|
618
924
|
const args = trimmed.slice(5).trim();
|
|
619
925
|
if (!args) {
|
|
620
926
|
console.log(`\n\x1b[31m${t.openUsagePrompt}\x1b[0m\n`);
|
|
@@ -633,10 +939,11 @@ async function handleInput(input) {
|
|
|
633
939
|
if (stats && stats.isDirectory()) {
|
|
634
940
|
try {
|
|
635
941
|
process.chdir(resolvedPath);
|
|
942
|
+
workspaceRoot = process.cwd();
|
|
943
|
+
workspaceReady = true;
|
|
636
944
|
activeOpenFile = null; // Switch to directory mode clears active file focus
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
console.log(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
|
|
945
|
+
saveCurrentSession();
|
|
946
|
+
redrawScreen(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
|
|
640
947
|
} catch (err) {
|
|
641
948
|
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
642
949
|
}
|
|
@@ -644,75 +951,36 @@ async function handleInput(input) {
|
|
|
644
951
|
return;
|
|
645
952
|
}
|
|
646
953
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
fs.writeFileSync(resolvedPath, '', 'utf8');
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
activeOpenFile = resolvedPath; // Track currently open file
|
|
656
|
-
|
|
657
|
-
// Read content
|
|
658
|
-
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
659
|
-
const lines = content.split('\n');
|
|
660
|
-
const activeEffort = getActiveEffort();
|
|
661
|
-
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
662
|
-
|
|
663
|
-
let contextContent = '';
|
|
664
|
-
if (activeEffort === 'ultracode') {
|
|
665
|
-
if (lines.length > 1000) {
|
|
666
|
-
const totalChunks = Math.ceil(lines.length / 2000);
|
|
667
|
-
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): `;
|
|
668
|
-
const proceed = await confirmPrompt(confirmMsg);
|
|
669
|
-
|
|
670
|
-
if (proceed) {
|
|
671
|
-
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
672
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
673
|
-
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}`;
|
|
674
|
-
} else {
|
|
675
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
676
|
-
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.]`;
|
|
677
|
-
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
678
|
-
}
|
|
679
|
-
} else if (lines.length > preset.maxReadLines) {
|
|
680
|
-
const truncatedContent = lines.slice(0, 100).join('\n');
|
|
681
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
682
|
-
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}`;
|
|
683
|
-
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated first 100 lines for AI context.\x1b[0m`);
|
|
684
|
-
} else {
|
|
685
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
686
|
-
}
|
|
954
|
+
if (!workspaceReady) {
|
|
955
|
+
if (stats && stats.isFile()) {
|
|
956
|
+
workspaceRoot = path.dirname(resolvedPath);
|
|
957
|
+
process.chdir(workspaceRoot);
|
|
958
|
+
workspaceReady = true;
|
|
687
959
|
} else {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
} else {
|
|
694
|
-
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
695
|
-
}
|
|
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;
|
|
696
965
|
}
|
|
697
|
-
|
|
698
|
-
// Load file into messages history as a System context message
|
|
699
|
-
messages.push({
|
|
700
|
-
role: 'user',
|
|
701
|
-
content: contextContent
|
|
702
|
-
});
|
|
966
|
+
}
|
|
703
967
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
968
|
+
if (!stats || !stats.isFile()) {
|
|
969
|
+
console.log(`\n\x1b[31m${t.openPathError}${sanitizeUntrustedText(resolvedPath)}\x1b[0m\n`);
|
|
970
|
+
promptUser();
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
708
973
|
|
|
709
|
-
|
|
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
|
-
|
|
981
|
+
launchEditor(filePath);
|
|
982
|
+
|
|
983
|
+
console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${filePath}\x1b[0m\n`);
|
|
716
984
|
} catch (err) {
|
|
717
985
|
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
718
986
|
}
|
|
@@ -721,259 +989,347 @@ async function handleInput(input) {
|
|
|
721
989
|
return;
|
|
722
990
|
}
|
|
723
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
|
+
|
|
724
1057
|
// Check if active profile and API key are configured
|
|
725
1058
|
if (!hasApiKey()) {
|
|
726
1059
|
console.log(`\n${t.noKeyError}\n`);
|
|
1060
|
+
if (lastConfigError) console.log(`\x1b[31m${sanitizeUntrustedText(lastConfigError)}\x1b[0m\n`);
|
|
727
1061
|
promptUser();
|
|
728
1062
|
return;
|
|
729
1063
|
}
|
|
730
1064
|
|
|
731
1065
|
// Push user message to history
|
|
732
|
-
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
|
+
}
|
|
733
1075
|
|
|
734
1076
|
const activeEffort = getActiveEffort();
|
|
735
1077
|
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
736
1078
|
const maxLoops = preset.maxLoops;
|
|
737
|
-
|
|
738
|
-
|
|
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);
|
|
739
1094
|
let loopCount = 0;
|
|
740
|
-
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
|
+
});
|
|
741
1104
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (loopCount === 0) messages.pop();
|
|
754
|
-
break;
|
|
755
|
-
}
|
|
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
|
+
}
|
|
756
1116
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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);
|
|
767
1143
|
}
|
|
768
|
-
finalReply = finalReply.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
769
1144
|
}
|
|
770
|
-
|
|
771
|
-
|
|
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;
|
|
772
1158
|
}
|
|
773
|
-
console.log(`\n\x1b[1;38;2;250;100;30mDave\x1b[0m: ${finalReply}\n`);
|
|
774
|
-
messages.push({ role: 'assistant', content: activeReply });
|
|
775
|
-
break;
|
|
776
|
-
}
|
|
777
1159
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if (
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
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
|
+
}
|
|
786
1177
|
}
|
|
787
|
-
thoughtText = thoughtText.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
788
|
-
}
|
|
789
|
-
if (thoughtText) {
|
|
790
|
-
console.log(`\x1b[90m💭 Dave: ${thoughtText}\x1b[0m`);
|
|
791
1178
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
const toolName = match[1];
|
|
798
|
-
const toolArg = match[2];
|
|
799
|
-
|
|
800
|
-
let toolResult = '';
|
|
801
|
-
const statusColor = '\x1b[35m'; // purple for agent action
|
|
802
|
-
const resetColor = '\x1b[0m';
|
|
803
|
-
|
|
804
|
-
try {
|
|
805
|
-
if (toolName === 'LIST_DIR') {
|
|
806
|
-
const resolvedPath = path.resolve(toolArg.trim() || '.');
|
|
807
|
-
actionTracker.start(`Listing directory: ${path.basename(resolvedPath) || '.'}`);
|
|
808
|
-
|
|
809
|
-
const files = fs.readdirSync(resolvedPath);
|
|
810
|
-
toolResult = files.map(f => {
|
|
811
|
-
const stat = fs.statSync(path.join(resolvedPath, f));
|
|
812
|
-
return `${f} (${stat.isDirectory() ? 'Dir' : 'File, ' + stat.size + 'B'})`;
|
|
813
|
-
}).join('\n') || '(Empty directory)';
|
|
814
|
-
}
|
|
815
|
-
else if (toolName === 'READ_FILE') {
|
|
816
|
-
let filePath = toolArg.trim();
|
|
817
|
-
let startLine = 1;
|
|
818
|
-
let endLine = null;
|
|
819
|
-
|
|
820
|
-
// Parse range like: path/to/file:20-80
|
|
821
|
-
const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
|
|
822
|
-
if (rangeMatch) {
|
|
823
|
-
filePath = filePath.slice(0, -rangeMatch[0].length);
|
|
824
|
-
startLine = parseInt(rangeMatch[1], 10);
|
|
825
|
-
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.';
|
|
826
1183
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const lines = content.split('\n');
|
|
833
|
-
const activeEffort = getActiveEffort();
|
|
834
|
-
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
835
|
-
|
|
836
|
-
if (rangeMatch) {
|
|
837
|
-
const sliced = lines.slice(startLine - 1, endLine);
|
|
838
|
-
toolResult = sliced.join('\n');
|
|
839
|
-
} else {
|
|
840
|
-
if (activeEffort === 'ultracode') {
|
|
841
|
-
if (lines.length > 1000) {
|
|
842
|
-
actionTracker.stop(); // Stop the dynamic status line before prompt
|
|
843
|
-
process.stdout.write('\n'); // Carriage return to next line for clean confirmation
|
|
844
|
-
const totalChunks = Math.ceil(lines.length / 2000);
|
|
845
|
-
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): `;
|
|
846
|
-
const proceed = await confirmPrompt(confirmMsg);
|
|
847
|
-
|
|
848
|
-
if (proceed) {
|
|
849
|
-
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
850
|
-
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}`;
|
|
851
|
-
} else {
|
|
852
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
853
|
-
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.]`;
|
|
854
|
-
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
855
|
-
}
|
|
856
|
-
} else if (lines.length > preset.maxReadLines) {
|
|
857
|
-
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
858
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
859
|
-
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}`;
|
|
860
|
-
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated to first ${preset.maxReadLines} lines.\x1b[0m`);
|
|
861
|
-
} else {
|
|
862
|
-
toolResult = content;
|
|
863
|
-
}
|
|
864
|
-
} else {
|
|
865
|
-
// Non-ultracode modes: Smart Incremental Reading (精读模式)
|
|
866
|
-
if (lines.length > preset.maxReadLines) {
|
|
867
|
-
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
868
|
-
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
869
|
-
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}`;
|
|
870
|
-
} else {
|
|
871
|
-
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(', ')}.`);
|
|
872
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;
|
|
873
1195
|
}
|
|
874
1196
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const maxTotalMatches = 50;
|
|
895
|
-
|
|
896
|
-
function searchGrep(dir, q, depth = 0) {
|
|
897
|
-
if (depth > 5 || totalMatches >= maxTotalMatches) return;
|
|
898
|
-
const files = fs.readdirSync(dir);
|
|
899
|
-
for (const f of files) {
|
|
900
|
-
if (totalMatches >= maxTotalMatches) break;
|
|
901
|
-
if (f === 'node_modules' || f === '.git' || f === '.gemini' || f === 'package-lock.json') continue;
|
|
902
|
-
const fullPath = path.join(dir, f);
|
|
903
|
-
try {
|
|
904
|
-
const stat = fs.statSync(fullPath);
|
|
905
|
-
if (stat.isDirectory()) {
|
|
906
|
-
searchGrep(fullPath, q, depth + 1);
|
|
907
|
-
} else if (stat.isFile()) {
|
|
908
|
-
const ext = path.extname(f).toLowerCase();
|
|
909
|
-
const textExts = ['.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml', '.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs', '.java', '.ts'];
|
|
910
|
-
if (textExts.includes(ext) || stat.size < 100000) {
|
|
911
|
-
const content = fs.readFileSync(fullPath, 'utf8');
|
|
912
|
-
if (content.includes(q)) {
|
|
913
|
-
const lines = content.split('\n');
|
|
914
|
-
const fileMatches = [];
|
|
915
|
-
for (let i = 0; i < lines.length; i++) {
|
|
916
|
-
if (lines[i].includes(q)) {
|
|
917
|
-
fileMatches.push({ lineNum: i + 1, text: lines[i].trim() });
|
|
918
|
-
totalMatches++;
|
|
919
|
-
if (totalMatches >= maxTotalMatches) break;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
if (fileMatches.length > 0) {
|
|
923
|
-
results.push({
|
|
924
|
-
file: path.relative(process.cwd(), fullPath),
|
|
925
|
-
matches: fileMatches
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
} catch (e) {
|
|
932
|
-
// 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();
|
|
933
1216
|
}
|
|
934
|
-
}
|
|
1217
|
+
}).catch(() => {});
|
|
935
1218
|
}
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
936
1221
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
});
|
|
948
|
-
toolResult = outputText;
|
|
949
|
-
} else {
|
|
950
|
-
toolResult = `No matches found for "${query}"`;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
else {
|
|
954
|
-
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;
|
|
955
1232
|
}
|
|
956
|
-
} catch (err) {
|
|
957
|
-
toolResult = `Error executing tool ${toolName}: ${err.message}`;
|
|
958
|
-
console.log(`\n\x1b[31m[Agent Error] ${err.message}\x1b[0m\n`);
|
|
959
|
-
}
|
|
960
1233
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
+
});
|
|
966
1242
|
|
|
967
|
-
|
|
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
|
+
}
|
|
968
1278
|
|
|
969
|
-
|
|
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();
|
|
970
1316
|
}
|
|
971
1317
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
});
|
|
975
1327
|
}
|
|
976
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
|
+
|
|
977
1333
|
if (sessionTokenUsage.lastTotalTokens > 0) {
|
|
978
1334
|
console.log(
|
|
979
1335
|
`\x1b[90mTokens: ${sessionTokenUsage.lastInputTokens.toLocaleString()} input, ` +
|
|
@@ -985,10 +1341,31 @@ async function handleInput(input) {
|
|
|
985
1341
|
);
|
|
986
1342
|
}
|
|
987
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
|
+
|
|
988
1350
|
promptUser();
|
|
989
1351
|
}
|
|
990
1352
|
|
|
991
1353
|
// Start the UI
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1354
|
+
async function startApp() {
|
|
1355
|
+
if (isFirstLaunch()) {
|
|
1356
|
+
clearConsole();
|
|
1357
|
+
await runWelcomeAnimation();
|
|
1358
|
+
setInitialized();
|
|
1359
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1360
|
+
}
|
|
1361
|
+
initSession();
|
|
1362
|
+
clearConsole();
|
|
1363
|
+
// The octopus workspace header remains part of every normal startup.
|
|
1364
|
+
drawHeader();
|
|
1365
|
+
promptUser();
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
startApp().catch(error => {
|
|
1369
|
+
process.stdout.write('\x1b[?25h');
|
|
1370
|
+
console.error(error);
|
|
1371
|
+
});
|