@semalt-ai/code 1.8.1 → 1.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +29 -8
- package/lib/agent.js +725 -133
- package/lib/api.js +193 -59
- package/lib/commands.js +263 -201
- package/lib/config.js +33 -4
- package/lib/constants.js +52 -2
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +73 -73
- package/lib/prompts.js +90 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +418 -198
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +212 -61
- package/lib/ui/create-ui.js +145 -377
- package/lib/ui/diff.js +91 -78
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +200 -107
- 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 +179 -42
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +60 -0
- package/lib/ui/theme.js +99 -0
- package/lib/ui/utils.js +135 -6
- package/lib/ui/writer.js +603 -0
- package/lib/ui.js +11 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/ui/input-field.js
CHANGED
|
@@ -4,6 +4,8 @@ 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');
|
|
8
|
+
const writer = require('./writer');
|
|
7
9
|
|
|
8
10
|
const SLASH_CMDS = [
|
|
9
11
|
'/help','/file','/new','/model','/models','/shell','/compact',
|
|
@@ -107,10 +109,15 @@ function parseKeySequence(buf) {
|
|
|
107
109
|
// ─── InputField ───────────────────────────────────────────────────────────────
|
|
108
110
|
|
|
109
111
|
class InputField extends EventEmitter {
|
|
110
|
-
constructor(layout, chatHistory) {
|
|
112
|
+
constructor(layout, chatHistory, onChange) {
|
|
111
113
|
super();
|
|
112
114
|
this._layout = layout;
|
|
113
115
|
this._chatHistory = chatHistory;
|
|
116
|
+
this._onChange = typeof onChange === 'function' ? onChange : () => {};
|
|
117
|
+
// Cached render output — re-computed on every _render()/_renderHints()
|
|
118
|
+
// call. Consumed by the UI orchestrator to build the live region.
|
|
119
|
+
this._inputLines = [`${DIM}›${RST}`];
|
|
120
|
+
this._hintsText = '';
|
|
114
121
|
this._chars = [];
|
|
115
122
|
this._cursor = 0;
|
|
116
123
|
this._disabled = false;
|
|
@@ -122,15 +129,15 @@ class InputField extends EventEmitter {
|
|
|
122
129
|
this._historyIdx = -1;
|
|
123
130
|
this._draft = '';
|
|
124
131
|
this._selectCapture = null;
|
|
125
|
-
this._blink = true;
|
|
126
132
|
this._idle = false;
|
|
127
133
|
this._idleTimer = null;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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;
|
|
134
141
|
this._escBuf = null;
|
|
135
142
|
this._escTimer = null;
|
|
136
143
|
this._utf8Buf = null;
|
|
@@ -152,13 +159,27 @@ class InputField extends EventEmitter {
|
|
|
152
159
|
this._savedChars = null;
|
|
153
160
|
this._savedCursor = 0;
|
|
154
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
|
+
|
|
155
168
|
this._onData = (chunk) => this._handleData(chunk);
|
|
156
169
|
if (process.stdin.isTTY) {
|
|
157
170
|
process.stdin.setRawMode(true);
|
|
158
171
|
process.stdin.resume();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
172
|
+
// Terminal-mode toggles go through the writer queue so they can't
|
|
173
|
+
// land inside another task's compound write. Each is a tiny escape
|
|
174
|
+
// that only toggles state — no display content.
|
|
175
|
+
writer.enqueue(() => {
|
|
176
|
+
// audit: allowed — terminal-mode raw escapes inside writer.enqueue (sanctioned escape hatch).
|
|
177
|
+
try {
|
|
178
|
+
process.stdout.write('\x1b[?2004h'); // bracketed paste
|
|
179
|
+
process.stdout.write('\x1b[>4;2m'); // xterm modifyOtherKeys level 2
|
|
180
|
+
process.stdout.write('\x1b[>1u'); // kitty keyboard protocol
|
|
181
|
+
} catch {}
|
|
182
|
+
});
|
|
162
183
|
process.stdin.on('data', this._onData);
|
|
163
184
|
this._idleTimer = setTimeout(() => this._goIdle(), 0);
|
|
164
185
|
}
|
|
@@ -185,20 +206,6 @@ class InputField extends EventEmitter {
|
|
|
185
206
|
});
|
|
186
207
|
}
|
|
187
208
|
|
|
188
|
-
hideCursor() {
|
|
189
|
-
if (!this._cursorHidden) {
|
|
190
|
-
this._cursorHidden = true;
|
|
191
|
-
process.stdout.write('\x1b[?25l');
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
showCursor() {
|
|
196
|
-
if (this._cursorHidden) {
|
|
197
|
-
this._cursorHidden = false;
|
|
198
|
-
process.stdout.write('\x1b[?25h');
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
209
|
captureNavigation(handler) {
|
|
203
210
|
this._navCapture = handler;
|
|
204
211
|
this._navSearchMode = false;
|
|
@@ -206,37 +213,33 @@ class InputField extends EventEmitter {
|
|
|
206
213
|
this._deleteLine();
|
|
207
214
|
this._renderHints();
|
|
208
215
|
this._render();
|
|
209
|
-
this.hideCursor();
|
|
210
216
|
}
|
|
211
217
|
|
|
212
218
|
releaseNavigation() {
|
|
213
219
|
this._navCapture = null;
|
|
214
220
|
this._navSearchMode = false;
|
|
215
221
|
this._navSearchQuery = '';
|
|
216
|
-
this.showCursor();
|
|
217
222
|
this._renderHints();
|
|
218
223
|
this._render();
|
|
219
224
|
}
|
|
220
225
|
|
|
221
|
-
//
|
|
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.
|
|
222
230
|
_goIdle() {
|
|
223
231
|
this._idle = true;
|
|
224
232
|
clearTimeout(this._idleTimer);
|
|
225
233
|
this._idleTimer = null;
|
|
226
|
-
this.hideCursor();
|
|
227
234
|
this.emit('idle');
|
|
228
235
|
}
|
|
229
236
|
|
|
230
|
-
// Resume periodic writes and restart the idle countdown.
|
|
231
237
|
_goActive() {
|
|
232
238
|
const wasIdle = this._idle;
|
|
233
239
|
this._idle = false;
|
|
234
240
|
clearTimeout(this._idleTimer);
|
|
235
241
|
this._idleTimer = setTimeout(() => this._goIdle(), 0);
|
|
236
|
-
if (wasIdle)
|
|
237
|
-
this.showCursor();
|
|
238
|
-
this.emit('active');
|
|
239
|
-
}
|
|
242
|
+
if (wasIdle) this.emit('active');
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
setSearchItems(items) {
|
|
@@ -752,6 +755,10 @@ class InputField extends EventEmitter {
|
|
|
752
755
|
|
|
753
756
|
if (key !== 'tab') this._clearHint();
|
|
754
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
|
+
|
|
755
762
|
switch (key) {
|
|
756
763
|
case 'shift+enter': this._insertChar('\n'); this._render(); break;
|
|
757
764
|
case 'enter': {
|
|
@@ -791,9 +798,9 @@ class InputField extends EventEmitter {
|
|
|
791
798
|
case 'ctrl+l': this._chatHistory.clearMessages(); break;
|
|
792
799
|
case 'ctrl+r': this._enterSearchMode(); break;
|
|
793
800
|
case 'ctrl+o': if (this._navCapture) this._navCapture('expand'); else this.emit('expand'); break;
|
|
794
|
-
case 'ctrl+g':
|
|
795
|
-
case 'ctrl+c':
|
|
796
|
-
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;
|
|
797
804
|
case 'ctrl+right':
|
|
798
805
|
case 'alt+f': this._jumpWordForward(); this._render(); break;
|
|
799
806
|
case 'ctrl+left':
|
|
@@ -816,6 +823,58 @@ class InputField extends EventEmitter {
|
|
|
816
823
|
}
|
|
817
824
|
}
|
|
818
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
|
+
|
|
819
878
|
// ── Raw stdin byte handler ───────────────────────────────────────────────────
|
|
820
879
|
|
|
821
880
|
_handleData(chunk) {
|
|
@@ -836,8 +895,13 @@ class InputField extends EventEmitter {
|
|
|
836
895
|
if (this._selectCapture) { this._processBuf(buf, false); return; }
|
|
837
896
|
if (this._navCapture) { this._processBuf(buf, false); return; }
|
|
838
897
|
if (this._disabled) {
|
|
839
|
-
if (buf[0] === 0x03
|
|
840
|
-
|
|
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();
|
|
902
|
+
this.emit('abort');
|
|
903
|
+
} else if (buf[0] === 0x04) {
|
|
904
|
+
// Ctrl+D while agent active: ignored (bash readline semantics).
|
|
841
905
|
} else if (buf[0] === 0x0f) {
|
|
842
906
|
this.emit('expand');
|
|
843
907
|
} else if (buf[0] === 0x1b && buf.length === 1) {
|
|
@@ -974,6 +1038,10 @@ class InputField extends EventEmitter {
|
|
|
974
1038
|
if (cp !== undefined && cp >= 0x20) {
|
|
975
1039
|
const ch = String.fromCodePoint(cp);
|
|
976
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();
|
|
977
1045
|
if (!this._pasteMode) {
|
|
978
1046
|
this._clearHint();
|
|
979
1047
|
this._render();
|
|
@@ -995,18 +1063,18 @@ class InputField extends EventEmitter {
|
|
|
995
1063
|
}
|
|
996
1064
|
|
|
997
1065
|
// ── Hints row ────────────────────────────────────────────────────────────────
|
|
1066
|
+
//
|
|
1067
|
+
// Build the hints-line string and publish the updated live region. No
|
|
1068
|
+
// direct stdout writes here — the shared writer owns the terminal.
|
|
998
1069
|
|
|
999
1070
|
_renderHints() {
|
|
1000
|
-
const layout = this._layout;
|
|
1001
|
-
if (!layout || !layout.hintsRow) return;
|
|
1002
|
-
const row = layout.hintsRow;
|
|
1003
|
-
const cols = layout.cols;
|
|
1004
|
-
|
|
1005
1071
|
let text;
|
|
1006
|
-
if (this.
|
|
1072
|
+
if (this._exitArmed) {
|
|
1073
|
+
text = `${FG_YELLOW}Press Ctrl+C again to exit${RST}`;
|
|
1074
|
+
} else if (this._searchMode) {
|
|
1007
1075
|
text = `${FG_YELLOW}ctrl+r${RST}${DIM} next match ${RST}${FG_YELLOW}esc${RST}${DIM} cancel search${RST}`;
|
|
1008
1076
|
} else if (this._disabled) {
|
|
1009
|
-
text = `${FG_YELLOW}esc${RST}${DIM} interrupt${RST}`;
|
|
1077
|
+
text = `${FG_YELLOW}esc${RST}${DIM}/${RST}${FG_YELLOW}ctrl+c${RST}${DIM} interrupt${RST}`;
|
|
1010
1078
|
} else if (this._navCapture) {
|
|
1011
1079
|
if (this._navSearchMode) {
|
|
1012
1080
|
text = `${FG_YELLOW}search:${RST} ${FG_CYAN}'${this._navSearchQuery}'${RST} ${DIM}↑↓ navigate enter select esc clear${RST}`;
|
|
@@ -1017,42 +1085,46 @@ class InputField extends EventEmitter {
|
|
|
1017
1085
|
text = `${DIM}↑↓ history ${RST}${FG_CYAN}ctrl+r${RST}${DIM} search ${RST}${FG_CYAN}ctrl+c${RST}${DIM} cancel${RST}`;
|
|
1018
1086
|
}
|
|
1019
1087
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
process.stdout.write(`\x1b7\x1b[${row};1H\x1b[2K${text}`);
|
|
1023
|
-
const visLen = text.replace(/\x1b\[[^m]*m/g, '').length;
|
|
1024
|
-
if (visLen < cols) process.stdout.write(' '.repeat(cols - visLen));
|
|
1025
|
-
process.stdout.write('\x1b8');
|
|
1088
|
+
this._hintsText = text;
|
|
1089
|
+
this._notify();
|
|
1026
1090
|
}
|
|
1027
1091
|
|
|
1092
|
+
// Expose the last-computed hints line for the orchestrator.
|
|
1093
|
+
renderHintsLine() { return this._hintsText || ''; }
|
|
1094
|
+
|
|
1028
1095
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
1096
|
+
//
|
|
1097
|
+
// Compute the rendered input rows and cache them in this._inputLines for
|
|
1098
|
+
// the UI orchestrator. Then _notify() — the orchestrator pulls the lines
|
|
1099
|
+
// and repaints the live region.
|
|
1100
|
+
//
|
|
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.
|
|
1029
1107
|
|
|
1030
1108
|
_render() {
|
|
1031
|
-
const layout = this._layout;
|
|
1032
|
-
const cols = layout.cols;
|
|
1033
|
-
|
|
1034
1109
|
if (this._disabled) {
|
|
1035
1110
|
if (this._inputHeight !== 1) {
|
|
1036
1111
|
this._inputHeight = 1;
|
|
1037
|
-
|
|
1112
|
+
this._layout.setInputHeight(1);
|
|
1038
1113
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
process.stdout.write(`\x1b7\x1b[${baseRow};1H\x1b[2K${DIM}›${RST}`);
|
|
1043
|
-
process.stdout.write(' '.repeat(Math.max(0, cols - 1)));
|
|
1044
|
-
process.stdout.write('\x1b8');
|
|
1114
|
+
this._inputLines = [`${DIM}›${RST}`];
|
|
1115
|
+
this._caretPosition = null;
|
|
1116
|
+
this._notify();
|
|
1045
1117
|
return;
|
|
1046
1118
|
}
|
|
1047
1119
|
|
|
1048
1120
|
if (this._navCapture) {
|
|
1049
1121
|
if (this._inputHeight !== 1) {
|
|
1050
1122
|
this._inputHeight = 1;
|
|
1051
|
-
|
|
1123
|
+
this._layout.setInputHeight(1);
|
|
1052
1124
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1125
|
+
this._inputLines = [`${DIM}›${RST}`];
|
|
1126
|
+
this._caretPosition = null;
|
|
1127
|
+
this._notify();
|
|
1056
1128
|
return;
|
|
1057
1129
|
}
|
|
1058
1130
|
|
|
@@ -1060,20 +1132,17 @@ class InputField extends EventEmitter {
|
|
|
1060
1132
|
if (this._searchMode) {
|
|
1061
1133
|
if (this._inputHeight !== 1) {
|
|
1062
1134
|
this._inputHeight = 1;
|
|
1063
|
-
|
|
1135
|
+
this._layout.setInputHeight(1);
|
|
1064
1136
|
}
|
|
1065
|
-
const baseRow = layout.inputRow;
|
|
1066
1137
|
const noMatch = this._searchQuery && this._searchMatchIdx === -1;
|
|
1067
1138
|
const queryColor = noMatch ? '\x1b[38;5;203m' : FG_CYAN;
|
|
1068
1139
|
const prefix = `${DIM}(search)${RST} ${queryColor}'${this._searchQuery}'${RST}${DIM}:${RST} `;
|
|
1069
1140
|
const matched = this._chars.join('');
|
|
1070
1141
|
|
|
1071
|
-
// Type label
|
|
1072
1142
|
const typeLabel = this._searchMatchType
|
|
1073
1143
|
? `${DIM}[${this._searchMatchType}]${RST} `
|
|
1074
1144
|
: '';
|
|
1075
1145
|
|
|
1076
|
-
// Highlight the matching part (case-insensitive)
|
|
1077
1146
|
let displayMatch;
|
|
1078
1147
|
const matchedLow = matched.toLowerCase();
|
|
1079
1148
|
const qLow = this._searchQuery.toLowerCase();
|
|
@@ -1087,10 +1156,14 @@ class InputField extends EventEmitter {
|
|
|
1087
1156
|
displayMatch = matched;
|
|
1088
1157
|
}
|
|
1089
1158
|
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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)) };
|
|
1166
|
+
this._notify();
|
|
1094
1167
|
return;
|
|
1095
1168
|
}
|
|
1096
1169
|
|
|
@@ -1101,13 +1174,7 @@ class InputField extends EventEmitter {
|
|
|
1101
1174
|
const needed = Math.min(5, newlineCount + 1);
|
|
1102
1175
|
if (needed !== this._inputHeight) {
|
|
1103
1176
|
this._inputHeight = needed;
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
const baseRow = layout.inputRow;
|
|
1108
|
-
|
|
1109
|
-
for (let r = baseRow; r < baseRow + this._inputHeight; r++) {
|
|
1110
|
-
process.stdout.write(`\x1b[${r};1H\x1b[2K`);
|
|
1177
|
+
this._layout.setInputHeight(needed);
|
|
1111
1178
|
}
|
|
1112
1179
|
|
|
1113
1180
|
const PREFIX = '\x1b[36m › \x1b[0m';
|
|
@@ -1129,45 +1196,71 @@ class InputField extends EventEmitter {
|
|
|
1129
1196
|
pos += lines[li].length + 1;
|
|
1130
1197
|
}
|
|
1131
1198
|
|
|
1199
|
+
const out = [];
|
|
1132
1200
|
const renderCount = Math.min(lines.length, this._inputHeight);
|
|
1133
1201
|
for (let li = 0; li < renderCount; li++) {
|
|
1134
|
-
const r = baseRow + li;
|
|
1135
1202
|
const lineChars = lines[li];
|
|
1136
|
-
|
|
1137
|
-
let lineStr;
|
|
1138
|
-
if (li === cursorLine) {
|
|
1139
|
-
const leftStr = lineChars.slice(0, cursorCol).join('');
|
|
1140
|
-
const cursorCh = cursorCol < lineChars.length ? lineChars[cursorCol] : ' ';
|
|
1141
|
-
const afterStr = lineChars.slice(cursorCol + (cursorCol < lineChars.length ? 1 : 0)).join('');
|
|
1142
|
-
const cursorEsc = this._blink
|
|
1143
|
-
? `\x1b[7m${cursorCh}\x1b[27m`
|
|
1144
|
-
: `\x1b[4m${cursorCh}\x1b[24m`;
|
|
1145
|
-
lineStr = leftStr + cursorEsc + afterStr;
|
|
1146
|
-
} else {
|
|
1147
|
-
lineStr = lineChars.join('');
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1203
|
+
const lineStr = lineChars.join('');
|
|
1150
1204
|
const leadIn = li === 0 ? PREFIX : ' '.repeat(PREFIX_LEN);
|
|
1151
1205
|
const hintStr = (li === 0 && this._hint) ? `${DIM}${this._hint}${RST}` : '';
|
|
1152
|
-
|
|
1153
|
-
const pad = visLen < cols ? ' '.repeat(cols - visLen) : '';
|
|
1154
|
-
|
|
1155
|
-
process.stdout.write(`\x1b[${r};1H` + leadIn + lineStr + hintStr + pad);
|
|
1206
|
+
out.push(leadIn + lineStr + hintStr);
|
|
1156
1207
|
}
|
|
1208
|
+
|
|
1209
|
+
if (out.length === 0) out.push(PREFIX);
|
|
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
|
+
};
|
|
1227
|
+
this._notify();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Expose the last-computed input lines for the orchestrator.
|
|
1231
|
+
renderInputLines() { return this._inputLines.slice(); }
|
|
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
|
+
|
|
1241
|
+
_notify() {
|
|
1242
|
+
try { this._onChange(); } catch {}
|
|
1157
1243
|
}
|
|
1158
1244
|
|
|
1159
1245
|
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
1160
1246
|
|
|
1161
1247
|
destroy() {
|
|
1162
|
-
if (this.
|
|
1163
|
-
if (this.
|
|
1164
|
-
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; }
|
|
1165
1251
|
if (process.stdin.isTTY) {
|
|
1166
1252
|
process.stdin.removeListener('data', this._onData);
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1253
|
+
// Terminal-mode pops must run in queue order so they don't race with
|
|
1254
|
+
// a pending live-region redraw on shutdown.
|
|
1255
|
+
writer.enqueue(() => {
|
|
1256
|
+
// audit: allowed — terminal-mode raw escapes inside writer.enqueue (sanctioned escape hatch).
|
|
1257
|
+
try {
|
|
1258
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
1259
|
+
process.stdout.write('\x1b[<u'); // pop kitty keyboard protocol
|
|
1260
|
+
process.stdout.write('\x1b[>4;0m'); // reset modifyOtherKeys
|
|
1261
|
+
process.stdout.write('\x1b[?2004l'); // disable bracketed paste
|
|
1262
|
+
} catch {}
|
|
1263
|
+
});
|
|
1171
1264
|
process.stdin.setRawMode(false); // restore cooked mode so mouse works again
|
|
1172
1265
|
}
|
|
1173
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
|
+
};
|
package/lib/ui/select.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const writer = require('./writer');
|
|
4
|
+
|
|
5
|
+
// Modal-region select menu. Each frame is published via writer.setModal so
|
|
6
|
+
// the menu lives in the modal band above the status region — it redraws in
|
|
7
|
+
// place on arrow-key input and never lands in scrollback. Resolves with the
|
|
8
|
+
// selected index (or null on cancel); callers emit their own summary line
|
|
9
|
+
// via writer.scrollback after the promise resolves.
|
|
10
|
+
//
|
|
11
|
+
// Two input paths:
|
|
12
|
+
// * TUI: opts.captureNavigation(handler) → release fn. Mirrors the
|
|
13
|
+
// permission picker. The host (input-field) routes prev/next/
|
|
14
|
+
// select/cancel/expand actions to handler; release detaches.
|
|
15
|
+
// * Non-TUI: direct raw stdin. Used by cmdModels and the permission-
|
|
16
|
+
// prompt fallback in non-chat command flows.
|
|
17
|
+
//
|
|
18
|
+
// Returns null when stdout/stdin aren't TTY and no captureNavigation is
|
|
19
|
+
// supplied — there's no surface to render onto.
|
|
20
|
+
|
|
21
|
+
async function interactiveSelect(items, renderItem, options) {
|
|
22
|
+
const opts = options || {};
|
|
23
|
+
const initialIndex = Math.max(0, Math.min(opts.initialIndex || 0, items.length - 1));
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
const buildLines = (idx) => {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (let i = 0; i < items.length; i++) {
|
|
30
|
+
out.push(renderItem(items[i], i === idx, false));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── TUI path ─────────────────────────────────────────────────────────────
|
|
36
|
+
// Host wires keystrokes through captureNavigation; rendering goes through
|
|
37
|
+
// the writer's modal region so the live region (status bar, input row)
|
|
38
|
+
// beneath stays untouched between frames.
|
|
39
|
+
if (typeof opts.captureNavigation === 'function') {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
let idx = initialIndex;
|
|
42
|
+
let done = false;
|
|
43
|
+
writer.setModal(buildLines(idx));
|
|
44
|
+
const releaseNav = opts.captureNavigation((action) => {
|
|
45
|
+
if (done) return;
|
|
46
|
+
if (action === 'prev') {
|
|
47
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
48
|
+
writer.setModal(buildLines(idx));
|
|
49
|
+
} else if (action === 'next') {
|
|
50
|
+
idx = (idx + 1) % items.length;
|
|
51
|
+
writer.setModal(buildLines(idx));
|
|
52
|
+
} else if (action === 'select' || action === 'cancel') {
|
|
53
|
+
done = true;
|
|
54
|
+
// Order matters: clearModal first so the modal is erased while
|
|
55
|
+
// the host still has caret suppressed, then releaseNav so the
|
|
56
|
+
// host's render hooks fire setLive with the input caret
|
|
57
|
+
// restored. Reversing the two redraws the live region with the
|
|
58
|
+
// modal still in place for one frame, then nukes the caret.
|
|
59
|
+
writer.clearModal();
|
|
60
|
+
if (typeof releaseNav === 'function') releaseNav();
|
|
61
|
+
resolve(action === 'select' ? idx : null);
|
|
62
|
+
} else if (action === 'expand') {
|
|
63
|
+
if (typeof opts.onExpand === 'function') opts.onExpand();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Non-TUI path ─────────────────────────────────────────────────────────
|
|
70
|
+
// Need a real terminal on both ends to read raw keys and draw a modal.
|
|
71
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return null;
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let idx = initialIndex;
|
|
75
|
+
let done = false;
|
|
76
|
+
const wasRaw = typeof process.stdin.isRaw === 'boolean' ? process.stdin.isRaw : false;
|
|
77
|
+
|
|
78
|
+
writer.setModal(buildLines(idx));
|
|
79
|
+
|
|
80
|
+
process.stdin.setRawMode(true);
|
|
81
|
+
process.stdin.resume();
|
|
82
|
+
|
|
83
|
+
const finish = (result) => {
|
|
84
|
+
if (done) return;
|
|
85
|
+
done = true;
|
|
86
|
+
try { process.stdin.setRawMode(wasRaw); } catch {}
|
|
87
|
+
process.stdin.removeListener('data', onData);
|
|
88
|
+
process.stdin.pause();
|
|
89
|
+
writer.clearModal();
|
|
90
|
+
resolve(result);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onData = (chunk) => {
|
|
94
|
+
if (done) return;
|
|
95
|
+
const data = chunk.toString('utf8');
|
|
96
|
+
if (data[0] === '\x0f') { if (typeof opts.onExpand === 'function') opts.onExpand(); return; }
|
|
97
|
+
if (data === '\x03' || data === '\x1b' || data === 'q') { finish(null); return; }
|
|
98
|
+
if (data === '\r' || data === '\n') { finish(idx); return; }
|
|
99
|
+
if (data === '\x1b[A' || data === 'k') {
|
|
100
|
+
idx = (idx - 1 + items.length) % items.length;
|
|
101
|
+
writer.setModal(buildLines(idx));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (data === '\x1b[B' || data === 'j') {
|
|
105
|
+
idx = (idx + 1) % items.length;
|
|
106
|
+
writer.setModal(buildLines(idx));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
process.stdin.on('data', onData);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { interactiveSelect };
|