Day 18 / 共 20 天 · 第 4 周 平台化进阶 · 加深版

TUI 深入

Day 04 搭了 TUI 骨架(用 React/Ink 把界面渲染到终端);今天钻进那个看似普通的输入框——多行编辑、粘贴、自动补全、键位、vim、对话框仲裁全在里面。贴真实代码。终端交互的精华都在这。

📍 你在整门课的位置 · 第 4 周「平台化进阶」(Ink 终端 UI = 用组件搭的仪表盘,今天拆最核心的那块表——输入框)
D16 会话/记忆 D17 配置/成本 D18 TUI 深入 D19 远程/workflow D20 收官
L01

输入框的分层

🤔 痛点:一个输入框能有多复杂? 你以为终端输入框就是"敲字、回车"。但它要同时应付:写多行、粘 500 行代码、@文件补全、/命令补全、↑翻历史、emacs 快捷键、vim 模式、bash 模式……全塞进一个组件必然乱成一锅粥。
💡 本质:像乐高一样分层,每层只干一件事 Claude Code 把输入框拆成四层,像乐高积木Cursor(纯数据:文本+光标)→ useTextInput(按键怎么改光标)→ useVimInput(加 vim)→ PromptInput(上层业务)。每块单独可测,叠起来才是完整输入框。这正是 Day 04"Ink=用组件搭仪表盘"思路在单个控件上的延续。

输入框 PromptInput.tsx(2650 行)本身不处理按键——它是"编排层",负责拼装模式/建议/粘贴/历史/渲染。真正逐键编辑在 useTextInputsrc/hooks/useTextInput.ts)。分层:

管什么
PromptInput.tsx编排:模式(bash !)、自动补全、粘贴、历史、提交分流
useTextInput.ts逐键编辑:光标、emacs 键位、换行/提交判定
useVimInput.ts在 useTextInput 外套一层 vim 状态机
Cursor.ts不可变的文本+光标模型
为什么分这么细? 输入框看着简单,其实要处理:多行、粘贴大段、@文件补全、/命令补全、↑历史、emacs 快捷键、vim 模式、bash 模式……全塞一个组件会乱成一团。分层后:Cursor 管"文本和光标的纯数据"、useTextInput 管"按键怎么改光标"、useVimInput 加 vim、PromptInput 管"上层业务"。每层只干一件事,组合起来才是完整输入框。
L02

Cursor:不可变的编辑模型

底层编辑用不可变的 Cursor 对象src/utils/Cursor.ts)。每次按键产出一个 Cursor,而不是改旧的。看真实代码里的用法(useTextInput.ts:203):

const newCursor = cursor.insert(text)   // 插入 → 返回新 Cursor(不改旧的)
// 删除、移动光标、删词……每个操作都返回新 Cursor
为什么用"不可变 Cursor"? 文本编辑涉及一堆操作(插入、删除、移光标、删词),如果直接改一个可变对象,很容易状态错乱、也难做撤销。用"每次操作返回新 Cursor"(不可变),每个操作都是纯函数 旧Cursor → 新Cursor——清晰、可测、天然支持撤销(保存历史 Cursor 即可)。这跟 Day 06 queryLoop"整体替换 State"、Day 10 的"浅拷贝而非改原对象"是同一种"用不可变数据避免状态错乱"的思想,全项目一以贯之。撤销栈由 useInputBuffer 单管(防抖 1000ms、最多 50 条)。
L03

换行 vs 提交(真实代码 · 点睛)

输入框最微妙的判断:按回车是"换行"还是"提交"?真实代码 handleEnteruseTextInput.ts:248):

function handleEnter(key: Key) {
  // ① 反斜杠 + Enter → 换行(删掉反斜杠)
  if (multiline && cursor.offset > 0 && cursor.text[cursor.offset - 1] === '\\') {
    markBackslashReturnUsed()
    return cursor.backspace().insert('\n')
  }
  // ② Meta(Option)+Enter 或 Shift+Enter → 换行
  if (key.meta || key.shift) {
    return cursor.insert('\n')
  }
  // ③ Apple Terminal 不支持自定义 Shift+Enter,用原生 macOS 修饰键检测
  if (env.terminal === 'Apple_Terminal' && isModifierPressed('shift')) {
    return cursor.insert('\n')
  }
  // ④ 普通 Enter → 提交
  onSubmit?.(originalValue)
}
三种换行方式(\+Enter、Shift/Option+Enter、Apple Terminal 特判),只有普通 Enter 才提交(最后一行 onSubmit)。
按下 Enter 前一个字符是 \ ? 有 Shift/Option ? 换行 cursor.insert('\n') 提交 onSubmit(value) (另外:补全下拉可见时,Enter 变成"接受建议",见 L04)
图注:一次回车的"换行还是提交"判定树。
📝 举个例子:想写多行 prompt 你打 请重构这个函数:\ 然后回车 → 因前一个字符是 \,走分支①换行(反斜杠被删掉),光标落到第二行继续打;打完最后按普通回车 → 走分支④提交。
为什么这么讲究? 终端里既要能多行输入(写长 prompt/贴代码),又要能快速提交。约定俗成:普通回车提交、修饰键+回车换行。但不同终端对"修饰键+回车"的支持不一(看代码里专门为 Apple_Terminal 加了特判!),所以提供了三种换行法(连纯 \+回车都支持)兼容各种终端。这种"照顾各种终端怪癖"的细节,是终端应用体验好坏的关键。
L04

自动补全:/ @ # 触发

useTypeaheadPromptInput.tsx:1281)驱动。输入变化时判断触发字符:

  • / 开头 → 斜杠命令补全(Day 11 的命令列表)。
  • @ → 文件/agent 补全。
  • # → slack 频道等。

