给 Agent 写终端界面,我一个 UI 库都没装

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 能不能用。

typescript 复制代码
const options = [
  { label: '允许(执行一次)', value: 'allow' },
  { label: '允许(本会话不再询问)', value: 'allow_session' },
  { label: '编辑参数后执行', value: 'edit' },
  { label: '拒绝', value: 'reject' },
];

前两档是常识,第三档是很多人会漏掉的。

第二档的必要性不用多解释。一个中等规模的任务,模型要调二十几次 read_file。每次都弹一个确认框、等你按方向键、再回车,实际体验是你在给一个只会问"我能看看这个文件吗"的实习生当门卫。所以要有"本会话不再询问"。

它记的确实只有工具名:

typescript 复制代码
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 框里:

typescript 复制代码
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 的确认框还会额外画一个彩色对照:

typescript 复制代码
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:

typescript 复制代码
// Esc interrupts the running task
useInput((_input, key) => {
  if (key.escape && running) {
    abortRef.current?.abort();
  }
});

确认框出现时 running 是 true,所以 Esc 不是"取消编辑",是"中止整个任务"。

三个说法指向三种行为:标题说取消编辑,底部说中断会话,实际做的是中止当前任务。用户按 Esc 会发现整个任务没了,不只是编辑框关了。

这是"两个人在不同时间写同一块界面"留下的痕迹。标题照设计稿写的,onCancel 是按组件拆分预留的,底部那行是后来发现退不出去临时加的说明。三句话都对的时候没人在,改了一处,另外两处没跟上。

确认卡的分支:

graph TD A[&#34;工具要执行<br/>权限判定返回 confirm&#34;] --> B[&#34;画确认卡<br/>工具名 + 原因 + 参数摘要&#34;] B --> C[&#34;四档选择&#34;] C -->|允许一次| D[&#34;kind = allow&#34;] C -->|本会话不再询问| E[&#34;kind = allow_session<br/>按工具名记住&#34;] C -->|拒绝| F[&#34;kind = reject<br/>模型收到拒绝原因&#34;] C -->|编辑参数| G[&#34;弹出预填 JSON 的输入框&#34;] G --> H{&#34;JSON 能解析&#34;} H -->|能| I[&#34;kind = edit<br/>带上改后的 args&#34;] H -->|不能| J[&#34;红字显示错误<br/>输入内容保留&#34;] J --> G D --> K[&#34;执行工具&#34;] E --> K I --> K

J 回到 G 那个环是我特意留的:清空输入等于让人前功尽弃,宁可让他看着错误信息改。

光标不是动画

模型输出到一半时,那一行末尾有个 ▋ 在闪。实现它没用一个字符计时器,也没有独立的光标状态。

typescript 复制代码
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 上一个布尔字段,渲染时:

tsx 复制代码
{it.pending ? <Text color={theme.cursor}>▋</Text> : null}

光标不是"动画",是"这个 item 还没完成"的可视化。它不需要 tick,不需要 requestAnimationFrame,状态变了重绘一次就行。

这套设计的好处是它对所有没完成的东西都适用。thinking 块有光标,工具调用行也有:

typescript 复制代码
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;
}

工具行一出现就带光标,结果回来时摘掉光标、内容换成结果。中间那段等待,用户看到的是"这一行还在跑",不是"界面卡住了"。

思考流也一样,只是颜色更暗:

typescript 复制代码
case 'thinking_delta':
  if (thinkingItemId === null) {
    const it = addItem({ kind: 'thinking', text: '', pending: true });
    thinkingItemId = it.id;
  }
  appendToItem(thinkingItemId, e.text);
  break;

thinking 的内容不入库,只在屏幕上滚过去,关掉终端就没了。这个决定有点争议,有人想要完整的思考记录。我觉得存下来只会让会话表变成垃圾场,而且 reasoning 模型的思考过程经常自相矛盾,存了也没法复盘。

思考流和正式输出不能同时存在。模型开始输出正文时,思考块要立刻收掉光标:

