xiaotime 0.5.1 → 0.5.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.
Files changed (50) hide show
  1. package/dist/__tests__/ansi_tags.test.d.ts +9 -0
  2. package/dist/__tests__/ansi_tags.test.d.ts.map +1 -0
  3. package/dist/__tests__/ansi_tags.test.js +58 -0
  4. package/dist/__tests__/ansi_tags.test.js.map +1 -0
  5. package/dist/__tests__/paste_transform_submit.test.d.ts +2 -0
  6. package/dist/__tests__/paste_transform_submit.test.d.ts.map +1 -0
  7. package/dist/__tests__/paste_transform_submit.test.js +74 -0
  8. package/dist/__tests__/paste_transform_submit.test.js.map +1 -0
  9. package/dist/__tests__/session_match.test.d.ts +10 -0
  10. package/dist/__tests__/session_match.test.d.ts.map +1 -0
  11. package/dist/__tests__/session_match.test.js +55 -0
  12. package/dist/__tests__/session_match.test.js.map +1 -0
  13. package/dist/ansi_tags.d.ts +20 -0
  14. package/dist/ansi_tags.d.ts.map +1 -0
  15. package/dist/ansi_tags.js +38 -0
  16. package/dist/ansi_tags.js.map +1 -0
  17. package/dist/config.d.ts +1 -1
  18. package/dist/config.js +1 -1
  19. package/dist/display.d.ts +53 -22
  20. package/dist/display.d.ts.map +1 -1
  21. package/dist/display.js +750 -47
  22. package/dist/display.js.map +1 -1
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +34 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/session.d.ts.map +1 -1
  28. package/dist/session.js +5 -2
  29. package/dist/session.js.map +1 -1
  30. package/dist/session_match.d.ts +19 -0
  31. package/dist/session_match.d.ts.map +1 -0
  32. package/dist/session_match.js +9 -0
  33. package/dist/session_match.js.map +1 -0
  34. package/dist/sidebar.d.ts +84 -0
  35. package/dist/sidebar.d.ts.map +1 -0
  36. package/dist/sidebar.js +310 -0
  37. package/dist/sidebar.js.map +1 -0
  38. package/dist/tools.d.ts +4 -0
  39. package/dist/tools.d.ts.map +1 -1
  40. package/dist/tools.js +25 -66
  41. package/dist/tools.js.map +1 -1
  42. package/dist/ui_state.d.ts +41 -96
  43. package/dist/ui_state.d.ts.map +1 -1
  44. package/dist/ui_state.js +226 -213
  45. package/dist/ui_state.js.map +1 -1
  46. package/dist/ws.d.ts +10 -14
  47. package/dist/ws.d.ts.map +1 -1
  48. package/dist/ws.js +240 -119
  49. package/dist/ws.js.map +1 -1
  50. package/package.json +10 -5
