dave-code 1.0.4 → 1.1.0

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