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/bin/index.js CHANGED
@@ -10,9 +10,14 @@
10
10
 
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
- import { exec } from 'child_process';
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
- ' /stats - 显示当前会话的 Token 使用统计',
70
- ' /lang - 切换中英文界面',
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: '已在编辑器中打开文件,并将其内容加载至 AI 上下文。',
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
- ' /stats - Show session token usage statistics',
129
- ' /lang - Switch language between Chinese and English',
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 in editor and loaded its content into AI context.',
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 spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
240
- let spinnerIdx = 0;
241
-
242
- function drawProgressBar() {
243
- const percentage = totalChunks > 0 ? Math.round((completedCount / totalChunks) * 100) : 100;
244
- const barLength = 25;
245
- const filledLength = totalChunks > 0 ? Math.round((completedCount / totalChunks) * barLength) : barLength;
246
- const emptyLength = barLength - filledLength;
247
-
248
- const filledBar = '█'.repeat(filledLength);
249
- const emptyBar = ''.repeat(emptyLength);
250
-
251
- const barColor = completedCount === totalChunks ? '\x1b[32m' : '\x1b[38;2;140;90;240m';
252
- const spinnerFrame = spinnerFrames[spinnerIdx];
253
-
254
- let suffix = '';
255
- if (failedCount > 0) {
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
- drawProgressBar();
363
+ emitProgress();
289
364
  return { idx: task.idx, start, end, summary };
290
365
  } catch (err) {
291
366
  completedCount++;
292
367
  failedCount++;
293
- drawProgressBar();
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
- t.tipsTitle,
323
- ...t.tipsLines,
324
- t.newsTitle,
325
- ...t.newsLines,
326
- t.docTitle,
327
- ...t.docLines,
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 + '\x1b[1mDave Code v0.1.0\x1b[0m' + boxColor + ' ' + '─'.repeat(boxWidth - 21) + '┐' + 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
- class AgentActionTracker {
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
- draw() {
418
- const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
419
- const spinnerFrame = spinnerFrames[this.spinnerIdx];
420
-
421
- const cleanActionText = this.lastActionText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
422
-
423
- let displayAction = cleanActionText;
424
- if (displayAction.length > 50) {
425
- displayAction = displayAction.slice(0, 47) + '...';
426
- }
427
-
428
- process.stdout.write(
429
- `\r\x1b[K\x1b[35m⚙ [Agent Actions]\x1b[0m ${this.actionsCount} actions | Last: ${displayAction} \x1b[36m${spinnerFrame}\x1b[0m`
430
- );
431
- }
432
-
433
- stop() {
434
- if (this.spinnerInterval) {
435
- clearInterval(this.spinnerInterval);
436
- this.spinnerInterval = null;
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
- clearConsole();
489
- drawHeader();
490
- console.log(`\n\x1b[32mActive model set to: ${chosenProfile.model}\x1b[0m\n`);
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
- messages = [];
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
- clearConsole();
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
- clearConsole();
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
- clearConsole();
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
- // Reload config and refresh
609
- console.log(t.reloadingConfigMsg);
610
- clearConsole();
611
- drawHeader();
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 (trimmed.startsWith('/open')) {
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
- clearConsole();
638
- drawHeader();
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
- // It is a file or a path to be created as a file
648
- try {
649
- // Create empty file if it doesn't exist
650
- if (!fs.existsSync(resolvedPath)) {
651
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
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
- // Non-ultracode modes: Smart Incremental Reading
689
- if (lines.length > preset.maxReadLines) {
690
- const truncatedContent = lines.slice(0, 100).join('\n');
691
- const relPath = path.relative(process.cwd(), resolvedPath);
692
- 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}`;
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
- // Open in default editor
705
- const command = process.platform === 'win32'
706
- ? `start "" "${resolvedPath}"`
707
- : (process.platform === 'darwin' ? `open "${resolvedPath}"` : `xdg-open "${resolvedPath}"`);
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
- exec(command, (err) => {
710
- if (err && process.platform === 'win32') {
711
- exec(`notepad "${resolvedPath}"`);
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
- console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${resolvedPath}\x1b[0m\n`);
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: trimmed });
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
- const actionTracker = new AgentActionTracker();
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 activeReply = '';
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
- while (loopCount < maxLoops) {
743
- // Simulate thinking with animated spinner
744
- const prefixText = currentLang === 'cn' ? '正在思考' : 'Thinking';
745
- const stopSpinner = startSpinner(prefixText);
746
-
747
- try {
748
- activeReply = await getAIResponse(messages, activeOpenFile, preset.maxReadLines);
749
- stopSpinner();
750
- } catch (error) {
751
- stopSpinner();
752
- console.log(`\n\x1b[31mAPI Error:\x1b[0m ${error.message}\n`);
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
- // Check if the reply contains any tool tags
758
- const match = activeReply.match(/<<([A-Z_]+):\s*([\s\S]*?)>>/);
759
- if (!match) {
760
- // Final response from Dave
761
- let finalReply = activeReply;
762
- const thinkMatch = finalReply.match(/<think>([\s\S]*?)<\/think>/);
763
- if (thinkMatch) {
764
- const thinkContent = thinkMatch[1].trim();
765
- if (thinkContent) {
766
- console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
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
- if (finalReply === '') {
771
- finalReply = '(Finished reasoning / 已思考完毕但未返回额外文本)';
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
- // Extract thought text if present
779
- let thoughtText = activeReply.slice(0, match.index).trim();
780
- if (thoughtText) {
781
- const thinkMatch = thoughtText.match(/<think>([\s\S]*?)<\/think>/);
782
- if (thinkMatch) {
783
- const thinkContent = thinkMatch[1].trim();
784
- if (thinkContent) {
785
- console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
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
- // Save Dave's intermediate tool call message to history
795
- messages.push({ role: 'assistant', content: activeReply });
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
- const resolvedPath = path.resolve(filePath);
829
- actionTracker.start(`Reading file: ${path.basename(filePath)}${rangeMatch ? ` (Lines ${startLine}-${endLine})` : ''}`);
830
-
831
- const content = fs.readFileSync(resolvedPath, 'utf8');
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
- else if (toolName === 'WRITE_FILE') {
877
- const firstNewline = toolArg.indexOf('\n');
878
- const filePathPart = firstNewline === -1 ? toolArg : toolArg.slice(0, firstNewline);
879
- const fileContentPart = firstNewline === -1 ? '' : toolArg.slice(firstNewline + 1);
880
-
881
- const resolvedPath = path.resolve(filePathPart.trim());
882
- actionTracker.start(`Writing file: ${path.basename(resolvedPath)}`);
883
-
884
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
885
- fs.writeFileSync(resolvedPath, fileContentPart, 'utf8');
886
- toolResult = `Successfully wrote to file: ${resolvedPath}`;
887
- }
888
- else if (toolName === 'SEARCH_GREP') {
889
- const query = toolArg.trim();
890
- actionTracker.start(`Searching files for: "${query}"`);
891
-
892
- const results = [];
893
- let totalMatches = 0;
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
- searchGrep(process.cwd(), query);
938
-
939
- if (results.length > 0) {
940
- let outputText = 'Found matches:\n';
941
- results.forEach(res => {
942
- outputText += `- ${res.file}:\n`;
943
- res.matches.forEach(m => {
944
- const displayInfo = m.text.length > 120 ? m.text.slice(0, 117) + '...' : m.text;
945
- outputText += ` Line ${m.lineNum}: ${displayInfo}\n`;
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
- // Append tool response to history as a user message
962
- messages.push({
963
- role: 'user',
964
- content: `[Tool Response for ${toolName}:\n${toolResult}]`
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
- actionTracker.stop(); // Stop the action tracker spinner before we loop back to thinking
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
- loopCount++;
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
- actionTracker.finish();
973
- if (loopCount >= maxLoops) {
974
- console.log(`\n\x1b[33m[Warning] Loop limit reached (${maxLoops} steps). Dave stopped to prevent high API token usage. Try raising the effort level using /effort or narrowing down the task.\x1b[0m\n`);
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
- clearConsole();
993
- drawHeader();
994
- promptUser();
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
+ });