@semalt-ai/code 1.8.0 → 1.8.1

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/lib/agent.js CHANGED
@@ -395,6 +395,19 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
395
395
  break;
396
396
  } catch (err) {
397
397
  lastApiErr = err;
398
+ if (debug) {
399
+ const header = `\n───── raw http error (iteration ${iteration + 1}, attempt ${attempt}/${MAX_RETRIES}) ─────\n`;
400
+ const footer = `\n───── end raw http error ─────\n`;
401
+ const status = err.statusCode ? `HTTP ${err.statusCode}` : 'network error';
402
+ const headerLines = err.responseHeaders
403
+ ? Object.entries(err.responseHeaders).map(([k, v]) => `${k}: ${v}`).join('\n')
404
+ : '';
405
+ const body = err.rawBody !== undefined ? err.rawBody : (err.stack || err.message || String(err));
406
+ const parts = [status];
407
+ if (headerLines) parts.push(headerLines);
408
+ parts.push(body || '(empty body)');
409
+ process.stderr.write(header + parts.join('\n\n') + footer);
410
+ }
398
411
  }
399
412
  }
400
413
 
@@ -433,18 +446,74 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
433
446
  }
434
447
  }
435
448
 
436
- if (!reply) break;
449
+ if (!reply) {
450
+ // Empty reply from the model — stream resolved with no content and no
451
+ // tool_calls. Most common causes: server-side disconnect mid-stream,
452
+ // context-window overflow that slipped past the 400/413 handler, or a
453
+ // model that returns only a stop token. Surface it so the user isn't
454
+ // left staring at an idle prompt.
455
+ if (cb.onError) {
456
+ const hint = iteration > 0 ? ' (after tool execution)' : '';
457
+ cb.onError({ message: `Agent returned an empty response${hint}. The connection to the model may have dropped — try again or /compact if context is large.`, isWarning: true });
458
+ }
459
+ break;
460
+ }
437
461
 
438
462
  const toolCalls = extractToolCalls(reply);
439
463
  const cleanedReply = cleanAssistantContent(reply);
440
464
 
465
+ // Detect mid-tag truncation: an opening tool tag in the raw reply with
466
+ // no matching close. This happens when the model streams a large
467
+ // `<write_file>…` body and hits max_tokens or a server-side cutoff
468
+ // before the closing tag arrives. cleanAssistantContent strips the
469
+ // unclosed tag + its trailing content, so cleanedReply looks
470
+ // legitimate (just the planning preamble) and extractToolCalls finds
471
+ // zero calls — the loop would break silently and the user sees the
472
+ // planning text followed by nothing. Surface it so the user can retry,
473
+ // shorten the request, or bump max_tokens.
474
+ let truncatedTag = null;
475
+ for (const [tag, entry] of Object.entries(TAG_REGISTRY)) {
476
+ if (entry.type !== 'tool') continue;
477
+ let opens = 0;
478
+ for (const m of reply.matchAll(new RegExp(`<${tag}([^>]*)>`, 'gi'))) {
479
+ // Skip self-closing (`<tag .../>`) — they don't need a matching close.
480
+ if (!m[1].trimEnd().endsWith('/')) opens++;
481
+ }
482
+ if (opens === 0) continue;
483
+ const closes = (reply.match(new RegExp(`<\\/${tag}>`, 'gi')) || []).length;
484
+ if (opens > closes) { truncatedTag = tag; break; }
485
+ }
486
+ if (truncatedTag && cb.onError) {
487
+ cb.onError({ message: `Response truncated mid-<${truncatedTag}> tag — likely hit max_tokens or a server-side cutoff. Try again, shorten the request, or raise the model's max_tokens.`, isWarning: true });
488
+ }
489
+
441
490
  messages.push({ role: 'assistant', content: cleanedReply });
442
491
  // When showThink is off and the turn has tool calls, suppress the text bubble —
443
492
  // pre-tool reasoning is noise, tool result bubbles already convey what happened.
444
493
  const displayReply = (!showThink && toolCalls.length > 0) ? '' : cleanedReply;
445
494
  if (cb.onAssistantMessage) cb.onAssistantMessage(displayReply);
446
495
 
447
- if (toolCalls.length === 0) break;
496
+ // If nothing meaningful came back (no text to show, no tools to run) but
497
+ // the reply string wasn't strictly empty, it's usually model wrapper
498
+ // noise or a stripped-only response. Still a dead-end for the user.
499
+ if (toolCalls.length === 0 && !cleanedReply.trim()) {
500
+ if (cb.onError) {
501
+ cb.onError({ message: 'Agent reply had no visible content and no actions — stopping.', isWarning: true });
502
+ }
503
+ break;
504
+ }
505
+
506
+ if (toolCalls.length === 0) {
507
+ // Model narrated next steps but didn't emit a tool tag. Happens when the
508
+ // model ends a plan with "Let me do that for you." and stops. If we just
509
+ // break, the user sees a dangling promise and thinks the connection dropped.
510
+ if (iteration > 0 && /\b(let me|i['’]?ll|i will|i'?m going to|next[, ]|now[, ]? ?(i|we)|going to (create|write|build|add|make|run|do|set up|install))\b/i.test(cleanedReply)) {
511
+ if (cb.onError) {
512
+ cb.onError({ message: 'Agent described next steps but did not emit a tool call. Reply "continue" (or similar) to push it forward, or restart if it keeps stalling.', isWarning: true });
513
+ }
514
+ }
515
+ break;
516
+ }
448
517
  if (isAborted()) break;
449
518
 
450
519
  if (!cb.onToolStart) {
package/lib/api.js CHANGED
@@ -284,6 +284,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
284
284
  messages: trimmedMessages,
285
285
  temperature: temperature !== undefined ? temperature : config.temperature,
286
286
  stream: true,
287
+ stream_options: { include_usage: true },
287
288
  };
288
289
 
289
290
  if (maxTokens !== undefined) payload.max_tokens = maxTokens;
@@ -319,6 +320,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
319
320
  err.statusCode = res.statusCode;
320
321
  err.parsedErr = parsedErr;
321
322
  err.detail = detail;
323
+ err.rawBody = errBody;
324
+ err.responseHeaders = res.headers;
322
325
  throw err;
323
326
  }
