Deep Dive | ink/terminal-querier.ts 212 行终端能力查询器¶
重要性:⭐⭐⭐(让 Claude Code 知道终端"能做什么" 的关键工具) 真实位置:
src/ink/terminal-querier.ts(212 行) 角色:通过 ANSI 转义码查询终端能力(颜色 / 焦点 / 鼠标 / Kitty 键盘协议等) 关联:topics/ink-rendering-pipeline.md、topics/keybindings-system.md
1. 问题背景:为什么要查终端能力¶
终端不是统一的。每个终端模拟器(iTerm2、Terminal.app、Windows Terminal、xterm、VSCode integrated terminal)支持的功能不同。
| 能力 | 哪些终端支持 |
|---|---|
| 16 颜色 | 几乎所有 |
| 256 颜色 | 大多数现代终端 |
| True color (24-bit) | iTerm2 / Windows Terminal / VSCode 等 |
| 鼠标事件 | 几乎所有 |
| 焦点事件 | 多数支持 |
| Kitty keyboard protocol | Kitty / WezTerm / ghostty |
| 同步输出(BSU) | iTerm2 / WezTerm |
| Sixel 图形 | xterm / mlterm |
| 终端类型查询 (XTVERSION) | 大多数现代 |
Claude Code 怎么知道某个终端支持什么? —— 问它(用 ANSI 转义码)。
2. 文件结构总览¶
terminal-querier.ts (212 行)
│
├── 行 1-21 :文件头注释(关键设计解释)
├── 行 22-25 :imports
│
├── A. 类型定义(行 27-50)
│ ├── TerminalQuery 泛型
│ ├── 6 种 Response 类型别名
│
├── B. Query 构造器(行 53-128)
│ ├── decrqm() (行 53-62) DECRQM (DEC private mode status)
│ ├── da1() (行 65-72) Primary DA
│ ├── da2() (行 75-82) Secondary DA
│ ├── kittyKeyboard() (行 85-92) Kitty 键盘协议
│ ├── cursorPosition() (行 95-103) DECXCPR (光标位置)
│ ├── oscColor() (行 106-113) OSC 动态颜色查询
│ ├── xtversion() (行 116-128) XTVERSION (终端版本)
│
├── C. Querier 内部类型(行 131-150)
│ ├── SENTINEL 常量 (DA1) (行 132-135)
│ ├── Pending 类型 (行 137-145)
│
└── D. TerminalQuerier 类(行 148-212)
├── queue: Pending[] (行 154-155)
├── constructor(stdout) (行 157-159)
├── send(query) (行 174-185) Promise<T | undefined>
├── flush() (行 197-205) Promise<void>
└── onResponse(r) (行 212+) dispatcher
212 行 = 完整 ANSI 终端能力查询系统。
3. 文件头注释(行 1-21)—— 关键设计¶
/**
* 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
*/
关键洞察 1:"避免超时" —— 用 DA1 sentinel 实现。
关键洞察 2:"响应可识别" —— 终端查询的响应和键盘事件在语法上可区分。
关键洞察 3:"in-order response" —— 终端按发送顺序响应,DA1 是"必到"的"安全屏障"。
关键洞察 4:"用 DA1 当哨兵" —— VT100 之后所有终端都支持 DA1。
4. A 段:类型定义(行 27-50)¶
4.1 TerminalQuery<T> 泛型¶
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
}
2 字段泛型:
- request: string —— 要发送的 ANSI 转义码
- match: type guard —— 识别匹配响应
巧妙设计:match 是 type guard((r) => r is T),调用方拿到响应时类型已确定。
4.2 7 种 Response 类型¶
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' }>
7 个类型别名 —— 全部用 Extract<> 从大联合类型提取。
5. B 段:7 个 Query 构造器(行 53-128)¶
5.1 decrqm(mode) (行 53-62)¶
export function decrqm(mode: number): TerminalQuery<DecrpmResponse> {
return {
request: csi(`?${mode}$p`), // CSI ? 2026 $ p
match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode,
}
}
DECRQM —— DEC 私有模式状态查询(CSI ? mode $ p)。
用途:查终端是否支持某个 DEC 私有模式(如 2026 = 同步输出,2027 = 字素簇)。
5.2 da1() (行 65-72)¶
export function da1(): TerminalQuery<Da1Response> {
return {
request: csi('c'), // CSI c
match: (r): r is Da1Response => r.type === 'da1',
}
}
DA1 —— 主设备属性查询(CSI c)。
用途:所有终端都支持,作为哨兵。
5.3 da2() (行 75-82)¶
export function da2(): TerminalQuery<Da2Response> {
return {
request: csi('>c'), // CSI > c
match: (r): r is Da2Response => r.type === 'da2',
}
}
DA2 —— 次设备属性(CSI > c),返回终端版本。
5.4 kittyKeyboard() (行 85-92)¶
export function kittyKeyboard(): TerminalQuery<KittyResponse> {
return {
request: csi('?u'), // CSI ? u
match: (r): r is KittyResponse => r.type === 'kittyKeyboard',
}
}
Kitty 键盘协议查询(CSI ? u)。
用途:检测 Kitty 终端 / WezTerm / ghostty 等支持现代键盘协议的终端。
5.5 cursorPosition() (行 95-103)¶
export function cursorPosition(): TerminalQuery<CursorPosResponse> {
return {
request: csi('?6n'), // CSI ? 6 n
match: (r): r is CursorPosResponse => r.type === 'cursorPosition',
}
}
DECXCPR —— 光标位置查询(CSI ? 6 n)。
关键注释(行 99-102):
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.).
详细解释:
- 普通 DSR(CSI 6 n)和 Shift+F3 输出完全一样
- 用 ? 标记(DEC 私有)区分
5.6 oscColor(code) (行 106-113)¶
export function oscColor(code: number): TerminalQuery<OscResponse> {
return {
request: osc(code, '?'), // OSC 11; ?
match: (r): r is OscResponse => r.type === 'osc' && r.code === code,
}
}
OSC 动态颜色查询(如 OSC 11 = 背景色,OSC 10 = 前景色)。
? 数据槽 —— 询问当前值。
5.7 xtversion() (行 116-128)¶
export function xtversion(): TerminalQuery<XtversionResponse> {
return {
request: csi('>0q'), // CSI > 0 q
match: (r): r is XtversionResponse => r.type === 'xtversion',
}
}
XTVERSION —— 终端名/版本(CSI > 0 q)。
关键注释(行 121-123):
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.
SSH 友好 —— 通过 pty 而不是环境变量,SSH 时也能识别客户端终端。
6. C 段:Querier 内部类型(行 131-150)¶
6.1 SENTINEL = csi('c') (行 132-135)¶
DA1 当哨兵 —— "必到"的查询。
6.2 Pending 类型 (行 137-145)¶
type Pending =
| {
kind: 'query'
match: (r: TerminalResponse) => boolean
resolve: (r: TerminalResponse | undefined) => void
}
| { kind: 'sentinel'; resolve: () => void }
联合类型 —— 队列里要么是 query,要么是 sentinel。
7. D 段:TerminalQuerier 类(行 148-212)¶
7.1 字段¶
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) {}
}
2 字段:
- queue: Pending[] —— 排队
- stdout: NodeJS.WriteStream —— 注入(依赖反转)
7.2 send(query) (行 174-185)¶
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)
})
}
关键设计: - 立即把 query 写入 stdout(不等待) - 返回 Promise(消费者等响应) - 永不 reject(never rejects; never times out on its own)
7.3 flush() (行 197-205)¶
flush(): Promise<void> {
return new Promise(resolve => {
this.queue.push({ kind: 'sentinel', resolve })
this.stdout.write(SENTINEL)
})
}
关键设计: - 写入 DA1 哨兵 - 返回 Promise(DA1 到达时 resolve) - 副作用:DA1 到达时,所有未响应的 query 解析为 undefined
7.4 onResponse(r) —— dispatcher (行 212+)¶
onResponse(r: TerminalResponse): void {
// 1. 尝试匹配排队中的 query(FIFO,first match wins)
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
}
// 2. 如果是 DA1(哨兵响应),解析所有早于哨兵的 query 为 undefined
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()
}
}
}
3 步逻辑: 1. 尝试匹配 query(first match wins) 2. 如果是 DA1 → 解析所有早于哨兵的 query 为 undefined 3. 其他情况 → 静默丢弃
关键:"first match wins" —— 用户可以同时 send(da1()) + 调 flush(),第一次 DA1 响应匹配显式 query,第二次触发哨兵。
8. 关键设计¶
8.1 "无超时" 是 DA1 哨兵的承诺¶
普通超时做法:
Claude Code 做法:
// 不设超时 —— 靠 DA1 哨兵保证"一定会 resolve"
const result = await send(query)
const sentinel = await flush()
// flush 到达时 = 所有 query 已 resolve
"无超时" = 不会卡住。
8.2 "In-Order Response" 是 ANSI 标准¶
终端按发送顺序响应 —— 这是 ANSI 标准。
意味着: - FIFO 队列即可 - 不需要时间戳 - 不需要 ID
ANSI 标准的好处 —— 协议简单。
8.3 "Type Guard" 让 match 是 type guard¶
TypeScript 推断 —— 调用方拿到 response 时,类型已确定。
const response = await send(decrqm(2026))
// response: DecrpmResponse | undefined
if (response) {
// response.mode 是 number(已推断)
}
8.4 "Shared stdin" 是关键设计¶
关键洞察(行 6-8):
Terminal queries 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.
挑战:查询响应和键盘事件走同一个 stdin。
解决:语法可区分(CSI 序列 vs 普通字符)。
做法:input parser(parse-keypress.ts)识别后分发到 onResponse()。
8.5 "SSH 友好" 的 xtversion¶
传统方式:
Claude Code 方式:
关键:xtversion 通过 pty 走,不依赖环境变量。
9. 实战:写一个简化版终端查询器¶
// 简化版(~30 行)
class SimpleQuerier {
constructor(private stdout) {}
async queryColor() {
return new Promise((resolve) => {
this.stdout.write('\x1b]11;?\x07') // OSC 11; ? ST
// ⚠️ 实际需要 waitForResponse + 超时
setTimeout(() => resolve(undefined), 1000)
})
}
}
对比 Claude Code: - 简化版有超时(不优雅) - 简化版没有共享 stdin 的 dispatcher - 简化版没有 type guard - Claude Code 多了 150+ 边界处理
10. 关键洞察¶
10.1 "212 行 = 完整的终端能力查询系统"¶
7 个 query 构造器 + 1 个 dispatcher 类 + 完整类型系统 = 212 行。
这是"小而精"的极致。
10.2 "DA1 哨兵"是无超时的核心¶
普通做法:用 setTimeout 超时 Claude Code 做法:用 DA1 当保证到达的响应
前者会"假超时"(终端慢但最终会响应)。 后者一定 resolve(DA1 一定响应)。
10.3 "Shared stdin" 是 ANSI 标准¶
Web 项目用 WebSocket、SSE、长轮询等专用通道。 终端只能共享 stdin。
这是终端的"约束" —— 但 ANSI 标准让它仍然优雅。
10.4 "Type Guard" 是 TypeScript 优势¶
TypeScript 推断让调用方无需 cast。
和 Web 项目一样 —— TypeScript 让"协议层"类型安全。
10.5 "SSH 友好" 是真实世界需求¶
Web 项目多在浏览器跑,终端多在 SSH 跑。
xtversion 通过 pty 而不是环境变量 —— 解决了 SSH 难题。
10.6 "详细注释" 是大型项目"必备"¶
/**
* 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.).
*/
20 行的注释解释一个 ? 的位置 —— 避免未来维护者写错。
11. 阅读清单¶
- ✅ 完整通读
src/ink/terminal-querier.ts(212 行) - ✅ 读 topics/ink-rendering-pipeline.md 配合
- 📌 读
src/ink/parse-keypress.ts看看 dispatcher - 📌 找
terminal-querier.ts的所有调用点(grepTerminalQuerier) - 📌 读
src/ink/termio/csi.ts和osc.ts(csi/osc 转义码构造)
12. 练习任务¶
- 找
TerminalQuerier在 codebase 的所有实例化点 —— 应该 1-2 处 - 写测试 —— 覆盖 7 个 query 构造器 + 3 个 dispatcher 步骤
- 画时序图 —— "用户查询终端能力"的完整流程
- 思考:如果让你加一种"XTPUSH"查询(XTerm 推送协议),怎么改?