@semalt-ai/code 1.8.3 → 1.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +14 -7
- package/lib/agent.js +189 -58
- package/lib/api.js +11 -34
- package/lib/commands.js +206 -121
- package/lib/config.js +1 -0
- package/lib/constants.js +1 -1
- package/lib/permissions.js +9 -8
- package/lib/prompts.js +4 -7
- package/lib/tools.js +14 -7
- package/lib/ui/chat-history.js +19 -8
- package/lib/ui/create-ui.js +63 -38
- package/lib/ui/diff.js +4 -3
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +134 -59
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +135 -28
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +2 -0
- package/lib/ui/theme.js +25 -4
- package/lib/ui/utils.js +94 -27
- package/lib/ui/writer.js +393 -45
- package/lib/ui.js +6 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/ui/format.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure formatters for the tool-line chrome. Kept zero-dep and side-effect
|
|
4
|
+
// free: every function maps inputs → string, does not touch stdout, and does
|
|
5
|
+
// not read config or state. Consumed by:
|
|
6
|
+
// - commands.js — builds pending + final activity lines
|
|
7
|
+
// - writer.js — nothing (writer is layout-only)
|
|
8
|
+
//
|
|
9
|
+
// Layout contract for a tool line (all four segments are " · "-joined):
|
|
10
|
+
//
|
|
11
|
+
// <glyph> <category> · <operation> · <duration> · <meta>
|
|
12
|
+
//
|
|
13
|
+
// glyph ● pending (muted) / ✓ success / ✗ failure
|
|
14
|
+
// category 5-col padded short tag ("net ", "file ", "shell", …)
|
|
15
|
+
// operation verb + target ("GET https://x", "write /tmp/x", "npm install")
|
|
16
|
+
// duration formatDuration(ms), pending lines trail with "…"
|
|
17
|
+
// meta type-specific tail — exit codes, byte counts, match counts, …
|
|
18
|
+
|
|
19
|
+
const { RST, DIM } = require('./ansi');
|
|
20
|
+
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
21
|
+
|
|
22
|
+
// Adaptive precision. ms < 1s shows "Nms", under a minute shows "N.Ns"
|
|
23
|
+
// (sub-10s) or "Ns", under an hour shows "MmSs", above uses "HhMm". Never
|
|
24
|
+
// returns the empty string for a valid number — callers strip the value
|
|
25
|
+
// when they want no duration rendered.
|
|
26
|
+
function formatDuration(ms) {
|
|
27
|
+
if (ms == null || !Number.isFinite(ms)) return '';
|
|
28
|
+
const v = ms < 0 ? 0 : ms;
|
|
29
|
+
if (v < 1000) return `${Math.round(v)}ms`;
|
|
30
|
+
if (v < 60_000) {
|
|
31
|
+
const s = v / 1000;
|
|
32
|
+
return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
|
|
33
|
+
}
|
|
34
|
+
if (v < 3_600_000) {
|
|
35
|
+
const totalSec = Math.floor(v / 1000);
|
|
36
|
+
return `${Math.floor(totalSec / 60)}m${totalSec % 60}s`;
|
|
37
|
+
}
|
|
38
|
+
const totalMin = Math.floor(v / 60_000);
|
|
39
|
+
return `${Math.floor(totalMin / 60)}h${totalMin % 60}m`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Human-readable byte count. Single decimal below 10, none above. Scales
|
|
43
|
+
// through KB/MB/GB. Rejects NaN/negative by returning '' so callers can
|
|
44
|
+
// drop the meta fragment entirely.
|
|
45
|
+
function formatBytes(n) {
|
|
46
|
+
if (n == null || !Number.isFinite(n) || n < 0) return '';
|
|
47
|
+
if (n < 1024) return `${Math.round(n)} B`;
|
|
48
|
+
const kb = n / 1024;
|
|
49
|
+
if (kb < 1024) return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`;
|
|
50
|
+
const mb = kb / 1024;
|
|
51
|
+
if (mb < 1024) return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`;
|
|
52
|
+
const gb = mb / 1024;
|
|
53
|
+
return gb < 10 ? `${gb.toFixed(1)} GB` : `${Math.round(gb)} GB`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Short tag for HTTP / network errors. Prefers Node's err.code when
|
|
57
|
+
// available (authoritative) and falls back to substring matches against
|
|
58
|
+
// err.message for non-Node-originated errors (our own "Request timeout"
|
|
59
|
+
// sentinel, for example).
|
|
60
|
+
function formatHttpErrorTag(err) {
|
|
61
|
+
if (!err) return 'error';
|
|
62
|
+
const code = err.code || '';
|
|
63
|
+
const msg = String(err.message || err).toLowerCase();
|
|
64
|
+
if (code === 'ETIMEDOUT' || /timed out|timeout/.test(msg)) return 'timeout';
|
|
65
|
+
if (code === 'ECONNREFUSED' || /refused/.test(msg)) return 'refused';
|
|
66
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || /getaddrinfo|dns/.test(msg)) return 'dns';
|
|
67
|
+
if (code === 'ECONNRESET' || /reset by peer|econnreset/.test(msg)) return 'reset';
|
|
68
|
+
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || /certificate|cert/.test(msg)) return 'cert';
|
|
69
|
+
if (code === 'EHOSTUNREACH' || /unreachable/.test(msg)) return 'unreachable';
|
|
70
|
+
return 'error';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The XML-tag extractor and native-function mapper hand the agent loop
|
|
74
|
+
// *action* names (read, write, append) that differ from the *tag* names
|
|
75
|
+
// (read_file, write_file, append_file) indexed by TAG_REGISTRY and
|
|
76
|
+
// TOOL_CATEGORIES. Normalize here so downstream category/label lookups
|
|
77
|
+
// resolve regardless of which code path produced the call tuple.
|
|
78
|
+
const ACTION_TO_TAG = {
|
|
79
|
+
read: 'read_file',
|
|
80
|
+
write: 'write_file',
|
|
81
|
+
append: 'append_file',
|
|
82
|
+
exec: 'shell',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function _normalizeTag(tag) {
|
|
86
|
+
return ACTION_TO_TAG[tag] || tag || 'tool';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5-column label. Wider than "net"/"file" so concurrent lines align their
|
|
90
|
+
// operation columns; category names of 5 chars ("shell") fit exactly.
|
|
91
|
+
const CATEGORY_WIDTH = 5;
|
|
92
|
+
|
|
93
|
+
function _categoryLabel(tag) {
|
|
94
|
+
const cat = TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool';
|
|
95
|
+
return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _truncate(text, max) {
|
|
99
|
+
if (text == null) return '';
|
|
100
|
+
const s = String(text);
|
|
101
|
+
if (s.length <= max) return s;
|
|
102
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Verb + target string. Never more than ~80 chars visible — longer URLs,
|
|
106
|
+
// commands, paths are head-tail truncated so the line stays on one row
|
|
107
|
+
// even in 120-col terminals with metadata appended.
|
|
108
|
+
function _operation(tag, arg, attrs) {
|
|
109
|
+
const a = attrs || {};
|
|
110
|
+
const max = 80;
|
|
111
|
+
const pathOrArg = (k) => a[k] || arg || '';
|
|
112
|
+
switch (_normalizeTag(tag)) {
|
|
113
|
+
case 'http_get': return _truncate(`GET ${a.url || arg || ''}`, max);
|
|
114
|
+
case 'download': return _truncate(`download ${a.url || arg || ''}`, max);
|
|
115
|
+
case 'upload': return _truncate(`upload ${a.path || arg || ''}`, max);
|
|
116
|
+
case 'shell': return _truncate(a.command || arg || '', max);
|
|
117
|
+
case 'read_file': return _truncate(`read ${pathOrArg('path')}`, max);
|
|
118
|
+
case 'write_file': return _truncate(`write ${pathOrArg('path')}`, max);
|
|
119
|
+
case 'create_file': return _truncate(`create ${pathOrArg('path')}`, max);
|
|
120
|
+
case 'append_file': return _truncate(`append ${pathOrArg('path')}`, max);
|
|
121
|
+
case 'delete_file': return _truncate(`delete ${pathOrArg('path')}`, max);
|
|
122
|
+
case 'list_dir': return _truncate(`list ${pathOrArg('path') || '.'}`, max);
|
|
123
|
+
case 'make_dir': return _truncate(`mkdir ${pathOrArg('path')}`, max);
|
|
124
|
+
case 'remove_dir': return _truncate(`rmdir ${pathOrArg('path')}`, max);
|
|
125
|
+
case 'move_file': return _truncate(`move ${a.src || ''} → ${a.dst || ''}`, max);
|
|
126
|
+
case 'copy_file': return _truncate(`copy ${a.src || ''} → ${a.dst || ''}`, max);
|
|
127
|
+
case 'edit_file': return _truncate(`edit ${a.path || ''}:${a.line || ''}`, max);
|
|
128
|
+
case 'search_files': return _truncate(`search ${a.pattern || arg || ''}${a.dir && a.dir !== '.' ? ` in ${a.dir}` : ''}`, max);
|
|
129
|
+
case 'search_in_file': return _truncate(`search-in ${a.path || arg || ''}`, max);
|
|
130
|
+
case 'replace_in_file': return _truncate(`replace ${a.path || arg || ''}`, max);
|
|
131
|
+
case 'file_stat': return _truncate(`stat ${pathOrArg('path')}`, max);
|
|
132
|
+
case 'get_env': return _truncate(`get $${a.name || arg || ''}`, max);
|
|
133
|
+
case 'set_env': return _truncate(`set $${a.name || ''}`, max);
|
|
134
|
+
case 'ask_user': return _truncate(`ask ${a.question || arg || ''}`, max);
|
|
135
|
+
case 'store_memory': return _truncate(`store ${a.key || ''}`, max);
|
|
136
|
+
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
137
|
+
case 'list_memories': return 'list memories';
|
|
138
|
+
case 'system_info': return 'system info';
|
|
139
|
+
default: return _truncate(arg ? `${tag} ${arg}` : tag, max);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Type-specific meta tail. Split into sub-parts so the 4-segment joiner
|
|
144
|
+
// renders e.g. "200 · 42 KB" with its own " · " between them. Returns an
|
|
145
|
+
// array of strings; the caller joins them with " · ".
|
|
146
|
+
function _metaParts(tag, meta, error) {
|
|
147
|
+
const out = [];
|
|
148
|
+
const normalized = _normalizeTag(tag);
|
|
149
|
+
if (error && (normalized === 'http_get' || normalized === 'download')) {
|
|
150
|
+
out.push(formatHttpErrorTag(error));
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
switch (normalized) {
|
|
154
|
+
case 'http_get':
|
|
155
|
+
case 'download':
|
|
156
|
+
if (meta && typeof meta.status_code === 'number') out.push(String(meta.status_code));
|
|
157
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
158
|
+
break;
|
|
159
|
+
case 'shell':
|
|
160
|
+
if (meta && typeof meta.exit_code === 'number') out.push(`exit ${meta.exit_code}`);
|
|
161
|
+
break;
|
|
162
|
+
case 'read_file':
|
|
163
|
+
case 'write_file':
|
|
164
|
+
case 'create_file':
|
|
165
|
+
case 'append_file':
|
|
166
|
+
case 'upload':
|
|
167
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
168
|
+
break;
|
|
169
|
+
case 'search_files':
|
|
170
|
+
case 'search_in_file':
|
|
171
|
+
if (meta && typeof meta.count === 'number') {
|
|
172
|
+
out.push(`${meta.count} ${meta.count === 1 ? 'match' : 'matches'}`);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'replace_in_file':
|
|
176
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} replaced`);
|
|
177
|
+
break;
|
|
178
|
+
case 'list_dir':
|
|
179
|
+
if (meta && typeof meta.count === 'number') out.push(`${meta.count} ${meta.count === 1 ? 'entry' : 'entries'}`);
|
|
180
|
+
break;
|
|
181
|
+
case 'file_stat':
|
|
182
|
+
if (meta && typeof meta.bytes === 'number' && meta.bytes >= 0) out.push(formatBytes(meta.bytes));
|
|
183
|
+
if (meta && meta.kind) out.push(meta.kind);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Build the full styled 4-segment tool line. `status` is one of
|
|
190
|
+
// 'pending' | 'success' | 'failure'. For 'failure', caller may pass an
|
|
191
|
+
// Error-shaped `error` and/or partial `meta` — we format both when
|
|
192
|
+
// present (e.g. a failed HTTP fetch that still yielded a status code).
|
|
193
|
+
function formatToolLine(args) {
|
|
194
|
+
const {
|
|
195
|
+
status,
|
|
196
|
+
tag,
|
|
197
|
+
arg,
|
|
198
|
+
attrs,
|
|
199
|
+
durationMs,
|
|
200
|
+
meta,
|
|
201
|
+
error,
|
|
202
|
+
} = args || {};
|
|
203
|
+
|
|
204
|
+
let glyph;
|
|
205
|
+
let glyphColor;
|
|
206
|
+
if (status === 'pending') {
|
|
207
|
+
glyph = UI_ICONS.pending;
|
|
208
|
+
glyphColor = UI_THEME.muted;
|
|
209
|
+
} else if (status === 'failure' || error) {
|
|
210
|
+
glyph = UI_ICONS.error;
|
|
211
|
+
glyphColor = UI_THEME.error;
|
|
212
|
+
} else {
|
|
213
|
+
glyph = UI_ICONS.success;
|
|
214
|
+
glyphColor = UI_THEME.success;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cat = _categoryLabel(tag);
|
|
218
|
+
const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
|
|
219
|
+
|
|
220
|
+
const op = _operation(tag, arg, attrs);
|
|
221
|
+
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
222
|
+
const metaParts = _metaParts(tag, meta, error);
|
|
223
|
+
|
|
224
|
+
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
225
|
+
// so the " · " separator — neutral DIM — can sit between them without
|
|
226
|
+
// leaking the surrounding color into the separator itself.
|
|
227
|
+
const sep = ` ${DIM}·${RST} `;
|
|
228
|
+
|
|
229
|
+
const durColor = status === 'failure' ? UI_THEME.error : UI_THEME.muted;
|
|
230
|
+
const metaColor = status === 'failure' ? UI_THEME.error : UI_THEME.subtle;
|
|
231
|
+
|
|
232
|
+
const segments = [];
|
|
233
|
+
segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
|
|
234
|
+
segments.push(`${UI_THEME.default}${op}${RST}`);
|
|
235
|
+
segments.push(`${durColor}${durStr}${RST}`);
|
|
236
|
+
for (const m of metaParts) {
|
|
237
|
+
if (m) segments.push(`${metaColor}${m}${RST}`);
|
|
238
|
+
}
|
|
239
|
+
return segments.join(sep);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
formatDuration,
|
|
244
|
+
formatBytes,
|
|
245
|
+
formatHttpErrorTag,
|
|
246
|
+
formatToolLine,
|
|
247
|
+
};
|
package/lib/ui/input-field.js
CHANGED
|
@@ -4,6 +4,7 @@ const EventEmitter = require('events');
|
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
|
|
6
6
|
const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
|
|
7
|
+
const { stripAnsi, termWidth } = require('./utils');
|
|
7
8
|
const writer = require('./writer');
|
|
8
9
|
|
|
9
10
|
const SLASH_CMDS = [
|
|
@@ -128,15 +129,15 @@ class InputField extends EventEmitter {
|
|
|
128
129
|
this._historyIdx = -1;
|
|
129
130
|
this._draft = '';
|
|
130
131
|
this._selectCapture = null;
|
|
131
|
-
this._blink = true;
|
|
132
132
|
this._idle = false;
|
|
133
133
|
this._idleTimer = null;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
// Caret position within the rendered input lines, consumed by the
|
|
135
|
+
// orchestrator to position the OS cursor after every live-region
|
|
136
|
+
// redraw. { line, col } in display columns, or null when the caret
|
|
137
|
+
// should stay hidden (disabled, navigation capture). The terminal's
|
|
138
|
+
// native blinking cursor IS the caret — no custom rendering effect
|
|
139
|
+
// is applied to the character under it.
|
|
140
|
+
this._caretPosition = null;
|
|
140
141
|
this._escBuf = null;
|
|
141
142
|
this._escTimer = null;
|
|
142
143
|
this._utf8Buf = null;
|
|
@@ -158,6 +159,12 @@ class InputField extends EventEmitter {
|
|
|
158
159
|
this._savedChars = null;
|
|
159
160
|
this._savedCursor = 0;
|
|
160
161
|
|
|
162
|
+
// Bash-style exit-arm state: first Ctrl+C on empty input arms, second
|
|
163
|
+
// Ctrl+C within the window exits. Timer is unref'd so a lingering arm
|
|
164
|
+
// never keeps the process alive.
|
|
165
|
+
this._exitArmed = false;
|
|
166
|
+
this._exitArmTimer = null;
|
|
167
|
+
|
|
161
168
|
this._onData = (chunk) => this._handleData(chunk);
|
|
162
169
|
if (process.stdin.isTTY) {
|
|
163
170
|
process.stdin.setRawMode(true);
|
|
@@ -166,6 +173,7 @@ class InputField extends EventEmitter {
|
|
|
166
173
|
// land inside another task's compound write. Each is a tiny escape
|
|
167
174
|
// that only toggles state — no display content.
|
|
168
175
|
writer.enqueue(() => {
|
|
176
|
+
// audit: allowed — terminal-mode raw escapes inside writer.enqueue (sanctioned escape hatch).
|
|
169
177
|
try {
|
|
170
178
|
process.stdout.write('\x1b[?2004h'); // bracketed paste
|
|
171
179
|
process.stdout.write('\x1b[>4;2m'); // xterm modifyOtherKeys level 2
|
|
@@ -198,22 +206,6 @@ class InputField extends EventEmitter {
|
|
|
198
206
|
});
|
|
199
207
|
}
|
|
200
208
|
|
|
201
|
-
hideCursor() {
|
|
202
|
-
if (!this._cursorHidden) {
|
|
203
|
-
this._cursorHidden = true;
|
|
204
|
-
// Serialize through writer so this can't interleave mid-frame with a
|
|
205
|
-
// bubble/scrollback write.
|
|
206
|
-
writer.enqueue(() => { try { process.stdout.write('\x1b[?25l'); } catch {} });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
showCursor() {
|
|
211
|
-
if (this._cursorHidden) {
|
|
212
|
-
this._cursorHidden = false;
|
|
213
|
-
writer.enqueue(() => { try { process.stdout.write('\x1b[?25h'); } catch {} });
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
209
|
captureNavigation(handler) {
|
|
218
210
|
this._navCapture = handler;
|
|
219
211
|
this._navSearchMode = false;
|
|
@@ -221,37 +213,33 @@ class InputField extends EventEmitter {
|
|
|
221
213
|
this._deleteLine();
|
|
222
214
|
this._renderHints();
|
|
223
215
|
this._render();
|
|
224
|
-
this.hideCursor();
|
|
225
216
|
}
|
|
226
217
|
|
|
227
218
|
releaseNavigation() {
|
|
228
219
|
this._navCapture = null;
|
|
229
220
|
this._navSearchMode = false;
|
|
230
221
|
this._navSearchQuery = '';
|
|
231
|
-
this.showCursor();
|
|
232
222
|
this._renderHints();
|
|
233
223
|
this._render();
|
|
234
224
|
}
|
|
235
225
|
|
|
236
|
-
//
|
|
226
|
+
// Pause status-bar ticking so the terminal viewport can scroll freely.
|
|
227
|
+
// The OS cursor is managed by the writer based on caret state, not here —
|
|
228
|
+
// it stays visible at the input caret whenever getCaretPosition() is
|
|
229
|
+
// non-null.
|
|
237
230
|
_goIdle() {
|
|
238
231
|
this._idle = true;
|
|
239
232
|
clearTimeout(this._idleTimer);
|
|
240
233
|
this._idleTimer = null;
|
|
241
|
-
this.hideCursor();
|
|
242
234
|
this.emit('idle');
|
|
243
235
|
}
|
|
244
236
|
|
|
245
|
-
// Resume periodic writes and restart the idle countdown.
|
|
246
237
|
_goActive() {
|
|
247
238
|
const wasIdle = this._idle;
|
|
248
239
|
this._idle = false;
|
|
249
240
|
clearTimeout(this._idleTimer);
|
|
250
241
|
this._idleTimer = setTimeout(() => this._goIdle(), 0);
|
|
251
|
-
if (wasIdle)
|
|
252
|
-
this.showCursor();
|
|
253
|
-
this.emit('active');
|
|
254
|
-
}
|
|
242
|
+
if (wasIdle) this.emit('active');
|
|
255
243
|
}
|
|
256
244
|
|
|
257
245
|
setSearchItems(items) {
|
|
@@ -767,6 +755,10 @@ class InputField extends EventEmitter {
|
|
|
767
755
|
|
|
768
756
|
if (key !== 'tab') this._clearHint();
|
|
769
757
|
|
|
758
|
+
// Any non-Ctrl+C key cancels an armed exit — a letter, arrow, Tab,
|
|
759
|
+
// Backspace, or Enter all revert the hints row to normal.
|
|
760
|
+
if (key !== 'ctrl+c') this._clearExitArm();
|
|
761
|
+
|
|
770
762
|
switch (key) {
|
|
771
763
|
case 'shift+enter': this._insertChar('\n'); this._render(); break;
|
|
772
764
|
case 'enter': {
|
|
@@ -806,9 +798,9 @@ class InputField extends EventEmitter {
|
|
|
806
798
|
case 'ctrl+l': this._chatHistory.clearMessages(); break;
|
|
807
799
|
case 'ctrl+r': this._enterSearchMode(); break;
|
|
808
800
|
case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
|
|
809
|
-
case 'ctrl+g':
|
|
810
|
-
case 'ctrl+c':
|
|
811
|
-
case 'ctrl+d': this.
|
|
801
|
+
case 'ctrl+g': this.emit('interrupt'); break;
|
|
802
|
+
case 'ctrl+c': this._onCtrlC(); break;
|
|
803
|
+
case 'ctrl+d': this._onCtrlD(); break;
|
|
812
804
|
case 'ctrl+right':
|
|
813
805
|
case 'alt+f': this._jumpWordForward(); this._render(); break;
|
|
814
806
|
case 'ctrl+left':
|
|
@@ -831,6 +823,58 @@ class InputField extends EventEmitter {
|
|
|
831
823
|
}
|
|
832
824
|
}
|
|
833
825
|
|
|
826
|
+
// ── Bash-style Ctrl+C / Ctrl+D ───────────────────────────────────────────────
|
|
827
|
+
//
|
|
828
|
+
// Ctrl+C is context-dependent. _handleKey is only reached when the agent is
|
|
829
|
+
// idle (agent-active Ctrl+C is handled directly in _handleData's disabled
|
|
830
|
+
// branch), so these helpers only cover the idle cases: clear a non-empty
|
|
831
|
+
// line, or arm/confirm exit on empty input. Ctrl+D mirrors readline: exit
|
|
832
|
+
// only when the line is empty, otherwise do nothing.
|
|
833
|
+
_onCtrlC() {
|
|
834
|
+
if (this._chars.length > 0) {
|
|
835
|
+
// Non-empty line: clear it and DO NOT arm. A subsequent Ctrl+C on the
|
|
836
|
+
// newly-empty line starts the arm sequence from scratch.
|
|
837
|
+
this._deleteLine();
|
|
838
|
+
this._clearExitArm();
|
|
839
|
+
this._render();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (this._exitArmed) {
|
|
843
|
+
this._clearExitArm();
|
|
844
|
+
this.emit('interrupt');
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
this._armExit();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
_onCtrlD() {
|
|
851
|
+
if (this._chars.length > 0) return;
|
|
852
|
+
this.emit('interrupt');
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
_armExit() {
|
|
856
|
+
this._exitArmed = true;
|
|
857
|
+
if (this._exitArmTimer) clearTimeout(this._exitArmTimer);
|
|
858
|
+
this._exitArmTimer = setTimeout(() => {
|
|
859
|
+
this._exitArmed = false;
|
|
860
|
+
this._exitArmTimer = null;
|
|
861
|
+
this._renderHints();
|
|
862
|
+
}, 2000);
|
|
863
|
+
// Don't let a pending arm keep the event loop alive.
|
|
864
|
+
if (this._exitArmTimer.unref) this._exitArmTimer.unref();
|
|
865
|
+
this._renderHints();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
_clearExitArm() {
|
|
869
|
+
if (!this._exitArmed && !this._exitArmTimer) return;
|
|
870
|
+
this._exitArmed = false;
|
|
871
|
+
if (this._exitArmTimer) {
|
|
872
|
+
clearTimeout(this._exitArmTimer);
|
|
873
|
+
this._exitArmTimer = null;
|
|
874
|
+
}
|
|
875
|
+
this._renderHints();
|
|
876
|
+
}
|
|
877
|
+
|
|
834
878
|
// ── Raw stdin byte handler ───────────────────────────────────────────────────
|
|
835
879
|
|
|
836
880
|
_handleData(chunk) {
|
|
@@ -852,9 +896,12 @@ class InputField extends EventEmitter {
|
|
|
852
896
|
if (this._navCapture) { this._processBuf(buf, false); return; }
|
|
853
897
|
if (this._disabled) {
|
|
854
898
|
if (buf[0] === 0x03) {
|
|
899
|
+
// Ctrl+C while agent is active: interrupt the operation and reset
|
|
900
|
+
// any pending exit-arm so the next idle Ctrl+C starts a fresh arm.
|
|
901
|
+
this._clearExitArm();
|
|
855
902
|
this.emit('abort');
|
|
856
903
|
} else if (buf[0] === 0x04) {
|
|
857
|
-
|
|
904
|
+
// Ctrl+D while agent active: ignored (bash readline semantics).
|
|
858
905
|
} else if (buf[0] === 0x0f) {
|
|
859
906
|
this.emit('expand');
|
|
860
907
|
} else if (buf[0] === 0x1b && buf.length === 1) {
|
|
@@ -991,6 +1038,10 @@ class InputField extends EventEmitter {
|
|
|
991
1038
|
if (cp !== undefined && cp >= 0x20) {
|
|
992
1039
|
const ch = String.fromCodePoint(cp);
|
|
993
1040
|
this._insertChar(ch);
|
|
1041
|
+
// Printable chars bypass _handleKey — explicitly cancel an armed
|
|
1042
|
+
// exit so typing a letter behaves like bash (any keystroke except
|
|
1043
|
+
// a second Ctrl+C reverts the hint).
|
|
1044
|
+
this._clearExitArm();
|
|
994
1045
|
if (!this._pasteMode) {
|
|
995
1046
|
this._clearHint();
|
|
996
1047
|
this._render();
|
|
@@ -1018,7 +1069,9 @@ class InputField extends EventEmitter {
|
|
|
1018
1069
|
|
|
1019
1070
|
_renderHints() {
|
|
1020
1071
|
let text;
|
|
1021
|
-
if (this.
|
|
1072
|
+
if (this._exitArmed) {
|
|
1073
|
+
text = `${FG_YELLOW}Press Ctrl+C again to exit${RST}`;
|
|
1074
|
+
} else if (this._searchMode) {
|
|
1022
1075
|
text = `${FG_YELLOW}ctrl+r${RST}${DIM} next match ${RST}${FG_YELLOW}esc${RST}${DIM} cancel search${RST}`;
|
|
1023
1076
|
} else if (this._disabled) {
|
|
1024
1077
|
text = `${FG_YELLOW}esc${RST}${DIM}/${RST}${FG_YELLOW}ctrl+c${RST}${DIM} interrupt${RST}`;
|
|
@@ -1045,9 +1098,12 @@ class InputField extends EventEmitter {
|
|
|
1045
1098
|
// the UI orchestrator. Then _notify() — the orchestrator pulls the lines
|
|
1046
1099
|
// and repaints the live region.
|
|
1047
1100
|
//
|
|
1048
|
-
// The
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1101
|
+
// The caret is the terminal's native blinking cursor, positioned by the
|
|
1102
|
+
// writer after each redraw using this._caretPosition. Rendering here does
|
|
1103
|
+
// not embed any visual cursor into the text — the inverted/underlined
|
|
1104
|
+
// simulation was removed because it duplicated the native cursor and
|
|
1105
|
+
// caused a phantom "below hints" blink when the OS cursor wasn't moved
|
|
1106
|
+
// up to the input row after a frame.
|
|
1051
1107
|
|
|
1052
1108
|
_render() {
|
|
1053
1109
|
if (this._disabled) {
|
|
@@ -1056,6 +1112,7 @@ class InputField extends EventEmitter {
|
|
|
1056
1112
|
this._layout.setInputHeight(1);
|
|
1057
1113
|
}
|
|
1058
1114
|
this._inputLines = [`${DIM}›${RST}`];
|
|
1115
|
+
this._caretPosition = null;
|
|
1059
1116
|
this._notify();
|
|
1060
1117
|
return;
|
|
1061
1118
|
}
|
|
@@ -1066,6 +1123,7 @@ class InputField extends EventEmitter {
|
|
|
1066
1123
|
this._layout.setInputHeight(1);
|
|
1067
1124
|
}
|
|
1068
1125
|
this._inputLines = [`${DIM}›${RST}`];
|
|
1126
|
+
this._caretPosition = null;
|
|
1069
1127
|
this._notify();
|
|
1070
1128
|
return;
|
|
1071
1129
|
}
|
|
@@ -1098,7 +1156,13 @@ class InputField extends EventEmitter {
|
|
|
1098
1156
|
displayMatch = matched;
|
|
1099
1157
|
}
|
|
1100
1158
|
|
|
1101
|
-
|
|
1159
|
+
const rendered = prefix + typeLabel + displayMatch;
|
|
1160
|
+
this._inputLines = [rendered];
|
|
1161
|
+
// Put the OS cursor at the end of the visible text so the user sees
|
|
1162
|
+
// where their next keystroke will land (they're editing _searchQuery
|
|
1163
|
+
// in the prefix, but by design the row gets rewritten with the match
|
|
1164
|
+
// on every keystroke; trailing position is the least surprising).
|
|
1165
|
+
this._caretPosition = { line: 0, col: termWidth(stripAnsi(rendered)) };
|
|
1102
1166
|
this._notify();
|
|
1103
1167
|
return;
|
|
1104
1168
|
}
|
|
@@ -1136,20 +1200,7 @@ class InputField extends EventEmitter {
|
|
|
1136
1200
|
const renderCount = Math.min(lines.length, this._inputHeight);
|
|
1137
1201
|
for (let li = 0; li < renderCount; li++) {
|
|
1138
1202
|
const lineChars = lines[li];
|
|
1139
|
-
|
|
1140
|
-
let lineStr;
|
|
1141
|
-
if (li === cursorLine) {
|
|
1142
|
-
const leftStr = lineChars.slice(0, cursorCol).join('');
|
|
1143
|
-
const cursorCh = cursorCol < lineChars.length ? lineChars[cursorCol] : ' ';
|
|
1144
|
-
const afterStr = lineChars.slice(cursorCol + (cursorCol < lineChars.length ? 1 : 0)).join('');
|
|
1145
|
-
const cursorEsc = this._blink
|
|
1146
|
-
? `\x1b[7m${cursorCh}\x1b[27m`
|
|
1147
|
-
: `\x1b[4m${cursorCh}\x1b[24m`;
|
|
1148
|
-
lineStr = leftStr + cursorEsc + afterStr;
|
|
1149
|
-
} else {
|
|
1150
|
-
lineStr = lineChars.join('');
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1203
|
+
const lineStr = lineChars.join('');
|
|
1153
1204
|
const leadIn = li === 0 ? PREFIX : ' '.repeat(PREFIX_LEN);
|
|
1154
1205
|
const hintStr = (li === 0 && this._hint) ? `${DIM}${this._hint}${RST}` : '';
|
|
1155
1206
|
out.push(leadIn + lineStr + hintStr);
|
|
@@ -1157,12 +1208,36 @@ class InputField extends EventEmitter {
|
|
|
1157
1208
|
|
|
1158
1209
|
if (out.length === 0) out.push(PREFIX);
|
|
1159
1210
|
this._inputLines = out;
|
|
1211
|
+
|
|
1212
|
+
// Caret sits at PREFIX_LEN + display-width of the chars on the caret
|
|
1213
|
+
// line that precede it. termWidth handles double-width glyphs (CJK,
|
|
1214
|
+
// emoji) so the column is correct even when the input contains them.
|
|
1215
|
+
// If the caret row was dropped because _inputHeight clamped the render
|
|
1216
|
+
// to fewer rows, keep the caret on the last rendered row at its end —
|
|
1217
|
+
// the text that overflowed isn't visible anyway.
|
|
1218
|
+
const caretLineIdx = Math.min(cursorLine, renderCount - 1);
|
|
1219
|
+
const caretLineChars = lines[caretLineIdx] || [];
|
|
1220
|
+
const beforeCaret = caretLineIdx === cursorLine
|
|
1221
|
+
? caretLineChars.slice(0, cursorCol).join('')
|
|
1222
|
+
: caretLineChars.join('');
|
|
1223
|
+
this._caretPosition = {
|
|
1224
|
+
line: caretLineIdx,
|
|
1225
|
+
col: PREFIX_LEN + termWidth(beforeCaret),
|
|
1226
|
+
};
|
|
1160
1227
|
this._notify();
|
|
1161
1228
|
}
|
|
1162
1229
|
|
|
1163
1230
|
// Expose the last-computed input lines for the orchestrator.
|
|
1164
1231
|
renderInputLines() { return this._inputLines.slice(); }
|
|
1165
1232
|
|
|
1233
|
+
// Expose the caret position inside the most recently rendered input rows:
|
|
1234
|
+
// { line, col } — `line` is the 0-indexed input row and `col` is the
|
|
1235
|
+
// 0-indexed display column (PREFIX width + termWidth of preceding chars).
|
|
1236
|
+
// Returns null when no caret should appear (disabled / nav capture).
|
|
1237
|
+
getCaretPosition() {
|
|
1238
|
+
return this._caretPosition ? { ...this._caretPosition } : null;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1166
1241
|
_notify() {
|
|
1167
1242
|
try { this._onChange(); } catch {}
|
|
1168
1243
|
}
|
|
@@ -1170,14 +1245,15 @@ class InputField extends EventEmitter {
|
|
|
1170
1245
|
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
1171
1246
|
|
|
1172
1247
|
destroy() {
|
|
1173
|
-
if (this.
|
|
1174
|
-
if (this.
|
|
1175
|
-
if (this.
|
|
1248
|
+
if (this._escTimer) { clearTimeout(this._escTimer); this._escTimer = null; }
|
|
1249
|
+
if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
|
|
1250
|
+
if (this._exitArmTimer) { clearTimeout(this._exitArmTimer); this._exitArmTimer = null; }
|
|
1176
1251
|
if (process.stdin.isTTY) {
|
|
1177
1252
|
process.stdin.removeListener('data', this._onData);
|
|
1178
1253
|
// Terminal-mode pops must run in queue order so they don't race with
|
|
1179
1254
|
// a pending live-region redraw on shutdown.
|
|
1180
1255
|
writer.enqueue(() => {
|
|
1256
|
+
// audit: allowed — terminal-mode raw escapes inside writer.enqueue (sanctioned escape hatch).
|
|
1181
1257
|
try {
|
|
1182
1258
|
process.stdout.write('\x1b[?25h'); // show cursor
|
|
1183
1259
|
process.stdout.write('\x1b[<u'); // pop kitty keyboard protocol
|
|
@@ -1185,7 +1261,6 @@ class InputField extends EventEmitter {
|
|
|
1185
1261
|
process.stdout.write('\x1b[?2004l'); // disable bracketed paste
|
|
1186
1262
|
} catch {}
|
|
1187
1263
|
});
|
|
1188
|
-
this._cursorHidden = false;
|
|
1189
1264
|
process.stdin.setRawMode(false); // restore cooked mode so mouse works again
|
|
1190
1265
|
}
|
|
1191
1266
|
}
|
package/lib/ui/layout.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Thin wrappers over writer.scrollback for the four primary error categories
|
|
4
|
+
// and three neutral system-line glyphs. All composition + colour comes from
|
|
5
|
+
// theme.js so the palette stays consistent with the rest of the chrome. Each
|
|
6
|
+
// helper returns the writer.scrollback promise so callers can `await` if
|
|
7
|
+
// they need the line flushed before exit.
|
|
8
|
+
|
|
9
|
+
const { UI_THEME, UI_ICONS } = require('./theme');
|
|
10
|
+
const { RST } = require('./ansi');
|
|
11
|
+
const writer = require('./writer');
|
|
12
|
+
|
|
13
|
+
const ICON_ERROR = `${UI_THEME.error}${UI_ICONS.error}${RST}`;
|
|
14
|
+
const ICON_SUCCESS = `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
15
|
+
const ICON_WARN = `${UI_THEME.warning}${UI_ICONS.warn}${RST}`;
|
|
16
|
+
const ICON_INFO = `${UI_THEME.muted}${UI_ICONS.bullet}${RST}`;
|
|
17
|
+
const SEP = `${UI_THEME.muted}·${RST}`;
|
|
18
|
+
|
|
19
|
+
function _cat(name) { return `${UI_THEME.accent}${name}${RST}`; }
|
|
20
|
+
|
|
21
|
+
function agentError(msg) {
|
|
22
|
+
return writer.scrollback(`${ICON_ERROR} ${_cat('agent')} ${SEP} ${msg}${RST}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toolError(tag, msg) {
|
|
26
|
+
return writer.scrollback(`${ICON_ERROR} ${_cat('tool')} ${SEP} ${UI_THEME.accent}${tag}${RST} ${SEP} ${msg}${RST}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function netError(msg) {
|
|
30
|
+
return writer.scrollback(`${ICON_ERROR} ${_cat('net')} ${SEP} ${msg}${RST}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function configError(msg) {
|
|
34
|
+
return writer.scrollback(`${ICON_ERROR} ${_cat('config')} ${SEP} ${msg}${RST}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sysInfo(msg) { return writer.scrollback(`${ICON_INFO} ${msg}${RST}`); }
|
|
38
|
+
function sysSuccess(msg) { return writer.scrollback(`${ICON_SUCCESS} ${msg}${RST}`); }
|
|
39
|
+
function sysWarn(msg) { return writer.scrollback(`${ICON_WARN} ${msg}${RST}`); }
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
agentError, toolError, netError, configError,
|
|
43
|
+
sysInfo, sysSuccess, sysWarn,
|
|
44
|
+
};
|