首页
/ Orca 移动端终端直输模式:让按键直达 PTY 的默认化设计与源码解析

Orca 移动端终端直输模式:让按键直达 PTY 的默认化设计与源码解析

2026-09-05 13:39:32作者:廉皓灿Ida

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)。当移动端通过以下三条路径发现终端句柄时:

  1. 会话 tab 快照(session tab snapshots);
  2. terminal.list 轮询;
  3. 本地创建终端;

它只会把从未被默认过的句柄加入 liveInputTerminalHandles。如果用户手动把某个句柄切回缓冲输入,该句柄仍留在 defaulted 集合中,因此后续的 tab 刷新不会把它重新翻回直输模式。

文档总结了这个「每个句柄一次性」的完整流程:

  1. 新句柄出现;
  2. 移动端将其默认为直输输入;
  3. 用户可以把它切回缓冲输入;
  4. 快照/列表刷新保留用户的选择;
  5. worktree 路由重置时清空默认跟踪,为下一个会话作用域做准备。

纯函数实现:无副作用的集合合并

这套逻辑的核心是 mobile/src/terminal/terminal-live-input.ts 中的纯函数,全部以「输入集合 + 新句柄」为参数、返回新集合,并且刻意做到无变化时不分配新对象(直接原样返回输入集合),这在高频刷新的会话 tab 订阅路径上避免了无谓的 GC 与重渲染。

defaultTerminalLiveInputHandlesterminal-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:本轮新发现的终端句柄列表;
  • 返回值中 changedfalse 时,两个集合引用与输入完全相同(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 集合。

仓库中的调用点与这一设计一一对应:

发现路径(只默认、不裁剪)

清理路径(唯一生命周期信号)

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 以 hostIdworktreeId 为作用域。它维护了六个 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):

  1. 先把 pending 的 toggle 编辑逐句柄合并到加载出的 disabled 集合(而不是整体替换,以免抹掉同一 worktree 下其他句柄的 opt-out);
  2. 调用 applyDisabledTerminalLiveInputHandles 调和 enabled/defaulted;
  3. 再把此前积压的 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\x1bTab\tBackspace\x7f、方向键 → \x1b[A/\x1b[B/\x1b[D/\x1b[CPageUp/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 还专门验证了原型链防护:constructortoStringhasOwnProperty 等对象原型属性名从原生按键事件中到来时会被安全地判空,避免映射表查表被原型污染影响。

粘贴级字节预算#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#L136liveInputTerminalHandles.has(activeHandle) 计算 liveInputEnabled,驱动输入栏 UI 与输入路由。

八、UI 行为

按设计文档的 UI Behavior 一节,直输模式下的表现与既有 live input 栏完全一致,无新增控件:

  • accessory 上的直输图标呈激活态;
  • 输入栏提示「键盘输入将直接发送到终端」(对应组件 MobileTerminalLiveInputStatus.tsx);
  • 点击终端聚焦隐藏捕获输入,而不会因为终端变为活跃就自动弹出键盘;
  • 通过同一个模式开关随时切回缓冲命令框,缓冲模式下的命令字段行为保持不变。

九、SSH 与远端场景的意义

设计文档指出,SSH 会话正是这次默认化的一等动机:直输模式避免了本地侧的「命令拼装假设」,无论 shell 是本地还是远端,发送的都是同一份 PTY 字节。变更不触碰 source-control provider 行为,也不改变 terminal.send 的语义——它只改变了「默认走哪条输入路径」,而不改变字节如何被发送。

十、验证:从设计清单到测试证据

设计文档 Validation 一节列出的验证项,均可在仓库测试中找到对应实现:

  1. 一次性默认合并的单元验证——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)。
  2. 过期句柄裁剪的单元验证——terminal-live-input.test.ts#L145-L165:以存活句柄集合为准清理 pty-stale,并验证全存活时不发生分配。
  3. 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(纯函数与字节映射)这条链路定位,并参考上表列出的测试文件复现边界行为。

登录后查看全文
热门项目推荐
相关项目推荐