@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/lib/commands.js CHANGED
@@ -8,6 +8,10 @@ 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');
11
15
 
12
16
  function formatTimeAgo(ts) {
13
17
  const diffMs = Date.now() - ts;
@@ -41,9 +45,8 @@ function createCommands({
41
45
  FG_TEAL,
42
46
  FG_YELLOW,
43
47
  RST,
44
- StatusBar,
48
+ approxTokens,
45
49
  getCols,
46
- hr,
47
50
  boxLine,
48
51
  interactiveSelect,
49
52
  createUI,
@@ -100,29 +103,54 @@ function createCommands({
100
103
  return null;
101
104
  }
102
105
 
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();
106
+ // Pick the first dashboard model when the user is authenticated but has
107
+ // not selected one yet. Persists credentials to config and returns
108
+ // { name, modelId } on success; null otherwise (not logged in, already
109
+ // selected, empty list, or API error).
110
+ async function ensureDefaultModel() {
111
+ const config = getConfig();
112
+ if (!config.auth_token) return null;
113
+ if (config.default_model && config.dashboard_model_id) return null;
114
+ let response;
115
+ try { response = await dashboardListModels(); } catch { return null; }
116
+ const models = Array.isArray(response && response.models) ? response.models : [];
117
+ if (!models.length) return null;
118
+ const first = models[0];
119
+ let credResp;
120
+ try { credResp = await dashboardGetModelForCli(first.id); } catch { return null; }
121
+ const model = credResp && credResp.model ? credResp.model : null;
122
+ if (!model) return null;
123
+ const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
124
+ || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
125
+ const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
126
+ if (contextLength !== null) updated.context_length = contextLength;
127
+ setConfig(updated);
128
+ return { name: model.name, modelId: model.model_id };
113
129
  }
114
130
 
115
131
  async function cmdChat(opts) {
132
+ await ensureDefaultModel();
133
+
134
+ // Build the three end-of-session artifacts that teardown emits as
135
+ // scrollback. Returning them as a plain object lets both exit paths
136
+ // (/exit submit and Ctrl+C onInterrupt) route through writer.teardown,
137
+ // which is the only place that can append them below the erased live
138
+ // region in a single atomic write.
139
+ function buildExitArtifacts() {
140
+ return {
141
+ summary: sessionMetrics ? sessionMetrics.summary() : '',
142
+ resumeHint: currentChatId !== null
143
+ ? ` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`
144
+ : '',
145
+ goodbye: ` ${FG_GRAY}Goodbye!${RST}`,
146
+ };
147
+ }
148
+
116
149
  const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
117
150
  showThink: opts.showThink || false,
118
151
  onInterrupt: (destroyFn) => {
119
152
  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`);
153
+ destroyFn(buildExitArtifacts());
126
154
  process.exit(0);
127
155
  },
128
156
  });
@@ -157,6 +185,10 @@ function createCommands({
157
185
  let currentModel = opts.model || getConfig().default_model;
158
186
  let resolvedTokenLimit = await resolveTokenLimit(currentModel);
159
187
  statusBar.setModel(currentModel);
188
+ // Seed the context indicator with the profile's limit up-front so it
189
+ // renders "0 / 200,000 tok (0%)" before the first API response, instead
190
+ // of appearing out of thin air once a turn completes.
191
+ statusBar.setContextLimit(resolvedTokenLimit);
160
192
  let sessionMetrics = null;
161
193
  // system prompt is prepended fresh on every API call in agent.js — never stored in history
162
194
  let messages = [];
@@ -300,10 +332,12 @@ function createCommands({
300
332
  }
301
333
  }
302
334
 
303
- // Pending selection state (for in-chat /history, /models, /chats)
335
+ // Pending selection state (for in-chat /history, /models, /chats).
336
+ // The picker renders into the writer's modal region — same band as the
337
+ // permission picker — so navigation redraws in place and only the final
338
+ // selection (or cancellation) leaves a line in scrollback.
304
339
  let pendingAction = null;
305
340
  const PAGE_SIZE = 5;
306
- let listMsg = null;
307
341
 
308
342
  function getNavSearchText(type, item) {
309
343
  if (type === 'history') {
@@ -361,31 +395,27 @@ function createCommands({
361
395
  return parts.join('\n');
362
396
  }
363
397
 
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;
398
+ function collapseListMsg(_type, _item) {
399
+ // Modal is transient — clearing it removes the picker from view; the
400
+ // selection's success line is emitted to scrollback by
401
+ // handlePendingSelection.
402
+ writer.clearModal();
370
403
  }
371
404
 
372
405
  function showPendingStep() {
373
406
  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
- }
407
+ const lines = buildListContent().split('\n');
408
+ // Match the system-message bubble look so the modal reads as part of
409
+ // the same chat block: muted bullet on the title row, indented
410
+ // continuations underneath.
411
+ const modalLines = lines.length > 0
412
+ ? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
413
+ : [];
414
+ writer.setModal(modalLines);
382
415
  }
383
416
 
384
417
  function finalizeListMsg() {
385
- if (listMsg) {
386
- chatHistory.removeById(listMsg.id);
387
- listMsg = null;
388
- }
418
+ writer.clearModal();
389
419
  }
390
420
 
391
421
  function activateNavCapture() {
@@ -447,6 +477,7 @@ function createCommands({
447
477
  currentModel = loaded.model;
448
478
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
449
479
  statusBar.setModel(currentModel);
480
+ statusBar.setContextLimit(resolvedTokenLimit);
450
481
  }
451
482
  displayLoadedMessages(messages);
452
483
  chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
@@ -478,6 +509,7 @@ function createCommands({
478
509
  currentModel = model.model_id;
479
510
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
480
511
  statusBar.setModel(currentModel);
512
+ statusBar.setContextLimit(resolvedTokenLimit);
481
513
  currentChatId = null;
482
514
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
483
515
  statusBar.update('idle');
@@ -535,7 +567,7 @@ function createCommands({
535
567
  // Exit
536
568
  if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
537
569
  saveSession();
538
- destroy();
570
+ destroy(buildExitArtifacts());
539
571
  resolveExit();
540
572
  return;
541
573
  }
@@ -569,6 +601,7 @@ function createCommands({
569
601
  }
570
602
 
571
603
  if (text === '/history') {
604
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
572
605
  const sessions = storage.list();
573
606
  if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
574
607
  refreshInputSearchItems();
@@ -617,6 +650,14 @@ function createCommands({
617
650
  inputField.setDisabled(true);
618
651
  statusBar.update('thinking', 'Starting login...');
619
652
  await _loginFlow(chatHistory, statusBar);
653
+ const picked = await ensureDefaultModel();
654
+ if (picked) {
655
+ currentModel = picked.modelId;
656
+ resolvedTokenLimit = await resolveTokenLimit(currentModel);
657
+ statusBar.setModel(currentModel);
658
+ statusBar.setContextLimit(resolvedTokenLimit);
659
+ chatHistory.addMessage({ role: 'system', content: `✓ Model → ${picked.name} (${picked.modelId})` });
660
+ }
620
661
  statusBar.update('idle');
621
662
  inputField.setDisabled(false);
622
663
  return;
@@ -666,6 +707,7 @@ function createCommands({
666
707
  }
667
708
 
668
709
  if (text === '/models') {
710
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
669
711
  inputField.setDisabled(true);
670
712
  statusBar.update('thinking', 'Loading models...');
671
713
  try {
@@ -688,14 +730,17 @@ function createCommands({
688
730
  }
689
731
 
690
732
  if (text === '/model') {
733
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
691
734
  chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
692
735
  return;
693
736
  }
694
737
 
695
738
  if (text.startsWith('/model ')) {
739
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
696
740
  currentModel = text.slice(7).trim();
697
741
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
698
742
  statusBar.setModel(currentModel);
743
+ statusBar.setContextLimit(resolvedTokenLimit);
699
744
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
700
745
  return;
701
746
  }
@@ -803,6 +848,11 @@ function createCommands({
803
848
  inputField.setDisabled(true);
804
849
  chatHistory.addMessage({ role: 'user', content: text });
805
850
  statusBar.update('thinking', 'Thinking...');
851
+ // Bump the context-size indicator with this user message's approximate
852
+ // token count. It'll be overwritten with the exact prompt_tokens from
853
+ // the API response when the first turn completes — this just keeps the
854
+ // indicator reactive in the gap before that.
855
+ statusBar.addPendingTokens(approxTokens(text));
806
856
  await createChatIfNeeded(text);
807
857
  messages.push({ role: 'user', content: text });
808
858
 
@@ -845,29 +895,57 @@ function createCommands({
845
895
  chatHistory.addMessage({ role: 'think', content });
846
896
  statusBar.update('streaming', 'Streaming response');
847
897
  },
848
- onToolStart: (tag, input, attrs) => {
898
+ onToolStart: (tag, input, ctx) => {
849
899
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
850
- const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
900
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
851
901
  const isDownload = tag === 'download' || tag === 'http_get';
852
902
  if (isDownload) {
853
903
  statusBar.update('waiting_download', `Waiting for download: ${short}`);
854
904
  } else {
855
905
  statusBar.update('tool', `${actionLabel}: ${short}`);
856
906
  }
857
- },
858
- onToolEnd: (tag, result, durationMs) => {
859
- const isError = typeof result === 'string' && result.startsWith('Error');
860
- if (isError) {
861
- chatHistory.addMessage({
862
- role: 'tool',
907
+ // Register the invocation with the writer's activity region.
908
+ // The render function is re-invoked by the writer on every
909
+ // redraw so the pending line's elapsed time stays current with
910
+ // the ticker cadence without an explicit refresh timer.
911
+ if (ctx && ctx.id) {
912
+ writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
913
+ status: 'pending',
863
914
  tag,
864
- content: `${tag} ✕ [${durationMs}ms]`,
865
- output: typeof result === 'string' && result.trim() ? result : null,
866
- });
867
- statusBar.update('streaming', 'Streaming response');
915
+ arg: input,
916
+ attrs: ctx.attrs,
917
+ durationMs: elapsedMs,
918
+ }));
919
+ }
920
+ },
921
+ onToolEnd: (tag, result, durationMs, ctx) => {
922
+ const hasError = !!(ctx && ctx.error);
923
+ const finalLine = formatToolLine({
924
+ status: hasError ? 'failure' : 'success',
925
+ tag,
926
+ 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) : '',
927
+ attrs: ctx ? ctx.attrs : null,
928
+ durationMs,
929
+ meta: ctx ? ctx.meta : null,
930
+ error: ctx ? ctx.error : null,
931
+ });
932
+ if (ctx && ctx.id) {
933
+ writerModule.endActivity(ctx.id, finalLine);
868
934
  } else {
869
- const actionLabel = TAG_REGISTRY[tag]?.label || tag;
870
- statusBar.update('tool', `✓ ${actionLabel} [${durationMs}ms]`);
935
+ // No invocation id means the agent-loop wasn't upgraded to pass
936
+ // structured context (shouldn't happen in practice). Fall back
937
+ // to a direct scrollback line so the tool still leaves a trace.
938
+ writerModule.scrollback(finalLine);
939
+ }
940
+ if (hasError) {
941
+ // Preserve the expandable error body as a follow-up tool
942
+ // bubble. Empty content suppresses its header so the scrollback
943
+ // line above (written by endActivity) isn't duplicated.
944
+ const body = typeof result === 'string' && result.trim() ? result : null;
945
+ if (body) {
946
+ chatHistory.addMessage({ role: 'tool', tag, content: '', output: body, isError: true });
947
+ }
948
+ statusBar.update('streaming', 'Streaming response');
871
949
  }
872
950
  },
873
951
  onToken: (token) => {
@@ -958,18 +1036,13 @@ function createCommands({
958
1036
  saveSession();
959
1037
  });
960
1038
 
961
- // Wait until user exits
1039
+ // Wait until user exits. The /exit submit handler already ran
1040
+ // destroy(buildExitArtifacts()), so the session summary, resume hint,
1041
+ // and goodbye have been emitted as scrollback inside teardown's
1042
+ // single atomic write. Nothing more to print here.
962
1043
  await exitPromise;
963
1044
  setUIActive(false);
964
1045
  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
1046
  }
974
1047
 
975
1048
  async function _loginFlow(chatHistory, statusBar) {
@@ -1005,8 +1078,9 @@ function createCommands({
1005
1078
  }
1006
1079
 
1007
1080
  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; }
1081
+ if (!promptArgs.length) { writer.scrollback(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
1082
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1083
+ await ensureDefaultModel();
1010
1084
  const model = opts.model || getConfig().default_model;
1011
1085
  const userPrompt = promptArgs.join(' ');
1012
1086
  const context = opts.file ? readFileContext(opts.file) : '';
@@ -1016,62 +1090,60 @@ function createCommands({
1016
1090
  try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
1017
1091
  }
1018
1092
  let messages = [{ role: 'user', content: fullPrompt }];
1019
- const statusBar = new StatusBar();
1020
- statusBar.update({ model, status: 'thinking' });
1093
+ writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
1021
1094
  const codeResult = await runAgentLoop(messages, model, undefined, null, {
1022
1095
  debug: opts.debug || false,
1023
1096
  systemPrompt: resolvedSystemPrompt,
1024
1097
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1025
1098
  });
1026
1099
  messages = codeResult.messages;
1027
- statusBar.destroy();
1028
- console.log();
1029
- if (codeResult.metrics) console.log(codeResult.metrics.summary());
1100
+ writer.scrollback('\n');
1101
+ if (codeResult.metrics) writer.scrollback(codeResult.metrics.summary());
1030
1102
  if (opts.dryRun) printDryRunSummary();
1031
1103
  }
1032
1104
 
1033
1105
  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; }
1106
+ if (!filePath) { writer.scrollback(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
1107
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1108
+ if (!fs.existsSync(filePath)) { writer.scrollback(` ${FG_RED}✗ File not found: ${filePath}${RST}`); return; }
1109
+ await ensureDefaultModel();
1037
1110
  const content = fs.readFileSync(filePath, 'utf8');
1038
1111
  const instruction = instructionArgs.join(' ');
1039
1112
  const messages = [
1040
1113
  { role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
1041
1114
  { role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
1042
1115
  ];
1043
- console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1044
- const editStatusBar = new StatusBar();
1045
- editStatusBar.update({ model: opts.model || getConfig().default_model, status: 'editing' });
1116
+ writer.scrollback(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1046
1117
  let result = await chatSync(messages, { model: opts.model });
1047
- editStatusBar.destroy();
1048
1118
  if (result && !opts.dryRun) {
1049
1119
  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
1120
  fs.writeFileSync(filePath, result);
1051
- console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1121
+ writer.scrollback(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1052
1122
  } else if (opts.dryRun) {
1053
- console.log(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1123
+ writer.scrollback(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1054
1124
  }
1055
1125
  }
1056
1126
 
1057
1127
  async function cmdShell(opts, commandArgs) {
1058
1128
  const command = commandArgs.join(' ');
1059
- if (!command) { console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
1129
+ if (!command) { writer.scrollback(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
1060
1130
  const result = await agentExecShell(command);
1061
1131
  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; }
1132
+ if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1133
+ await ensureDefaultModel();
1063
1134
  const messages = [
1064
1135
  { role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
1065
1136
  { role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
1066
1137
  ];
1067
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`); console.log();
1138
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}\n`);
1139
+ // audit: allowed — non-TUI streaming prefix, must precede StreamRenderer sync writes.
1068
1140
  process.stdout.write(' ');
1069
1141
  try {
1070
1142
  await chatStream(messages, { model: opts.model });
1071
1143
  } catch (err) {
1072
- console.log(`\n ${FG_RED}✗ ${err.message}${RST}`);
1144
+ msgs.netError(err.message);
1073
1145
  }
1074
- console.log();
1146
+ writer.scrollback('\n');
1075
1147
  }
1076
1148
  }
1077
1149
 
@@ -1079,10 +1151,10 @@ function createCommands({
1079
1151
  const config = getConfig();
1080
1152
  let response;
1081
1153
  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 }; }
1154
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1083
1155
  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}`);
1156
+ 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 }; }
1157
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Your Models${RST}\n ${FG_DARK}${'─'.repeat(60)}${RST}`);
1086
1158
  const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
1087
1159
  const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
1088
1160
  const active = model.base_url === config.api_base && model.model_id === config.default_model;
@@ -1091,18 +1163,18 @@ function createCommands({
1091
1163
  const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
1092
1164
  return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
1093
1165
  }, { 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 }; }
1166
+ if (selectedIndex === null) { writer.scrollback(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1095
1167
  const selectedModel = models[selectedIndex];
1096
1168
  let credentialsResponse;
1097
1169
  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 }; }
1170
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1099
1171
  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 }; }
1172
+ 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
1173
  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
1174
  const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
1103
1175
  if (contextLength !== null) updatedConfig.context_length = contextLength;
1104
1176
  setConfig(updatedConfig);
1105
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
1177
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
1106
1178
  return { model: model.model_id, dbId: model.id };
1107
1179
  }
1108
1180
 
@@ -1113,55 +1185,59 @@ function createCommands({
1113
1185
  api_key: opts.apiKey || 'any',
1114
1186
  dashboard_url: opts.dashboardUrl || current.dashboard_url,
1115
1187
  auth_token: current.auth_token || '',
1116
- default_model: opts.defaultModel || 'default',
1188
+ default_model: opts.defaultModel || '',
1117
1189
  temperature: 0.7,
1118
1190
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
1119
1191
  stream: true,
1120
1192
  models: current.models,
1121
1193
  };
1122
1194
  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`);
