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.
- 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 +750 -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 +226 -213
- 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 +240 -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,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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
// ──
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
477
|
-
|
|
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;
|