/** * Incremental ANSI paint, SSH cadence, hangup signals, and sliding windows. * * Kept off `tui.ts` so the launch picker can dirty-paint without loading * the agent-backed SshTui class. */ /** Give a running turn this long to settle after cancel before we flush anyway. */ export declare const HANGUP_CANCEL_TIMEOUT_MS = 10000; export type PaintLinkKind = 'local' | 'ssh'; /** In-app dialog / slash-suggestion page size. Launch picker uses 9 of its own. */ export declare const PICKER_WINDOW = 12; /** * Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH * sessions pick a tier from a measured round-trip (CSI 6n), falling back to * 160 ms when the probe is missing. */ export declare function resolvePaintIntervalMs(configured?: number, env?: NodeJS.ProcessEnv, options?: { ssh?: boolean; rttMs?: number; }): number; /** True when this process is attached to an SSH session (jump host / proxy). */ export { detectSshSession } from './terminal-input.js'; /** Node errno on a write/close that means the TTY is gone (SSH drop, HUP). */ export declare function isHangupErrno(error: unknown): boolean; /** * Replace launcher SIGTERM/SIGINT/SIGHUP handlers with `handler`. SSH drop * otherwise lets `dsh` dispose the whole tree before this plugin can detach. */ export declare function captureHangupSignals(handler: () => void): void; export declare function releaseHangupSignals(handler: () => void): void; /** After detach, extra HUP/TERM from sshd must not kill the leftover Host. */ export declare function ignoreFurtherHangupSignals(): void; /** * Wait until `isIdle` is true or `timeoutMs` elapses. Used after cancel so a * hangup can flush a settled session log instead of tearing a live write. */ export declare function waitUntilIdleOrTimeout(isIdle: () => boolean, timeoutMs: number, now?: () => number, wait?: (ms: number) => Promise): Promise<'idle' | 'timeout'>; /** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */ export declare function paintIntervalForRtt(rttMs: number | undefined): number; /** * Where the paint cadence came from: a measured round trip, an explicit * `DSH_TUI_PAINT_MS`, a local TTY's constant, or a probe that never answered. */ export type PaintCadenceSource = 'measured' | 'configured' | 'local' | 'unprobed'; /** The redraw budget a drag assumes on a link whose round trip was never measured. */ export declare const RESIZE_UNKNOWN_LINK_BUDGET_MS = 200; /** * How long one frame takes to reach the terminal, for pacing a drag. * * Two of the four sources are an *answer*: `paintIntervalForRtt` already turned a * measured round trip into a cadence, and an explicit `DSH_TUI_PAINT_MS` is the * user's own. The other two are not, and they are not the same kind of guess: * * - a local TTY's cadence is a constant about a link that is not a link, so it is * used as it stands; * - an **unprobed** link's interval is the SSH default — a number nobody measured * — and pacing frames by it composes them faster than the link can deliver them, * which queues stale geometries behind each other and reads as a drag that * freezes and jumps. That case gets a deliberately slow budget instead. * * Nothing here claims a round trip: the status row keeps painting `SSH ○○○○ 160ms` * when the probe never answered, and this budget stays an internal pacing floor. * @param link - which kind of link the session is on. * @param source - where the cadence came from. * @param intervalMs - the cadence in force. * @returns milliseconds one frame needs, at least. */ export declare function linkRedrawBudgetMs(link: PaintLinkKind, source: PaintCadenceSource, intervalMs: number): number; /** * How many measurements the link's reported round-trip is taken over. * * Three is the smallest window where one outlier cannot move the answer and two * agreeing measurements can. */ export declare const RTT_HISTORY = 3; /** * The link's current round-trip, from the last few measurements. * * The median — not the latest, not the mean. The probe shares the wire with the * paint it is measuring, so a single burst can read like an overloaded link, and * because the cadence *and* the per-frame byte budget follow this number, one * bad measurement used to sit on the footer chip (and in the paint budget) for * the rest of the session. A median ignores one outlier, follows a real change * as soon as two measurements agree, and averages the pair in between so the * chip does not jump. * @param samples - measured round-trips, oldest first; non-finite ones ignored. * @returns the median in whole milliseconds, or undefined with nothing to go on. */ export declare function medianRtt(samples: readonly number[]): number | undefined; export declare function paintLinkLabel(kind: PaintLinkKind, intervalMs: number, probed: boolean): string; export type LinkQuality = 'local' | 'good' | 'ok' | 'slow' | 'poor' | 'unknown'; /** Signal-bar quality from a measured SSH round-trip, or local TTY. */ export declare function linkQualityOf(kind: PaintLinkKind, rttMs: number | undefined): LinkQuality; /** How many filled signal pips: 4 local/fast, 3 ok, 2 slow, 1 poor, 0 unknown. */ export declare function linkSignalPips(quality: LinkQuality): number; /** * How many bytes one frame may carry before the rest waits for the next tick. * * A 100x30 full repaint costs about 3.7 kB, which on a 400ms link is a third of * a second of serialization the user waits through before anything moves. The * cadence already follows the measured RTT; the budget bounds what each of * those ticks may spend, so a resize or a burst of streaming output degrades * into a few ordered frames instead of one long freeze and a queue behind it. */ export declare const FRAME_BYTE_BUDGETS: Record; /** The per-frame byte budget for a link quality. */ export declare function frameByteBudget(quality: LinkQuality): number; /** * How many body lines an expanded card may show before it points at the * overlay, by link quality. * * A measured slow link is the only case that truncates: there, every body line * is bytes the user waits for, and the overlay is one key away (`Enter`). Fast * and unmeasured links keep the behavior 0.3.9 settled on — an expanded body * shows in full, or opens in the overlay when it cannot fit the workspace — so * nothing changes for a local terminal or a link whose RTT could not be probed. */ export declare const TOOL_BODY_LINES_BY_QUALITY: Record; /** The body-line cap for a link quality; `Infinity` means "show it all". */ export declare function toolBodyLineLimit(quality: LinkQuality): number; /** Compact footer chip: `SSH ●●●○ 90ms` — 1 pip red, 2 yellow, 3+ green. */ export declare function formatLinkQualityChip(kind: PaintLinkKind, intervalMs: number, rttMs: number | undefined, probed: boolean, color?: boolean): string; export interface PaintOptions { width: number; height: number; paintRows: readonly string[]; previousRows: readonly string[]; sizeChanged: boolean; chromeChanged: boolean; chromeStart: number; previousChromeStart?: number; cursorRow: number; cursorColumn: number; /** Keep the cursor hidden (session picker). Default shows it on the input. */ hideCursor?: boolean; /** * Never touch a row above this index, even when the size changed. * * A window drag repaints the whole screen on every event, and a frame's size is * proportional to the width, so the *growing* direction is the expensive one: * each frame is bigger than the last while the terminal drains at a fixed rate, * the backlog compounds, and the screen keeps moving after the pointer has * stopped (measured against a byte-rate-limited consumer: 30–44 ms of leftover * drain growing, against 0–10 ms shrinking). The transcript is not what that * frame is needed for — the terminal reflows the grid it already holds — so a * drag paints the chrome and leaves the transcript to the terminal: a frame * about 1/6 the size, flat in the column count. `paintTailBudget` already * decides when the transcript may be left behind; this is the frame's half of * the same decision. * * A frame with `dirtyFrom` never clears the screen: `\x1b[H\x1b[J` is precisely * what it must not do. */ dirtyFrom?: number; } export interface PaintFrame { /** The single stdout write for this frame. */ output: string; /** Row indices actually written, ascending. Only these are now up to date. */ painted: number[]; /** Rows that needed a paint but did not fit the byte budget. */ deferred: number[]; /** Highest deferred row, to pass back as `from` on the next frame. */ resume: number | undefined; /** Bytes this frame carried, for the ledger `/diag` and the tests read. */ bytes: number; } /** * Which dirty rows to paint first when a byte budget applies. * * Rows at or below `from` were deferred by the previous frame and go first so * they cannot starve; everything above is the tail the user is looking at, * painted newest-row-first so the input area is current even when the budget * runs out. Without a `from`, the order is simply tail-first. * @param dirty - row indices that need a paint, ascending. * @param from - highest row the previous frame deferred, if any. * @returns indices in paint order. */ export declare function paintOrder(dirty: readonly number[], from?: number): number[]; /** * Compose one incremental paint, optionally bounded by a byte budget. * * Every row is addressed absolutely, so the paint order is free: with a budget * the frame spends it on the tail first and reports what it could not reach, so * the caller keeps those rows dirty instead of forgetting them. * @param options - the frame to paint, plus the budget and resume point. * @returns the write, what it covered, and what is left for the next tick. */ export declare function composePaintFrame(options: PaintOptions & { maxBytes?: number; from?: number; }): PaintFrame; /** * The snapshot for the next frame: only the rows a frame painted become clean. * * A deferred row has to keep its previous content in the snapshot, or the next * frame would compare it against itself, call it unchanged, and never paint it: * the row would simply stay wrong. The array also keeps its old length while * anything is owed, so the "row count changed" clear keeps firing until the * frame is complete. * @param previous - the snapshot the frame painted against. * @param current - the rows the frame wanted to show. * @param painted - indices the frame actually wrote. * @returns the snapshot to hand to the next frame. */ export declare function advancePaintedRows(previous: readonly string[], current: readonly string[], painted: readonly number[]): string[]; /** One incremental paint as a single stdout write (one SSH packet when corked). */ export declare function composePaintOutput(options: PaintOptions): string; /** Whether `text` could still grow into a recognized escape sequence. */ export declare function isEscapePrefix(text: string): boolean; /** Parse a Device Status Report cursor reply (`CSI row;col R`). */ export declare function parseCursorPositionReply(text: string): { row: number; column: number; } | undefined; /** * Find a cursor reply inside a noisy buffer. * * The anchored parse above only works when the CPR is the whole chunk. Real * terminals interleave it with focus events, mouse reports, bracketed-paste * marks or keystrokes that raced the probe, and an anchored match then fails * until the probe times out — which is how the footer's link chip ends up * stuck on four hollow circles (`SSH ○○○○`) for a whole session. */ export declare function findCursorPositionReply(text: string): { row: number; column: number; } | undefined; /** * Round-trip to the attached terminal via CSI 6n. Returns undefined when the * reply never arrives (dumb pipe, blocked DSR). Does not interpret the * coordinates — only the elapsed milliseconds matter. * * Sampling and plausibility live in `terminal-input.ts`: a single request used * to accept a previous probe's answer (~2 ms) and to report a repaint queue as * the link (~1900 ms on a 50 ms SSH line). */ export declare function probeTerminalRttMs(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream, timeoutMs?: number, ssh?: boolean): Promise; /** Sliding window of `windowSize` items that keeps `cursor` visible. */ export declare function pickerWindowStart(cursor: number, total: number, windowSize?: number): number; /** * Immediate first-frame chrome so a 2–3s Host boot is not a blank TTY. * * Line mode gets nothing: a splash enters the alternate screen and paints over * itself, which is exactly what that mode exists to avoid — the log would open * with screen control instead of the session's first event. * @param message - what the launcher is waiting for. * @param color - whether colour is allowed. */ export declare function writeBootSplash(message: string, color?: boolean): void;