init claude-code
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user