forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathterminal-querier.ts
More file actions
212 lines (192 loc) · 7.66 KB
/
Copy pathterminal-querier.ts
File metadata and controls
212 lines (192 loc) · 7.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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()
}
}
}
}