@gjsify/readline 0.2.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/readline",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Node.js readline module for Gjs",
5
5
  "type": "module",
6
6
  "module": "lib/esm/index.js",
@@ -34,11 +34,11 @@
34
34
  "readline"
35
35
  ],
36
36
  "dependencies": {
37
- "@gjsify/events": "^0.2.0"
37
+ "@gjsify/events": "^0.3.1"
38
38
  },
39
39
  "devDependencies": {
40
- "@gjsify/cli": "^0.2.0",
41
- "@gjsify/unit": "^0.2.0",
40
+ "@gjsify/cli": "^0.3.1",
41
+ "@gjsify/unit": "^0.3.1",
42
42
  "@types/node": "^25.6.0",
43
43
  "typescript": "^6.0.3"
44
44
  }
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
- // Node.js readline module for GJS
2
1
  // Reference: Node.js lib/readline.js
2
+ // Reimplemented for GJS without Node.js primordials.
3
3
 
4
4
  import { EventEmitter } from 'node:events';
5
+ import { StringDecoder } from 'node:string_decoder';
5
6
  import type { Readable, Writable } from 'node:stream';
6
7
 
7
8
  export interface InterfaceOptions {
@@ -17,9 +18,8 @@ export interface InterfaceOptions {
17
18
  tabSize?: number;
18
19
  }
19
20
 
20
- /**
21
- * readline.Interface — reads lines from a Readable stream.
22
- */
21
+ const LINE_END = /\r\n|\r|\n/;
22
+
23
23
  export class Interface extends EventEmitter {
24
24
  terminal: boolean;
25
25
  line = '';
@@ -27,6 +27,10 @@ export class Interface extends EventEmitter {
27
27
 
28
28
  private _input: Readable | null;
29
29
  private _output: Writable | null;
30
+
31
+ get input(): Readable | null { return this._input; }
32
+ get output(): Writable | null { return this._output; }
33
+
30
34
  private _prompt: string;
31
35
  private _closed = false;
32
36
  private _paused = false;
@@ -35,6 +39,12 @@ export class Interface extends EventEmitter {
35
39
  private _crlfDelay: number;
36
40
  private _lineBuffer = '';
37
41
  private _questionCallback: ((answer: string) => void) | null = null;
42
+ // Per-listener refs so close() removes only our listeners, not the keypress
43
+ // parser's 'data' listener — which must survive across sequential prompts.
44
+ private _boundOnData: ((chunk: Buffer | string) => void) | null = null;
45
+ private _boundOnEnd: (() => void) | null = null;
46
+ private _boundOnError: ((err: Error) => void) | null = null;
47
+ private _boundOnKeypress: ((str: string | undefined, key: Key) => void) | null = null;
38
48
 
39
49
  constructor(input?: Readable | InterfaceOptions, output?: Writable) {
40
50
  super();
@@ -55,26 +65,52 @@ export class Interface extends EventEmitter {
55
65
  this._crlfDelay = opts.crlfDelay ?? 100;
56
66
 
57
67
  if (this._input) {
58
- this._input.on('data', (chunk: Buffer | string) => this._onData(chunk));
59
- this._input.on('end', () => this._onEnd());
60
- this._input.on('error', (err: Error) => this.emit('error', err));
68
+ this._boundOnData = (chunk: Buffer | string) => this._onData(chunk);
69
+ this._boundOnEnd = () => this._onEnd();
70
+ this._boundOnError = (err: Error) => this.emit('error', err);
71
+ this._input.on('data', this._boundOnData);
72
+ this._input.on('end', this._boundOnEnd);
73
+ this._input.on('error', this._boundOnError);
61
74
 
62
75
  if ('setEncoding' in this._input && typeof this._input.setEncoding === 'function') {
63
76
  this._input.setEncoding('utf8');
64
77
  }
78
+
79
+ if (this.terminal) {
80
+ emitKeypressEvents(this._input as Readable & Record<symbol, unknown>, this as any);
81
+ if ('setRawMode' in this._input && typeof (this._input as any).setRawMode === 'function') {
82
+ if (!(this._input as any).isRaw) (this._input as any).setRawMode(true);
83
+ }
84
+ if ('resume' in this._input && typeof (this._input as any).resume === 'function') {
85
+ (this._input as any).resume();
86
+ }
87
+
88
+ this._boundOnKeypress = (str: string | undefined, key: Key) => {
89
+ if (!key) return;
90
+ if (key.name === 'backspace' || key.name === 'delete') {
91
+ if (this.cursor > 0) {
92
+ this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor);
93
+ this.cursor--;
94
+ }
95
+ } else if (
96
+ str && str.length === 1 && !key.ctrl && !key.meta &&
97
+ key.name !== 'return' && key.name !== 'enter' && key.name !== 'escape'
98
+ ) {
99
+ this.line = this.line.slice(0, this.cursor) + str + this.line.slice(this.cursor);
100
+ this.cursor++;
101
+ }
102
+ };
103
+ this._input.on('keypress', this._boundOnKeypress as (...args: unknown[]) => void);
104
+ }
65
105
  }
66
106
  }
67
107
 
68
108
  private _onData(chunk: Buffer | string): void {
69
109
  if (this._closed || this._paused) return;
70
-
71
110
  const str = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
72
111
  this._lineBuffer += str;
73
-
74
- // Process lines separated by \n, \r\n, or standalone \r
75
- const lineEnd = /\r\n|\r|\n/;
76
112
  let m: RegExpExecArray | null;
77
- while ((m = lineEnd.exec(this._lineBuffer)) !== null) {
113
+ while ((m = LINE_END.exec(this._lineBuffer)) !== null) {
78
114
  const line = this._lineBuffer.substring(0, m.index);
79
115
  this._lineBuffer = this._lineBuffer.substring(m.index + m[0].length);
80
116
  this._onLine(line);
@@ -82,27 +118,21 @@ export class Interface extends EventEmitter {
82
118
  }
83
119
 
84
120
  private _onLine(line: string): void {
85
- // Add to history
86
121
  if (line.length > 0 && this._historySize > 0) {
87
122
  if (this.history.length === 0 || this.history[0] !== line) {
88
123
  this.history.unshift(line);
89
- if (this.history.length > this._historySize) {
90
- this.history.pop();
91
- }
124
+ if (this.history.length > this._historySize) this.history.pop();
92
125
  }
93
126
  }
94
-
95
127
  if (this._questionCallback) {
96
128
  const cb = this._questionCallback;
97
129
  this._questionCallback = null;
98
130
  cb(line);
99
131
  }
100
-
101
132
  this.emit('line', line);
102
133
  }
103
134
 
104
135
  private _onEnd(): void {
105
- // Emit remaining buffer as last line
106
136
  if (this._lineBuffer.length > 0) {
107
137
  this._onLine(this._lineBuffer);
108
138
  this._lineBuffer = '';
@@ -110,106 +140,121 @@ export class Interface extends EventEmitter {
110
140
  this.close();
111
141
  }
112
142
 
113
- /** Set the prompt string. */
114
143
  setPrompt(prompt: string): void {
115
144
  this._prompt = prompt;
116
145
  }
117
146
 
118
- /** Get the current prompt string. */
119
147
  getPrompt(): string {
120
148
  return this._prompt;
121
149
  }
122
150
 
123
- /** Write the prompt to the output stream. */
124
- prompt(preserveCursor?: boolean): void {
125
- if (this._closed) {
126
- throw new Error('readline was closed');
127
- }
128
- if (this._output) {
129
- this._output.write(this._prompt);
130
- }
151
+ prompt(_preserveCursor?: boolean): void {
152
+ if (this._closed) throw new Error('readline was closed');
153
+ this._output?.write(this._prompt);
131
154
  }
132
155
 
133
- /**
134
- * Display the query and wait for user input.
135
- */
136
156
  question(query: string, callback: (answer: string) => void): void;
137
157
  question(query: string, options: Record<string, unknown>, callback: (answer: string) => void): void;
138
- question(query: string, optionsOrCallback: Record<string, unknown> | ((answer: string) => void), callback?: (answer: string) => void): void {
139
- if (this._closed) {
140
- throw new Error('readline was closed');
141
- }
142
-
158
+ question(
159
+ query: string,
160
+ optionsOrCallback: Record<string, unknown> | ((answer: string) => void),
161
+ callback?: (answer: string) => void,
162
+ ): void {
163
+ if (this._closed) throw new Error('readline was closed');
143
164
  const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback!;
144
-
145
165
  this._questionCallback = cb;
166
+ this._output?.write(query);
167
+ }
146
168
 
147
- if (this._output) {
148
- this._output.write(query);
149
- }
169
+ clearLine(_dir: number, callback?: () => void): boolean {
170
+ this.line = '';
171
+ this.cursor = 0;
172
+ if (callback) callback();
173
+ return true;
150
174
  }
151
175
 
152
- /** Write data to the output stream. */
153
176
  write(data: string | Buffer | null, key?: { ctrl?: boolean; meta?: boolean; shift?: boolean; name?: string }): void {
154
177
  if (this._closed) return;
155
-
178
+ if (key) {
179
+ if (this._input) (this._input as any).emit('keypress', data ?? '', key);
180
+ return;
181
+ }
156
182
  if (data !== null && data !== undefined) {
157
- if (this._output) {
158
- this._output.write(data);
183
+ const str = typeof data === 'string' ? data : data.toString('utf8');
184
+ if (str) {
185
+ this.line = this.line.slice(0, this.cursor) + str + this.line.slice(this.cursor);
186
+ this.cursor += str.length;
159
187
  }
188
+ this._output?.write(data);
160
189
  }
161
190
  }
162
191
 
163
- /** Close the interface. */
164
192
  close(): void {
165
193
  if (this._closed) return;
166
194
  this._closed = true;
167
195
 
168
196
  if (this._input) {
169
- this._input.removeAllListeners('data');
170
- this._input.removeAllListeners('end');
197
+ // Remove only our own listeners — removeAllListeners would also strip the
198
+ // keypress parser's 'data' listener. The _KEYPRESS_DECODER Symbol idempotency
199
+ // guard would then prevent emitKeypressEvents from re-installing for the next
200
+ // prompt, silencing all keypress events.
201
+ if (this._boundOnData) this._input.removeListener('data', this._boundOnData);
202
+ if (this._boundOnEnd) this._input.removeListener('end', this._boundOnEnd);
203
+ if (this._boundOnError) this._input.removeListener('error', this._boundOnError);
204
+ if (this._boundOnKeypress) this._input.removeListener('keypress', this._boundOnKeypress as (...args: unknown[]) => void);
205
+ this._boundOnData = null;
206
+ this._boundOnEnd = null;
207
+ this._boundOnError = null;
208
+ this._boundOnKeypress = null;
209
+
210
+ if (this.terminal && (this._input as any).isRaw &&
211
+ 'setRawMode' in this._input && typeof (this._input as any).setRawMode === 'function') {
212
+ (this._input as any).setRawMode(false);
213
+ }
214
+
215
+ // Pause stdin so ProcessReadStream releases the GLib main loop via
216
+ // quitMainLoop(). Mirrors Node.js readline: close() pauses the stream
217
+ // so the event loop can drain and the process exits when no more prompts follow.
218
+ if ('pause' in this._input && typeof (this._input as any).pause === 'function') {
219
+ (this._input as any).pause();
220
+ }
171
221
  }
172
222
 
173
223
  this.emit('close');
174
224
  }
175
225
 
176
- /** Pause the input stream. */
177
226
  pause(): this {
178
227
  if (this._closed) return this;
179
228
  this._paused = true;
180
-
181
229
  if (this._input && 'pause' in this._input && typeof this._input.pause === 'function') {
182
230
  this._input.pause();
183
231
  }
184
-
185
232
  this.emit('pause');
186
233
  return this;
187
234
  }
188
235
 
189
- /** Resume the input stream. */
190
236
  resume(): this {
191
237
  if (this._closed) return this;
192
238
  this._paused = false;
193
-
194
239
  if (this._input && 'resume' in this._input && typeof this._input.resume === 'function') {
195
240
  this._input.resume();
196
241
  }
197
-
198
242
  this.emit('resume');
199
243
  return this;
200
244
  }
201
245
 
202
- /** Get the current line content. */
203
246
  getCursorPos(): { rows: number; cols: number } {
204
- return { rows: 0, cols: this.cursor };
247
+ const columns = (this._output as any)?.columns ?? 80;
248
+ const len = this._prompt.length + this.cursor;
249
+ return { rows: Math.floor(len / columns), cols: len % columns };
205
250
  }
206
251
 
207
252
  [Symbol.asyncIterator](): AsyncIterableIterator<string> {
208
253
  const lines: string[] = [];
209
- let resolve: ((value: IteratorResult<string>) => void) | null = null;
254
+ let resolve: ((value: IteratorResult<string, undefined>) => void) | null = null;
210
255
  let done = false;
211
256
 
212
- this.on('line', (line: string) => {
257
+ const onLine = (line: string) => {
213
258
  if (resolve) {
214
259
  const r = resolve;
215
260
  resolve = null;
@@ -217,77 +262,65 @@ export class Interface extends EventEmitter {
217
262
  } else {
218
263
  lines.push(line);
219
264
  }
220
- });
221
-
222
- this.on('close', () => {
265
+ };
266
+ const onClose = () => {
223
267
  done = true;
224
268
  if (resolve) {
225
269
  const r = resolve;
226
270
  resolve = null;
227
- r({ value: undefined as unknown as string, done: true });
271
+ r({ value: undefined, done: true });
228
272
  }
229
- });
273
+ };
274
+
275
+ this.on('line', onLine);
276
+ this.on('close', onClose);
230
277
 
231
278
  return {
232
- next(): Promise<IteratorResult<string>> {
233
- if (lines.length > 0) {
234
- return Promise.resolve({ value: lines.shift()!, done: false });
235
- }
236
- if (done) {
237
- return Promise.resolve({ value: undefined as unknown as string, done: true });
238
- }
239
- return new Promise<IteratorResult<string>>((r) => { resolve = r; });
279
+ next: (): Promise<IteratorResult<string, undefined>> => {
280
+ if (lines.length > 0) return Promise.resolve({ value: lines.shift()!, done: false });
281
+ if (done) return Promise.resolve({ value: undefined, done: true });
282
+ return new Promise<IteratorResult<string, undefined>>((r) => { resolve = r; });
240
283
  },
241
- return(): Promise<IteratorResult<string>> {
284
+ return: (): Promise<IteratorResult<string, undefined>> => {
242
285
  done = true;
243
- return Promise.resolve({ value: undefined as unknown as string, done: true });
286
+ this.removeListener('line', onLine);
287
+ this.removeListener('close', onClose);
288
+ return Promise.resolve({ value: undefined, done: true });
244
289
  },
245
290
  [Symbol.asyncIterator]() { return this; },
246
291
  };
247
292
  }
248
293
  }
249
294
 
250
- /**
251
- * Create a readline Interface.
252
- */
253
- export function createInterface(input?: Readable | InterfaceOptions, output?: Writable, completer?: InterfaceOptions['completer'], terminal?: boolean): Interface {
295
+ export function createInterface(
296
+ input?: Readable | InterfaceOptions,
297
+ output?: Writable,
298
+ completer?: InterfaceOptions['completer'],
299
+ terminal?: boolean,
300
+ ): Interface {
254
301
  if (typeof input === 'object' && input !== null && !('read' in input && typeof input.read === 'function')) {
255
302
  return new Interface(input);
256
303
  }
257
304
  return new Interface({ input: input as Readable, output, completer, terminal });
258
305
  }
259
306
 
260
- // --- Terminal utility functions ---
261
-
262
- /**
263
- * Clear the current line of a TTY stream.
264
- * dir: -1 = to the left, 1 = to the right, 0 = entire line
265
- */
266
307
  export function clearLine(stream: Writable, dir: number, callback?: () => void): boolean {
267
308
  if (!stream || typeof stream.write !== 'function') {
268
309
  if (callback) callback();
269
310
  return true;
270
311
  }
271
-
272
312
  const code = dir < 0 ? '\x1b[1K' : dir > 0 ? '\x1b[0K' : '\x1b[2K';
273
313
  return stream.write(code, callback);
274
314
  }
275
315
 
276
- /**
277
- * Clear from cursor to end of screen.
278
- */
279
316
  export function clearScreenDown(stream: Writable, callback?: () => void): boolean {
280
317
  if (!stream || typeof stream.write !== 'function') {
281
318
  if (callback) callback();
282
319
  return true;
283
320
  }
284
-
285
321
  return stream.write('\x1b[0J', callback);
286
322
  }
287
323
 
288
- /**
289
- * Move cursor to the specified position.
290
- */
291
324
  export function cursorTo(
292
325
  stream: Writable,
293
326
  x: number,
@@ -299,22 +332,11 @@ export function cursorTo(
299
332
  else if (callback) callback();
300
333
  return true;
301
334
  }
302
-
303
- if (typeof y === 'function') {
304
- callback = y;
305
- y = undefined;
306
- }
307
-
308
- const code = typeof y === 'number'
309
- ? `\x1b[${y + 1};${x + 1}H`
310
- : `\x1b[${x + 1}G`;
311
-
335
+ if (typeof y === 'function') { callback = y; y = undefined; }
336
+ const code = typeof y === 'number' ? `\x1b[${y + 1};${x + 1}H` : `\x1b[${x + 1}G`;
312
337
  return stream.write(code, callback);
313
338
  }
314
339
 
315
- /**
316
- * Move cursor relative to its current position.
317
- */
318
340
  export function moveCursor(
319
341
  stream: Writable,
320
342
  dx: number,
@@ -325,28 +347,190 @@ export function moveCursor(
325
347
  if (callback) callback();
326
348
  return true;
327
349
  }
328
-
329
350
  let code = '';
330
351
  if (dx > 0) code += `\x1b[${dx}C`;
331
352
  else if (dx < 0) code += `\x1b[${-dx}D`;
332
353
  if (dy > 0) code += `\x1b[${dy}B`;
333
354
  else if (dy < 0) code += `\x1b[${-dy}A`;
334
-
335
- if (code) {
336
- return stream.write(code, callback);
337
- }
338
-
355
+ if (code) return stream.write(code, callback);
339
356
  if (callback) callback();
340
357
  return true;
341
358
  }
342
359
 
343
- /**
344
- * Enable keypress events on a stream (no-op in GJS — full implementation
345
- * requires raw terminal mode which depends on the input stream type).
346
- */
347
- export function emitKeypressEvents(_stream: Readable, _interface?: Interface): void {
348
- // Keypress event emission requires raw terminal mode.
349
- // This is a best-effort no-op; real keypress detection needs platform-specific code.
360
+ // ── Keypress parser ───────────────────────────────────────────────────────────
361
+ // Ported from refs/node/lib/internal/readline/utils.js (emitKeys generator)
362
+ // Original: Node.js contributors, MIT.
363
+ // Rewritten for TypeScript / GJS without Node.js primordials.
364
+
365
+ export interface Key {
366
+ sequence: string;
367
+ name: string | undefined;
368
+ ctrl: boolean;
369
+ meta: boolean;
370
+ shift: boolean;
371
+ code?: string;
372
+ }
373
+
374
+ const ESCAPE_CODE_TIMEOUT = 500;
375
+
376
+ function* emitKeys(stream: { emit(event: string, ...args: unknown[]): boolean }): Generator<void, void, string> {
377
+ while (true) {
378
+ let ch: string = yield;
379
+ let s = ch;
380
+ let escaped = false;
381
+ const key: Key = { sequence: '', name: undefined, ctrl: false, meta: false, shift: false };
382
+
383
+ if (ch === '\x1b') {
384
+ escaped = true;
385
+ s += (ch = yield);
386
+ if (ch === '\x1b') s += (ch = yield);
387
+ }
388
+
389
+ if (escaped && (ch === 'O' || ch === '[')) {
390
+ let code = ch;
391
+ let modifier = 0;
392
+
393
+ if (ch === 'O') {
394
+ s += (ch = yield);
395
+ if (ch >= '0' && ch <= '9') { modifier = ch.charCodeAt(0) - 1; s += (ch = yield); }
396
+ code += ch;
397
+ } else if (ch === '[') {
398
+ s += (ch = yield);
399
+ if (ch === '[') { code += ch; s += (ch = yield); }
400
+ const cmdStart = s.length - 1;
401
+ if (ch >= '0' && ch <= '9') {
402
+ s += (ch = yield);
403
+ if (ch >= '0' && ch <= '9') { s += (ch = yield); if (ch >= '0' && ch <= '9') s += (ch = yield); }
404
+ }
405
+ if (ch === ';') { s += (ch = yield); if (ch >= '0' && ch <= '9') s += (yield); }
406
+ const cmd = s.slice(cmdStart);
407
+ let match: RegExpExecArray | null;
408
+ if ((match = /^(?:(\d\d?)(?:;(\d))?([~^$])|(\d{3}~))$/.exec(cmd))) {
409
+ if (match[4]) { code += match[4]; } else { code += match[1] + match[3]; modifier = (parseInt(match[2] ?? '1', 10) || 1) - 1; }
410
+ } else if ((match = /^((\d;)?(\d))?([A-Za-z])$/.exec(cmd))) {
411
+ code += match[4]; modifier = (parseInt(match[3] ?? '1', 10) || 1) - 1;
412
+ } else { code += cmd; }
413
+ }
414
+
415
+ key.ctrl = !!(modifier & 4); key.meta = !!(modifier & 10); key.shift = !!(modifier & 1); key.code = code;
416
+
417
+ switch (code) {
418
+ case '[P': case 'OP': case '[11~': case '[[A': key.name = 'f1'; break;
419
+ case '[Q': case 'OQ': case '[12~': case '[[B': key.name = 'f2'; break;
420
+ case '[R': case 'OR': case '[13~': case '[[C': key.name = 'f3'; break;
421
+ case '[S': case 'OS': case '[14~': case '[[D': key.name = 'f4'; break;
422
+ case '[15~': case '[[E': key.name = 'f5'; break;
423
+ case '[17~': key.name = 'f6'; break; case '[18~': key.name = 'f7'; break;
424
+ case '[19~': key.name = 'f8'; break; case '[20~': key.name = 'f9'; break;
425
+ case '[21~': key.name = 'f10'; break; case '[23~': key.name = 'f11'; break;
426
+ case '[24~': key.name = 'f12'; break;
427
+ case '[200~': key.name = 'paste-start'; break; case '[201~': key.name = 'paste-end'; break;
428
+ case '[A': case 'OA': key.name = 'up'; break;
429
+ case '[B': case 'OB': key.name = 'down'; break;
430
+ case '[C': case 'OC': key.name = 'right'; break;
431
+ case '[D': case 'OD': key.name = 'left'; break;
432
+ case '[E': case 'OE': key.name = 'clear'; break;
433
+ case '[F': case 'OF': key.name = 'end'; break;
434
+ case '[H': case 'OH': key.name = 'home'; break;
435
+ case '[1~': key.name = 'home'; break; case '[2~': key.name = 'insert'; break;
436
+ case '[3~': key.name = 'delete'; break; case '[4~': key.name = 'end'; break;
437
+ case '[5~': case '[[5~': key.name = 'pageup'; break;
438
+ case '[6~': case '[[6~': key.name = 'pagedown'; break;
439
+ case '[7~': key.name = 'home'; break; case '[8~': key.name = 'end'; break;
440
+ case '[a': key.name = 'up'; key.shift = true; break;
441
+ case '[b': key.name = 'down'; key.shift = true; break;
442
+ case '[c': key.name = 'right'; key.shift = true; break;
443
+ case '[d': key.name = 'left'; key.shift = true; break;
444
+ case '[2$': key.name = 'insert'; key.shift = true; break;
445
+ case '[3$': key.name = 'delete'; key.shift = true; break;
446
+ case '[5$': key.name = 'pageup'; key.shift = true; break;
447
+ case '[6$': key.name = 'pagedown'; key.shift = true; break;
448
+ case '[7$': key.name = 'home'; key.shift = true; break;
449
+ case '[8$': key.name = 'end'; key.shift = true; break;
450
+ case 'Oa': key.name = 'up'; key.ctrl = true; break;
451
+ case 'Ob': key.name = 'down'; key.ctrl = true; break;
452
+ case 'Oc': key.name = 'right'; key.ctrl = true; break;
453
+ case 'Od': key.name = 'left'; key.ctrl = true; break;
454
+ case '[2^': key.name = 'insert'; key.ctrl = true; break;
455
+ case '[3^': key.name = 'delete'; key.ctrl = true; break;
456
+ case '[5^': key.name = 'pageup'; key.ctrl = true; break;
457
+ case '[6^': key.name = 'pagedown'; key.ctrl = true; break;
458
+ case '[7^': key.name = 'home'; key.ctrl = true; break;
459
+ case '[8^': key.name = 'end'; key.ctrl = true; break;
460
+ case '[Z': key.name = 'tab'; key.shift = true; break;
461
+ default: key.name = 'undefined'; break;
462
+ }
463
+ } else if (ch === '\r') {
464
+ key.name = 'return'; key.meta = escaped;
465
+ } else if (ch === '\n') {
466
+ key.name = 'enter'; key.meta = escaped;
467
+ } else if (ch === '\t') {
468
+ key.name = 'tab'; key.meta = escaped;
469
+ } else if (ch === '\b' || ch === '\x7f') {
470
+ key.name = 'backspace'; key.meta = escaped;
471
+ } else if (ch === '\x1b') {
472
+ key.name = 'escape'; key.meta = escaped;
473
+ } else if (ch === ' ') {
474
+ key.name = 'space'; key.meta = escaped;
475
+ } else if (!escaped && ch <= '\x1a') {
476
+ key.name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1);
477
+ key.ctrl = true;
478
+ } else if (/^[0-9A-Za-z]$/.test(ch)) {
479
+ key.name = ch.toLowerCase(); key.shift = /^[A-Z]$/.test(ch); key.meta = escaped;
480
+ } else if (escaped) {
481
+ key.name = ch.length ? undefined : 'escape'; key.meta = true;
482
+ }
483
+
484
+ key.sequence = s;
485
+ if (s.length !== 0 && (key.name !== undefined || escaped)) {
486
+ stream.emit('keypress', escaped ? undefined : s, key);
487
+ } else if (s.length === 1) {
488
+ stream.emit('keypress', s, key);
489
+ }
490
+ }
491
+ }
492
+
493
+ const _KEYPRESS_DECODER = Symbol('keypress-decoder');
494
+ const _ESCAPE_DECODER = Symbol('escape-decoder');
495
+
496
+ // Idempotent — calling twice on the same stream is a no-op.
497
+ // Ported from refs/node/lib/internal/readline/emitKeypressEvents.js.
498
+ export function emitKeypressEvents(stream: Readable & Record<symbol, unknown>, iface: { escapeCodeTimeout?: number } = {}): void {
499
+ if ((stream as any)[_KEYPRESS_DECODER]) return;
500
+
501
+ (stream as any)[_KEYPRESS_DECODER] = new StringDecoder('utf8');
502
+ (stream as any)[_ESCAPE_DECODER] = emitKeys(stream as any);
503
+ (stream as any)[_ESCAPE_DECODER].next();
504
+
505
+ const escTimeout = iface.escapeCodeTimeout ?? ESCAPE_CODE_TIMEOUT;
506
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
507
+ const triggerEscape = () => (stream as any)[_ESCAPE_DECODER].next('');
508
+
509
+ function onData(input: Buffer | string): void {
510
+ if ((stream as any).listenerCount('keypress') > 0) {
511
+ const str: string = (stream as any)[_KEYPRESS_DECODER].write(
512
+ typeof input === 'string' ? Buffer.from(input) : input,
513
+ );
514
+ if (str) {
515
+ clearTimeout(timeoutId);
516
+ for (const ch of str) {
517
+ try {
518
+ (stream as any)[_ESCAPE_DECODER].next(ch);
519
+ if (ch === '\x1b') timeoutId = setTimeout(triggerEscape, escTimeout);
520
+ } catch {
521
+ (stream as any)[_ESCAPE_DECODER] = emitKeys(stream as any);
522
+ (stream as any)[_ESCAPE_DECODER].next();
523
+ }
524
+ }
525
+ }
526
+ } else {
527
+ (stream as any).removeListener('data', onData);
528
+ delete (stream as any)[_KEYPRESS_DECODER];
529
+ delete (stream as any)[_ESCAPE_DECODER];
530
+ }
531
+ }
532
+
533
+ (stream as any).on('data', onData);
350
534
  }
351
535
 
352
536
  export default {