xiaotime 0.5.1 → 0.5.3

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 +773 -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 +212 -209
  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 +236 -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,208 @@ 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
77
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
93
78
  const SPINNER_INTERVAL_MS = 100;
94
- const ELAPSED_THRESHOLD_MS = 3000; // show elapsed time after this
79
+ const ELAPSED_THRESHOLD_MS = 3000;
95
80
  const MENU_OPTIONS = [
96
81
  "Run",
97
82
  "Run and don't ask again",
98
83
  "Reject and tell Claude why",
99
84
  ];
100
85
  const MENU_HINT = "(1-3, ↑↓ Enter, Esc to reject)";
86
+ const PASTE_START = "\x1b[200~";
87
+ const PASTE_END = "\x1b[201~";
88
+ const BPASTE_ENABLE = "\x1b[?2004h";
89
+ const BPASTE_DISABLE = "\x1b[?2004l";
90
+ // Rapid-chunk coalescing thresholds
91
+ const RAPID_PASTE_GAP_MS = 30; // chunks arriving faster than this = paste burst
92
+ const RAPID_PASTE_IDLE_MS = 60; // flush buffer after this much silence
93
+ // ── PasteTransform ────────────────────────────────────────────────────────────
94
+ // Processes raw stdin chunks. Two interception modes:
95
+ //
96
+ // Mode A — bracketed paste: ESC[200~ starts buffering, ESC[201~ flushes.
97
+ // Mode B — rapid-chunk coalescing: bursts of chunks with gaps < RAPID_PASTE_GAP_MS
98
+ // are held and flushed as one block after RAPID_PASTE_IDLE_MS of silence.
99
+ //
100
+ // Output is pushed into a PassThrough (`sink`) which readline reads from.
101
+ // Readline never touches this Transform directly — that prevents readline from
102
+ // calling emitKeypressEvents() on us and bypassing our interception.
103
+ class PasteTransform extends stream_1.Transform {
104
+ // Mode A
105
+ _inBracketedPaste = false;
106
+ _bracketedBuf = "";
107
+ // Mode B
108
+ _rapidBuf = "";
109
+ _lastChunkAt = 0;
110
+ _rapidTimer = null;
111
+ // The PassThrough that readline reads from
112
+ sink;
113
+ constructor() {
114
+ super();
115
+ this.sink = new stream_1.PassThrough();
116
+ // Forward our Transform output to the sink
117
+ this.pipe(this.sink);
118
+ }
119
+ _transform(chunk, _enc, cb) {
120
+ let s = chunk.toString();
121
+ // ── Mode A: bracketed paste ──────────────────────────────────────────────
122
+ if (s.includes(PASTE_START)) {
123
+ this._inBracketedPaste = true;
124
+ this._bracketedBuf = "";
125
+ s = s.replace(PASTE_START, "");
126
+ }
127
+ if (s.includes(PASTE_END)) {
128
+ this._inBracketedPaste = false;
129
+ const tail = s.replace(PASTE_END, "");
130
+ this._bracketedBuf += tail;
131
+ const safe = this._bracketedBuf.replace(/[\r\n]+/g, " ");
132
+ this._bracketedBuf = "";
133
+ this.push(Buffer.from(safe));
134
+ cb();
135
+ return;
136
+ }
137
+ if (this._inBracketedPaste) {
138
+ this._bracketedBuf += s;
139
+ cb();
140
+ return;
141
+ }
142
+ // ── Mode B: rapid-chunk coalescing ───────────────────────────────────────
143
+ const now = Date.now();
144
+ const gap = now - this._lastChunkAt;
145
+ this._lastChunkAt = now;
146
+ const hasNewline = /[\r\n]/.test(s);
147
+ const isBurst = gap < RAPID_PASTE_GAP_MS && this._rapidBuf.length > 0;
148
+ if (isBurst || (hasNewline && this._rapidBuf.length > 0)) {
149
+ this._rapidBuf += s;
150
+ this._scheduleRapidFlush();
151
+ cb();
152
+ return;
153
+ }
154
+ if (hasNewline && this._rapidBuf.length === 0) {
155
+ // A lone CR/LF is a deliberate Enter, not the first chunk of a paste.
156
+ // Pass it straight through so readline emits its `line` event and the
157
+ // prompt submits. Without this, the newline gets buffered and collapsed
158
+ // to a space by _scheduleRapidFlush, so readline never sees a line
159
+ // terminator and NO prompt ever submits. Only buffer + newline-strip
160
+ // when the chunk carries text alongside the newline (the first chunk of
161
+ // a fast, un-bracketed multi-line paste).
162
+ if (/^[\r\n]+$/.test(s)) {
163
+ this.push(chunk);
164
+ cb();
165
+ return;
166
+ }
167
+ this._rapidBuf += s;
168
+ this._scheduleRapidFlush();
169
+ cb();
170
+ return;
171
+ }
172
+ // Normal single keystroke — pass through immediately
173
+ this.push(chunk);
174
+ cb();
175
+ }
176
+ _scheduleRapidFlush() {
177
+ if (this._rapidTimer)
178
+ clearTimeout(this._rapidTimer);
179
+ this._rapidTimer = setTimeout(() => {
180
+ this._rapidTimer = null;
181
+ this._flushRapidBuf();
182
+ }, RAPID_PASTE_IDLE_MS);
183
+ }
184
+ // Flush the rapid-coalesce buffer to readline.
185
+ //
186
+ // Trailing-newline rule — fixes the "press Enter twice" bug on SSH clients
187
+ // (Termius, mosh) that batch a fast-typed line together with its Enter into a
188
+ // single read, e.g. "hey junior\r" arriving as ONE chunk. The old code
189
+ // collapsed EVERY newline — including that trailing \r — to a space, so
190
+ // readline never saw a line terminator: the first Enter silently did nothing
191
+ // and you had to hit Enter again.
192
+ //
193
+ // - buffer ends in a newline AND the text before it has no internal newline
194
+ // → a single typed line + Enter. Re-emit one \r so readline submits on the
195
+ // first Enter.
196
+ // - buffer has an internal newline (genuine multi-line paste) → collapse all
197
+ // newlines to spaces and emit NO terminator, so the paste lands in the
198
+ // input for review (unchanged behaviour).
199
+ _flushRapidBuf() {
200
+ if (!this._rapidBuf)
201
+ return;
202
+ const buf = this._rapidBuf;
203
+ this._rapidBuf = "";
204
+ const trailing = buf.match(/[\r\n]+$/);
205
+ const body = trailing ? buf.slice(0, trailing.index) : buf;
206
+ const isSingleLineEnter = trailing !== null && !/[\r\n]/.test(body);
207
+ if (isSingleLineEnter) {
208
+ this.push(Buffer.from(body + "\r"));
209
+ }
210
+ else {
211
+ this.push(Buffer.from(buf.replace(/[\r\n]+/g, " ")));
212
+ }
213
+ }
214
+ _flush(cb) {
215
+ if (this._rapidTimer) {
216
+ clearTimeout(this._rapidTimer);
217
+ this._rapidTimer = null;
218
+ }
219
+ const combined = this._bracketedBuf + this._rapidBuf;
220
+ if (combined) {
221
+ const safe = combined.replace(/[\r\n]+/g, " ");
222
+ this.push(Buffer.from(safe));
223
+ }
224
+ this._bracketedBuf = "";
225
+ this._rapidBuf = "";
226
+ cb();
227
+ }
228
+ }
229
+ exports.PasteTransform = PasteTransform;
101
230
  class SessionUI {
102
231
  state = "idle";
103
232
  spinnerTimer = null;
104
233
  spinnerFrame = 0;
105
234
  spinnerStartedAt = 0;
106
235
  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
236
  spinnerLineDirty = false;
112
237
  rl;
113
238
  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
239
  closeListeners = [];
240
+ _pasteTransform = null;
121
241
  constructor() {
122
242
  this.rl = this._makeRl();
243
+ process.on("exit", () => {
244
+ process.stdout.write(BPASTE_DISABLE);
245
+ process.stdout.write("\x1b[?25h"); // never leave the cursor hidden on exit
246
+ });
123
247
  }
124
- /** Hand the underlying readline to callers that need it for cleanup. */
125
248
  getRl() {
126
249
  return this.rl;
127
250
  }
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
251
  onRlClose(listener) {
136
252
  this.closeListeners.push(listener);
137
253
  this.rl.on("close", listener);
138
254
  }
139
- /** Set the explicit current state. Drives spinner start/stop. */
140
255
  setState(next) {
141
256
  if (next === this.state)
142
257
  return;
143
258
  const prev = this.state;
144
259
  this.state = next;
145
- // Transition out of any spinner state.
146
260
  if (prev === "generating" || prev === "running_tool") {
147
261
  this.stopSpinner();
148
262
  }
149
- // Transition in.
150
263
  if (next === "generating") {
151
264
  this.startSpinner("thinking");
152
265
  }
153
266
  else if (next === "running_tool") {
154
267
  this.startSpinner("running tool");
155
268
  }
156
- // `awaiting_approval` / `idle` => no spinner. Approval-menu path
157
- // takes over stdin in `promptApprovalMenu`.
158
269
  }
159
270
  getState() {
160
271
  return this.state;
161
272
  }
162
- /** Wait for one line of user input on the main rl. */
163
273
  promptLine() {
164
274
  return new Promise((resolve) => {
165
- // Drop any previous one-shot listener that didn't fire.
166
275
  if (this.lineListener) {
167
276
  this.rl.off("line", this.lineListener);
168
277
  }
@@ -174,49 +283,12 @@ class SessionUI {
174
283
  this.rl.once("line", listener);
175
284
  });
176
285
  }
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
286
  async promptApprovalMenu(toolHeader) {
212
287
  this.setState("awaiting_approval");
288
+ this._destroyPasteTransform();
213
289
  if (toolHeader) {
214
290
  process.stdout.write(toolHeader);
215
291
  }
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
292
  let selectedIdx;
221
293
  try {
222
294
  selectedIdx = await this._renderMenuAndGetSelection();
@@ -224,9 +296,6 @@ class SessionUI {
224
296
  finally {
225
297
  this.rl = this._makeRl();
226
298
  }
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
299
  if (selectedIdx === -1) {
231
300
  return { approved: false, rejectReason: "(no TTY)" };
232
301
  }
@@ -238,82 +307,54 @@ class SessionUI {
238
307
  process.stdout.write(chalk_1.default.green(" ✓ Approved (allowlist requested)\n"));
239
308
  return { approved: true, allowlistRequested: true };
240
309
  }
241
- // selectedIdx === 2 — rejection. Prompt for free-form reason via
242
- // the recreated rl (line-mode, single line, empty allowed).
243
310
  process.stdout.write(chalk_1.default.yellow(" Reason (Enter to send empty): "));
244
311
  const reason = await new Promise((resolve) => {
245
312
  this.rl.once("line", (line) => resolve(line.trim()));
246
313
  });
247
314
  return { approved: false, rejectReason: reason };
248
315
  }
249
- // ── Menu internals ───────────────────────────────────────────────
316
+ // ── PasteTransform lifecycle ──────────────────────────────────────────────
317
+ _destroyPasteTransform() {
318
+ if (this._pasteTransform) {
319
+ process.stdin.unpipe(this._pasteTransform);
320
+ this._pasteTransform.destroy();
321
+ this._pasteTransform = null;
322
+ }
323
+ }
324
+ // ── rl factory ───────────────────────────────────────────────────────────
250
325
  _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
326
  process.stdin.removeAllListeners("keypress");
290
327
  process.stdin.removeAllListeners("end");
291
328
  process.stdin.removeAllListeners("error");
292
329
  process.stdout.removeAllListeners("resize");
330
+ process.stdout.write(BPASTE_ENABLE);
331
+ // Create PasteTransform, pipe stdin through it.
332
+ // Readline reads from pt.sink (a PassThrough) — NOT from the Transform
333
+ // directly. This prevents readline from calling emitKeypressEvents() on
334
+ // the transform, which would bypass our paste interception.
335
+ this._destroyPasteTransform();
336
+ const pt = new PasteTransform();
337
+ this._pasteTransform = pt;
338
+ process.stdin.pipe(pt);
293
339
  const rl = readline.createInterface({
294
- input: process.stdin,
340
+ input: pt.sink, // readline reads from the safe PassThrough sink
295
341
  output: process.stdout,
296
342
  terminal: true,
297
343
  });
298
344
  for (const l of this.closeListeners) {
299
345
  rl.on("close", l);
300
346
  }
347
+ rl.on("close", () => {
348
+ process.stdout.write(BPASTE_DISABLE);
349
+ this._destroyPasteTransform();
350
+ });
301
351
  return rl;
302
352
  }
353
+ // ── Menu internals ────────────────────────────────────────────────────────
303
354
  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
355
  this.rl.removeAllListeners("close");
309
356
  this.rl.close();
310
357
  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
358
  if (!stdin.isTTY) {
318
359
  process.stdout.write(chalk_1.default.red("Rejected (no TTY)\n"));
319
360
  return -1;
@@ -323,20 +364,12 @@ class SessionUI {
323
364
  stdin.setRawMode(true);
324
365
  }
325
366
  catch {
326
- // Belt + suspenders — isTTY guard above should cover this, but a
327
- // mid-session TTY loss could surface here.
328
367
  process.stdout.write(chalk_1.default.red("Rejected (no TTY)\n"));
329
368
  return -1;
330
369
  }
331
370
  stdin.resume();
332
371
  let focusedIdx = 0;
333
372
  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
373
  let onData = null;
341
374
  try {
342
375
  return await new Promise((resolve, reject) => {
@@ -347,29 +380,18 @@ class SessionUI {
347
380
  onData = (chunk) => {
348
381
  try {
349
382
  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
383
  if (s.includes("\x03")) {
353
384
  finish(2);
354
385
  process.kill(process.pid, "SIGINT");
355
386
  return;
356
387
  }
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
388
  if (s.charCodeAt(0) === 0x1b) {
366
389
  if (s === "\x1b") {
367
390
  finish(2);
368
391
  return;
369
392
  }
370
393
  if (s === "\x1b[A") {
371
- focusedIdx =
372
- (focusedIdx - 1 + MENU_OPTIONS.length) % MENU_OPTIONS.length;
394
+ focusedIdx = (focusedIdx - 1 + MENU_OPTIONS.length) % MENU_OPTIONS.length;
373
395
  this._rerenderMenu(focusedIdx);
374
396
  return;
375
397
  }
@@ -378,27 +400,20 @@ class SessionUI {
378
400
  this._rerenderMenu(focusedIdx);
379
401
  return;
380
402
  }
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
403
  const hex = Array.from(chunk)
385
404
  .map((b) => b.toString(16).padStart(2, "0"))
386
405
  .join(" ");
387
406
  process.stderr.write(chalk_1.default.dim(`\n[approval-menu] ignored unrecognized escape: ${hex}\n`));
388
407
  return;
389
408
  }
390
- // Number-key one-shot selectors.
391
409
  if (s === "1" || s === "2" || s === "3") {
392
410
  finish(Number(s) - 1);
393
411
  return;
394
412
  }
395
- // Enter on focused row.
396
413
  if (s === "\r" || s === "\n") {
397
414
  finish(focusedIdx);
398
415
  return;
399
416
  }
400
- // Anything else — ignore. No echo, no bounce. The visible
401
- // menu stays put so the user can try again.
402
417
  }
403
418
  catch (err) {
404
419
  reject(err);
@@ -408,18 +423,14 @@ class SessionUI {
408
423
  });
409
424
  }
410
425
  finally {
411
- if (onData) {
426
+ if (onData)
412
427
  stdin.removeListener("data", onData);
413
- }
414
428
  try {
415
429
  stdin.setRawMode(wasRaw);
416
430
  }
417
- catch {
418
- /* setRawMode can throw if stdin lost its TTY mid-session. */
419
- }
431
+ catch { /* TTY lost mid-session */ }
420
432
  }
421
433
  }
422
- /** Total lines the menu occupies: one per option + one hint line. */
423
434
  _menuLineCount() {
424
435
  return MENU_OPTIONS.length + 1;
425
436
  }
@@ -432,7 +443,6 @@ class SessionUI {
432
443
  process.stdout.write(chalk_1.default.dim(` ${MENU_HINT}\n`));
433
444
  }
434
445
  _rerenderMenu(focusedIdx) {
435
- // Move cursor up to top of menu, clear each line, redraw.
436
446
  const lines = this._menuLineCount();
437
447
  process.stdout.write(`\x1b[${lines}A`);
438
448
  for (let i = 0; i < MENU_OPTIONS.length; i++) {
@@ -452,19 +462,19 @@ class SessionUI {
452
462
  if (i < lines - 1)
453
463
  process.stdout.write("\n");
454
464
  }
455
- // Cursor left at start of the (now-cleared) first menu line so
456
- // the post-selection echo lands there cleanly.
457
465
  process.stdout.write(`\x1b[${lines - 1}A\r`);
458
466
  }
459
- // ── Spinner internals ────────────────────────────────────────────
467
+ // ── Spinner internals ─────────────────────────────────────────────────────
460
468
  startSpinner(label) {
461
469
  this.spinnerLabel = label;
462
470
  this.spinnerFrame = 0;
463
471
  this.spinnerStartedAt = Date.now();
464
472
  this.spinnerLineDirty = false;
473
+ // Hide the terminal cursor while the spinner animates. Without this the
474
+ // blinking hardware cursor jumps column-0 ↔ end on every \r repaint, which
475
+ // reads as a cursor "rapidly disappearing" next to the thinking label.
476
+ process.stdout.write("\x1b[?25l");
465
477
  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
478
  this.renderSpinnerFrame();
469
479
  }
470
480
  renderSpinnerFrame() {
@@ -473,10 +483,6 @@ class SessionUI {
473
483
  const elapsedStr = elapsed >= ELAPSED_THRESHOLD_MS
474
484
  ? chalk_1.default.dim(` (${Math.floor(elapsed / 1000)}s)`)
475
485
  : "";
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
486
  process.stdout.write(`\r${chalk_1.default.cyan(frame)} ${chalk_1.default.dim(this.spinnerLabel)}${elapsedStr}\x1b[K`);
481
487
  this.spinnerLineDirty = true;
482
488
  this.spinnerFrame++;
@@ -487,15 +493,11 @@ class SessionUI {
487
493
  this.spinnerTimer = null;
488
494
  }
489
495
  if (this.spinnerLineDirty) {
490
- // Clear the spinner line so the next print doesn't overlap.
491
496
  process.stdout.write("\r\x1b[K");
492
497
  this.spinnerLineDirty = false;
493
498
  }
499
+ process.stdout.write("\x1b[?25h"); // restore cursor hidden by startSpinner
494
500
  }
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
501
  onFirstToken() {
500
502
  if (this.spinnerLineDirty) {
501
503
  process.stdout.write("\r\x1b[K");
@@ -505,6 +507,7 @@ class SessionUI {
505
507
  clearInterval(this.spinnerTimer);
506
508
  this.spinnerTimer = null;
507
509
  }
510
+ process.stdout.write("\x1b[?25h"); // restore cursor hidden by startSpinner
508
511
  }
509
512
  }
510
513
  exports.SessionUI = SessionUI;