@semalt-ai/code 1.8.1 → 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
@@ -4,10 +4,14 @@ const fs = require('fs');
4
4
 
5
5
  const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
6
6
  const { configShow } = require('./config');
7
- const { SYSTEM_PROMPT } = require('./prompts');
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');
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,
@@ -96,43 +99,79 @@ function createCommands({
96
99
  (m) => m.model === model || (m.api_base === config.api_base && m.model === config.default_model)
97
100
  );
98
101
  if (match && Number.isInteger(match.context_length) && match.context_length > 0) return match.context_length;
102
+ if (Number.isInteger(config.context_length) && config.context_length > 0) return config.context_length;
99
103
  return null;
100
104
  }
101
105
 
102
- function printBanner() {
103
- const w = Math.min(getCols() - 4, 60);
104
- console.log();
105
- console.log(` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`);
106
- console.log(boxLine('', w));
107
- console.log(boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w));
108
- console.log(boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w));
109
- console.log(boxLine('', w));
110
- console.log(` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`);
111
- 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 };
112
129
  }
113
130
 
114
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
+
115
149
  const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
116
150
  showThink: opts.showThink || false,
117
151
  onInterrupt: (destroyFn) => {
118
152
  saveSession();
119
- destroyFn();
120
- if (sessionMetrics) console.log('\n' + sessionMetrics.summary());
121
- if (currentChatId !== null) {
122
- console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
123
- }
124
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
153
+ destroyFn(buildExitArtifacts());
125
154
  process.exit(0);
126
155
  },
127
156
  });
128
157
 
129
158
  setUIActive(true);
130
159
 
