/ ink / terminal-querier.ts
terminal-querier.ts
  1  /**
  2   * Query the terminal and await responses without timeouts.
  3   *
  4   * Terminal queries (DECRQM, DA1, OSC 11, etc.) share the stdin stream
  5   * with keyboard input. Response sequences are syntactically
  6   * distinguishable from key events, so the input parser recognizes them
  7   * and dispatches them here.
  8   *
  9   * To avoid timeouts, each query batch is terminated by a DA1 sentinel
 10   * (CSI c) — every terminal since VT100 responds to DA1, and terminals
 11   * answer queries in order. So: if your query's response arrives before
 12   * DA1's, the terminal supports it; if DA1 arrives first, it doesn't.
 13   *
 14   * Usage:
 15   *   const [sync, grapheme] = await Promise.all([
 16   *     querier.send(decrqm(2026)),
 17   *     querier.send(decrqm(2027)),
 18   *     querier.flush(),
 19   *   ])
 20   *   // sync and grapheme are DECRPM responses or undefined if unsupported
 21   */
 22  
 23  import type { TerminalResponse } from './parse-keypress.js'
 24  import { csi } from './termio/csi.js'
 25  import { osc } from './termio/osc.js'
 26  
 27  /** A terminal query: an outbound request sequence paired with a matcher
 28   *  that recognizes the expected inbound response. Built by `decrqm()`,
 29   *  `oscColor()`, `kittyKeyboard()`, etc. */
 30  export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = {
 31    /** Escape sequence to write to stdout */
 32    request: string
 33    /** Recognizes the expected response in the inbound stream */
 34    match: (r: TerminalResponse) => r is T
 35  }
 36  
 37  type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }>
 38  type Da1Response = Extract<TerminalResponse, { type: 'da1' }>
 39  type Da2Response = Extract<TerminalResponse, { type: 'da2' }>
 40  type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }>
 41  type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }>
 42  type OscResponse = Extract<TerminalResponse, { type: 'osc' }>
 43  type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }>
 44  
 45  // -- Query builders --
 46  
 47  /** DECRQM: request DEC private mode status (CSI ? mode $ p).
 48   *  Terminal replies with DECRPM (CSI ? mode ; status $ y) or ignores. */
 49  export function decrqm(mode: number): TerminalQuery<DecrpmResponse> {
 50    return {
 51      request: csi(`?${mode}$p`),
 52      match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode,
 53    }
 54  }
 55  
 56  /** Primary Device Attributes query (CSI c). Every terminal answers this —
 57   *  used internally by flush() as a universal sentinel. Call directly if
 58   *  you want the DA1 params. */
 59  export function da1(): TerminalQuery<Da1Response> {
 60    return {
 61      request: csi('c'),
 62      match: (r): r is Da1Response => r.type === 'da1',
 63    }
 64  }
 65  
 66  /** Secondary Device Attributes query (CSI > c). Returns terminal version. */
 67  export function da2(): TerminalQuery<Da2Response> {
 68    return {
 69      request: csi('>c'),
 70      match: (r): r is Da2Response => r.type === 'da2',
 71    }
 72  }
 73  
 74  /** Query current Kitty keyboard protocol flags (CSI ? u).
 75   *  Terminal replies with CSI ? flags u or ignores. */
 76  export function kittyKeyboard(): TerminalQuery<KittyResponse> {
 77    return {
 78      request: csi('?u'),
 79      match: (r): r is KittyResponse => r.type === 'kittyKeyboard',
 80    }
 81  }
 82  
 83  /** DECXCPR: request cursor position with DEC-private marker (CSI ? 6 n).
 84   *  Terminal replies with CSI ? row ; col R. The `?` marker is critical —
 85   *  the plain DSR form (CSI 6 n → CSI row;col R) is ambiguous with
 86   *  modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). */
 87  export function cursorPosition(): TerminalQuery<CursorPosResponse> {
 88    return {
 89      request: csi('?6n'),
 90      match: (r): r is CursorPosResponse => r.type === 'cursorPosition',
 91    }
 92  }
 93  
 94  /** OSC dynamic color query (e.g. OSC 11 for bg color, OSC 10 for fg).
 95   *  The `?` data slot asks the terminal to reply with the current value. */
 96  export function oscColor(code: number): TerminalQuery<OscResponse> {
 97    return {
 98      request: osc(code, '?'),
 99      match: (r): r is OscResponse => r.type === 'osc' && r.code === code,
100    }
101  }
102  
103  /** XTVERSION: request terminal name/version (CSI > 0 q).
104   *  Terminal replies with DCS > | name ST (e.g. "xterm.js(5.5.0)") or ignores.
105   *  This survives SSH — the query goes through the pty, not the environment,
106   *  so it identifies the *client* terminal even when TERM_PROGRAM isn't
107   *  forwarded. Used to detect xterm.js for wheel-scroll compensation. */
108  export function xtversion(): TerminalQuery<XtversionResponse> {
109    return {
110      request: csi('>0q'),
111      match: (r): r is XtversionResponse => r.type === 'xtversion',
112    }
113  }
114  
115  // -- Querier --
116  
117  /** Sentinel request sequence (DA1). Kept internal; flush() writes it. */
118  const SENTINEL = csi('c')
119  
120  type Pending =
121    | {
122        kind: 'query'
123        match: (r: TerminalResponse) => boolean
124        resolve: (r: TerminalResponse | undefined) => void
125      }
126    | { kind: 'sentinel'; resolve: () => void }
127  
128  export class TerminalQuerier {
129    /**
130     * Interleaved queue of queries and sentinels in send order. Terminals
131     * respond in order, so each flush() barrier only drains queries queued
132     * before it — concurrent batches from independent callers stay isolated.
133     */
134    private queue: Pending[] = []
135  
136    constructor(private stdout: NodeJS.WriteStream) {}
137  
138    /**
139     * Send a query and wait for its response.
140     *
141     * Resolves with the response when `query.match` matches an incoming
142     * TerminalResponse, or with `undefined` when a flush() sentinel arrives
143     * before any matching response (meaning the terminal ignored the query).
144     *
145     * Never rejects; never times out on its own. If you never call flush()
146     * and the terminal doesn't respond, the promise remains pending.
147     */
148    send<T extends TerminalResponse>(
149      query: TerminalQuery<T>,
150    ): Promise<T | undefined> {
151      return new Promise(resolve => {
152        this.queue.push({
153          kind: 'query',
154          match: query.match,
155          resolve: r => resolve(r as T | undefined),
156        })
157        this.stdout.write(query.request)
158      })
159    }
160  
161    /**
162     * Send the DA1 sentinel. Resolves when DA1's response arrives.
163     *
164     * As a side effect, all queries still pending when DA1 arrives are
165     * resolved with `undefined` (terminal didn't respond → doesn't support
166     * the query). This is the barrier that makes send() timeout-free.
167     *
168     * Safe to call with no pending queries — still waits for a round-trip.
169     */
170    flush(): Promise<void> {
171      return new Promise(resolve => {
172        this.queue.push({ kind: 'sentinel', resolve })
173        this.stdout.write(SENTINEL)
174      })
175    }
176  
177    /**
178     * Dispatch a response parsed from stdin. Called by App.tsx's
179     * processKeysInBatch for every `kind: 'response'` item.
180     *
181     * Matching strategy:
182     * - First, try to match a pending query (FIFO, first match wins).
183     *   This lets callers send(da1()) explicitly if they want the DA1
184     *   params — a separate DA1 write means the terminal sends TWO DA1
185     *   responses. The first matches the explicit query; the second
186     *   (unmatched) fires the sentinel.
187     * - Otherwise, if this is a DA1, fire the FIRST pending sentinel:
188     *   resolve any queries queued before that sentinel with undefined
189     *   (the terminal answered DA1 without answering them → unsupported)
190     *   and signal its flush() completion. Only draining up to the first
191     *   sentinel keeps later batches intact when multiple callers have
192     *   concurrent queries in flight.
193     * - Unsolicited responses (no match, no sentinel) are silently dropped.
194     */
195    onResponse(r: TerminalResponse): void {
196      const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r))
197      if (idx !== -1) {
198        const [q] = this.queue.splice(idx, 1)
199        if (q?.kind === 'query') q.resolve(r)
200        return
201      }
202  
203      if (r.type === 'da1') {
204        const s = this.queue.findIndex(p => p.kind === 'sentinel')
205        if (s === -1) return
206        for (const p of this.queue.splice(0, s + 1)) {
207          if (p.kind === 'query') p.resolve(undefined)
208          else p.resolve()
209        }
210      }
211    }
212  }