package/dist/ui_state.js CHANGED
@@ -2,52 +2,36 @@
2
2
  /**
3
3
  * Session-UI state machine for the xiaotime dev CLI.
4
4
  *
5
- * SPRINT-CLI-STATE-MACHINE-1 (#6163 / closes #6127) shipped the original
6
- * state machine + raw-mode approval prompt. That fix eliminated the
7
- * defaults-to-N half of the "yy" double-echo bug but the visible-yy half
8
- * persisted because `readline.createInterface({ terminal: true })` attaches
9
- * its own keypress handler to stdin that echoes characters via its own
10
- * stdout writes; `rl.pause()` stops `line` events but does NOT detach that
11
- * echo path, and `setRawMode(true)` only suppresses kernel-level echo, not
12
- * readline's.
5
+ * SPRINT-CLI-STATE-MACHINE-1 (#6163 / closes #6127)
6
+ * SPRINT-CLI-APPROVAL-MENU-1 (#6509)
7
+ * SPRINT-CLI-INPUT-AND-TOOL-HARDENING-1 (#7299) PR2
8
+ * SPRINT-CLI-PASTE-MODE-4 push-based paste interception.
13
9
  *
14
- * SPRINT-CLI-APPROVAL-MENU-1 (#6509) replaces `promptApproval`'s `[y/N]`
15
- * one-shot prompt with `promptApprovalMenu` — a three-option numbered menu
16
- * (Run / Run + allowlist / Reject + feedback). The rl is explicitly
17
- * **closed and recreated** around the menu render so its keypress echo
18
- * physically can't fire (the rl object is gone for the duration of the
19
- * menu). Persistent close listeners survive the recreation via
20
- * `onRlClose()` — callers that need a stable close hook register through
21
- * that instead of `getRl().on("close", ...)`.
10
+ * Two strategies run together:
22
11
  *
23
- * SPRINT-CLI-INPUT-AND-TOOL-HARDENING-1 (#7299) PR2 closes a follow-up
24
- * leak in the above recreation path: Node's `rl.close()` doesn't reliably
25
- * detach the prior rl's keypress listener from stdin when raw mode was
26
- * toggled mid-cycle. Each surviving listener echoes keystrokes
27
- * independently, so N approval-menu cycles produced ~N+1 character
28
- * multiplication. `_makeRl` now strips leftover `keypress` listeners
29
- * before attaching a new rl — full rationale + rejected alternatives on
30
- * `_makeRl` below + in issue #7299 Decisions log (D4, D5).
12
+ * 1. Bracketed paste (ESC[200~ / ESC[201~) the clean path. When the
13
+ * terminal sends these markers the PasteTransform buffers everything
14
+ * between them, collapses newlines, and emits a single block. Readline
15
+ * never sees a raw newline, so it cannot auto-submit.
31
16
  *
32
- * The state machine has four states:
17
+ * 2. Rapid-chunk coalescing the fallback. When bracketed paste mode is
18
+ * not supported by the terminal (Termius, older SSH clients) chunks
19
+ * arriving with gaps < RAPID_PASTE_GAP_MS are held in a buffer.
20
+ * After RAPID_PASTE_IDLE_MS of silence the buffer is flushed as a
21
+ * single block with internal newlines collapsed.
33
22
  *
34
- * IDLE — at the `you:` prompt, awaiting user input.
35
- * GENERATING — model is streaming tokens (or about to). Spinner.
36
- * RUNNING_TOOL — a server-side tool is running. Spinner.
37
- * AWAITING_APPROVAL user must navigate the approval menu for a
38
- * client-side tool. Main rl is closed; raw stdin
39
- * captures keystrokes.
23
+ * Root-cause fix (PASTE-MODE-4): readline must NOT receive the
24
+ * PasteTransform stream directly as `input` with `terminal: true`.
25
+ * When readline sees a Transform as input in terminal mode it internally
26
+ * calls readline.emitKeypressEvents() on that stream, which installs its
27
+ * own `data` listener that fires keypress events — including \r/\n — before
28
+ * our _transform() can intercept them.
40
29
  *
41
- * The WS handler in `ws.ts` is the sole driver. Transitions:
42
- *
43
- * IDLE -- user line received -> GENERATING
44
- * GENERATING -- text_delta -> GENERATING (refresh)
45
- * GENERATING -- tool_call (server) -> RUNNING_TOOL
46
- * GENERATING -- tool_call (client+approval) -> AWAITING_APPROVAL
47
- * AWAITING_APPROVAL -- menu selection -> RUNNING_TOOL
48
- * RUNNING_TOOL -- tool_result sent -> GENERATING
49
- * GENERATING -- message_stop -> IDLE
50
- * ANY -- error -> IDLE
30
+ * Solution: push-based forwarding. PasteTransform processes chunks from
31
+ * stdin and pushes safe output into a PassThrough. Readline reads from the
32
+ * PassThrough. readline.emitKeypressEvents() is called on the PassThrough,
33
+ * which only ever sees already-safe data — newlines inside paste blocks have
34
+ * already been collapsed to spaces.
51
35
  */
52
36
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
53
37
  if (k2 === undefined) k2 = k;
@@ -86,83 +70,217 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
86
70
  return (mod && mod.__esModule) ? mod : { "default": mod };
87
71
  };
88
72
  Object.defineProperty(exports, "__esModule", { value: true });
89
- exports.SessionUI = void 0;
73
+ exports.SessionUI = exports.PasteTransform = void 0;
90
74
  const readline = __importStar(require("readline"));
75
+ const stream_1 = require("stream");
91
76
  const chalk_1 = __importDefault(require("chalk"));
