ANSI 转义码常见坑点
踩一次就会终生记住的具体失败模式。每条包含「症状」、「成因」、「修复」。
最近更新
01你输出的彩色文字继续污染后续行,连用户的命令提示符都被染色。
成因设置了颜色属性,但在最后一次换行或程序退出前没有发送 SGR 0(
\x1b[0m)重置。修复始终用
\x1b[0m(或更窄的\x1b[39m仅重置前景色)结束彩色输出。把颜色当作 try/finally 资源管理。02用
\x1b]0;...设置窗口标题后,下一段程序输出莫名其妙消失或不渲染。成因OSC 必须显式以 BEL(
\x07)或 ST(\x1b\\)结束。若漏写终止符,解析器会一直把后续字节当作标题文本吞掉,直到遇到终止字符。修复OSC 序列务必闭合:
\x1b]0;title\x07。为兼容 xterm 优先用 BEL(\x07),追求 ECMA-48 严谨则用 ST(\x1b\\)。03对齐良好的列表表格,一旦加上彩色就错位 —— 含彩色的那一行看起来更短。
成因转义序列含不可打印字节,朴素的
len(string)或printf '%-20s'把它们计入了宽度。修复先剥离 ANSI(见
/strip)再测量可见宽度,然后在彩色输出外补齐填充。大多数 UI 库(rich、blessed、lipgloss、ratatui)会代你处理。04用
\x1b[?1049l退出备用屏幕后,光标停在错的行 —— 通常比预期高一行。成因DECSET 1049 会恢复保存时的光标位置,但应用在 alt screen 内最后一次写的可见行常常没有以换行结尾。
修复退出前先发送
\r\n(或平台对应换行),或在\x1b[?1049l前显式 DECRC(\x1b8)以恢复到一个干净的行。05用户抱怨工具在管道或重定向中仍输出颜色,把日志搞乱。
成因工具无条件输出颜色,没有遵从「不要颜色」的标准信号。
修复尊重事实标准
NO_COLOR=1(https://no-color.org),同时检查isatty(stdout)—— 输出不是终端时禁用颜色。也应当遵守TERM=dumb与CLICOLOR=0。06真彩色在你机器上正常,在其他终端却看起来偏色或错乱。
成因并非所有终端都支持 24 位色;不支持的会把每个 RGB 量化到 256 色调色板最近邻,色相可能大幅偏移。
修复发送
38;2;r;g;b前检查$COLORTERM(应为truecolor或24bit)。不支持时回退到 256 色调色板索引,或 16 种基础色。07色盲用户区分不出你的「成功」与「错误」输出。
成因两个状态只用颜色区分;一旦丢掉绿/红信号,文字看起来完全一样。
修复颜色与文本前缀或图标搭配使用:
✓ ok与✗ failed、[ok]与[err]。颜色应当强化信号,绝不能单独承载信号。09vim 崩溃(或任意 TUI 异常退出)后,用户 shell 中的方向键失灵 —— 按 ↑ 不再调出上一条历史命令,光标位置反而出现像
OA的散字符。Backspace、Home、End 也可能异常。成因崩溃的应用在启动时发了
smkx(\x1b[?1h\x1b=—— DECCKM 开 + DECKPAM)把终端切到应用键盘模式,但退出前未能发出配套的rmkx(\x1b[?1l\x1b>—— DECCKM 关 + DECKPNM)。DECCKM 卡在「开」上后,用户按 ↑ 时终端发\x1bOA(SS3 A)—— 但用户 shell 的 readline / zsh-line-editor 只绑了*常规模式*的\x1b[A(CSI A)。方向键的字节流到了却匹配不到任何绑定,readline 只能把可打印部分(OA)回显出来,而不是上翻历史。修复立即恢复(不必登出):跑
tput rmkx(或直接printf '\e[?1l\e>')—— 切换的两半都发。仍不行就reset或tput reset做更宽的硬重置。预防自家 TUI:注册 SIGINT / SIGTERM / SIGQUIT 处理器 +atexit/defer块,确保任何退出路径(含未处理 panic)都发rmkx。Go:defer fmt.Print("\x1b[?1l\x1b>")。Python:atexit.register(lambda: sys.stdout.write('\x1b[?1l\x1b>'))。Rust crossterm:在包装结构体的Drop实现里用LeaveAlternateScreen+DisableMouseCapture清理模式。10你的工具发
\x1b[A期望光标上移 0 行(空操作),结果光标上跳 1 行。或者发\x1b[m后样式被清除(如愿),但发\x1b[J想啥都不做时,光标之下整屏被擦掉。成因CSI 参数默认值并非统一为
0。 SGR(m)省略 Ps 时默认0(重置)——\x1b[m等同\x1b[0m。但光标移动 CSI(CUU/CUD/CUF/CUB=A/B/C/D)默认值是1——\x1b[A等同\x1b[1A(上 1 行),而不是\x1b[0A(如果有「上 0 行」之意)。擦除指令(ED\x1b[J、EL\x1b[K)默认0表示「从光标到屏末 / 行末」——\x1b[J擦除从光标到屏末,并非「擦 0 个 = 空操作」。每个序列的默认值是它各自 ECMA-48 规范的一部分;把「CSI 默认 0」当统一规则迟早翻车。修复值有意义时务必显式写参数。
\x1b[1A表「上 1 行」(别依赖默认值)、\x1b[0m表「重置 SGR」(别依赖 SGR 专属默认 —— 显式0字节数相同且读起来不含糊)。真想要「空操作」就别发任何字节 —— 别试图找一个表「不动」的参数值。每写一个 CSI 序列,查对应/sequence/<slug>页的参数表 —— 每条序列的默认值都按规范单独列出,因为规范本就是逐条定义。11TUI 退出后,用户 shell 中每次粘贴剪贴板都带上多余的
\x1b[200~…\x1b[201~字节标记 —— 粘进去的命令不再执行,提示行上变成200~ls -la 201~,更糟时这些标记会写入正在编辑的文件。成因退出的 TUI 在启动时发了
\x1b[?2004h(DEC 私有模式 2004 —— *括号粘贴*),请求终端为后续粘贴前后加哨兵标记(这样应用能区分键入 vs 粘贴),但退出前未发出配套的\x1b[?2004l。终端仍处在括号粘贴模式,可用户的 shell(readline / zsh-line-editor)没绑这些标记 —— 它们就当作普通字符流过来。修复立即恢复:跑
printf '\e[?2004l'关闭括号粘贴模式,或reset更宽地恢复。zsh / bash readline 8.0+ 实际上*识别*括号粘贴,绑了的话会静默吃掉标记;若仍泄漏,说明 shell 较旧或绑定被剥离 —— 查bind -p | grep paste。预防自家 TUI:发出的每个\x1b[?2004h都在同一个清理块中配\x1b[?2004l(与rmkx/ 光标恢复 / 鼠标禁用 / 退出 alt-screen 并列)。更稳:用 XTSAVE/XTRESTORE 栈(\x1b[?2004s入栈、\x1b[?2004r出栈),让退出时的状态回到父进程原状,而非无条件「关」。12在自家 TUI 里按单独 Esc(想退出某模式,如 vim 插入模式)后明显延迟 100–1000 ms 才有反应。更糟:连按 Esc + 别的(比如 Meta-as-Esc 约定下的 Alt+字母)有时被识别成单纯 Esc、有时被识别成组合键 —— 竞态般的飘忽。
成因几乎所有多字节输入序列都以
\x1b开头 —— 恰是用户单按 Esc 时发的同一字节。输入循环见到\x1b后没法立刻判断:「孤立 Esc」还是「\x1b[A/\x1bOP/\x1b]…的首字节」?天真方案:等T时间,若T内无后续字节则判定「孤立 Esc」。ncurses 把T(ESCDELAY 环境变量)默认为 1000 ms —— 太长,用户能感知卡顿。把T设太短(< 20 ms)则在慢速 tmux / SSH 链路上,真正的\x1b[A会被拆成两次读 —— 误判为「Esc」+ 字面[A。修复现代折衷:
export ESCDELAY=25(25 ms)—— 快到本地终端孤立 Esc 几无感知,慢到即使慢速 SSH 也不会把 2 字节序列拆开。更优解:上 kitty 键盘协议(CSI u ——\x1b[>1u标志 1 'disambiguate escape codes')—— 若目标模拟器支持(kitty / foot / WezTerm / ghostty / Konsole 24.02+),终端会对孤立 Esc 发\x1b[27u,彻底消除歧义。通过 XTGETTCAP 查Su能力探测,不支持时回退到 ESCDELAY。分语言:bashread -t 0.025、Pythonselect.select(..., 0.025)、Rust crosstermpoll(Duration::from_millis(25))、Go tcellPollEvent+EventTime过滤。13TUI 启动时发
\x1b[?3h(DECCOLM 置位 —— 请求 132 列宽屏)。直接在 xterm 下跑没事;同一二进制在 tmux 或 GNU screen 下跑就会出现一行(或几行)错乱输出 —— 半擦除的行、80–131 列里飘的残影字符、整片版面按错列宽渲染。调整 tmux 窗格大小常常会加剧。成因DECCOLM 在真 VT510 上是物理列数切换,所以规范规定
\x1b[?3h/\x1b[?3l必带副作用:清屏 + 光标回家 + 重置边距(详见/sequence/deccolm)。xterm 全部尊重 —— 包括通过 X11 资源allowC132真正完成 80↔132 大小切换。tmux 做不到:tmux 是复用器,其窗格列数由外部终端定死,无法请求父端调整。所以 tmux 对\x1b[?3h的仿真做了清屏 + 光标回家 + 重置边距(因解析器急切派发副作用),却没改实际列数 —— TUI 自以为有 132 列,开始写超宽行并在中途回卷,而 tmux 窗格仍是 80 列。错位即损坏。SIGWINCH 让陷阱更深:tmux 自身 resize 时会传播 SIGWINCH,但不会为撤销 DECCOLM 失配再发一次,TUI 永远不知道自己对列数的认知是错的。DECSCPP(CSI Pn$|—— 带参列数)同理。修复复用器下不要发 DECCOLM。通过
$TMUX/$STY环境变量检测 tmux / screen,或读 terminfocols(tput cols)—— 若它在非allowC132类模拟器下报固定的 80 / 132,则当前为嵌套环境。现代方案:用TIOCGWINSZ(ioctl)或$COLUMNS查真实终端尺寸并按此宽度渲染 —— 2026 年绝不要把 DECCOLM 当作布局设置原语。若你确需宽屏(截图 / 打印流程),就守门:if [ -n "$TMUX" ] || [ -n "$STY" ]; then echo '复用器下宽屏模式被禁用'; fi,并优雅退出。Resize 正确性:装SIGWINCH处理器,每次信号到来重读TIOCGWINSZ并重排版面 —— 切勿在窗口事件后仍信任最后一次基于 DECCOLM 推断的列数。与/pitfalls/stuck-app-mode同属「副作用置状态、退出未撤销」的一类 bug。14CLI 发了带
id=参数的 OSC 8 超链接(意图:跨多行文本指向同一 URL:\x1b]8;id=row42;https://x.com\x07Item\x1b]8;;\x07)。在 Kitty 下,所有id=row42的行在悬停任一行时一起高亮 —— 正常。在 iTerm2 下,id=被静默忽略 —— 每个\x1b]8;...\x07 … \x1b]8;;\x07块各自独立、无分组。在老版 gnome-terminal(≤ 3.36)或 konsole(< 21.04)下,对两个不同 URL重用id=row42,会让第二个链接静默继承第一个 URL —— 数据悄然损坏。成因OSC 8 规范(gnome-terminal 作者 Egmont Koblinger 2017 年提案,Kitty / WezTerm / Ghostty / VS Code terminal / iTerm2 3.5+ / Konsole 21.04+ / Windows Terminal 1.21+ 采纳)把
id=<token>定义为「同 id 且同 URL 的相邻或非相邻字段视为同一逻辑链接」的提示(让悬停高亮能跨行回卷而不把每个单元格当成独立链接)。规范未规定的:(a) 同 id 出现两个不同 URL 时的行为 —— 未定义,由实现自决。(b) 会话内是否允许重绑 id。(c) 作用域是每屏 / 每窗格 / 还是会话全局。各模拟器各自为政:Kitty 以id+url为去重键(同 id 不同 url → 两个独立链接 —— 正确);21.04 前 Konsole / 3.36 前 gnome-terminal 仅以 id 去重(同 id 第二个 url 被静默忽略 —— 数据损坏);iTerm2 完全忽略 id(每段各自独立);Windows Terminal 接受 id 但不跨行视觉聚合。修复两条规则:(1) 同一 OSC 8 会话内不要为两个不同 URL 重用同一
id=。每个逻辑链接用内容哈希或单调计数器生成 id:id=link-$(uuidgen | head -c 8),绝不要重复用id=row。Kitty 的正确行为(id+url为去重键)是上限;按 id-only 去重作为下限来兼容。(2) 如目标受众含 iTerm2 或老版 gnome-terminal / konsole,则不要依赖id=做分组。分组是 UX 提示而非布局原语 —— 真正需要「同一 URL 跨多行」保证的内容,请每行都发完整的\x1b]8;;URL\x07cell-text\x1b]8;;\x07包络(字节多但处处可用)。把id=留给真正的「单链接跨行回卷」情形 —— Kitty / Ghostty 用户由此获得轻微 UX 提升。探测:OSC 8 id 语义没有类似 DA 的探针 —— 要么假设保守行为,要么在 CI 中针对目标模拟器做测试。参考序列OSC 8 — 内联超链接
15Shell 脚本里两种着色方式混用 —— 时而
tput setaf 1(基于 terminfo),时而printf '\033[31m'(硬编码 SGR 31)。在现代终端里两者看起来一样,但在黑白 TTY、Linux 控制台、TERM=dumb/TERM=xterm-mono下,tput版会正确无色,printf版却把^[[31m原始字节漏给输出。更糟:脚本管道进less(不加-R)或重定向到文件时,printf字节会作为乱码显示,而tput在非 TTY 下原本会产出干净文本。成因
tput setaf 1做了printf '\033[31m'不做的两件事:(1) 查$TERM和 terminfo 数据库返回该终端的正确转义序列 —— 比如xterm-256color返回\e[31m,但linux在黑白控制台返回空串;dumb在哪都返回空;screen-256color返回\e[31m。(2) 尊重isatty(stdout):当 stdout 非 TTY(管道 / 文件 / 被 CI 捕获)时,现代 ncurses(≥ 6.2)的tput不输出任何内容 —— 静默无操作。printf '\033[31m'两者都不做:无论终端能力或输出目的地如何,永远原样吐 5 个字节。陷阱在于意图错位:开发者「想要红字」→ 开发机上两者都对 → 上线 → 不同 TERM 的用户或重定向到日志文件的用户看到乱码。修复双规则决策:(1) 可移植脚本(要派发到未知终端:系统安装脚本、发行版工具、
/etc/bashrc片段、CI runner)用tput。性能代价(每次tputfork ~1 ms)远小于正确性收益。建议在脚本顶部缓存:red=$(tput setaf 1); reset=$(tput sgr0)。(2) 目标终端已知且受控的场景(Docker entrypoint、自家 CI 内构建输出 —— 你已知TERM=xterm-256color、测试夹具、自家 dotfile 自家终端)才用硬编码printf '\033[31m'。即便如此也要用[ -t 1 ](stdout isatty)守门,避免管道场景漏字节:[ -t 1 ] && printf '\033[31m%s\033[0m\n' "red" || printf '%s\n' "red"。同一脚本内不要混用,团队保持一种心智模型。无论哪种都要尊重NO_COLOR=1。与/pitfalls/no-color和/pitfalls/tmux-sigwinch-deccolm中的tput cols建议联读。16TUI 用
\x1b[?1049h进入备用屏幕,并在状态行之间只写普通\n。结果每行没从下一行的第 0 列开始,输出对角阶梯式漂移 —— 第 2 行从第 1 行结尾处开始,第 3 行从第 2 行结尾处开始,窗格右侧被截断的回卷塞满。绕过 ncurses / TUI 库的直接写最容易暴露这个问题。成因
\n只是 LF(0x0A),不是 LF+CR。ECMA-48 用 LNM(行进/新行模式 —— ANSI 模式 20,通过\x1b[20h/\x1b[20l置/复位)控制单独 LF 是否同时把光标回到第 0 列。LNM 默认关:LF 只换行不归位。常规 pty 上,内核 termios 层会加ONLCR(输出 LF → CR-LF 翻译),所以走 stdout 的应用免费获得「换行即归位」—— 反过来掩盖了 LNM-off 的行为。备用屏幕缓冲本身不受 termios 影响(它是终端层缓冲,不在 tty 行规则一侧),但陷阱在于:alt-screen 的输出常由绕过 stdio 缓冲的代码路径产生(对 fd 1 直接write()、自研的成批转义渲染循环)—— 这些路径绕开ONLCR翻译。内核 ONLCR 只在write(STDOUT_FILENO, "\n", 1)经行规则时触发;框架渲染循环为获得完整控制常通过termios.c_oflag &= ~ONLCR关掉。结果是:alt-screen 内你的\n变成纯 LF,光标推进行号但永不归零列,输出对角阶梯。修复alt-screen 渲染循环里始终发
\r\n。把每个行尾视作 2 字节序列 —— shell 里printf '%s\r\n'、C / Go / Rust 里write("row\r\n")、Python 里print(line, end='\r\n')。不要依赖 LNM(\x1b[20h)作为修复:xterm + gnome-terminal 支持,但 Windows Terminal 和部分 macOS 终端不理会,Konsole 仅部分尊重。不要在渲染循环内通过termios恢复ONLCR—— 这会让每个 LF 都触发列归零,包括转义串内部的,弄坏像\x1b[Hrow1\nrow2这种定位写(row2应在第 2 行第 1 列,不是第 0 列)。可移植不变量:alt-screen 内光标位置归你管 —— 行间显式\r\n,或用\x1b[<row>;1H(CUP 到下一行起点)做绝对定位。ncurses / blessed / lipgloss / ratatui 自动处理;裸转义代码路径需自己来。相关:/sequence/alt-screen、/pitfalls/alt-screen-newline(退出时光标位置话题)。17TUI 画固定宽列表,列边框在纯 ASCII 行上完美对齐;一旦某行出现 emoji(
🎉)、CJK 字符(字)或组合符(é=e+\u0301),该行的右边框就左右偏移 1–2 个单元格。更糟:同一二进制在 glibc Linux 上对齐方式与 musl Alpine 不同,又与 macOS Terminal 不同 —— 三者收到的字节流完全一样。成因对「该字形多少单元格宽」有四个互不相同的独立测量:(1) 你的库(glibc / musl 的
wcwidth(3)、内嵌的 jquast/wcwidth、Rust 的unicode-width)按码点查表得到一个值。glibc 的表约 Unicode 13(2020);musl 更旧,部分歧义宽字符处理不同;jquast/wcwidth 跟到 Unicode 15+。(2) 终端模拟器独立决定怎么画 —— xterm 的cjkWidth资源把 East-Asian-Width-Ambiguous 在 1 与 2 间切换;wezterm 自有一套表;Apple Terminal 把许多 emoji 硬编码为宽 1,而 wezterm / iTerm2 渲染为宽 2。(3) 模拟器挑的字体可能根本没那个字形,替换字形占用的单元格数不同。(4) Unicode East-Asian-Width 本身每个版本都在改 ——…(U+2026)在 Unicode 8 是 Ambiguous,11 里明确宽 1,部分亚洲 locale 又重新变回 Ambiguous。如果你的wcwidth是按某版编译、终端是按另一版编译,那已经输了。修复不要把本地 wcwidth 当真理。三档修复,按你愿付的代价挑:(1) 便宜且可移植 —— 对齐关键列限制为 ASCII(
[ -~]);用户输入字符串若超出,就放到对宽度不在意的列(最右、或用…截断而不计单元格数)。(2) 更好 —— 内嵌 jquast/wcwidth(Python)、unicode-width(Rust)、Go 的mattn/go-runewidth,把 Unicode 版本钉死;同一版本作为测试夹具一起发,让 glibc / musl 分歧在 CI 中暴露。East-Asian-Ambiguous 字符:CJK locale 用户视为宽 2,其余宽 1 —— 通过LANG/LC_CTYPE决断。(3) 真值 —— 问终端:发该字形,紧跟\x1b[6n(DSR 光标位置查询 —— 见/sequence/csi-dsr),解析\x1b[<row>;<col>R应答,与写入前的列号相减。代价:每个不确定字形一次往返 + CSI 食谱里指出的输入泄漏风险(必须消费应答,否则进入用户输入)。实战 TUI(textual、ratatui、lipgloss)走二档策略 + 不确定度超阈值时回退到三档。与/pitfalls/terminal-width-math(同一对齐问题的 SGR 字节侧)联读。18你的应用发出
\x1b]8;;https://example.com\x07link\x1b]8;;\x07(OSC 8 超链接)、\x1b]52;c;...\x07(OSC 52 剪贴板)、\x1b]1337;File=...\x07(iTerm2 内联图片)或\x1b]9;4;1;42\x07(ConEmu 进度),直接在支持的终端运行一切正常 —— 但一旦在 tmux 内运行,外层终端显示原始字节(]8;;https://example.com变成纯文本),功能完全失效。换个外层终端重连 tmux 也没用。成因tmux 是终端复用器,不是透传:它解析内层窗格发出的每一个转义序列,并决定哪些转发给外层终端。默认白名单覆盖基本功能(SGR、光标控制、备用屏幕、鼠标),但默认丢弃多数 OSC 家族扩展 —— OSC 8、OSC 52、OSC 1337、OSC 9;4 都会被丢弃或转换,除非显式允许。设计动机保守:tmux 无法知道外层终端是否支持,转发可能出错(一个困惑的外层终端可能把字节回灌进 tmux 的输入流)。两套机制管控:(1)
allow-passthrough on—— tmux ≥ 3.3 的选项,开启 DCS 透传包络\x1bPtmux;<把每个内层 \x1b 都重复一次>\x1b\\;要送外层的应用必须把原始转义包起来,把每个嵌入的\x1b写成\x1b\x1b。(2) 逐功能set-option allow-*—— 较新 tmux 提供allow-set-clipboard(OSC 52)、allow-hyperlinks(OSC 8 —— 3.4 起)、allow-rename(OSC 0/1/2),各有自己的默认值。陷阱是意图错位:内层窗格的应用通过 DA 查询(这类查询会穿过 tmux)检测外层终端的能力,却忘了检测 tmux 本身。结果:应用以为「外层支持 OSC 8,直接发」→ tmux 静默丢弃。修复双管齐下。(1) 服务侧(tmux 配置):在
~/.tmux.conf加 ——set -g allow-passthrough on(tmux ≥ 3.3)、set -g allow-hyperlinks on(tmux ≥ 3.4 for OSC 8)、set -g allow-set-clipboard on(OSC 52)。tmux source ~/.tmux.conf重载。权衡:任何内层应用都能毒化外层终端 —— 仅在可信环境开启。(2) 客户侧(应用):检测$TMUX环境变量(tmux 在它启动的每个 shell 里都设置)。一旦命中,把每个要送外层的转义包进 DCS 透传包络:\x1bPtmux;+ 内层转义(每个\x1b改写为\x1b\x1b)+\x1b\\。具体例子:要在 tmux 内发 OSC 8,不再直接发\x1b]8;;https://example.com\x07TEXT\x1b]8;;\x07,而是发\x1bPtmux;\x1b\x1b]8;;https://example.com\x07TEXT\x1b\x1b]8;;\x07\x1b\\。注意:这只是转发字节 —— tmux 自己仍不理解这些字节,它的滚动历史里会留下字面转义。库层面:kitty 的pyperclip分叉、gum的样式模块、Rust 的crossterm0.27+ 都会检测$TMUX并自动 DCS 包裹。相关:/sequence/osc-hyperlink、/family/osc食谱的超链接章节(含 id-rebind 兄弟陷阱)。参考序列OSC 8 — 内联超链接
19你的 CLI 用 24 位真彩色 SGR(
\x1b[38;2;r;g;b m)画一个平滑渐变,在 Ghostty、WezTerm、kitty、iTerm2、Windows Terminal 上呈现干净渐变 —— 数百级色阶之间柔和过渡。但在 macOS Terminal.app(系统自带)、Linux 控制台(fbcon)、旧版 PuTTY、未打真彩色补丁的某些 xterm 上,同一渐变渲染成可见的色带 / 条纹 —— 本该平滑斜坡的位置出现 8 或 16 条独立色带。更糟:每个模拟器的色带样子还略不同,所以即便字节完全一致,不同平台的截图也对不上。成因通过
$TERM=xterm-256color(多数发行版默认)声明 256 色的终端不会拒绝 24 位 SGR 字节 —— 它们优雅接受,再下采样到最近的索引调色板项。下采样策略各家实现不同:macOS Terminal.app 映射到 256 色立方体并截断(出色带);Linux 控制台(/dev/console,内核无 DRM/KMS 色彩扩展)映射到 16 个 ANSI 基色 + 8 个高亮(256 级渐变塌缩到 16 条可见色带);未编译--enable-direct-color的旧 xterm 同样。即便支持真彩色的模拟器,对特定调色板范围有时也下采样 —— macOS Terminal 的「Pro」主题改写索引前 16,但真彩色字节原样穿过,所以混合渐变(部分 24 位、部分索引)在并排时呈现两套视觉。检测盲点:$TERM=xterm-256color对真彩色支持模糊 —— 它声明 256 索引但完全不说 24 位。事实标准的真彩色能力标记是$COLORTERM=truecolor(或24bit)—— ghostty / wezterm / kitty / iTerm2 / Windows Terminal 自动设置;macOS Terminal.app / Linux 控制台 / 经旧链路的 SSH 会话不设。修复发之前先检测。对真彩色 SGR 加
$COLORTERM=truecolor或$COLORTERM=24bit守门。缺失时手动量化到 256 色调色板(xterm 6×6×6 立方:index = 16 + 36*r + 6*g + b,其中r,g,b ∈ 0..5;将每个 0..255 分量映射到 [0, 95, 135, 175, 215, 255] 中最近的)—— 改发\x1b[38;5;<idx>m。这样渐变交给你的量化器,而非模拟器的神秘下采样。附赠:渐变在所有 256 色模拟器上看起来一致。库支持:Rusttermcolor的ColorChoice::Auto已做此事;Gofatih/colorv1.16+ 检查$COLORTERM;Pythonrich的Console(color_system='auto')同逻辑。不要假设$TERM=xterm-direct被设置 —— 那是显式真彩色 TERM 但采用率近零;依赖$COLORTERM。不要只在自己机器测试 —— 至少抽查 macOS Terminal.app(最常见的假阳性:它接收真彩色不抗议、却下采样得很差)。相关:/sequence/sgr-fg-truecolor(字节级规范)、/family/sgr食谱真彩色章节。20使用 NVDA、JAWS、Orca 或 VoiceOver 的视障用户反映:你的工具被读屏读成「escape bracket three one m error escape bracket zero m」而不是「error」。
成因读屏软件逐字符朗读文本缓冲;SGR / CSI 原始字节会按其 Unicode 码点被读出。当 stdout 是真实 TTY(或读屏的辅助 pty)时,无条件输出颜色的工具把转义字节交给辅助软件 —— 它无法把这些字节渲染成样式,只能朗读。
修复尊重
NO_COLOR=1(见相关误区)—— 许多读屏用户已全局设置。检测已知的可访问终端环境变量(TERM_PROGRAM=Speakup、SCREEN_READER_RUNNING)并降级为纯文本。TUI 应提供--plain/--no-color开关,优先使用error:、ok:等语义前缀而非仅靠颜色。ARIA 等价机制不适用 —— 终端没有 DOM,唯一的干预就是「不输出」那些字节。21
\x1b[0;0H(或\x1b[0H)看起来没有效果 —— 光标停在原位,或比预期靠下/靠右一行一列。成因CUP(
\x1b[<row>;<col>H)由 ECMA-48 §8.3.21 规定为从 1 开始计数 ——\x1b[1;1H是左上角,不是\x1b[0;0H。按 §5.4,省略的 CSI 参数默认为 1,所以\x1b[H(无参数)等价于\x1b[1;1H,而非\x1b[0;0H。多数终端把0静默归为1,因此症状常常只在你用从 0 基坐标系(canvas / framebuffer / DOM)换算来的\x1b[<top-1>;<left-1>H时才显现。修复从 0 基坐标系迁移时记得加 1:
printf '\x1b[%d;%dH' (row + 1) (col + 1)。\x1b[H是「回到左上角」的惯用简写。另外 CUP 顺序是行;列(y;x),不是x;y—— 来自 canvas / GL 坐标 API(x 在前 y 在后)的人需调换参数。\x1b[H\x1b[2J是「清屏并回到左上角」的通用范式;单发\x1b[2J不会移动光标。完整 CUP 字节规范见/sequence/cursor-position。参考序列CUP — 设置光标位置
22你的
clear风格命令发了\x1b[2J,可视区域清空了,但用户向上滚动还能看到刚刚的内容。更糟的是 —— 用户的clear别名能清回滚缓冲而你的 TUI 不能,于是用户报告你「泄漏历史」。成因ED(
\x1b[<n>J,ECMA-48 §8.3.39)有四种选择模式:0(光标到屏幕末,默认)、1(屏幕首到光标)、2(整个可视屏幕)和3(整个屏幕包含回滚缓冲,xterm 扩展,xterm-ctlseqs 记为Ps = 3)。\x1b[2J是回滚出现之前定义的 —— 它针对可视屏幕,不针对模拟器保存历史行的环形缓冲。xterm 作者 2002 年加入3正是因为用户想要清回滚而标准没有。Linux console、kitty、alacritty、wezterm、Windows Terminal、iTerm2、gnome-terminal、macOS Terminal.app 都支持3J;老式 DEC VT 与一些嵌入式终端会静默忽略。修复按需选择。如果希望用户仍能上滚查看历史(TUI 重绘、全屏菜单),用
\x1b[H\x1b[2J—— 仅清可视区,保留回滚。如果要真正的clear等价(欢迎屏、安全敏感 CLI),发\x1b[H\x1b[2J\x1b[3J——3J扩展会清掉环形缓冲。ED 不移动光标;惯用做法是配\x1b[H回到左上角。注意 macOS Terminal.app 仅在Edit > Clear Buffer快捷键策略保持默认时遵循3J;iTerm2 等其他终端无条件遵循。完整四模式字节表见/sequence/erase-display。23程序写够 80 个字符填满第 1 行后,后续 CSI 光标定位指令看起来差一格 ——
\x1b[1;1H不能干净复位,或\x1b[B(光标下移)一次跳两行而非一行。如果 DECAWM 关闭,症状不同:超过右边界的字符被静默丢弃而非换行,状态行文字消失。成因DECAWM(
\x1b[?7h/\x1b[?7l,DEC 私有模式 7,所有现代终端默认开)定义打印字符到达最右列时的行为。DECAWM 开时,光标停在 N 列(最后一列)的「待换行」状态 —— 直到下一个打印字符到达才进入下一行第 1 列。这期间发的光标位置查询返回行 R / 列 N,而不是直觉以为的行 R+1 / 列 1。CSI 光标移动(\x1b[B、\x1b[C)会清掉「待换行」标记并按从 N 列出发执行。DECAWM 关(\x1b[?7l)时,超出字符被静默丢弃 —— 光标停在 N 列,后续字符原地覆盖写入。修复交互式 TUI:写完最右列后不要靠列位置算式 —— 每次状态行重绘前显式发
\x1b[<row>;<col>H(CUP)消除歧义。不希望换到下一行的状态行(右沿时钟、滚动指示):写之前关掉 DECAWM\x1b[?7l,写完恢复\x1b[?7h,并在代码里自行截断 —— 别指望终端在跨模拟器上都优雅丢弃溢出(Linux console 与 xterm 在此边缘行为不同)。DSR\x1b[6n光标位置查询:注意「待换行」状态下回复是R;N而非R+1;1,解析代码要容忍。完整 DECAWM 字节规范见/sequence/dec-line-wrap,光标回报机制见/sequence/csi-dsr。24同一条 256 色 SGR 指令在不同终端看起来差异明显 —— 在 xterm 调成「橙色」的
\x1b[38;5;208m,到 iTerm2 上变「棕色」,macOS Terminal 默认 Basic 配色变「土红色」,solarized / dracula / one-dark 等主题下又略微脱饱和。成因xterm 256 色调色板分三段(ECMA-48 §SGR-Indexed-Color、xterm-ctlseqs §256-Color):0–15 槽是用户可配的 ANSI / aixterm 16 色(由配色 / 主题映射),16–231 槽是 6×6×6 RGB 立方(
16 + 36*r + 6*g + b,r,g,b ∈ 0..5,分量值依次对应 0/95/135/175/215/255),232–255 槽是 24 阶灰度梯度。6 色立方区段是规范化的,但立方的锚点值并非如此 —— 终端只在文档里承诺索引→RGB 函数,不少终端故意覆盖。iTerm2 有Indexed Colours配置(Profile → Colours → Color Presets)整体覆盖 16–231 槽;macOS Terminal Basic 配色重新平衡了中间调;真彩色模拟终端(kitty、alacritty、wezterm)严格遵守 xterm 立方。具体到 208 槽:xterm 立方 →(5,2,0)→(255,135,0)(橙色);iTerm2 默认 →(225,135,40)(偏棕);macOS Terminal Basic →(220,120,40)(在亮背景下接近土红)。修复优先语义而非索引。若需品牌色精准(logo 橙、跨机器一致的状态绿),用真彩色
\x1b[38;2;255;135;0m(用$COLORTERM=truecolor守门)—— 6 色立方只能给「大致橙色」。若需稳定标识(CI / 日志高亮,关心角色而非视觉),用 0–15 槽 —— 与用户配色绑定,所以红色是用户口中的红色,这才是对的取舍。别用 6 色立方做用户会跨模拟器并排比较的事 —— 他们会比较,差异是真实的。调色板调试:在每个目标终端跑for i in {0..255}; do printf '\x1b[48;5;${i}m %3d \x1b[0m' $i; done视觉对比。库支持:Pythonrich、Rustcrossterm自带 6 立方→RGB 量化器;Gofatih/color交由终端解释。完整规范见/sequence/sgr-fg-256,真彩色逃生口见/sequence/sgr-fg-truecolor。25你发
\x1b[38;5;208m WARNING: disk full然后单纯\n,下一段程序输出(有时连用户的 shell 提示符)会沿用 208 号色。这与未重置的\x1b[31m是同一族「颜色泄漏」问题,但 256 色槽常常看起来「差不多正常」,泄漏只在读到提示符时才明显。成因SGR 状态是粘性的 —— 终端会保留当前的前景 / 背景 / 属性,直到你显式改它。
\n是 C0 LF(\x0a),只让光标下移,不复位 SGR。窄复位\x1b[39m仅把前景恢复为默认(也就是 shell 提示符选的颜色,并非「无色」)。全复位\x1b[0m清掉一切(fg、bg、bold、dim、italic、underline……)。256 色相关的工具范例常省略尾部重置,孤立测试时 OK,集成进真实 CLI 输出就泄漏。加剧因素:用printf/echo的人常写printf '\x1b[38;5;208mWARN\n'而不是printf '\x1b[38;5;208mWARN\x1b[0m\n'—— 差就差一段转义。修复换行前与退出时都要重置。惯用范式是
\x1b[<set>m<text>\x1b[0m\n—— 重置在换行之前而非之后,因为如果下游工具把输出管到分页器 / less / Slack 转义剥离器,尾部换行可能是截断前的最后一字节。长生命周期 TUI 应在 atexit / 信号处理器里发\x1b[0m—— 即便用户中途 Ctrl-C,提示符也能恢复干净。更窄重置:仅设前景时\x1b[39m是精确还原(保留 bg 与属性);背景对应\x1b[49m,单属性对应\x1b[22m(关 bold)、\x1b[24m(关 underline)等。与其他样式组合时用窄重置。SGR-0 家族误区见/pitfalls#color-bleed,重置字节速查见/sequence/sgr-reset。26从 0 基 canvas 思路(顶行 0、24 行终端下页脚是行 23)出发,发
\x1b[0;23r想保留上下页边距 —— 实际滚动行为完全不符:滚动会跨过想保留的静态行,或完全无视 DECSTBM 把整屏当作滚动区。从另一头同样翻车:在 25 行终端上发\x1b[2;24r,期望 2..24 行滚动、第 25 行作状态栏,结果状态栏在第一次滚动时就消失。成因DECSTBM(
\x1b[<top>;<bottom>r,DEC 私有,参见 xterm-ctlseqs §DECSTBM)使用从 1 起、包含端点的行索引。24 行终端上行编号 1..24:\x1b[1;24r是「整屏滚动」(默认),\x1b[2;24r保留第 1 行作静态页眉,\x1b[1;23r保留第 24 行作静态页脚,\x1b[2;23r同时保留第 1 与第 24。top=0的 DECSTBM 按规范未定义 —— 多数终端静默归为top=1,所以症状是「行为不符预期」而非「报错」。另外:DECSTBM 会把光标移到滚动区内部的 (1,1)(VT100 / xterm-ctlseqs 行为),所以发完 DECSTBM 后旧的 CUP 位置可能与你以为的不同。底必须 ≥ 顶的约束会静默拒绝反向区间(\x1b[24;2r),xterm-ctlseqs 记录了「回退到上一区域」的 no-op 行为。修复在边界上换算,别在脑子里换算。应用内部用 0 基行号,那就在调 DECSTBM 那一刻做
+1:printf '\x1b[%d;%dr' (top + 1) (bottom + 1)。把行 1 当作「第一行可见」,N 行终端把 N 当作「最后一行可见」。N 用tput lines取(拿不到 shell 时退而用 DSR\x1b[18t),不要硬编码 24 或 25。DECSTBM 后总是显式跟一个 CUP(\x1b[<row>;<col>H)—— DECSTBM 把光标搬到区内 (1,1) 的副作用很少是你想要的,宁可覆盖也别记。嵌套 TUI(tmux、screen)场景:发 DECSTBM 前先按$TERM探测多路复用器的支持情况 —— 复用器可能改写或截断滚动区。字节规范见/sequence/decstbm。27TUI 设了滚动区(
\x1b[2;23r保留页眉页脚)后崩溃 / 被 kill,没复位。用户回到 shell 输入命令 —— 输出只在 2..23 行内滚动;提示符可能钉在区内最后一行,而原页眉(现在是 shell 输出)和原页脚(现在是提示符碎片)静止不可访问。clear也救不了。改变窗口大小有时能修(部分终端在 SIGWINCH 时重置 DECSTBM),有时更糟。成因DECSTBM 是持久性终端状态 —— 一旦发了
\x1b[<top>;<bottom>r,滚动区一直生效,直到终端收到另一条\x1b[r(或\x1b[1;<N>r,N = 当前行数,二者都表示「整屏」)、RIS 复位(\x1bc)或 DECSTR 软复位(\x1b[!p)。它不在 TUI 退出、shell 重画提示符、clear(其实就是\x1b[H\x1b[2J—— 都不动 DECSTBM)或子进程 fork 时复位。Alt-screen 退出(\x1b[?1049l)会在 xterm 与多数现代终端上复位 DECSTBM(属于 alt-screen 对的保存状态契约)—— 但仅当 TUI 真的用了 alt-screen。在主屏崩溃且设了 DECSTBM 的 TUI 会卡死用户。修复DECSTBM 必须配清理。把滚动区设置当 try/finally 资源:正常退出、各信号处理器(至少 SIGINT、SIGTERM、SIGHUP)、Drop / defer 块里,在交还终端之前发
\x1b[r(整屏滚动)。长生命周期 TUI 优先 alt-screen + DECSTBM 一起用,让 alt-screen 退出处理器代你清理 DECSTBM。用户侧恢复:解卡咒语是printf '\x1b[r'(或tput rs1,或 coreutils 的reset—— 但reset会重探$TERM较慢);在 TUI 的 troubleshooting / README 里写明白。别指望用户知道。需要一起测试的恶性组合:DECSTBM + DECCKM + DECKPAM 都设、都不复位 —— 这就是「崩溃的 nvim 留下方向键、提示符、滚动三重失灵」。字节规范见/sequence/decstbm,DECCKM 持久化误区见/pitfalls#stuck-app-mode。