@semalt-ai/code 1.8.3 → 1.8.5

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,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
- this._cursorHidden = false;
135
- this._blinkTimer = setInterval(() => {
136
- if (this._idle || this._disabled) return;
137
- this._blink = !this._blink;
138
- if (!this._pasteMode) this._render();
139
- }, 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;
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
- // 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.
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.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;
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
- this.emit('interrupt');
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._searchMode) {
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 "caret" is a printable inverted/underlined character embedded in
1049
- // the text; the OS cursor stays hidden for the whole TUI session so it
1050
- // never flickers during in-place redraws.
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
- this._inputLines = [prefix + typeLabel + displayMatch];
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._blinkTimer) { clearInterval(this._blinkTimer); this._blinkTimer = null; }
1174
- if (this._escTimer) { clearTimeout(this._escTimer); this._escTimer = null; }
1175
- 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; }
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
@@ -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 };