跳转至

Deep Dive | ink/terminal-querier.ts 212 行终端能力查询器

重要性:⭐⭐⭐(让 Claude Code 知道终端"能做什么" 的关键工具) 真实位置src/ink/terminal-querier.ts212 行角色:通过 ANSI 转义码查询终端能力(颜色 / 焦点 / 鼠标 / Kitty 键盘协议等) 关联topics/ink-rendering-pipeline.mdtopics/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 —— 识别匹配响应

巧妙设计matchtype 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)

/** Sentinel request sequence (DA1). Kept internal; flush() writes it. */
const SENTINEL = csi('c')

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 哨兵的承诺

普通超时做法

const result = await Promise.race([
  fetchResponse(),
  sleep(timeoutMs).then(() => undefined)
])

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

match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode

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

传统方式

const terminal = process.env.TERM_PROGRAM  // xterm-kitty
// SSH 时 TERM_PROGRAM 是 server 端的环境变量

Claude Code 方式

const result = await send(xtversion())
// 通过 pty 询问 → 拿到客户端真实终端

关键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 优势

match: (r): r is DecrpmResponse => ...

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. 阅读清单

  1. ✅ 完整通读 src/ink/terminal-querier.ts(212 行)
  2. ✅ 读 topics/ink-rendering-pipeline.md 配合
  3. 📌 读 src/ink/parse-keypress.ts 看看 dispatcher
  4. 📌 找 terminal-querier.ts 的所有调用点(grep TerminalQuerier
  5. 📌 读 src/ink/termio/csi.tsosc.ts(csi/osc 转义码构造)

12. 练习任务

  1. TerminalQuerier 在 codebase 的所有实例化点 —— 应该 1-2 处
  2. 写测试 —— 覆盖 7 个 query 构造器 + 3 个 dispatcher 步骤
  3. 画时序图 —— "用户查询终端能力"的完整流程
  4. 思考:如果让你加一种"XTPUSH"查询(XTerm 推送协议),怎么改?