@semalt-ai/code 1.8.3 → 1.8.5

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/commands.js CHANGED
@@ -3,11 +3,72 @@
3
3
  const fs = require('fs');
4
4
 
5
5
  const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
6
- const { configShow } = require('./config');
6
+ const { configShow, isNativeToolsActive } = require('./config');
7
7
  const { getSystemPrompt } = require('./prompts');
8
8
  const { SessionStorage } = require('./storage');
9
9
  const { getSkippedOps, setUIActive } = require('./tools');
10
10
  const { AUDIT_LOG } = require('./audit');
11
+ const { formatToolLine } = require('./ui/format');
12
+ const writerModule = require('./ui/writer');
13
+ const writer = writerModule;
14
+ const msgs = require('./ui/messages');
15
+ const dbg = require('./debug');
16
+
17
+ // Drop assistant.tool_calls and role:tool messages whose ids don't pair up.
18
+ // A loaded chat may contain role:tool with empty/missing tool_call_id (legacy
19
+ // rows, dropped fields in transit) or assistant.tool_calls without a matching
20
+ // tool response (truncated turn). Either side without its partner produces a
21
+ // 400 from strict providers like MiniMax — the validator in api.js will throw
22
+ // — so we strip both sides of the orphan pair before sending.
23
+ function cleanOrphanedToolMessages(msgs) {
24
+ const calledIds = new Set();
25
+ const respondedIds = new Set();
26
+ for (const m of msgs) {
27
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
28
+ for (const tc of m.tool_calls) {
29
+ if (tc && tc.id) calledIds.add(tc.id);
30
+ }
31
+ } else if (m.role === 'tool' && m.tool_call_id) {
32
+ respondedIds.add(m.tool_call_id);
33
+ }
34
+ }
35
+ const paired = new Set();
36
+ for (const id of calledIds) if (respondedIds.has(id)) paired.add(id);
37
+
38
+ let droppedTool = 0;
39
+ let droppedAssistantCalls = 0;
40
+ let droppedAssistantMsgs = 0;
41
+ const out = [];
42
+ for (const m of msgs) {
43
+ if (m.role === 'tool') {
44
+ if (!m.tool_call_id || !paired.has(m.tool_call_id)) { droppedTool++; continue; }
45
+ out.push(m);
46
+ } else if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
47
+ const kept = m.tool_calls.filter((tc) => tc && tc.id && paired.has(tc.id));
48
+ droppedAssistantCalls += m.tool_calls.length - kept.length;
49
+ const hasContent = typeof m.content === 'string' && m.content.trim().length > 0;
50
+ if (kept.length === 0 && !hasContent) { droppedAssistantMsgs++; continue; }
51
+ const next = { ...m };
52
+ if (kept.length > 0) next.tool_calls = kept;
53
+ else delete next.tool_calls;
54
+ out.push(next);
55
+ } else {
56
+ out.push(m);
57
+ }
58
+ }
59
+ return { messages: out, droppedTool, droppedAssistantCalls, droppedAssistantMsgs };
60
+ }
61
+
62
+ function reconstructLoadedMessage(m) {
63
+ const msg = { role: m.role, content: m.content };
64
+ if (m.tool_call_id !== undefined && m.tool_call_id !== null && m.tool_call_id !== '') {
65
+ msg.tool_call_id = m.tool_call_id;
66
+ }
67
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
68
+ msg.tool_calls = m.tool_calls;
69
+ }
70
+ return msg;
71
+ }
11
72
 