92
- const SPINNER_FRAMES = ["", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
93
- const SPINNER_INTERVAL_MS = 100;
94
- const ELAPSED_THRESHOLD_MS = 3000; // show elapsed time after this
77
+ // Claude-Code-style "thinking" indicator: ONE fixed symbol whose brightness
78
+ // pulses in place, beside a static label. The old braille cycle "bounced"
79
+ // because the glyph itself changed every frame; here the glyph never changes
80
+ // only its colour ramps dim → bright → dim — so the eye reads a steady pulse.
81
+ // The ramp is laid out up-then-down so cycling the index is a smooth triangle.
82
+ const SPINNER_SYMBOL = "✻";
83
+ const SPINNER_PULSE_SHADES = [
84
+ "#2f3a42", "#46555f", "#5e7480", "#7d97a4",
85
+ "#a9c4d0", "#7d97a4", "#5e7480", "#46555f",
86
+ ];
87
+ const SPINNER_INTERVAL_MS = 120;
88
+ const ELAPSED_THRESHOLD_MS = 3000;
95
89
  const MENU_OPTIONS = [
96
90
  "Run",
97
91
  "Run and don't ask again",
98
92
  "Reject and tell Claude why",
99
93
  ];
100
94
  const MENU_HINT = "(1-3, ↑↓ Enter, Esc to reject)";
95
+ const PASTE_START = "\x1b[200~";
96
+ const PASTE_END = "\x1b[201~";
97
+ const BPASTE_ENABLE = "\x1b[?2004h";
98
+ const BPASTE_DISABLE = "\x1b[?2004l";
99
+ // Rapid-chunk coalescing thresholds
100
+ const RAPID_PASTE_GAP_MS = 30; // chunks arriving faster than this = paste burst
101
+ const RAPID_PASTE_IDLE_MS = 60; // flush buffer after this much silence
102
+ // ── PasteTransform ────────────────────────────────────────────────────────────
103
+ // Processes raw stdin chunks. Two interception modes:
104
+ //
105
+ // Mode A — bracketed paste: ESC[200~ starts buffering, ESC[201~ flushes.
106
+ // Mode B — rapid-chunk coalescing: bursts of chunks with gaps < RAPID_PASTE_GAP_MS
107
+ // are held and flushed as one block after RAPID_PASTE_IDLE_MS of silence.
108
+ //
109
+ // Output is pushed into a PassThrough (`sink`) which readline reads from.
110
+ // Readline never touches this Transform directly — that prevents readline from
111
+ // calling emitKeypressEvents() on us and bypassing our interception.
112
+ class PasteTransform extends stream_1.Transform {
113
+ // Mode A
114
+ _inBracketedPaste = false;
115
+ _bracketedBuf = "";
116
+ // Mode B
117
+ _rapidBuf = "";
118
+ _lastChunkAt = 0;
119
+ _rapidTimer = null;
120
+ // The PassThrough that readline reads from
121
+ sink;
122
+ constructor() {
123
+ super();
124
+ this.sink = new stream_1.PassThrough();
125
+ // Forward our Transform output to the sink
126
+ this.pipe(this.sink);
127
+ }
128
+ _transform(chunk, _enc, cb) {
129
+ let s = chunk.toString();
130
+ // ── Mode A: bracketed paste ──────────────────────────────────────────────
131
+ if (s.includes(PASTE_START)) {
132
+ this._inBracketedPaste = true;
133
+ this._bracketedBuf = "";
134
+ s = s.replace(PASTE_START, "");
135
+ }
136
+ if (s.includes(PASTE_END)) {
137
+ this._inBracketedPaste = false;
138
+ const tail = s.replace(PASTE_END, "");
139
+ this._bracketedBuf += tail;
140
+ const safe = this._bracketedBuf.replace(/[\r\n]+/g, " ");
141
+ this._bracketedBuf = "";
142
+ this.push(Buffer.from(safe));
143
+ cb();
144
+ return;
145
+ }
146
+ if (this._inBracketedPaste) {
147
+ this._bracketedBuf += s;
148
+ cb();
149
+ return;
150
+ }
151
+ // ── Mode B: rapid-chunk coalescing ───────────────────────────────────────
152
+ const now = Date.now();
153
+ const gap = now - this._lastChunkAt;
154
+ this._lastChunkAt = now;
155
+ const hasNewline = /[\r\n]/.test(s);
156
+ const isBurst = gap < RAPID_PASTE_GAP_MS && this._rapidBuf.length > 0;
157
+ if (isBurst || (hasNewline && this._rapidBuf.length > 0)) {
158
+ this._rapidBuf += s;
159
+ this._scheduleRapidFlush();
160
+ cb();
161
+ return;
162
+ }
163
+ if (hasNewline && this._rapidBuf.length === 0) {
164
+ // A lone CR/LF is a deliberate Enter, not the first chunk of a paste.
165
+ // Pass it straight through so readline emits its `line` event and the
166
+ // prompt submits. Without this, the newline gets buffered and collapsed
167
+ // to a space by _scheduleRapidFlush, so readline never sees a line
168
+ // terminator and NO prompt ever submits. Only buffer + newline-strip
169
+ // when the chunk carries text alongside the newline (the first chunk of
170
+ // a fast, un-bracketed multi-line paste).
171
+ if (/^[\r\n]+$/.test(s)) {
172
+ this.push(chunk);
173
+ cb();
174
+ return;
175
+ }
176
+ this._rapidBuf += s;
177
+ this._scheduleRapidFlush();
178
+ cb();
179
+ return;
180
+ }
181
+ // Normal single keystroke — pass through immediately
182
+ this.push(chunk);
183
+ cb();
184
+ }
185
+ _scheduleRapidFlush() {
186
+ if (this._rapidTimer)
187
+ clearTimeout(this._rapidTimer);
188
+ this._rapidTimer = setTimeout(() => {
189
+ this._rapidTimer = null;
190
+ this._flushRapidBuf();
191
+ }, RAPID_PASTE_IDLE_MS);
192
+ }
193
+ // Flush the rapid-coalesce buffer to readline.
194
+ //
195
+ // Trailing-newline rule — fixes the "press Enter twice" bug on SSH clients
196
+ // (Termius, mosh) that batch a fast-typed line together with its Enter into a
197
+ // single read, e.g. "hey junior\r" arriving as ONE chunk. The old code
198
+ // collapsed EVERY newline — including that trailing \r — to a space, so
199
+ // readline never saw a line terminator: the first Enter silently did nothing
200
+ // and you had to hit Enter again.
201
+ //
202
+ // - buffer ends in a newline AND the text before it has no internal newline
203
+ // → a single typed line + Enter. Re-emit one \r so readline submits on the
204
+ // first Enter.
205
+ // - buffer has an internal newline (genuine multi-line paste) → collapse all
206
+ // newlines to spaces and emit NO terminator, so the paste lands in the
207
+ // input for review (unchanged behaviour).
208
+ _flushRapidBuf() {
209
+ if (!this._rapidBuf)
210
+ return;
211
+ const buf = this._rapidBuf;
212
+ this._rapidBuf = "";
213
+ const trailing = buf.match(/[\r\n]+$/);
214
+ const body = trailing ? buf.slice(0, trailing.index) : buf;
215
+ const isSingleLineEnter = trailing !== null && !/[\r\n]/.test(body);
216
+ if (isSingleLineEnter) {
217
+ this.push(Buffer.from(body + "\r"));
218
+ }
219
+ else {
220
+ this.push(Buffer.from(buf.replace(/[\r\n]+/g, " ")));
221
+ }
222
+ }
223
+ _flush(cb) {
224
+ if (this._rapidTimer) {
225
+ clearTimeout(this._rapidTimer);
226
+ this._rapidTimer = null;
227
+ }
228
+ const combined = this._bracketedBuf + this._rapidBuf;
229
+ if (combined) {
230
+ const safe = combined.replace(/[\r\n]+/g, " ");
231
+ this.push(Buffer.from(safe));
232
+ }
233
+ this._bracketedBuf = "";
234
+ this._rapidBuf = "";
235
+ cb();
236
+ }
237
+ }
238
+ exports.PasteTransform = PasteTransform;
101
239
  class SessionUI {
102
240
  state = "idle";
103
241
  spinnerTimer = null;
104
242
  spinnerFrame = 0;
105
243
  spinnerStartedAt = 0;
106
244
  spinnerLabel = "";
107
- // True only while the spinner has written something to the line and
108
- // we owe a clear. Set on first frame, cleared on stopSpinner. Lets
109
- // text_delta callers know they need to clear the spinner line
110
- // before printing the first token.
111
245
  spinnerLineDirty = false;
112
246
  rl;
113
247
  lineListener = null;
114
- /**
115
- * Close-listener callbacks that should survive rl recreations. The
116
- * approval-menu path closes + recreates `rl` to physically detach
117
- * readline's keypress echo. ws.ts registers its "Session paused"
118
- * resume hint here so it stays attached across recreations.
119
- */
120
248
  closeListeners = [];
249
+ _pasteTransform = null;
121
250
  constructor() {
122
251
  this.rl = this._makeRl();
252
+ process.on("exit", () => {
253
+ process.stdout.write(BPASTE_DISABLE);
254
+ process.stdout.write("\x1b[?25h"); // never leave the cursor hidden on exit
255
+ });
123
256
  }
124
- /** Hand the underlying readline to callers that need it for cleanup. */
125
257
  getRl() {
126
258
  return this.rl;
127
259
  }
128
- /**
129
- * Register a callback to fire when the rl reports a non-suppressed
130
- * `close` event (the only one users observe is Ctrl+C at the main
131
- * prompt — close-during-approval is suppressed via removeAllListeners
132
- * before `rl.close()`). Callbacks are re-attached on every rl
133
- * recreation so they outlive `promptApprovalMenu`.
134
- */
135
260
  onRlClose(listener) {
136
261
  this.closeListeners.push(listener);
137
262
  this.rl.on("close", listener);
138
263
  }
139
- /** Set the explicit current state. Drives spinner start/stop. */
140
264
  setState(next) {
141
265
  if (next === this.state)
142
266
  return;
143
267
  const prev = this.state;
144
268
  this.state = next;
145
- // Transition out of any spinner state.
146
269
  if (prev === "generating" || prev === "running_tool") {
147
270
  this.stopSpinner();
148
271
  }
149
- // Transition in.
150
272
  if (next === "generating") {
151
273
  this.startSpinner("thinking");
152
274
  }
153
275
  else if (next === "running_tool") {
154
276
  this.startSpinner("running tool");
155
277
  }
156
- // `awaiting_approval` / `idle` => no spinner. Approval-menu path
157
- // takes over stdin in `promptApprovalMenu`.
158
278
  }
159
279
  getState() {
160
280
  return this.state;
161
281
  }
162
- /** Wait for one line of user input on the main rl. */
163
282
  promptLine() {
164
283
  return new Promise((resolve) => {
165
- // Drop any previous one-shot listener that didn't fire.
166
284
  if (this.lineListener) {
167
285
  this.rl.off("line", this.lineListener);
168
286
  }
@@ -174,49 +292,12 @@ class SessionUI {
174
292
  this.rl.once("line", listener);
175
293
  });
176
294
  }
177
- /**
178
- * Three-option numbered approval menu. Replaces the pre-#6509
179
- * `[y/N]` one-shot prompt.
180
- *
181
- * Visual:
182
- * ❯ 1. Run
183
- * 2. Run and don't ask again
184
- * 3. Reject and tell Claude why
185
- * (1-3, ↑↓ Enter, Esc to reject)
186
- *
187
- * Controls:
188
- * - Number key 1/2/3 — one-shot select
189
- * - Arrow Up/Down — move focus
190
- * - Enter — select focused
191
- * - Esc — equivalent to option 3 (reject)
192
- * - Ctrl+C — reject + propagate SIGINT (cancels session)
193
- *
194
- * The rl is closed before the menu render and recreated after. This
195
- * physically removes readline's keypress echo from stdin for the
196
- * duration of the menu — the bug fix the #6163 patch attempted via
197
- * `rl.pause()` (which only stops `line` events, not the keypress
198
- * echo). Persistent close listeners are stashed in
199
- * `this.closeListeners` and re-attached on recreate; the close event
200
- * triggered by THIS close() is suppressed via removeAllListeners
201
- * before the call so it doesn't fire the "Session paused" hint.
202
- *
203
- * For option 3, after the menu resolves the recreated rl prompts
204
- * once for a free-form rejection reason (line-mode). Empty input is
205
- * allowed.
206
- *
207
- * The returned `allowlistRequested: true` (option 2) is a signal to
208
- * the caller — PR2 wires it to write the pattern to
209
- * `~/.xiaotime/settings.json`. PR1 callers may ignore it.
210
- */
211
295
  async promptApprovalMenu(toolHeader) {
212
296
  this.setState("awaiting_approval");
297
+ this._destroyPasteTransform();
213
298
  if (toolHeader) {
214
299
  process.stdout.write(toolHeader);
215
300
  }
216
- // Recreate the rl in `finally` so it ALWAYS lands — even if the
217
- // no-TTY bail-out fires inside `_renderMenuAndGetSelection`. The
218
- // next `promptLine` call needs a live rl regardless of how the
219
- // menu resolved.
220
301
  let selectedIdx;
221
302
  try {
222
303
  selectedIdx = await this._renderMenuAndGetSelection();
@@ -224,9 +305,6 @@ class SessionUI {
224
305
  finally {
225
306
  this.rl = this._makeRl();
226
307
  }
227
- // -1 → bail-out from `_renderMenuAndGetSelection` because stdin is
228
- // not a TTY (CI / piped invocation / test runner). Skip the
229
- // rejection-reason readline prompt because nothing would feed it.
230
308
  if (selectedIdx === -1) {
231
309
  return { approved: false, rejectReason: "(no TTY)" };
232
310
  }
@@ -238,82 +316,54 @@ class SessionUI {
238
316
  process.stdout.write(chalk_1.default.green(" ✓ Approved (allowlist requested)\n"));
239
317
  return { approved: true, allowlistRequested: true };
240
318
  }
241
- // selectedIdx === 2 — rejection. Prompt for free-form reason via
242
- // the recreated rl (line-mode, single line, empty allowed).
243
319
  process.stdout.write(chalk_1.default.yellow(" Reason (Enter to send empty): "));
244
320
  const reason = await new Promise((resolve) => {
245
321
  this.rl.once("line", (line) => resolve(line.trim()));
246
322
  });
247
323
  return { approved: false, rejectReason: reason };
248
324
  }
249
- // ── Menu internals ───────────────────────────────────────────────
325
+ // ── PasteTransform lifecycle ──────────────────────────────────────────────
326
+ _destroyPasteTransform() {
327
+ if (this._pasteTransform) {
328
+ process.stdin.unpipe(this._pasteTransform);
329
+ this._pasteTransform.destroy();
330
+ this._pasteTransform = null;
331
+ }
332
+ }
333
+ // ── rl factory ───────────────────────────────────────────────────────────
250
334
  _makeRl() {
251
- // SPRINT-CLI-INPUT-AND-TOOL-HARDENING-1 (#7299) PR2 — strip
252
- // leftover keypress listeners on stdin before attaching a new rl.
253
- //
254
- // Background: `readline.createInterface({ terminal: true })`
255
- // attaches its own keypress handler to `process.stdin` via
256
- // `emitKeypressEvents` (Node internals). `rl.close()` is documented
257
- // to detach that handler, but in practice — when a prior menu
258
- // cycle toggled stdin between raw and cooked mode — Node's cleanup
259
- // doesn't always run, leaving the prior rl's keypress listener
260
- // attached. Each subsequent `_makeRl` call (post-#6509 happens
261
- // once per approval-required tool call) adds another listener;
262
- // every active listener independently echoes each keystroke
263
- // through readline's `_ttyWrite`. N leaked listeners = N+1 echo
264
- // on every key.
265
- //
266
- // Stephan observed 6-8x character multiplication after a session
267
- // with several approval-menu cycles 2026-05-25 — directly traced
268
- // to this accumulation. The fix is to strip "keypress" listeners
269
- // explicitly before each recreate; readline's `createInterface`
270
- // re-bootstraps `emitKeypressEvents` on the next call, so we
271
- // don't lose the keypress-event infrastructure.
272
- //
273
- // Rejected alternatives — see #7299 Decisions D4: (B) switching
274
- // to `terminal: false` post-menu (would need two rl modes), and
275
- // (C) not recreating at all (reverts to the pre-#6509 double-echo
276
- // bug). A is surgical + asserted by
277
- // `ui_state_listener_lifecycle.test.ts`.
278
- //
279
- // `end` / `error` are stripped for the same reason — each
280
- // `createInterface({ terminal: true })` registers one of each on
281
- // stdin. `resize` is stripped on STDOUT, not stdin (readline
282
- // attaches the resize handler to the output stream for terminal
283
- // width tracking — verified empirically against Node 22). Without
284
- // these explicit cleanups the listener counts cross Node's default
285
- // MaxListeners (10) after ~11 recreations and log
286
- // MaxListenersExceededWarning to stderr. They don't produce
287
- // visible character echo (only keypress does) but they're the
288
- // same root-cause leak and a one-line fix to include.
289
335
  process.stdin.removeAllListeners("keypress");
290
336
  process.stdin.removeAllListeners("end");
291
337
  process.stdin.removeAllListeners("error");
292
338
  process.stdout.removeAllListeners("resize");
339
+ process.stdout.write(BPASTE_ENABLE);
340
+ // Create PasteTransform, pipe stdin through it.
341
+ // Readline reads from pt.sink (a PassThrough) — NOT from the Transform
342
+ // directly. This prevents readline from calling emitKeypressEvents() on
343
+ // the transform, which would bypass our paste interception.
344
+ this._destroyPasteTransform();
345
+ const pt = new PasteTransform();
346
+ this._pasteTransform = pt;
347
+ process.stdin.pipe(pt);
293
348
  const rl = readline.createInterface({
294
- input: process.stdin,
349
+ input: pt.sink, // readline reads from the safe PassThrough sink
295
350
  output: process.stdout,
296
351
  terminal: true,
297
352
  });
298
353
  for (const l of this.closeListeners) {
299
354
  rl.on("close", l);
300
355
  }
356
+ rl.on("close", () => {
357
+ process.stdout.write(BPASTE_DISABLE);
358
+ this._destroyPasteTransform();
359
+ });
301
360
  return rl;
302
361
  }
362
+ // ── Menu internals ────────────────────────────────────────────────────────
303
363
  async _renderMenuAndGetSelection() {
304
- // Suppress the close-listener side effects for this internal
305
- // close — we don't want the "Session paused" hint firing
306
- // mid-approval. Stash the listeners on this.closeListeners so
307
- // _makeRl can re-attach them after the menu resolves.
308
364
  this.rl.removeAllListeners("close");
309
365
  this.rl.close();
310
366
  const stdin = process.stdin;
311
- // Bail BEFORE rendering the menu if stdin isn't a TTY. setRawMode
312
- // can silently no-op on non-TTY stdin in some environments
313
- // (vitest, piped invocation) — in that case the data listener
314
- // would attach but never receive bytes, hanging the caller
315
- // indefinitely. -1 sentinel tells promptApprovalMenu to skip the
316
- // rejection-reason prompt (which would also hang).
317
367
  if (!stdin.isTTY) {
318
368
  process.stdout.write(chalk_1.default.red("Rejected (no TTY)\n"));
319
369
  return -1;
@@ -323,20 +373,12 @@ class SessionUI {
323
373
  stdin.setRawMode(true);
324
374
  }
325
375
  catch {
326
- // Belt + suspenders — isTTY guard above should cover this, but a
327
- // mid-session TTY loss could surface here.
328
376
  process.stdout.write(chalk_1.default.red("Rejected (no TTY)\n"));
329
377
  return -1;
330
378
  }
331
379
  stdin.resume();
332
380
  let focusedIdx = 0;
333
381
  this._renderMenu(focusedIdx);
334
- // #6532 CR-r1: function-level finally guarantees onData removal +
335
- // setRawMode restore even if the Promise body throws or the
336
- // process is signalled. The previous design relied on every
337
- // `finish()` call doing the cleanup, leaving leak windows when
338
- // onData itself threw or when an awaiter rejected without
339
- // routing through finish().
340
382
  let onData = null;
341
383
  try {
342
384
  return await new Promise((resolve, reject) => {
@@ -347,29 +389,18 @@ class SessionUI {
347
389
  onData = (chunk) => {
348
390
  try {
349
391
  const s = chunk.toString();
350
- // Ctrl+C → reject + propagate SIGINT so the WS handler's
351
- // close path runs and the session ends cleanly.
352
392
  if (s.includes("\x03")) {
353
393
  finish(2);
354
394
  process.kill(process.pid, "SIGINT");
355
395
  return;
356
396
  }
357
- // Allowlisted escape-prefixed sequences. Esc alone (no
358
- // `[`) → option 3 (reject). Arrow up/down rotate focus.
359
- // Any OTHER `\x1b`-prefixed input is explicitly rejected
360
- // (logged to stderr at debug level + ignored) per #6532
361
- // CR-r1: previously such sequences silently no-op'd,
362
- // which is currently safe due to the no-echo policy but
363
- // would become a terminal-injection vector if a future
364
- // diagnostic change started echoing unknown input.
365
397
  if (s.charCodeAt(0) === 0x1b) {
366
398
  if (s === "\x1b") {
367
399
  finish(2);
368
400
  return;
369
401
  }
370
402
  if (s === "\x1b[A") {
371
- focusedIdx =
372
- (focusedIdx - 1 + MENU_OPTIONS.length) % MENU_OPTIONS.length;
403
+ focusedIdx = (focusedIdx - 1 + MENU_OPTIONS.length) % MENU_OPTIONS.length;
373
404
  this._rerenderMenu(focusedIdx);
374
405
  return;
375
406
  }
@@ -378,27 +409,20 @@ class SessionUI {
378
409
  this._rerenderMenu(focusedIdx);
379
410
  return;
380
411
  }
381
- // Unrecognized escape sequence — log + ignore. Use the
382
- // hex form so a malicious paste of bell/CSI bytes is
383
- // not re-rendered through stderr.
384
412
  const hex = Array.from(chunk)
385
413
  .map((b) => b.toString(16).padStart(2, "0"))
386
414
  .join(" ");
387
415
  process.stderr.write(chalk_1.default.dim(`\n[approval-menu] ignored unrecognized escape: ${hex}\n`));
388
416
  return;
389
417
  }
390
- // Number-key one-shot selectors.
391
418
  if (s === "1" || s === "2" || s === "3") {
392
419
  finish(Number(s) - 1);
393
420
  return;
394
421
  }
395
- // Enter on focused row.
396
422
  if (s === "\r" || s === "\n") {
397
423
  finish(focusedIdx);
398
424
  return;
399
425
  }
400
- // Anything else — ignore. No echo, no bounce. The visible
401
- // menu stays put so the user can try again.
402
426
  }
403
427
  catch (err) {
404
428
  reject(err);
@@ -408,18 +432,14 @@ class SessionUI {
408
432
  });
409
433
  }
410
434
  finally {
411
- if (onData) {
435
+ if (onData)
412
436
  stdin.removeListener("data", onData);
413
- }
414
437
  try {
415
438
  stdin.setRawMode(wasRaw);
416
439
  }
417
- catch {
418
- /* setRawMode can throw if stdin lost its TTY mid-session. */
419
- }
440
+ catch { /* TTY lost mid-session */ }
420
441
  }
421
442
  }
422
- /** Total lines the menu occupies: one per option + one hint line. */
423
443
  _menuLineCount() {
424
444
  return MENU_OPTIONS.length + 1;
425
445
  }
@@ -432,7 +452,6 @@ class SessionUI {
432
452
  process.stdout.write(chalk_1.default.dim(` ${MENU_HINT}\n`));
433
453
  }
434
454
  _rerenderMenu(focusedIdx) {
435
- // Move cursor up to top of menu, clear each line, redraw.
436
455
  const lines = this._menuLineCount();
437
456
  process.stdout.write(`\x1b[${lines}A`);
438
457
  for (let i = 0; i < MENU_OPTIONS.length; i++) {
@@ -452,32 +471,29 @@ class SessionUI {
452
471
  if (i < lines - 1)
453
472
  process.stdout.write("\n");
454
473
  }
455
- // Cursor left at start of the (now-cleared) first menu line so
456
- // the post-selection echo lands there cleanly.
457
474
  process.stdout.write(`\x1b[${lines - 1}A\r`);
458
475
  }
459
- // ── Spinner internals ────────────────────────────────────────────
476
+ // ── Spinner internals ─────────────────────────────────────────────────────
460
477
  startSpinner(label) {
461
478
  this.spinnerLabel = label;
462
479
  this.spinnerFrame = 0;
463
480
  this.spinnerStartedAt = Date.now();
464
481
  this.spinnerLineDirty = false;
482
+ // Hide the terminal cursor while the spinner animates. Without this the
483
+ // blinking hardware cursor jumps column-0 ↔ end on every \r repaint, which
484
+ // reads as a cursor "rapidly disappearing" next to the thinking label.
485
+ process.stdout.write("\x1b[?25l");
465
486
  this.spinnerTimer = setInterval(() => this.renderSpinnerFrame(), SPINNER_INTERVAL_MS);
466
- // Render the first frame immediately so there's no perceptible
467
- // delay between state-set and visible feedback.
468
487
  this.renderSpinnerFrame();
469
488
  }
470
489
  renderSpinnerFrame() {
471
- const frame = SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length];
490
+ const shade = SPINNER_PULSE_SHADES[this.spinnerFrame % SPINNER_PULSE_SHADES.length];
472
491
  const elapsed = Date.now() - this.spinnerStartedAt;
473
492
  const elapsedStr = elapsed >= ELAPSED_THRESHOLD_MS
474
493
  ? chalk_1.default.dim(` (${Math.floor(elapsed / 1000)}s)`)
475
494
  : "";
476
- // Clear-to-end-of-line (`\x1b[K`) after the spinner content
477
- // rather than trailing spaces — robust against terminals where
478
- // the last frame was longer than the current one (the trailing
479
- // spaces approach left orphaned chars on shrinking labels).
480
- process.stdout.write(`\r${chalk_1.default.cyan(frame)} ${chalk_1.default.dim(this.spinnerLabel)}${elapsedStr}\x1b[K`);
495
+ // Only the symbol's colour changes frame to frame; the label is static.
496
+ process.stdout.write(`\r${chalk_1.default.hex(shade)(SPINNER_SYMBOL)} ${chalk_1.default.dim(this.spinnerLabel)}${elapsedStr}\x1b[K`);
481
497
  this.spinnerLineDirty = true;
482
498
  this.spinnerFrame++;
483
499
  }
@@ -487,15 +503,11 @@ class SessionUI {
487
503
  this.spinnerTimer = null;
488
504
  }
489
505
  if (this.spinnerLineDirty) {
490
- // Clear the spinner line so the next print doesn't overlap.
491
506
  process.stdout.write("\r\x1b[K");
492
507
  this.spinnerLineDirty = false;
493
508
  }
509
+ process.stdout.write("\x1b[?25h"); // restore cursor hidden by startSpinner
494
510
  }
495
- /** Called by the text_delta renderer the first time a real model
496
- * token arrives during a `generating` state. Clears the spinner so
497
- * the token prints cleanly, but leaves the state at `generating`
498
- * (token streaming continues without re-spinning). */
499
511
  onFirstToken() {
500
512
  if (this.spinnerLineDirty) {
501
513
  process.stdout.write("\r\x1b[K");
@@ -505,6 +517,7 @@ class SessionUI {
505
517
  clearInterval(this.spinnerTimer);
506
518
  this.spinnerTimer = null;
507
519
  }
520
+ process.stdout.write("\x1b[?25h"); // restore cursor hidden by startSpinner
508
521
  }
509
522
  }
510
523
  exports.SessionUI = SessionUI;