324
327
  return res;
@@ -365,6 +368,9 @@ function createApiClient({ getConfig, saveConfig, ui }) {
365
368
  let inReasoning = false;
366
369
  let streamUsage = null;
367
370
  let resolved = false;
371
+ // delta.tool_calls accumulator (OpenAI function-calling streaming format).
372
+ // Keyed by `index` per the OpenAI spec.
373
+ const toolCallAcc = [];
368
374
  const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
369
375
  if (!silent) {
370
376
  process.stdout.write('\n');
@@ -373,9 +379,35 @@ function createApiClient({ getConfig, saveConfig, ui }) {
373
379
  let firstContentToken = true;
374
380
  let lineBuffer = '';
375
381
 
382
+ function escapeXml(s) {
383
+ return String(s)
384
+ .replace(/&/g, '&amp;')
385
+ .replace(/</g, '&lt;')
386
+ .replace(/>/g, '&gt;');
387
+ }
388
+
389
+ // Convert any accumulated tool_calls into a MiniMax XML block and
390
+ // append it to fullText so extractToolCalls() picks them up. Runs once
391
+ // at stream end.
392
+ function appendToolCallsXml() {
393
+ const valid = toolCallAcc.filter((t) => t && t.name);
394
+ if (valid.length === 0) return;
395
+ const invokes = valid.map((tc) => {
396
+ let args = {};
397
+ try { args = tc.arguments ? JSON.parse(tc.arguments) : {}; } catch {}
398
+ const params = Object.entries(args).map(([k, v]) => {
399
+ const val = typeof v === 'string' ? v : JSON.stringify(v);
400
+ return `<parameter name="${escapeXml(k)}">${val}</parameter>`;
401
+ }).join('\n');
402
+ return `<invoke name="${escapeXml(tc.name)}">\n${params}\n</invoke>`;
403
+ }).join('\n');
404
+ fullText += `\n<minimax:tool_call>\n${invokes}\n</minimax:tool_call>`;
405
+ }
406
+
376
407
  function finalize() {
377
408
  if (resolved) return;
378
409
  resolved = true;
410
+ appendToolCallsXml();
379
411
  if (!silent) renderer.flush();
380
412
  const elapsed = (Date.now() - startTime) / 1000;
381
413
  const tps = tokenCount / (elapsed || 1);
@@ -385,7 +417,16 @@ function createApiClient({ getConfig, saveConfig, ui }) {
385
417
  StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
386
418
  StatusBar.current.render();
387
419
  }
388
- resolve({ content: fullText, usage: streamUsage });
420
+ // Fallback for endpoints that don't honor stream_options.include_usage:
421
+ // estimate prompt/completion tokens locally so the status bar still updates.
422
+ let usage = streamUsage;
423
+ if (!usage) {
424
+ usage = {
425
+ prompt_tokens: estimateTokens(JSON.stringify(trimmedMessages)),
426
+ completion_tokens: estimateTokens(fullText) + estimateTokens(reasoningText),
427
+ };
428
+ }
429
+ resolve({ content: fullText, usage });
389
430
  }
390
431
 
391
432
  res.setEncoding('utf8');
@@ -427,6 +468,25 @@ function createApiClient({ getConfig, saveConfig, ui }) {
427
468
  }
428
469
  }
429
470
 
471
+ const toolCallsDelta = delta.tool_calls;
472
+ if (Array.isArray(toolCallsDelta)) {
473
+ for (const tc of toolCallsDelta) {
474
+ const idx = typeof tc.index === 'number' ? tc.index : toolCallAcc.length;
475
+ const isNew = !toolCallAcc[idx];
476
+ if (isNew) toolCallAcc[idx] = { name: '', arguments: '' };
477
+ if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
478
+ if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
479
+ // When the model streams purely via delta.tool_calls (no
480
+ // delta.content), firstContentToken never flips, so the status
481
+ // bar stays on "Thinking…" for the entire tool-call stream.
482
+ // Surface each new tool slot the moment its name is known so
483
+ // the user sees "Using tool: <name>" instead of a frozen UI.
484
+ if (isNew && StatusBar.current && toolCallAcc[idx].name) {
485
+ StatusBar.current.update('tool', `Using tool: ${toolCallAcc[idx].name}`);
486
+ }
487
+ }
488
+ }
489
+
430
490
  const content = delta.content || '';
431
491
  if (content) {
432
492
  if (inReasoning) {
package/lib/commands.js CHANGED
@@ -7,6 +7,7 @@ const { configShow } = require('./config');
7
7
  const { SYSTEM_PROMPT } = require('./prompts');
8
8
  const { SessionStorage } = require('./storage');
9
9
  const { getSkippedOps, setUIActive } = require('./tools');
10
+ const { AUDIT_LOG } = require('./audit');
10
11
 
11
12
  function formatTimeAgo(ts) {
12
13
  const diffMs = Date.now() - ts;
@@ -150,6 +151,7 @@ function createCommands({
150
151
  let messages = [];
151
152
  let currentChatId = null;
152
153
  let savedUpTo = 0;
154
+ let debugMode = !!opts.debug;
153
155
 
154
156
  // Resolve system prompt override from --system-prompt file if provided
155
157
  let resolvedSystemPrompt = null;
@@ -576,6 +578,7 @@ function createCommands({
576
578
  ' /shell <cmd> Run shell command',
577
579
  ' !<cmd> Run shell command',
578
580
  ' /approve Toggle auto-approve',
581
+ ' /debug [off] Enable debug output + show last 5 audit entries',
579
582
  ' /config Show config',
580
583
  ' exit Quit',
581
584
  ].join('\n'),
@@ -753,6 +756,40 @@ function createCommands({
753
756
  return;
754
757
  }
755
758
 
759
+ if (text === '/debug' || text.startsWith('/debug ')) {
760
+ const arg = text === '/debug' ? '' : text.slice(7).trim().toLowerCase();
761
+ if (arg === 'off' || arg === 'false' || arg === '0') debugMode = false;
762
+ else debugMode = true;
763
+
764
+ let tail = '';
765
+ try {
766
+ const content = fs.readFileSync(AUDIT_LOG, 'utf8');
767
+ const lines = content.trim().split('\n').filter((l) => l.trim()).slice(-5);
768
+ if (lines.length) {
769
+ const formatted = lines.map((line) => {
770
+ try {
771
+ const entry = JSON.parse(line);
772
+ const mark = entry.approved ? '✓' : '✗';
773
+ return ` ${mark} ${entry.ts} ${entry.tag} ${entry.input} → ${entry.result}`;
774
+ } catch {
775
+ return ` ${line}`;
776
+ }
777
+ });
778
+ tail = '\nLast 5 audit entries:\n' + formatted.join('\n');
779
+ } else {
780
+ tail = '\nAudit log is empty.';
781
+ }
782
+ } catch {
783
+ tail = '\nNo audit log found.';
784
+ }
785
+
786
+ chatHistory.addMessage({
787
+ role: 'system',
788
+ content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors → stderr)${tail}`,
789
+ });
790
+ return;
791
+ }
792
+
756
793
  if (text.startsWith('/shell ') || text.startsWith('!')) {
757
794
  const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
758
795
  inputField.setDisabled(true);
@@ -810,7 +847,12 @@ function createCommands({
810
847
  if (entry?.type === 'tool') {
811
848
  const actionLabel = entry.label || tag;
812
849
  const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
813
- statusBar.update('tool', `${actionLabel}${detail ? ': ' + detail : ''}`);
850
+ const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
851
+ const barState = isDownload ? 'waiting_download' : 'tool';
852
+ const label = isDownload
853
+ ? `Waiting for download${detail ? ': ' + detail : ''}`
854
+ : `${actionLabel}${detail ? ': ' + detail : ''}`;
855
+ statusBar.update(barState, label);
814
856
  if (!opts.showThink) chatHistory.clearStreamingContent();
815
857
  }
816
858
  if (entry?.display === 'think_bubble') {
@@ -824,7 +866,12 @@ function createCommands({
824
866
  onToolStart: (tag, input, attrs) => {
825
867
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
826
868
  const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
827
- statusBar.update('tool', `${actionLabel}: ${short}`);
869
+ const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
870
+ if (isDownload) {
871
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
872
+ } else {
873
+ statusBar.update('tool', `${actionLabel}: ${short}`);
874
+ }
828
875
  },
829
876
  onToolEnd: (tag, result, durationMs) => {
830
877
  const isError = typeof result === 'string' && result.startsWith('Error');
@@ -913,7 +960,7 @@ function createCommands({
913
960
  try {
914
961
  const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
915
962
  showThink: opts.showThink || false,
916
- debug: opts.debug || false,
963
+ debug: debugMode,
917
964
  callbacks,
918
965
  systemPrompt: resolvedSystemPrompt,
919
966
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
package/lib/constants.js CHANGED
@@ -34,6 +34,7 @@ const TAG_REGISTRY = {
34
34
  think: { type: 'visual', streaming: true, display: 'think_bubble' },
35
35
  reasoning: { type: 'visual', streaming: true, display: 'think_bubble' },
36
36
  reflection: { type: 'visual', streaming: true, display: 'think_bubble' },
37
+ plan: { type: 'visual', streaming: true, display: 'think_bubble' },
37
38
 
38
39
  // Executed as tool calls
39
40
  exec: { type: 'tool', streaming: false, label: 'Running command' },
@@ -65,6 +66,21 @@ const TAG_REGISTRY = {
65
66
  list_memories: { type: 'tool', streaming: false, label: 'Listing memories' },
66
67
  system_info: { type: 'tool', streaming: false, label: 'Reading system info' },
67
68
 
69
+ // MiniMax-M2 native tool-call wrappers. `extractToolCalls` parses them into
70
+ // internal calls; classifying them here keeps raw XML out of the UI stream.
71
+ 'minimax:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
72
+ invoke: { type: 'strip' },
73
+ parameter: { type: 'strip' },
74
+
75
+ // Qwen / Hermes native tool-call wrappers. Qwen3-family models emit a
76
+ // JSON-shaped `<tool_call>{"name":"...","arguments":{...}}</tool_call>`
77
+ // block inline when the server's tool parser is not applied, and some
78
+ // finetunes also use the namespaced `<qwen:tool_call>` or the
79
+ // `<function_call>` spelling. `extractToolCalls` parses all three.
80
+ 'qwen:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
81
+ tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
82
+ function_call: { type: 'tool', streaming: false, label: 'Using tool' },
83
+
68
84
  // Silently stripped — model wrapper artifacts
69
85
  answer: { type: 'strip' },
70
86
  response: { type: 'strip' },
package/lib/prompts.js CHANGED
@@ -85,20 +85,27 @@ To read an environment variable:
85
85
  To set an environment variable for the current session:
86
86
  <set_env name="VARIABLE_NAME" value="value"/>
87
87
 
88
- To reason or think before acting (hidden from user by default):
89
- <think>your reasoning here</think>
88
+ To plan your next action before executing a tool (hidden from user by default):
89
+ <plan>your reasoning and planned next step here</plan>
90
+
91
+ ## Reasoning vs planning — IMPORTANT:
92
+
93
+ - Your internal chain-of-thought reasoning uses your native \`<think>...</think>\` block. Use it normally for deliberation. Do NOT treat \`<think>\` as a user-facing tool and do NOT try to emit \`<think>\` as an action — it is reserved for your own reasoning and is handled by the runtime.
94
+ - When you need to explicitly record a short plan that the agent framework can see (for logging or hand-off between steps), use \`<plan>...</plan>\` instead. \`<plan>\` is a tool tag; \`<think>\` is not.
95
+ - Never emit \`<think>\` as one of the "one tool tag per response" actions. The valid action tags are only the ones listed above (exec, read_file, write_file, plan, etc.).
90
96
 
91
97
  ## STRICT RULES — follow exactly:
92
98
 
93
- 1. **ONE tool tag per response, no exceptions.** After emitting a tag, stop. Wait for the result. Then emit the next tag in your next response. Never put two or more action tags in the same response.
94
- 2. Only use the exact tag names listed above. Unknown tag names will not be executed.
95
- 3. When the user asks you to do something on their system, USE the tools — do NOT just print instructions.
96
- 4. Each action will be shown to the user for approval before execution.
97
- 5. After execution you will receive the result and can proceed.
98
- 6. Be concise. Provide working solutions. Use markdown for code blocks in explanations.
99
- 7. **Permission denied fallback:** If any tool action (write_file, delete_file, make_dir, etc.) is denied or returns a permission error, immediately retry the same operation using \`<exec>\` with an equivalent shell command (e.g. \`tee\`, \`rm\`, \`mkdir -p\`, \`cp\`, \`mv\`). Never give up after a single denial — the shell is always the fallback.
100
- 8. If a shell command itself also fails with a permission error, immediately retry it prefixed with \`sudo\` (e.g. \`sudo tee /path\`, \`sudo mkdir -p /path\`). Do not ask just try. Only stop and explain if sudo itself fails.
101
- 9. Current working directory: ${process.cwd()}`;
99
+ 1. **ONE tool tag per response, no exceptions.** After emitting a tag, stop. Wait for the result. Then emit the next tag in your next response. Never put two or more action tags in the same response. (Your native \`<think>\` reasoning block does NOT count as a tool tag and may precede the action tag normally.)
100
+ 2. **Always end your response with exactly one action tag** whenever the task is not yet complete. Do NOT end a response with only prose like "Let me create the files…" — that prose must be immediately followed by the actual tool tag in the same response. Preamble without a tool tag is a bug.
101
+ 3. Only use the exact tag names listed above. Unknown tag names will not be executed.
102
+ 4. When the user asks you to do something on their system, USE the tools do NOT just print instructions.
103
+ 5. Each action will be shown to the user for approval before execution.
104
+ 6. After execution you will receive the result and can proceed with the next action in a new response.
105
+ 7. Be concise. Provide working solutions. Use markdown for code blocks in explanations.
106
+ 8. **Permission denied fallback:** If any tool action (write_file, delete_file, make_dir, etc.) is denied or returns a permission error, immediately retry the same operation using \`<exec>\` with an equivalent shell command (e.g. \`tee\`, \`rm\`, \`mkdir -p\`, \`cp\`, \`mv\`). Never give up after a single denial the shell is always the fallback.
107
+ 9. If a shell command itself also fails with a permission error, immediately retry it prefixed with \`sudo\` (e.g. \`sudo tee /path\`, \`sudo mkdir -p /path\`). Do not ask — just try. Only stop and explain if sudo itself fails.
108
+ 10. Current working directory: ${process.cwd()}`;
102
109
 
103
110
  module.exports = {
104
111
  SYSTEM_PROMPT,
package/lib/tools.js CHANGED
@@ -125,8 +125,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
125
125
  // For append the final state is existing + new content
126
126
  const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
127
127
 
128
+ // In CLI mode, print the diff inline. In TUI mode, direct stdout writes
129
+ // collide with the live chat-history/status-bar redraw, so we route the
130
+ // diff into the permission description instead (where it renders inside
131
+ // the permission bubble and is safely truncated by MAX_DESC_LINES).
128
132
  const diffOutput = renderDiff(existing, finalContent, filePath);
129
- process.stdout.write(diffOutput + '\n');
133
+ if (!_uiActive) process.stdout.write(diffOutput + '\n');
130
134
 
131
135
  // Dry-run: record the skipped op and return without writing
132
136
  if (_dryRun) {
@@ -139,6 +143,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
139
143
  // Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
140
144
  let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
141
145
  if (content) desc += ` (${content.length} chars)`;
146
+ if (_uiActive) desc = `${desc}\n${diffOutput}`;
142
147
  const approved = await permissionManager.askPermission('file', desc, tag);
143
148
  if (!approved) {
144
149
  logToolCall(tag, { path: filePath, content }, false, 'denied');
@@ -270,27 +275,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
270
275
  try {
271
276
  const dstDir = path.dirname(dst);
272
277
  if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
273
- const cfg = getConfig ? getConfig() : {};
274
- const timeout = cfg.command_timeout_ms || 30000;
275
- const mvResult = spawnSync('mv', [src, dst], { encoding: 'utf8', timeout });
276
- if (mvResult.error && mvResult.error.code === 'ENOENT') throw new Error('mv not available');
277
- if (mvResult.error) throw mvResult.error;
278
- if (mvResult.status !== 0) throw new Error((mvResult.stderr || 'mv failed').trim());
279
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
280
- logToolCall('move_file', { src, dst }, true, 'ok');
281
- return { status: 'ok', src, dst };
282
- } catch (mvErr) {
283
- // Fallback: JS rename (works only within same filesystem)
284
278
  try {
285
279
  fs.renameSync(src, dst);
286
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
287
- logToolCall('move_file', { src, dst }, true, 'ok');
288
- return { status: 'ok', src, dst };
289
- } catch (error) {
290
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
291
- logToolCall('move_file', { src, dst }, true, 'error');
292
- return { error: error.message };
280
+ } catch (renameErr) {
281
+ if (renameErr.code !== 'EXDEV') throw renameErr;
282
+ // Cross-device rename not supported copy then remove
283
+ fs.cpSync(src, dst, { recursive: true });
284
+ fs.rmSync(src, { recursive: true, force: true });
293
285
  }
286
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
287
+ logToolCall('move_file', { src, dst }, true, 'ok');
288
+ return { status: 'ok', src, dst };
289
+ } catch (error) {
290
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
291
+ logToolCall('move_file', { src, dst }, true, 'error');
292
+ return { error: error.message };
294
293
  }
295
294
  }
296
295
 
@@ -360,36 +359,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
360
359
  return { error: 'Permission denied' };
361
360
  }
362
361
  try {
363
- const cfg = getConfig ? getConfig() : {};
364
- const timeout = cfg.command_timeout_ms || 30000;
365
- const gr = spawnSync('grep', ['-n', '-E', '--', pattern, filePath], { encoding: 'utf8', timeout });
366
- if (gr.error && gr.error.code === 'ENOENT') throw new Error('grep not available');
367
- if (gr.error) throw gr.error;
368
- // grep exit 1 = no matches (not an error), 2 = real error
369
- if (gr.status === 2) throw new Error((gr.stderr || '').trim() || 'grep error');
370
- const matches = (gr.stdout || '').split('\n').filter(Boolean).map(line => {
371
- const colon = line.indexOf(':');
372
- return colon === -1 ? null : { line: parseInt(line.slice(0, colon), 10), content: line.slice(colon + 1) };
373
- }).filter(Boolean);
362
+ const data = fs.readFileSync(filePath, 'utf8');
363
+ const regex = new RegExp(pattern);
364
+ const matches = data.split('\n')
365
+ .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
366
+ .filter(Boolean);
374
367
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
375
368
  logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
376
369
  return { matches, path: filePath };
377
- } catch (grepErr) {
378
- // Fallback: JS regex search
379
- try {
380
- const data = fs.readFileSync(filePath, 'utf8');
381
- const regex = new RegExp(pattern);
382
- const matches = data.split('\n')
383
- .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
384
- .filter(Boolean);
385
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
386
- logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
387
- return { matches, path: filePath };
388
- } catch (error) {
389
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
390
- logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
391
- return { error: error.message };
392
- }
370
+ } catch (error) {
371
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
372
+ logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
373
+ return { error: error.message };
393
374
  }
394
375
  }
395
376
 
@@ -430,52 +411,32 @@ function createToolExecutor(permissionManager, ui, getConfig) {
430
411
  return { error: 'Permission denied' };
431
412
  }
432
413
  try {
433
- const cfg = getConfig ? getConfig() : {};
434
- const timeout = cfg.command_timeout_ms || 30000;
435
- // Split glob: "src/**/*.js" → root=searchDir/src, nameGlob="*.js"
436
- const parts = pattern.split('/');
437
- const nameGlob = parts[parts.length - 1];
438
- const subDirs = parts.slice(0, -1).filter(p => p !== '**' && p !== '');
439
- const searchRoot = subDirs.length > 0 ? path.join(searchDir, ...subDirs) : searchDir;
440
- const fr = spawnSync('find', [searchRoot, '-type', 'f', '-name', nameGlob], { encoding: 'utf8', timeout });
441
- if (fr.error && fr.error.code === 'ENOENT') throw new Error('find not available');
442
- if (fr.error) throw fr.error;
443
- const files = (fr.stdout || '').split('\n').filter(Boolean)
444
- .map(f => path.relative(searchDir, f))
445
- .filter(Boolean)
446
- .sort();
414
+ let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
415
+ regStr = regStr.replace(/\*\*/g, '\x00');
416
+ regStr = regStr.replace(/\*/g, '[^/]*');
417
+ regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
418
+ regStr = regStr.replace(/\x00/g, '.*');
419
+ const regex = new RegExp(`^${regStr}$`);
420
+ const matchName = !pattern.includes('/');
421
+ const files = [];
422
+ function walk(dir, rel) {
423
+ let entries;
424
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
425
+ for (const entry of entries) {
426
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
427
+ if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
428
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
429
+ }
430
+ }
431
+ walk(searchDir, '');
432
+ files.sort();
447
433
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
448
434
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
449
435
  return { files, pattern, dir: searchDir };
450
- } catch (_findErr) {
451
- // Fallback: JS glob walker
452
- try {
453
- let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
454
- regStr = regStr.replace(/\*\*/g, '\x00');
455
- regStr = regStr.replace(/\*/g, '[^/]*');
456
- regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
457
- regStr = regStr.replace(/\x00/g, '.*');
458
- const regex = new RegExp(`^${regStr}$`);
459
- const matchName = !pattern.includes('/');
460
- const files = [];
461
- function walk(dir, rel) {
462
- let entries;
463
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
464
- for (const entry of entries) {
465
- const relPath = rel ? `${rel}/${entry.name}` : entry.name;
466
- if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
467
- if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
468
- }
469
- }
470
- walk(searchDir, '');
471
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
472
- logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
473
- return { files, pattern, dir: searchDir };
474
- } catch (error) {
475
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
476
- logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
477
- return { error: error.message };
478
- }
436
+ } catch (error) {
437
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
438
+ logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
439
+ return { error: error.message };
479
440
  }
480
441
  }
481
442
 
@@ -543,37 +504,50 @@ function createToolExecutor(permissionManager, ui, getConfig) {
543
504
  logToolCall('download', { url }, false, 'denied');
544
505
  return { error: 'Permission denied' };
545
506
  }
546
- const dlResult = spawnSync('curl', ['-sLo', outPath, url], { encoding: 'utf8', timeout: 120000 });
547
- if (!dlResult.error || dlResult.error.code !== 'ENOENT') {
548
- if (dlResult.error || dlResult.status !== 0) {
549
- try { fs.unlinkSync(outPath); } catch {}
550
- const msg = dlResult.error ? dlResult.error.message : (dlResult.stderr || 'curl failed').trim();
551
- _log(` ${FG_RED}✗ ${msg}${RST}`);
552
- logToolCall('download', { url }, true, 'error');
553
- return { error: msg };
554
- }
555
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
556
- logToolCall('download', { url }, true, 'ok');
557
- return { status: 'ok', path: outPath };
558
- }
559
- // Fallback: Node.js http/https
560
507
  return new Promise((resolve) => {
561
- const proto = url.startsWith('https') ? https : http;
562
- const file = fs.createWriteStream(outPath);
563
- proto.get(url, (res) => {
564
- res.pipe(file);
565
- file.on('finish', () => {
566
- file.close();
567
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
568
- logToolCall('download', { url }, true, 'ok');
569
- resolve({ status: 'ok', path: outPath });
508
+ function doDownload(target, redirectsLeft) {
509
+ const proto = target.startsWith('https') ? https : http;
510
+ const req = proto.get(target, (res) => {
511
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
512
+ res.resume();
513
+ return doDownload(res.headers.location, redirectsLeft - 1);
514
+ }
515
+ if (res.statusCode >= 400) {
516
+ res.resume();
517
+ const msg = `HTTP ${res.statusCode}`;
518
+ _log(` ${FG_RED}✗ ${msg}${RST}`);
519
+ logToolCall('download', { url }, true, 'error');
520
+ return resolve({ error: msg });
521
+ }
522
+ const file = fs.createWriteStream(outPath);
523
+ res.pipe(file);
524
+ file.on('finish', () => {
525
+ file.close();
526
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
527
+ logToolCall('download', { url }, true, 'ok');
528
+ resolve({ status: 'ok', path: outPath });
529
+ });
530
+ file.on('error', (err) => {
531
+ fs.unlink(outPath, () => {});
532
+ _log(` ${FG_RED}✗ ${err.message}${RST}`);
533
+ logToolCall('download', { url }, true, 'error');
534
+ resolve({ error: err.message });
535
+ });
570
536
  });
571
- }).on('error', (err) => {
572
- fs.unlink(outPath, () => {});
573
- _log(` ${FG_RED}✗ ${err.message}${RST}`);
574
- logToolCall('download', { url }, true, 'error');
575
- resolve({ error: err.message });
576
- });
537
+ req.on('error', (err) => {
538
+ fs.unlink(outPath, () => {});
539
+ _log(` ${FG_RED}✗ ${err.message}${RST}`);
540
+ logToolCall('download', { url }, true, 'error');
541
+ resolve({ error: err.message });
542
+ });
543
+ req.setTimeout(120000, () => {
544
+ req.destroy();
545
+ fs.unlink(outPath, () => {});
546
+ logToolCall('download', { url }, true, 'error');
547
+ resolve({ error: 'Request timeout' });
548
+ });
549
+ }
550
+ doDownload(url, 5);
577
551
  });
578
552
  }
579
553
 
@@ -662,32 +636,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
662
636
  return { error: 'Permission denied' };
663
637
  }
664
638
  const httpCfg = getConfig ? getConfig() : {};
665
- const curlTimeout = Math.max(15, Math.floor((httpCfg.request_timeout_ms || 15000) / 1000));
666
- // Try curl first: -sL follows redirects; -w appends status code on its own line
667
- const curlResult = spawnSync(
668
- 'curl', ['-sL', '--max-time', String(curlTimeout), '-w', '\n%{http_code}', url],
669
- { encoding: 'utf8', timeout: (curlTimeout + 5) * 1000 }
670
- );
671
- if (!curlResult.error || curlResult.error.code !== 'ENOENT') {
672
- if (curlResult.error) {
673
- _log(` ${FG_RED}✗ ${curlResult.error.message}${RST}`);
674
- logToolCall('http_get', { url }, true, 'error');
675
- return { error: curlResult.error.message };
676
- }
677
- const stdout = curlResult.stdout || '';
678
- const lastNl = stdout.lastIndexOf('\n');
679
- const body = lastNl >= 0 ? stdout.slice(0, lastNl) : stdout;
680
- const statusCode = parseInt((lastNl >= 0 ? stdout.slice(lastNl + 1) : '').trim(), 10) || 0;
681
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${url} (${statusCode}, ${body.length} chars)${RST}`);
682
- logToolCall('http_get', { url }, true, statusCode < 400 ? 'ok' : 'error');
683
- return buildHttpResult(url, statusCode, body, rawHtml);
684
- }
685
- // Fallback: Node.js http/https
639
+ const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
686
640
  return new Promise((resolve) => {
687
641
  function doGet(target, redirectsLeft) {
688
642
  const proto = target.startsWith('https') ? https : http;
689
643
  const req = proto.get(target, (res) => {
690
644
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
645
+ res.resume();
691
646
  return doGet(res.headers.location, redirectsLeft - 1);
692
647
  }
693
648
  let data = '';
@@ -704,9 +659,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
704
659
  logToolCall('http_get', { url: target }, true, 'error');
705
660
  resolve({ error: err.message });
706
661
  });
707
- req.setTimeout(15000, () => { req.destroy(); logToolCall('http_get', { url: target }, true, 'error'); resolve({ error: 'Request timeout' }); });
662
+ req.setTimeout(reqTimeoutMs, () => {
663
+ req.destroy();
664
+ logToolCall('http_get', { url: target }, true, 'error');
665
+ resolve({ error: 'Request timeout' });
666
+ });
708
667
  }
709
- doGet(url, 3);
668
+ doGet(url, 5);
710
669
  });
711
670
  }
712
671
 
@@ -841,9 +800,162 @@ function createToolExecutor(permissionManager, ui, getConfig) {
841
800
  };
842
801
  }
843
802
 
803
+ // Map a MiniMax-style {name, params} invocation to the internal
804
+ // [action, arg1, arg2, …] call tuple consumed by the agent loop.
805
+ function mapInvokeToCall(toolName, params) {
806
+ const name = (toolName || '').toLowerCase();
807
+ const p = params || {};
808
+ switch (name) {
809
+ case 'write_file':
810
+ case 'create_file':
811
+ return p.path ? ['write', p.path, p.content != null ? p.content : ''] : null;
812
+ case 'read_file':
813
+ return p.path ? ['read', p.path] : null;
814
+ case 'append_file':
815
+ return p.path ? ['append', p.path, p.content != null ? p.content : ''] : null;
816
+ case 'delete_file':
817
+ return p.path ? ['delete_file', p.path] : null;
818
+ case 'list_dir':
819
+ return ['list_dir', p.path || p.dir || '.'];
820
+ case 'make_dir':
821
+ return p.path ? ['make_dir', p.path] : null;
822
+ case 'remove_dir':
823
+ return p.path ? ['remove_dir', p.path] : null;
824
+ case 'move_file':
825
+ return p.src && p.dst ? ['move_file', p.src, p.dst] : null;
826
+ case 'copy_file':
827
+ return p.src && p.dst ? ['copy_file', p.src, p.dst] : null;
828
+ case 'file_stat':
829
+ return p.path ? ['file_stat', p.path] : null;
830
+ case 'search_files':
831
+ return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
832
+ case 'search_in_file':
833
+ return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
834
+ case 'replace_in_file':
835
+ return p.path && p.search !== undefined
836
+ ? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '']
837
+ : null;
838
+ case 'edit_file':
839
+ return p.path && p.line !== undefined
840
+ ? ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : '']
841
+ : null;
842
+ case 'get_env':
843
+ return p.name ? ['get_env', p.name] : null;
844
+ case 'set_env':
845
+ return p.name ? ['set_env', p.name, p.value != null ? p.value : ''] : null;
846
+ case 'download':
847
+ return p.url ? ['download', p.url] : null;
848
+ case 'upload':
849
+ return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
850
+ case 'http_get':
851
+ return p.url ? ['http_get', p.url, p.raw || ''] : null;
852
+ case 'http_get_next':
853
+ return p.key ? ['http_get_next', p.key] : null;
854
+ case 'ask_user':
855
+ return p.question ? ['ask_user', p.question] : null;
856
+ case 'store_memory':
857
+ return p.key ? ['store_memory', p.key, p.value != null ? p.value : ''] : null;
858
+ case 'recall_memory':
859
+ return p.key ? ['recall_memory', p.key] : null;
860
+ case 'list_memories':
861
+ return ['list_memories'];
862
+ case 'system_info':
863
+ return ['system_info'];
864
+ case 'exec':
865
+ case 'shell':
866
+ case 'run':
867
+ case 'run_command':
868
+ case 'bash':
869
+ return p.command ? ['shell', p.command] : null;
870
+ default:
871
+ return null;
872
+ }
873
+ }
874
+
844
875
  function extractToolCalls(text) {
845
876
  const calls = [];
846
877
 
878
+ // MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
879
+ // inference server's tool parser is disabled, or round-tripped back into
880
+ // text by chatStream when delta.tool_calls is streamed.
881
+ //
882
+ // <minimax:tool_call> <qwen:tool_call>
883
+ // <invoke name="write_file"> <invoke name="write_file">
884
+ // <parameter name="path">… <parameter name="path">…
885
+ // </invoke> </invoke>
886
+ // </minimax:tool_call> </qwen:tool_call>
887
+ const INVOKE_RE = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
888
+ const PARAM_RE = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/g;
889
+ const WRAPPER_BLOCK_RE = /<(?:minimax:tool_call|qwen:tool_call)>([\s\S]*?)<\/(?:minimax:tool_call|qwen:tool_call)>/g;
890
+ for (const blockMatch of text.matchAll(WRAPPER_BLOCK_RE)) {
891
+ const block = blockMatch[1];
892
+ for (const invokeMatch of block.matchAll(INVOKE_RE)) {
893
+ const params = {};
894
+ for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
895
+ let val = pMatch[2];
896
+ if (val.startsWith('\n')) val = val.slice(1);
897
+ if (val.endsWith('\n')) val = val.slice(0, -1);
898
+ params[pMatch[1]] = val;
899
+ }
900
+ const call = mapInvokeToCall(invokeMatch[1], params);
901
+ if (call) calls.push(call);
902
+ }
903
+ }
904
+
905
+ // Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
906
+ // and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
907
+ //
908
+ // <tool_call>
909
+ // {"name": "write_file", "arguments": {"path": "a.css", "content": "…"}}
910
+ // </tool_call>
911
+ //
912
+ // Some variants use <function_call> or the key `parameters` instead of
913
+ // `arguments`. The block may also wrap <invoke> when the finetune follows
914
+ // the MiniMax instruction template — handle both.
915
+ const JSON_BLOCK_RE = /<(tool_call|function_call)>([\s\S]*?)<\/\1>/g;
916
+ for (const blockMatch of text.matchAll(JSON_BLOCK_RE)) {
917
+ const inner = blockMatch[2].trim();
918
+ if (!inner) continue;
919
+
920
+ if (/<invoke\s/i.test(inner)) {
921
+ for (const invokeMatch of inner.matchAll(INVOKE_RE)) {
922
+ const params = {};
923
+ for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
924
+ let val = pMatch[2];
925
+ if (val.startsWith('\n')) val = val.slice(1);
926
+ if (val.endsWith('\n')) val = val.slice(0, -1);
927
+ params[pMatch[1]] = val;
928
+ }
929
+ const call = mapInvokeToCall(invokeMatch[1], params);
930
+ if (call) calls.push(call);
931
+ }
932
+ continue;
933
+ }
934
+
935
+ let parsed = null;
936
+ try { parsed = JSON.parse(inner); } catch {}
937
+ if (!parsed) {
938
+ const firstBrace = inner.indexOf('{');
939
+ const lastBrace = inner.lastIndexOf('}');
940
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
941
+ try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
942
+ }
943
+ }
944
+ if (!parsed) continue;
945
+
946
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
947
+ for (const entry of entries) {
948
+ if (!entry || typeof entry !== 'object') continue;
949
+ const name = entry.name || entry.tool || entry.function || entry.tool_name;
950
+ const params = entry.arguments || entry.parameters || entry.params || entry.args || {};
951
+ const resolved = typeof params === 'string'
952
+ ? (() => { try { return JSON.parse(params); } catch { return {}; } })()
953
+ : params;
954
+ const call = mapInvokeToCall(name, resolved);
955
+ if (call) calls.push(call);
956
+ }
957
+ }
958
+
847
959
  for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
848
960
  for (const line of match[1].trim().split('\n')) {
849
961
  const cmd = line.trim();
@@ -977,5 +1089,6 @@ module.exports = {
977
1089
  createToolExecutor,
978
1090
  extractToolCalls,
979
1091
  getSkippedOps,
1092
+ mapInvokeToCall,
980
1093
  setUIActive,
981
1094
  };
package/lib/ui/ansi.js CHANGED
@@ -50,9 +50,10 @@ const KEYWORDS = new Set([
50
50
  ]);
51
51
 
52
52
  const SPINNER_DEFS = {
53
- thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
54
- streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
55
- tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
53
+ thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
54
+ streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
55
+ tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
56
+ waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
56
57
  };
57
58
 
58
59
  module.exports = {
@@ -138,7 +138,7 @@ class ChatHistory {
138
138
  } else if (msg.role === 'permission') {
139
139
  process.stdout.write(`\n${FG_YELLOW} ⚠ Permission required: ${content}${RST}\n`);
140
140
  } else {
141
- const isErr = msg.isError || (content.toLowerCase().includes('error') && !content.toLowerCase().includes('iswarning'));
141
+ const isErr = !!msg.isError && !msg.isWarning;
142
142
  const color = isErr ? FG_RED : FG_YELLOW;
143
143
  const prefix = isErr ? '✕' : '⚠';
144
144
  const lines = content.split('\n');
@@ -7,7 +7,7 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
7
7
 
8
8
  const SLASH_CMDS = [
9
9
  '/help','/file','/new','/model','/models','/shell','/compact',
10
- '/clear','/approve','/config','/history','/login','/whoami','/logout','/chats',
10
+ '/clear','/approve','/debug','/config','/history','/login','/whoami','/logout','/chats',
11
11
  ];
12
12
 
13
13
  // ─── Key sequence parser ──────────────────────────────────────────────────────
@@ -42,7 +42,7 @@ class FullStatusBar {
42
42
  this._streamStart = null; this._streamTokens = 0; this._speed = 0;
43
43
  }
44
44
 
45
- const animStates = ['thinking', 'streaming', 'tool'];
45
+ const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
46
46
  if (animStates.includes(state) && !this._animTimer) {
47
47
  this._animTimer = setInterval(() => { this._animIdx++; this._renderBar(); }, 100);
48
48
  } else if (!animStates.includes(state) && this._animTimer) {
@@ -102,7 +102,8 @@ class FullStatusBar {
102
102
  const timePart = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
103
103
  const rightParts = [timePart];
104
104
  if (this._model) rightParts.push(this._model);
105
- if (this._totalTokens > 0) rightParts.push(`${this._totalTokens.toLocaleString()} tok`);
105
+ const liveTokens = this._totalTokens + this._streamTokens;
106
+ rightParts.push(`${liveTokens.toLocaleString()} tok`);
106
107
  if (state === 'streaming' && this._speed > 0) rightParts.push(`${this._speed} t/s`);
107
108
 
108
109
  const rightVisible = rightParts.join(' · ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semalt-ai/code",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Self-hosted AI Coding Assistant CLI",
5
5
  "main": "index.js",
6
6
  "bin": {