typescript 复制代码
case 'text_delta':
  if (thinkingItemId !== null) {
    updateItem(thinkingItemId, { pending: false });
    thinkingItemId = null;
  }

不然屏幕上两个光标,一个在思考块末尾,一个在正文末尾,看着像两个东西同时在跑。

好看的部分到这儿。收光标这个动作,藏着一个我一直没改的匹配问题。

工具结果回来时要找到对应的 pending 行,代码是这么找的:

typescript 复制代码
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 会清光标:

typescript 复制代码
setItems((prev) =>
  prev.map((it) => (it.kind === 'assistant' || it.kind === 'thinking') ? { ...it, pending: false } : it),
);

只清了 assistant 和 thinking,没清 tool。如果在工具执行期间按 Esc 中止,那个工具行的 pending 永远不会被摘,光标一直闪在那儿。任务已经结束了,界面还挂着一个"正在执行"的假象。

事件到界面的流转长这样:

graph TD A[&#34;turn_start&#34;] --> B[&#34;状态行更新轮次&#34;] B --> C[&#34;text_delta&#34;] C --> D{&#34;已有 assistant 项&#34;} D -->|没有| E[&#34;建一项,pending = true<br/>尾部显示流式光标&#34;] D -->|有| F[&#34;追加文本&#34;] E --> F C -.->|先收掉思考流| G[&#34;thinking 项 pending = false&#34;] H[&#34;tool_call_start&#34;] --> I[&#34;建工具行,pending = true<br/>参数超 160 字截断&#34;] I --> J[&#34;tool_result&#34;] J --> K[&#34;按名前缀找 pending 行<br/>摘光标,贴结果和 diff&#34;] K --> L{&#34;是 todo_write&#34;} L -->|是| M[&#34;追加一行 N/M 完成&#34;] C --> N[&#34;run 结束 finally&#34;] J --> N N --> O[&#34;assistant 和 thinking 的 pending 全清&#34;]

O 少了 tool,就是上面那个洞。

spinner 十行

typescript 复制代码
/** 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 上去,界面更卡。

进度条只在有意义的时候画

typescript 复制代码
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)时才画。默认情况下状态行只有文字:

bash 复制代码
第 3 轮 · 12.4k 入 / 3.1k 出 · $0.08

这行信息量比进度条大。轮次告诉你还要多久,token 告诉你上下文涨得多快,费用告诉你钱包。三个数都是流式的,每轮更新。

diff 在 core,不在 UI

改文件的工具执行完,界面上会显示改了哪几行。这部分逻辑没有一行在 ui/ 目录里。

设计文档里这条写得很清楚:

diff 生成落 core(diff/lines.ts:diffLines LCS + 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 侧的桥接函数:

typescript 复制代码
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 是锦上添花,任何失败都不影响主流程
  }
}
less 复制代码
它拿到的是 `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 按规范实现:

typescript 复制代码
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,八条决策每条都写了备选方案和放弃理由。上面提到的前缀匹配和快照匹配两个问题,在那份文档的风险表里都标出来了,只是裁决写的是"维持不变"。

相关推荐
德尔塔A1 小时前
同一条命令,在 Windows 上炸了三种方式
typescript
德尔塔A1 小时前
同一个会话,终端和浏览器同时开着会发生什么
typescript
咚咚咚小柒4 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
螺丝钉tina9 小时前
[Playwright][01] 从 Puppeteer 到 Playwright:为什么选择 Playwright + TypeScript?
前端·javascript·typescript
miofly1 天前
GitHub 今日推荐|rea:用 MCP 把反编译器和 AI agent 接到一起的逆向工具
typescript·开源·github
ZoeLandia1 天前
React11:数据请求与 TanStack Query
前端·react.js·typescript·react
康小旺2 天前
AI 写 Vue 总写成同一个味道:给它装 Skills,装的不是知识是边界
vue.js·typescript
ZoeLandia2 天前
React08:自定义 Hook
开发语言·前端·react.js·typescript
aloha_7893 天前
HTML、JavaScript 与 TypeScript 基础速查
javascript·typescript·html