@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.
@@ -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
- this._cursorHidden = false;
129
- this._blinkTimer = setInterval(() => {
130
- if (this._idle || this._disabled) return;
131
- this._blink = !this._blink;
132
- if (!this._pasteMode) this._render();
133
- }, 500);
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
- process.stdout.write('\x1b[?2004h'); // bracketed paste
160
- process.stdout.write('\x1b[>4;2m'); // xterm modifyOtherKeys level 2
161
- process.stdout.write('\x1b[>1u'); // kitty keyboard protocol
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
- // Stop all periodic stdout writes so the terminal viewport can scroll freely.
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.emit('interrupt'); break;
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 || buf[0] === 0x04) {
840
- this.emit('interrupt');
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._searchMode) {
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
- // Use DECSC/DECRC so the cursor is restored after drawing (keeps streaming
1021
- // tokens anchored at historyRows when the blink timer calls this).
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
- layout.setInputHeight(1);
1112
+ this._layout.setInputHeight(1);
1038
1113
  }
1039
- const baseRow = layout.inputRow;
1040
- // DECSC/DECRC keeps the cursor in the history scroll region so streaming
1041
- // tokens written after this blink-timer redraw land at the right row.
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
- layout.setInputHeight(1);
1123
+ this._layout.setInputHeight(1);
1052
1124
  }
1053
- const baseRow = layout.inputRow;
1054
- process.stdout.write(`\x1b[${baseRow};1H\x1b[2K${DIM}›${RST}`);
1055
- process.stdout.write(' '.repeat(Math.max(0, cols - 1)));
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
- layout.setInputHeight(1);
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 line = prefix + typeLabel + displayMatch;
1091
- process.stdout.write(`\x1b[${baseRow};1H\x1b[2K${line}`);
1092
- const visLen = line.replace(/\x1b\[[^m]*m/g, '').length;
1093
- if (visLen < cols) process.stdout.write(' '.repeat(cols - visLen));
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
- layout.setInputHeight(needed);
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
- const visLen = PREFIX_LEN + lineChars.length + (li === 0 ? this._hint.length : 0);
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._blinkTimer) { clearInterval(this._blinkTimer); this._blinkTimer = null; }
1163
- if (this._escTimer) { clearTimeout(this._escTimer); this._escTimer = null; }
1164
- if (this._idleTimer) { clearTimeout(this._idleTimer); this._idleTimer = null; }
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
- this._cursorHidden = true; this.showCursor(); // restore cursor visibility
1168
- process.stdout.write('\x1b[<u'); // pop kitty keyboard protocol
1169
- process.stdout.write('\x1b[>4;0m'); // reset modifyOtherKeys
1170
- process.stdout.write('\x1b[?2004l'); // disable bracketed paste
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
@@ -43,8 +43,6 @@ class LayoutManager {
43
43
  if (this._heightChangeCb) this._heightChangeCb();
44
44
  }
45
45
 
46
- moveTo(row, col) { process.stdout.write(`\x1b[${row};${col || 1}H`); }
47
-
48
46
  destroy() {
49
47
  process.stdout.removeListener('resize', this._handler);
50
48
  }
@@ -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
+ };
@@ -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 };