| name | wterm-mobile-dev |
| description | wterm-mobile Android 终端应用开发工作流 - 基于 Capacitor + @wterm/react,含完整开发闭环 |
wterm-mobile Dev Skill
项目概要
- 目标: Android 终端应用,通过 Tailscale 访问本地 Mac/PC
- 技术栈: Capacitor + React + @wterm/react (WASM 终端)
- 服务端: WebSocket → tmux 桥接(本地 Mac 运行)
- 核心功能: 多 terminal Tab、主机管理、后台保活、WebDAV 同步
一、必读文档顺序
每次开发前必须按顺序阅读:
1. ~/.codex/AGENTS.md → 全局入口、硬护栏
2. ~/.codex/USER.md → 用户偏好(称呼 Jason)
3. coding-principals/SKILL.md → 开发方法论
4. examples/mobile/docs/spec.md → 项目范围与验收
5. examples/mobile/docs/architecture.md → 模块边界与数据流
6. examples/mobile/docs/dev-workflow.md → 执行门禁与验证
7. examples/mobile/task.md → 当前任务板
8. examples/mobile/docs/ui-slices.md → 页面切片与 ownership
9. 本 SKILL.md → 项目约束、可复用门禁
二、项目特有约束
2.1 禁止修改的代码
- 不修改
@wterm/core、@wterm/dom、@wterm/react 源码
- 不修改
examples/ 下其他项目代码
- 只复用,只扩展
2.2 真源分工
spec.md:产品范围与验收
architecture.md:模块边界、数据流、ownership
dev-workflow.md:执行顺序、验证门禁、证据要求
ui-slices.md:页面级切片与文件 ownership
task.md:当前任务状态
CACHE.md:本轮短期上下文
MEMORY.md:长期可复用经验
evidence/:截图、日志、APK、真机证据
- 说明:
examples/mobile/evidence/ 是本地证据仓,默认不进 Git 主线;Git 中只保留目录说明文件
2.3 旧文档处理
examples/mobile/note.md 是 agent 自己看的工作笔记,不是主真源
- 新任务不再往
note.md 追加流程说明
2.4 不在本项目范围
- screen 集成(用户自行管理)
- Tailscale 客户端集成(用户已有 Tailscale App)
- 密钥导入/生成(后续扩展)
- 数据加密存储(后续扩展)
- 生物识别解锁(后续扩展)
2.5 服务端位置
- WebSocket 服务端必须运行在本地 Mac/PC(不是手机)
- 手机端纯客户端角色
2.6 UI 参考图规则
- UI 开发前先冻结主参考图与次参考图
- 先对齐信息结构和交互结构,再做视觉细节
- 当前项目的主 UI 主线是:
Connections 页 + 终端页,不是网页式主机列表页
2.7 页面级切片规则
- 页面级重构先看
docs/ui-slices.md
- App Shell、Connections、Connection Properties、Terminal 必须分层
- 不跨页混改;先壳后功能,先 ownership 后细节
2.8 卡片与预览区规则
- Connections 卡片的 preview 区在没有真实 preview 时,不要回退渲染 subtitle
- preview 和摘要信息必须分层:上半区负责 preview / 占位,下半区负责 title / subtitle / action
- 否则同一 host 摘要会在卡片内重复出现,容易被误判成渲染 bug
2.9 连接模型拆分规则
- mobile 的连接真源必须显式区分
bridgeHost / bridgePort / sessionName;禁止再用 host/username 混装 server 与 tmux session 语义
- terminal header / live session / tab 文案必须能直接看出
server + session 组合,否则多 server / 多 tmux session 场景会失真
2.10 daemon 收敛规则
- server 侧启动入口要收敛成单一 daemon CLI,默认监听地址/端口由统一配置真源决定(当前
0.0.0.0:3333)
- 验证过程中产生的临时 tmux session 需要及时清掉,只保留一个明确实验 session,避免把测试垃圾当成真实 session 列表
bridgePort / daemon 端口 / daemon tmux session 名必须共用同一配置真源;不要在 UI、server、shell script、文案里散落硬编码
- daemon restart/status 只证明 tmux session 存在,不等于 socket 已 ready;验证时至少补一次端口监听检查或真实 WebSocket probe
- terminal 排版真源在 daemon / tmux;client 只上报 viewport(
cols / rows) 并渲染镜像,不能在 keyboard 显隐 / pinch / rotate 时自行 replay buffer
wterm daemon start/restart/install-service 不能只看 launchd loaded;必须至少等到 daemon 端口真正监听,再允许回报 ready,避免手机首连撞启动窗口
- websocket bridge 必须做双向 heartbeat:client 需要
pong timeout -> close -> reconnect,server 需要 protocol ping/pong 回收僵尸 socket;不能让失联 tab 长时间占住 session
- websocket reconnect 的
ws.onopen 必须同步发送 stream-mode,否则 active tab 会暂时退化成 idle/backfill 频率,表现为“秒级延迟”
- scrollback 若通过 DOM prepend/trim 历史行,client 在“未贴底”时必须保 scrollTop 锚点;否则持续输出后回滚会像 buffer 丢失
- 手势滚动进入历史阅读态后,scroll lock 要做成 latch,直到真实输入发生才允许恢复 bottom-follow;不能靠“回到底部”自动解锁
- terminal 单指手势要先做 axis lock:竖向滚动在“确认纵向手势的那一刻”重取
startScrollTop,横向手势再切 tab;否则会出现“不是从当前底部开始滚”的跳变
- 多 tab terminal 在 hidden → active 切换时,不能拿 hidden 期间最后一次
bufferUpdateKind 去重算滚动锚点;inactive tab 应冻结 scroll/layout 推导,切回后只按“贴底/保留原 scrollTop”恢复
- mobile 光标不要额外开本地 blink 动画;只消费 bridge/buffer 的 cursor 位置,避免字体/viewport 变化后出现视觉错位
- 若要让 mobile 光标忠实镜像 tmux,
CellData 真源必须包含 width(0/1/2):client 只能按远程 cell 宽度/continuation 渲染 cursor,不能再按本地字符宽度猜位置
- 多 tab terminal 不允许只保留一个 active TerminalView 再靠
outputHistory replay;每个 session 必须常驻自己的 terminal 实例和本地 buffer
- terminal 持久化缓存不允许只拼 raw output chunk;应从本地 terminal buffer 抽取按行 snapshot(scrollback + visible rows)后再持久化
- daemon 的
sendInitialSnapshot() 不能让 tmux capture-pane 异常冒泡到进程级;最多只允许日志告警 + fallback snapshot
- daemon 的 buffer 真源必须按 tmux session mirror 维护:一个 websocket/tab 只是客户端,不得拥有自己的 authoritative buffer;客户端 detach/reattach 不能重建 session 镜像
- 悬浮球快捷菜单的语义是“文本 snippet 注入”;方向键 / Esc / Tab / Backspace 属于常驻快捷栏,不要和自定义 snippet 共用同一概念模型
2.11 Session Picker 统一入口规则
New connection 入口必须先进入 session picker:先列历史连接,再列当前 tmux sessions,最后才是 clean session / full form
- session picker 顶部必须支持手动输入 Tailscale IP / token,并在输入后立即尝试拉 tmux sessions
- tmux session 列表需要支持最小 CRUD(list/create/rename/kill)以及 multi-select 直接开多个 tabs
- terminal 顶部
+ 的长按必须复用同一个 session picker,用于 quick new tab;普通点击再回 Connections
2.12 Bridge Auth 规则
- daemon / websocket bridge 必须支持共享 token 鉴权;server 真源优先为
~/.wterm/config.json -> mobile.daemon.authToken,WTERM_MOBILE_AUTH_TOKEN 只作为显式 override
- client 的 remembered server / host / picker target 都要携带
authToken,并在 websocket 连接阶段透传
- 验证时必须补一条“无 token 失败 / 正确 token 成功”的证据
三、开发闭环流程
3.1 流程图
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 1.规划 │───▶│ 2.开发 │───▶│ 3.测试 │───▶│ 4.提交 │───▶│ 5.沉淀 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
更新task/CACHE 读skill 运行验证 Git commit 更新skill
定义成功 最小切片 证据记录 检查清单 经验沉淀
3.2 Phase 1: 规划阶段
目标: 明确任务范围、定义成功标准、冻结边界
规划输出模板
每次任务开始前,必须先更新 examples/mobile/task.md 和 examples/mobile/CACHE.md:
## Task-XXX
- 目标:
- 成功标准:
- 验证入口:
- 范围:
- 不在范围:
- 风险:
- 证据输出位置:
进入开发前的冻结检查
| 检查项 | 必须确认 |
|---|
| 成功标准是否可验证? | ✅ |
| 验证入口是否明确? | ✅ |
| 是否有唯一真源? | ✅ |
| 是否只改本轮切片? | ✅ |
3.3 Phase 2: 开发阶段
目标: 最小切片实现,每步可验证
开发顺序(Foundation Modules)
1. Config Module → Host/Session 类型定义、存储方式
2. Provider/Adapter → WebSocket 消息协议
3. Minimal Debug Entry → 最小 UI 可见
4. Observability → 状态变更 event
5. Testing/Harness → 验证入口
6. Build/Install → Capacitor 配置
最小切片规则
最小切片 = 1个文件 + 1个功能 + 1次验证
示例:
- 切片1: 创建 src/lib/types.ts → 定义 Host 类型 → tsc 编译通过
- 切片2: 创建 src/hooks/useHostStorage.ts → localStorage 存取 → 浏览器验证
- 切片3: 创建 src/components/HostList.tsx → 显示主机列表 → 浏览器查看
禁止事项
| 禁止 | 原因 |
|---|
| 一次改多个文件 | 违反最小切片原则 |
| 添加未请求的功能 | 违反 Simplicity First |
| 重构未要求的代码 | 违反 Surgical Changes |
| 修改 @wterm 核心包 | 项目约束 |
3.4 Phase 3: 测试阶段
目标: 四层验证,证据记录
四层验证框架
| 层级 | 验证内容 | 验证方式 |
|---|
| L1: Unit | 纯函数、类型、状态机 | tsc --noEmit + vitest |
| L2: Function | 模块主路径功能 | 浏览器手动验证 |
| L3: Orchestration | 跨模块推进、多 Tab | 多场景手动验证 |
| L4: Runtime | Android 运行态 | 模拟器/真机验证 |
验证入口定义
pnpm --filter @wterm/mobile type-check
pnpm --filter @wterm/mobile test
pnpm --filter @wterm/mobile dev
pnpm --filter @wterm/mobile build
npx cap sync android
npx cap run android
证据记录模板
每次验证后在 examples/mobile/evidence/<date-task>/ 保存:
- 截图
- 命令输出
- APK 路径
- 必要时 logcat / console
完成证据最低标准
- 截图
- 命令输出
- APK 路径
- 必要时 logcat
3.5 Phase 4: 提交阶段
目标: 清晰的 commit,检查清单
Git Commit 规范
<type>: <subject>
<body>
feat: 新功能
fix: 修复
refactor: 重构(仅限请求的重构)
docs: 文档更新
test: 测试添加/修改
chore: 配置/构建变更
feat: 添加 HostList 组件和 useHostStorage hook
- 创建 src/lib/types.ts 定义 Host 类型
- 创建 src/hooks/useHostStorage.ts 实现本地存储
- 创建 src/components/HostList.tsx 显示主机列表
验证: pnpm --filter @wterm/mobile dev → 浏览器访问 → 添加主机成功
提交前检查清单
| 检查项 | 命令 |
|---|
| 类型检查通过 | pnpm --filter @wterm/mobile type-check |
| 无未使用代码 | 手动检查 |
| task.md 已更新 | git diff examples/mobile/task.md |
| CACHE/MEMORY 是否需要更新 | 检查是否有新约束 |
| SKILL.md 是否需要更新 | 检查是否有新门禁 |
3.6 Phase 5: 经验沉淀
目标: 新约束/经验写入 Skill
Skill 更新时机
| 触发条件 | 更新内容 |
|---|
| 发现新的项目约束 | 写入 "禁止事项" |
| 发现新的验证入口 | 写入 "验证入口" |
| 发现反模式/坑 | 写入 "常见问题" |
| 发现可复用模式 | 写入 "最佳实践" |
3.7 回归验证(下次启动)
每次开发前执行:
git log --oneline -5
pnpm --filter @wterm/mobile type-check
pnpm --filter @wterm/mobile dev
cd examples/mobile && npx cap run android --livereload
四、完整功能规格
4.1 主机管理
| 字段 | 说明 |
|---|
| id | UUID |
| name | 显示名称 |
| bridgeHost | IP 或 Tailscale 域名 |
| bridgePort | bridge 端口(默认由统一配置决定,当前 3333) |
| sessionName | tmux session 名 |
| authType | password / key |
| password/privateKey | 凭据(暂不加密) |
| tags | 分组标签(数组) |
| pinned | 是否置顶首页 |
| lastConnected | 最后连接时间戳 |
| autoCommand | 连接后自动执行的命令 |
- 分组/标签: 支持(如"工作服务器"、"个人服务器")
- 搜索/过滤: 不需要
- 备注/描述: 不需要
4.2 虚拟键盘工具栏
| 功能 | 说明 |
|---|
| 位置 | 底部,手机键盘上方 |
| 基础按键 | Ctrl, Alt, Tab, ESC, 方向键 |
| 扩展按键 | F1-F12(电脑键盘模式全显示) |
| 自定义组合键 | 支持(如 Ctrl+C, Ctrl+D),可增删 |
| 预设模板 | 默认提供 Ctrl+C/D/Z |
| 拖拽排序 | 支持 |
| 存储 | 用户配置文件 + WebDAV 导入导出 |
4.3 应用启动行为
| 功能 | 说明 |
|---|
| 自动连接 | 启动时自动连接上次活跃 Session |
| Tab 状态恢复 | 保存上次关闭时的 Tab 状态 |
| 快速重连 | 一键连接最近 3 个主机 |
| 自动命令 | 主机级别默认 + 连接时可临时覆盖 |
| 命令历史 | 每个 Tab 保存 host+autoCommand,WebDAV 同步 |
4.4 Tab 栏设计
| 功能 | 说明 |
|---|
| 位置 | 顶部 |
| 显示内容 | 动态标题(来自 tmux / shell 标题),可手动重命名 |
| 重命名持久化 | 支持 |
| 最大 Tab 数 | 10 |
4.5 后台保活
| 功能 | 说明 |
|---|
| 通知栏 | 显示每个 Tab 连接状态 |
| 自动重连 | 网络恢复后自动重连 |
| 重连次数 | 可配置,默认 3 次 |
| 心跳间隔 | 30 秒 |
4.6 Session 历史
| 功能 | 说明 |
|---|
| Tab 状态保存 | 上次关闭时的 Tab 配置 |
| Session 快照 | 保存完整终端输出历史 |
4.7 网络状态提示
| 功能 | 说明 |
|---|
| 断开提示 | Toast 提示网络断开 |
| 错误详情 | 显示具体错误(认证失败、超时、网络不可达) |
4.8 Android 特有功能
| 功能 | 说明 |
|---|
| 横屏模式 | 支持,终端尺寸自动调整 |
| 外接键盘 | 支持 USB/蓝牙键盘 |
| 分享功能 | 分享终端输出/命令 |
4.9 数据同步
| 功能 | 说明 |
|---|
| 配置导入导出 | WebDAV 支持 |
| 快捷键配置 | WebDAV 同步 |
| 命令历史 | WebDAV 同步 |
五、WebSocket 消息协议
客户端 → 服务端
type ClientMessage =
| { type: 'connect', payload: HostConfig }
| { type: 'input', payload: string }
| { type: 'resize', payload: { cols: number, rows: number } }
| { type: 'ping' }
| { type: 'close' }
服务端 → 客户端
type ServerMessage =
| { type: 'connected', payload: { sessionId: string } }
| { type: 'data', payload: string }
| { type: 'error', payload: { message: string } }
| { type: 'title', payload: string }
| { type: 'closed', payload: { reason: string } }
| { type: 'pong' }
六、状态机定义
idle → connecting → connected → closed
↓ ↓
error reconnecting → connected
interface Host {
id: string;
name: string;
bridgeHost: string;
bridgePort: number;
sessionName: string;
authType: 'password' | 'key';
password?: string;
privateKey?: string;
tags: string[];
pinned: boolean;
lastConnected?: number;
autoCommand?: string;
}
interface Session {
id: string;
hostId: string;
connectionName: string;
bridgeHost: string;
bridgePort: number;
sessionName: string;
title: string;
ws: WebSocket | null;
state: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error' | 'closed';
hasUnread: boolean;
customName?: string;
}
七、文件夹结构
examples/mobile/
├── docs/ # spec / architecture / workflow
├── evidence/ # 截图 / 日志 / APK / 真机证据
├── task.md # 当前任务板
├── CACHE.md # 短期上下文
├── MEMORY.md # 长期经验
├── android/ # npx cap add android 生成
├── src/
│ ├── components/
│ │ ├── TerminalTabs.tsx # 顶部 Tab 栏
│ │ ├── TerminalView.tsx # 单个终端视图
│ │ ├── HostList.tsx # 主机列表页
│ │ ├── HostForm.tsx # 添加/编辑主机表单
│ │ ├── QuickActions.tsx # 快捷键工具栏
│ │ └── ConnectionBar.tsx # 连接状态栏
│ ├── hooks/
│ │ ├── useSession... # session / bridge 状态管理
│ │ ├── useHostStorage.ts # 主机配置存储
│ │ ├── useKeepAlive.ts # 后台保活
│ │ └── useQuickActions.ts # 快捷键管理
│ ├── contexts/
│ │ └── SessionContext.tsx # 多会话状态管理
│ ├── lib/
│ │ ├── types.ts # Host, Session 类型
│ │ ├── websocket.ts # WebSocket 协议
│ │ ├── storage.ts # localStorage 封装
│ │ └── webdav.ts # WebDAV 同步
│ ├── server/
│ │ └── server.ts # WebSocket → tmux 桥接
│ ├── App.tsx
│ └── main.tsx
├── capacitor.config.ts
├── package.json
└── note.md # 历史记录(非主真源)
八、复用代码来源
| 需求 | 来源 | 复用方式 |
|---|
| WebSocket tmux 桥接 | examples/mobile/src/server/server.ts | 当前真源 |
| PTY 本地连接 | examples/local/server.ts | 参考 resize 协议 |
| 终端渲染 | @wterm/react | npm install |
| WebSocket Transport | @wterm/core | npm install |
九、常见问题(按需更新)
问题: WebSocket 连接超时
- 触发信号: 网络不稳定或 Tailscale 未连接
- 解决方案: 检查 Tailscale 状态,重连逻辑自动触发
- 边界条件: 最多重试 3 次(可配置)
问题: Android APK 能打开但连不上本地 tmux bridge
- 触发信号: terminal 一直停在 idle / connecting,bridge 是
ws://,Capacitor WebView 运行在 https
- 真源:
androidScheme=https 会把移动端带到 secure context,ws:// bridge 会被 mixed-content / cleartext 规则卡住
- 解决方案:
capacitor.config.ts 使用 androidScheme=http,AndroidManifest 打开 usesCleartextTraffic=true
- 验证: HTTP 入口或 APK 中连接成功后,header 进入
Connected,bridge 日志出现 session create/close
问题: Android 输入法弹出后又消失 / 键盘按钮无效
- 触发信号: 点快捷栏键盘按钮无反应,或 logcat 出现
ImeTracker ... onCancelled
- 真源: WebView 内 DOM textarea 与原生
EditText anchor 在抢 input focus;只调用 showSoftInput() 不够
- 解决方案: Android 上 terminal 不再主动 focus DOM textarea;键盘按钮只走原生
ImeAnchor;必要时先 clear WebView focus,再由原生 EditText 请求焦点并 showSoftInput
- 验证: logcat 中
ImeAnchor show()/showSoftInput() 命中,点击键盘按钮后系统 IME 实际弹出且中文输入可提交到 tmux
问题: 手机上下滑导致整页 reload / 回弹
- 触发信号: 竖向滑动 shell 时,整个页面像被重新加载或出现 WebView 级下拉回弹
- 真源: body/root 仍可滚动,或 Capacitor WebView 自身 overscroll 未关
- 解决方案:
html/body/#root 固定为 overflow:hidden + overscroll-behavior:none,只让 terminal buffer 容器滚动;MainActivity 再把 WebView 设为 OVER_SCROLL_NEVER
- 验证: 竖向滑动只滚 terminal buffer,不触发整页回弹/重载
十、最佳实践(按需更新)
模式: 最小切片开发
- 适用场景: 所有功能开发
- 示例: 先 types.ts → 再 useHostStorage.ts → 再 HostList.tsx
模式: cat -v 输入真相验证
- 适用场景: 终端快捷键、特殊字符、自定义组合键验证
- 动作: autoCommand 进入
cat -v,然后点击方向键 / Esc / 自定义快捷键
- 验收: 终端必须直接显示
^[[A、^[、^A 或自定义文本,证明字节序列真实进入 tmux
模式: Tab 跟手切换分层
- 适用场景: 多 tab 终端左右滑动切换
- 动作:
TerminalView 只做 axis lock 与横向手势 delta 上报;TerminalCanvas 统一负责相邻 tab 预览、跟手位移、半屏阈值、回弹/完成动画与最终切 tab
- 反模式: 在单个 terminal view 内直接切 tab,会把手势判定、scroll 锚点和切换时序耦死,容易回归“瞬切/错位/滚动锚点跳变”
模式: 移动端发热先看 CPU/IO 真源
- 触发信号: 手机明显发热,但网络流量不大
- 动作: 先抓
adb shell dumpsys cpuinfo、top -H -p <pid>、dumpsys gfxinfo;重点看 Chrome_IOThread / RenderThread / Slow issue draw commands
- 高频真源: 1) server 端空刷 viewport(例如把
cursor.visible 当变化条件导致每 96ms 发包) 2) client 端每帧 localStorage.setItem(JSON.stringify(buffer/snapshot)) 3) 全量 scrollback DOM + 常驻 blur
Inspired by coding-principals skill.
问题: pnpm install 速度极慢或卡住
- 触发信号: 下载进度长期停滞(如 next@33MB 只下载 1MB),resolved 卡在 55 左右
- 真源: npm registry 官方源在中国网络下速度极慢(~5KB/s)
- 解决方案:
- 切换到 npmmirror:
pnpm config set registry https://registry.npmmirror.com
- 重新执行:
pkill -9 -f pnpm; pnpm install --no-frozen-lockfile
- 验证: 切换后 resolved 应快速达到 1400+,packages 应显示 +1293
- 恢复: 安装完成后可恢复官方源:
pnpm config set registry https://registry.npmjs.org
原型页面经验(2026-04-18)
交互设计要点
- 顶部说明文字不是按钮,只显示当前状态
- 快捷栏按钮实现真实交互(点击切换状态)
- 快捷键编辑界面使用全屏覆盖(z-index: 200)
- 终端高度自适应:根据键盘状态动态计算
- 快捷键盘展开:180px
- 系统键盘显示:280px
- 无键盘:320px
最佳实践
- 使用 CSS transition 实现平滑高度变化
- Session 切换面板使用 position: absolute + z-index: 100
- 编辑界面使用 position: fixed 全屏覆盖