@semalt-ai/code 1.8.3 → 1.8.4

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/index.js CHANGED
@@ -18,6 +18,7 @@ const { createCommands } = require('./lib/commands');
18
18
  const { parseArgs } = require('./lib/args');
19
19
  const { CONFIG_PATH } = require('./lib/constants');
20
20
  const { AUDIT_LOG } = require('./lib/audit');
21
+ const writer = require('./lib/ui/writer');
21
22
 
22
23
  // Install process-wide signal handlers so every exit path (normal, SIGINT,
23
24
  // SIGHUP, SIGTERM, uncaught exception) restores the terminal. Safe to call
@@ -90,7 +91,7 @@ async function main() {
90
91
  const command = rawArgs[0];
91
92
 
92
93
  if (command === '--help' || command === '-h') {
93
- console.log(`
94
+ writer.scrollback(`
94
95
  Semalt.AI — Self-hosted AI Coding Assistant
95
96
 
96
97
  Usage: semalt-code [command] [options]
@@ -130,11 +131,13 @@ Options:
130
131
 
131
132
  Config: ${CONFIG_PATH}
132
133
  `);
134
+ await writer.flush();
133
135
  return;
134
136
  }
135
137
 
136
138
  if (command === '--version' || command === '-v') {
137
- console.log(PACKAGE_JSON.version);
139
+ writer.scrollback(PACKAGE_JSON.version);
140
+ await writer.flush();
138
141
  return;
139
142
  }
140
143
 
@@ -170,20 +173,22 @@ Config: ${CONFIG_PATH}
170
173
  try {
171
174
  const entry = JSON.parse(line);
172
175
  const icon = entry.approved ? `${ui.FG_GREEN}✓${ui.RST}` : `${ui.FG_RED}✗${ui.RST}`;
173
- console.log(`${icon} ${line}`);
176
+ writer.scrollback(`${icon} ${line}`);
174
177
  } catch {
175
- console.log(line);
178
+ writer.scrollback(line);
176
179
  }
177
180
  }
178
181
  } catch {
179
- console.log('No audit log found.');
182
+ writer.scrollback('No audit log found.');
180
183
  }
184
+ await writer.flush();
181
185
  } else if (command === 'config') {
182
186
  const sub = rawArgs[1];
183
187
  if (sub === 'set') {
184
188
  const key = rawArgs[2];
185
189
  const value = rawArgs[3];
186
190
  if (!key || value === undefined) {
191
+ // audit: allowed — pre-UI argparse usage error to stderr; exits immediately.
187
192
  process.stderr.write(`Usage: semalt-code config set <key> <value>\n`);
188
193
  process.exit(1);
189
194
  }
@@ -194,11 +199,12 @@ Config: ${CONFIG_PATH}
194
199
  parsed = value;
195
200
  }
196
201
  configSet(key, parsed);
197
- console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
202
+ writer.scrollback(`Set ${key} = ${JSON.stringify(parsed)}`);
198
203
  } else {
199
204
  // default: "show" or bare "config"
200
- console.log(configShow());
205
+ writer.scrollback(configShow());
201
206
  }
207
+ await writer.flush();
202
208
  } else {
203
209
  const { opts } = parseArgs(rawArgs);
204
210
  await commands.cmdChat(opts);
@@ -209,6 +215,7 @@ main().catch((error) => {
209
215
  // Tear down the TUI synchronously so the error message lands below the
210
216
  // last scrollback line, not on top of a still-rendered live region.
211
217
  try { ui.teardownTerminal(); } catch {}
218
+ // audit: allowed — fatal error message to stderr after writer teardown.
212
219
  process.stderr.write(`\n ${ui.FG_RED}✗ Fatal: ${error.message}${ui.RST}\n\n`);
213
220
  process.exit(1);
214
221
  });
package/lib/agent.js CHANGED
@@ -8,6 +8,8 @@ const { mapInvokeToCall } = require('./tools');
8
8
  const { UI_THEME } = require('./ui/theme');
9
9
  const { RST } = require('./ui/ansi');
10
10
  const { getCols: _getCols, repeatToWidth } = require('./ui/utils');
11
+ const writer = require('./ui/writer');
12
+ const messages = require('./ui/messages');
11
13
 
12
14
  class StreamParser {
13
15
  constructor(onToken, onTagOpen, onTagContent, onTagClose) {
@@ -153,6 +155,31 @@ function estimateTokens(text) {
153
155
  return Math.floor((text || '').length / 4);
154
156
  }
155
157
 
158
+ // User-initiated aborts surface through several shapes depending on where in
159
+ // the Node http stack the signal fires: `new Error('Aborted')` from our own
160
+ // abort paths in api.js, or AbortError/ABORT_ERR from Node's built-ins. The
161
+ // authoritative check is the signal itself — this helper is the fallback.
162
+ function isAbortError(err) {
163
+ if (!err) return false;
164
+ if (err.name === 'AbortError') return true;
165
+ if (err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED') return true;
166
+ if (typeof err.message === 'string' && /^Aborted$/i.test(err.message)) return true;
167
+ return false;
168
+ }
169
+
170
+ function abortableSleep(ms, signal) {
171
+ return new Promise((resolve) => {
172
+ if (signal && signal.aborted) { resolve(); return; }
173
+ const t = setTimeout(resolve, ms);
174
+ if (signal) {
175
+ signal.addEventListener('abort', () => {
176
+ clearTimeout(t);
177
+ resolve();
178
+ }, { once: true });
179
+ }
180
+ });
181
+ }
182
+
156
183
  function detectFormat(reply, toolCalls) {
157
184
  if (!reply || !reply.trim()) return 'empty';
158
185
  if (/<(minimax:tool_call|qwen:tool_call|tool_call|function_call)\b/i.test(reply)) return 'tool_call';
@@ -281,6 +308,116 @@ function truncateForDebug(text, maxLines = 40, maxChars = 2000) {
281
308
  return s;
282
309
  }
283
310
 
311
+ // Per-tag meta extractor. Converts a tool-executor return value into the
312
+ // compact meta object consumed by the tool-line formatter — exit codes for
313
+ // shell, byte counts for file ops, status_code + bytes for HTTP, etc. A
314
+ // pure function by design: no UI state, no config reads. The callback
315
+ // layer (commands.js) feeds the meta into formatToolLine together with
316
+ // the tag, so the formatter can produce the 4-segment line in either the
317
+ // pending (live region) or final (scrollback) context.
318
+ function _metaForTool(tag, result) {
319
+ if (!result || result.error) return null;
320
+ switch (tag) {
321
+ case 'shell':
322
+ case 'exec':
323
+ return { exit_code: result.exit_code };
324
+ case 'read':
325
+ case 'read_file':
326
+ return {
327
+ bytes: typeof result.bytes === 'number'
328
+ ? result.bytes
329
+ : (result.content ? Buffer.byteLength(String(result.content), 'utf8') : 0),
330
+ };
331
+ case 'write':
332
+ case 'write_file':
333
+ case 'create_file':
334
+ case 'append':
335
+ case 'append_file':
336
+ case 'upload':
337
+ return { bytes: typeof result.bytes === 'number' ? result.bytes : 0 };
338
+ case 'list_dir':
339
+ return { count: Array.isArray(result.items) ? result.items.length : 0 };
340
+ case 'search_files':
341
+ return { count: Array.isArray(result.files) ? result.files.length : 0 };
342
+ case 'search_in_file':
343
+ return { count: Array.isArray(result.matches) ? result.matches.length : 0 };
344
+ case 'replace_in_file':
345
+ return { count: typeof result.count === 'number' ? result.count : 0 };
346
+ case 'http_get':
347
+ case 'download':
348
+ return {
349
+ status_code: result.status_code,
350
+ bytes: typeof result.bytes === 'number'
351
+ ? result.bytes
352
+ : (result.body ? Buffer.byteLength(String(result.body), 'utf8') : 0),
353
+ };
354
+ case 'file_stat':
355
+ return {
356
+ bytes: result.size_kb ? Math.round(parseFloat(result.size_kb) * 1024) : 0,
357
+ kind: result.type || null,
358
+ };
359
+ default:
360
+ return null;
361
+ }
362
+ }
363
+
364
+ // Turn a [action, arg1, arg2, …] call tuple into the `attrs` bag that
365
+ // formatToolLine looks up when building the operation string. Centralized
366
+ // here so the per-tag positional-arg contract is written down in exactly
367
+ // one place — any new tool added to the agent-loop tuple schema also gets
368
+ // its attrs mapping here.
369
+ function _attrsFromCall(call) {
370
+ if (!Array.isArray(call) || call.length === 0) return {};
371
+ const [tag, ...args] = call;
372
+ switch (tag) {
373
+ case 'shell':
374
+ case 'exec':
375
+ return { command: args[0] || '' };
376
+ case 'read':
377
+ case 'read_file':
378
+ case 'list_dir':
379
+ case 'delete_file':
380
+ case 'make_dir':
381
+ case 'remove_dir':
382
+ case 'file_stat':
383
+ return { path: args[0] || '' };
384
+ case 'write':
385
+ case 'write_file':
386
+ case 'create_file':
387
+ case 'append':
388
+ case 'append_file':
389
+ return { path: args[0] || '', content: args[1] || '' };
390
+ case 'upload':
391
+ return { path: args[0] || '' };
392
+ case 'move_file':
393
+ case 'copy_file':
394
+ return { src: args[0] || '', dst: args[1] || '' };
395
+ case 'edit_file':
396
+ return { path: args[0] || '', line: args[1], content: args[2] || '' };
397
+ case 'search_files':
398
+ return { pattern: args[0] || '', dir: args[1] || '.' };
399
+ case 'search_in_file':
400
+ return { path: args[0] || '', pattern: args[1] || '' };
401
+ case 'replace_in_file':
402
+ return { path: args[0] || '', search: args[1] || '', replace: args[2] || '', flags: args[3] || '' };
403
+ case 'get_env':
404
+ return { name: args[0] || '' };
405
+ case 'set_env':
406
+ return { name: args[0] || '', value: args[1] || '' };
407
+ case 'download':
408
+ case 'http_get':
409
+ return { url: args[0] || '' };
410
+ case 'ask_user':
411
+ return { question: args[0] || '' };
412
+ case 'store_memory':
413
+ return { key: args[0] || '', value: args[1] || '' };
414
+ case 'recall_memory':
415
+ return { key: args[0] || '' };
416
+ default:
417
+ return {};
418
+ }
419
+ }
420
+
284
421
  function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agentExecFile, ui, getConfig }) {
285
422
  const { BOLD, FG_DARK, FG_GRAY, FG_TEAL, FG_YELLOW, RST, THEME, getCols } = ui;
286
423
 
@@ -466,6 +603,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
466
603
  // for one-shot/non-TTY flows where there's no UI to host the block.
467
604
  const emitDebug = (block) => {
468
605
  if (typeof cb.onDebug === 'function') cb.onDebug(block);
606
+ // audit: allowed — stderr debug under --debug flag (no UI hosting available).
469
607
  else process.stderr.write('\n' + block + '\n');
470
608
  };
471
609
 
@@ -479,12 +617,6 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
479
617
 
480
618
  const activeSystemPrompt = overrideSystemPrompt !== null ? overrideSystemPrompt : getSystemPrompt(nativeTools);
481
619
 
482
- // Response contract: every model response must end with a tool call or
483
- // <final_answer>...</final_answer>. Anything else is degraded — push a
484
- // synthetic nudge and retry, capped to prevent runaway loops.
485
- const MAX_DEGRADED_RETRIES = 2;
486
- let degradedRetries = 0;
487
-
488
620
  for (let iteration = 0; iteration < maxIterations; iteration++) {
489
621
  if (isAborted()) break;
490
622
  const linePrefix = `${FG_TEAL}${BOLD}◆ ${RST}`;
@@ -582,6 +714,14 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
582
714
  lastApiErr = null;
583
715
  break;
584
716
  } catch (err) {
717
+ // User-initiated abort: not a transient failure. Skip the retry
718
+ // counter, the "Retrying (N/M)..." status update, the debug dump,
719
+ // and the post-loop error surface. The "Interrupted." feedback is
720
+ // already shown by the input-field abort listener.
721
+ if (controller.signal.aborted || isAborted() || isAbortError(err)) {
722
+ lastApiErr = null;
723
+ break;
724
+ }
585
725
  lastApiErr = err;
586
726
  if (debug) {
587
727
  const status = err.statusCode ? `HTTP ${err.statusCode}` : 'network error';
@@ -621,13 +761,25 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
621
761
  }
622
762
  }
623
763
  cb.onRetry?.(attempt + 1, MAX_RETRIES);
624
- await new Promise((r) => setTimeout(r, delayMs));
764
+ await abortableSleep(delayMs, controller.signal);
765
+ // Ctrl+C pressed during backoff: bail without the next attempt.
766
+ if (controller.signal.aborted || isAborted()) {
767
+ lastApiErr = null;
768
+ break;
769
+ }
625
770
  }
626
771
  }
627
772
  } finally {
628
773
  clearInterval(abortWatcher);
629
774
  }
630
775
 
776
+ // User-initiated abort: exit the turn quietly. Skip the empty-reply
777
+ // "connection dropped" warning below — the abort listener already
778
+ // surfaced "Interrupted." and the outer prompt will return.
779
+ if (controller.signal.aborted || isAborted()) {
780
+ break;
781
+ }
782
+
631
783
  if (lastApiErr) {
632
784
  if (cb.onError) cb.onError(lastApiErr);
633
785
  break;
@@ -652,13 +804,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
652
804
  if (cb.onError) {
653
805
  cb.onError({ message: warnMsg, isWarning: true });
654
806
  } else {
655
- // Non-TUI fallback (cb.onError is unset only for one-shot CLI
656
- // commands like `cmdCode`, which don't run the shared live-region
657
- // writer). Direct stdout write is safe here: no status-bar timer
658
- // or bubble renderer is competing for stdout.
659
- process.stdout.write(
660
- `\n ${THEME.warn}⚠ ${warnMsg}${THEME.reset}\n`
661
- );
807
+ messages.sysWarn(warnMsg);
662
808
  }
663
809
  }
664
810
 
@@ -729,9 +875,6 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
729
875
  }
730
876
  const isNativeCall = nativeToolCalls.length > 0;
731
877
  const cleanedReply = cleanAssistantContent(reply);
732
- // Protocol contract: a valid response ends with a tool call OR a
733
- // <final_answer>...</final_answer> block. Anything else is degraded.
734
- const hasFinal = /<final_answer\b[\s\S]*?<\/final_answer>/i.test(reply);
735
878
 
736
879
  if (debug && result) {
737
880
  const lastUserMsg = [...messagesWithSystem].reverse().find((m) => m.role === 'user');
@@ -864,46 +1007,25 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
864
1007
  continue;
865
1008
  }
866
1009
 
867
- if (hasFinal) {
868
- // Model declared it is done honor the protocol and terminate.
869
- // An empty <final_answer></final_answer> is the model's choice;
870
- // we don't police content.
871
- degradedRetries = 0;
872
- break;
873
- }
874
-
875
- // Protocol violation: neither a tool call nor a <final_answer>. Nudge
876
- // the model to restate in-protocol, capped to prevent runaway loops.
877
- if (degradedRetries >= MAX_DEGRADED_RETRIES) {
878
- if (cb.onError) {
879
- cb.onError({ message: `Agent violated the response contract after ${MAX_DEGRADED_RETRIES} retries — no tool call or <final_answer> block emitted. Stopping.`, isWarning: false });
880
- }
881
- break;
882
- }
883
- degradedRetries++;
884
- if (cb.onError) {
885
- cb.onError({ message: 'Response missing tool call or <final_answer> — nudging model to retry in-protocol.', isWarning: true });
886
- }
887
- messages.push({
888
- role: 'user',
889
- content: 'Your previous response contained neither a tool call nor a <final_answer> block, which violates the response contract. If you need to perform an action, emit the appropriate tool tag now. If you are done, wrap your reply in <final_answer>...</final_answer>. Do not describe intended actions in prose.',
890
- });
891
- continue;
1010
+ // No tool calls and non-empty content (the empty case was already
1011
+ // handled by the `!reply` guard above). This is the model's final
1012
+ // answer for this turn — end the loop and return control to the user.
1013
+ break;
892
1014
  }
893
- // Non-degraded response (has tool calls) — reset the retry counter.
894
- degradedRetries = 0;
895
1015
  if (isAborted()) break;
896
1016
 
897
1017
  if (!cb.onToolStart) {
898
- // Non-TUI fallback: only one-shot CLI commands leave cb.onToolStart
899
- // unset. The shared live-region writer isn't running, so a direct
900
- // write here can't interleave with a bubble/status redraw.
901
- process.stdout.write(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}\n`);
1018
+ writer.scrollback(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
902
1019
  }
903
1020
 
904
1021
  const results = [];
905
1022
  const debugEntries = debug ? [] : null;
906
1023
  let aborted = false;
1024
+ // Per-invocation id. Paired across onToolStart/onToolEnd so the UI
1025
+ // layer can track each concurrent tool's activity-region slot and
1026
+ // commit its final line atomically via endActivity. Monotonic —
1027
+ // never reused even if the agent runs the same tag twice.
1028
+ let invocationCounter = 0;
907
1029
 
908
1030
  for (const call of toolCalls) {
909
1031
  if (isAborted()) { aborted = true; break; }
@@ -911,8 +1033,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
911
1033
  const tag = call[0] || 'unknown';
912
1034
  const arg = call[1] || '';
913
1035
  const toolStart = Date.now();
1036
+ const invocationId = `tool-${iteration}-${invocationCounter++}-${tag}`;
1037
+ const attrs = _attrsFromCall(call);
1038
+ const startCtx = { id: invocationId, call, attrs, startedAt: toolStart };
914
1039
 
915
- if (cb.onToolStart) cb.onToolStart(tag, arg);
1040
+ if (cb.onToolStart) cb.onToolStart(tag, arg, startCtx);
916
1041
 
917
1042
  try {
918
1043
  if (tag === 'shell') {
@@ -920,7 +1045,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
920
1045
  const ms = Date.now() - toolStart;
921
1046
  if (shellResult.stderr === 'Permission denied by user') {
922
1047
  const resultStr = `Command \`${arg}\`: Permission denied by user.`;
923
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
1048
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
924
1049
  results.push(resultStr);
925
1050
  if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
926
1051
  aborted = true;
@@ -929,7 +1054,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
929
1054
  let out = shellResult.stdout;
930
1055
  if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
931
1056
  const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
932
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
1057
+ const meta = _metaForTool(tag, shellResult);
1058
+ const error = shellResult.exit_code !== 0
1059
+ ? { message: `exit ${shellResult.exit_code}`, code: shellResult.exit_code }
1060
+ : null;
1061
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
933
1062
  results.push(resultStr);
934
1063
  if (debugEntries) debugEntries.push({
935
1064
  tag,
@@ -948,14 +1077,18 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
948
1077
 
949
1078
  if (fileResult.error === 'Permission denied') {
950
1079
  const resultStr = `${tag} ${call[1] || ''}: Permission denied by user.`;
951
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
1080
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
952
1081
  results.push(resultStr);
953
1082
  if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
954
1083
  aborted = true;
955
1084
  break;
956
1085
  } else {
957
1086
  const resultStr = formatFileResult(call, fileResult);
958
- if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
1087
+ const meta = _metaForTool(tag, fileResult);
1088
+ const error = fileResult.error
1089
+ ? { message: fileResult.error, code: fileResult.error_code || null }
1090
+ : null;
1091
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
959
1092
  results.push(resultStr);
960
1093
  if (debugEntries) debugEntries.push({
961
1094
  tag,
@@ -968,12 +1101,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
968
1101
  }
969
1102
  } catch (err) {
970
1103
  const ms = Date.now() - toolStart;
971
- if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms);
1104
+ if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms, { id: invocationId, call, attrs, meta: null, error: err });
972
1105
  if (cb.onError) {
973
1106
  cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
974
1107
  } else {
975
- // Non-TUI fallback — see comment on the onToolStart branch above.
976
- process.stdout.write(`\n ${THEME.warn}⚠ Tool error (${tag}): ${err.message}${THEME.reset}\n`);
1108
+ messages.toolError(tag, err.message);
977
1109
  }
978
1110
  logToolCall(tag, { args: call.slice(1) }, false, 'error');
979
1111
  results.push(`${tag}: Error — ${err.message}`);
@@ -1033,8 +1165,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
1033
1165
  if (cb.onError) {
1034
1166
  cb.onError({ message: warnMsg, isWarning: true });
1035
1167
  } else {
1036
- // Non-TUI fallback — see comment above on the Found-actions path.
1037
- process.stdout.write(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}${warnMsg}${RST}`);
1168
+ messages.sysWarn(warnMsg);
1038
1169
  }
1039
1170
  // Push whatever results accumulated before the denial so the LLM has
1040
1171
  // context if the user asks to continue.
package/lib/api.js CHANGED
@@ -6,6 +6,8 @@ const { URL } = require('url');
6
6
 
7
7
  const { buildToolsSchema, isUIActive } = require('./tools');
8
8
  const { TOOL_SPECS } = require('./tool_specs');
9
+ const writer = require('./ui/writer');
10
+ const messages = require('./ui/messages');
9
11
 
10
12
  function createApiClient({ getConfig, saveConfig, ui }) {
11
13
  const {
@@ -17,7 +19,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
17
19
  FG_RED,
18
20
  FG_TEAL,
19
21
  RST,
20
- StatusBar,
21
22
  StreamRenderer,
22
23
  } = ui;
23
24
 
@@ -472,10 +473,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
472
473
  const toolCallAcc = [];
473
474
  const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
474
475
  if (!silent) {
476
+ // audit: allowed — non-TUI streaming setup, must interleave with StreamRenderer sync writes.
475
477
  process.stdout.write('\n');
476
478
  renderer._linesWritten = 1;
477
479
  }
478
- let firstContentToken = true;
479
480
  let lineBuffer = '';
480
481
 
481
482
  function escapeXml(s) {
@@ -517,14 +518,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
517
518
  }));
518
519
  if (!nativeTools) appendToolCallsXml();
519
520
  if (!silent) renderer.flush();
520
- const elapsed = (Date.now() - startTime) / 1000;
521
- const tps = tokenCount / (elapsed || 1);
522
- if (StatusBar.current) {
523
- let latency = `${Math.round(tps)} tok/s · ${elapsed.toFixed(1)}s`;
524
- if (reasoningText) latency += ` · ${estimateTokens(reasoningText)} think`;
525
- StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
526
- StatusBar.current.render();
527
- }
528
521
  // Fallback for endpoints that don't honor stream_options.include_usage:
529
522
  // estimate prompt/completion tokens locally so the status bar still updates.
530
523
  let usage = streamUsage;
@@ -613,6 +606,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
613
606
  if (!inReasoning) {
614
607
  inReasoning = true;
615
608
  if (showThink && !uiActive) {
609
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
616
610
  process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
617
611
  renderer._linesWritten++;
618
612
  }
@@ -620,6 +614,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
620
614
  reasoningText += reasoning;
621
615
  tokenCount++;
622
616
  if (showThink && !uiActive) {
617
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
623
618
  process.stdout.write(`${FG_DARK}${DIM}${reasoning}${RST}`);
624
619
  }
625
620
  }
@@ -633,14 +628,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
633
628
  if (tc.id) toolCallAcc[idx].id = tc.id;
634
629
  if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
635
630
  if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
636
- // When the model streams purely via delta.tool_calls (no
637
- // delta.content), firstContentToken never flips, so the status
638
- // bar stays on "Thinking…" for the entire tool-call stream.
639
- // Surface each new tool slot the moment its name is known so
640
- // the user sees "Using tool: <name>" instead of a frozen UI.
641
- if (isNew && StatusBar.current && toolCallAcc[idx].name) {
642
- StatusBar.current.update('tool', `Using tool: ${toolCallAcc[idx].name}`);
643
- }
644
631
  }
645
632
  }
646
633
 
@@ -649,28 +636,18 @@ function createApiClient({ getConfig, saveConfig, ui }) {
649
636
  if (inReasoning) {
650
637
  inReasoning = false;
651
638
  if (showThink && !silent) {
639
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
652
640
  process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
653
641
  renderer._linesWritten++;
654
642
  }
655
643
  }
656
644
  if (onToken) {
657
- if (firstContentToken) {
658
- firstContentToken = false;
659
- if (StatusBar.current) StatusBar.current.update({ status: 'streaming' });
660
- }
661
645
  onToken(content);
662
646
  } else {
663
647
  renderer.feed(content);
664
648
  }
665
649
  fullText += content;
666
650
  tokenCount++;
667
- if (tokenCount % 20 === 0 && StatusBar.current) {
668
- const elapsedSec = (Date.now() - startTime) / 1000 || 0.001;
669
- StatusBar.current.liveUpdate({
670
- tokens: `${tokenCount} tok`,
671
- latency: `${Math.round(tokenCount / elapsedSec)} tok/s`,
672
- });
673
- }
674
651
  }
675
652
  } catch {}
676
653
  }
@@ -712,7 +689,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
712
689
  },
713
690
  }, body);
714
691
  } catch (error) {
715
- console.log(` ${FG_RED}✗ ${error.message}${RST}`);
692
+ messages.netError(error.message);
716
693
  return '';
717
694
  }
718
695
 
@@ -724,7 +701,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
724
701
  });
725
702
  res.on('end', () => {
726
703
  if (res.statusCode !== 200) {
727
- console.log(` ${FG_RED}✗ Error: HTTP ${res.statusCode} — ${data}${RST}`);
704
+ messages.netError(`HTTP ${res.statusCode} — ${data}`);
728
705
  resolve('');
729
706
  return;
730
707
  }
@@ -732,15 +709,15 @@ function createApiClient({ getConfig, saveConfig, ui }) {
732
709
  try {
733
710
  const parsed = JSON.parse(data);
734
711
  const content = parsed.choices[0].message.content;
735
- console.log(content);
712
+ writer.scrollback(content);
736
713
  resolve(content);
737
714
  } catch (error) {
738
- console.log(` ${FG_RED}✗ Parse error: ${error.message}${RST}`);
715
+ messages.netError(`Parse error: ${error.message}`);
739
716
  resolve('');
740
717
  }
741
718
  });
742
719
  res.on('error', (error) => {
743
- console.log(` ${FG_RED}✗ ${error.message}${RST}`);
720
+ messages.netError(error.message);
744
721
  resolve('');
745
722
  });
746
723
  });