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.
- package/dist/__tests__/ansi_tags.test.d.ts +9 -0
- package/dist/__tests__/ansi_tags.test.d.ts.map +1 -0
- package/dist/__tests__/ansi_tags.test.js +58 -0
- package/dist/__tests__/ansi_tags.test.js.map +1 -0
- package/dist/__tests__/paste_transform_submit.test.d.ts +2 -0
- package/dist/__tests__/paste_transform_submit.test.d.ts.map +1 -0
- package/dist/__tests__/paste_transform_submit.test.js +74 -0
- package/dist/__tests__/paste_transform_submit.test.js.map +1 -0
- package/dist/__tests__/session_match.test.d.ts +10 -0
- package/dist/__tests__/session_match.test.d.ts.map +1 -0
- package/dist/__tests__/session_match.test.js +55 -0
- package/dist/__tests__/session_match.test.js.map +1 -0
- package/dist/ansi_tags.d.ts +20 -0
- package/dist/ansi_tags.d.ts.map +1 -0
- package/dist/ansi_tags.js +38 -0
- package/dist/ansi_tags.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/display.d.ts +53 -22
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +773 -47
- package/dist/display.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -3
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +5 -2
- package/dist/session.js.map +1 -1
- package/dist/session_match.d.ts +19 -0
- package/dist/session_match.d.ts.map +1 -0
- package/dist/session_match.js +9 -0
- package/dist/session_match.js.map +1 -0
- package/dist/sidebar.d.ts +84 -0
- package/dist/sidebar.d.ts.map +1 -0
- package/dist/sidebar.js +310 -0
- package/dist/sidebar.js.map +1 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +25 -66
- package/dist/tools.js.map +1 -1
- package/dist/ui_state.d.ts +41 -96
- package/dist/ui_state.d.ts.map +1 -1
- package/dist/ui_state.js +212 -209
- package/dist/ui_state.js.map +1 -1
- package/dist/ws.d.ts +10 -14
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +236 -119
- package/dist/ws.js.map +1 -1
- 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)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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;
|
|
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
|
-
// ──
|
|
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:
|
|
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;
|