【声明】本博客所有内容均为个人业余时间创作,所述技术案例均来自公开开源项目(如Github,Apache基金会),不涉及任何企业机密或未公开技术,如有侵权请联系删除
标题
208、【Agent】【OpenCode】TUI 内部:终端背景色的获取
背景
上篇 blog
【Agent】【OpenCode】TUI 内部:18 层 Provider 的职责地图
把 18 层 Provider 归成 4 组:数据源(Args/config/SDK/Sync/KV/Local)、UI 结构(Route/Theme/Toast/Dialog/Keybind)、输入 prompt(Ref/Stash/History/Frecency/Command)、生命周期(Exit/ErrorBoundary);嵌套规则是"谁 init 里use了谁,谁就嵌在谁内部"。其中ThemeProvider属组 2(界面结构),它决定 TUI 用深色还是浅色主题——但一个前提问题悬着:dark/light 的初值mode到底从哪来?本篇展开getTerminalBackgroundColor()(app.tsx:45-103):程序如何探测终端背景色,自动定主题
OpenCode
程序读不到"终端背景色"这种 OS API——深色还是浅色只有终端自己知道。opencode 的做法是直接问终端:发一条 ANSI/OSC 序列,终端把背景色"说"出来,程序解析后按亮度判断。
app.tsx:121 调用它:const mode = await getTerminalBackgroundColor(),结果喂给 ThemeProvider。完整实现如下(app.tsx:45-103):
asyncfunctiongetTerminalBackgroundColor():Promise<"dark"|"light">{// can't set raw mode if not a TTYif(!process.stdin.isTTY)return"dark"returnnewPromise((resolve)=>{lettimeout:NodeJS.Timeoutconstcleanup=()=>{process.stdin.setRawMode(false)process.stdin.removeListener("data",handler)clearTimeout(timeout)}consthandler=(data:Buffer)=>{conststr=data.toString()constmatch=str.match(/\x1b]11;([^\x07\x1b]+)/)if(match){cleanup()constcolor=match[1]// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)letr=0,g=0,b=0if(color.startsWith("rgb:")){constparts=color.substring(4).split("/")r=parseInt(parts[0],16)>>8// 16-bit → 8-bitg=parseInt(parts[1],16)>>8b=parseInt(parts[2],16)>>8}elseif(color.startsWith("#")){r=parseInt(color.substring(1,3),16)g=parseInt(color.substring(3,5),16)b=parseInt(color.substring(5,7),16)}elseif(color.startsWith("rgb(")){constparts=color.substring(4,color.length-1).split(",")r=parseInt(parts[0]);g=parseInt(parts[1]);b=parseInt(parts[2])}constluminance=(0.299*r+0.587*g+0.114*b)/255resolve(luminance>0.5?"light":"dark")}}process.stdin.setRawMode(true)process.stdin.on("data",handler)process.stdout.write("\x1b]11;?\x07")timeout=setTimeout(()=>{cleanup();resolve("dark")},1000)})}🧩OSC 11:程序怎么"问"终端
关键一行是process.stdout.write("\x1b]11;?\x07")(app.tsx:96):
\x1b ] 11 ; ? \x07 ESC OSC 编号 参数 BELOSC 11(Operating System Command 11)就是"查询/设置终端背景色"。发?表示查询,BEL(\x07)或ESC \是序列结束符。终端收到后会回一条同格式的应答,比如:
\x1b]11;rgb:1e1e/1e1e/1e1e\x07 (XTerm 标准:每通道 16 位,/ 分隔) \x1b]11;#1e1e1e\x07 (有些终端回 hex)🔬底层背景:ANSI 控制序列家族
\x1b]11;...不是孤例,它归属于 ANSI 转义序列这个体系。凡是以 ESC(\x1b,即\033)开头、后面跟一个"引入符"的,都算控制序列:
| 引入符 | 家族 | 用途示例 |
|---|---|---|
[ | CSI | 光标移动、SGR 颜色:ESC[31m红字 |
] | OSC | 操作系统命令:ESC]0;标题BEL改窗口标题、ESC]11;?查背景色 |
P | DCS | 设备控制串(如终端查询回复的包装) |
\ | ST | 字符串终止符,常与 BEL 等价用于结束 OSC |
代码里正则/\x1b]11;([^\x07\x1b]+)/的字符类[^\x07\x1b]正是为兼容两种结束方式:BEL(\x07)或 ESC(\x1b,ST 的开头字节)一到,捕获就停。不理解 OSC/ST 的差异,这行正则的排除逻辑就看着莫名其妙。
🧩raw mode + data:收下终端的"回信"
终端把应答当成"输入"发回 stdin,所以要先setRawMode(true)并监听data(app.tsx:94-95)。为什么必须 raw mode?因为非 raw 模式(canonical 模式)下,终端驱动会做两件"好心坏事":
- 行缓冲(ICANON):输入攒到换行才交给程序——终端应答末尾没有换行,会被一直憋着;
- 信号处理(ISIG):Ctrl+C 这类控制键被转成信号(SIGINT)而不是字节。
只有 raw mode 关掉 ICANON/ISIG/ECHO 等,才能逐字节收到ESC ] 11;...这种无换行的控制序列。若非 TTY(app.tsx:47),进不了 raw mode,直接返回dark。
收到数据后,handler(app.tsx:58-92)用正则抓ESC ] 11;之后的颜色串。
⚠️应答分片的边界:data 不保证一次到齐
stdin的data事件每次给一段字节,不能假设整条 OSC 应答一次到齐。如果终端把应答拆成两段——第一段\x1b]11;rgb:1e1e/、第二段1e1e/1e1e\x07——handler 对第一段做正则匹配会失败(颜色串不完整、还没出现结束符)。opencode 没有做"跨 chunk 拼接缓冲",遇到分片就匹配不到,最终靠 1 秒超时兜底返回dark。这是"容忍不完整应答"的工程取舍:追求简单、可接受偶发误判深色,也不愿为分片维护一个累积缓冲。
🧩三种颜色格式与亮度判断
| 格式 | 例子 | 解析 |
|---|---|---|
rgb: | rgb:1e1e/1e1e/1e1e | 每通道16 位,parseInt(x,16) >> 8砍成 8 位 |
# | #1e1e1e | 每两位一段parseInt(hex,16) |
rgb( | rgb(30,30,30) | 十进制 |
解析出 RGB 后算相对亮度(app.tsx:87):
luminance = (0.299r + 0.587g + 0.114b) / 255📐亮度公式再谈:人眼不是均等看待 RGB
系数 0.299/0.587/0.114 不是拍脑袋,而是BT.601 亮度系数——对应人眼对三种颜色的敏感度:对绿色最敏感、红色次之、蓝色最弱,所以绿通道权重最高。三个系数和为 1,加权后值域正好落在 [0,255],除以 255 归一化到 [0,1],阈值取 0.5。
严格说这是工程近似:没做 gamma 校正、也没换算到感知均匀的色彩空间(如 CIELAB 的 L*)。对"判深色还是浅色"这种二值问题,近似足够,换来的是实现简单。阈值 0.5 也是经验值——背景偏亮超过一半就认为浅色终端。
🎨应答格式差异:为什么三种格式都要兼容
终端对 OSC 11 查询的应答格式并不统一:
- xterm / 大多数:
rgb:RRRR/GGGG/BBBB——每通道16 位,这是 XTerm 规范格式,所以代码要>> 8把 16 位砍成 8 位; - 部分终端:直接回
#RRGGBB; - 个别:回
rgb(R,G,B)十进制。
代码三种都解析(app.tsx:70-84)。一个容忍度边界值得注意:若某终端用 8 位回rgb:RR/GG/BB(不标准),parseInt(x,16) >> 8会把小于 256 的值右移 8 位直接砍成 0 → 全黑 → 亮度 0 → 误判深色。规范终端都回 16 位,这段是"对不标准应答的选择性失明"。
⚠️健壮性与 Windows 副作用
函数自带三处兜底:
- 非 TTY 直接返回
dark(app.tsx:47) - cleanup 还原现场:拿到应答或超时后
setRawMode(false)、移除data监听、清定时器(app.tsx:52-56)——不能把 stdin 留在 raw mode - 1 秒超时:终端不应答就
resolve("dark"),绝不阻塞启动(app.tsx:98-101)
还有一个 Windows 特有副作用:切 raw mode 会重开ENABLE_PROCESSED_INPUT。app.tsx:123-125 的注释原话:
setRawMode(false) restores the original console mode which re-enables ENABLE_PROCESSED_INPUT
所以探完背景色后必须再调一次win32DisableProcessedInput()(app.tsx:125),否则 Windows 上 Ctrl+C 又会变回杀进程的信号——这正是 Win32 Ctrl+C 防御那套机制的用武之地。
🧩启动时序与 ThemeProvider 的衔接
看 app.tsx 里这段的调用顺序(app.tsx:117-152 节选):
win32InstallCtrlCGuard() / win32DisableProcessedInput() // 先装 Ctrl+C 守卫 const mode = await getTerminalBackgroundColor() // 再探背景色(会切 raw mode) win32DisableProcessedInput() // 探完立刻重清 flag render(... <ThemeProvider mode={mode}> ...) // 最后才渲染顺序有讲究:守卫在最前,保证整个探测过程里 Ctrl+C 不会被系统拦截;探测同步 await(最长 1 秒,超时即 dark),确保 render 前主题已定;探测完立刻重清raw mode 带出的 flag。另外mode只是"开机默认主题"——用户进入 TUI 后仍可在主题对话框切换(ThemeProvider 会把选择存进 KV 持久化),这个函数只负责第一次渲染时给出合理的深/浅起点。
📊总结
| 环节 | 作用 |
|---|---|
发\x1b]11;?\x07 | OSC 11 查询终端背景色 |
setRawMode(true)+data | 收终端应答(非 TTY 直接 dark) |
正则抓ESC ] 11;后颜色串 | 提取 RGB 说明(兼容 BEL / ESC 终止) |
三种格式解析 +>> 8 | 归一化成 8 位 RGB |
| BT.601 亮度 + 阈值 0.5 | 判 dark/light,交给 ThemeProvider |
| cleanup + 1s 超时 | 还原 raw mode、防死等、容错应答分片 |
再调win32DisableProcessedInput | 抵消 raw mode 重开ENABLE_PROCESSED_INPUT |
📌一句话记忆
TUI 拿不到终端背景色 API,就用 OSC 11 序列"问":发
\x1b]11;?\x07,进 raw mode 收应答(非 TTY 或 1 秒超时都兜底 dark),正则抓出颜色串(rgb:16 位>>8/#hex/rgb()十进制),算 BT.601 亮度、阈值 0.5 定 dark/light 喂给 ThemeProvider 做开机默认主题;最后还原 raw mode 并重清 Windows 的ENABLE_PROCESSED_INPUT。
OK,本篇先到这里,如有疑问,欢迎评论区留言讨论,祝各位功力大涨,技术更上一层楼!!!更多内容见下篇 blog
【Agent】【OpenCode】TUI 内部:tui() 的生命周期 Promise