TUI 深入
Day 04 搭了 TUI 骨架(用 React/Ink 把界面渲染到终端);今天钻进那个看似普通的输入框——多行编辑、粘贴、自动补全、键位、vim、对话框仲裁全在里面。贴真实代码。终端交互的精华都在这。
输入框的分层
@文件补全、/命令补全、↑翻历史、emacs 快捷键、vim 模式、bash 模式……全塞进一个组件必然乱成一锅粥。Cursor(纯数据:文本+光标)→ useTextInput(按键怎么改光标)→ useVimInput(加 vim)→ PromptInput(上层业务)。每块单独可测,叠起来才是完整输入框。这正是 Day 04"Ink=用组件搭仪表盘"思路在单个控件上的延续。输入框 PromptInput.tsx(2650 行)本身不处理按键——它是"编排层",负责拼装模式/建议/粘贴/历史/渲染。真正逐键编辑在 useTextInput(src/hooks/useTextInput.ts)。分层:
| 层 | 管什么 |
|---|---|
PromptInput.tsx | 编排:模式(bash !)、自动补全、粘贴、历史、提交分流 |
useTextInput.ts | 逐键编辑:光标、emacs 键位、换行/提交判定 |
useVimInput.ts | 在 useTextInput 外套一层 vim 状态机 |
Cursor.ts | 不可变的文本+光标模型 |
Cursor 管"文本和光标的纯数据"、useTextInput 管"按键怎么改光标"、useVimInput 加 vim、PromptInput 管"上层业务"。每层只干一件事,组合起来才是完整输入框。Cursor:不可变的编辑模型
底层编辑用不可变的 Cursor 对象(src/utils/Cursor.ts)。每次按键产出一个新 Cursor,而不是改旧的。看真实代码里的用法(useTextInput.ts:203):
const newCursor = cursor.insert(text) // 插入 → 返回新 Cursor(不改旧的)
// 删除、移动光标、删词……每个操作都返回新 Cursor
旧Cursor → 新Cursor——清晰、可测、天然支持撤销(保存历史 Cursor 即可)。这跟 Day 06 queryLoop"整体替换 State"、Day 10 的"浅拷贝而非改原对象"是同一种"用不可变数据避免状态错乱"的思想,全项目一以贯之。撤销栈由 useInputBuffer 单管(防抖 1000ms、最多 50 条)。换行 vs 提交(真实代码 · 点睛)
输入框最微妙的判断:按回车是"换行"还是"提交"?真实代码 handleEnter(useTextInput.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)。请重构这个函数:\ 然后回车 → 因前一个字符是 \,走分支①换行(反斜杠被删掉),光标落到第二行继续打;打完最后按普通回车 → 走分支④提交。Apple_Terminal 加了特判!),所以提供了三种换行法(连纯 \+回车都支持)兼容各种终端。这种"照顾各种终端怪癖"的细节,是终端应用体验好坏的关键。自动补全:/ @ # 触发
由 useTypeahead(PromptInput.tsx:1281)驱动。输入变化时判断触发字符:
- / 开头 → 斜杠命令补全(Day 11 的命令列表)。
- @ → 文件/agent 补全。
- # → slack 频道等。
接受方式:Tab/→ 接受、↑↓ 选择、Esc 关闭。护栏:有建议显示时,普通 Enter 不提交(而是接受当前建议)。
/mod,下拉出现 /model,你按回车——期望是"选中 /model",不是"把 /mod 这半截提交出去"。所以补全下拉可见时,回车语义变成"接受建议"。这种"根据当前 UI 状态改变按键含义"的上下文敏感,是好交互的细节。Fuse.js(Day 01 依赖表)做模糊搜索——你敲 /mdl 也能匹配 /model。粘贴:三层处理
往终端粘贴(尤其大段文本/图片)要特殊处理,分三层:
- 检测层
usePasteHandler:靠按键事件的isPasted标志(括号粘贴模式)判定,分块累积 + 100ms 超时合并;拖入的图片路径 → 读图。 - 业务层
onTextPaste(PromptInput.tsx:1374):清理 ANSI、\r→\n;超过阈值就不塞全文,而是存进pastedContents、只在输入框插一个占位引用。 - 截断层
inputPaste.ts:>10000 字保留首尾各 500 字、中间折叠。
[Pasted 500 lines] 占位,实际内容存在 pastedContents 里,提交时才展开发给模型。图片同理插一个图片引用占位。"输入框只显示摘要、内容另存"是体验优化——既不卡界面又不丢内容。括号粘贴模式(bracketed paste)是终端特性,让程序能区分"用户逐字敲的"和"粘贴进来的"。键位系统与 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。
useVimInput 包在 useTextInput 外面:INSERT 模式基本"放行"给底层的普通编辑;NORMAL 模式拦截按键、走 vim 状态机(d+w = 删一个词……)解析成对 Cursor 的操作。因为底层是 L02 讲的不可变 Cursor,vim 的各种操作都能表达成"旧 Cursor → 新 Cursor",dot-repeat 也只是"重放上次那串操作"。分层设计让 vim 这种复杂模式能干净地叠加上去,而不用改动底层编辑。对话框浮层:优先级仲裁
权限卡、模型选择、成本确认……这些浮层怎么管?不是"组件栈",而是"队列 + 一个优先级仲裁函数"。核心是 getFocusedInputDialog()(REPL.tsx:2414)——按固定优先级返回"当前唯一活跃"的对话框:退出态最高 → message-selector → 用户正在打字时(1500ms 内)抑制打断式弹窗 → 各类权限/对话框。
PROMPT_SUPPRESSION_MS=1500),不会你打一半突然蹦个框把你打断。各类请求存在各自队列,永远渲染队头 [0],处理完出队。权限卡的数据由 Day 09 的"await Promise"模式产生。另一类对话框在 dialogLaunchers.tsx——那些 REPL 之外、启动期的一次性对话框(快照更新、resume 选择器)。今日小结 + 动手
🧠 今天你应该能回答
- 输入框为什么分 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