source dump of claude code
at main 212 lines 7.8 kB view raw
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 23import type { TerminalResponse } from './parse-keypress.js' 24import { csi } from './termio/csi.js' 25import { 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. */ 30export 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 37type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }> 38type Da1Response = Extract<TerminalResponse, { type: 'da1' }> 39type Da2Response = Extract<TerminalResponse, { type: 'da2' }> 40type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }> 41type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }> 42type OscResponse = Extract<TerminalResponse, { type: 'osc' }> 43type 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. */ 49export 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. */ 59export 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. */ 67export 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. */ 76export 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.). */ 87export 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. */ 96export 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. */ 108export 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. */ 118const SENTINEL = csi('c') 119 120type Pending = 121 | { 122 kind: 'query' 123 match: (r: TerminalResponse) => boolean 124 resolve: (r: TerminalResponse | undefined) => void 125 } 126 | { kind: 'sentinel'; resolve: () => void } 127 128export 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}