@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.
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const writer = require('./ui/writer');
4
+ const messages = require('./ui/messages');
5
+
3
6
  const TIER_FS = ['read_file', 'write_file', 'append_file', 'delete_file', 'list_dir', 'make_dir', 'move_file', 'copy_file', 'file_stat', 'search_files', 'store_memory', 'recall_memory'];
4
7
  const TIER_EXEC = ['exec'];
5
8
  const TIER_NET = ['http_get', 'download'];
@@ -117,7 +120,7 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
117
120
  if (uiCallbacks) {
118
121
  uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
119
122
  } else {
120
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
123
+ messages.sysSuccess(`Auto-approved: ${description}`);
121
124
  }
122
125
  }
123
126
 
@@ -133,7 +136,7 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
133
136
  }
134
137
 
135
138
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
136
- process.stdout.write(` [non-TTY] Auto-approving: ${description}\n`);
139
+ writer.scrollback(` [non-TTY] Auto-approving: ${description}`);
137
140
  return true;
138
141
  }
139
142
 
@@ -155,13 +158,11 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
155
158
  return true;
156
159
  }
157
160
 
158
- // Fallback: legacy TTY interactive select (used outside of chat UI)
161
+ // Fallback: TTY interactive select (used outside of chat UI)
159
162
  const alwaysLabel = tag ? `Yes, always for <${tag}>` : 'Yes, always';
160
163
  const choices = ['Yes', alwaysLabel, 'No'];
161
164
 
162
- console.log();
163
- console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
164
- console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
165
+ writer.scrollback(`\n ${FG_YELLOW}${BOLD}⚠ Permission required${RST}\n ${FG_GRAY}${actionType}: ${description}${RST}`);
165
166
 
166
167
  const selectedIndex = await interactiveSelect(
167
168
  choices,
@@ -174,13 +175,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
174
175
  );
175
176
 
176
177
  if (selectedIndex === null || selectedIndex === 2) {
177
- console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
178
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
178
179
  return false;
179
180
  }
180
181
 
181
182
  if (selectedIndex === 1 && tag) {
182
183
  state.sessionApprovedTags.add(tag);
183
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for <${tag}> this session${RST}`);
184
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for <${tag}> this session${RST}`);
184
185
  }
185
186
 
186
187
  return true;
package/lib/prompts.js CHANGED
@@ -94,12 +94,9 @@ ${TAG_INVENTORY}
94
94
  7. Be concise. Provide working solutions. Use markdown for code blocks in explanations.
95
95
  8. Current working directory: __CWD__
96
96
 
97
- Response contract (strict):
98
- Every response must end with exactly one of:
99
- (a) a tool call, using one of the XML tags listed above; OR
100
- (b) <final_answer>...</final_answer> containing your answer to the user.
101
- A response containing neither is invalid and will be rejected.
102
- Do not describe actions in prose outside these two forms. Either call the tool, or wrap your final reply in <final_answer>.`;
97
+ Response contract:
98
+ - If the task requires an action, emit the appropriate tool tag(s) — do not narrate intended actions in prose without the tag.
99
+ - If the task is complete or the user's question can be answered directly without running a tool, reply in plain prose. No special wrapper is needed.`;
103
100
 
