/** * How long a read that could still grow into a sequence is held. * * The window is what makes a lone `ESC` (the reader pressed Escape) different from * the first byte of a sequence the terminal is still sending. W-1 moved the timer, * not the number: changing it changes how Escape feels, and that is a separate * decision with its own evidence. */ export declare const ESCAPE_HOLD_MS = 60; /** * One framed unit of input. * * The vocabulary is deliberately small — four kinds carry text, one carries the * released Escape, one carries a device reply — because the Reader's job is to say * *what arrived*, not what it means. `key` and `burst` reach the same routing today; * they are distinguished because the Router (W-2) has to decide "one key" versus * "several keys in one read" (the read-coalescing gaps G-1/G-2 live exactly there). */ export type InputFrame = /** One complete terminal unit: a control byte, a full CSI sequence, or a lone ESC. */ { kind: 'key'; sequence: string; } /** A read of ordinary typing. It may carry several keys — W-3 splits it. */ | { kind: 'burst'; text: string; } /** Text to be typed exactly as it is (a sequence tail, an Alt remainder, a release). */ | { kind: 'literal'; text: string; } /** The payload between paste markers. */ | { kind: 'paste'; text: string; } /** The held read turned out to be the Escape key itself. */ | { kind: 'escape'; } /** A device reply (cursor position) — input to the prober, never to an owner. */ | { kind: 'reply'; sequence: string; }; export interface InputReaderOptions { /** A read that survived decoding and reply filtering. */ onRead: (text: string) => void; /** A frame the Reader produced on its own: paste traffic, or a released hold. */ onFrame: (frame: InputFrame) => void; /** * How long a stalled paste may hold the reader before it is abandoned. * * A seam for tests only — the default is the release value. A test that waited the * real two seconds kept a whole file's process alive alongside the others and was * enough to unbalance a timer-sensitive paint test on a Windows runner. */ pasteMarkerHoldMs?: number; } /** * How long a paste may sit with half a marker before the reader gives up on it. * * This is a watchdog, not a key hold: a paste is a stream, and its close marker may * arrive in a second chunk after a slow terminal, a ConPTY or a loaded runner — on * Windows CI the gap was over 200 ms, and expiring that early typed the marker's tail * into the draft and left the session inside the paste. So the window is generous. * When it does expire the reader does not just release the tail as payload, it * **leaves the paste**: a paste that never closed must not keep the keyboard as its * payload for the rest of the session. */ export declare const PASTE_MARKER_HOLD_MS = 2000; export declare class InputReader { private readonly options; private readonly decoder; private readonly guard; /** A read that may still grow (escape assembly). */ private held; private holdTimer; /** Inside `ESC[200~ … ESC[201~`. */ private inPaste; /** * The tail of a read that ended *inside* a paste marker (`…ESC[20`), waiting for * its other half. */ private pasteTail; private pasteTailTimer; constructor(options: InputReaderOptions); /** One read of bytes: decode it, drop device replies, hand the rest on. */ push(chunk: Buffer): void; /** The read with any held prefix in front of it. The hold window is over. */ assemble(text: string): string; /** * The frames of one read, whichever kind it is. * * A read is what the terminal happened to send, not what the reader pressed: a * fast typist, a slow link or ConPTY can put several keys in one read. This is the * one place that decides where one unit ends and the next begins; everything above * it (Router, owners) sees complete units only, and nothing above it re-parses the * bytes (AD-20 §4.4, §6). */ read(text: string): InputFrame[]; /** * Frame one paste read, holding back a marker it cut in half. * * A terminal writes bytes, not reads: `ESC[201~` can arrive as `…ESC[20` and `1~`. * Framed naively, the first read swallows `ESC[20` as payload and the second one * carries no marker at all — so `inPaste` never clears and **every later key is * pasted text**: Esc stops closing a dialog, `/exit` stops exiting. Found by * `scripts/tui-paste-probe.mjs` on CI, where the pty chunked the write exactly * there. The fix is the same rule the escape ladder already uses — a read that * could still grow is held, and released as payload if its other half never comes. */ private framePaste; private armPasteTail; private cancelPasteTail; /** * One non-paste read, split into complete units. * * The rules, and why each one exists: * * - **A complete CSI/SS3 sequence is one unit.** `ESC [ 5 ~ ESC [ 5 ~` is two page * keys, not one unnameable read (G-1). A sequence the read cuts short stays one * piece: `hold()` has already claimed it if it could still grow, and a trailing * `ESC [` must reach the hold again rather than be split into `ESC` + `[`. * - **An Alt chord is one unit.** `ESC` plus exactly one printable character is * Alt+that key, so `ESC 1 x` is the chord Alt+1 *and then* `x` — never the text * `1x` (G-3), and never a chord that swallows the character behind it. * - **A control byte is one unit, CR/LF pair as one Enter.** `\x10\x10` is two * presses of a rebound key (G-2/G-8), and Windows' CRLF is one key. * - **A run of ordinary characters is one unit.** Splitting typing per character * would make a pasted `yes` answer a confirm dialog character by character, * which is the boundary W-0 pinned (`yes-typed-at-dialog`). Keymap lookup on a * whole run is still exactly what a run means: text. * - **A burst the paste heuristic claims stays whole.** A console without * bracketed paste sends a paste as ordinary bytes with line breaks in them; * that read has to reach the paste path intact (`looksLikePasteBurst`). */ units(text: string): InputFrame[]; /** * Whether a read is paste traffic: inside a paste, or carrying a marker. * * Asked before anything else, exactly as the inline check it replaces was: while * inside a paste, CR/LF are literal characters rather than submit. */ isPasteTraffic(combined: string): boolean; /** * The frames of one paste read, in order: payload inside the markers as `paste`, * everything outside them as `literal` (which is what the inline version typed * directly, without a keymap lookup). * * Only complete markers are handled here: `framePaste` holds back a read that ends * inside one, because a marker whose two halves land in two reads would otherwise * leave the reader inside the paste for the rest of the session. */ frames(combined: string): InputFrame[]; /** * Keep a read that could still grow into a sequence. * @returns true when it was held (the caller stops there), false when the read is * complete as it stands. */ hold(combined: string): boolean; /** * Whether one read is a paste from a console that cannot say so. * * A burst carrying more than one line break is not a human pressing Enter twice — * they cannot press it twice inside one read. This is the fallback's one rule, and * it lives here so every caller applies the same one. */ static looksLikePasteBurst(text: string): boolean; /** Whether a complete read is a device reply rather than input. */ isDeviceReply(text: string): boolean; /** Classify one complete read by shape. */ classify(text: string): InputFrame; /** * Hand over a held reply tail now. * * The guard releases on its own window; this is for a caller that already knows * the tail is stale — `scripts/bench-b2.mjs` drives keystrokes faster than the * hold, and a measurement that waited for the window would be measuring the wait. */ releaseReplyTail(): void; /** Drop the holds and the reply filter (display detached, teardown). */ stop(): void; private cancelHold; /** * What a held read turned out to be. * * Verbatim from the inline timer it replaces: a lone `ESC` is the Escape key, an * `ESC O` without its final byte is Alt+O, an escape sequence that never completed * is consumed silently (its `ESC` must not read as a cancel), and anything else is * typed. The last branch is unreachable for the shapes `isEscapePrefix` accepts — * it is kept because "unreachable today" is not a reason to change a release. */ private releaseHold; }