12
73
  function formatTimeAgo(ts) {
13
74
  const diffMs = Date.now() - ts;
@@ -41,9 +102,8 @@ function createCommands({
41
102
  FG_TEAL,
42
103
  FG_YELLOW,
43
104
  RST,
44
- StatusBar,
105
+ approxTokens,
45
106
  getCols,
46
- hr,
47
107
  boxLine,
48
108
  interactiveSelect,
49
109
  createUI,
@@ -100,29 +160,54 @@ function createCommands({
100
160
  return null;
101
161
  }
102
162
 
103
- function printBanner() {
104
- const w = Math.min(getCols() - 4, 60);
105
- console.log();
106
- console.log(` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`);
107
- console.log(boxLine('', w));
108
- console.log(boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w));
109
- console.log(boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w));
110
- console.log(boxLine('', w));
111
- console.log(` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`);
112
- console.log();
163
+ // Pick the first dashboard model when the user is authenticated but has
164
+ // not selected one yet. Persists credentials to config and returns
165
+ // { name, modelId } on success; null otherwise (not logged in, already
166
+ // selected, empty list, or API error).
167
+ async function ensureDefaultModel() {
168
+ const config = getConfig();
169
+ if (!config.auth_token) return null;
170
+ if (config.default_model && config.dashboard_model_id) return null;
171
+ let response;
172
+ try { response = await dashboardListModels(); } catch { return null; }
173
+ const models = Array.isArray(response && response.models) ? response.models : [];
174
+ if (!models.length) return null;
175
+ const first = models[0];
176
+ let credResp;
177
+ try { credResp = await dashboardGetModelForCli(first.id); } catch { return null; }
178
+ const model = credResp && credResp.model ? credResp.model : null;
179
+ if (!model) return null;
180
+ const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
181
+ || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
182
+ const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
183
+ if (contextLength !== null) updated.context_length = contextLength;
184
+ setConfig(updated);
185
+ return { name: model.name, modelId: model.model_id };
113
186
  }
114
187
 
115
188
  async function cmdChat(opts) {
189
+ await ensureDefaultModel();
190
+
191
+ // Build the three end-of-session artifacts that teardown emits as
192
+ // scrollback. Returning them as a plain object lets both exit paths
193
+ // (/exit submit and Ctrl+C onInterrupt) route through writer.teardown,
194
+ // which is the only place that can append them below the erased live
195
+ // region in a single atomic write.
196
+ function buildExitArtifacts() {
197
+ return {
198
+ summary: sessionMetrics ? sessionMetrics.summary() : '',
199
+ resumeHint: currentChatId !== null
200
+ ? ` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`
201
+ : '',
202
+ goodbye: ` ${FG_GRAY}Goodbye!${RST}`,
203
+ };
204
+ }
205
+
116
206
  const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
117
207
  showThink: opts.showThink || false,
118
208
  onInterrupt: (destroyFn) => {
119
209
  saveSession();
120
- destroyFn();
121
- if (sessionMetrics) console.log('\n' + sessionMetrics.summary());
122
- if (currentChatId !== null) {
123
- console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
124
- }
125
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
210
+ destroyFn(buildExitArtifacts());
126
211
  process.exit(0);
127
212
  },
128
213
  });
@@ -157,12 +242,20 @@ function createCommands({
157
242
  let currentModel = opts.model || getConfig().default_model;
158
243
  let resolvedTokenLimit = await resolveTokenLimit(currentModel);
159
244
  statusBar.setModel(currentModel);
245
+ // Seed the context indicator with the profile's limit up-front so it
246
+ // renders "0 / 200,000 tok (0%)" before the first API response, instead
247
+ // of appearing out of thin air once a turn completes.
248
+ statusBar.setContextLimit(resolvedTokenLimit);
160
249
  let sessionMetrics = null;
161
250
  // system prompt is prepended fresh on every API call in agent.js — never stored in history
162
251
  let messages = [];
163
252
  let currentChatId = null;
164
253
  let savedUpTo = 0;
165
- let debugMode = !!opts.debug;
254
+ // The agent loop's per-iteration `formatDebugBlock` runs whenever any
255
+ // debug mode is active. In simple mode the block is rendered as a TUI
256
+ // chat bubble (cb.onDebug → addMessage). In file mode emitDebug routes
257
+ // the block to the debug file instead, keeping the TUI clean.
258
+ let debugMode = dbg.isActive();
166
259
 
167
260
  // Resolve system prompt override from --system-prompt file if provided
168
261
  let resolvedSystemPrompt = null;
@@ -252,7 +345,15 @@ function createCommands({
252
345
  if (currentChatId === null) return;
253
346
  const newMessages = messages.slice(savedUpTo).filter((m) => m.role !== 'system');
254
347
  if (!newMessages.length) return;
255
- try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
348
+ try {
349
+ const resp = await dashboardSaveMessages(currentChatId, newMessages);
350
+ savedUpTo = messages.length;
351
+ if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
352
+ msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
353
+ }
354
+ } catch (err) {
355
+ msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
356
+ }
256
357
  }
257
358
 
258
359
  function displayLoadedMessages(loadedMessages) {
@@ -263,7 +364,7 @@ function createCommands({
263
364
  const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
264
365
 
265
366
  if (m.role === 'tool') {
266
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: raw, ts });
367
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
267
368
  continue;
268
369
  }
269
370
 
@@ -272,7 +373,7 @@ function createCommands({
272
373
  .replace(/^Tool execution results[^\n]*\n+/, '')
273
374
  .replace(/\n+Continue with the task\.[\s\S]*$/, '')
274
375
  .trim();
275
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: body || raw, ts });
376
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
276
377
  continue;
277
378
  }
278
379
 
@@ -281,6 +382,34 @@ function createCommands({
281
382
  }
282
383
  }
283
384
 
385
+ // After loading a saved chat (via --resume, /history, or /chats), the
386
+ // status bar has no API-reported prompt_tokens to display until the next
387
+ // turn completes — the indicator would sit at 0 until the user sends a
388
+ // follow-up. Seed it with a client-side estimate of the loaded messages
389
+ // using the same approxTokens estimator the live `addPendingTokens` path
390
+ // uses, then push it through `updateMetrics({contextTokens})` — the same
391
+ // setter agent.js wires up via cb.onMetricsUpdate. The next real turn
392
+ // overwrites this with the API's authoritative prompt_tokens.
393
+ function seedContextFromMessages() {
394
+ let total = 0;
395
+ for (const m of messages) {
396
+ if (typeof m.content === 'string') total += approxTokens(m.content);
397
+ }
398
+ statusBar.updateMetrics({ contextTokens: total });
399
+ }
400
+
401
+ function emitCleanupWarning(cleanup) {
402
+ if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
403
+ const parts = [];
404
+ if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
405
+ if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
406
+ if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
407
+ chatHistory.addMessage({
408
+ role: 'system',
409
+ content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
410
+ });
411
+ }
412
+
284
413
  // --resume: load previous chat
285
414
  if (opts.resume) {
286
415
  const resumeId = parseInt(opts.resume, 10);
@@ -288,22 +417,28 @@ function createCommands({
288
417
  try {
289
418
  const chatData = await dashboardGetChat(resumeId);
290
419
  const loaded = chatData && chatData.messages ? chatData.messages : [];
291
- for (const m of loaded) messages.push({ role: m.role, content: m.content });
420
+ for (const m of loaded) messages.push(reconstructLoadedMessage(m));
421
+ const cleanup = cleanOrphanedToolMessages(messages);
422
+ messages = cleanup.messages;
292
423
  currentChatId = resumeId;
293
424
  savedUpTo = messages.length;
294
425
  const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
295
426
  displayLoadedMessages(loaded);
296
427
  chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
428
+ emitCleanupWarning(cleanup);
429
+ seedContextFromMessages();
297
430
  } catch (error) {
298
431
  chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
299
432
  }
300
433
  }
301
434
  }
302
435
 
303
- // Pending selection state (for in-chat /history, /models, /chats)
436
+ // Pending selection state (for in-chat /history, /models, /chats).
437
+ // The picker renders into the writer's modal region — same band as the
438
+ // permission picker — so navigation redraws in place and only the final
439
+ // selection (or cancellation) leaves a line in scrollback.
304
440
  let pendingAction = null;
305
441
  const PAGE_SIZE = 5;
306
- let listMsg = null;
307
442
 
308
443
  function getNavSearchText(type, item) {
309
444
  if (type === 'history') {
@@ -361,31 +496,27 @@ function createCommands({
361
496
  return parts.join('\n');
362
497
  }
363
498
 
364
- function collapseListMsg(type, item) {
365
- if (!listMsg) return;
366
- const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
367
- listMsg.content = `${titleMap[type] || type} · ${buildItemDetail(type, item)}`;
368
- chatHistory.collapseById(listMsg.id);
369
- listMsg = null;
499
+ function collapseListMsg(_type, _item) {
500
+ // Modal is transient — clearing it removes the picker from view; the
501
+ // selection's success line is emitted to scrollback by
502
+ // handlePendingSelection.
503
+ writer.clearModal();
370
504
  }
371
505
 
372
506
  function showPendingStep() {
373
507
  if (!pendingAction) return;
374
- const content = buildListContent();
375
- if (!listMsg) {
376
- listMsg = { role: 'system', content, id: `list-${Date.now()}` };
377
- chatHistory.addMessage(listMsg);
378
- } else {
379
- listMsg.content = content;
380
- chatHistory.rerenderById(listMsg.id);
381
- }
508
+ const lines = buildListContent().split('\n');
509
+ // Match the system-message bubble look so the modal reads as part of
510
+ // the same chat block: muted bullet on the title row, indented
511
+ // continuations underneath.
512
+ const modalLines = lines.length > 0
513
+ ? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
514
+ : [];
515
+ writer.setModal(modalLines);
382
516
  }
383
517
 
384
518
  function finalizeListMsg() {
385
- if (listMsg) {
386
- chatHistory.removeById(listMsg.id);
387
- listMsg = null;
388
- }
519
+ writer.clearModal();
389
520
  }
390
521
 
391
522
  function activateNavCapture() {
@@ -440,26 +571,35 @@ function createCommands({
440
571
  if (type === 'history') {
441
572
  const loaded = storage.load(activeItems[idx].id);
442
573
  if (loaded) {
443
- messages = (loaded.messages || []).filter((m) => m.role !== 'system');
574
+ const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
575
+ const cleanup = cleanOrphanedToolMessages(filtered);
576
+ messages = cleanup.messages;
444
577
  session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
445
578
  currentChatId = null; savedUpTo = 0;
446
579
  if (loaded.model && loaded.model !== currentModel) {
447
580
  currentModel = loaded.model;
448
581
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
449
582
  statusBar.setModel(currentModel);
583
+ statusBar.setContextLimit(resolvedTokenLimit);
450
584
  }
451
585
  displayLoadedMessages(messages);
452
586
  chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
587
+ emitCleanupWarning(cleanup);
588
+ seedContextFromMessages();
453
589
  }
454
590
  } else if (type === 'chats') {
455
591
  const selectedChat = activeItems[idx];
456
592
  try {
457
593
  const chatData = await dashboardGetChat(selectedChat.id);
458
594
  const loaded = chatData && chatData.messages ? chatData.messages : [];
459
- messages = loaded.map((m) => ({ role: m.role, content: m.content }));
595
+ const reconstructed = loaded.map(reconstructLoadedMessage);
596
+ const cleanup = cleanOrphanedToolMessages(reconstructed);
597
+ messages = cleanup.messages;
460
598
  currentChatId = selectedChat.id; savedUpTo = messages.length;
461
599
  displayLoadedMessages(loaded);
462
600
  chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
601
+ emitCleanupWarning(cleanup);
602
+ seedContextFromMessages();
463
603
  } catch (err) {
464
604
  chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
465
605
  }
@@ -478,6 +618,7 @@ function createCommands({
478
618
  currentModel = model.model_id;
479
619
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
480
620
  statusBar.setModel(currentModel);
621
+ statusBar.setContextLimit(resolvedTokenLimit);
481
622
  currentChatId = null;
482
623
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
483
624
  statusBar.update('idle');
@@ -535,7 +676,7 @@ function createCommands({
535
676
  // Exit
536
677
  if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
537
678
  saveSession();
538
- destroy();
679
+ destroy(buildExitArtifacts());
539
680
  resolveExit();
540
681
  return;
541
682
  }
@@ -569,6 +710,7 @@ function createCommands({
569
710
  }
570
711
 
571
712
  if (text === '/history') {
713
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
572
714
  const sessions = storage.list();
573
715
  if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
574
716
  refreshInputSearchItems();
@@ -617,6 +759,14 @@ function createCommands({
617
759
  inputField.setDisabled(true);
618
760
  statusBar.update('thinking', 'Starting login...');
619
761
  await _loginFlow(chatHistory, statusBar);
762
+ const picked = await ensureDefaultModel();
763
+ if (picked) {
764
+ currentModel = picked.modelId;
765
+ resolvedTokenLimit = await resolveTokenLimit(currentModel);
766
+ statusBar.setModel(currentModel);
767
+ statusBar.setContextLimit(resolvedTokenLimit);
768
+ chatHistory.addMessage({ role: 'system', content: `✓ Model → ${picked.name} (${picked.modelId})` });
769
+ }
620
770
  statusBar.update('idle');
621
771
  inputField.setDisabled(false);
622
772
  return;
@@ -666,6 +816,7 @@ function createCommands({
666
816
  }
667
817
 
668
818
  if (text === '/models') {
819
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
669
820
  inputField.setDisabled(true);
670
821
  statusBar.update('thinking', 'Loading models...');
671
822
  try {
@@ -688,14 +839,17 @@ function createCommands({
688
839
  }
689
840
 
690
841
  if (text === '/model') {
842
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
691
843
  chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
692
844
  return;
693
845
  }
694
846
 
695
847
  if (text.startsWith('/model ')) {
848
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
696
849
  currentModel = text.slice(7).trim();
697
850
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
698
851
  statusBar.setModel(currentModel);
852
+ statusBar.setContextLimit(resolvedTokenLimit);
699
853
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
700
854
  return;
701
855
  }
@@ -722,7 +876,8 @@ function createCommands({
722
876
  }
723
877
 
724
878
  if (text === '/prompt') {
725
- const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt();
879
+ const nativeTools = isNativeToolsActive(currentModel);
880
+ const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt(nativeTools);
726
881
  const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
727
882
  const mode = getConfig().system_prompt_mode || 'system_role';
728
883
  chatHistory.addMessage({
@@ -765,9 +920,12 @@ function createCommands({
765
920
  tail = '\nNo audit log found.';
766
921
  }
767
922
 
923
+ const sink = dbg.isFile()
924
+ ? `file (${dbg.getMode()} mode)`
925
+ : 'inline chat history';
768
926
  chatHistory.addMessage({
769
927
  role: 'system',
770
- content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors stderr)${tail}`,
928
+ content: `Debug output: ${debugMode ? 'ON' : 'OFF'} → ${sink}${tail}`,
771
929
  });
772
930
  return;
773
931
  }
@@ -803,6 +961,11 @@ function createCommands({
803
961
  inputField.setDisabled(true);
804
962
  chatHistory.addMessage({ role: 'user', content: text });
805
963
  statusBar.update('thinking', 'Thinking...');
964
+ // Bump the context-size indicator with this user message's approximate
965
+ // token count. It'll be overwritten with the exact prompt_tokens from
966
+ // the API response when the first turn completes — this just keeps the
967
+ // indicator reactive in the gap before that.
968
+ statusBar.addPendingTokens(approxTokens(text));
806
969
  await createChatIfNeeded(text);
807
970
  messages.push({ role: 'user', content: text });
808
971
 
@@ -845,9 +1008,14 @@ function createCommands({
845
1008
  chatHistory.addMessage({ role: 'think', content });
846
1009
  statusBar.update('streaming', 'Streaming response');
847
1010
  },
848
- onToolStart: (tag, input, attrs) => {
1011
+ onPermissionAsk: (tag, input) => {
1012
+ // Status-bar update fires while the permission picker is open so
1013
+ // the user can see what's pending in the side label, not just
1014
+ // inside the modal. Mirrors the labels onToolStart uses post-grant
1015
+ // — the next streaming/idle state will overwrite this when the
1016
+ // picker closes (whether granted or denied).
849
1017
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
850
- const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
1018
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
851
1019
  const isDownload = tag === 'download' || tag === 'http_get';
852
1020
  if (isDownload) {
853
1021
  statusBar.update('waiting_download', `Waiting for download: ${short}`);
@@ -855,19 +1023,79 @@ function createCommands({
855
1023
  statusBar.update('tool', `${actionLabel}: ${short}`);
856
1024
  }
857
1025
  },
858
- onToolEnd: (tag, result, durationMs) => {
859
- const isError = typeof result === 'string' && result.startsWith('Error');
860
- if (isError) {
861
- chatHistory.addMessage({
862
- role: 'tool',
863
- tag,
864
- content: `${tag} ✕ [${durationMs}ms]`,
865
- output: typeof result === 'string' && result.trim() ? result : null,
866
- });
867
- statusBar.update('streaming', 'Streaming response');
1026
+ onToolStart: (tag, input, ctx) => {
1027
+ const actionLabel = TAG_REGISTRY[tag]?.label || tag;
1028
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
1029
+ const isDownload = tag === 'download' || tag === 'http_get';
1030
+ if (isDownload) {
1031
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
868
1032
  } else {
869
- const actionLabel = TAG_REGISTRY[tag]?.label || tag;
870
- statusBar.update('tool', `✓ ${actionLabel} [${durationMs}ms]`);
1033
+ statusBar.update('tool', `${actionLabel}: ${short}`);
1034
+ }
1035
+ // Register the invocation with the writer's activity region.
1036
+ // The render function is re-invoked by the writer on every
1037
+ // redraw so the pending line's elapsed time stays current with
1038
+ // the ticker cadence without an explicit refresh timer.
1039
+ //
1040
+ // ask_user is the only currently-blocking tool — it pauses the
1041
+ // agent until the user responds via the modal. A ticking
1042
+ // elapsed-time meter on a paused tool is misleading ("13s"
1043
+ // suggests work is happening), and the per-tick redraw
1044
+ // interacts badly with the open modal (see TECHNICAL_DEBT.md).
1045
+ // Render once with no duration meta and freeze. Replace this
1046
+ // name check with a category flag (e.g. blocking: true on the
1047
+ // tool spec) if more blocking tools appear.
1048
+ if (ctx && ctx.id) {
1049
+ if (tag === 'ask_user') {
1050
+ const staticLine = formatToolLine({
1051
+ status: 'pending',
1052
+ tag,
1053
+ arg: input,
1054
+ attrs: ctx.attrs,
1055
+ noDuration: true,
1056
+ });
1057
+ writerModule.startActivity(ctx.id, () => staticLine);
1058
+ } else {
1059
+ writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
1060
+ status: 'pending',
1061
+ tag,
1062
+ arg: input,
1063
+ attrs: ctx.attrs,
1064
+ durationMs: elapsedMs,
1065
+ }));
1066
+ }
1067
+ }
1068
+ },
1069
+ onToolEnd: (tag, result, durationMs, ctx) => {
1070
+ const hasError = !!(ctx && ctx.error);
1071
+ const isBlocking = tag === 'ask_user';
1072
+ const finalLine = formatToolLine({
1073
+ status: hasError ? 'failure' : 'success',
1074
+ tag,
1075
+ arg: ctx && ctx.attrs ? (ctx.attrs.command || ctx.attrs.path || ctx.attrs.url || ctx.attrs.src || ctx.attrs.key || ctx.attrs.name || ctx.attrs.pattern) : '',
1076
+ attrs: ctx ? ctx.attrs : null,
1077
+ durationMs,
1078
+ meta: ctx ? ctx.meta : null,
1079
+ error: ctx ? ctx.error : null,
1080
+ noDuration: isBlocking,
1081
+ });
1082
+ if (ctx && ctx.id) {
1083
+ writerModule.endActivity(ctx.id, finalLine);
1084
+ } else {
1085
+ // No invocation id means the agent-loop wasn't upgraded to pass
1086
+ // structured context (shouldn't happen in practice). Fall back
1087
+ // to a direct scrollback line so the tool still leaves a trace.
1088
+ writerModule.scrollback(finalLine);
1089
+ }
1090
+ if (hasError) {
1091
+ // Preserve the expandable error body as a follow-up tool
1092
+ // bubble. Empty content suppresses its header so the scrollback
1093
+ // line above (written by endActivity) isn't duplicated.
1094
+ const body = typeof result === 'string' && result.trim() ? result : null;
1095
+ if (body) {
1096
+ chatHistory.addMessage({ role: 'tool', tag, content: '', output: body, isError: true });
1097
+ }
1098
+ statusBar.update('streaming', 'Streaming response');
871
1099
  }
872
1100
  },
873
1101
  onToken: (token) => {
@@ -958,18 +1186,13 @@ function createCommands({
958
1186
  saveSession();
959
1187
  });
960
1188
 
961
- // Wait until user exits
1189
+ // Wait until user exits. The /exit submit handler already ran
1190
+ // destroy(buildExitArtifacts()), so the session summary, resume hint,
1191
+ // and goodbye have been emitted as scrollback inside teardown's
1192
+ // single atomic write. Nothing more to print here.
962
1193
  await exitPromise;
963
1194
  setUIActive(false);
964
1195
  saveSession();
965
- if (sessionMetrics) {
966
- // Show summary in terminal after destroy
967
- console.log('\n' + sessionMetrics.summary());
968
- }
969
- if (currentChatId !== null) {
970
- console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
971
- }
972
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
973
1196
  }
974
1197
 
975
1198
  async function _loginFlow(chatHistory, statusBar) {
@@ -1005,8 +1228,9 @@ function createCommands({
1005
1228
  }
1006
1229
 
1007
1230
  async function cmdCode(opts, promptArgs) {
1008
- if (!promptArgs.length) { console.log(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
1009
- if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1231
+ if (!promptArgs.length) { writer.scrollback(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
1232
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1233
+ await ensureDefaultModel();
1010
1234
  const model = opts.model || getConfig().default_model;
1011
1235
  const userPrompt = promptArgs.join(' ');
1012
1236
  const context = opts.file ? readFileContext(opts.file) : '';
@@ -1016,62 +1240,60 @@ function createCommands({
1016
1240
  try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
1017
1241
  }
1018
1242
  let messages = [{ role: 'user', content: fullPrompt }];
1019
- const statusBar = new StatusBar();
1020
- statusBar.update({ model, status: 'thinking' });
1243
+ writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
1021
1244
  const codeResult = await runAgentLoop(messages, model, undefined, null, {
1022
- debug: opts.debug || false,
1245
+ debug: dbg.isActive(),
1023
1246
  systemPrompt: resolvedSystemPrompt,
1024
1247
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1025
1248
  });
1026
1249
  messages = codeResult.messages;
1027
- statusBar.destroy();
1028
- console.log();
1029
- if (codeResult.metrics) console.log(codeResult.metrics.summary());
1250
+ writer.scrollback('\n');
1251
+ if (codeResult.metrics) writer.scrollback(codeResult.metrics.summary());
1030
1252
  if (opts.dryRun) printDryRunSummary();
1031
1253
  }
1032
1254
 
1033
1255
  async function cmdEdit(opts, filePath, instructionArgs) {
1034
- if (!filePath) { console.log(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
1035
- if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1036
- if (!fs.existsSync(filePath)) { console.log(` ${FG_RED}✗ File not found: ${filePath}${RST}`); return; }
1256
+ if (!filePath) { writer.scrollback(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
1257
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1258
+ if (!fs.existsSync(filePath)) { writer.scrollback(` ${FG_RED}✗ File not found: ${filePath}${RST}`); return; }
1259
+ await ensureDefaultModel();
1037
1260
  const content = fs.readFileSync(filePath, 'utf8');
1038
1261
  const instruction = instructionArgs.join(' ');
1039
1262
  const messages = [
1040
1263
  { role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
1041
1264
  { role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
1042
1265
  ];
1043
- console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1044
- const editStatusBar = new StatusBar();
1045
- editStatusBar.update({ model: opts.model || getConfig().default_model, status: 'editing' });
1266
+ writer.scrollback(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1046
1267
  let result = await chatSync(messages, { model: opts.model });
1047
- editStatusBar.destroy();
1048
1268
  if (result && !opts.dryRun) {
1049
1269
  if (result.startsWith('```')) { const lines = result.split('\n'); result = lines.at(-1).trim() === '```' ? lines.slice(1, -1).join('\n') : lines.slice(1).join('\n'); }
1050
1270
  fs.writeFileSync(filePath, result);
1051
- console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1271
+ writer.scrollback(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1052
1272
  } else if (opts.dryRun) {
1053
- console.log(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1273
+ writer.scrollback(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1054
1274
  }
1055
1275
  }
1056
1276
 
1057
1277
  async function cmdShell(opts, commandArgs) {
1058
1278
  const command = commandArgs.join(' ');
1059
- if (!command) { console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
1279
+ if (!command) { writer.scrollback(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
1060
1280
  const result = await agentExecShell(command);
1061
1281
  if (opts.analyze) {
1062
- if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1282
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1283
+ await ensureDefaultModel();
1063
1284
  const messages = [
1064
1285
  { role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
1065
1286
  { role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
1066
1287
  ];
1067
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`); console.log();
1288
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}\n`);
1289
+ // audit: allowed — non-TUI streaming prefix, must precede StreamRenderer sync writes.
1068
1290
  process.stdout.write(' ');
1069
1291
  try {
1070
1292
  await chatStream(messages, { model: opts.model });
1071
1293
  } catch (err) {
1072
- console.log(`\n ${FG_RED}✗ ${err.message}${RST}`);
1294
+ msgs.netError(err.message);
1073
1295
  }
1074
- console.log();
1296
+ writer.scrollback('\n');
1075
1297
  }
1076
1298
  }
1077
1299
 
@@ -1079,10 +1301,10 @@ function createCommands({
1079
1301
  const config = getConfig();
1080
1302
  let response;
1081
1303
  try { response = await dashboardListModels(); }
1082
- catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1304
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1083
1305
  const models = Array.isArray(response && response.models) ? response.models : [];
1084
- if (!models.length) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No models available.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1085
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Your Models${RST}`); console.log(` ${FG_DARK}${'─'.repeat(60)}${RST}`);
1306
+ if (!models.length) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}No models available.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1307
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Your Models${RST}\n ${FG_DARK}${'─'.repeat(60)}${RST}`);
1086
1308
  const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
1087
1309
  const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
1088
1310
  const active = model.base_url === config.api_base && model.model_id === config.default_model;
@@ -1091,18 +1313,18 @@ function createCommands({
1091
1313
  const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
1092
1314
  return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
1093
1315
  }, { initialIndex: Math.max(0, activeIndex) });
1094
- if (selectedIndex === null) { console.log(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1316
+ if (selectedIndex === null) { writer.scrollback(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1095
1317
  const selectedModel = models[selectedIndex];
1096
1318
  let credentialsResponse;
1097
1319
  try { credentialsResponse = await dashboardGetModelForCli(selectedModel.id); }
1098
- catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1320
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1099
1321
  const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
1100
- if (!model) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load selected model.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1322
+ if (!model) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load selected model.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1101
1323
  const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null) || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
1102
1324
  const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
1103
1325
  if (contextLength !== null) updatedConfig.context_length = contextLength;
1104
1326
  setConfig(updatedConfig);
1105
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
1327
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
1106
1328
  return { model: model.model_id, dbId: model.id };
1107
1329
  }
1108
1330
 
@@ -1113,55 +1335,59 @@ function createCommands({
1113
1335
  api_key: opts.apiKey || 'any',
1114
1336
  dashboard_url: opts.dashboardUrl || current.dashboard_url,
1115
1337
  auth_token: current.auth_token || '',
1116
- default_model: opts.defaultModel || 'default',
1338
+ default_model: opts.defaultModel || '',
1117
1339
  temperature: 0.7,
1118
1340
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
1119
1341
  stream: true,
1120
1342
  models: current.models,
1121
1343
  };
1122
1344
  setConfig(cfg);
1123
- console.log(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}`);
1124
- console.log(` ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
1345
+ writer.scrollback(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}\n ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
1125
1346
  }
1126
1347
 
1127
1348
  async function cmdLogin() {
1128
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ CLI Login${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1349
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
1129
1350
  let loginRequest;
1130
1351
  try { loginRequest = await requestCliLogin(); }
1131
- catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
1132
- console.log(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}`);
1133
- console.log(` ${FG_CYAN}${loginRequest.verification_url}${RST}`);
1134
- console.log(` ${FG_DARK}Waiting for confirmation...${RST}`);
1352
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
1353
+ writer.scrollback(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}\n ${FG_CYAN}${loginRequest.verification_url}${RST}\n ${FG_DARK}Waiting for confirmation...${RST}`);
1135
1354
  const startedAt = Date.now();
1136
1355
  while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
1137
1356
  await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
1138
1357
  let status;
1139
1358
  try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
1140
- catch (err) { if (err.statusCode === 404 || err.statusCode === 410) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`); return; } continue; }
1141
- if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token }); console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`); return; }
1142
- if (status.status === 'expired') { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
1359
+ catch (err) { if (err.statusCode === 404 || err.statusCode === 410) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`); return; } continue; }
1360
+ if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token }); writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`); return; }
1361
+ if (status.status === 'expired') { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
1143
1362
  }
1144
- console.log(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
1363
+ writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
1145
1364
  }
1146
1365
 
1147
1366
  async function cmdWhoAmI() {
1148
1367
  let response;
1149
- try { response = await dashboardWhoAmI(); } catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
1368
+ try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
1150
1369
  const user = response && response.user ? response.user : null;
1151
- if (!user) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
1152
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Current User${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1153
- console.log(formatUserLine('ID', user.id)); console.log(formatUserLine('Email', user.email || '-'));
1154
- console.log(formatUserLine('Name', user.name || '-')); console.log(formatUserLine('Provider', user.provider || '-'));
1155
- if (user.avatar_url) console.log(formatUserLine('Avatar', user.avatar_url));
1156
- console.log();
1370
+ if (!user) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
1371
+ const lines = [
1372
+ '',
1373
+ ` ${FG_TEAL}${BOLD}◆ Current User${RST}`,
1374
+ ` ${FG_DARK}${''.repeat(40)}${RST}`,
1375
+ formatUserLine('ID', user.id),
1376
+ formatUserLine('Email', user.email || '-'),
1377
+ formatUserLine('Name', user.name || '-'),
1378
+ formatUserLine('Provider', user.provider || '-'),
1379
+ ];
1380
+ if (user.avatar_url) lines.push(formatUserLine('Avatar', user.avatar_url));
1381
+ lines.push('');
1382
+ writer.scrollback(lines.join('\n'));
1157
1383
  }
1158
1384
 
1159
1385
  async function cmdLogout() {
1160
1386
  const config = getConfig();
1161
- if (!config.auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
1162
- try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
1387
+ if (!config.auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
1388
+ try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
1163
1389
  setConfig({ ...config, auth_token: '' });
1164
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
1390
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
1165
1391
  }
1166
1392
 
1167
1393
  function printDryRunSummary() {
@@ -1174,15 +1400,24 @@ function createCommands({
1174
1400
  const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
1175
1401
  const row = (content) => { const visible = stripA(content).length; const pad = ' '.repeat(Math.max(0, INNER - visible)); return isTTY ? `${FG_TEAL}║${RST}${content}${pad}${FG_TEAL}║${RST}` : `║${stripA(content)}${pad}║`; };
1176
1402
  const hr40 = (tl, fill, tr) => { const line = tl + fill.repeat(INNER) + tr; return isTTY ? `${FG_TEAL}${line}${RST}` : line; };
1177
- console.log(); console.log(hr40('╔','═','╗'));
1178
- console.log(row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`));
1179
- console.log(hr40('','═',''));
1180
- console.log(row(` ✎ Files that would change: ${files.length} `));
1181
- console.log(row(` ▶ Commands that would run: ${cmds.length} `));
1182
- console.log(row(` Network calls: ${nets.length} `));
1183
- console.log(hr40('╚','═','╝'));
1184
- if (ops.length > 0) { console.log(); for (const op of ops) { if (isTTY) console.log(` ${op.symbol} ${FG_GRAY}${op.desc}${RST}`); else console.log(` ${op.symbol} ${op.desc}`); } }
1185
- console.log();
1403
+ const out = [
1404
+ '',
1405
+ hr40('','═',''),
1406
+ row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`),
1407
+ hr40('╠','═','╣'),
1408
+ row(` Files that would change: ${files.length} `),
1409
+ row(` ▶ Commands that would run: ${cmds.length} `),
1410
+ row(` Network calls: ${nets.length} `),
1411
+ hr40('╚','═','╝'),
1412
+ ];
1413
+ if (ops.length > 0) {
1414
+ out.push('');
1415
+ for (const op of ops) {
1416
+ out.push(isTTY ? ` ${op.symbol} ${FG_GRAY}${op.desc}${RST}` : ` ${op.symbol} ${op.desc}`);
1417
+ }
1418
+ }
1419
+ out.push('');
1420
+ writer.scrollback(out.join('\n'));
1186
1421
  }
1187
1422
 
1188
1423
  return {