1195
+ writer.scrollback(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}\n ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
1125
1196
  }
1126
1197
 
1127
1198
  async function cmdLogin() {
1128
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ CLI Login${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1199
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
1129
1200
  let loginRequest;
1130
1201
  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}`);
1202
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
1203
+ 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
1204
  const startedAt = Date.now();
1136
1205
  while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
1137
1206
  await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
1138
1207
  let status;
1139
1208
  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; }
1209
+ 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; }
1210
+ 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; }
1211
+ if (status.status === 'expired') { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
1143
1212
  }
1144
- console.log(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
1213
+ writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
1145
1214
  }
1146
1215
 
1147
1216
  async function cmdWhoAmI() {
1148
1217
  let response;
1149
- try { response = await dashboardWhoAmI(); } catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
1218
+ try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
1150
1219
  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();
1220
+ if (!user) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
1221
+ const lines = [
1222
+ '',
1223
+ ` ${FG_TEAL}${BOLD}◆ Current User${RST}`,
1224
+ ` ${FG_DARK}${''.repeat(40)}${RST}`,
1225
+ formatUserLine('ID', user.id),
1226
+ formatUserLine('Email', user.email || '-'),
1227
+ formatUserLine('Name', user.name || '-'),
1228
+ formatUserLine('Provider', user.provider || '-'),
1229
+ ];
1230
+ if (user.avatar_url) lines.push(formatUserLine('Avatar', user.avatar_url));
1231
+ lines.push('');
1232
+ writer.scrollback(lines.join('\n'));
1157
1233
  }
1158
1234
 
1159
1235
  async function cmdLogout() {
1160
1236
  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; } }
1237
+ if (!config.auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
1238
+ try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
1163
1239
  setConfig({ ...config, auth_token: '' });
1164
- console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
1240
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
1165
1241
  }
1166
1242
 
1167
1243
  function printDryRunSummary() {
@@ -1174,15 +1250,24 @@ function createCommands({
1174
1250
  const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
1175
1251
  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
1252
  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();
1253
+ const out = [
1254
+ '',
1255
+ hr40('','═',''),
1256
+ row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`),
1257
+ hr40('╠','═','╣'),
1258
+ row(` Files that would change: ${files.length} `),
1259
+ row(` ▶ Commands that would run: ${cmds.length} `),
1260
+ row(` Network calls: ${nets.length} `),
1261
+ hr40('╚','═','╝'),
1262
+ ];
1263
+ if (ops.length > 0) {
1264
+ out.push('');
1265
+ for (const op of ops) {
1266
+ out.push(isTTY ? ` ${op.symbol} ${FG_GRAY}${op.desc}${RST}` : ` ${op.symbol} ${op.desc}`);
1267
+ }
1268
+ }
1269
+ out.push('');
1270
+ writer.scrollback(out.join('\n'));
1186
1271
  }
1187
1272
 
1188
1273
  return {
package/lib/config.js CHANGED
@@ -20,6 +20,7 @@ function _maybeWarnApiKeyAny(cfg) {
20
20
  }
21
21
  if (_LOCAL_HOSTS.has(host)) return;
22
22
  _apiKeyAnyWarned = true;
23
+ // audit: allowed — pre-UI startup warning, fires once before TUI initialises.
23
24
  process.stderr.write(
24
25
  "⚠ api_key='any' against non-local endpoint — requests will likely fail " +
25
26
  "with 401. Run 'semalt-code config set api_key <key>' to set a real key.\n"
package/lib/constants.js CHANGED
@@ -12,7 +12,7 @@ const DEFAULT_CONFIG = {
12
12
  api_key: 'any',
13
13
  dashboard_url: 'https://cli.semalt.ai',
14
14
  auth_token: '',
15
- default_model: 'default',
15
+ default_model: '',
16
16
  dashboard_model_id: null,
17
17
  temperature: 0.7,
18
18
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,