dsh-ssh-tui 0.8.3 → 0.8.4-rc.2
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/CHANGELOG.md +132 -12
- package/README.en.md +20 -17
- package/README.md +11 -8
- package/docs/release-notes-0.8.4-rc.1.md +271 -0
- package/docs/release-notes-0.8.4-rc.2.md +72 -0
- package/docs/release-notes-0.8.4.md +145 -0
- package/lib/doctor.js +41 -4
- package/lib/doctor.js.map +1 -1
- package/lib/dsh-compat.js +22 -0
- package/lib/dsh-compat.js.map +1 -1
- package/lib/footer-budget.js +1 -7
- package/lib/footer-budget.js.map +1 -1
- package/lib/i18n/en.js +6 -1
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +6 -1
- package/lib/i18n/zh.js.map +1 -1
- package/lib/input-reader.js +415 -0
- package/lib/input-reader.js.map +1 -0
- package/lib/input-router.js +82 -0
- package/lib/input-router.js.map +1 -0
- package/lib/preset-rows.js +70 -19
- package/lib/preset-rows.js.map +1 -1
- package/lib/terminal-input.js +27 -13
- package/lib/terminal-input.js.map +1 -1
- package/lib/tui.js +812 -482
- package/lib/tui.js.map +1 -1
- package/lib/types/doctor.d.ts +13 -1
- package/lib/types/dsh-compat.d.ts +20 -1
- package/lib/types/footer-budget.d.ts +0 -9
- package/lib/types/input-reader.d.ts +198 -0
- package/lib/types/input-router.d.ts +73 -0
- package/lib/types/preset-rows.d.ts +22 -7
- package/lib/types/terminal-input.d.ts +15 -1
- package/lib/types/tui.d.ts +210 -18
- package/package.json +88 -87
- package/lib/footer-accents.js +0 -28
- package/lib/footer-accents.js.map +0 -1
- package/lib/types/footer-accents.d.ts +0 -22
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The input Reader: bytes in, framed input units out.
|
|
3
|
+
*
|
|
4
|
+
* This module owns everything between the wire and "here is what arrived":
|
|
5
|
+
*
|
|
6
|
+
* - **byte decoding** — a UTF-8 code point split across two reads is one
|
|
7
|
+
* character, not two replacement glyphs (`StringDecoder`)
|
|
8
|
+
* - **device-reply filtering** — the `CSI row;col R` answers the link probe asks
|
|
9
|
+
* for are not typing; the guard strips them and holds a possible tail for
|
|
10
|
+
* `INPUT_HOLD_MS` so `ESC [ 1` + `7;1R` cannot become `[17;1R` in the draft
|
|
11
|
+
* - **escape assembly** — a sequence may arrive in pieces (`ESC`, then `[A`;
|
|
12
|
+
* `ESC[`, then `5~`), so a read that could still grow is held for
|
|
13
|
+
* {@link ESCAPE_HOLD_MS} and released as what it turned out to be
|
|
14
|
+
* - **paste framing** — `ESC[200~ … ESC[201~` markers, the payload between them,
|
|
15
|
+
* and the console-without-bracketed-paste fallback (a burst carrying more than
|
|
16
|
+
* one line break in one read is a paste, not repeated Enter presses)
|
|
17
|
+
* - **unit classification** — whether a read is one complete terminal unit, a
|
|
18
|
+
* burst of typing, paste traffic, or a device reply (shape only: this module
|
|
19
|
+
* does not know which key does what)
|
|
20
|
+
*
|
|
21
|
+
* It owns **no** semantics. It does not look at `screen`, `dialog`, the composer or
|
|
22
|
+
* the workspace, does not consult the keymap, and never mutates owner state: the
|
|
23
|
+
* frames it emits are the input to the caller's routing, not a decision about it.
|
|
24
|
+
* That is the boundary the contract freezes (`docs/decisions/input-reader.md`,
|
|
25
|
+
* AD-21) — the same input bytes must produce the same frames, and the caller must
|
|
26
|
+
* be able to prove it (`tests/input-reader.test.mjs` compares the frames and the
|
|
27
|
+
* consumer calls against traces captured before this module existed).
|
|
28
|
+
*
|
|
29
|
+
* The two hold windows are deliberately separate and must not be merged:
|
|
30
|
+
* `INPUT_HOLD_MS` (30 ms, in the guard) guards a **reply** and hands the bytes back
|
|
31
|
+
* as input when the window passes; {@link ESCAPE_HOLD_MS} (60 ms, here) guards a
|
|
32
|
+
* **keystroke** and turns what it held into an Escape or into text. One is a filter
|
|
33
|
+
* with a fallback, the other an assembly step with two possible readings.
|
|
34
|
+
*
|
|
35
|
+
* @module dsh-ssh-tui/input-reader
|
|
36
|
+
*/
|
|
37
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
38
|
+
import { isEscapePrefix, parseCursorPositionReply } from './paint.js';
|
|
39
|
+
import { TerminalInputGuard } from './terminal-input.js';
|
|
40
|
+
/**
|
|
41
|
+
* How long a read that could still grow into a sequence is held.
|
|
42
|
+
*
|
|
43
|
+
* The window is what makes a lone `ESC` (the reader pressed Escape) different from
|
|
44
|
+
* the first byte of a sequence the terminal is still sending. W-1 moved the timer,
|
|
45
|
+
* not the number: changing it changes how Escape feels, and that is a separate
|
|
46
|
+
* decision with its own evidence.
|
|
47
|
+
*/
|
|
48
|
+
export const ESCAPE_HOLD_MS = 60;
|
|
49
|
+
/** Whether `text` is exactly one complete terminal unit (shape, not meaning). */
|
|
50
|
+
const COMPLETE_CSI = /^\x1b\[[0-9;?<]*[ -/]*[@-~]$/u;
|
|
51
|
+
const COMPLETE_SS3 = /^\x1bO[@-~]$/u;
|
|
52
|
+
/** The same shapes, anchored at the start: one complete unit of a longer read. */
|
|
53
|
+
const COMPLETE_CSI_AT = /^\x1b\[[0-9;?<]*[ -/]*[@-~]/u;
|
|
54
|
+
const COMPLETE_SS3_AT = /^\x1bO[@-~]/u;
|
|
55
|
+
/**
|
|
56
|
+
* `ESC` plus one printable character: the Alt chord.
|
|
57
|
+
*
|
|
58
|
+
* Not `[ -~]` alone: a terminal can send Alt with any character it can encode, and the
|
|
59
|
+
* chord has to reach the Alt branch in one piece or its character leaks as text.
|
|
60
|
+
*/
|
|
61
|
+
const ALT_CHORD_AT = /^\x1b(?!\[|O)[^\x00-\x1f\x7f]/u;
|
|
62
|
+
const PASTE_MARKERS = ['\x1b[200~', '\x1b[201~'];
|
|
63
|
+
/**
|
|
64
|
+
* How long a paste may sit with half a marker before the reader gives up on it.
|
|
65
|
+
*
|
|
66
|
+
* This is a watchdog, not a key hold: a paste is a stream, and its close marker may
|
|
67
|
+
* arrive in a second chunk after a slow terminal, a ConPTY or a loaded runner — on
|
|
68
|
+
* Windows CI the gap was over 200 ms, and expiring that early typed the marker's tail
|
|
69
|
+
* into the draft and left the session inside the paste. So the window is generous.
|
|
70
|
+
* When it does expire the reader does not just release the tail as payload, it
|
|
71
|
+
* **leaves the paste**: a paste that never closed must not keep the keyboard as its
|
|
72
|
+
* payload for the rest of the session.
|
|
73
|
+
*/
|
|
74
|
+
export const PASTE_MARKER_HOLD_MS = 2_000;
|
|
75
|
+
/**
|
|
76
|
+
* The length of the longest suffix of `text` that is a *proper* prefix of a paste
|
|
77
|
+
* marker (0 when there is none) — what a read cut in half leaves behind.
|
|
78
|
+
*/
|
|
79
|
+
function pasteMarkerSuffix(text) {
|
|
80
|
+
const longest = Math.max(...PASTE_MARKERS.map(marker => marker.length)) - 1;
|
|
81
|
+
for (let length = Math.min(longest, text.length); length > 0; length -= 1) {
|
|
82
|
+
const suffix = text.slice(text.length - length);
|
|
83
|
+
if (PASTE_MARKERS.some(marker => marker.startsWith(suffix)))
|
|
84
|
+
return length;
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
export class InputReader {
|
|
89
|
+
options;
|
|
90
|
+
decoder = new StringDecoder('utf8');
|
|
91
|
+
guard;
|
|
92
|
+
/** A read that may still grow (escape assembly). */
|
|
93
|
+
held = '';
|
|
94
|
+
holdTimer;
|
|
95
|
+
/** Inside `ESC[200~ … ESC[201~`. */
|
|
96
|
+
inPaste = false;
|
|
97
|
+
/**
|
|
98
|
+
* The tail of a read that ended *inside* a paste marker (`…ESC[20`), waiting for
|
|
99
|
+
* its other half.
|
|
100
|
+
*/
|
|
101
|
+
pasteTail = '';
|
|
102
|
+
pasteTailTimer;
|
|
103
|
+
constructor(options) {
|
|
104
|
+
this.options = options;
|
|
105
|
+
this.guard = new TerminalInputGuard(text => this.options.onRead(text), undefined,
|
|
106
|
+
// The filter must not mistake a paste's close marker for half a cursor reply.
|
|
107
|
+
{ isPasteTraffic: text => this.isPasteTraffic(text) });
|
|
108
|
+
}
|
|
109
|
+
/** One read of bytes: decode it, drop device replies, hand the rest on. */
|
|
110
|
+
push(chunk) {
|
|
111
|
+
const decoded = this.decoder.write(chunk);
|
|
112
|
+
if (decoded === '')
|
|
113
|
+
return;
|
|
114
|
+
this.guard.push(decoded);
|
|
115
|
+
}
|
|
116
|
+
/** The read with any held prefix in front of it. The hold window is over. */
|
|
117
|
+
assemble(text) {
|
|
118
|
+
const combined = this.held + text;
|
|
119
|
+
this.held = '';
|
|
120
|
+
this.cancelHold();
|
|
121
|
+
return combined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The frames of one read, whichever kind it is.
|
|
125
|
+
*
|
|
126
|
+
* A read is what the terminal happened to send, not what the reader pressed: a
|
|
127
|
+
* fast typist, a slow link or ConPTY can put several keys in one read. This is the
|
|
128
|
+
* one place that decides where one unit ends and the next begins; everything above
|
|
129
|
+
* it (Router, owners) sees complete units only, and nothing above it re-parses the
|
|
130
|
+
* bytes (AD-20 §4.4, §6).
|
|
131
|
+
*/
|
|
132
|
+
read(text) {
|
|
133
|
+
const combined = this.pasteTail === '' ? text : `${this.pasteTail}${text}`;
|
|
134
|
+
if (this.pasteTail !== '') {
|
|
135
|
+
this.pasteTail = '';
|
|
136
|
+
this.cancelPasteTail();
|
|
137
|
+
}
|
|
138
|
+
if (!this.isPasteTraffic(combined))
|
|
139
|
+
return this.units(combined);
|
|
140
|
+
const { frames, tail } = this.framePaste(combined);
|
|
141
|
+
this.pasteTail = tail;
|
|
142
|
+
// Armed for the whole paste, not just for a held tail: a terminal can swallow the
|
|
143
|
+
// close marker outright (ConPTY drops a partial CSI), and a paste that never says
|
|
144
|
+
// it ended must not keep the keyboard as its payload.
|
|
145
|
+
if (this.inPaste || tail !== '')
|
|
146
|
+
this.armPasteTail();
|
|
147
|
+
else
|
|
148
|
+
this.cancelPasteTail();
|
|
149
|
+
return frames;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Frame one paste read, holding back a marker it cut in half.
|
|
153
|
+
*
|
|
154
|
+
* A terminal writes bytes, not reads: `ESC[201~` can arrive as `…ESC[20` and `1~`.
|
|
155
|
+
* Framed naively, the first read swallows `ESC[20` as payload and the second one
|
|
156
|
+
* carries no marker at all — so `inPaste` never clears and **every later key is
|
|
157
|
+
* pasted text**: Esc stops closing a dialog, `/exit` stops exiting. Found by
|
|
158
|
+
* `scripts/tui-paste-probe.mjs` on CI, where the pty chunked the write exactly
|
|
159
|
+
* there. The fix is the same rule the escape ladder already uses — a read that
|
|
160
|
+
* could still grow is held, and released as payload if its other half never comes.
|
|
161
|
+
*/
|
|
162
|
+
framePaste(combined) {
|
|
163
|
+
const keep = pasteMarkerSuffix(combined);
|
|
164
|
+
if (keep === 0)
|
|
165
|
+
return { frames: this.frames(combined), tail: '' };
|
|
166
|
+
const body = combined.slice(0, combined.length - keep);
|
|
167
|
+
return { frames: this.frames(body), tail: combined.slice(combined.length - keep) };
|
|
168
|
+
}
|
|
169
|
+
armPasteTail() {
|
|
170
|
+
this.cancelPasteTail();
|
|
171
|
+
this.pasteTailTimer = setTimeout(() => {
|
|
172
|
+
this.pasteTailTimer = undefined;
|
|
173
|
+
const pending = this.pasteTail;
|
|
174
|
+
this.pasteTail = '';
|
|
175
|
+
// Its other half never came. Those bytes are what the reader pasted, and the
|
|
176
|
+
// paste they belonged to is over: leaving `inPaste` set here is what turned a
|
|
177
|
+
// slow (or swallowed) marker into a session that ignored every key.
|
|
178
|
+
if (pending !== '')
|
|
179
|
+
this.options.onFrame({ kind: this.inPaste ? 'paste' : 'literal', text: pending });
|
|
180
|
+
this.inPaste = false;
|
|
181
|
+
}, this.options.pasteMarkerHoldMs ?? PASTE_MARKER_HOLD_MS);
|
|
182
|
+
}
|
|
183
|
+
cancelPasteTail() {
|
|
184
|
+
if (this.pasteTailTimer !== undefined)
|
|
185
|
+
clearTimeout(this.pasteTailTimer);
|
|
186
|
+
this.pasteTailTimer = undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* One non-paste read, split into complete units.
|
|
190
|
+
*
|
|
191
|
+
* The rules, and why each one exists:
|
|
192
|
+
*
|
|
193
|
+
* - **A complete CSI/SS3 sequence is one unit.** `ESC [ 5 ~ ESC [ 5 ~` is two page
|
|
194
|
+
* keys, not one unnameable read (G-1). A sequence the read cuts short stays one
|
|
195
|
+
* piece: `hold()` has already claimed it if it could still grow, and a trailing
|
|
196
|
+
* `ESC [` must reach the hold again rather than be split into `ESC` + `[`.
|
|
197
|
+
* - **An Alt chord is one unit.** `ESC` plus exactly one printable character is
|
|
198
|
+
* Alt+that key, so `ESC 1 x` is the chord Alt+1 *and then* `x` — never the text
|
|
199
|
+
* `1x` (G-3), and never a chord that swallows the character behind it.
|
|
200
|
+
* - **A control byte is one unit, CR/LF pair as one Enter.** `\x10\x10` is two
|
|
201
|
+
* presses of a rebound key (G-2/G-8), and Windows' CRLF is one key.
|
|
202
|
+
* - **A run of ordinary characters is one unit.** Splitting typing per character
|
|
203
|
+
* would make a pasted `yes` answer a confirm dialog character by character,
|
|
204
|
+
* which is the boundary W-0 pinned (`yes-typed-at-dialog`). Keymap lookup on a
|
|
205
|
+
* whole run is still exactly what a run means: text.
|
|
206
|
+
* - **A burst the paste heuristic claims stays whole.** A console without
|
|
207
|
+
* bracketed paste sends a paste as ordinary bytes with line breaks in them;
|
|
208
|
+
* that read has to reach the paste path intact (`looksLikePasteBurst`).
|
|
209
|
+
*/
|
|
210
|
+
units(text) {
|
|
211
|
+
if (InputReader.looksLikePasteBurst(text))
|
|
212
|
+
return [{ kind: 'burst', text }];
|
|
213
|
+
const frames = [];
|
|
214
|
+
let index = 0;
|
|
215
|
+
while (index < text.length) {
|
|
216
|
+
const char = text[index];
|
|
217
|
+
if (char === '\x1b') {
|
|
218
|
+
const rest = text.slice(index);
|
|
219
|
+
const sequence = COMPLETE_CSI_AT.exec(rest)?.[0] ?? COMPLETE_SS3_AT.exec(rest)?.[0];
|
|
220
|
+
if (sequence !== undefined) {
|
|
221
|
+
frames.push({ kind: 'key', sequence });
|
|
222
|
+
index += sequence.length;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// Something that could still grow into a sequence — `ESC [ 5` with its final
|
|
226
|
+
// byte in the next read, a mouse report cut in half — goes on as one piece, so
|
|
227
|
+
// the hold below claims exactly what it claimed before: splitting it here would
|
|
228
|
+
// turn the head of a sequence into an Alt chord and type its tail as text.
|
|
229
|
+
if (isEscapePrefix(rest)) {
|
|
230
|
+
frames.push({ kind: 'key', sequence: rest });
|
|
231
|
+
index += rest.length;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const chord = ALT_CHORD_AT.exec(rest)?.[0];
|
|
235
|
+
if (chord !== undefined) {
|
|
236
|
+
frames.push({ kind: 'key', sequence: chord });
|
|
237
|
+
index += chord.length;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// A lone ESC, or something that cannot grow: its own unit, and the hold decides.
|
|
241
|
+
frames.push({ kind: 'key', sequence: '\x1b' });
|
|
242
|
+
index += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (char === '\r' || char === '\n') {
|
|
246
|
+
const next = text[index + 1];
|
|
247
|
+
frames.push({ kind: 'key', sequence: char === '\n' ? '\n' : '\r' });
|
|
248
|
+
index += (char === '\r' && next === '\n') || (char === '\n' && next === '\r') ? 2 : 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (char < ' ' || char === '\x7f') {
|
|
252
|
+
frames.push({ kind: 'key', sequence: char });
|
|
253
|
+
index += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
let end = index;
|
|
257
|
+
while (end < text.length && text[end] >= ' ' && text[end] !== '\x7f' && text[end] !== '\x1b')
|
|
258
|
+
end += 1;
|
|
259
|
+
frames.push({ kind: 'burst', text: text.slice(index, end) });
|
|
260
|
+
index = end;
|
|
261
|
+
}
|
|
262
|
+
return frames;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Whether a read is paste traffic: inside a paste, or carrying a marker.
|
|
266
|
+
*
|
|
267
|
+
* Asked before anything else, exactly as the inline check it replaces was: while
|
|
268
|
+
* inside a paste, CR/LF are literal characters rather than submit.
|
|
269
|
+
*/
|
|
270
|
+
isPasteTraffic(combined) {
|
|
271
|
+
return this.inPaste || combined.includes('\x1b[200~') || combined.includes('\x1b[201~');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The frames of one paste read, in order: payload inside the markers as `paste`,
|
|
275
|
+
* everything outside them as `literal` (which is what the inline version typed
|
|
276
|
+
* directly, without a keymap lookup).
|
|
277
|
+
*
|
|
278
|
+
* Only complete markers are handled here: `framePaste` holds back a read that ends
|
|
279
|
+
* inside one, because a marker whose two halves land in two reads would otherwise
|
|
280
|
+
* leave the reader inside the paste for the rest of the session.
|
|
281
|
+
*/
|
|
282
|
+
frames(combined) {
|
|
283
|
+
const frames = [];
|
|
284
|
+
let index = 0;
|
|
285
|
+
while (index < combined.length) {
|
|
286
|
+
if (combined.startsWith('\x1b[200~', index)) {
|
|
287
|
+
this.inPaste = true;
|
|
288
|
+
index += 6;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (combined.startsWith('\x1b[201~', index)) {
|
|
292
|
+
this.inPaste = false;
|
|
293
|
+
index += 6;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let end = index;
|
|
297
|
+
while (end < combined.length
|
|
298
|
+
&& !combined.startsWith('\x1b[200~', end)
|
|
299
|
+
&& !combined.startsWith('\x1b[201~', end)) {
|
|
300
|
+
end += 1;
|
|
301
|
+
}
|
|
302
|
+
if (end > index) {
|
|
303
|
+
const part = combined.slice(index, end);
|
|
304
|
+
frames.push(this.inPaste ? { kind: 'paste', text: part } : { kind: 'literal', text: part });
|
|
305
|
+
index = end;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
index += 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return frames;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Keep a read that could still grow into a sequence.
|
|
315
|
+
* @returns true when it was held (the caller stops there), false when the read is
|
|
316
|
+
* complete as it stands.
|
|
317
|
+
*/
|
|
318
|
+
hold(combined) {
|
|
319
|
+
if (!isEscapePrefix(combined))
|
|
320
|
+
return false;
|
|
321
|
+
this.held = combined;
|
|
322
|
+
this.cancelHold();
|
|
323
|
+
this.holdTimer = setTimeout(() => {
|
|
324
|
+
this.holdTimer = undefined;
|
|
325
|
+
const pending = this.held;
|
|
326
|
+
this.held = '';
|
|
327
|
+
this.releaseHold(pending);
|
|
328
|
+
}, ESCAPE_HOLD_MS);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Whether one read is a paste from a console that cannot say so.
|
|
333
|
+
*
|
|
334
|
+
* A burst carrying more than one line break is not a human pressing Enter twice —
|
|
335
|
+
* they cannot press it twice inside one read. This is the fallback's one rule, and
|
|
336
|
+
* it lives here so every caller applies the same one.
|
|
337
|
+
*/
|
|
338
|
+
static looksLikePasteBurst(text) {
|
|
339
|
+
let breaks = 0;
|
|
340
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
341
|
+
const char = text[index];
|
|
342
|
+
if (char === '\r' && text[index + 1] !== '\n')
|
|
343
|
+
breaks += 1;
|
|
344
|
+
else if (char === '\n' && text[index - 1] !== '\r')
|
|
345
|
+
breaks += 1;
|
|
346
|
+
if (breaks > 1)
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
/** Whether a complete read is a device reply rather than input. */
|
|
352
|
+
isDeviceReply(text) {
|
|
353
|
+
return parseCursorPositionReply(text) !== undefined;
|
|
354
|
+
}
|
|
355
|
+
/** Classify one complete read by shape. */
|
|
356
|
+
classify(text) {
|
|
357
|
+
if (this.isDeviceReply(text))
|
|
358
|
+
return { kind: 'reply', sequence: text };
|
|
359
|
+
if (text === '\x1b')
|
|
360
|
+
return { kind: 'key', sequence: text };
|
|
361
|
+
if (text.length === 1) {
|
|
362
|
+
const code = text.codePointAt(0) ?? 0;
|
|
363
|
+
return code < 0x20 || code === 0x7f ? { kind: 'key', sequence: text } : { kind: 'burst', text };
|
|
364
|
+
}
|
|
365
|
+
if (COMPLETE_CSI.test(text) || COMPLETE_SS3.test(text))
|
|
366
|
+
return { kind: 'key', sequence: text };
|
|
367
|
+
return { kind: 'burst', text };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Hand over a held reply tail now.
|
|
371
|
+
*
|
|
372
|
+
* The guard releases on its own window; this is for a caller that already knows
|
|
373
|
+
* the tail is stale — `scripts/bench-b2.mjs` drives keystrokes faster than the
|
|
374
|
+
* hold, and a measurement that waited for the window would be measuring the wait.
|
|
375
|
+
*/
|
|
376
|
+
releaseReplyTail() {
|
|
377
|
+
this.guard.release();
|
|
378
|
+
}
|
|
379
|
+
/** Drop the holds and the reply filter (display detached, teardown). */
|
|
380
|
+
stop() {
|
|
381
|
+
this.cancelHold();
|
|
382
|
+
this.cancelPasteTail();
|
|
383
|
+
this.pasteTail = '';
|
|
384
|
+
this.guard.stop();
|
|
385
|
+
}
|
|
386
|
+
cancelHold() {
|
|
387
|
+
if (this.holdTimer !== undefined)
|
|
388
|
+
clearTimeout(this.holdTimer);
|
|
389
|
+
this.holdTimer = undefined;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* What a held read turned out to be.
|
|
393
|
+
*
|
|
394
|
+
* Verbatim from the inline timer it replaces: a lone `ESC` is the Escape key, an
|
|
395
|
+
* `ESC O` without its final byte is Alt+O, an escape sequence that never completed
|
|
396
|
+
* is consumed silently (its `ESC` must not read as a cancel), and anything else is
|
|
397
|
+
* typed. The last branch is unreachable for the shapes `isEscapePrefix` accepts —
|
|
398
|
+
* it is kept because "unreachable today" is not a reason to change a release.
|
|
399
|
+
*/
|
|
400
|
+
releaseHold(pending) {
|
|
401
|
+
if (pending === '\x1b') {
|
|
402
|
+
this.options.onFrame({ kind: 'escape' });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (pending === '\x1bO') {
|
|
406
|
+
this.options.onFrame({ kind: 'literal', text: 'O' });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO'))
|
|
410
|
+
return;
|
|
411
|
+
if (pending !== '')
|
|
412
|
+
this.options.onFrame({ kind: 'literal', text: pending });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=input-reader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"input-reader.js","sourceRoot":"","sources":["../src/input-reader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEnD,OAAO,EAAE,cAAc,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AAExD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,EAAE,CAAA;AAwChC,iFAAiF;AACjF,MAAM,YAAY,GAAG,+BAA+B,CAAA;AACpD,MAAM,YAAY,GAAG,eAAe,CAAA;AACpC,kFAAkF;AAClF,MAAM,eAAe,GAAG,8BAA8B,CAAA;AACtD,MAAM,eAAe,GAAG,cAAc,CAAA;AACtC;;;;;GAKG;AACH,MAAM,YAAY,GAAG,gCAAgC,CAAA;AAErD,MAAM,aAAa,GAAG,CAAC,WAAW,EAAE,WAAW,CAAU,CAAA;AAEzD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,CAAA;AAEzC;;;GAGG;AACH,SAAS,iBAAiB,CAAC,IAAY;IACrC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAA;IAC3E,KAAK,IAAI,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC,EAAE,CAAC;QAC1E,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;QAC/C,IAAI,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;YAAE,OAAO,MAAM,CAAA;IAC5E,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED,MAAM,OAAO,WAAW;IAeO;IAdZ,OAAO,GAAG,IAAI,aAAa,CAAC,MAAM,CAAC,CAAA;IACnC,KAAK,CAAoB;IAC1C,oDAAoD;IAC5C,IAAI,GAAG,EAAE,CAAA;IACT,SAAS,CAA2C;IAC5D,oCAAoC;IAC5B,OAAO,GAAG,KAAK,CAAA;IACvB;;;OAGG;IACK,SAAS,GAAG,EAAE,CAAA;IACd,cAAc,CAA2C;IAEjE,YAA6B,OAA2B;QAA3B,YAAO,GAAP,OAAO,CAAoB;QACtD,IAAI,CAAC,KAAK,GAAG,IAAI,kBAAkB,CACjC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EACjC,SAAS;QACT,8EAA8E;QAC9E,EAAE,cAAc,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,CACtD,CAAA;IACH,CAAC;IAED,2EAA2E;IAC3E,IAAI,CAAC,KAAa;QAChB,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,OAAO,KAAK,EAAE;YAAE,OAAM;QAC1B,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC1B,CAAC;IAED,6EAA6E;IAC7E,QAAQ,CAAC,IAAY;QACnB,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;QACjC,IAAI,CAAC,IAAI,GAAG,EAAE,CAAA;QACd,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,OAAO,QAAQ,CAAA;IACjB,CAAC;IAED;;;;;;;;OAQG;IACH,IAAI,CAAC,IAAY;QACf,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,GAAG,IAAI,EAAE,CAAA;QAC1E,IAAI,IAAI,CAAC,SAAS,KAAK,EAAE,EAAE,CAAC;YAC1B,IAAI,CAAC,SAAS,GAAG,EAAE,CAAA;YACnB,IAAI,CAAC,eAAe,EAAE,CAAA;QACxB,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;QAC/D,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAA;QAClD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;QACrB,kFAAkF;QAClF,kFAAkF;QAClF,sDAAsD;QACtD,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,KAAK,EAAE;YAAE,IAAI,CAAC,YAAY,EAAE,CAAA;;YAC/C,IAAI,CAAC,eAAe,EAAE,CAAA;QAC3B,OAAO,MAAM,CAAA;IACf,CAAC;IAED;;;;;;;;;;OAUG;IACK,UAAU,CAAC,QAAgB;QACjC,MAAM,IAAI,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAA;QACxC,IAAI,IAAI,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAA;QAClE,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;QACtD,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAA;IACpF,CAAC;IAEO,YAAY;QAClB,IAAI,CAAC,eAAe,EAAE,CAAA;QACtB,IAAI,CAAC,cAAc,GAAG,UAAU,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,cAAc,GAAG,SAAS,CAAA;YAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAA;YAC9B,IAAI,CAAC,SAAS,GAAG,EAAE,CAAA;YACnB,6EAA6E;YAC7E,8EAA8E;YAC9E,oEAAoE;YACpE,IAAI,OAAO,KAAK,EAAE;gBAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;YACrG,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACtB,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,iBAAiB,IAAI,oBAAoB,CAAC,CAAA;IAC5D,CAAC;IAEO,eAAe;QACrB,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS;YAAE,YAAY,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;QACxE,IAAI,CAAC,cAAc,GAAG,SAAS,CAAA;IACjC,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,KAAK,CAAC,IAAY;QAChB,IAAI,WAAW,CAAC,mBAAmB,CAAC,IAAI,CAAC;YAAE,OAAO,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3E,MAAM,MAAM,GAAiB,EAAE,CAAA;QAC/B,IAAI,KAAK,GAAG,CAAC,CAAA;QACb,OAAO,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;YAC3B,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAA;YACxB,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBACpB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;gBAC9B,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;gBACnF,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAA;oBACtC,KAAK,IAAI,QAAQ,CAAC,MAAM,CAAA;oBACxB,SAAQ;gBACV,CAAC;gBACD,6EAA6E;gBAC7E,+EAA+E;gBAC/E,gFAAgF;gBAChF,2EAA2E;gBAC3E,IAAI,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC;oBACzB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;oBAC5C,KAAK,IAAI,IAAI,CAAC,MAAM,CAAA;oBACpB,SAAQ;gBACV,CAAC;gBACD,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;gBAC1C,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;oBACxB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAA;oBAC7C,KAAK,IAAI,KAAK,CAAC,MAAM,CAAA;oBACrB,SAAQ;gBACV,CAAC;gBACD,iFAAiF;gBACjF,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAA;gBAC9C,KAAK,IAAI,CAAC,CAAA;gBACV,SAAQ;YACV,CAAC;YACD,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;gBACnC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAA;gBAC5B,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAA;gBACnE,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;gBACrF,SAAQ;YACV,CAAC;YACD,IAAI,IAAI,GAAG,GAAG,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBAClC,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC5C,KAAK,IAAI,CAAC,CAAA;gBACV,SAAQ;YACV,CAAC;YACD,IAAI,GAAG,GAAG,KAAK,CAAA;YACf,OAAO,GAAG,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,MAAM;gBAAE,GAAG,IAAI,CAAC,CAAA;YACtG,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,CAAC,CAAA;YAC5D,KAAK,GAAG,GAAG,CAAA;QACb,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;IAED;;;;;OAKG;IACH,cAAc,CAAC,QAAgB;QAC7B,OAAO,IAAI,CAAC,OAAO,IAAI,QAAQ,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;IACzF,CAAC;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,QAAgB;QACrB,MAAM,MAAM,GAAiB,EAAE,CAAA;QAC/B,IAAI,KAAK,GAAG,CAAC,CAAA;QACb,OAAO,KAAK,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC;YAC/B,IAAI,QAAQ,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC;gBAC5C,IAAI,CAAC,OAAO,GAAG,IAAI,CAAA;gBACnB,KAAK,IAAI,CAAC,CAAA;gBACV,SAAQ;YACV,CAAC;YACD,IAAI,QAAQ,CAAC,UAAU,CAAC,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC;gBAC5C,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;gBACpB,KAAK,IAAI,CAAC,CAAA;gBACV,SAAQ;YACV,CAAC;YACD,IAAI,GAAG,GAAG,KAAK,CAAA;YACf,OAAO,GAAG,GAAG,QAAQ,CAAC,MAAM;mBACvB,CAAC,QAAQ,CAAC,UAAU,CAAC,WAAW,EAAE,GAAG,CAAC;mBACtC,CAAC,QAAQ,CAAC,UAAU,CAAC,WAAW,EAAE,GAAG,CAAC,EAAE,CAAC;gBAC5C,GAAG,IAAI,CAAC,CAAA;YACV,CAAC;YACD,IAAI,GAAG,GAAG,KAAK,EAAE,CAAC;gBAChB,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;gBACvC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC3F,KAAK,GAAG,GAAG,CAAA;YACb,CAAC;iBAAM,CAAC;gBACN,KAAK,IAAI,CAAC,CAAA;YACZ,CAAC;QACH,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;IAED;;;;OAIG;IACH,IAAI,CAAC,QAAgB;QACnB,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC;YAAE,OAAO,KAAK,CAAA;QAC3C,IAAI,CAAC,IAAI,GAAG,QAAQ,CAAA;QACpB,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,IAAI,CAAC,SAAS,GAAG,UAAU,CAAC,GAAG,EAAE;YAC/B,IAAI,CAAC,SAAS,GAAG,SAAS,CAAA;YAC1B,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAA;YACzB,IAAI,CAAC,IAAI,GAAG,EAAE,CAAA;YACd,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,CAAA;QAC3B,CAAC,EAAE,cAAc,CAAC,CAAA;QAClB,OAAO,IAAI,CAAA;IACb,CAAC;IAED;;;;;;OAMG;IACH,MAAM,CAAC,mBAAmB,CAAC,IAAY;QACrC,IAAI,MAAM,GAAG,CAAC,CAAA;QACd,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YACpD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAA;YACxB,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI;gBAAE,MAAM,IAAI,CAAC,CAAA;iBACrD,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,IAAI;gBAAE,MAAM,IAAI,CAAC,CAAA;YAC/D,IAAI,MAAM,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAA;QAC7B,CAAC;QACD,OAAO,KAAK,CAAA;IACd,CAAC;IAED,mEAAmE;IACnE,aAAa,CAAC,IAAY;QACxB,OAAO,wBAAwB,CAAC,IAAI,CAAC,KAAK,SAAS,CAAA;IACrD,CAAC;IAED,2CAA2C;IAC3C,QAAQ,CAAC,IAAY;QACnB,IAAI,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,IAAI,KAAK,MAAM;YAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;QAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACtB,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;YACrC,OAAO,IAAI,GAAG,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;QACjG,CAAC;QACD,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;QAC9F,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAChC,CAAC;IAED;;;;;;OAMG;IACH,gBAAgB;QACd,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAA;IACtB,CAAC;IAED,wEAAwE;IACxE,IAAI;QACF,IAAI,CAAC,UAAU,EAAE,CAAA;QACjB,IAAI,CAAC,eAAe,EAAE,CAAA;QACtB,IAAI,CAAC,SAAS,GAAG,EAAE,CAAA;QACnB,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAA;IACnB,CAAC;IAEO,UAAU;QAChB,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS;YAAE,YAAY,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QAC9D,IAAI,CAAC,SAAS,GAAG,SAAS,CAAA;IAC5B,CAAC;IAED;;;;;;;;OAQG;IACK,WAAW,CAAC,OAAe;QACjC,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;YACvB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;YACxC,OAAM;QACR,CAAC;QACD,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;YACxB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAA;YACpD,OAAM;QACR,CAAC;QACD,IAAI,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,OAAM;QACtE,IAAI,OAAO,KAAK,EAAE;YAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAA;IAC9E,CAAC;CACF"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The InputRouter: one unit in, exactly one owner out.
|
|
3
|
+
*
|
|
4
|
+
* W-1 taught the TUI *what arrived* (`InputFrame`). This module answers the other
|
|
5
|
+
* question — *who takes it* — and it is the only place that may. The four owners
|
|
6
|
+
* (Screen, Surface, Composer, Workspace) each have an adapter in `tui.ts`; an
|
|
7
|
+
* adapter may read its own state, but it may not decide cross-owner ownership
|
|
8
|
+
* again, and it may not hand a unit on to the next owner when it does not use it
|
|
9
|
+
* (AD-20 §4.3, §4.5 F-1/F-6).
|
|
10
|
+
*
|
|
11
|
+
* The decision is a pure function of `(unit kind, unit, ownership snapshot)`: no
|
|
12
|
+
* app state is read or written, nothing is painted, no transcript row is pushed,
|
|
13
|
+
* and the snapshot is a plain object the caller keeps and overwrites, so a
|
|
14
|
+
* keystroke costs no allocation (W-2 is a hot-path migration).
|
|
15
|
+
*
|
|
16
|
+
* The rules are the ones `tui.ts` already applied inline, moved here verbatim —
|
|
17
|
+
* including one asymmetry that is **recorded debt, not a decision**:
|
|
18
|
+
*
|
|
19
|
+
* 1. `Ctrl+L` is handled by the Screen when a Screen is up and by the Composer
|
|
20
|
+
* otherwise; the repaint has two implementations and W-2 does not merge them
|
|
21
|
+
* (AD-20 §6: only a test that proves both owners repaint identically may).
|
|
22
|
+
* W-3b.2 owns that one.
|
|
23
|
+
*
|
|
24
|
+
* W-3b.1 closed the other asymmetry this module used to record:
|
|
25
|
+
* `SURFACE_COMPOSER_KEYS` sent `Home`/`End`/`Delete`/kitty-`Ctrl+Shift+C` to the
|
|
26
|
+
* composer while a Surface was up. The Surface owns them now, like every other unit,
|
|
27
|
+
* and its adapter decides what they mean — the field it borrowed, or nothing.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-ssh-tui/input-router
|
|
30
|
+
*/
|
|
31
|
+
const PAGE_UP = '\x1b[5~';
|
|
32
|
+
const PAGE_DOWN = '\x1b[6~';
|
|
33
|
+
const ARROW_UP = '\x1b[A';
|
|
34
|
+
const ARROW_DOWN = '\x1b[B';
|
|
35
|
+
const SGR_MOUSE = /^\x1b\[<\d+;\d+;\d+[Mm]$/u;
|
|
36
|
+
export class InputRouter {
|
|
37
|
+
/** The one cross-owner decision. One unit in, one owner out. */
|
|
38
|
+
route(kind, unit, view) {
|
|
39
|
+
// A Screen replaces the workspace and owns the keyboard outright (AD-20 §4.3,
|
|
40
|
+
// priority 3). This single line is why no Screen event can reach the composer
|
|
41
|
+
// behind it: the owners below are never consulted.
|
|
42
|
+
if (view.screenKind !== undefined)
|
|
43
|
+
return 'screen';
|
|
44
|
+
// Paste is text: the field that owns the keyboard takes it. A Screen already
|
|
45
|
+
// terminated the paste above (it does not fall through to a field behind it), and
|
|
46
|
+
// a Surface without a field refuses it in its own adapter — the decision is still
|
|
47
|
+
// "the Surface", never the composer behind it (AD-20 §4.3, priority 1).
|
|
48
|
+
if (kind === 'paste')
|
|
49
|
+
return view.surfaceKind !== undefined ? 'surface' : 'composer';
|
|
50
|
+
// A Surface owns everything while it is up — including the composer's own editing
|
|
51
|
+
// sequences: `Home`/`End`/`Delete` and the kitty copy key are the Surface's units
|
|
52
|
+
// since W-3b.1, and its adapter edits the field it borrowed or drops them. The
|
|
53
|
+
// composer behind it is not the field on screen, so nothing here hands a unit to it
|
|
54
|
+
// by default.
|
|
55
|
+
if (view.surfaceKind !== undefined) {
|
|
56
|
+
if (kind === 'control' && unit === '\x0c')
|
|
57
|
+
return 'composer';
|
|
58
|
+
return 'surface';
|
|
59
|
+
}
|
|
60
|
+
return this.composerOrWorkspace(kind, unit, view);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* With nothing layered over the workspace, the Composer owns text and its editing
|
|
64
|
+
* keys, and the Workspace owns the transcript: the mouse, the page keys, and the
|
|
65
|
+
* focus ring — which an arrow reaches only while the field is empty and there is a
|
|
66
|
+
* card to land on (otherwise the same arrow browses history, and history is the
|
|
67
|
+
* composer's).
|
|
68
|
+
*/
|
|
69
|
+
composerOrWorkspace(kind, unit, view) {
|
|
70
|
+
if (kind !== 'sequence')
|
|
71
|
+
return 'composer';
|
|
72
|
+
if (SGR_MOUSE.test(unit))
|
|
73
|
+
return 'workspace';
|
|
74
|
+
if (unit === PAGE_UP || unit === PAGE_DOWN)
|
|
75
|
+
return 'workspace';
|
|
76
|
+
if (unit === ARROW_UP || unit === ARROW_DOWN) {
|
|
77
|
+
return view.composerEmpty && view.workspaceCards ? 'workspace' : 'composer';
|
|
78
|
+
}
|
|
79
|
+
return 'composer';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=input-router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"input-router.js","sourceRoot":"","sources":["../src/input-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAoCH,MAAM,OAAO,GAAG,SAAS,CAAA;AACzB,MAAM,SAAS,GAAG,SAAS,CAAA;AAC3B,MAAM,QAAQ,GAAG,QAAQ,CAAA;AACzB,MAAM,UAAU,GAAG,QAAQ,CAAA;AAC3B,MAAM,SAAS,GAAG,2BAA2B,CAAA;AAE7C,MAAM,OAAO,WAAW;IACtB,gEAAgE;IAChE,KAAK,CAAC,IAAc,EAAE,IAAY,EAAE,IAAuB;QACzD,8EAA8E;QAC9E,8EAA8E;QAC9E,mDAAmD;QACnD,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;YAAE,OAAO,QAAQ,CAAA;QAClD,6EAA6E;QAC7E,kFAAkF;QAClF,kFAAkF;QAClF,wEAAwE;QACxE,IAAI,IAAI,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAA;QACpF,kFAAkF;QAClF,kFAAkF;QAClF,+EAA+E;QAC/E,oFAAoF;QACpF,cAAc;QACd,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;YACnC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,MAAM;gBAAE,OAAO,UAAU,CAAA;YAC5D,OAAO,SAAS,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,mBAAmB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;IACnD,CAAC;IAED;;;;;;OAMG;IACK,mBAAmB,CAAC,IAAc,EAAE,IAAY,EAAE,IAAuB;QAC/E,IAAI,IAAI,KAAK,UAAU;YAAE,OAAO,UAAU,CAAA;QAC1C,IAAI,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,WAAW,CAAA;QAC5C,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,WAAW,CAAA;QAC9D,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YAC7C,OAAO,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,CAAA;QAC7E,CAAC;QACD,OAAO,UAAU,CAAA;IACnB,CAAC;CACF"}
|
package/lib/preset-rows.js
CHANGED
|
@@ -255,10 +255,16 @@ export function duplicatePatchRows(rows) {
|
|
|
255
255
|
}
|
|
256
256
|
return duplicates;
|
|
257
257
|
}
|
|
258
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Whether a patch already addresses one roster row, by id or by module name.
|
|
260
|
+
*
|
|
261
|
+
* Every kind counts: an override or a disable targets the row just as an insert
|
|
262
|
+
* does. A repair must leave an intentional `- id: present` / `disabled: true`
|
|
263
|
+
* alone instead of mounting the same id a second time; the later entry would
|
|
264
|
+
* either collide with it or silently undo the user's choice.
|
|
265
|
+
*/
|
|
259
266
|
export function patchNamesRow(rows, row) {
|
|
260
|
-
return rows.some(candidate =>
|
|
261
|
-
|| candidate.name === row.name);
|
|
267
|
+
return rows.some(candidate => candidate.id === row.id || candidate.name === row.name);
|
|
262
268
|
}
|
|
263
269
|
/** The roster rows a patch does not declare yet. */
|
|
264
270
|
export function missingRosterRows(rows, candidates = ROSTER_ROWS) {
|
|
@@ -287,12 +293,20 @@ function normalizeEnd(text) {
|
|
|
287
293
|
* @param missing - the roster rows to mount; defaults to the 0.1.5 set.
|
|
288
294
|
* @param generation - which host line the profile boots on.
|
|
289
295
|
* @returns the new text, or `undefined` when nothing has to change.
|
|
296
|
+
* @throws when `existing` is not valid YAML; appending to a broken patch would
|
|
297
|
+
* make it longer without making it bootable.
|
|
290
298
|
*/
|
|
291
299
|
export function planRosterRepair(existing, missing = ROSTER_ROWS, generation = 'legacy') {
|
|
292
300
|
const analysis = analyzePatch(existing);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
301
|
+
// A patch the loader cannot parse is the one case where appending a fresh
|
|
302
|
+
// block is not a repair: `/mode fix` and the first-run repair would turn a
|
|
303
|
+
// user's malformed override into a file that still cannot boot, while
|
|
304
|
+
// claiming they had been fixed. Leave it to `/doctor`, which reports the
|
|
305
|
+
// parser error, and refuse the write rather than making the file longer.
|
|
306
|
+
if (!analysis.parseable) {
|
|
307
|
+
throw new Error(`profile patch does not parse; refusing to add rows: ${analysis.error ?? 'unknown YAML error'}`);
|
|
308
|
+
}
|
|
309
|
+
const toAdd = missing.filter(row => !patchNamesRow(analysis.rows, row));
|
|
296
310
|
if (toAdd.length === 0)
|
|
297
311
|
return undefined;
|
|
298
312
|
const entry = `${rosterPatchHeader(generation)}${rosterEntriesText(toAdd)}`;
|
|
@@ -394,41 +408,73 @@ export function planDuplicateRepair(existing) {
|
|
|
394
408
|
/**
|
|
395
409
|
* Mount the roster in one profile, unless it is already composed.
|
|
396
410
|
*
|
|
397
|
-
* Idempotent: a profile whose patch already
|
|
398
|
-
* `@deepseek-ai/dsh-web-app`, for example) is left untouched.
|
|
399
|
-
*
|
|
411
|
+
* Idempotent: a profile whose patch already addresses the roster row (one
|
|
412
|
+
* bundling `@deepseek-ai/dsh-web-app`, for example) is left untouched. An
|
|
413
|
+
* existing file is backed up before the small whole-file replace; a
|
|
400
414
|
* half-written patch would only be seen by the next launch.
|
|
401
415
|
* @param home - the harness home carrying `profiles/`.
|
|
402
416
|
* @param profile - the profile to patch.
|
|
403
417
|
* @param missing - the rows to mount; defaults to the 0.1.5 set.
|
|
404
418
|
* @param generation - which host line the profile boots on.
|
|
419
|
+
* @param stamp - filename-safe backups stamp, injectable for tests.
|
|
405
420
|
* @returns `present` when the row already exists, else `written`.
|
|
421
|
+
* @throws when the existing patch cannot be read, is not valid YAML, or cannot
|
|
422
|
+
* be backed up; a file that cannot be preserved is not overwritten.
|
|
406
423
|
*/
|
|
407
|
-
export async function ensureRosterRows(home, profile, missing = ROSTER_ROWS, generation = 'legacy') {
|
|
424
|
+
export async function ensureRosterRows(home, profile, missing = ROSTER_ROWS, generation = 'legacy', stamp) {
|
|
408
425
|
const path = rosterPatchPath(home, profile);
|
|
409
|
-
let existing
|
|
426
|
+
let existing;
|
|
410
427
|
try {
|
|
411
428
|
existing = await readFile(path, 'utf8');
|
|
412
429
|
}
|
|
413
|
-
catch {
|
|
414
|
-
//
|
|
430
|
+
catch (error) {
|
|
431
|
+
// Only a missing file means "start from the template". EACCES, EISDIR,
|
|
432
|
+
// EIO and every other read failure must abort the repair: treating them as
|
|
433
|
+
// absence would overwrite a profile the process could not read.
|
|
434
|
+
if (!isEnoent(error)) {
|
|
435
|
+
throw new Error(`cannot read profile patch ${path}; refusing to repair: ${errorMessage(error)}`, { cause: error });
|
|
436
|
+
}
|
|
415
437
|
}
|
|
416
|
-
const repair = planRosterRepair(existing, missing, generation);
|
|
438
|
+
const repair = planRosterRepair(existing ?? PATCH_TEMPLATE, missing, generation);
|
|
417
439
|
if (repair === undefined)
|
|
418
440
|
return 'present';
|
|
419
|
-
|
|
420
|
-
|
|
441
|
+
if (existing === undefined) {
|
|
442
|
+
await mkdir(dirname(path), { recursive: true });
|
|
443
|
+
await writeFile(path, repair.text, 'utf8');
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
// The same rule `/doctor --fix` follows: a file the user owns is backed up
|
|
447
|
+
// before it is replaced, and a failed backup aborts the write.
|
|
448
|
+
await writePatchWithBackup(path, repair.text, stamp);
|
|
449
|
+
}
|
|
421
450
|
return 'written';
|
|
422
451
|
}
|
|
452
|
+
/** A filesystem error's code, when it has one. */
|
|
453
|
+
function errorCode(error) {
|
|
454
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
455
|
+
? String(error.code)
|
|
456
|
+
: undefined;
|
|
457
|
+
}
|
|
458
|
+
/** A human-readable message for a caught filesystem error. */
|
|
459
|
+
function errorMessage(error) {
|
|
460
|
+
return error instanceof Error ? error.message : String(error);
|
|
461
|
+
}
|
|
462
|
+
/** Whether a read failure means the file is absent, and nothing else. */
|
|
463
|
+
function isEnoent(error) {
|
|
464
|
+
return errorCode(error) === 'ENOENT';
|
|
465
|
+
}
|
|
423
466
|
/**
|
|
424
467
|
* Write a repaired patch next to a copy of the previous content.
|
|
425
468
|
*
|
|
426
|
-
* `/doctor`
|
|
427
|
-
* before anything is replaced. A
|
|
469
|
+
* `/doctor` and the automatic repair change a file the user owns, so the old
|
|
470
|
+
* text is kept beside it before anything is replaced. A source that vanished
|
|
471
|
+
* between the read and the copy needs no backup; any other backup failure
|
|
472
|
+
* aborts before the write, leaving the original untouched.
|
|
428
473
|
* @param path - the patch file to replace.
|
|
429
474
|
* @param text - the new content.
|
|
430
475
|
* @param stamp - filename-safe timestamp for the backup, injectable for tests.
|
|
431
476
|
* @returns the backup path, or `undefined` when there was nothing to back up.
|
|
477
|
+
* @throws when the backup cannot be written; the destination is not touched.
|
|
432
478
|
*/
|
|
433
479
|
export async function writePatchWithBackup(path, text, stamp = new Date().toISOString().replace(/[:.]/gu, '-')) {
|
|
434
480
|
let backup;
|
|
@@ -436,7 +482,12 @@ export async function writePatchWithBackup(path, text, stamp = new Date().toISOS
|
|
|
436
482
|
backup = `${path}.bak-${stamp}`;
|
|
437
483
|
await copyFile(path, backup);
|
|
438
484
|
}
|
|
439
|
-
catch {
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (!isEnoent(error)) {
|
|
487
|
+
throw new Error(`cannot back up profile patch ${path}; refusing to write: ${errorMessage(error)}`, { cause: error });
|
|
488
|
+
}
|
|
489
|
+
// The source disappeared after the caller read it. There is no user content
|
|
490
|
+
// left to preserve, so the repair may create the file fresh.
|
|
440
491
|
backup = undefined;
|
|
441
492
|
}
|
|
442
493
|
await mkdir(dirname(path), { recursive: true });
|