/** True when this process is talking to a terminal through SSH. */ export declare function detectSshSession(env?: NodeJS.ProcessEnv): boolean; /** What a probe writes to ask the terminal where its cursor is. */ export declare const CURSOR_POSITION_REQUEST = "\u001B[6n"; /** * How long a half-arrived reply is held before it is handed over as typing. * A real sequence arrives within one read or the next; anything slower is * either a very unlucky split or the start of a user's own escape key. */ export declare const INPUT_HOLD_MS = 30; /** A single probe answer that misses this window counts as "no reply". */ export declare const RTT_SAMPLE_TIMEOUT_MS = 350; /** Wider window for the rest of the measurement once one has been missed. */ export declare const RTT_SLOW_SAMPLE_TIMEOUT_MS = 800; /** Answers to collect before one is reported. */ export declare const RTT_SAMPLE_COUNT = 3; /** Two answers this close already describe the link; the third is not needed. */ export declare const RTT_AGREEMENT_MS = 6; /** Wall-clock ceiling for one measurement, however chatty the terminal is. */ export declare const RTT_MEASURE_BUDGET_MS = 2500; /** * An SSH answer that came back this much faster than the slowest sample cannot * have made the round trip — it was already on the wire when we asked. Relative * on purpose: `ssh localhost` honestly answers in about 2 ms. */ export declare const RTT_OUTLIER_RATIO = 0.25; /** Remove every complete cursor reply from `text`. */ export declare function stripCursorReplies(text: string): { text: string; replies: number; last?: string; }; /** * Drop cursor replies from a byte stream that is otherwise user input. * * Feed every read to `push()`; the bytes it returns are what the user actually * typed. A read that ends in the middle of a possible reply is held back (see * `flush()`) so `ESC [ 1` + `7;1R` cannot become visible digits either. */ export interface TerminalInputFilterOptions { /** * Whether a read is bracketed-paste traffic. * * The filter asks instead of sniffing the markers itself: paste framing has one * owner (the Reader), and a marker literal here would be a second one. It matters * because a paste's close marker ends in `ESC[20` — the shape of a half-arrived * cursor reply — so holding that tail cuts the marker in half and the Reader stays * inside the paste for the rest of the session. */ isPasteTraffic?: (text: string) => boolean; } export declare class TerminalInputFilter { private readonly options; private held; /** The most recent cursor reply this filter swallowed, if any. */ lastReply: string | undefined; constructor(options?: TerminalInputFilterOptions); push(text: string): { forward: string; replies: number; }; /** Release a held partial (its window passed, so it was not a reply). */ flush(): string; /** Forget a held partial — used when the caller knows it must be stale. */ reset(): void; get pending(): boolean; } /** * The filter with the hold timer attached, for a caller that turns bytes into * keystrokes (the TUI and the session picker) instead of forwarding them. */ export declare class TerminalInputGuard { private readonly emit; private readonly holdMs; private readonly filter; private timer; constructor(emit: (text: string) => void, holdMs?: number, options?: TerminalInputFilterOptions); /** Feed one decoded read; anything that is not a reply reaches `emit`. */ push(text: string): void; /** Hand a half-arrived sequence over (its window passed; it is typing). */ release(): void; /** Drop the timer and any held bytes (display detached, picker settled). */ stop(): void; } export interface TerminalInputPumpOptions { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream; /** Receives bytes that are real typing, never probe answers. */ onInput: (text: string) => void; /** Link kind; defaults to the SSH environment of this process. */ ssh?: boolean; holdMs?: number; sampleTimeoutMs?: number; samples?: number; /** Give up sampling after this long; the attach must not wait forever. */ budgetMs?: number; /** One line per probe attempt, for `DSH_TUI_DEBUG=1` troubleshooting. */ debug?: (message: string) => void; } /** * stdin → replies (for the probe) + real input (for the caller). * * Long-lived on purpose: the relay keeps it running for the whole attachment, * so a reply that arrives seconds late is dropped instead of reaching the Host. */ export declare class TerminalInputPump { private readonly options; private readonly filter; private readonly decoder; private readonly waiters; /** When the last answer (accepted or not) arrived; drives the quiet wait. */ private lastAnswerAt; /** When the last request went out; an answer may follow it for a window. */ private lastRequestAt; /** Current answer window; widened once when the first one is missed. */ private windowMs; /** Answers seen, used to tell "slow terminal" from "terminal never answers". */ private answersSeen; /** Slowest sample so far; the quiet wait has to clear the worst of them. */ private slowestSampleMs; private holdTimer; private listening; /** Set by {@link quiet}: the held tail is dropped instead of forwarded. */ private dropping; readonly ssh: boolean; constructor(options: TerminalInputPumpOptions); start(): void; stop(): void; /** * Wait one more round trip before letting go of the terminal. * * A cursor reply can still be on the wire when a relay stops. The terminal * answers a Device Status Report after the round trip, so on a slow SSH link * that is hundreds of milliseconds after the request; restoring cooked mode * first lets the tty echo the answer as literal `^[[25;1R` text at the user's * prompt, and leaves it in the tty queue for the shell to read as typing. * Staying in raw mode for one round trip swallows it instead. Typing that * lands in this window is forwarded as usual — the window exists to drop * answers, not keystrokes. * @param graceMs - how long the terminal may still owe us a reply. */ handBack(graceMs?: number): Promise; /** * Ask the terminal for its cursor a few times and report the round-trip that * best describes the link. `undefined` means the terminal never answered in * time (a dumb pipe), and the caller must not treat that as "0 ms". */ measure(): Promise; /** * Drop an answer that came back far too fast to have made the round trip — * an answer to somebody else's request, still on the wire when we asked. * Relative, not absolute (`ssh localhost` honestly answers in 2 ms), and * measured against the *median*: comparing with the slowest sample would * throw away three good answers because one request sat behind a repaint. */ private dropFastOutliers; /** * How long the line must be quiet before the next request may go out. * * The only safe answer is the full sample window: a request we sent can be * answered at any point inside it, so anything shorter can attribute that * answer to the request that follows. Deriving it from the samples instead * looks cheaper and is exactly what goes wrong — a leftover answer reports * 1 ms, the window shrinks to the minimum, and the honest answer to the * abandoned request lands in the *next* window (250 ms link reported as 1 ms * and 0 ms, two "agreeing" samples, early stop, chip frozen on a wrong value). */ private quietMs; /** * One request, one window: what a display-liveness check needs. * * `measure()` deliberately is not this. It waits out its whole budget between * attempts so a late answer cannot be read as a fast one, and a check that * costs seconds per question is a picker that hangs. Reading a late answer as * this window's is the harmless direction here: it reports "the terminal is * there", which is the conservative answer for every caller. */ measureOnce(timeoutMs?: number): Promise; /** * Wait until the line has been quiet for a full window, measured from the * later of the last answer and the last request. An answer to a request we * already gave up on lands in here and is thrown away, so it cannot be * attributed to the request that follows it. */ private quiet; /** * Consume what the kernel already holds, so an answer that was on its way * before this request cannot be read as its answer. * * `read()` emits `data` for the chunk it returns whenever a listener is * attached, and the listener path would then deliver the same bytes twice * (a typed `hi` arrived at the Host as `hihi`). Detach for the duration of * the loop: it is synchronous, so nothing can be lost in between. */ private drain; private ask; private readonly onData; private feed; /** * One reply: hand it to the oldest outstanding request. An answer that * belongs to a request we already gave up on is simply dropped here — and * its arrival time still restarts the quiet window, which is what keeps it * from being attributed to the next request. */ private answer; /** * Ask the terminal for its cursor after printing `probe`, and report where it * says the cursor ended up. * * This is the measurement half of the pump: `measure()` answers "how long did * the round trip take", this answers "how far did the cursor move". Replies are * swallowed by the same filter that swallows them for the timing probe — which * matters, because a reply nobody consumes is echoed on screen as literal * `^[[1;5R` text, and that is exactly what a hand-rolled version of this did. * @param probe - the text to print before asking (cleared afterwards). * @param timeoutMs - how long to wait for the answer. * @returns the coordinates the terminal reported, or undefined when it stayed * silent. */ askPosition(probe: string, timeoutMs?: number): Promise<{ row: number; column: number; } | undefined>; private scheduleHold; }