@semalt-ai/code 1.8.0 → 1.8.3

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.
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // Process-level signal/exit wiring for the TUI. The actual terminal cleanup
4
+ // (erase live region, reset SGR, show cursor, pop modes) lives in
5
+ // writer.teardown() so every exit path runs the exact same single-write
6
+ // sequence. This module just makes sure that sequence runs no matter how
7
+ // the process dies.
8
+
9
+ const writer = require('./writer');
10
+
11
+ let _registered = false;
12
+
13
+ // Kept for backwards compatibility with callers that publish viewport
14
+ // metrics. The live-region writer doesn't need them — teardown computes
15
+ // what to erase from its own live-region height.
16
+ function setCleanupInfo(_info) {}
17
+
18
+ // Idempotent. Safe to call from synchronous contexts including the 'exit'
19
+ // handler. Delegates to writer.teardown(), which is the single source of
20
+ // truth for what a "clean" terminal state looks like.
21
+ function teardownTerminal() {
22
+ try { writer.teardown(); } catch {}
23
+ }
24
+
25
+ // Install process-level handlers. Called once by the entrypoint. Idempotent
26
+ // so library code can also call it defensively without doubling handlers.
27
+ function registerTerminalCleanup() {
28
+ if (_registered) return;
29
+ _registered = true;
30
+
31
+ // Normal exit + process.exit(): fires synchronously, last thing Node does.
32
+ // Catches every path that doesn't already manually call teardown.
33
+ process.on('exit', () => { try { writer.teardown(); } catch {} });
34
+
35
+ // Signals that should terminate the app. Cleanup first, then exit with
36
+ // the conventional 128+signum code. In TUI raw mode, Ctrl+C is consumed
37
+ // at the byte level and SIGINT is not delivered, so this handler only
38
+ // trips in non-raw contexts (one-shot commands, readline prompts, etc.).
39
+ process.on('SIGINT', () => { try { writer.teardown(); } catch {} process.exit(130); });
40
+ process.on('SIGTERM', () => { try { writer.teardown(); } catch {} process.exit(143); });
41
+ process.on('SIGHUP', () => { try { writer.teardown(); } catch {} process.exit(129); });
42
+
43
+ // Last-chance net: if something throws outside a try/catch, still
44
+ // restore terminal state before the stack trace prints.
45
+ process.on('uncaughtException', (err) => {
46
+ try { writer.teardown(); } catch {}
47
+ try { console.error(err && err.stack ? err.stack : err); } catch {}
48
+ process.exit(1);
49
+ });
50
+
51
+ process.on('unhandledRejection', (reason) => {
52
+ try { writer.teardown(); } catch {}
53
+ try { console.error(reason && reason.stack ? reason.stack : reason); } catch {}
54
+ process.exit(1);
55
+ });
56
+ }
57
+
58
+ module.exports = { teardownTerminal, registerTerminalCleanup, setCleanupInfo };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const { bg256, fg256, bgRGB, fgRGB } = require('./ansi');
4
+
5
+ // Shared chrome palette. Used by tool-status lines, debug blocks, meta
6
+ // messages, and any other non-content surface. Diff bodies have their own
7
+ // background palette below. Keep palette choices out of renderers — if a
8
+ // colour is added to a status line, it should come from here.
9
+ const UI_THEME = {
10
+ success: fg256(114), // bright mint
11
+ warning: fg256(179), // amber
12
+ error: fg256(174), // salmon
13
+ info: fg256(75), // light blue
14
+ muted: fg256(240), // dim gray — frames, metadata
15
+ subtle: fg256(244), // slightly less dim — secondary text
16
+ accent: fg256(141), // soft purple — tool names, IDs
17
+ default: '\x1b[39m', // terminal default fg
18
+ };
19
+
20
+ // Indicator glyphs for status lines. Separated from colour so the renderer
21
+ // can swap either independently (e.g. an ASCII-only fallback later).
22
+ const UI_ICONS = {
23
+ pending: '●',
24
+ success: '✓',
25
+ error: '✗',
26
+ retry: '⟳',
27
+ warn: '⚠',
28
+ bullet: '●',
29
+ };
30
+
31
+ // Diff rendering palette. Each change-type carries both a 256-color and a
32
+ // truecolor background so the renderer can pick based on COLORTERM at start.
33
+ // Only this file names colors — keep magic numbers out of diff.js.
34
+ const DIFF_THEME = {
35
+ added: {
36
+ bg256: bg256(22), // deep forest green
37
+ bgTC: bgRGB(14, 40, 23),
38
+ signFg: fg256(114), // bright mint
39
+ sign: '+',
40
+ },
41
+ removed: {
42
+ bg256: bg256(52), // deep burgundy
43
+ bgTC: bgRGB(50, 18, 20),
44
+ signFg: fg256(174), // bright salmon
45
+ sign: '-',
46
+ },
47
+ context: {
48
+ bg256: '',
49
+ bgTC: '',
50
+ signFg: '',
51
+ sign: ' ',
52
+ },
53
+ gutter: { bg256: '', bgTC: '' },
54
+ lineNumber: fg256(240), // dim gray
55
+ code: '\x1b[39m', // terminal default fg
56
+ header: '\x1b[1;38;5;75m', // bold light blue
57
+ frame: fg256(238), // very dim gray
58
+ };
59
+
60
+ // Maps an agent-loop tool tag onto the short category shown before the colon
61
+ // in a tool-status line ("file:", "net:", "cmd:"). Renderers should fall
62
+ // back to the tag itself if the tag isn't listed here.
63
+ const TOOL_CATEGORIES = {
64
+ exec: 'cmd', shell: 'cmd', run: 'cmd', run_command: 'cmd',
65
+ read_file: 'file', write_file: 'file', create_file: 'file',
66
+ append_file: 'file', delete_file: 'file', edit_file: 'file',
67
+ list_dir: 'file', search_files: 'file', search_in_file: 'file',
68
+ replace_in_file: 'file', move_file: 'file', copy_file: 'file',
69
+ file_stat: 'file', make_dir: 'file', remove_dir: 'file',
70
+ http_get: 'net', download: 'net', upload: 'net',
71
+ ask_user: 'user',
72
+ store_memory: 'memory', recall_memory: 'memory', list_memories: 'memory',
73
+ get_env: 'env', set_env: 'env',
74
+ system_info: 'system',
75
+ debug: 'debug',
76
+ };
77
+
78
+ module.exports = { DIFF_THEME, UI_THEME, UI_ICONS, TOOL_CATEGORIES };
package/lib/ui/utils.js CHANGED
@@ -7,6 +7,38 @@ function stripAnsi(str) {
7
7
  return str.replace(/\x1b\[[^m]*m/g, '');
8
8
  }
9
9
 
10
+ // Display-column width of a string. Handles ASCII/Latin/Cyrillic (1 col),
11
+ // CJK ideographs + hiragana/katakana (2 cols), emoji in SMP pictograph
12
+ // blocks and the dingbat/misc-symbols range (2 cols), and zero-width joiners,
13
+ // combining marks, variation selectors (0 cols). Out-of-scope scripts
14
+ // (Hangul, Thai, RTL) fall through as 1 col.
15
+ function termWidth(str) {
16
+ if (!str) return 0;
17
+ let w = 0;
18
+ for (const ch of str) {
19
+ const cp = ch.codePointAt(0);
20
+ if (cp === 0) continue;
21
+ if ((cp >= 0x0300 && cp <= 0x036F) ||
22
+ (cp >= 0x200B && cp <= 0x200F) ||
23
+ cp === 0x2060 ||
24
+ (cp >= 0xFE00 && cp <= 0xFE0F) ||
25
+ (cp >= 0xE0100 && cp <= 0xE01EF)) {
26
+ continue;
27
+ }
28
+ if ((cp >= 0x3040 && cp <= 0x33FF) ||
29
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
30
+ (cp >= 0x4E00 && cp <= 0x9FFF) ||
31
+ (cp >= 0xF900 && cp <= 0xFAFF) ||
32
+ (cp >= 0x2600 && cp <= 0x27BF) ||
33
+ (cp >= 0x1F300 && cp <= 0x1FAFF)) {
34
+ w += 2;
35
+ continue;
36
+ }
37
+ w += 1;
38
+ }
39
+ return w;
40
+ }
41
+
10
42
  function hr(char, color) {
11
43
  const { FG_DARK, RST } = require('./ansi');
12
44
  const c = char || '─';
@@ -14,6 +46,36 @@ function hr(char, color) {
14
46
  process.stdout.write(`${col}${c.repeat(getCols())}${RST}\n`);
15
47
  }
16
48
 
49
+ // Repeat a single-column glyph so the visible row is exactly `width` columns.
50
+ // Accepts an optional `used` arg (columns already occupied) to fill the
51
+ // remainder — handy for header lines like " LABEL ══════…" that need to
52
+ // reach the right edge regardless of terminal size.
53
+ function repeatToWidth(char, width, used) {
54
+ const w = Math.max(0, (width || 0) - (used || 0));
55
+ return (char || '─').repeat(w);
56
+ }
57
+
58
+ // Number of *physical* terminal rows that `text` occupies at the given
59
+ // width. Uses termWidth (display columns) so emoji / CJK / combining marks
60
+ // are counted correctly, and ceils each logical line's wrap count. An
61
+ // empty line still occupies 1 row. This is the number to use whenever the
62
+ // redraw logic cursor-ups to erase or when the layout grows to hold new
63
+ // content — counting `text.split('\n').length` undercounts wrapped lines
64
+ // and lets the trailing physical row leak into scrollback on the next
65
+ // redraw (TUI duplicate-frame bug).
66
+ function displayRows(text, termCols) {
67
+ if (text === undefined || text === null) return 0;
68
+ const cols = Math.max(1, termCols | 0);
69
+ const raw = stripAnsi(String(text));
70
+ const lines = raw.split('\n');
71
+ let rows = 0;
72
+ for (const line of lines) {
73
+ const w = termWidth(line);
74
+ rows += w === 0 ? 1 : Math.ceil(w / cols);
75
+ }
76
+ return rows;
77
+ }
78
+
17
79
  function boxLine(text, width) {
18
80
  const { FG_DARK, RST, BOX_V } = require('./ansi');
19
81
  const w = width || (getCols() - 4);
@@ -42,4 +104,4 @@ function isPrintableKey(str, key) {
42
104
  return !/[\x00-\x1f\x7f]/.test(str);
43
105
  }
44
106
 
45
- module.exports = { getCols, getRows, stripAnsi, hr, boxLine, insertCharAt, removeCharAt, isPrintableKey };
107
+ module.exports = { getCols, getRows, stripAnsi, termWidth, displayRows, hr, repeatToWidth, boxLine, insertCharAt, removeCharAt, isPrintableKey };
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ // Single owner of process.stdout for the TUI. All bubble rendering, status
4
+ // bar ticks, and prompt redraws go through this module so their writes can
5
+ // never interleave mid-frame.
6
+ //
7
+ // Layout (bottom-up):
8
+ // SCROLLBACK — everything the writer emits via scrollback(). Immutable
9
+ // after the call resolves. Flows into terminal scrollback
10
+ // naturally as the cursor advances past the bottom.
11
+ // MODAL REGION — optional. 0–N lines pushed via setModal(); cleared via
12
+ // clearModal(). Used for permission pickers, confirmation
13
+ // dialogs, and other modal widgets that must redraw in
14
+ // place on every keystroke without landing in scrollback.
15
+ // STATUS REGION— 1–N lines at the bottom of the viewport. Redrawn in
16
+ // place whenever setLive() is called. Each live line is
17
+ // truncated to (cols − 1) so it occupies exactly one
18
+ // physical row; combined height is modalLines.length +
19
+ // statusLines.length and precise.
20
+ //
21
+ // Invariant: after any operation resolves, the cursor is at column 0 of the
22
+ // row immediately below the status region. Every operation erases the
23
+ // combined modal+status region first (cursor-up + \x1b[J), writes any
24
+ // scrollback content, then redraws modal then status, then leaves the
25
+ // cursor below them.
26
+ //
27
+ // Serialization is a plain Promise chain. process.stdout.write on a TTY is
28
+ // synchronous, so each task's compound write is emitted in one burst and
29
+ // can't interleave with another task's writes.
30
+
31
+ const { stripAnsi } = require('./utils');
32
+
33
+ let _queue = Promise.resolve();
34
+ let _liveLines = [];
35
+ let _modalLines = [];
36
+ let _liveHeight = 0;
37
+ let _destroyed = false;
38
+
39
+ function _cols() { return process.stdout.columns || 80; }
40
+
41
+ // ANSI-aware truncate to (cols-1) visible columns so each rendered live
42
+ // line occupies exactly one physical terminal row — no wrap, no height
43
+ // mis-count. Visible width uses stripAnsi (ASCII/basic). Live-region chrome
44
+ // is short and single-line; if it ever overflows we lose the tail, which
45
+ // is fine for chrome.
46
+ function _fitOneRow(line) {
47
+ const max = Math.max(0, _cols() - 1);
48
+ if (!line) return '';
49
+ if (stripAnsi(line).length <= max) return line;
50
+ let out = '';
51
+ let vis = 0;
52
+ let i = 0;
53
+ while (i < line.length && vis < max) {
54
+ if (line[i] === '\x1b' && line[i + 1] === '[') {
55
+ const m = line.slice(i).match(/^\x1b\[[0-9;?]*[a-zA-Z]/);
56
+ if (m) { out += m[0]; i += m[0].length; continue; }
57
+ }
58
+ out += line[i];
59
+ vis++;
60
+ i++;
61
+ }
62
+ return out + '\x1b[0m';
63
+ }
64
+
65
+ function _eraseLiveSeq() {
66
+ if (_liveHeight <= 0) return '';
67
+ return `\x1b[${_liveHeight}A\r\x1b[J`;
68
+ }
69
+
70
+ function _drawLiveSeq() {
71
+ const total = _modalLines.length + _liveLines.length;
72
+ if (total === 0) { _liveHeight = 0; return ''; }
73
+ let out = '';
74
+ for (let i = 0; i < _modalLines.length; i++) {
75
+ out += _fitOneRow(_modalLines[i]);
76
+ out += '\n';
77
+ }
78
+ for (let i = 0; i < _liveLines.length; i++) {
79
+ out += _fitOneRow(_liveLines[i]);
80
+ out += '\n';
81
+ }
82
+ _liveHeight = total;
83
+ return out;
84
+ }
85
+
86
+ function _writeSync(s) {
87
+ if (!s) return;
88
+ try { process.stdout.write(s); } catch {}
89
+ }
90
+
91
+ function _enqueue(task) {
92
+ _queue = _queue.then(() => {
93
+ if (_destroyed) return;
94
+ try { task(); } catch {}
95
+ });
96
+ return _queue;
97
+ }
98
+
99
+ // Append text to scrollback (above the live region). The compound write
100
+ // erases the live region, emits text, redraws the live region — all in a
101
+ // single process.stdout.write so nothing can interleave.
102
+ function scrollback(text) {
103
+ if (text === undefined || text === null || text === '') return _queue;
104
+ return _enqueue(() => {
105
+ let out = String(text);
106
+ if (!out.endsWith('\n')) out += '\n';
107
+ _writeSync(_eraseLiveSeq() + out + _drawLiveSeq());
108
+ });
109
+ }
110
+
111
+ // Replace the live region with `lines` (array of strings, usually 1–10).
112
+ // Each line is truncated to cols-1 and occupies exactly one physical row.
113
+ function setLive(lines) {
114
+ return _enqueue(() => {
115
+ _liveLines = Array.isArray(lines) ? lines.map((l) => (l == null ? '' : String(l))) : [];
116
+ _writeSync(_eraseLiveSeq() + _drawLiveSeq());
117
+ });
118
+ }
119
+
120
+ // Erase the live region entirely — used on teardown and when handing the
121
+ // terminal off to a sub-renderer (e.g. interactive menus). Drops the modal
122
+ // too so the sub-renderer doesn't paint over a ghost modal.
123
+ function clearLive() {
124
+ return _enqueue(() => {
125
+ _writeSync(_eraseLiveSeq());
126
+ _liveLines = [];
127
+ _modalLines = [];
128
+ _liveHeight = 0;
129
+ });
130
+ }
131
+
132
+ // Replace the modal region with `lines`. Modal sits above status and below
133
+ // scrollback. Used for permission pickers, confirm dialogs, text input
134
+ // prompts, etc. — any widget that must redraw on every keystroke without
135
+ // filling scrollback. Status region is untouched; scrollback is untouched.
136
+ function setModal(lines) {
137
+ return _enqueue(() => {
138
+ _modalLines = Array.isArray(lines) ? lines.map((l) => (l == null ? '' : String(l))) : [];
139
+ _writeSync(_eraseLiveSeq() + _drawLiveSeq());
140
+ });
141
+ }
142
+
143
+ // Remove the modal region. Status region stays where it was.
144
+ function clearModal() {
145
+ return _enqueue(() => {
146
+ if (_modalLines.length === 0) return;
147
+ _modalLines = [];
148
+ _writeSync(_eraseLiveSeq() + _drawLiveSeq());
149
+ });
150
+ }
151
+
152
+ function hasModal() { return _modalLines.length > 0; }
153
+
154
+ // Re-emit the current live region at the current terminal width. Called on
155
+ // resize: the truncation window changes but the underlying line data is the
156
+ // same.
157
+ function redrawLive() {
158
+ return _enqueue(() => {
159
+ _writeSync(_eraseLiveSeq() + _drawLiveSeq());
160
+ });
161
+ }
162
+
163
+ // Resolve once every enqueued task has run. Used at exit / hand-off points.
164
+ function flush() { return _queue; }
165
+
166
+ // Enqueue an arbitrary synchronous side-effect so it runs in order with
167
+ // writes. Handy for terminal mode toggles (cursor show/hide, bracketed
168
+ // paste on/off) that must not land inside another task's compound write.
169
+ // The callback can use process.stdout.write directly for raw escapes but
170
+ // must not produce scrollback output — use scrollback() for that.
171
+ function enqueue(fn) {
172
+ return _enqueue(() => { fn(); });
173
+ }
174
+
175
+ function getLiveHeight() { return _liveHeight; }
176
+ function getLiveLines() { return _liveLines.slice(); }
177
+
178
+ function destroy() { _destroyed = true; }
179
+
180
+ // Synchronous, single-write cleanup for every exit path. Erases the live
181
+ // region (which is chrome, not content), resets the terminal modes the TUI
182
+ // turned on at startup, and lands the cursor at column 0 of the row
183
+ // immediately below the last scrollback line — so the shell prompt prints
184
+ // cleanly under the session's last visible content.
185
+ //
186
+ // Must be idempotent: 'exit' fires after handlers that already called
187
+ // teardown manually, and signal handlers may overlap during a fast Ctrl+C.
188
+ //
189
+ // Must be a single process.stdout.write of one pre-built string. Anything
190
+ // else risks interleaving with a queued task that hadn't drained yet, or
191
+ // with another writer-bypassing module emitting partial output. Setting
192
+ // _destroyed before the write turns every queued task into a no-op so they
193
+ // can't repaint over our cleanup.
194
+ let _torn = false;
195
+ function teardown() {
196
+ if (_torn) return;
197
+ _torn = true;
198
+ _destroyed = true;
199
+
200
+ // Non-TTY: no escape sequences were ever emitted, nothing to clean up.
201
+ if (!process.stdout.isTTY) return;
202
+
203
+ const parts = [];
204
+
205
+ // Erase the live region. Cursor invariant places it at the row immediately
206
+ // below the live region; cursor-up by N lands on the first live row, then
207
+ // \x1b[J wipes from there to end of screen. After this the cursor is at
208
+ // column 0 of the row immediately after the last scrollback line — exactly
209
+ // where the shell prompt should appear.
210
+ if (_liveHeight > 0) {
211
+ parts.push(`\x1b[${_liveHeight}A\r\x1b[J`);
212
+ } else {
213
+ // No live region active: ensure cursor is at column 0 so a shell prompt
214
+ // doesn't print mid-line if some upstream write left the cursor drifting.
215
+ parts.push('\r');
216
+ }
217
+
218
+ parts.push('\x1b[0m'); // reset all SGR attributes
219
+ parts.push('\x1b[?25h'); // show OS cursor
220
+ parts.push('\x1b[r'); // reset DECSTBM scroll region (no-op if unset)
221
+ parts.push('\x1b[<u'); // pop kitty keyboard protocol
222
+ parts.push('\x1b[>4;0m'); // reset xterm modifyOtherKeys
223
+ parts.push('\x1b[?2004l'); // disable bracketed paste
224
+
225
+ _liveLines = [];
226
+ _modalLines = [];
227
+ _liveHeight = 0;
228
+
229
+ try { process.stdout.write(parts.join('')); } catch {}
230
+
231
+ // Restore stdin to cooked mode so the shell receives keystrokes normally.
232
+ // Done after the stdout write so a slow/blocking setRawMode can't delay
233
+ // the visible cleanup.
234
+ try {
235
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
236
+ process.stdin.setRawMode(false);
237
+ }
238
+ } catch {}
239
+ }
240
+
241
+ module.exports = {
242
+ scrollback,
243
+ setLive,
244
+ clearLive,
245
+ setModal,
246
+ clearModal,
247
+ hasModal,
248
+ redrawLive,
249
+ flush,
250
+ enqueue,
251
+ getLiveHeight,
252
+ getLiveLines,
253
+ destroy,
254
+ teardown,
255
+ };
package/lib/ui.js CHANGED
@@ -10,6 +10,7 @@ const history = require('./ui/chat-history');
10
10
  const statusB = require('./ui/status-bar');
11
11
  const input = require('./ui/input-field');
12
12
  const createUi = require('./ui/create-ui');
13
+ const terminal = require('./ui/terminal');
13
14
 
14
15
  module.exports = {
15
16
  // ANSI constants
@@ -44,4 +45,8 @@ module.exports = {
44
45
 
45
46
  // Factory
46
47
  createUI: createUi.createUI,
48
+
49
+ // Terminal teardown
50
+ teardownTerminal: terminal.teardownTerminal,
51
+ registerTerminalCleanup: terminal.registerTerminalCleanup,
47
52
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semalt-ai/code",
3
- "version": "1.8.0",
3
+ "version": "1.8.3",
4
4
  "description": "Self-hosted AI Coding Assistant CLI",
5
5
  "main": "index.js",
6
6
  "bin": {