104
101
  const NATIVE_SYSTEM_PROMPT_TEMPLATE = `You are Semalt.AI, an expert AI coding assistant running in the user's terminal. Use the provided tools to execute shell commands and file operations; do not just print instructions. Each call is approved by the user before execution, and the result is returned to you for the next step.
105
102
 
@@ -107,7 +104,7 @@ Use \`<think>...</think>\` for internal reasoning (runtime-handled; never emit a
107
104
 
108
105
  Be concise. Use markdown for code blocks in explanations. Current working directory: __CWD__
109
106
 
110
- Response contract: every response must end with either (a) one or more tool calls, or (b) <final_answer>...</final_answer> containing your answer to the user. Prose outside those two forms is invalid.`;
107
+ Response contract: if the task requires an action, emit one or more tool calls do not narrate intended actions in prose without the tool call. Otherwise, answer in plain prose; no special wrapper is needed.`;
111
108
 
112
109
  function getSystemPrompt(nativeTools = false) {
113
110
  const template = nativeTools ? NATIVE_SYSTEM_PROMPT_TEMPLATE : SYSTEM_PROMPT_TEMPLATE;
package/lib/tools.js CHANGED
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const { spawn } = require('child_process');
10
10
 
11
11
  const { logToolCall } = require('./audit');
12
+ const writer = require('./ui/writer');
12
13
 
13
14
  const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
14
15
 
@@ -22,6 +23,7 @@ function getSkippedOps() { return _skippedOps.slice(); }
22
23
  let _uiActive = false;
23
24
  function setUIActive(v) { _uiActive = v; }
24
25
  function isUIActive() { return _uiActive; }
26
+ // audit: allowed — fires only when TUI is inactive (one-shot non-TUI commands), no live region to protect.
25
27
  function _log(...args) { if (!_uiActive) console.log(...args); }
26
28
 
27
29
  // Reject writes outside the project CWD and in sensitive system/home dirs
@@ -164,7 +166,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
164
166
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
165
167
  }
166
168
  logToolCall('read_file', { path: filePath }, true, 'ok');
167
- return { content: data, path: filePath };
169
+ return { content: data, path: filePath, bytes: Buffer.byteLength(data, 'utf8') };
168
170
  } catch (error) {
169
171
  _log(` ${FG_RED}✗ ${error.message}${RST}`);
170
172
  logToolCall('read_file', { path: filePath }, true, 'error');
@@ -202,7 +204,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
202
204
  const diffOutput = _uiActive
203
205
  ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
204
206
  : renderDiff(existing, finalContent, filePath);
205
- if (!_uiActive) process.stdout.write(diffOutput + '\n');
207
+ if (!_uiActive) writer.scrollback(diffOutput);
206
208
 
207
209
  // Dry-run: record the skipped op and return without writing
208
210
  if (_dryRun) {
@@ -212,7 +214,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
212
214
  return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
213
215
  }
214
216
 
215
- // Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
217
+ // Permission check — routes through TUI dialog in chat mode, interactiveSelect in non-TUI flows
216
218
  let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
217
219
  if (content) desc += ` (${content.length} chars)`;
218
220
  if (_uiActive) desc = `${desc}\n${diffOutput}`;
@@ -715,18 +717,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
715
717
  }
716
718
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
717
719
  logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
718
- resolve({ status_code: res.statusCode, body });
720
+ // `bytes` is the total transferred payload length (pre-cap);
721
+ // consumers that want to know the wire size without parsing
722
+ // the appended truncation note rely on this.
723
+ resolve({ status_code: res.statusCode, body, bytes: totalBytes });
719
724
  });
720
725
  });
721
726
  req.on('error', (err) => {
722
727
  _log(` ${FG_RED}✗ ${err.message}${RST}`);
723
728
  logToolCall('http_get', { url: target }, true, 'error');
724
- resolve({ error: err.message });
729
+ resolve({ error: err.message, error_code: err.code });
725
730
  });
726
731
  req.setTimeout(reqTimeoutMs, () => {
727
732
  req.destroy();
728
733
  logToolCall('http_get', { url: target }, true, 'error');
729
- resolve({ error: 'Request timeout' });
734
+ resolve({ error: 'Request timeout', error_code: 'ETIMEDOUT' });
730
735
  });
731
736
  }
732
737
  doGet(url, 5);
@@ -747,10 +752,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
747
752
  return { question, answer: selected || options[0] };
748
753
  }
749
754
  if (!process.stdout.isTTY || process.stdin.isRaw) {
750
- process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}\n`);
755
+ writer.scrollback(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}`);
751
756
  logToolCall('ask_user', { question }, true, 'ok');
752
757
  return { question, answer: 'y' };
753
758
  }
759
+ // audit: allowed — inline prompt without trailing newline; unreachable when TUI writer is active
760
+ // (process.stdin.isRaw is true while the TUI input field holds raw mode).
754
761
  process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${FG_GRAY}>${RST} `);
755
762
  const buf = Buffer.alloc(4096);
756
763
  let input = '';
