CLI 第一版是 readline 加 console.log。用户输入一行,屏幕上滚一堆字,再输入下一行。
能用,但很糙。模型正在输出的时候你看不出它在输出还是卡死了,工具失败和成功长得一样,改了文件不知道改了哪几行。
换成 Ink 之后界面像样了。Ink 是 React 的终端渲染器,用组件描述界面,它负责算差异重绘。这是依赖,该装。
但接下来我列了一串想加的东西:spinner、进度条、diff 染色、语法高亮、颜色检测。按正常思路每个都有现成的包,ink-spinner、cli-progress、diff、cli-highlight、supports-color。
最后一个都没装。
倒不是说"零依赖"是什么信仰。是这个项目的定位决定的:一个装在用户机器上、要跑在任何终端里的 CLI,依赖树每多一层都是风险。用户可能在 tmux 里,可能 ssh 到一台老服务器,可能 $TERM 是 dumb,可能终端只支持 8 色。多一个包就多一组我控制不了的行为。
下面写这些东西各花多少行,以及哪几处我到现在还没收拾。
确认卡有四档
这是整个界面里最要紧的一块,它直接决定 Agent 能不能用。
const options = [
{ label: '允许(执行一次)', value: 'allow' },
{ label: '允许(本会话不再询问)', value: 'allow_session' },
{ label: '编辑参数后执行', value: 'edit' },
{ label: '拒绝', value: 'reject' },
];
前两档是常识,第三档是很多人会漏掉的。
第二档的必要性不用多解释。一个中等规模的任务,模型要调二十几次 read_file。每次都弹一个确认框、等你按方向键、再回车,实际体验是你在给一个只会问"我能看看这个文件吗"的实习生当门卫。所以要有"本会话不再询问"。
它记的确实只有工具名:
export class PermissionState {
/** "本会话不再询问":allow_session 决策的记忆(会话内有效) */
private sessionAllowed = new Set<string>();
/** registry 收到 allow_session 决策时记入。 */
allowSession(tool: string): void {
this.sessionAllowed.add(tool);
}
isSessionAllowed(tool: string): boolean {
return this.sessionAllowed.has(tool);
}
}
这里有个必须说清楚的风险。你对 run_command 选了"本会话不再询问",这个会话里模型跑的每一条命令都不会再问你,包括参数完全不同的命令。
allowSession 只收到一个工具名,没有参数,也没有按命令前缀区分。它没法分辨"允许 pnpm test"和"允许 rm -rf"。
我当时的想法是"用户自己选的,后果自负"。可界面上那行字写的是"允许(本会话不再询问)",用户读到的是"这一个操作",代码实现的是"这个工具的所有操作"。这是文案和实现之间的语义差,不是我设计时的取舍。
更合理的做法是对 run_command 禁用这一档,或者只允许记住命令的第一个词。两件都没做。
第三档是「编辑参数后执行」,我觉得这是四档里最有价值的一个。
模型传错参数是常事。路径写错一个字母、limit 传成字符串、正则少个转义。正常交互是:你拒绝,然后在对话框里用自然语言说"路径应该是 src/core 不是 src/cores",模型重新理解、重新生成、再弹一个确认框。三轮下来你只想关掉终端。
有这一档,你按一下方向键,光标落到一个预填了当前参数的 JSON 框里:
function EditArgs(props: {
request: ConfirmRequest;
onSubmit: (args: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(() => JSON.stringify(props.request.args, null, 0));
const [error, setError] = useState('');
return (
<Box flexDirection="column">
<Text color={theme.user}>编辑参数(JSON,回车提交,Esc 取消):</Text>
{error ? <Text color={theme.error}>{error}</Text> : null}
<TextInput
value={value}
onChange={setValue}
onSubmit={(v) => {
try {
const parsed = JSON.parse(v) as Record<string, unknown>;
props.onSubmit(parsed);
} catch (e) {
setError(`JSON 解析失败: ${(e as Error).message}`);
}
}}
/>
<Text dimColor>(取消请 Ctrl+C 中断本次会话操作)</Text>
</Box>
);
}
两个细节。JSON.stringify(args, null, 0) 第三个参数是 0,不缩进。参数里有长字符串(比如 write_file 的 content),一缩进就占满屏幕还得上下滚,压成一行至少能看见开头和结尾。
另一个是解析失败时不清空输入。清空最省事,但用户刚敲了半天的参数就没了。这里只把错误显示在上面,value 保持原样,用户可以在原来那串上改掉多余的逗号。
onSubmit 成功后直接把 { kind: 'edit', args } 传出去,不再重新确认,改完的参数据此执行。这是故意的。理论上用户能在这一步把 rm -rf ~ 塞进 run_command 的 command 里,绕过所有权限判断。但我认为这不算漏洞:能在这个框里打字的人本来就坐在键盘前。权限系统防的是模型,不是防用户自己。
edit_file 的确认框还会额外画一个彩色对照:
function EditPreview({ oldStr, newStr }: { oldStr: string; newStr: string }) {
const clip = (s: string): string[] => {
const lines = s.split('\n').slice(0, 10);
if (s.split('\n').length > 10) lines.push(`…(其余 ${s.split('\n').length - 10} 行省略)`);
return lines;
};
return (
<Box flexDirection="column" paddingLeft={2}>
{clip(oldStr).map((l, i) => (
<Text key={`o${i}`} color={theme.diffDel}>
- {l}
</Text>
))}
{clip(newStr).map((l, i) => (
<Text key={`n${i}`} color={theme.diffAdd}>
+ {l}
</Text>
))}
</Box>
);
}
旧行红前缀 -,新行绿前缀 +,各截前 10 行。这不是完整的 diff,只是把"要删什么、要加什么"摊开。确认之前能看见内容,比看一行 edit_file({"path":"..."}) 强太多。
这一档还有一处我没收拾干净。EditArgs 的标题写着「编辑参数(JSON,回车提交,Esc 取消)」,底部又写「取消请 Ctrl+C 中断本次会话操作」,同一屏两个提示互相打架,而且都不对。
EditArgs 收了 onCancel 参数,但整个函数体里没有任何地方调用它。死参数,传进来的 () => setEditing(false) 永远不会执行。
那按 Esc 会怎样?app.tsx 里只有一个 useInput:
// Esc interrupts the running task
useInput((_input, key) => {
if (key.escape && running) {
abortRef.current?.abort();
}
});
确认框出现时 running 是 true,所以 Esc 不是"取消编辑",是"中止整个任务"。
三个说法指向三种行为:标题说取消编辑,底部说中断会话,实际做的是中止当前任务。用户按 Esc 会发现整个任务没了,不只是编辑框关了。
这是"两个人在不同时间写同一块界面"留下的痕迹。标题照设计稿写的,onCancel 是按组件拆分预留的,底部那行是后来发现退不出去临时加的说明。三句话都对的时候没人在,改了一处,另外两处没跟上。
确认卡的分支:
graph TD
A["工具要执行<br/>权限判定返回 confirm"] --> B["画确认卡<br/>工具名 + 原因 + 参数摘要"]
B --> C["四档选择"]
C -->|允许一次| D["kind = allow"]
C -->|本会话不再询问| E["kind = allow_session<br/>按工具名记住"]
C -->|拒绝| F["kind = reject<br/>模型收到拒绝原因"]
C -->|编辑参数| G["弹出预填 JSON 的输入框"]
G --> H{"JSON 能解析"}
H -->|能| I["kind = edit<br/>带上改后的 args"]
H -->|不能| J["红字显示错误<br/>输入内容保留"]
J --> G
D --> K["执行工具"]
E --> K
I --> K
J 回到 G 那个环是我特意留的:清空输入等于让人前功尽弃,宁可让他看着错误信息改。
光标不是动画
模型输出到一半时,那一行末尾有个 ▋ 在闪。实现它没用一个字符计时器,也没有独立的光标状态。
case 'text_delta':
if (assistantItemId === null) {
const it = addItem({ kind: 'assistant', text: '', pending: true }); // pending = 流式光标
assistantItemId = it.id;
}
assistantText += e.text;
appendToItem(assistantItemId, e.text);
break;
pending 是 item 上一个布尔字段,渲染时:
{it.pending ? <Text color={theme.cursor}>▋</Text> : null}
光标不是"动画",是"这个 item 还没完成"的可视化。它不需要 tick,不需要 requestAnimationFrame,状态变了重绘一次就行。
这套设计的好处是它对所有没完成的东西都适用。thinking 块有光标,工具调用行也有:
case 'tool_call_start': {
collectedCalls.push(e.call);
const argsStr = JSON.stringify(e.call.args ?? safeParseArgs(e.call.argsJson));
const shown = argsStr.length > 160 ? argsStr.slice(0, 160) + '…' : argsStr;
addItem({ kind: 'tool', text: `${icons.tool} ${e.call.name}(${shown})`, pending: true });
break;
}
工具行一出现就带光标,结果回来时摘掉光标、内容换成结果。中间那段等待,用户看到的是"这一行还在跑",不是"界面卡住了"。
思考流也一样,只是颜色更暗:
case 'thinking_delta':
if (thinkingItemId === null) {
const it = addItem({ kind: 'thinking', text: '', pending: true });
thinkingItemId = it.id;
}
appendToItem(thinkingItemId, e.text);
break;
thinking 的内容不入库,只在屏幕上滚过去,关掉终端就没了。这个决定有点争议,有人想要完整的思考记录。我觉得存下来只会让会话表变成垃圾场,而且 reasoning 模型的思考过程经常自相矛盾,存了也没法复盘。
思考流和正式输出不能同时存在。模型开始输出正文时,思考块要立刻收掉光标:
case 'text_delta':
if (thinkingItemId !== null) {
updateItem(thinkingItemId, { pending: false });
thinkingItemId = null;
}
不然屏幕上两个光标,一个在思考块末尾,一个在正文末尾,看着像两个东西同时在跑。
好看的部分到这儿。收光标这个动作,藏着一个我一直没改的匹配问题。
工具结果回来时要找到对应的 pending 行,代码是这么找的:
setItems((prev) =>
prev.map((it) =>
it.kind === 'tool' && it.pending && it.text.startsWith(headerPrefix)
? { ...it, pending: false, ok: e.result.ok, text: `${header}\n${resultText}`, diff }
: it,
),
);
headerPrefix 是 ⚙ 工具名,pending 行创建时是 ⚙ 工具名({参数}),按名前缀能匹配上。
问题是 prev.map 不会在第一个命中处停下。两个同名的并行调用会同时被第一个结果覆盖。
这不是理论问题。引擎的 executeEach 是"谁先完成谁先 yield",模型完全可以在一轮里发两个 read_file。第一个结果回来,两行 pending 都匹配 ⚙ read_file,都变成第一份结果;第二个结果回来,已经没有 pending 行了,map 什么都不改,第二份结果在界面上消失(库里还有,只是看不见)。
设计文档里我自己写过一行:
pending 项文本前缀匹配本就脆弱
当时的处理只是顺手把"工具成败"从 startsWith('⚙') 前缀判断改成结构化的 ok 字段,匹配机制一个字没动。要改得给每个 pending 行存 call.id,按 id 找。不难,但要做就得改 Item 类型、改两处创建、改一处更新,而我当时在赶别的功能。
同一个地方还有个小洞。run 结束时 finally 会清光标:
setItems((prev) =>
prev.map((it) => (it.kind === 'assistant' || it.kind === 'thinking') ? { ...it, pending: false } : it),
);
只清了 assistant 和 thinking,没清 tool。如果在工具执行期间按 Esc 中止,那个工具行的 pending 永远不会被摘,光标一直闪在那儿。任务已经结束了,界面还挂着一个"正在执行"的假象。
事件到界面的流转长这样:
graph TD
A["turn_start"] --> B["状态行更新轮次"]
B --> C["text_delta"]
C --> D{"已有 assistant 项"}
D -->|没有| E["建一项,pending = true<br/>尾部显示流式光标"]
D -->|有| F["追加文本"]
E --> F
C -.->|先收掉思考流| G["thinking 项 pending = false"]
H["tool_call_start"] --> I["建工具行,pending = true<br/>参数超 160 字截断"]
I --> J["tool_result"]
J --> K["按名前缀找 pending 行<br/>摘光标,贴结果和 diff"]
K --> L{"是 todo_write"}
L -->|是| M["追加一行 N/M 完成"]
C --> N["run 结束 finally"]
J --> N
N --> O["assistant 和 thinking 的 pending 全清"]
O 少了 tool,就是上面那个洞。
spinner 十行
/** F-36 运行状态行 spinner 的盲文字符帧(80ms/帧) */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
function useSpinnerFrame(active: boolean): string {
const [frame, setFrame] = useState(0);
useEffect(() => {
if (!active) return;
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
return () => clearInterval(id);
}, [active]);
return SPINNER_FRAMES[frame]!;
}
ink-spinner 做的就是这件事。我手写是因为它总共十行,而多一个包就多一件要处理的事:它的 Ink 版本依赖会不会跟我这个冲突。
| 用盲文字符不用 ` | /-` 是为了等宽。⠋ 和 ⠙ 在终端里占同一个格子,切换时行高行宽都不变,只有那一个字符在变。` | 和/` 在某些字体下宽度不同,spinner 一转整行会抖。 |
80ms 是试出来的。50ms 看着焦虑,120ms 像卡顿。
active 为 false 时在 return 之前不建 interval,这个顺序很重要。先建再判断的话,每次渲染都留一个定时器,几十秒后进程里堆着上百个 interval,CPU 上去,界面更卡。
进度条只在有意义的时候画
function f36TurnBar(turn: number, maxTurns: number): string {
if (!Number.isFinite(maxTurns) || maxTurns <= 0 || maxTurns > 1000) return '';
const filled = Math.min(10, Math.max(0, Math.ceil((turn / maxTurns) * 10)));
return ` [${'█'.repeat(filled)}${'░'.repeat(10 - filled)}]`;
}
maxTurns > 1000 这个判断是关键。
引擎的 maxTurns 默认值很大,语义上等于"不限制"。照着 turn / maxTurns 画,跑三轮进度条是空的,跑三十轮还是空的。一个永远空的进度条比没有更糟,它会让人怀疑是不是坏了。
所以只在用户显式设了有限值(比如 /turns 20)时才画。默认情况下状态行只有文字:
第 3 轮 · 12.4k 入 / 3.1k 出 · $0.08
这行信息量比进度条大。轮次告诉你还要多久,token 告诉你上下文涨得多快,费用告诉你钱包。三个数都是流式的,每轮更新。
diff 在 core,不在 UI
改文件的工具执行完,界面上会显示改了哪几行。这部分逻辑没有一行在 ui/ 目录里。
设计文档里这条写得很清楚:
diff 生成落 core(
diff/lines.ts:diffLinesLCS +compactDiff),渲染落 CLI(ui/diff-view.tsx);零新增 npm 依赖(不引diff库)
理由两条。快照数据本来就在 core(FileChangeStore 存了 before 和 after),放一起不用跨包传。更实际的是测试:packages/cli 没有测试设施,渲染组件放进去只能靠肉眼验收,放在 core 就能写单测。
LCS 在 diff/lines.ts 里,整个文件一百行出头,diffLines 和 compactDiff 各占一半。八条用例覆盖纯增、纯删、空输入、超大降级、中部 hunk 连续性、上下文窗口、折叠、预算内原样。这一百行是我自己写的,但"为什么不用 npm 的 diff"是另一回事:那个包很成熟,功能比我这百行多得多。我用它的唯一理由就是不想为了一个功能往依赖树里塞一棵子树。
UI 侧的桥接函数:
function f36DiffForTool(call: ToolCall, sessionId: string, service: SessionService): DiffLine[] | undefined {
if (call.name !== 'edit_file' && call.name !== 'write_file') return undefined;
const args = (call.args ?? safeParseArgs(call.argsJson)) as { path?: unknown };
const path = typeof args.path === 'string' ? args.path : '';
if (!path) return undefined;
try {
const rows = service.changes.list(sessionId, 10); // 最新在前
const row = rows.find((r) => r.tool === call.name && r.path === path && !r.rolled_back);
if (!row || row.before_oversized || row.after_oversized) return undefined;
return compactDiff(diffLines(row.before_content ?? '', row.after_content ?? ''));
} catch {
return undefined; // diff 是锦上添花,任何失败都不影响主流程
}
}
它拿到的是 `tool_result` 事件,不是工具改完直接回调。工具层不知道有个 UI 在看,它只管写快照和返回结果,UI 自己去快照表里找。
这个解耦有代价。`rows.find` 只按工具名和路径匹配,取最新的一条。一个会话里同一个文件被 `edit_file` 改了两次,第二次的 `tool_result` 拿到"最新快照"确实是对的;但两个 `edit_file` 并发改同一个文件,或者第一次的结果还没回来第二次已经开始,就可能拿到另一条。
设计文档里也承认了这个风险,裁决是"不匹配就不显示 diff,不影响结果文本"。实际实现里 `find` 找到一条就返回,不判断它是不是"这次调用的"。所以不是"不匹配就不显示",是"匹配错了也显示"。
`maxLines` 默认 40,加上 `±2` 行上下文和连续省略折叠。改了两百行的文件,屏幕上不超过 40 行。
## 颜色只用 16 个名字
```typescript
export const theme: Theme = noColor()
? { border: undefined, user: undefined, /* ... 全 undefined ... */ }
: {
border: 'gray',
user: 'cyan',
toolPending: 'yellow',
toolOk: 'green',
toolFail: 'red',
system: undefined, // system 走 dimColor
prompt: 'green',
warn: 'yellow',
error: 'red',
diffAdd: 'green',
diffDel: 'red',
diffHunk: 'cyan',
thinking: 'gray',
cursor: 'gray',
};
值全是 gray、cyan、yellow、green、red 这种 Ink 内置的 16 色名,没有一个十六进制色值。
原因是我不知道用户跑在什么终端里。truecolor 检测能做,但要引 supports-color,而且检测本身会失败:我见过 tmux 里 COLORTERM=truecolor 而外层 ssh 只给 8 色的情况。写 #0f5e58 在那种环境下会被降级成某个不确定的近色,效果不如我直接指定 green。
NO_COLOR 按规范实现:
function noColor(): boolean {
return process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
}
no-color.org 的约定是"变量存在且非空字符串",不看值。所以 NO_COLOR=1、NO_COLOR=yes、NO_COLOR=false 都是关闭颜色,只有 NO_COLOR= 这种空串才算没设。
这条约定看着反直觉(NO_COLOR=false 按字面意思是"不关"),但它的目的是让用户一键关颜色时不用记值。既然不看值,把 false 当"开"反而是错的。
置位之后所有 token 变成 undefined,Ink 把 color={undefined} 当不着色,输出是纯文本。管道重定向、写文件、CI 日志里都不会混进 ANSI 转义。
回头看这几个东西加起来多少行:spinner 10 行,进度条 5 行,流式光标是复用一个布尔字段(零行新增),diff 的 LCS 和折叠 101 行,渲染组件 42 行,主题 89 行。一共 247 行,替掉五个 npm 包。
省下的不只是依赖树。这 247 行每一行出问题时我都能直接改,不用等上游发版,不用读别人的抽象。
但也不该把"零依赖"当目标。Ink 本身是依赖,React 是依赖,ink-select-input 和 ink-text-input 也是依赖,我没打算重写它们。判断标准是"这个包解决的是通用问题,还是我这个界面特有的问题"。终端渲染、输入框、选择器是通用的;"Agent 的某个工具正在跑"这件事只有我知道它该长什么样。
至于上面那几处没修的,都还在代码里。读仓库的人翻得到,写出来比装作没有强。
代码在 github.com/lwly/delta-…。这篇涉及五块:
- 主界面、事件循环、状态行、流式光标
packages/cli/src/ui/app.tsx - 确认卡四档与参数编辑
packages/cli/src/ui/confirm.tsx - 主题 token 与
NO_COLOR降级packages/cli/src/ui/theme.ts - diff 渲染
packages/cli/src/ui/diff-view.tsx - LCS 行对比与折叠
packages/core/src/diff/lines.ts(零依赖决策的落点)
设计决策表在仓库的 docs/design-f36-cli-tui.md,八条决策每条都写了备选方案和放弃理由。上面提到的前缀匹配和快照匹配两个问题,在那份文档的风险表里都标出来了,只是裁决写的是"维持不变"。