| /** | |
| * Query the terminal and await responses without timeouts. | |
| * | |
| * Terminal queries (DECRQM, DA1, OSC 11, etc.) share the stdin stream | |
| * with keyboard input. Response sequences are syntactically | |
| * distinguishable from key events, so the input parser recognizes them | |
| * and dispatches them here. | |
| * | |
| * To avoid timeouts, each query batch is terminated by a DA1 sentinel | |
| * (CSI c) — every terminal since VT100 responds to DA1, and terminals | |
| * answer queries in order. So: if your query's response arrives before | |
| * DA1's, the terminal supports it; if DA1 arrives first, it doesn't. | |
| * | |
| * Usage: | |
| * const [sync, grapheme] = await Promise.all([ | |
| * querier.send(decrqm(2026)), | |
| * querier.send(decrqm(2027)), | |
| * querier.flush(), | |
| * ]) | |
| * // sync and grapheme are DECRPM responses or undefined if unsupported | |
| */ | |
| import type { TerminalResponse } from './parse-keypress.js' | |
| import { csi } from './termio/csi.js' | |
| import { osc } from './termio/osc.js' | |
| /** A terminal query: an outbound request sequence paired with a matcher | |
| * that recognizes the expected inbound response. Built by `decrqm()`, | |
| * `oscColor()`, `kittyKeyboard()`, etc. */ | |
| export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = { | |
| /** Escape sequence to write to stdout */ | |
| request: string | |
| /** Recognizes the expected response in the inbound stream */ | |
| match: (r: TerminalResponse) => r is T | |
| } | |
| type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }> | |
| type Da1Response = Extract<TerminalResponse, { type: 'da1' }> | |
| type Da2Response = Extract<TerminalResponse, { type: 'da2' }> | |
| type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }> | |
| type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }> | |
| type OscResponse = Extract<TerminalResponse, { type: 'osc' }> | |
| type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }> | |
| // -- Query builders -- | |
| /** DECRQM: request DEC private mode status (CSI ? mode $ p). | |
| * Terminal replies with DECRPM (CSI ? mode ; status $ y) or ignores. */ | |
| export function decrqm(mode: number): TerminalQuery<DecrpmResponse> { | |
| return { | |
| request: csi(`?${mode}$p`), | |
| match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode, | |
| } | |
| } | |
| /** Primary Device Attributes query (CSI c). Every terminal answers this — | |
| * used internally by flush() as a universal sentinel. Call directly if | |
| * you want the DA1 params. */ | |
| export function da1(): TerminalQuery<Da1Response> { | |
| return { | |
| request: csi('c'), | |
| match: (r): r is Da1Response => r.type === 'da1', | |
| } | |
| } | |
| /** Secondary Device Attributes query (CSI > c). Returns terminal version. */ | |
| export function da2(): TerminalQuery<Da2Response> { | |
| return { | |
| request: csi('>c'), | |
| match: (r): r is Da2Response => r.type === 'da2', | |
| } | |
| } | |
| /** Query current Kitty keyboard protocol flags (CSI ? u). | |
| * Terminal replies with CSI ? flags u or ignores. */ | |
| export function kittyKeyboard(): TerminalQuery<KittyResponse> { | |
| return { | |
| request: csi('?u'), | |
| match: (r): r is KittyResponse => r.type === 'kittyKeyboard', | |
| } | |
| } | |
| /** DECXCPR: request cursor position with DEC-private marker (CSI ? 6 n). | |
| * Terminal replies with CSI ? row ; col R. The `?` marker is critical — | |
| * the plain DSR form (CSI 6 n → CSI row;col R) is ambiguous with | |
| * modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). */ | |
| export function cursorPosition(): TerminalQuery<CursorPosResponse> { | |
| return { | |
| request: csi('?6n'), | |
| match: (r): r is CursorPosResponse => r.type === 'cursorPosition', | |
| } | |
| } | |
| /** OSC dynamic color query (e.g. OSC 11 for bg color, OSC 10 for fg). | |
| * The `?` data slot asks the terminal to reply with the current value. */ | |
| export function oscColor(code: number): TerminalQuery<OscResponse> { | |
| return { | |
| request: osc(code, '?'), | |
| match: (r): r is OscResponse => r.type === 'osc' && r.code === code, | |
| } | |
| } | |
| /** XTVERSION: request terminal name/version (CSI > 0 q). | |
| * Terminal replies with DCS > | name ST (e.g. "xterm.js(5.5.0)") or ignores. | |
| * This survives SSH — the query goes through the pty, not the environment, | |
| * so it identifies the *client* terminal even when TERM_PROGRAM isn't | |
| * forwarded. Used to detect xterm.js for wheel-scroll compensation. */ | |
| export function xtversion(): TerminalQuery<XtversionResponse> { | |
| return { | |
| request: csi('>0q'), | |
| match: (r): r is XtversionResponse => r.type === 'xtversion', | |
| } | |
| } | |
| // -- Querier -- | |
| /** Sentinel request sequence (DA1). Kept internal; flush() writes it. */ | |
| const SENTINEL = csi('c') | |
| type Pending = | |
| | { | |
| kind: 'query' | |
| match: (r: TerminalResponse) => boolean | |
| resolve: (r: TerminalResponse | undefined) => void | |
| } | |
| | { kind: 'sentinel'; resolve: () => void } | |
| export class TerminalQuerier { | |
| /** | |
| * Interleaved queue of queries and sentinels in send order. Terminals | |
| * respond in order, so each flush() barrier only drains queries queued | |
| * before it — concurrent batches from independent callers stay isolated. | |
| */ | |
| private queue: Pending[] = [] | |
| constructor(private stdout: NodeJS.WriteStream) {} | |
| /** | |
| * Send a query and wait for its response. | |
| * | |
| * Resolves with the response when `query.match` matches an incoming | |
| * TerminalResponse, or with `undefined` when a flush() sentinel arrives | |
| * before any matching response (meaning the terminal ignored the query). | |
| * | |
| * Never rejects; never times out on its own. If you never call flush() | |
| * and the terminal doesn't respond, the promise remains pending. | |
| */ | |
| send<T extends TerminalResponse>( | |
| query: TerminalQuery<T>, | |
| ): Promise<T | undefined> { | |
| return new Promise(resolve => { | |
| this.queue.push({ | |
| kind: 'query', | |
| match: query.match, | |
| resolve: r => resolve(r as T | undefined), | |
| }) | |
| this.stdout.write(query.request) | |
| }) | |
| } | |
| /** | |
| * Send the DA1 sentinel. Resolves when DA1's response arrives. | |
| * | |
| * As a side effect, all queries still pending when DA1 arrives are | |
| * resolved with `undefined` (terminal didn't respond → doesn't support | |
| * the query). This is the barrier that makes send() timeout-free. | |
| * | |
| * Safe to call with no pending queries — still waits for a round-trip. | |
| */ | |
| flush(): Promise<void> { | |
| return new Promise(resolve => { | |
| this.queue.push({ kind: 'sentinel', resolve }) | |
| this.stdout.write(SENTINEL) | |
| }) | |
| } | |
| /** | |
| * Dispatch a response parsed from stdin. Called by App.tsx's | |
| * processKeysInBatch for every `kind: 'response'` item. | |
| * | |
| * Matching strategy: | |
| * - First, try to match a pending query (FIFO, first match wins). | |
| * This lets callers send(da1()) explicitly if they want the DA1 | |
| * params — a separate DA1 write means the terminal sends TWO DA1 | |
| * responses. The first matches the explicit query; the second | |
| * (unmatched) fires the sentinel. | |
| * - Otherwise, if this is a DA1, fire the FIRST pending sentinel: | |
| * resolve any queries queued before that sentinel with undefined | |
| * (the terminal answered DA1 without answering them → unsupported) | |
| * and signal its flush() completion. Only draining up to the first | |
| * sentinel keeps later batches intact when multiple callers have | |
| * concurrent queries in flight. | |
| * - Unsolicited responses (no match, no sentinel) are silently dropped. | |
| */ | |
| onResponse(r: TerminalResponse): void { | |
| const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r)) | |
| if (idx !== -1) { | |
| const [q] = this.queue.splice(idx, 1) | |
| if (q?.kind === 'query') q.resolve(r) | |
| return | |
| } | |
| if (r.type === 'da1') { | |
| const s = this.queue.findIndex(p => p.kind === 'sentinel') | |
| if (s === -1) return | |
| for (const p of this.queue.splice(0, s + 1)) { | |
| if (p.kind === 'query') p.resolve(undefined) | |
| else p.resolve() | |
| } | |
| } | |
| } | |
| } | |