黑底白字、只能打字的终端,是怎么显示出彩色代码、进度条、输入框、弹窗、转圈动画的?本篇讲:终端怎么"画图"、为什么用做网页的思路做终端界面、文字怎么自动排版、刷屏为什么不卡、键鼠操作怎么被感知。
6.1 黑底白字的终端里怎么"画图"
先说一个事实:终端本质上只能显示字符。它没有"画一个按钮"这种能力,你在终端里看到的一切------边框、进度条、颜色、动画------全都是字符和特殊指令拼出来的。
字符能拼出什么
- 边框和分隔线:用
┃ ━ ┏ ┓ ┗ ┛这类制表符拼盒子; - 进度条:用
█ ▓ ▒ ░拼填充; - 转圈动画:
⠋ ⠙ ⠹ ...这组盲文字符依次切换,看起来像在转; - 勾选框、箭头、图标:
✓ ✗ ▸ •等符号; - 甚至"图片":用半个字符高度的色块配合 24 位真彩色,可以在终端里近似显示图像(像远看的马赛克画)。
所以"画图"的第一步是选字符。项目里专门维护了一个"图形字符表",不同终端支持程度不同(有的终端显示不出某些 Unicode 符号),程序会根据终端能力降级------显示不出 fancy 符号就退回用普通的 | + -。
颜色和样式怎么来的:控制指令
终端除了接收"要显示的字符",还接收一类特殊的控制指令(不可见,以一个叫 ESC 的特殊字符开头)。你可以把它们理解成"给终端递的小纸条",终端看到小纸条不显示出来,而是照做:
- "接下来的字用红色" → 后面的字变红;
- "把光标移到第 5 行第 10 列" → 光标瞬移;
- "清掉从这里到行尾的内容" → 擦除;
- "切换到备用屏幕" / "切回来" → 类似进入/退出一个全屏页面(vim 退出后终端恢复原样就是靠这个);
- "开鼠标追踪" → 之后鼠标点击会变成数据报上来;
- "改窗口标题栏文字"。
这类指令有好几套"方言"(ANSI、CSI、SGR、OSC......名字不用记),项目里有一个专门的小模块负责生成和解析它们。
关键认知:终端界面 = 字符 + 控制指令的流
所谓"终端图形界面",本质上就是程序不断往屏幕上输出"字符"和"控制光标/颜色的小纸条"。理解了这一点,后面三章讲的技术就都顺理成章了:我们不是在"画界面",而是在"精心编排一串字符和指令,让它看起来像个界面"。
6.2 用做网页的思路做终端界面:界面为什么能随数据自动更新
如果直接手写"输出字符 + 移动光标"的代码来做复杂界面,会非常痛苦------界面上有几百个会变的元素(聊天记录、输入框、状态条、弹窗、动画),手动管理"哪里该更新"几乎不可能。项目的解法是:借用做网页的 React 框架,但把它的输出目标从浏览器换成终端。
React 的核心思想:描述"该长什么样",而不是"怎么改"
做网页时有两种写法。老式写法是"命令式":数据变了,你手动找到页面上那个元素,手动改它的文字。界面一复杂,这种"手动追踪每个元素"的代码就乱成一团。
React 带来的写法是"声明式":你不管怎么改,你只描述界面在给定数据下应该长什么样 ------"如果正在加载,就显示转圈;如果有消息列表,就逐条渲染消息;输入框内容是 input 这个变量"。数据一变,React 自动算出界面哪里需要变,帮你改好。你写的是"界面和数据的关系",不是"怎么操作屏幕"。
打个比方:命令式像"木偶戏",演员要手动牵每根线让木偶动;声明式像"拍电影",导演只管说"这场戏应该是这样的",具体灯光机位由剧组搞定。
问题:React 天生只会输出到网页
React 内部把界面组织成一棵"元素树"(这个盒子里有文字、那个盒子里有按钮......),但它默认只会把这棵树渲染成网页元素(HTML),不认识终端。
解法:自研一个"翻译官"
项目自己实现了一个终端版的渲染层(在 @ant/ink 这个独立小包里)。它做的事是:
- 让 React 照常工作------你还是用 React 的方式写界面、用组件、用状态、用钩子(hooks),React 照常构建那棵元素树;
- 当 React 算出"树变成这样了",翻译官接手:把这棵树翻译成终端能懂的东西------哪些字符放第几行第几列、用什么颜色、哪些地方要移动光标擦除重画。
于是开发者得到了 React 的全部便利(组件化、数据驱动、不用手动管屏幕),而用户看到的是终端界面。这就是第 1 篇提到的"自研终端界面库",它是整个项目技术难度最高的部分之一:React 内部有一套"渲染器"接口,浏览器用的是官方的网页渲染器,这个项目自己写了一个"终端渲染器"插进去。
界面长什么样:盒子套盒子
React 里做网页布局靠"盒子"(div),终端版也一样:整个界面是一个个矩形盒子嵌套------顶部状态栏是一个横盒、聊天记录区是一个竖盒、输入框是一个横盒、弹窗是盖在上面的一个居中盒。每个盒子里放文字或别的盒子。这种"盒子套盒子"的布局方式,下一章讲怎么自动算位置。
6.3 文字怎么自动对齐、换行、排版
界面是盒子套盒子,但"哪个盒子放第几行、文字怎么换行对齐"总得有人算。这就是布局引擎的活。
借用来的布局引擎:Yoga
网页上盒子怎么排(横排还是竖排、靠左还是居中、间距多少、怎么换行)有一套成熟规则叫 Flexbox(弹性盒子布局)。项目直接复用了一个叫 Yoga 的引擎------它是把网页那套布局规则做成了可以嵌进任何程序的独立库(React Native 做手机 App 也用它)。
你只需要用"网页排版语言"描述规则:
- 这个盒子竖着排列子元素;
- 这一行左右两端对齐(中间留空);
- 这段文字太长就自动换行;
- 这个区域固定高度,超出的部分可滚动;
- 左边占固定宽度,右边填满剩余空间。
Yoga 拿到这些规则和终端窗口的实际尺寸(比如 120 列 × 40 行),就能算出每个盒子、每个字符块的精确位置:第几行到第几行、第几列到第几列。
终端排版特有的难题:字符宽度
网页排版按像素算,终端排版按"格子"算,而"格子"有个麻烦:字符宽度不统一。
- 英文字母、数字:占 1 格;
- 中文、日文等全角字:占 2 格;
- emoji:可能占 2 格,而且有些 emoji 是多个符号"合体"的(比如带肤色的人脸、家庭 emoji),合起来算一个视觉字符但由多个编码组成;
- 还有带颜色/样式控制符的文字:控制符不占格子,但混在字符串里。
如果换行和对齐时数错了宽度,界面就会错位、表格对不齐、边框穿帮。所以项目有一组专门处理"字符宽度"的基础工具:
- 按"视觉字符"而不是按编码切分字符串(一个合体 emoji 不能被拦腰切断);
- 计算一段文字实际占几格;
- 做"带颜色感知"的截断和换行------截断一段红字时,不能把控制颜色的指令切坏,否则后面整屏变色;
- 长单词、中文混排时按格子边界换行。
缓存:别每次都重算宽度
字符宽度计算很频繁(每次重绘都要算每段文字多宽),而字符串内容大部分时候没变。项目把"某段文字的宽度"缓存起来,内容没变就直接用上次的结果。这种"算过就记住"的小优化在终端渲染里积少成多,是流畅度的关键之一。
6.4 为什么刷屏不卡:画面是怎么"只改变化的地方"的
AI 逐字输出时,界面每秒更新几十次,如果每次都把整个屏幕擦掉重画,终端会疯狂闪烁,而且数据量大。实际体验很顺滑,靠的是三层优化。
双缓冲:先在草稿上画好,再对比着改
程序维护两个"画面":
- 当前画面:屏幕上现在显示的内容(每个格子是什么字符、什么颜色);
- 下一画面:界面"应该变成"的样子(React + Yoga 算出来的结果)。
更新时不是直接改屏幕,而是:先在内存里把下一画面完整算出来,然后和当前画面逐格对比,找出不一样的格子,只对这些地方发"移动光标 + 写新字符"的指令。没变的格子一个指令都不发。
AI 又吐出一个字时,可能整个屏幕只有最后一行的末尾变了一格,那就只动那一格------闪烁和数据量都降到最低。这种"两帧对比、只改差异"的技术叫双缓冲 + diff(差异计算),是图形渲染的经典手法,游戏和 GUI 都用。
帧合并:变化太快就攒一批
数据变化可能比屏幕刷新还快(比如一秒来几百个文字片段)。程序不会来一个片段就重绘一次,而是把一帧时间内(约 16 毫秒,对应每秒 60 帧)的所有变化攒在一起,一次重绘。眼睛能感知的流畅度上限大约就是每秒 60 帧,超过没意义,反而浪费。
结构优化:能跳过就跳过
- React 本身会算出"哪些组件的数据没变、不用重新算";
- 渲染层会跳过完全没变的子盒子;
- 相邻的样式指令会合并(连续 50 个红字,不用每个字前都发一次"变红"指令,发一次管一段);
- 长消息列表做"虚拟化":屏幕只能显示 30 行,那列表里有成百上千条历史消息时,只渲染可见的那 30 行,滚到哪渲染到哪,不把全部消息都排版。
这几层合起来,让终端这种"原始"的显示设备也能跑近 60 帧的流畅界面。
6.5 键盘、鼠标、复制粘贴、窗口缩放,程序怎么知道你干了什么
界面是输出,这一章讲输入:你的操作怎么变成程序能理解的事件。
键盘:一串字节的解读
你按一个键,终端发给程序一个或几个字节。普通字母就是它本身,但特殊键是一串"转义序列"(又一种以 ESC 开头的约定):
- 方向键、Home/End、PageUp/PageDown 各有编码;
- 带修饰键的组合键(Ctrl+C、Shift+Tab、Alt+Enter)也是特定字节序列;
- 功能键 F1-F12 各有编码。
程序把这串字节流解析成"按键事件":按了哪个键、有没有同时按 Ctrl/Shift/Alt。还有个特殊机制叫"转义码超时"------因为 ESC 既是一个键(Esc)又是所有转义序列的开头,程序靠"ESC 后面多快跟来后续字节"来判断你是按了 Esc 还是在按一个组合键。
鼠标:要先"申请"才给你报
默认情况下终端不报告鼠标(鼠标点击通常用来选文字)。程序想接收鼠标事件,得先发一条控制指令"开启鼠标追踪"。开启后,你点击、滚轮、拖拽,终端会把"动作 + 行列坐标"编成字节报上来,程序解析成鼠标事件。退出时再关掉追踪,把选择文字的能力还给用户。
粘贴:为什么要区分"打字"和"粘贴"
逐个敲键和粘贴一大段文字,字节流看起来一样(都是字符涌进来),但程序需要区别对待:粘贴一大段代码时,不应该触发"每敲一个键就自动补全/自动提交"之类的交互。终端有个"括号粘贴"模式:开启后,粘贴的内容会被两个特殊标记包起来,程序看到标记就知道"这是一整块粘贴",整块处理而不是逐键响应。粘贴图片、文件则走文件路径/数据的方式处理。
窗口缩放:尺寸变了就重新排版
你拖动终端窗口改变大小,终端会发一个"尺寸变化"事件(新的行列数)。程序拿到新尺寸后,通知布局引擎按新尺寸重算所有盒子位置------所以你放大窗口,界面会自动重新铺满。程序里有个钩子专门订阅终端尺寸,任何依赖宽度的组件都会自动跟着调整。
焦点进出:终端窗口失焦
终端还能报告"鼠标焦点进入/离开窗口"。这用于一些细节:比如窗口失焦时把闪烁的动画暂停省电、或者在你切走时把"有新消息"的提示标在标题栏上。
把这些事件接到 React 上
所有这些原始事件,最终都被包装成 React 能用的形式:界面组件通过钩子(hooks)订阅"按键""鼠标""尺寸变化"。比如输入框组件订阅按键事件来处理输入和快捷键,消息列表订阅滚轮来处理滚动。底层字节解析的复杂性被隔离在渲染库里,业务组件只面对干净的事件。
本篇小结
- 终端只能显示字符,所谓图形界面是**字符 + 控制指令(移动光标、变色、清屏)**拼出来的;程序会按终端能力降级符号。
- 项目用 React 的方式写界面 (只描述"该长什么样",数据变了自动更新),再用自研的终端渲染器把 React 的元素树翻译成字符和指令。
- 布局复用 Yoga (网页弹性布局的独立版),自动算盒子位置;终端特有的难点是字符宽度不一(全角字、合体 emoji、颜色控制符),有专门工具按视觉宽度处理。
- 流畅度靠三层:双缓冲 diff (两帧对比只改差异格子)、帧合并 (16ms 攒一批重绘)、结构优化(跳过没变的组件、合并样式指令、长列表只渲染可见行)。
- 输入靠解析终端字节流:按键(含组合键、Esc 歧义处理)、鼠标(需先开启追踪)、粘贴(括号粘贴模式区分打字和粘贴)、窗口缩放(触发重排)、焦点进出;事件最终包装成 React 钩子供组件订阅。
下一篇讲对外连接:换模型、登录、出错重试、外接各种服务、编辑器对接、手机远程控制。