终端体验 — 从能用到好用
功能再强,用起来不舒服,就等于没有
📝 本章目标
读完本章,你将:
- 用四个 Prompt 让 AI 帮你实现彩色输出、进度反馈、更多命令和多行输入
- 理解终端颜色背后的 ANSI 转义序列——那些
\033[开头的神秘字符- 掌握从 Markdown 到终端彩色输出的渲染管线
- 学会用事件驱动架构和命令注册表模式构建可扩展的终端界面
两辆车,同一款发动机,同样的马力。一辆的仪表盘清晰明了,方向盘手感细腻,座椅包裹感恰到好处;另一辆的仪表盘字太小看不清,方向盘硬邦邦,座椅坐久了腰疼。
你会选哪辆?
答案显而易见。发动机决定了车能不能跑,但仪表盘、方向盘、座椅决定了你想不想开。
我们的 Agent 现在就像那辆”能跑但不想开”的车。功能全有——对话、工具调用、权限控制、钩子、MCP 集成——但交互体验还停留在”纯文本打印”的阶段。AI 的回复没有颜色区分,等待时没有任何反馈,输入长文本只能挤在一行里,想做什么操作得记住有限的几个命令。
这一章,我们给 Agent 装上一套舒适的仪表盘:彩色的输出让信息层次分明,旋转的进度指示器让等待不再焦虑,丰富的命令让操作随手可得,多行输入让编写长文本变得自然。
先动手造出来,再回头理解。
动手:用四个 Prompt 打造舒适的终端体验
继续在你的 harness 项目里工作。如果你跟着前面几章做了,现在应该有完整的工具系统、权限系统、钩子、技能和 MCP 集成。如果没有,去 GitHub 仓库 git checkout ch08-mcp 获取起点。
打开 Claude Code,确认你在项目根目录,然后跟着走。
Prompt 1:美化输出
我们的 Agent 现在所有输出都是白底黑字——用户输入、AI 回复、工具调用结果、错误信息全挤在一起,看起来像一面文字墙。第一步是给不同类型的内容上不同的颜色。
复制下面这段话,粘贴到 Claude Code 里:
帮我美化终端输出:
1. AI 的回复要渲染成好看的样子——
标题加粗,代码块带边框和语法高亮,
列表有缩进,链接有颜色,粗体和斜体也要能显示。
2. 不同类型的信息用不同颜色区分——
用户输入用一种颜色,AI 回复用另一种,
系统提示用灰色,错误信息用红色。
3. 工具调用的过程也要好看——
调用了什么工具、传了什么参数、返回了什么结果,
用缩进和颜色区分开来。
选一个现成的终端渲染库来做这件事,
不要自己从零造轮子。
等 AI 跑完,它会帮你引入终端渲染库(通常是 rich),重构输出逻辑。试一下:
$ harness
Harness v0.1.0 — AI Agent Runtime
You > 用 Python 写一个快速排序
Assistant >
┌─────────────────────────────────────────┐
│ 好的,这是一个 Python 快速排序实现: │
│ │
│ ```python │
│ def quicksort(arr): │
│ if len(arr) <= 1: │
│ return arr │
│ pivot = arr[0] │
│ left = [x for x in arr if x < ...] │
│ ... │
│ ``` │
│ │
│ **时间复杂度**:平均 O(n log n) │
└─────────────────────────────────────────┘
You > exit
颜色和格式出来了,但等 AI 回复的时候屏幕上什么都没有——用户不知道系统是在工作还是卡死了。
Prompt 2:进度反馈
帮我加上进度反馈:
1. 等 AI 回复时显示一个旋转的加载动画,
旁边写上"思考中"之类的提示。
2. AI 回复如果很长,改成一个字一个字地显示出来,
像打字一样,而不是等全部生成完才一次性蹦出来。
3. 工具执行的时候也要有提示——
告诉用户正在执行什么,执行完了结果如何。
加载动画要优雅,不要闪烁太快也不要太慢。
$ harness
You > 帮我看看项目里有没有安全问题
⠋ 思考中...
Assistant > 我来检查一下项目的安全状况。
⚙ 执行工具:grep_search
参数:pattern="password|secret|api_key"
✓ 完成(0.3s)— 找到 3 处匹配
⚙ 执行工具:file_read
参数:path="config.py"
✓ 完成(0.1s)
⠋ 思考中...
Assistant > 发现了几个潜在的安全问题:
(AI 的回复一个字一个字地流式显示)
好看了,也有反馈了。但现在能用的命令只有 /help 和 /skill——太少了。
Prompt 3:更多命令
帮我加一批实用命令:
/clear — 清空当前对话,重新开始
/history — 显示对话历史
/model — 查看或切换当前使用的 AI 模型
/cost — 显示当前会话消耗了多少 token、花了多少钱
/export — 把当前对话导出成一个文件
/compact — 手动压缩对话上下文
/tools — 查看当前可用的所有工具
每个命令都要有简短的帮助说明。
/help 也要更新,列出所有新命令。
命令的实现方式要方便以后继续添加新命令——
加一个新命令应该只需要写一小段代码,
不用改动已有的代码。
$ harness
You > /help
Available commands:
/help — 显示此帮助信息
/clear — 清空对话,重新开始
/history — 显示对话历史
/model — 查看或切换 AI 模型
/cost — 显示 token 用量和费用
/export — 导出对话到文件
/compact — 手动压缩上下文
/tools — 查看可用工具列表
/skill — 查看或激活技能
You > /cost
Session cost:
Input tokens: 1,234
Output tokens: 567
Total cost: $0.0042
You > /model
Current model: claude-sonnet-4-20250514
功能齐全了。最后一个痛点——输入长文本。
Prompt 4:多行输入
帮我支持多行输入:
按回车不再直接发送,而是换行;
用一个特别的方式来表示"输入完了,发送"——
比如按两次回车、或者按某个快捷键。
另外也支持直接粘贴多行文本——
从别的地方复制一大段内容粘过来,
应该能正确识别为一整条消息,不会提前发送。
输入时要能看到行号,方便对照。
$ harness
You > 帮我审查下面这段代码:
... 1│ def process(data):
... 2│ result = eval(data["expr"])
... 3│ os.system(f"echo {result}")
... 4│ return result
... 5│
(按两次回车发送)
⠋ 思考中...
Assistant > 这段代码有严重的安全问题:
1. **`eval()` 注入** — 直接对用户数据调用 eval...
2. **命令注入** — 用 f-string 拼接 os.system...
💡 四个 Prompt 做了什么
- Prompt 1 给了眼睛——彩色输出让信息层次分明
- Prompt 2 给了脉搏——进度反馈让系统有了生命感
- Prompt 3 给了双手——丰富的命令让操作随手可得
- Prompt 4 给了笔记本——多行输入让表达不再受限
现在你手里有了一个用着舒服的 Agent。完整代码在 GitHub 仓库,对应 tag
ch09-terminal。接下来我们回过头,理解你刚刚构建的东西。
深入理解
ANSI 转义序列
终端里的颜色不是魔法。每一种颜色、加粗、下划线,都是通过一段特殊字符序列实现的——ANSI 转义序列。
原理很简单:终端程序逐字符读取输出。当它读到 \033[(ESC 加上左方括号)时,就知道”接下来的字符不是要显示的文字,而是一条格式指令”。终端解析这条指令,改变后续文字的显示样式,直到遇到下一条指令或者重置命令 \033[0m。
举个例子:
# ANSI 转义序列基础示例
# 红色文字
print("\033[31mThis is red\033[0m")
# 绿色加粗
print("\033[1;32mBold green\033[0m")
# 蓝色背景 + 白色文字
print("\033[44;37mWhite on blue\033[0m")
\033 是 ESC 字符(ASCII 27),[ 是控制序列引导符(CSI),后面的数字是具体指令,m 表示”这是一条样式指令”。多个指令用分号分隔。
常用的指令编号:
表 9-1:常用 ANSI 样式代码
| 代码 | 效果 | 代码 | 效果 |
|---|---|---|---|
0 | 重置所有样式 | 1 | 加粗 |
2 | 变暗 | 3 | 斜体 |
4 | 下划线 | 7 | 反色 |
30-37 | 前景色(标准 8 色) | 40-47 | 背景色(标准 8 色) |
90-97 | 前景色(高亮 8 色) | 100-107 | 背景色(高亮 8 色) |
38;5;N | 前景色(256 色) | 48;5;N | 背景色(256 色) |
38;2;R;G;B | 前景色(真彩色) | 48;2;R;G;B | 背景色(真彩色) |
从最早的 8 色,到 256 色,再到 RGB 真彩色——终端的色彩能力随着标准的演进越来越丰富。现代终端(iTerm2、Windows Terminal、Kitty)基本都支持真彩色,可以显示 1600 万种颜色。
为什么不直接手写 ANSI 序列?
原因有三:
- 可读性差——
\033[1;38;2;212;115;76m谁能一眼看出这是什么颜色? - 终端兼容性——不是所有终端都支持真彩色。有些古老的终端只支持 8 色
- 重复劳动——每次输出都要手动拼接重置码,漏了一个
\033[0m后面全变色
所以我们用库来封装这些细节。Python 的 rich 库就是做这件事的——你告诉它”这段文字要红色加粗”,它自动生成对应的 ANSI 序列,还能检测终端能力、自动降级。
Markdown 渲染管线
Claude 的回复是 Markdown 格式——标题用 #,代码块用 ```,粗体用 **,列表用 -。要把这些 Markdown 变成终端里好看的彩色输出,需要一条渲染管线。
图 9-1:Markdown 到终端彩色输出的渲染管线
- 原始 Markdown → Claude API 返回的文本流
- 流式缓冲 → 按块累积,检测完整的 Markdown 元素
- Markdown 解析 → 识别标题、代码块、列表、粗体等
- 样式映射 → 每种元素映射到对应的终端样式
- ANSI 渲染 → 生成带转义序列的终端输出
- 终端显示 → 用户看到彩色、有格式的文字
管线中最有挑战的环节是流式缓冲。AI 的回复是一个 token 一个 token 流过来的——可能先来一个 ```,过一会儿来个 python,再过一会儿来代码内容。你不能一看到 ``` 就断定”代码块开始了”,因为也许下一个 token 会告诉你这其实是行内代码。
解决方案是贪心缓冲:遇到可能是多字符标记的起始时(如 ```、**、#),先把后续 token 缓冲起来,等看到足够的上下文再决定怎么渲染。这会引入微小的延迟,但保证了渲染的正确性。
Rich 库的核心架构
rich 不是简单地”在字符串里插入 ANSI 代码”。它有一套完整的渲染架构:
# harness/rendering.py — Rich 渲染管线
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.syntax import Syntax
console = Console()
def render_assistant_message(text: str):
"""把 AI 的 Markdown 回复渲染成彩色终端输出"""
md = Markdown(text)
console.print(Panel(
md,
title="Assistant",
border_style="blue",
padding=(1, 2),
))
def render_tool_call(name: str, args: dict, result: str):
"""渲染工具调用过程"""
console.print(f"[dim]⚙ 执行工具:[bold]{name}[/bold][/dim]")
for k, v in args.items():
console.print(f"[dim] {k}={v}[/dim]")
console.print(f"[green] ✓ 完成[/green]")
Console 是 Rich 的核心——它检测终端能力(宽度、色深、是否支持 Unicode),所有输出都通过它。Markdown 解析器把 Markdown 文本解析成内部的 RenderableType 树,然后 Console 把这棵树渲染成 ANSI 序列。
这里有一个重要的设计决策:不在 Markdown 上做字符串替换,而是先解析成结构化的树,再从树渲染到终端。这样换一种输出目标(比如 HTML)只需要换渲染器,不需要重写解析逻辑。
事件驱动架构
Prompt 2 加的”进度反馈”看起来简单,但它暴露了一个架构问题:谁来决定什么时候显示什么?
在最初的实现里,代码大概长这样:
# 旧方式:直接打印
def query_loop(state):
while True:
user_input = input("You > ")
print("思考中...")
response = call_api(state)
print(response.text)
for tool in response.tools:
print(f"执行:{tool.name}")
result = execute(tool)
print(f"结果:{result}")
问题出在哪里?query_loop 既负责业务逻辑(调 API、执行工具),又负责界面展示(打印信息)。这两个职责纠缠在一起,导致:
- 想改输出格式就得改业务逻辑代码
- 想跑自动化测试就得处理一堆
print输出 - 想做一个 Web 界面就得把整个
query_loop重写
解决方案是事件驱动——业务逻辑只负责产生事件,界面层订阅事件并决定怎么展示:
# 新方式:事件驱动
def query_loop(state):
while True:
user_input = input("You > ")
yield Event("thinking_start")
response = call_api(state)
yield Event("thinking_end")
yield Event("response", text=response.text)
for tool in response.tools:
yield Event("tool_start", name=tool.name)
result = execute(tool)
yield Event("tool_end", name=tool.name,
result=result)
业务逻辑通过 yield 发出事件,完全不关心这些事件怎么被展示。终端界面层(或 Web 界面层、或测试层)各自监听这些事件,做自己的事:
图 9-2:事件驱动 vs 直接打印
- 业务逻辑层 → yield Event(…) — 只产生事件,不做任何展示
- 事件分发器 → 把事件路由到所有已注册的监听器
- 终端渲染器 → 监听事件,用 Rich 渲染彩色输出
- 日志记录器 → 监听事件,写入日志文件
- Web 前端 → 监听事件,通过 WebSocket 推送到浏览器
同一套业务逻辑,接上不同的渲染器就能适配不同的界面。这就是关注点分离的威力。
💡 核心概念:Generator 模式与事件流
Python 的
yield让函数变成一个生成器(Generator)——调用它不会一次性执行完,而是每次执行到yield就暂停,把值交给调用者,等调用者准备好了再继续。这天然适合事件驱动架构:业务逻辑不断
yield事件,调用者逐个消费这些事件。整个过程是惰性求值的——事件只有在被消费时才会驱动下一步执行。对比回调(callback)模式:回调需要事先注册,逻辑分散在各个回调函数里,调试时调用栈跳来跳去。Generator 模式的逻辑是线性的,
yield之后的代码就是”事件被处理后的下一步”,读起来和写起来都更直觉。
命令系统设计
Prompt 3 要求”加一个新命令应该只需要写一小段代码,不用改动已有的代码”。这是一个典型的开闭原则需求——对扩展开放,对修改关闭。
实现方式是**命令注册表(CommandRegistry)**模式:
# harness/commands.py — 命令注册表
from dataclasses import dataclass
from typing import Callable
@dataclass
class Command:
name: str
description: str
handler: Callable
class CommandRegistry:
def __init__(self):
self._commands: dict[str, Command] = {}
def register(self, name: str, description: str,
handler: Callable):
self._commands[name] = Command(name, description,
handler)
def execute(self, name: str, args: str, state):
cmd = self._commands.get(name)
if cmd is None:
return f"Unknown command: /{name}"
return cmd.handler(args, state)
def list_all(self) -> list[Command]:
return sorted(self._commands.values(),
key=lambda c: c.name)
注册一个新命令只需要一行:
# 注册命令示例
registry = CommandRegistry()
registry.register("clear", "清空对话,重新开始",
lambda args, state: state.reset())
registry.register("cost", "显示 token 用量和费用",
lambda args, state: format_cost(state.usage))
registry.register("model", "查看或切换 AI 模型",
lambda args, state: switch_model(args, state))
更优雅的方式是用装饰器:
# 用装饰器注册命令
def command(name: str, description: str):
def decorator(func):
registry.register(name, description, func)
return func
return decorator
@command("cost", "显示 token 用量和费用")
def cmd_cost(args: str, state):
input_tokens = state.usage.input_tokens
output_tokens = state.usage.output_tokens
cost = calculate_cost(input_tokens, output_tokens)
return (
f"Input tokens: {input_tokens:>8,}\n"
f"Output tokens: {output_tokens:>8,}\n"
f"Total cost: ${cost:.4f}"
)
现在所有已注册的命令一览:
表 9-2:Harness 命令列表
| 命令 | 功能 | 来源 |
|---|---|---|
/help | 显示帮助信息,列出所有可用命令 | 内置 |
/clear | 清空当前对话上下文,重新开始 | 内置 |
/history | 显示当前会话的对话历史 | 内置 |
/model | 查看当前模型或切换到另一个模型 | 内置 |
/cost | 显示本次会话的 token 用量和估算费用 | 内置 |
/export | 把当前对话导出为 JSON 或 Markdown 文件 | 内置 |
/compact | 手动触发上下文压缩 | 内置 |
/tools | 列出当前可用的所有工具(含 MCP 工具) | 内置 |
/skill | 查看或激活一个技能包 | 第 7 章 |
想扩展?在 .harness/commands/ 目录放一个 Python 文件,定义一个函数、用装饰器注册——启动时自动发现并加载。和技能系统的设计思路如出一辙:把扩展点暴露给用户,让他们不用改核心代码就能加功能。
命令解析
用户输入 /model claude-sonnet-4-20250514 时,需要把它拆成命令名和参数:
# 命令解析逻辑
def parse_command(input_text: str):
"""解析斜杠命令,返回 (name, args) 或 None"""
if not input_text.startswith("/"):
return None
parts = input_text[1:].split(maxsplit=1)
name = parts[0]
args = parts[1] if len(parts) > 1 else ""
return (name, args)
简单到几乎不值一提——但这种”简单”是刻意设计的。命令系统的复杂度应该在命令本身,而不是在解析和分发逻辑上。解析器就做一件事:拆分命令名和参数。分发器就做一件事:查表调用。每个命令的 handler 各自负责自己的复杂逻辑。
Claude Code 的终端实现
我们用 Python + Rich 做终端渲染,功能够用,但和 Claude Code 的终端体验比起来还是有差距。来看看 Claude Code 做了什么不一样的事。
Ink:终端里的 React
Claude Code 的终端界面不是用传统的”逐行打印”方式构建的,而是用了一个叫 Ink 的框架——它让你用 React 组件的方式来构建终端 UI。
// Ink 组件示例(概念性)
import { render, Text, Box } from "ink";
import Spinner from "ink-spinner";
function ThinkingIndicator({ message }) {
return (
<Box>
<Spinner type="dots" />
<Text color="gray"> {message}</Text>
</Box>
);
}
function ToolCallDisplay({ name, status }) {
return (
<Box flexDirection="column" paddingLeft={2}>
<Text>
<Text color="yellow">⚙</Text> {name}
</Text>
<Text color="green"> ✓ {status}</Text>
</Box>
);
}
这段代码看起来就像写 Web 前端——<Box> 对应 <div>,<Text> 对应 <span>,flexDirection 是 Flexbox 布局。但它渲染的目标不是浏览器,而是终端。
Ink 在终端上模拟了一个虚拟 DOM:
图 9-3:Ink 的虚拟 DOM 终端渲染流程
- React 组件树 → JSX 转为 Virtual DOM
- Diff 计算 → 新旧 DOM 差异
- Yoga 布局 → Flexbox 转为行列坐标
- ANSI 输出 → 只更新变化的区域
为什么要用这么”重”的方案?因为 Claude Code 的终端界面不是简单的”打印文本”——它有多个同时更新的区域:顶部是对话内容,底部是输入框,中间可能有工具调用的进度条,侧边可能有 token 计数。这些区域需要独立更新,互不干扰。传统的逐行打印做不到这一点——你打印了一行新内容,就没法回头修改上面的内容了。
Ink 通过接管整个终端屏幕,用光标定位和局部重绘来实现多区域更新。就像游戏引擎不是一行行画像素,而是管理一个帧缓冲区,每帧只更新变化的部分。
柔和的色彩系统
注意 Claude Code 的配色——不是鲜艳的纯色,而是柔和的低饱和色(pastel)。这不是随便选的,而是经过深思熟虑的设计决策:
- 暗色主题下纯白文字太刺眼,Claude Code 用
#C0A090这样的暖灰色做正文 - 代码高亮用低饱和度的蓝、绿、橙——长时间看不累
- 错误信息用暗红而不是亮红——严肃但不刺激
- 系统信息用灰色——存在但不抢注意力
💡 终端配色的经验法则
好的终端配色遵循三条原则:
- 信息层级——最重要的信息用最醒目的颜色(通常是暖色:橙、赤),次要信息用冷色或灰色
- 对比度足够——在暗色背景上,文字颜色的亮度至少要达到 WCAG AA 标准(4.5:1 对比度)
- 颜色数量克制——全局不超过 5-6 种主色。颜色太多等于没有颜色——什么都强调就是什么都不强调
用户体验的细节
“好用”藏在细节里。几个值得深挖的 UX 细节:
Spinner:等待的艺术
等待时的旋转动画看起来是个小事,但有讲究:
表 9-3:常见 Spinner 样式对比
| 样式 | 帧序列 | 适用场景 |
|---|---|---|
| dots | ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ | 通用,最常见 |
| line | -|/ | 简洁,ASCII 兼容 |
| arc | ◜◠◝◞◡◟ | 优雅,适合等待较长操作 |
| bounce | ⠁⠂⠄⠂ | 轻快,适合短操作 |
Claude Code 用的是 dots 系列的 Braille 点阵动画——8 帧循环,每帧间隔约 80ms。太快了会感觉焦躁(< 50ms),太慢了会感觉卡顿(> 150ms),80ms 刚好给人一种”正在流畅运转”的感觉。
流式输出:一个字一个字来
AI 回复的流式输出不仅是体验优化,更是感知性能优化。假设一次回复需要 3 秒生成完毕:
- 非流式:等 3 秒,突然出现一大段文字。用户感觉”卡了 3 秒”
- 流式:0.2 秒后开始逐字出现,3 秒后全部显示完。用户感觉”几乎立即就开始回答了”
实际等待时间一样,但感知完全不同。这就是**首字节时间(Time to First Token)**的价值。
流式输出的实现用的是 API 的 streaming 模式。后端每生成一个 token 就立即推送,前端收到就立即渲染。我们在第 2 章就实现了 streaming,现在只需要把”直接打印 token”改成”通过 Rich 渲染 token”。
多行输入的实现
多行输入看似简单,实际上要处理几种不同的情况:
键盘输入。 用户敲回车时,不发送,而是换行。双击回车(或 Ctrl+D)才发送。这需要用 prompt_toolkit 或类似的库来接管键盘输入——Python 内置的 input() 做不到这种级别的控制。
粘贴检测。 用户从外部粘贴多行文本时,终端会在极短时间内(< 5ms)收到大量回车。正常手动输入两次回车之间至少有几十毫秒。利用这个时间差可以区分”粘贴的换行”和”用户主动按的回车”。
行号显示。 多行模式下显示行号,用对齐的灰色数字,帮助用户对照代码的行数。
# 多行输入的核心逻辑(简化)
from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings
bindings = KeyBindings()
@bindings.add("enter", "enter")
def submit(event):
event.current_buffer.validate_and_handle()
@bindings.add("enter")
def newline(event):
event.current_buffer.insert_text("\n")
session = PromptSession(
multiline=True,
key_bindings=bindings,
prompt_continuation="... ",
)
user_input = session.prompt("You > ")
prompt_toolkit 是 Python 终端输入的瑞士军刀——它能接管所有键盘事件,支持自定义按键绑定、语法高亮、自动补全。IPython 和很多 CLI 工具底层都用的它。
进度条与 Token 计数
长时间的工具执行(比如跑测试)应该有进度反馈。问题是——大多数工具执行时你不知道总量,所以没法显示”50% 完成”。
解决方案是脉冲式进度条(indeterminate progress bar)——不显示百分比,只显示一条来回滚动的光带,表示”正在执行中”。Rich 库自带这种进度条:
# 脉冲式进度条
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
TextColumn("[bold blue]{task.description}"),
) as progress:
task = progress.add_task("执行工具...", total=None)
result = execute_tool(tool)
progress.remove_task(task)
total=None 告诉 Rich “我不知道总量”,它就会自动切换到脉冲模式。
Token 计数则是另一种反馈——不是实时进度,而是累积统计。在输入框旁边或状态栏里显示 tokens: 1.2k / 200k,让用户时刻知道上下文窗口还剩多少空间。这个信息决定了”要不要手动 /compact”——如果快撑满了,压缩一下比”突然收到报错”友好得多。
延伸思考
在进入下一章之前,想想这几个问题:
- 我们的渲染层和业务逻辑通过事件解耦了。如果要做一个 Web 界面版本的 harness,事件系统需要怎么改?WebSocket 推送和终端打印在事件消费方式上有什么根本区别?
- Claude Code 用 Ink(React for Terminal)来构建 UI,我们用 Rich(Python 库)。两种方案的本质差异在哪里——是语言选择的结果,还是架构理念的不同?在什么场景下”组件化终端 UI”比”逐行渲染”更合适?
- 终端 UI 的信息密度是有限的——屏幕宽度通常只有 80-120 列。当 AI 一边流式输出回复、一边触发工具调用、工具又在后台跑——这三件事同时发生时,你会怎么安排终端的空间分配,才能让用户不迷失?
章节小结
- 四个 Prompt 构建了舒适的终端体验:彩色输出 → 进度反馈 → 命令系统 → 多行输入
- ANSI 转义序列是终端颜色的底层原理:
\033[开头的控制序列改变文字样式,从 8 色到真彩色不断演进 - Markdown 渲染管线把 AI 的回复变成彩色终端输出:流式缓冲 → Markdown 解析 → 样式映射 → ANSI 渲染
- 事件驱动架构解耦了业务逻辑和界面展示:
yield Event(...)让同一套逻辑适配不同的界面 - CommandRegistry 模式让命令系统可扩展:注册一个新命令只需要一行代码,不用改已有逻辑
- Claude Code 用 Ink(React for Terminal)+ 虚拟 DOM 实现多区域独立更新的终端界面
- 用户体验藏在细节里:Spinner 的帧率、流式输出的首字节时间、多行输入的粘贴检测、柔和的配色系统
- 下一章我们给 Agent 加上测试和质量保障——确保这些功能不会在迭代中悄悄坏掉