接受方式:Tab/ 接受、↑↓ 选择、Esc 关闭。护栏:有建议显示时,普通 Enter 不提交(而是接受当前建议)。

"有建议时 Enter 不提交"为什么重要? 你敲 /mod,下拉出现 /model,你按回车——期望是"选中 /model",不是"把 /mod 这半截提交出去"。所以补全下拉可见时,回车语义变成"接受建议"。这种"根据当前 UI 状态改变按键含义"的上下文敏感,是好交互的细节。Fuse.js(Day 01 依赖表)做模糊搜索——你敲 /mdl 也能匹配 /model
L05

粘贴:三层处理

往终端粘贴(尤其大段文本/图片)要特殊处理,分三层:

  1. 检测层 usePasteHandler:靠按键事件的 isPasted 标志(括号粘贴模式)判定,分块累积 + 100ms 超时合并;拖入的图片路径 → 读图。
  2. 业务层 onTextPastePromptInput.tsx:1374):清理 ANSI、\r→\n超过阈值就不塞全文,而是存进 pastedContents、只在输入框插一个占位引用。
  3. 截断层 inputPaste.ts:>10000 字保留首尾各 500 字、中间折叠。
为什么大段粘贴要"占位引用"而非直接显示? 你粘了 500 行代码——如果全显示在输入框里,输入框撑爆、界面卡、你也看不清自己在打什么。所以大段内容被折叠成一个 [Pasted 500 lines] 占位,实际内容存在 pastedContents 里,提交时才展开发给模型。图片同理插一个图片引用占位。"输入框只显示摘要、内容另存"是体验优化——既不卡界面又不丢内容。括号粘贴模式(bracketed paste)是终端特性,让程序能区分"用户逐字敲的"和"粘贴进来的"。
L06

键位系统与 vim 模式

键位系统核心在 @anthropic/ink,仓库侧 src/keybindings/ 多为再导出。默认绑定表 defaultBindings.ts:32 按上下文分组:

  • Ctrl+O → 切 transcript 视图;Ctrl+R → 历史搜索;Shift+Tab → 切权限模式(Day 09)。
  • 用户可用 ~/.claude/keybindings.json 覆盖默认。
  • Ctrl+C/Ctrl+D 登记但不可重绑(走时间敏感的双击处理)。

vim 模式src/vim/ + useVimInput.ts):完整实现了 NORMAL/INSERT 模式、移动(motions)、操作符(d/c/y)、文本对象(iw/ap)、. 重复上次修改(dot-repeat)。NORMAL 模式把按键交给状态机 transition

vim 模式怎么套在普通编辑之上? useVimInput 包在 useTextInput 外面:INSERT 模式基本"放行"给底层的普通编辑;NORMAL 模式拦截按键、走 vim 状态机(d+w = 删一个词……)解析成对 Cursor 的操作。因为底层是 L02 讲的不可变 Cursor,vim 的各种操作都能表达成"旧 Cursor → 新 Cursor",dot-repeat 也只是"重放上次那串操作"。分层设计让 vim 这种复杂模式能干净地叠加上去,而不用改动底层编辑。
L07

对话框浮层:优先级仲裁

权限卡、模型选择、成本确认……这些浮层怎么管?不是"组件栈",而是"队列 + 一个优先级仲裁函数"。核心是 getFocusedInputDialog()REPL.tsx:2414)——按固定优先级返回"当前唯一活跃"的对话框:退出态最高 → message-selector → 用户正在打字时(1500ms 内)抑制打断式弹窗 → 各类权限/对话框。

"优先级仲裁"而非"弹窗堆栈"的好处:同一时刻只显示一个最该显示的对话框,避免弹窗打架/叠一堆。而且有个体贴设计——你正在打字时抑制打断式弹窗PROMPT_SUPPRESSION_MS=1500),不会你打一半突然蹦个框把你打断。各类请求存在各自队列,永远渲染队头 [0],处理完出队。权限卡的数据由 Day 09 的"await Promise"模式产生。另一类对话框dialogLaunchers.tsx——那些 REPL 之外、启动期的一次性对话框(快照更新、resume 选择器)。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 输入框为什么分 PromptInput/useTextInput/useVimInput/Cursor 四层?
  • 为什么用不可变 Cursor?(和 Day 06/10 同一思想)
  • 换行 vs 提交怎么判定?为什么专门给 Apple Terminal 加特判?
  • 大段粘贴为什么用占位引用?
  • vim 模式怎么叠加在普通编辑上?(靠不可变 Cursor)
  • 对话框为什么"优先级仲裁"?打字时为什么抑制弹窗?

✋ 动手:对着真实代码读一遍

# 1. 换行 vs 提交(L03,本页精华)
sed -n '248,268p' src/hooks/useTextInput.ts

# 2. Cursor 不可变编辑(L02)
grep -n "cursor.insert\|cursor.backspace\|newCursor" src/hooks/useTextInput.ts | head

# 3. 键位默认表 + vim(L06)
sed -n '32,60p' src/keybindings/defaultBindings.ts

# 4. 对话框优先级仲裁(L07)
sed -n '2414,2470p' src/screens/REPL.tsx | head -50

# 5. 实操:贴一大段文本看占位、开 vim、Shift+Tab 切权限
bun run dev
明天预告 · Day 19:Day 19 讲本 fork 最有特色的两块扩展——远程控制(手机/web 操控终端里的 Claude Code)和 workflow 引擎(多 agent 编排)。它们不是官方功能,但展示了这类系统能扩展到什么程度。

← Day 17 配置模型成本 Day 19 · 远程控制 / daemon / workflow →