160
+ const writer = require('./ui/writer');
131
161
  permissionManager.setUICallbacks({
132
162
  onAddMessage: (msg) => chatHistory.addMessage(msg),
133
163
  onRerenderMessage: (id) => chatHistory.rerenderById(id),
134
164
  onCollapseMessage: (id) => chatHistory.collapseById(id),
135
165
  onRemoveMessage: (id) => chatHistory.removeById(id),
166
+ // Modal-region API: setModal replaces the modal live band above the
167
+ // status region; clearModal drops it. Arrow-key redraws go through
168
+ // setModal only — no scrollback churn. When the picker resolves we
169
+ // clear the modal and push a single summary line to scrollback.
170
+ onShowModal: (lines) => writer.setModal(lines),
171
+ onCloseModal: (summary) => {
172
+ writer.clearModal();
173
+ if (summary) chatHistory.addMessage({ role: 'system', content: summary });
174
+ },
136
175
  onCaptureNavigation: (handler) => {
137
176
  inputField.captureNavigation(handler);
138
177
  return () => inputField.releaseNavigation();
@@ -146,6 +185,10 @@ function createCommands({
146
185
  let currentModel = opts.model || getConfig().default_model;
147
186
  let resolvedTokenLimit = await resolveTokenLimit(currentModel);
148
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);
149
192
  let sessionMetrics = null;
150
193
  // system prompt is prepended fresh on every API call in agent.js — never stored in history
151
194
  let messages = [];
@@ -187,14 +230,13 @@ function createCommands({
187
230
  }
188
231
  refreshInputSearchItems();
189
232
 
190
- // Banner — write at row 1, then compact the layout so the fixed panels sit
191
- // immediately below the banner with no blank gap. The layout grows as
192
- // messages are added (dynamic layout mode) until it reaches full-screen.
233
+ // Banner — emit once as scrollback above the live region. In the
234
+ // bottom-anchored live-region TUI, scrollback flows into terminal
235
+ // scrollback naturally, so no absolute positioning or scroll-region
236
+ // trickery is needed here.
193
237
  if (layout) {
194
- const BANNER_LINES = 8; // blank + top-border + empty + title + desc + empty + bottom-border + blank
195
238
  const w = Math.min(getCols() - 4, 60);
196
- process.stdout.write('\x1b[1;1H');
197
- process.stdout.write([
239
+ const banner = [
198
240
  ``,
199
241
  ` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
200
242
  boxLine('', w),
@@ -203,19 +245,8 @@ function createCommands({
203
245
  boxLine('', w),
204
246
  ` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
205
247
  ``,
206
- ].join('\n') + '\n');
207
-
208
- // Keep historyStart = 1 so the banner is inside the scroll region.
209
- // Growing mode uses _contentLines to position the first message below the
210
- // banner (at row BANNER_LINES + 1). When the terminal fills up the banner
211
- // scrolls naturally into the terminal scrollback — nothing disappears behind
212
- // a fixed header.
213
- layout._contentLines = BANNER_LINES;
214
- layout.rows = BANNER_LINES + 1 + layout.inputHeight + 3;
215
-
216
- // Erase the stale full-screen panels createUI drew before we compacted.
217
- process.stdout.write(`\x1b[${layout.rows + 1};1H\x1b[J`);
218
-
248
+ ].join('\n');
249
+ writer.scrollback(banner);
219
250
  redrawFixed();
220
251
  }
221
252
 
@@ -256,24 +287,29 @@ function createCommands({
256
287
  try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
257
288
  }
258
289
 
259
- const HISTORY_DISPLAY_TURNS = 3; // user+assistant pairs to show on load
260
-
261
290
  function displayLoadedMessages(loadedMessages) {
262
291
  chatHistory.clearMessages();
263
- const visible = loadedMessages.filter(
264
- (m) => (m.role === 'user' || m.role === 'assistant') &&
265
- (typeof m.content === 'string' ? m.content : '').trim()
266
- );
267
- const skip = Math.max(0, visible.length - HISTORY_DISPLAY_TURNS * 2);
268
- if (skip > 0) {
269
- chatHistory.addMessage({ role: 'system', content: `… ${skip} earlier messages not shown` });
270
- }
271
- for (const m of visible.slice(skip)) {
272
- chatHistory.addMessage({
273
- role: m.role,
274
- content: typeof m.content === 'string' ? m.content : '',
275
- ts: m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date()),
276
- });
292
+ for (const m of loadedMessages) {
293
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
294
+ const raw = typeof m.content === 'string' ? m.content : '';
295
+ const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
296
+
297
+ if (m.role === 'tool') {
298
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: raw, ts });
299
+ continue;
300
+ }
301
+
302
+ if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
303
+ const body = raw
304
+ .replace(/^Tool execution results[^\n]*\n+/, '')
305
+ .replace(/\n+Continue with the task\.[\s\S]*$/, '')
306
+ .trim();
307
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: body || raw, ts });
308
+ continue;
309
+ }
310
+
311
+ if (!raw.trim()) continue;
312
+ chatHistory.addMessage({ role: m.role, content: raw, ts });
277
313
  }
278
314
  }
279
315
 
@@ -296,32 +332,12 @@ function createCommands({
296
332
  }
297
333
  }
298
334
 
299
- // 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.
300
339
  let pendingAction = null;
301
340
  const PAGE_SIZE = 5;
302
- let listMsg = null;
303
-
304
- // In-place progress indicator for chunked HTTP fetches (http_get + http_get_next)
305
- let httpFetchMsg = null;
306
-
307
- function showHttpFetchProgress(url, part, total) {
308
- const maxUrl = Math.max(20, getCols() - 35);
309
- const shortUrl = url.length > maxUrl ? url.slice(0, maxUrl - 1) + '…' : url;
310
- const content = `Fetching URL · ${shortUrl} · Part ${part}/${total}`;
311
- if (!httpFetchMsg) {
312
- httpFetchMsg = { role: 'tool', tag: 'http_get', content, id: `http-fetch-${Date.now()}` };
313
- chatHistory.addMessage(httpFetchMsg);
314
- } else {
315
- httpFetchMsg.content = content;
316
- chatHistory.rerenderById(httpFetchMsg.id);
317
- }
318
- }
319
-
320
- function finalizeHttpFetch() {
321
- if (!httpFetchMsg) return;
322
- chatHistory.removeById(httpFetchMsg.id);
323
- httpFetchMsg = null;
324
- }
325
341
 
326
342
  function getNavSearchText(type, item) {
327
343
  if (type === 'history') {
@@ -379,31 +395,27 @@ function createCommands({
379
395
  return parts.join('\n');
380
396
  }
381
397
 
382
- function collapseListMsg(type, item) {
383
- if (!listMsg) return;
384
- const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
385
- listMsg.content = `${titleMap[type] || type} · ${buildItemDetail(type, item)}`;
386
- chatHistory.collapseById(listMsg.id);
387
- 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();
388
403
  }
389
404
 
390
405
  function showPendingStep() {
391
406
  if (!pendingAction) return;
392
- const content = buildListContent();
393
- if (!listMsg) {
394
- listMsg = { role: 'system', content, id: `list-${Date.now()}` };
395
- chatHistory.addMessage(listMsg);
396
- } else {
397
- listMsg.content = content;
398
- chatHistory.rerenderById(listMsg.id);
399
- }
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);
400
415
  }
401
416
 
402
417
  function finalizeListMsg() {
403
- if (listMsg) {
404
- chatHistory.removeById(listMsg.id);
405
- listMsg = null;
406
- }
418
+ writer.clearModal();
407
419
  }
408
420
 
409
421
  function activateNavCapture() {
@@ -465,6 +477,7 @@ function createCommands({
465
477
  currentModel = loaded.model;
466
478
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
467
479
  statusBar.setModel(currentModel);
480
+ statusBar.setContextLimit(resolvedTokenLimit);
468
481
  }
469
482
  displayLoadedMessages(messages);
470
483
  chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
@@ -496,6 +509,7 @@ function createCommands({
496
509
  currentModel = model.model_id;
497
510
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
498
511
  statusBar.setModel(currentModel);
512
+ statusBar.setContextLimit(resolvedTokenLimit);
499
513
  currentChatId = null;
500
514
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
501
515
  statusBar.update('idle');
@@ -553,7 +567,7 @@ function createCommands({
553
567
  // Exit
554
568
  if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
555
569
  saveSession();
556
- destroy();
570
+ destroy(buildExitArtifacts());
557
571
  resolveExit();
558
572
  return;
559
573
  }
@@ -587,6 +601,7 @@ function createCommands({
587
601
  }
588
602
 
589
603
  if (text === '/history') {
604
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
590
605
  const sessions = storage.list();
591
606
  if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
592
607
  refreshInputSearchItems();
@@ -635,6 +650,14 @@ function createCommands({
635
650
  inputField.setDisabled(true);
636
651
  statusBar.update('thinking', 'Starting login...');
637
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
+ }
638
661
  statusBar.update('idle');
639
662
  inputField.setDisabled(false);
640
663
  return;
@@ -684,6 +707,7 @@ function createCommands({
684
707
  }
685
708
 
686
709
  if (text === '/models') {
710
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
687
711
  inputField.setDisabled(true);
688
712
  statusBar.update('thinking', 'Loading models...');
689
713
  try {
@@ -706,14 +730,17 @@ function createCommands({
706
730
  }
707
731
 
708
732
  if (text === '/model') {
733
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
709
734
  chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
710
735
  return;
711
736
  }
712
737
 
713
738
  if (text.startsWith('/model ')) {
739
+ if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
714
740
  currentModel = text.slice(7).trim();
715
741
  resolvedTokenLimit = await resolveTokenLimit(currentModel);
716
742
  statusBar.setModel(currentModel);
743
+ statusBar.setContextLimit(resolvedTokenLimit);
717
744
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
718
745
  return;
719
746
  }
@@ -740,7 +767,7 @@ function createCommands({
740
767
  }
741
768
 
742
769
  if (text === '/prompt') {
743
- const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : SYSTEM_PROMPT;
770
+ const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt();
744
771
  const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
745
772
  const mode = getConfig().system_prompt_mode || 'system_role';
746
773
  chatHistory.addMessage({
@@ -821,6 +848,11 @@ function createCommands({
821
848
  inputField.setDisabled(true);
822
849
  chatHistory.addMessage({ role: 'user', content: text });
823
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));
824
856
  await createChatIfNeeded(text);
825
857
  messages.push({ role: 'user', content: text });
826
858
 
@@ -847,7 +879,7 @@ function createCommands({
847
879
  if (entry?.type === 'tool') {
848
880
  const actionLabel = entry.label || tag;
849
881
  const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
850
- const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
882
+ const isDownload = tag === 'download' || tag === 'http_get';
851
883
  const barState = isDownload ? 'waiting_download' : 'tool';
852
884
  const label = isDownload
853
885
  ? `Waiting for download${detail ? ': ' + detail : ''}`
@@ -863,48 +895,57 @@ function createCommands({
863
895
  chatHistory.addMessage({ role: 'think', content });
864
896
  statusBar.update('streaming', 'Streaming response');
865
897
  },
866
- onToolStart: (tag, input, attrs) => {
898
+ onToolStart: (tag, input, ctx) => {
867
899
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
868
- const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
869
- const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
900
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
901
+ const isDownload = tag === 'download' || tag === 'http_get';
870
902
  if (isDownload) {
871
903
  statusBar.update('waiting_download', `Waiting for download: ${short}`);
872
904
  } else {
873
905
  statusBar.update('tool', `${actionLabel}: ${short}`);
874
906
  }
875
- },
876
- onToolEnd: (tag, result, durationMs) => {
877
- const isError = typeof result === 'string' && result.startsWith('Error');
878
- if (isError) {
879
- finalizeHttpFetch();
880
- chatHistory.addMessage({
881
- 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',
882
914
  tag,
883
- content: `${tag} ✕ [${durationMs}ms]`,
884
- output: typeof result === 'string' && result.trim() ? result : null,
885
- });
886
- statusBar.update('streaming', 'Streaming response');
887
- } else if (tag === 'http_get') {
888
- const chunkedMatch = typeof result === 'string' && result.match(/^HTTP GET (.+?) \(\d+\) \[Part 1\/(\d+)\]/);
889
- if (chunkedMatch) {
890
- showHttpFetchProgress(chunkedMatch[1], 1, parseInt(chunkedMatch[2], 10));
891
- } else {
892
- finalizeHttpFetch();
893
- statusBar.update('tool', `✓ ${TAG_REGISTRY[tag]?.label || tag} [${durationMs}ms]`);
894
- }
895
- } else if (tag === 'http_get_next') {
896
- const partMatch = typeof result === 'string' && result.match(/^HTTP content "(.+?)" \[Part (\d+)\/(\d+)\]/);
897
- if (partMatch) {
898
- const part = parseInt(partMatch[2], 10);
899
- const total = parseInt(partMatch[3], 10);
900
- showHttpFetchProgress(partMatch[1], part, total);
901
- if (part === total) finalizeHttpFetch();
902
- } else {
903
- finalizeHttpFetch();
904
- }
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);
905
934
  } else {
906
- const actionLabel = TAG_REGISTRY[tag]?.label || tag;
907
- 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');
908
949
  }
909
950
  },
910
951
  onToken: (token) => {
@@ -937,6 +978,11 @@ function createCommands({
937
978
  onRetry: (attempt, max) => {
938
979
  statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
939
980
  },
981
+ onDebug: (block) => {
982
+ // Render in-history as a tool-style bubble so ctrl+O expand works and
983
+ // the RAW RESPONSE text survives TUI redraws (stderr would be clobbered).
984
+ chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
985
+ },
940
986
  onError: (err) => {
941
987
  if (err && err.isWarning) {
942
988
  chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
@@ -957,6 +1003,15 @@ function createCommands({
957
1003
  };
958
1004
  inputField.on('abort', _onAbort);
959
1005
 
1006
+ // Refresh in case a prior turn's 400 overflow persisted a learned
1007
+ // context_length to config after this chat started.
1008
+ if (resolvedTokenLimit == null) {
1009
+ const cfg = getConfig();
1010
+ if (Number.isInteger(cfg.context_length) && cfg.context_length > 0) {
1011
+ resolvedTokenLimit = cfg.context_length;
1012
+ }
1013
+ }
1014
+
960
1015
  try {
961
1016
  const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
962
1017
  showThink: opts.showThink || false,
@@ -981,18 +1036,13 @@ function createCommands({
981
1036
  saveSession();
982
1037
  });
983
1038
 
984
- // 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.
985
1043
  await exitPromise;
986
1044
  setUIActive(false);
987
1045
  saveSession();
988
- if (sessionMetrics) {
989
- // Show summary in terminal after destroy
990
- console.log('\n' + sessionMetrics.summary());
991
- }
992
- if (currentChatId !== null) {
993
- console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
994
- }
995
- console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
996
1046
  }
997
1047
 
998
1048
  async function _loginFlow(chatHistory, statusBar) {
@@ -1028,8 +1078,9 @@ function createCommands({
1028
1078
  }
1029
1079
 
1030
1080
  async function cmdCode(opts, promptArgs) {
1031
- if (!promptArgs.length) { console.log(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
1032
- 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();
1033
1084
  const model = opts.model || getConfig().default_model;
1034
1085
  const userPrompt = promptArgs.join(' ');
1035
1086
  const context = opts.file ? readFileContext(opts.file) : '';
@@ -1039,62 +1090,60 @@ function createCommands({
1039
1090
  try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
1040
1091
  }
1041
1092
  let messages = [{ role: 'user', content: fullPrompt }];
1042
- const statusBar = new StatusBar();
1043
- statusBar.update({ model, status: 'thinking' });
1093
+ writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
1044
1094
  const codeResult = await runAgentLoop(messages, model, undefined, null, {
1045
1095
  debug: opts.debug || false,
1046
1096
  systemPrompt: resolvedSystemPrompt,
1047
1097
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1048
1098
  });
1049
1099
  messages = codeResult.messages;
1050
- statusBar.destroy();
1051
- console.log();
1052
- if (codeResult.metrics) console.log(codeResult.metrics.summary());
1100
+ writer.scrollback('\n');
1101
+ if (codeResult.metrics) writer.scrollback(codeResult.metrics.summary());
1053
1102
  if (opts.dryRun) printDryRunSummary();
1054
1103
  }
1055
1104
 
1056
1105
  async function cmdEdit(opts, filePath, instructionArgs) {
1057
- if (!filePath) { console.log(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
1058
- if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
1059
- 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();
1060
1110
  const content = fs.readFileSync(filePath, 'utf8');
1061
1111
  const instruction = instructionArgs.join(' ');
1062
1112
  const messages = [
1063
1113
  { role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
1064
1114
  { role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
1065
1115
  ];
1066
- console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1067
- const editStatusBar = new StatusBar();
1068
- editStatusBar.update({ model: opts.model || getConfig().default_model, status: 'editing' });
1116
+ writer.scrollback(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1069
1117
  let result = await chatSync(messages, { model: opts.model });
1070
- editStatusBar.destroy();
1071
1118
  if (result && !opts.dryRun) {
1072
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'); }
1073
1120
  fs.writeFileSync(filePath, result);
1074
- console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1121
+ writer.scrollback(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1075
1122
  } else if (opts.dryRun) {
1076
- console.log(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1123
+ writer.scrollback(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1077
1124
  }
1078
1125
  }
1079
1126
 
1080
1127
  async function cmdShell(opts, commandArgs) {
1081
1128
  const command = commandArgs.join(' ');
1082
- 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; }
1083
1130
  const result = await agentExecShell(command);
1084
1131
  if (opts.analyze) {
1085
- 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();
1086
1134
  const messages = [
1087
1135
  { role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
1088
1136
  { role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
1089
1137
  ];
1090
- 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.
1091
1140
  process.stdout.write(' ');
1092
1141
  try {
1093
1142
  await chatStream(messages, { model: opts.model });
1094
1143
  } catch (err) {
1095
- console.log(`\n ${FG_RED}✗ ${err.message}${RST}`);
1144
+ msgs.netError(err.message);
1096
1145
  }
1097
- console.log();
1146
+ writer.scrollback('\n');
1098
1147
  }
1099
1148
  }
1100
1149
 
@@ -1102,10 +1151,10 @@ function createCommands({
1102
1151
  const config = getConfig();
1103
1152
  let response;
1104
1153
  try { response = await dashboardListModels(); }
1105
- 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 }; }
1106
1155
  const models = Array.isArray(response && response.models) ? response.models : [];
1107
- 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 }; }
1108
- 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}`);
1109
1158
  const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
1110
1159
  const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
1111
1160
  const active = model.base_url === config.api_base && model.model_id === config.default_model;
@@ -1114,18 +1163,18 @@ function createCommands({
1114
1163
  const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
1115
1164
  return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
1116
1165
  }, { initialIndex: Math.max(0, activeIndex) });
1117
- 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 }; }
1118
1167
  const selectedModel = models[selectedIndex];
1119
1168
  let credentialsResponse;
1120
1169
  try { credentialsResponse = await dashboardGetModelForCli(selectedModel.id); }
1121
- 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 }; }
1122
1171
  const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
1123
- 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 }; }
1124
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);
1125
1174
  const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
1126
1175
  if (contextLength !== null) updatedConfig.context_length = contextLength;
1127
1176
  setConfig(updatedConfig);
1128
- 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`);
1129
1178
  return { model: model.model_id, dbId: model.id };
1130
1179
  }
1131
1180
 
@@ -1136,55 +1185,59 @@ function createCommands({
1136
1185
  api_key: opts.apiKey || 'any',
1137
1186
  dashboard_url: opts.dashboardUrl || current.dashboard_url,
1138
1187
  auth_token: current.auth_token || '',
1139
- default_model: opts.defaultModel || 'default',
1188
+ default_model: opts.defaultModel || '',
1140
1189
  temperature: 0.7,
1141
1190
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
1142
1191
  stream: true,
1143
1192
  models: current.models,
1144
1193
  };
1145
1194
  setConfig(cfg);
1146
- console.log(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}`);
1147
- 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`);
1148
1196
  }
1149
1197
 
1150
1198
  async function cmdLogin() {
1151
- 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}`);
1152
1200
  let loginRequest;
1153
1201
  try { loginRequest = await requestCliLogin(); }
1154
- catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
1155
- console.log(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}`);
1156
- console.log(` ${FG_CYAN}${loginRequest.verification_url}${RST}`);
1157
- 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}`);
1158
1204
  const startedAt = Date.now();
1159
1205
  while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
1160
1206
  await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
1161
1207
  let status;
1162
1208
  try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
1163
- 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; }
1164
- 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; }
1165
- 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; }
1166
1212
  }
1167
- 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`);
1168
1214
  }
1169
1215
 
1170
1216
  async function cmdWhoAmI() {
1171
1217
  let response;
1172
- 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; }
1173
1219
  const user = response && response.user ? response.user : null;
1174
- if (!user) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
1175
- console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Current User${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
1176
- console.log(formatUserLine('ID', user.id)); console.log(formatUserLine('Email', user.email || '-'));
1177
- console.log(formatUserLine('Name', user.name || '-')); console.log(formatUserLine('Provider', user.provider || '-'));
1178
- if (user.avatar_url) console.log(formatUserLine('Avatar', user.avatar_url));
1179
- 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'));
1180
1233
  }
1181
1234
 
1182
1235
  async function cmdLogout() {
1183
1236
  const config = getConfig();
1184
- if (!config.auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
1185
- 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; } }
1186
1239
  setConfig({ ...config, auth_token: '' });
1187
- 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`);
1188
1241
  }
1189
1242
 
1190
1243
  function printDryRunSummary() {
@@ -1197,15 +1250,24 @@ function createCommands({
1197
1250
  const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
1198
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}║`; };
1199
1252
  const hr40 = (tl, fill, tr) => { const line = tl + fill.repeat(INNER) + tr; return isTTY ? `${FG_TEAL}${line}${RST}` : line; };
1200
- console.log(); console.log(hr40('╔','═','╗'));
1201
- console.log(row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`));
1202
- console.log(hr40('','═',''));
1203
- console.log(row(` ✎ Files that would change: ${files.length} `));
1204
- console.log(row(` ▶ Commands that would run: ${cmds.length} `));
1205
- console.log(row(` Network calls: ${nets.length} `));
1206
- console.log(hr40('╚','═','╝'));
1207
- 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}`); } }
1208
- 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'));
1209
1271
  }
1210
1272
 
1211
1273
  return {