@@ -142,14 +142,24 @@ class ChatHistory {
142
142
  }
143
143
  out += '\n';
144
144
  } else if (msg.role === 'tool') {
145
- const indicator = msg.isError
146
- ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
147
- : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
148
- const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
149
- const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
150
- const desc = _dimPaths(content);
151
- out = ` ${indicator} ${head} ${desc}\n`;
152
- lineCount = 1;
145
+ // Tool summary header. The writer's activity region now commits the
146
+ // primary "glyph · category · op · duration · meta" line directly to
147
+ // scrollback via endActivity — when a caller supplies an empty
148
+ // `content` they're signalling that the header is already present
149
+ // and only the expandable output body should render here.
150
+ if (content) {
151
+ const indicator = msg.isError
152
+ ? `${UI_THEME.error}${UI_ICONS.error}${RST}`
153
+ : `${UI_THEME.success}${UI_ICONS.success}${RST}`;
154
+ const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
155
+ const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
156
+ const desc = _dimPaths(content);
157
+ out = ` ${indicator} ${head} ${desc}\n`;
158
+ lineCount = 1;
159
+ } else {
160
+ out = '';
161
+ lineCount = 0;
162
+ }
153
163
  if (msg.output) {
154
164
  const wrapAt = Math.max(60, getCols() - 8);
155
165
  const outLines = [];
@@ -309,6 +319,7 @@ class ChatHistory {
309
319
  // the clear can't land inside a pending scrollback burst, then redraw
310
320
  // the live region under the new cursor.
311
321
  writer.enqueue(() => {
322
+ // audit: allowed — viewport clear inside writer.enqueue (sanctioned escape hatch).
312
323
  try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
313
324
  });
314
325
  writer.redrawLive();
@@ -2,7 +2,7 @@
2
2
 
3
3
  const readline = require('readline');
4
4
 
5
- const { RST, DIM, FG_YELLOW, FG_CYAN, FG_GRAY, BG_SELECTED } = require('./ansi');
5
+ const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
6
6
  const { ChatHistory } = require('./chat-history');
7
7
  const writer = require('./writer');
8
8
  const { registerTerminalCleanup } = require('./terminal');
@@ -10,9 +10,11 @@ const { registerTerminalCleanup } = require('./terminal');
10
10
  function _createNoOpUI() {
11
11
  const chatHistory = {
12
12
  addMessage: (msg) => {
13
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
13
14
  if (msg.role === 'assistant' || msg.role === 'user')
14
15
  process.stdout.write(`[${msg.role}] ${msg.content || ''}\n`);
15
16
  },
17
+ // audit: allowed — non-TUI no-op UI fallback, no live region to protect.
16
18
  streamToken: (token) => process.stdout.write(token),
17
19
  clearStreamingContent: () => {},
18
20
  finalizeLastMessage: () => {},
@@ -25,8 +27,9 @@ function _createNoOpUI() {
25
27
  };
26
28
  const statusBar = {
27
29
  update: () => {}, updateMetrics: () => {}, onToken: () => {},
28
- drawSeparator: () => {}, liveUpdate: () => {}, _renderBar: () => {},
29
- setModel: () => {}, destroy: () => {},
30
+ drawSeparator: () => {}, _renderBar: () => {},
31
+ setModel: () => {}, setContextLimit: () => {}, setReportedContext: () => {},
32
+ addPendingTokens: () => {}, destroy: () => {},
30
33
  };
31
34
  let _submitCb = null;
32
35
  const inputField = {
@@ -59,7 +62,7 @@ function createUI(opts) {
59
62
  const { LayoutManager } = require('./layout');
60
63
  const { FullStatusBar } = require('./status-bar');
61
64
  const { InputField } = require('./input-field');
62
- const { interactiveSelect } = require('./legacy');
65
+ const { interactiveSelect } = require('./select');
63
66
 
64
67
  const layout = new LayoutManager();
65
68
  const chatHistory = new ChatHistory();
@@ -93,24 +96,38 @@ function createUI(opts) {
93
96
  // chrome has a visible top edge.
94
97
  if (_sb) lines.push(_sb.renderSeparator());
95
98
 
96
- // Status bar.
97
- if (_sb) {
98
- const statusLine = _sb.renderLine();
99
- if (statusLine !== null && statusLine !== undefined) {
100
- lines.push(statusLine);
101
- }
102
- }
99
+ // Status bar. ALWAYS pushed — the row is a permanent fixture of the
100
+ // live region so the input and hint rows below it keep a stable
101
+ // vertical position. `renderLine()` is contractually non-null; missing
102
+ // data (model, tokens) renders as a short placeholder inside the row.
103
+ if (_sb) lines.push(_sb.renderLine());
103
104
 
104
105
  // Input row(s).
106
+ let caret = null;
105
107
  if (_inputField) {
106
- for (const row of _inputField.renderInputLines()) {
108
+ const inputLines = _inputField.renderInputLines();
109
+ for (const row of inputLines) {
107
110
  lines.push(row);
108
111
  }
109
112
  // Hints row.
110
113
  lines.push(_inputField.renderHintsLine());
114
+
115
+ // Caret: translate the input-local { line, col } into the live
116
+ // region's "rows up from the cursor's post-draw position". The
117
+ // post-draw cursor lands one row below the final hints row, so:
118
+ // rowsFromBottom = 1 (for hints) + (inputLines.length - caret.line)
119
+ // Leaving caret null keeps the OS cursor hidden — used while input
120
+ // is disabled or a navigation capture is active.
121
+ const inCaret = _inputField.getCaretPosition && _inputField.getCaretPosition();
122
+ if (inCaret && inputLines.length > 0) {
123
+ caret = {
124
+ rowsFromBottom: 1 + (inputLines.length - inCaret.line),
125
+ col: inCaret.col,
126
+ };
127
+ }
111
128
  }
112
129
 
113
- writer.setLive(lines);
130
+ writer.setLive(lines, caret);
114
131
  }
115
132
 
116
133
  _sb = new FullStatusBar(layout, _updateLive);
@@ -136,31 +153,34 @@ function createUI(opts) {
136
153
 
137
154
  // ── captureSelect (modal menu) ───────────────────────────────────────────────
138
155
  //
139
- // Interactive menus need to take over the bottom of the screen. Clear
140
- // the live region, let interactiveSelect draw into scrollback-like space,
141
- // then rebuild live on resume.
156
+ // Numbered-options picker for tools like ask_user. interactiveSelect
157
+ // renders each frame into the writer's modal region (above status,
158
+ // below scrollback) and routes keys through the input field's
159
+ // captureNavigation API — so the menu cohabits with the live region
160
+ // instead of taking over the screen, and Enter/Esc resolve in place.
142
161
  inputField.captureSelect = (menu) => new Promise((resolve) => {
143
162
  if (!process.stdin.isTTY) {
144
163
  resolve(menu.options[0]);
145
164
  return;
146
165
  }
147
- inputField.suspend();
148
- writer.clearLive().then(() => {
149
- interactiveSelect(
150
- menu.options,
151
- (opt, isSelected, isFinal) => {
152
- if (isSelected && !isFinal)
153
- return ` ${FG_YELLOW}❯${RST} ${BG_SELECTED}${FG_CYAN}${opt}${RST}`;
154
- if (isSelected)
155
- return ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`;
156
- return ` ${FG_GRAY}${opt}${RST}`;
166
+ interactiveSelect(
167
+ menu.options,
168
+ (opt, isSelected) => isSelected
169
+ ? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${opt}${RST}`
170
+ : ` ${FG_GRAY}${opt}${RST}`,
171
+ {
172
+ initialIndex: 0,
173
+ onExpand: () => chatHistory.toggleLastExpand(),
174
+ captureNavigation: (handler) => {
175
+ inputField.captureNavigation(handler);
176
+ return () => inputField.releaseNavigation();
157
177
  },
158
- { initialIndex: 0, onExpand: () => chatHistory.toggleLastExpand() }
159
- ).then((idx) => {
160
- inputField.resume();
161
- _updateLive();
162
- resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
163
- });
178
+ }
179
+ ).then((idx) => {
180
+ // Cancel returns null. Match the prior contract: pick the last
181
+ // option (typically "No"/decline) so callers don't need to
182
+ // special-case cancellation.
183
+ resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
164
184
  });
165
185
  });
166
186
 
@@ -181,6 +201,7 @@ function createUI(opts) {
181
201
  // starts in a known state, then paint the first live frame below a fresh
182
202
  // cursor position.
183
203
  writer.enqueue(() => {
204
+ // audit: allowed — terminal-mode raw escape inside writer.enqueue (sanctioned escape hatch).
184
205
  try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H\x1b[?25l'); } catch {}
185
206
  });
186
207
  // Pre-render both input and hints so the first _updateLive has valid
@@ -198,17 +219,21 @@ function createUI(opts) {
198
219
  // ── Destroy ──────────────────────────────────────────────────────────────────
199
220
  // Stop timers + stdin listeners FIRST so no further writes can be queued,
200
221
  // then run writer.teardown(). teardown is a single synchronous stdout
201
- // write that erases the live region and resets terminal state. After it
202
- // returns, the cursor sits at column 0 of the row immediately below the
203
- // last scrollback line so any subsequent console.log (goodbye banner,
204
- // metrics summary, resume hint) lands cleanly under the session content.
205
- function destroy() {
222
+ // write that erases the live region, emits any end-of-session artifacts
223
+ // passed in (session summary, resume hint, goodbye) as regular scrollback
224
+ // content, and resets terminal state. After it returns, the cursor sits
225
+ // at column 0 of the row immediately below those artifacts — ready for
226
+ // the shell prompt. Callers that want artifacts emitted at exit must
227
+ // pass them here rather than console.log-ing after destroy(); doing it
228
+ // after destroy races with terminal-mode restoration and can leave
229
+ // artifacts overlaid on scrollback with the cursor drifting mid-viewport.
230
+ function destroy(teardownOpts) {
206
231
  if (_destroyCalled) return;
207
232
  _destroyCalled = true;
208
233
  try { inputField.destroy(); } catch {}
209
234
  try { sb.destroy(); } catch {}
210
235
  try { layout.destroy(); } catch {}
211
- writer.teardown();
236
+ writer.teardown(teardownOpts);
212
237
  }
213
238
 
214
239
  return { chatHistory, statusBar: sb, inputField, layout, destroy, redrawFixed: _updateLive };
package/lib/ui/diff.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { FG_DARK, FG_RED, FG_GREEN, FG_GRAY, FG_YELLOW, FG_TEAL, RST, THEME, EL, hasTruecolor } = require('./ansi');
4
4
  const { getCols, stripAnsi, termWidth } = require('./utils');
5
5
  const { DIFF_THEME, UI_THEME } = require('./theme');
6
+ const writer = require('./writer');
6
7
 
7
8
  function diffLines(oldLines, newLines) {
8
9
  const m = oldLines.length, n = newLines.length;
@@ -206,7 +207,7 @@ function _mdInline(text) {
206
207
  }
207
208
 
208
209
  function renderMarkdown(text) {
209
- if (!process.stdout.isTTY) { process.stdout.write(text); return; }
210
+ if (!process.stdout.isTTY) { writer.scrollback(text); return; }
210
211
  const { loadConfig } = require('../config');
211
212
  const maxLines = (loadConfig().max_output_lines) || 50;
212
213
  const cols = getCols();
@@ -248,8 +249,8 @@ function renderMarkdown(text) {
248
249
  }
249
250
  let overflow = 0, printLines = output;
250
251
  if (output.length > maxLines) { overflow = output.length - maxLines; printLines = output.slice(0, maxLines); }
251
- for (const l of printLines) process.stdout.write(l + '\n');
252
- if (overflow > 0) process.stdout.write(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset + '\n');
252
+ if (printLines.length > 0) writer.scrollback(printLines.join('\n'));
253
+ if (overflow > 0) writer.scrollback(THEME.dim + '[... ' + overflow + ' more lines]' + THEME.reset);
253
254
  }
254
255
 
255
256
  module.exports = { renderDiff, renderMarkdown, _mdInline };