@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.
- package/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
|
@@ -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 };
|
package/lib/ui/theme.js
ADDED
|
@@ -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 };
|
package/lib/ui/writer.js
ADDED
|
@@ -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
|
};
|