Orca 移动端终端直输模式:让按键直达 PTY 的默认化设计与源码解析
Orca Mobile 的终端屏幕支持两种输入模式:缓冲命令输入与直输终端输入(direct input)。本文基于仓库中的设计文档 mobile-terminal-direct-input-default.md,结合 mobile/src 下对应的 Hook、纯函数与单元测试,完整讲清楚「首次看到的终端默认进入直输模式」这一行为的动机、一次性默认机制、句柄生命周期信号、持久化 opt-out 的时序处理,以及键盘事件到 PTY 字节的底层映射,帮助你在移动端远程终端、SSH 会话和 TUI 工具场景下理解并验证这套输入架构。
一、背景:两种终端输入模式
Orca 移动端目前有两种终端输入方式(见设计文档 mobile/mobile-terminal-direct-input-default.md 的 Context 一节):
- 缓冲命令输入(buffered command input):一个可见的命令文本框,用户按 Enter 后把整段内容作为一条命令发送。
- 直输终端输入(direct terminal input):一个隐藏的捕获输入框(hidden capture field),把键盘字节直接转发给 PTY。
缓冲输入适合「先完整敲出一条 shell 命令再执行」的场景,但对终端原生流程并不友好:shell、TUI 程序、REPL、编辑器、交互式提示符以及远程 SSH 会话都期望按键「即刻落地」。设计文档给出的核心变更是:在移动端首次发现一个终端时,让它默认进入直输模式,同时保留原有开关让用户随时切回缓冲模式。
二、设计目标与非目标
设计文档明确圈定了变更边界,这在实现中也被严格遵守:
目标(Goals)
- 首次被移动端看到的终端 tab 默认启动直输模式;
- 保留现有的 accessory 开关,用户可为单个终端切回缓冲命令输入;
- 终端保持打开、会话 tab 列表刷新时,保留用户的这次手动 opt-out;
- 行为保持在移动端客户端本地:不为此引入 host/runtime 状态,也不新增桌面端可见的配置项;
- 终端变为活跃时不自动弹出键盘;点击终端应聚焦直输捕获框,与既有 live-input 交互模型一致。
非目标(Non-goals)
- 不移除缓冲命令输入;
- 不改动
terminal.send、移动端订阅、PTY 尺寸语义; - 不修改 accessory 按键、听写、粘贴、终端手势输入或鼠标感知的 TUI 路由。
需要说明的是,实现层面在「移动端本地」这个约束内做了合理延伸:用户的 opt-out 会持久化到移动端本地存储(按 host + worktree 维度),跨应用重启仍然生效——它没有引入任何 host 端或桌面端可见状态,与设计目标并不冲突,下文会给出源码证据。
三、核心机制:一次性默认(one-shot default)
文档的 Design 一节描述了状态模型。移动端会话屏幕维护一个 liveInputTerminalHandles 集合,其中存放的终端句柄(handle)表示其输入栏处于直输模式。变更前该集合初始为空,因此所有终端默认走缓冲命令框。
新行为引入一个伴随的「已应用默认」集合(defaulted set)。当移动端通过以下三条路径发现终端句柄时:
- 会话 tab 快照(session tab snapshots);
terminal.list轮询;- 本地创建终端;
它只会把从未被默认过的句柄加入 liveInputTerminalHandles。如果用户手动把某个句柄切回缓冲输入,该句柄仍留在 defaulted 集合中,因此后续的 tab 刷新不会把它重新翻回直输模式。
文档总结了这个「每个句柄一次性」的完整流程:
- 新句柄出现;
- 移动端将其默认为直输输入;
- 用户可以把它切回缓冲输入;
- 快照/列表刷新保留用户的选择;
- worktree 路由重置时清空默认跟踪,为下一个会话作用域做准备。
纯函数实现:无副作用的集合合并
这套逻辑的核心是 mobile/src/terminal/terminal-live-input.ts 中的纯函数,全部以「输入集合 + 新句柄」为参数、返回新集合,并且刻意做到无变化时不分配新对象(直接原样返回输入集合),这在高频刷新的会话 tab 订阅路径上避免了无谓的 GC 与重渲染。
defaultTerminalLiveInputHandles(terminal-live-input.ts#L103-L130)实现一次性默认:
export function defaultTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,
terminalHandles: readonly string[]
): TerminalLiveInputDefaultResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | null = null
for (const handle of terminalHandles) {
if (defaultedHandles.has(handle)) {
continue
}
nextEnabledHandles ??= new Set(enabledHandles)
nextDefaultedHandles ??= new Set(defaultedHandles)
nextEnabledHandles.add(handle)
nextDefaultedHandles.add(handle)
}
if (!nextEnabledHandles || !nextDefaultedHandles) {
return { enabledHandles, defaultedHandles, changed: false }
}
return {
enabledHandles: nextEnabledHandles,
defaultedHandles: nextDefaultedHandles,
changed: true
}
}
参数语义与取值说明:
enabledHandles:当前处于直输模式的句柄集合;defaultedHandles:已经应用过一次默认的句柄集合(无论当前是直输还是被用户切回缓冲);terminalHandles:本轮新发现的终端句柄列表;- 返回值中
changed为false时,两个集合引用与输入完全相同(identity preserved),调用方可据此跳过状态更新。
配套的三个函数各司其职:
filterTerminalLiveInputDefaultCandidates(#L132-L137):把已持久化为缓冲模式(disabled)的句柄从默认候选中过滤掉,保证重新进入工作区时不会把用户 opt-out 的终端拉回直输;applyDisabledTerminalLiveInputHandles(#L139-L172):把持久化的 disabled 集合与内存态调和——从 enabled 中删除被禁用的句柄,并把它们标记为已默认;pruneTerminalLiveInputHandles(#L174-L207):以「存活句柄集合」为准,从 enabled 和 defaulted 中清除已消失的终端。
四、句柄发现与生命周期:terminal.list 是唯一的生命周期信号
设计文档特别强调:句柄清理以 terminal.list 作为终端生命周期信号。原因是会话 tab 快照可能滞后于本地创建或刚刚关闭的终端 tab,所以快照只应该「默认从未见过的句柄」,而不应该裁剪 live/defaulted 集合。
仓库中的调用点与这一设计一一对应:
发现路径(只默认、不裁剪)
- 会话 tab 快照应用时:use-mobile-session-tab-application.ts#L88-L90 从快照中提取终端 tab 的 handle,调用
defaultTerminalHandlesToLiveInput(terminalTabHandles); - 本地创建终端:use-mobile-session-terminal-create-actions.ts#L129 对刚创建的句柄执行默认;
- tab 切换发现新句柄:use-mobile-session-tab-switching.ts#L41。
清理路径(唯一生命周期信号)
terminal.list 轮询处 use-mobile-session-terminal-list.ts#L74-L87 是关键的实现细节:
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
const pruneContext = {
liveHandles,
showNativeChat: showNativeChatRef.current,
activeHandle: activeHandleRef.current
}
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
const retainedHandles = resolveRetainedTerminalHandles(pruneContext)
pruneTerminalHandlesFromLiveInput(retainedHandles)
bufferedTerminalDraftState.pruneDrafts(retainedHandles)
defaultTerminalHandlesToLiveInput([...liveHandles])
注意两个易错的区分:
- 裁剪(prune)针对 retained 集合而不是原始列表——被 native chat 面板覆盖的句柄在图重载期间仍保有订阅,若按原始列表裁剪,会在同一轮刷新中误删它的直输偏好;
- 默认(default)针对 liveHandles——即
terminal.list返回的真实存活句柄。
这正是文档中「快照默认新句柄、但清理只信 terminal.list」两条规则在代码里的落点。
五、状态 Hook:水合时序与 pending 编辑
所有集合状态由 use-terminal-live-input-mode-preference.ts 统一管理,Hook 以 hostId 和 worktreeId 为作用域。它维护了六个 ref:
| ref | 作用 |
|---|---|
liveInputTerminalHandlesRef |
当前直输模式句柄集合(对外暴露为 state liveInputTerminalHandles) |
defaultedLiveInputTerminalHandlesRef |
已应用过默认的句子集合(一次性保证) |
disabledLiveInputTerminalHandlesRef |
用户手动切回缓冲模式的句柄 |
disabledLiveInputHydratedRef |
持久化 opt-out 是否已从存储读取完成 |
pendingDisabledLiveInputHydrationEditsRef |
水合完成前的逐句柄 toggle 编辑(Map: handle → disabled) |
pendingLiveInputDefaultHandlesRef |
水合完成前收到的待默认句柄 |
时序难点在于:重新进入 worktree 时,异步读取持久化 opt-out 尚未完成,而终端发现(tab 快照、列表轮询、创建事件)可能已经到达。源码中的处理方式(use-terminal-live-input-mode-preference.ts#L32-L58):
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
// before the async persisted-disabled load on worktree re-entry.
if (!disabledLiveInputHydratedRef.current) {
for (const handle of handles) {
pendingLiveInputDefaultHandlesRef.current.add(handle)
}
return
}
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
handles,
disabledLiveInputTerminalHandlesRef.current
)
const result = defaultTerminalLiveInputHandles(...)
...
}, [])
水合完成时(#L154-L202):
- 先把 pending 的 toggle 编辑逐句柄合并到加载出的 disabled 集合(而不是整体替换,以免抹掉同一 worktree 下其他句柄的 opt-out);
- 调用
applyDisabledTerminalLiveInputHandles调和 enabled/defaulted; - 再把此前积压的 pending 默认句柄统一走一次默认逻辑。
一个重要的防御细节:只有当 preference.loaded === true 且存在 pending 编辑时才触发持久化——「存储读取失败返回的空集合」不会被当作有效数据写回,否则会覆盖其他终端的 opt-out。
六、本地持久化:opt-out 存储键与读写语义
持久化实现在 mobile/src/storage/preferences.ts#L82-L127,使用 AsyncStorage:
- 存储键:
orca:terminalLiveInputDisabled:拼接 URL 编码后的hostId:worktreeId; - 存储内容:仅被切回缓冲模式的句柄数组(JSON),JSON 数组形式,读取时解析为
Set; - 读取失败时返回
{ handles: new Set(), loaded: false },loaded: false标记保证上层不会把异常态当作「没有 opt-out」。
这套机制仍是纯移动端本地状态,完全符合文档「行为保持在移动端客户端本地、不新增桌面端可见配置」的目标,同时让「手动 opt-out 在会话 tab 列表刷新乃至应用重启后被保留」这一目标从内存态升级为持久态。
七、键盘到 PTY 的字节映射与输入预算
直输模式的价值在于「把键盘字节直接转发给 PTY」,其底层映射与限幅逻辑同在 mobile/src/terminal/terminal-live-input.ts 中:
特殊键字节表(#L36-L94):TERMINAL_LIVE_SPECIAL_KEY_IDS 把原生按键事件名映射为 PTY 字节,例如 Escape/Esc → \x1b、Tab → \t、Backspace → \x7f、方向键 → \x1b[A/\x1b[B/\x1b[D/\x1b[C、PageUp/PageDown → \x1b[5~/\x1b[6~、F1–F4 走 \x1bOP…\x1bOS、F5–F12 走 \x1b[15~…\x1b[24~。映射表刻意不包含 Enter,源码注释说明原因是:Enter 保留在 onSubmitEditing 路径上,若同时映射会因原生 TextInput 同时发出 submit 与 key 事件而重复发送回车。
字节表测试 terminal-live-input.test.ts#L34-L68 还专门验证了原型链防护:constructor、toString、hasOwnProperty 等对象原型属性名从原生按键事件中到来时会被安全地判空,避免映射表查表被原型污染影响。
粘贴级字节预算(#L96-L101):
const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024
export function isTerminalLiveInputWithinByteLimit(
text: string,
maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES
): boolean {
return encoder.encode(text).byteLength <= maxBytes
}
上限为 256 KiB,且按 TextEncoder 编码后的字节长度而非字符串长度计量——多字节字符会加速逼近上限,测试用例用 é(每字符 2 字节)验证了这一点(terminal-live-input.test.ts#L76-L85)。
焦点调度(#L217-L248):
scheduleTerminalLiveInputFocus以 50ms 延迟聚焦,并在重新调度时先清除挂起的旧定时器,防止路由切换中残留的 focus 回调在卸载/禁用后作用于原生 TextInput;focusTerminalLiveInputTarget处理 Android 特有情形:键盘已收起(keyboardHeight <= 0)但隐藏 TextInput 仍持有时,直接focus()是无效操作,因此先blur()再走refocus()强制开启新的焦点会话重新弹出键盘。
活跃终端的直输开关由会话运行时读取:use-mobile-session-terminal-runtime.ts#L136 以 liveInputTerminalHandles.has(activeHandle) 计算 liveInputEnabled,驱动输入栏 UI 与输入路由。
八、UI 行为
按设计文档的 UI Behavior 一节,直输模式下的表现与既有 live input 栏完全一致,无新增控件:
- accessory 上的直输图标呈激活态;
- 输入栏提示「键盘输入将直接发送到终端」(对应组件 MobileTerminalLiveInputStatus.tsx);
- 点击终端聚焦隐藏捕获输入,而不会因为终端变为活跃就自动弹出键盘;
- 通过同一个模式开关随时切回缓冲命令框,缓冲模式下的命令字段行为保持不变。
九、SSH 与远端场景的意义
设计文档指出,SSH 会话正是这次默认化的一等动机:直输模式避免了本地侧的「命令拼装假设」,无论 shell 是本地还是远端,发送的都是同一份 PTY 字节。变更不触碰 source-control provider 行为,也不改变 terminal.send 的语义——它只改变了「默认走哪条输入路径」,而不改变字节如何被发送。
十、验证:从设计清单到测试证据
设计文档 Validation 一节列出的验证项,均可在仓库测试中找到对应实现:
- 一次性默认合并的单元验证——terminal-live-input.test.ts#L87-L131:
- 首次发现的句柄被启用并标记为已默认(
defaults first-seen terminal handles to live input once); - 已被默认、且被用户切回缓冲的句柄不会在后续刷新中被重新启用(
does not default persisted buffered-mode handles back to live input on reentry); - 新发现的句柄仍会被默认启用(同用例中的
pty-2); - 无变化时不分配新集合(
does not allocate new live input sets when no handles need defaults)。
- 首次发现的句柄被启用并标记为已默认(
- 过期句柄裁剪的单元验证——terminal-live-input.test.ts#L145-L165:以存活句柄集合为准清理
pty-stale,并验证全存活时不发生分配。 - Hook 级时序验证——use-terminal-live-input-mode-preference.test.ts:
merges pre-hydration edits with loaded disabled handles:水合前发生的默认与 toggle 会正确合并进加载出的 disabled 集合,并只持久化一次;does not persist fallback-empty storage reads during clean hydration:存储读取失败(loaded: false)的空集合不会被写回。
此外,文档最后一条验证要求是启动 iOS 模拟器、进入会话终端屏幕并截图确认直输栏为默认输入面——属于人工验收项,仓库中以 mobile/scripts/start-emulator.mjs 等模拟器启动脚本为配套。
小结
「直输成为默认」看似只是一行状态初值的变化,但其背后是一套完整的句柄状态机:发现路径与生命周期路径分离、一次性默认集合保证幂等、纯函数合并保证可测试与零冗余分配、水合 pending 机制解决异步持久化与即时发现之间的竞态、字节级映射保证按键在本地与 SSH 远端语义一致。如果你要在 Orca 移动端调试输入模式异常(例如某个终端刷新后被「翻回」直输,或 opt-out 未生效),可依次沿 use-mobile-session-terminal-list.ts(生命周期信号)→ use-terminal-live-input-mode-preference.ts(状态合并与水合)→ terminal-live-input.ts(纯函数与字节映射)这条链路定位,并参考上表列出的测试文件复现边界行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00