بنقرة واحدة
zterm-mobile-dev
zterm Android 客户端开发工作流 - 基于 Capacitor + @jsonstudio/wtermmod-react,含完整开发闭环
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
zterm Android 客户端开发工作流 - 基于 Capacitor + @jsonstudio/wtermmod-react,含完整开发闭环
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
terminal buffer / render / daemon mirror 真源与门禁
zterm Windows Electron 客户端与 WezTerm daemon 开发闭环,覆盖共享核心边界、packaged preload、真实 Windows CDP/source-to-DOM gate 和精确资源清理。
zterm Mac 客户端开发工作流 - Electron 壳、terminal renderer、local/remote tmux、自闭环验证
wterm-mobile Android 终端应用开发工作流 - 基于 Capacitor + @wterm/react,含完整开发闭环
| name | zterm-mobile-dev |
| description | zterm Android 客户端开发工作流 - 基于 Capacitor + @jsonstudio/wtermmod-react,含完整开发闭环 |
每次开发前必须按顺序阅读:
1. ~/.codex/AGENTS.md → 全局入口、硬护栏
2. ~/.codex/USER.md → 用户偏好(称呼 Jason)
3. coding-principals/SKILL.md → 开发方法论
4. android/docs/spec.md → 项目范围与验收
5. android/docs/architecture.md → 模块边界与数据流
6. android/docs/decisions/0001-cross-platform-layout-profile.md → 跨尺寸布局 / Mac 共享壳决策
7. android/docs/decisions/2026-04-23-terminal-head-buffer-render-truth.md → terminal head / sparse buffer / render / UI 真源
8. android/docs/dev-workflow.md → 执行门禁与验证
9. android/task.md → 当前任务板
10. android/docs/ui-slices.md → 页面切片与 ownership
11. 本 SKILL.md → 项目约束、可复用门禁
../wterm,需要改底层时去 fork repo 改mac/、win/ 下其他客户端骨架../wtermzterm 命名,不再沿用 wterm-mobile-* 旧命名spec.md:产品范围与验收architecture.md:模块边界、数据流、ownershipdocs/decisions/0001-cross-platform-layout-profile.md:跨尺寸布局 / Mac 共享壳唯一决策dev-workflow.md:执行顺序、验证门禁、证据要求ui-slices.md:页面级切片与文件 ownershiptask.md:当前任务状态CACHE.md:本轮短期上下文MEMORY.md:长期可复用经验evidence/:截图、日志、APK、真机证据
android/evidence/ 是本地证据仓,默认不进 Git 主线;Git 中只保留目录说明文件pnpm --dir android run build:android。android/update-dist/latest.json、android/update-dist/zterm-<version>.apk、android/update-dist/zterm-latest-debug.apk、~/.zterm/updates/latest.json、~/.zterm/updates/zterm-<version>.apk sha/version 对齐。versionName、versionCode、APK 路径和 sha256;不能只说测试通过。assets/logo.png 为源,同时生成 ic_launcher、ic_launcher_round、ic_launcher_foreground 的 mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi 资源。Adaptive foreground 必须按 Launcher 二次蒙版预留安全区,默认前景不超过画布 80%,背景色与 Logo 外围一致;构建后既要从 APK 解包核对 legacy/adaptive hash,也要看真实 Launcher 截图,包内字节一致不能证明最终图标未被裁切。android/note.md 是 agent 自己看的工作台,不是主真源Connections 页 + 终端页,不是网页式主机列表页docs/ui-slices.mdbridgeHost / bridgePort / sessionName;禁止再用 host/username 混装 server 与 tmux session 语义relayEndpointCandidates/relayHostId/relayDeviceId,但保留 saved row 身份和显示端点;有 relay-rtc 时显示 Relay/WebRTC-first 路由入口。Relay action 构造 transportMode='auto' 的 WebRTC-first Host 交给 session-open owner,route 顺序必须是 rtc-direct -> tailscale/direct websocket -> rtc-relay(TURN);禁止让 Relay directory 替换 saved Host truth,也禁止把 TURN-only 成功冒充 UDP/P2P 成功。daemon.connected=true 且 daemon.hostId 非空。rtc-device-*、client-only、disconnected/stale daemon records 即使携带旧 endpoint/session snapshots,也只能作为 account directory fact,禁止出现在 connectable server rows、drawer host rail 或 target lookup。bridgeHost + authToken live fetch sessions。只有当前 target 自身带 relay-rtc candidates / relay identity 时,才允许按 Relay target 处理;显式 transportMode='webrtc' / relay-route target 禁止在 buildBridgeTargetFromHost() 中自动解析 direct endpoint 填入 bridgeHost。100.x:3333、IPv4/IPv6/wsUrl direct rows canonicalize 到同一 daemon.hostId,否则同一 daemon 会显示成“名字 0 sessions + IP 有 sessions”。alias 只能用于 UI 分组身份,不能替代 transport route truth。relay-rtc:<hostId>,没有 Tailscale/direct endpoint。修 drawer duplicate host 时不能只看 endpoint alias;必须同时验证 saved/Home server alias input,以及 rtc-only directory session catalog 的唯一匹配路径。只有一个 Relay daemon catalog 完整包含该 direct SessionGroup 的非 missing session 名称时才可 canonicalize;多个 daemon 都匹配时保持分离并显式暴露,禁止猜测合并。hostKey 触发的 refresh / quick-new / remote catalog open 必须构造显式 transportMode='auto' 的 WebRTC-first target:rtc-direct 使用 relay signaling + STUN-only ICE,saved/direct/Tailscale endpoint 作为中间候选,rtc-relay TURN-only 最后兜底。验证必须在 direct RTC 失败后证明 direct websocket 先于 TURN 被尝试;Tailscale 断开时再证明 TURN/Relay list-sessions 或 session open 可通。switchSession 绕过 route owner;当 online Relay catalog 唯一 owns 该 daemon 时,点击该 row 必须继续走 session-open owner,用 WebRTC-first target 复用并升级现有 session transport truth,禁止生成第二个同 tmux open tab。TerminalPage canonical projection owner 内去重:direct/Tailscale history、Relay history、rtc-only catalog alias 若最终 resolve 到同一 daemon host rail,则同一 serverIdentity.key + sessionName 只能渲染一行;只允许合并 route/close/open target metadata。禁止在 TerminalSessionDrawer 组件里靠视觉过滤,禁止让 transport/daemon 参与 UI 去重。relayDevices.daemon.endpoints、relayDevices.daemon.sessions、saved/Home alias inputs)都必须进入 comparator 的 UI key。Relay device UI key 必须包含 session name / updatedAt,禁止只比较 endpoint/connected,否则 Relay stream 只更新 session catalog 时 drawer 会继续显示旧 host rail 或旧 session count。SessionGroupHistory 必须保留 normalized relayEndpointCandidates。drawer catalog open/close 和 session-open owner 之间要透传这些候选,否则从 drawer 点 remote-only session 会退化成 direct bridgeHost/bridgePort WebSocket,现场表现为 Relay 行可见但打开时报 ws connect timeout。useSessionOpenActions 的 session-open 主线直接进入 Terminal;点击 active Session 必须走 open-tab/session owner resume。禁止 Home 直接 create/close session、写 Host storage、恢复 cold-start tabs,或用 Relay access token 做导航 gate。sessionName 时不能直接创建 zterm-*。useSessionOpenActions.handleOpenSavedConnection 必须按 server owner 先用 last-entered history 命中当前进程 open Session;否则 live fetch tmux truth,优先进入仍存在的 last-entered session,没有历史则进入第一条远端 session;只有远端列表为空才创建 generated clean session。测试必须证明正常远端有 session 时 createTmuxSession 不调用。server + session 组合,否则多 server / 多 tmux session 场景会失真bridgeHost 已显式写成 ws://host:port / wss://host:port,Android / Mac / shared storage 都必须把这个 endpoint 当成 display / preset id / effective port 的唯一真源;表单也要同步把 Bridge Port 刷成同一个端口,禁止出现双端口假象bridgeHost 直接输入原始 host:port(如 100.127.23.27:40807),shared endpoint 真源也必须当场拆成 bridgeHost=100.127.23.27 与 bridgePort=40807;禁止再把它和独立 bridgePort 二次拼接成非法 ws URL0.0.0.0:3333)install-global.sh 安装/升级 ~/.local/bin/zterm-daemon,再用 zterm-daemon configure-relay 写 ~/.wterm/config.json -> mobile.relay;daemon 只读取配置,不承载账号 UX,禁止把手工改散落配置当成最终交付。~/.local/bin/zterm-daemon 与 ~/.local/bin/wterm,写入前先清旧 symlink/file,released runner 读 config 前要迁移旧 ~/.wterm -> ~/.zterm。如果只靠手工修 PATH、手工挪目录或改已安装文件,视为未修真源。relay-ready 后由 daemon relay host client 发布 directory-update,session catalog 只能来自 tmux 枚举;枚举失败必须显式报错,禁止把失败伪造成空 sessions 的成功目录。deviceId;复用同一 id 会让 client metadata 覆盖 daemon directory identity,造成 account directory 假状态。account.directory;旧 TraversalRelayDeviceSnapshot[] 只能作为现有 UI 的 adapter projection,禁止在 Connections / Picker / Settings 各自从 legacy devices 反向补 endpoint/session 目录语义。rtc-direct -> tailscale/direct websocket -> rtc-relay(TURN) 排序。rtc-direct 使用 iceTransportPolicy=all 且不得携带 TURN credentials,避免 TURN 抢在 Tailscale 前面;rtc-relay 使用 iceTransportPolicy=relay 且只在 WebRTC direct 与 direct websocket 都失败/不可用后承担中继。验证时要分别报告 resolvedPath=rtc-direct、resolvedPath=tailscale 或 resolvedPath=rtc-relay + resolvedRelayTransport=turn,禁止把 Tailscale 直连冒充 Relay,也禁止把 TURN-only 成功说成 UDP 打洞成功。/api/auth/me 控制面真源并覆盖本地 TraversalRelayClientSettings;旧固定域名 claw.codewhisper.cc 只能作为历史别名迁移到 relay.codewhisper.cc,不得继续用于 TURN/WS。刷新失败时禁止打开 /ws/devices 或继续使用 stale TURN/WS 配置冒充 Relay 可用。rtc-error 或 signaling socket 关闭后,client 侧 ICE candidate 回调必须检查 WebSocket readyState 后再发送,并主动收口 peer/signaling;禁止让异步 ICE candidate 往 CLOSING/CLOSED socket 继续 send(),否则用户会看到误导性的 WebSocket is already in CLOSING or CLOSED state。@roamhq/wrtc 与当前平台 @roamhq/wrtc-<platform>-<arch>/wrtc.node 随 release staging 打包;只在源码环境通过不代表全局安装可启动。bridgePort / daemon 端口 / daemon tmux session 名必须共用同一配置真源;不要在 UI、server、shell script、文案里散落硬编码buffer-head-request / buffer-sync-request 只读取当前 mirror storehead/range 请求都不得 await tmux capture / canonical rebuildcols / rows) 并渲染镜像,不能在 keyboard 显隐 / pinch / rotate 时自行 replay bufferwterm daemon start/restart/install-service 不能只看 launchd loaded;必须至少等到 daemon 端口真正监听,再允许回报 ready,避免手机首连撞启动窗口pong timeout -> close -> reconnect,server 需要 protocol ping/pong 回收僵尸 socket;不能让失联 tab 长时间占住 sessionbuffer-head-request,再按本地 buffer 状态决定 diff / 三屏重锚 / reading gap repair);不能再依赖第二套 active/idle 语义startScrollTop,横向手势再切 tab;否则会出现“不是从当前底部开始滚”的跳变bufferUpdateKind 去重算滚动锚点;inactive tab 应冻结 scroll/layout 推导,切回后只按“贴底/保留原 scrollTop”恢复CellData 真源必须包含 width(0/1/2):client 只能按远程 cell 宽度/continuation 渲染 cursor,不能再按本地字符宽度猜位置outputHistory replay;每个 session 必须常驻自己的 terminal 实例和本地 bufferhead + range 读接口,不补第二份语义buffer-head/info,mirror body changed 发 buffer-sync diff。client 正常模式被动吃 push;只有 resume / reconnect / stale probe 才主动 buffer-head-request,只有 reading gap repair 才主动 buffer-sync-request/debug/runtime、/debug/runtime/logs 暴露现场快照;接口复用 daemon auth token,便于服务器端直接拉取现场证据packages/shared,只允许 import 叶子模块(如 schedule/next-fire.ts、connection/types.ts);禁止从 @zterm/shared 根入口取模块,因为根入口会连带 React/CSS,直接把 daemon 运行时打崩\r 执行;只有剪贴板注入才追加到 draft,不要混成同一路径scrollTop;应合并成单向 cadence(如 rAF)贴底,并屏蔽程序化 scroll 反向触发 onScroll,避免底部抖动/拉扯TerminalView 的 follow 对齐若会被 active/reset/layout/audit 多个入口复用,必须先收成单一 helper;scrollTop -> follow/reading 判定也要保持纯 helper,避免同一真相在多个 effect 里分叉updateSessionViewport() 这类 worker 入口必须对完全相同的 reading viewport 去重;若从 reading 切回 follow,要同步清掉已排队的 reading sync,不要让旧 request 在 follow 态晚到active switch、follow reset 等多个入口复用,必须先收成单点 helper;不要让同一 follow 真相在两个分支各算一遍connectSession / reconnect 若重复的是 socket 握手、heartbeat、公共 message switch,就抽 transport helper;但 connected 后的状态推进、bucket 排队、副作用仍保留在各自分支,不要为了去重把两条链混成一条connected 后共享的是同一份 baseline 推进(connected state、schedule-list、active bootstrap、watchdog、connectedCount),可以再抽一层公共 helper;但 bucket reset / pending input drain / retry 队列推进仍留在各自外层finalizeFailure 若共享的是完成位、cleanup、schedule error、manual-close 终止,也可以再抽一层 failure baseline;但 retry、bucket attempt、pending requeue 仍留在各自外层TerminalView 缩 effect 面时,若重复的是 viewport refresh 调度或当前 viewport emit,先抽本地 helper(如 scheduleViewportRefresh / emitCurrentViewportState);先单点化动作,再决定是否减少 effect 数量emitReadingViewportState);renderer 收口先做动作单点化,不急着硬合并 effectresetViewportToFollow / anchorReadingViewportAfterPrepend / emitReadingViewportIfNearEdge);先把动作名字化,再看 effect 是否还能继续收becameActive 与 viewportResetNonce 最终都只是在触发同一 follow reset 动作,可以继续并成一个 reset effect;但要保住 session 切换时 ref 初始化的语义,不要把 reset 信号提前吃掉emitViewportState 的 dedupe key 仍能兜住重复发送tabIndex=-1 + blur + outline nonepointerMove 更新 React state、pointerUp 立即提交,必须用 ref 同步保存最新 dragState;release 不能只读 state 闭包,否则会出现“拖了但顺序没生效”transform(如 translateY(0));这会让内部 position: fixed 的悬浮球/面板改绑到容器坐标系,导致入口“消失”Ctrl + C 会被错误保存成 Ctrlwebview_devtools_remote_<pid> 给目标滚动容器打 touchstart/touchmove/scrollTop probe,并用 adb logcat 验证 defaultPrevented 与 scrollTop 是否真实变化,再决定改事件捕获还是布局resolveKeyboardLiftPx / viewport height 逻辑。若键盘弹起后出现 gap、内容缺失或 quickbar 错位,先确认 WebView 是 overlay 还是 adjustResize:已 resize 时用当前 viewport height 且 lift=0,overlay 时才用 stable height + lift。visualViewport.resize 到达;viewport listener 必须把 current layout height 写入 React UI-shell state,不能只更新 width/top inset。否则后续纯高度 resize 不触发 render,会把首帧 overlay lift 冻结成偶发过度上抬。红测必须按 keyboard-first -> layout/visual resize -> adjustResize zero-lift 顺序重放。TerminalPage 计算 terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell 用同一个 terminalImeLiftPx 上台;TerminalView 不接收 IME layout token、不触发 Android upstream onResize、不把 keyboard 高度写回 daemon/tmux。terminalChromeBottomPx = measured quickBarHeight + safeOffset,terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell bottom = safeOffset + terminalImeLiftPx。禁止为了 IME 或默认态位置引入固定 render lift;也禁止 IME active 时只裁到 terminalImeLiftPx 而不预留 measured quickbar 高度,否则会遮挡终端内容。keyboardInsetPx:IME lift 已由外层 TerminalQuickBarShell.bottom 消费,TerminalQuickBar.onMeasuredHeightChange 只能上报真实 chrome 高度。否则键盘高度大于快捷栏时会把 quickBarHeight 压成 0,stage reserve 丢掉 QuickBar 高度。terminal-keyboard-lift.ts helper;TerminalPage 只能消费 terminalBottomChromeLiftPx 并同时加到 stage reserve 与 QuickBar shell bottom。foldable portrait 必须同时满足宽屏和 height >= width,compact landscape 必须限制 height <= 480;禁止让桌面样宽屏或 stale orientation 获得 mobile bottom lift。ping 做短超时 watchdog,避免“切回 tab 还是旧画面却迟迟不重连”forceHead 不能被 active-reentry 去重 guard 吃掉:lastActiveReentryAtRef、connectedBaselineBurstGuardRef 只能抑制 passive active-reentry 重复 head;explicit-resume + forceHead 必须在同一 OPEN WebSocket 上发送 buffer-head-request,否则会出现“连接还在但后台返回/网络波动后界面不刷新”。restore-sync = 只恢复 local shell / active runtime,不开 transportexplicit-resume = 用户显式激活后才允许 resumeActiveSessionTransportSessionContextcreateSession(connect:false) 虽然不打开 WebSocket,但必须恢复 session transport host / target identity;否则前后台返回或 persisted tab resume 会出现 targetKey=null -> missing-target -> reconnect,把同一 daemon/session 错误重建成新 WebSocket。这个修复只能放在 createSessionRuntime / SessionContext transport owner,禁止在 App/TerminalPage 用 reconnect 补偿,也禁止让 reconnect planner 对 missing target 做宽松 fallback。explicit-resume/debug/runtime.transportSubscribers[0].id 不应变化,日志不得新增 missing-target / transport-detached / rebuildadaptive-phone 通过 tmux resize-window -x 收窄宽度会自动切 window-size=manual,并可能把高度冻结在进入 manual 时的值;当前产品要求仍必须让 tmux 按最窄手机宽度重排,因此 resize 只能集中在 daemon adaptive lease owner,并且最后一个 lease 消失必须恢复 baseline + set-window-option -u window-size 释放 tmux 宽度控制权。要验证这点,必须跑真实 tmux PTY 回归,而不是只看源码里有没有 -y。buffer-head-request 超时不是 WebSocket 失败真相;只要 session socket 仍是 OPEN,activity/foreground owner 只能清 stale probe marker 并在同一 socket 继续请求 head,禁止调用 reconnectSession() 重建。SessionContext 必须先发 buffer-head-request probe。短等无响应只能继续同 socket probe / 显式暴露等待状态;除物理 close/error、target mismatch、missing/closed socket 的显式 open/resume 外,禁止强制替换 socket。UI 不得自行判断 timeout / 直接重连。~/.wterm/daemon-runtime/server.cjs 仍残留旧符号(如 scheduleMirrorFlush、旧 planner/active-push 逻辑)或 /debug/runtime 仍 404,先判定为 staged runtime 未切新;必要时本地执行 prepare-global-daemon-release.sh,覆盖 ~/.wterm/daemon-runtime/ 后只对 com.zterm.android.zterm-daemon 做单服务 launchctl bootstrap/kickstartrenderBottomIndex;renderTopIndex 只能派生,reading/follow 都只改 bottom pointer,renderer 不得参与 buffer 生产或把 producer bottom 写回 sourceTerminalView 不能因 visible/precheck window 不连续而冻结上一帧,必须先渲染最新 tail + 空白 gap 占位;follow 态禁止 prefetch/request 补洞,只等 live tail 或显式切到 readingBridgeSettings,Settings 只做 preset 选择BridgeSettings 里的 terminalWidthMode 是启动排版唯一真源;storage hook 首次 render 必须同步读取 localStorage 并 normalize,禁止先返回默认 mirror-fixed 再等 effect 修正,否则 restore/connect 首帧会按错误宽度模式连接。terminalWidthMode 时必须以 Settings draft next 为真源写回 BridgeSettings;禁止用旧 current 计算后只取 .terminalWidthMode,否则用户从 fixed 点 adaptive 后保存仍会被旧 fixed 覆盖。terminalWidthMode 时,默认模式判定必须优先用 visualViewport.width;Android WebView / 折叠屏可能首帧 visual viewport 窄但 layout viewport 宽,禁止用 Math.max(innerWidth, documentElement.clientWidth, visualViewport.width) 把手机错判成 mirror-fixed。terminal-width-mode localStorage key / TerminalWidthModeManager 属于分叉真源;不得恢复。宽度模式只允许经 STORAGE_KEYS.BRIDGE_SETTINGS -> BridgeSettings.terminalWidthMode -> TerminalPage/SessionContext。requestedTerminalGeometry 只允许保存 measured cols 事实,不允许把历史 widthMode 反过来覆盖当前 BridgeSettings.terminalWidthMode。connect/reconnect/open payload 的 width policy 必须以当前 BridgeSettings 为准;否则用户从 fixed 切到 adaptive 后,旧 session geometry 会继续发 mirror-fixed。APK latest、latest.json、~/.zterm/daemon-runtime/server.cjs 含新代码,都不证明运行中的 Mac daemon 已加载新 runtime。涉及 daemon-side adaptive/mirror/scheduler 修复时,必须看 /health 的 pid/uptimeSec 是否是更新后的进程,并跑真实 WebSocket + tmux probe:发送 connect/resize widthMode=adaptive-phone cols=N 后,用 tmux #{window_width}x#{window_height} 验证列数不因 adaptive 改变。若 daemon uptime 早于 runtime 更新,先做 service-scoped zterm-daemon restart,禁止把手机端继续改成补偿路径。adaptive-phone 没有 active 客户端时必须释放本轮 adaptive lease 对 tmux 的宽度控制权:先按 owner 捕获的 baseline 恢复宽度,再 set-window-option -u window-size。禁止 daemon-start 用 orphan heuristic 自动改用户 session;只有当前 owner 持有过 lease 才能释放本轮 lease。adaptive-phone 不得写 @zterm_adaptive_width_* 持久 option;baseline 只能是 runtime 内存 metadata。若旧 daemon 已留下历史 override,先报告现场事实,除非本轮 adaptive owner 正在释放自己创建的 lease,否则不要在启动时自动清理。alternate-screen。assertTmuxSessionExists() 只能 has-session;control/capture runtime 禁止 set-option -t <session> alternate-screen off。如果 iTerm2/TUI 显示不全但 window-size=<default>,要继续查 alternate-screen local override;正确修复是删除 daemon 副作用并 unset 历史 local option,不是在客户端补偿。adaptive-phone 也只能由 daemon adaptive lease owner 请求 tmux 改变宽度。移动端调试时禁止把“客户端 cols=N”直接当成“mirror cols=N”;必须等 daemon capture / tmux pane metrics 主线回写 mirror truth,且 gate 禁止 attach/resize/startMirror 自写 mirror.rows/cols/bufferStartIndex/bufferLines/cursor。adaptive-phone 可见宽度要求是 tmux 真实重排,不是客户端 renderer 本地投影:Settings / BridgeSettings / connect payload 显示 adaptive 只证明配置和 wire,不证明 tmux 已按手机宽度重排。禁止用 renderer crop 冒充 adaptive;也禁止在现有 fixed-row virtual scroll 上用 CSS white-space: normal / height:auto / wrapper width 做浏览器自换行,这会破坏 scrollTop -> row index 映射,表现为上滚循环、buffer 反复和 IME 后底部不可见。正确路径是 client 上报 measured cols -> daemon adaptive lease owner 聚合最窄 cols -> tmux resize-window -x -> mirror capture/readback -> client 渲染。cardBorder / 亮色 shell border;深色 terminal 左侧 1px 外框会被用户看到为白条。白条排查顺序:先查 stage/pane/group center border,再查 .wterm 内部 scrollbar,再查 DOM input/focus ring;禁止改 daemon/buffer/tmux 补 UI chrome 问题。adaptive-phone wire payload 必须带有限正数 cols 才能进入 daemon adaptive width lease owner;缺 cols / NaN / 0 是协议错误,不是可用 lease。daemon 必须显式返回 adaptive_width_cols_invalid 并保持进程存活,禁止让 normalizeTerminalCols() 的异常穿透杀掉 Node。client 冷启动尚未测量真实宽度时,先发送 SessionContext 默认启动列数,后续 TerminalView resize 再覆盖为真实列数;禁止发送 widthMode='adaptive-phone' 但 cols=undefined 的半语义 payload。buffer-sync wire payload 必须覆盖首个 changed range 到最后 changed range 之间的完整连续 authoritative span;禁止只发送不相邻变更行并让 client 保留中间旧行,否则快速 TUI 输出会出现旧 buffer 闪回。若需要降带宽,必须先设计多 range wire contract 和 client apply 红测,不能把有洞 payload 伪装成完整窗口。pointerup / touchend / click 再加时间戳去重。touch / pointer 只适合手势关闭、拖拽、滑动判定;如果按钮点击在真机上失效,先收敛成单一语义路径,再补回 regression test。click,如果 drawer/sheet 在 release 后才出现在手指下面,这个合成 click 可能命中新出现的 row。任何 row selection 都必须要求 press ownership 从同一 row 内开始;没有 matching row press 的 pointer click 必须丢弃。keyboard/accessibility detail=0 仍允许。sessionId,TerminalPage 只消费这个 id 去更新 focused session-group viewport slot;禁止把 remote:<owner>::session:<name> placeholder 当 active truth,也禁止在 drawer/UI 里新增重连或 transport fallback。回归要模拟 first tap -> owner returns id -> parent rerender with active Session -> center TerminalView 直接渲染新 session,不需要第二次点击。handleOpenGroupSession(..., { activate:false, navigate:false })。预览 selection storage 只存返回的本地 sessionId target;打开失败显式报错,不存 remote:<owner>::session:<name> placeholder,不切真实 shell。mirror-fixed 下 renderer 横向裁切平移优先于 shell 抽屉/tab 手势:非左侧热区(包括右侧与中间)的横向拖动都归 TerminalView crop pan;外层 drawer swipe 只允许左侧热区 + previous 方向。禁止因为 fixed 模式需要抽屉入口而重新启用左右两侧 tab swipe。mirror-fixed 的手势优先级不能只看起点热区:若当前 horizontal offset 大于 0,右滑仍能真实回移 renderer,TerminalView 必须消费并 stopPropagation() 整次横滑;只有 offset 在手势开始前已为 0,左缘右滑才允许交给 drawer。反模式是子级只 preventDefault() 但让父级 touchend 继续解析成 previous。mirror-fixed crop pan。96px 在约 347px 宽的手机 viewport 上过宽,会把视觉上已经离开左边缘的右滑误判成抽屉。改热区时必须保留 56px 正向 + 88px 反向成对 gate,并用真机确认非边缘右滑同时满足 drawer hidden 与 fixed offset 变化。open_tab -> active_session 是否被 UI 合成 click 推进,再查 transport error projection。TerminalSessionDrawer 底部 New Session 例外:真机不能依赖 click 或 pointerup;用按钮自身单一 touchend owner,并 stopPropagation() 截断父级 drawer 手势。TerminalSessionDrawer row 内关闭 × 也必须有自身 touchend 激活路径;父 row 有 touch/long-press 手势时,close button 要 stopPropagation()、清长按 timer、去重 synthetic click,测试必须覆盖 touch close 不触发 select。drawer:touchstart 而没有 add:*,不要继续猜 click/pointer/touch,也不要直接下“遮挡”结论;先加 cap:start/end:<target> 确认真实命中节点。TerminalSessionDrawer 底部 New Session 的语义 owner 应放在整个 footer hit surface,而不是只放在内部可视 button 上。ImeAnchor backspace 事件路径,以及 ImeAnchor key payload 路径。KEYCODE_DEL -> Backspace、KEYCODE_FORWARD_DEL -> Delete、KEYCODE_ESCAPE -> Escape;JS 层再统一映射到终端序列并路由到当前 active session。ImeAnchor 日志显示 showSoftInput(...)=true、onCreateInputConnection() 命中,dumpsys input_method 显示 mServedView=ImeAnchorEditText、mInputShown=true、mIsInputViewShown=true,但截图无键盘且 contentTopInsets 仍接近屏幕底部 / 导航栏(例如 2505)。EditText 做对照。若普通文本框也显示 mInputShown=true / mIsInputViewShown=true 但无键盘,先判定为 IME 进程全局 ghost shown,不再继续改 zterm anchor / renderer / tmux。adb shell am force-stop com.tencent.wetype 复位 WeType;这是单包复位,不是 broad kill。复位后重新点击 zterm 键盘按钮,必须用截图和 dumpsys input_method 验证真实键盘窗口展开,contentTopInsets 应变成真实键盘顶部(例如 1509)。mInputShown=true 当成视觉闭环;不要用 toggleSoftInput()、重复 guard、renderer reflow、tmux resize、IME 高度猜测或 WebView 清缓存去补偿 WeType ghost 状态。Escape / Backspace / Delete / Ctrl+C。ImeAnchor.getState().keyboardVisible;visible 时 hide,不 visible 时 show。不要用本地 requested flag 或 keyboardInset 猜测显隐。ImeAnchor.show();native keyboardState(visible=true) 到达后,如果本次是 terminal keyboard request,也要再做一次 follow/bottom realign,覆盖 IME 上台导致的 visual viewport 缩高。否则滚到历史区后键盘上台会裁切到旧 viewport,表现为输入区/底部缺失。TerminalView 不接收 IME layout token、不触发 upstream resize、不改 daemon/tmux geometry。ImeAnchorEditText 必须保持可服务输入法的真实 rect,但 cursor 不可见;若截图出现额外蓝色/灰色 native 光标,先查 anchor setCursorVisible(false)。New connection 入口必须先进入 session picker:先列历史连接,再列当前 tmux sessions,最后才是 clean session / full formbridgeHost + authToken,必须自动刷新 tmux sessions;不要要求每次人工点击 Connect+ 的长按必须复用同一个 session picker,用于 quick new tab;普通点击再回 ConnectionssessionId。saved tab list 导入可做 import-only semantic 去重。fetchTmuxSessions(),结果必须回写同一个 sessionGroups catalog,再由 drawer 投影 remote-only rows。禁止让 drawer effect 依赖整个投影列表,否则 catalog 更新会反复触发枚举。tmux_session_unavailable 投影成全局错误 banner。routecodex 这类本地 OPEN_TABS 中的过期 session,若 tmux truth 已不存在,只能表现为缺失事实或 idle closed shell,不能影响当前 session。~/.wterm/config.json -> mobile.daemon.authToken,WTERM_MOBILE_AUTH_TOKEN 只作为显式 overrideauthToken,并在 websocket 连接阶段透传ConnectionsPage / ConnectionPropertiesPage / TerminalPage 各自散落 breakpoint0001-cross-platform-layout-profile.md 冻结设计,再进入实现ZTerm.app,必须先退出旧实例,再打开新实例;不要直接 open -n 叠多个 app 进程污染证据single runtime · multi tabs:可以维护多个 open target tab,但同一时刻只允许一个 live websocket/runtime;不要把“可切换 tabs”误报成“并发多 live sessions”1 / 2 / 3 preset,风格靠近 iTerm2/Tabby,但不要上来做自由拖拽top / center / bottom 是用户显式分配的固定槽位,点击 peek 不得改写;stage 只基于当前 focus slot 计算 viewport projection。focus=top 时 viewport 为 empty / top / center,focus=bottom 时 viewport 为 center / bottom / empty,focus=center 时 viewport 等于固定槽位。focus 必须存 slot name,不能存 session id。抽屉点击 session 只替换当前 focus 槽位:focus=bottom 替换 bottom,focus=top 替换 top,focus=center 替换 center。禁止自动从 session 列表补邻居成 wheel,也禁止点击后循环轮转三槽位。抽屉长按/右键是唯一的槽位分配入口,打开 slot menu 后必须 suppress 下一次 click,避免菜单和 session 激活同时发生。before/center/after 边界模型。focus 在 before/top 边界时隐藏 before/top peek;focus 在 after/bottom 边界时隐藏 after/bottom peek;center 才显示两侧。UI shell 只消费 projection,不得自己补 top/bottom 或 left/right 局部判断。TerminalPageStageShell 的 sessionGroupVisible 条件、不要加 “center-only 不进 group”、不要让横屏强行进入 horizontal group、不要调整抽屉选择 session 的切换顺序;这些会破坏竖屏上中下显示和上下滚动。竖屏恢复包必须回到 1945 行为:!splitVisible && !landscape && center 才走当前 mobile group stage。width / height <= 0.4)强制 vertical,上下滚;宽竖屏默认 horizontal,但 Settings 可切 vertical;landscape 永远 horizontal。这个设置只影响 layout projection,禁止改写 drawer 固定槽位、session/tab/pane 真相。src/lib/server-identity.ts:用户可见 label 优先是 connection/daemon 名,颜色也按同一 server key;禁止在 drawer、side peek、tab 文案里把 bridgeHost:bridgePort 或 telnet/bridge 端口当服务器名。server-identity alias 输入必须同时来自 live sessions、session groups、Relay daemon endpoints;只从 live sessions 建 alias 会漏掉“目录里有 daemon,历史 group 用 IP”的常见路径。src/lib/server-color.ts 的固定红/黄/蓝/绿/青/橙 palette;禁止连续 hue hash 漂到紫/粉区,常见服务器 key 需要测试锁住不同色。TraversalSocket 或断言 WebSocket 实例/线路选择的测试必须在 beforeEach 清 defaultTraversalRouteHealthCache,否则前一用例记录的坏线路会让后续用例不创建 socket,表现为 MockWebSocket.instances 为 0。FileTransferSheet 只消费 StoragePermissionPlugin 返回的目录事实,不得在 UI 或 native 枚举层按扩展名、图片类型、隐藏文件名做过滤。showHidden,客户端必须请求完整列表;禁止用默认隐藏文件过滤制造“目录成功但文件消失”的假状态。file-upload-complete;禁止把上传后的远端路径写入 tmux、quick input、composer 或任何对话输入框。file-download-chunk 分块写入 native storage,并在完成后用 stat 校验本地字节数等于远端 totalBytes,失败必须显式进入 transfer error。switchRuntime: 'explicit-resume' 推进 transport;禁止 resumeActiveSessionTransport() 成功后提前 return,否则首次点击只开连接不切 UI,必须二次点击才可见。android/docs/loops/LOOP.md,机器真源是 android/docs/loops/loop-manifest.json。zterm.daily-triage 以 L1 report-only 运行:只读项目真源、报告、追加 run log;禁止 product code edits、daemon start/stop、stage/commit、push/merge。feature_id、owner、required gate 和 mainline_call_id;mainline_call_id 必须反查到 docs/wiki/mainline-call-map.json 的真实 edge_id。pnpm --dir android run test:feature-registry -- --reporter dot,其中 src/lib/loop-governance-truth.test.ts 会锁住 L1 禁动作、kill switch、manifest 和 mainline call ID。Android / daemon / shared / Mac 任一 terminal 主链改动,都必须按影响面选择验证层级,不能用低层 gate 冒充高层完成。
pnpm --dir android run test:feature-registry -- --reporter dot、pnpm --dir android exec tsc -p tsconfig.json --noEmit --pretty false。pnpm --dir android run daemon:mirror:close-loop。pnpm --dir mac test -- --reporter dot 与 pnpm --dir mac run type-check。bridge-transport、local tmux local-tmux-transport、terminal-runtime、workbench active target。汇报要求:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 1.规划 │───▶│ 2.开发 │───▶│ 3.测试 │───▶│ 4.提交 │───▶│ 5.沉淀 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
更新task/CACHE 读skill 运行验证 Git commit 更新skill
定义成功 最小切片 证据记录 检查清单 经验沉淀
目标: 明确任务范围、定义成功标准、冻结边界
每次任务开始前,必须先更新 android/task.md 和 android/CACHE.md:
## Task-XXX
- 目标:
- 成功标准:
- 验证入口:
- 范围:
- 不在范围:
- 风险:
- 证据输出位置:
| 检查项 | 必须确认 |
|---|---|
| 成功标准是否可验证? | ✅ |
| 验证入口是否明确? | ✅ |
| 是否有唯一真源? | ✅ |
| 是否只改本轮切片? | ✅ |
目标: 最小切片实现,每步可验证
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 核心包 | 项目约束 |
目标: 四层验证,证据记录
| 层级 | 验证内容 | 验证方式 |
|---|---|---|
| L1: Unit | 纯函数、类型、状态机 | tsc --noEmit + vitest |
| L2: Function | 模块主路径功能 | 浏览器手动验证 |
| L3: Orchestration | 跨模块推进、多 Tab | 多场景手动验证 |
| L4: Runtime | Android 运行态 | 模拟器/真机验证 |
当出现下面这类问题时,优先走 daemon 远程调试接口,而不是只靠猜:
唯一真源入口:
GET /debug/runtime
GET /debug/runtime/logs
sessionId / tmuxSessionName / scope 过滤GET /debug/runtime/control?enabled=1
鉴权规则:
?token=<auth>Authorization: Bearer <auth>优先用脚本,不手搓 curl:
cd android
pnpm daemon:runtime:remote snapshot --host 100.x.x.x --port 3333 --token <auth>
pnpm daemon:runtime:remote logs --host 100.x.x.x --port 3333 --token <auth> --limit 200
pnpm daemon:runtime:remote enable --host 100.x.x.x --port 3333 --token <auth> --reason ime-refresh-debug
pnpm daemon:runtime:remote logs --host 100.x.x.x --port 3333 --token <auth> --sessionId <session-id> --scope follow
现场排障最小顺序:
snapshot,语义是 runtime 状态快照,不是 terminal buffer 快照链路)
clientSessions[].state/lastBufferSyncRequest/lastHeadRequestAtmirrors[].revision/bufferStartIndex/bufferEndIndex/lastFlushCompletedAtenablelogs针对当前两类高频问题的看法:
“输入一下就恢复”
先看:
lastHeadRequestAt 是否持续推进)lastBufferSyncRequest.mode 是否仍在 follow“键盘弹出就正常,不弹就不正常”
先看:
# L1: Unit 验证
pnpm --filter @wterm/mobile type-check
pnpm --filter @wterm/mobile test # vitest 单元测试(如脚本存在)
# L2: Function 验证(本地开发)
pnpm --filter @wterm/mobile dev
# 结构验证:浏览器访问 portless 输出的 *.localhost 地址
# 真连通验证:pnpm --filter @wterm/mobile preview -- --host 127.0.0.1 --port 4173
# 手动操作:添加主机 → 连接 bridge → 验证终端显示
# L3: Orchestration 验证
# 多 Tab 操作:新建 Tab1 → 新建 Tab2 → 切换 → 关闭
# L4: Runtime Smoke(Android)
pnpm --filter @wterm/mobile build
npx cap sync android # 同步到 Android
npx cap run android # 启动模拟器/真机
适用场景:需要给 Jason 交付可安装升级包、必须进入 update/release 渠道。
cd android
./scripts/build-android-debug.sh
该命令是唯一标准入口,内部顺序固定:
pnpm build
test:terminal:regressionnpx cap sync androidnative/android/gradlew assembleDebugnode ./scripts/prepare-update-bundle.mjs <app-debug.apk>发布目标目录(必须检查):
android/update-dist/android/release-dist/~/.wterm/updates构建完成后最低验收(缺一不可):
update-dist/latest.json 存在且字段完整:
versionNameversionCodeapkUrlsha256sizerelease-dist/latest.json 与 update-dist/latest.json 的 versionCode/sha256/size 一致。update-dist/<apkUrl> 文件存在,且 sha256 与 manifest 一致。禁止事项:
build-android-debug.sh 直接手工拷贝 APK 冒充发布。latest.json 与实际 APK hash。每次验证后在 android/evidence/<date-task>/ 保存:
目标: 清晰的 commit,检查清单
# Commit message 格式
<type>: <subject>
<body>
# type 范围
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 android/task.md |
| CACHE/MEMORY 是否需要更新 | 检查是否有新约束 |
| SKILL.md 是否需要更新 | 检查是否有新门禁 |
目标: 新约束/经验写入 Skill
| 触发条件 | 更新内容 |
|---|---|
| 发现新的项目约束 | 写入 "禁止事项" |
| 发现新的验证入口 | 写入 "验证入口" |
| 发现反模式/坑 | 写入 "常见问题" |
| 发现可复用模式 | 写入 "最佳实践" |
每次开发前执行:
# 1. 检查上次提交状态
git log --oneline -5
# 2. 运行基础验证
pnpm --filter @wterm/mobile type-check
# 3. 本地启动验证
pnpm --filter @wterm/mobile dev
# 4. 如有 Android 项目
cd examples/mobile && npx cap run android --livereload
| 字段 | 说明 |
|---|---|
| id | UUID |
| name | 显示名称 |
| bridgeHost | IP 或 Tailscale 域名 |
| bridgePort | bridge 端口(默认由统一配置决定,当前 3333) |
| sessionName | tmux session 名 |
| authType | password / key |
| password/privateKey | 凭据(暂不加密) |
| tags | 分组标签(数组) |
| pinned | 是否置顶首页 |
| lastConnected | 最后连接时间戳 |
| autoCommand | 连接后自动执行的命令 |
| 功能 | 说明 |
|---|---|
| 位置 | 底部,手机键盘上方 |
| 基础按键 | Ctrl, Alt, Tab, ESC, 方向键 |
| 扩展按键 | F1-F12(电脑键盘模式全显示) |
| 自定义组合键 | 支持(如 Ctrl+C, Ctrl+D),可增删 |
| 预设模板 | 默认提供 Ctrl+C/D/Z |
| 拖拽排序 | 支持 |
| 存储 | 用户配置文件 + WebDAV 导入导出 |
| 功能 | 说明 |
|---|---|
| 自动连接 | 启动时自动连接上次活跃 Session |
| Tab 状态恢复 | 保存上次关闭时的 Tab 状态 |
| 快速重连 | 一键连接最近 3 个主机 |
| 自动命令 | 主机级别默认 + 连接时可临时覆盖 |
| 命令历史 | 每个 Tab 保存 host+autoCommand,WebDAV 同步 |
| 功能 | 说明 |
|---|---|
| 位置 | 顶部 |
| 显示内容 | 动态标题(来自 tmux / shell 标题),可手动重命名 |
| 重命名持久化 | 支持 |
| 最大 Tab 数 | 10 |
| 功能 | 说明 |
|---|---|
| 通知栏 | 显示每个 Tab 连接状态 |
| 自动重连 | 网络恢复后自动重连 |
| 重连次数 | 可配置,默认 3 次 |
| 心跳间隔 | 30 秒 |
| 功能 | 说明 |
|---|---|
| Tab 状态保存 | 上次关闭时的 Tab 配置 |
| Session 快照 | 保存完整终端输出历史 |
| 功能 | 说明 |
|---|---|
| 断开提示 | Toast 提示网络断开 |
| 错误详情 | 显示具体错误(认证失败、超时、网络不可达) |
| 功能 | 说明 |
|---|---|
| 横屏模式 | 支持,终端尺寸自动调整 |
| 外接键盘 | 支持 USB/蓝牙键盘 |
| 分享功能 | 分享终端输出/命令 |
| 功能 | 说明 |
|---|---|
| 配置导入导出 | WebDAV 支持 |
| 快捷键配置 | WebDAV 同步 |
| 命令历史 | WebDAV 同步 |
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; // 用户重命名的名称
}
android/
├── 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 桥接 | android/src/server/server.ts | 当前真源 |
| PTY 本地连接 | examples/local/server.ts | 参考 resize 协议 |
| 终端渲染 | @jsonstudio/wtermmod-react | npm install |
| WebSocket Transport | @jsonstudio/wtermmod-core | npm install |
Tailscale -> IPv6 -> IPv4 -> Relayws://,Capacitor WebView 运行在 httpsandroidScheme=https 会把移动端带到 secure context,ws:// bridge 会被 mixed-content / cleartext 规则卡住capacitor.config.ts 使用 androidScheme=http,AndroidManifest 打开 usesCleartextTraffic=trueConnected,bridge 日志出现 session create/closeImeTracker ... onCancelledEditText anchor 在抢 input focus;只调用 showSoftInput() 不够ImeAnchor;必要时先 clear WebView focus,再由原生 EditText 请求焦点并 showSoftInputImeAnchor show()/showSoftInput() 命中,点击键盘按钮后系统 IME 实际弹出且中文输入可提交到 tmuxImeAnchor 以后,没有给 quick input/editor 这类 DOM textarea 留独立输入通道;terminal IME 与 quick input DOM focus 没有分层ImeAnchor;quick input / editor / 浮层 textarea 获得 DOM focus 时,立即 suspend terminal IME、停止把 anchor 输入路由回 session;浮层展开时同时隐藏底部 shell quick rows,避免双入口叠加ImeAnchor 不能把输入字段伪装成 password/no-suggestions 真空场;否则语音/组合输入完成链可能只走 composing/finish,不走普通 commitTextImeAnchor 输入类型保持普通 text multiline,不再用 password/no-suggestions 组合硬压;同时在 InputConnection 补 finishComposingText 收口,确保最终文本会 emit 到 terminalvisibilitychange/resume/focus 与 Capacitor App.appStateChange;进入前台时强制 sweep reconnectAllSessions(),不要只等 heartbeat 自己超时html/body/#root 固定为 overflow:hidden + overscroll-behavior:none,只让 terminal buffer 容器滚动;MainActivity 再把 WebView 设为 OVER_SCROLL_NEVERtransform 抬起的 quick bar 容器下,再按 keyboardInset 计算 bottom/padding 会发生双重位移keyboardInsetscrollTop 始终不变,尤其是真机 WebView + DOM input 聚焦后100dvh 定高,键盘把 visualViewport 压小后容器仍认为自己没有 overflow,最终出现 scrollHeight == clientHeightpreventDefault;随后用 visualViewport.height + offsetTop 计算可见底,用 layoutHeight - visibleBottom 作为 bottom inset 抬升 sheet,不能只改 scroll 容器scrollHeight > clientHeight,真机 swipe 后 scrollTop 能增长+ 添加组合键 或编辑项进入 form,首屏不是从顶部开始,顶部内容像被吞掉;同时悬浮球可能压在表单右侧shortcut-editor-scroll,mode 切换时继承了旧 scrollTop;此外 full-screen editor 打开时 floating bubble 没隐藏shortcutEditorMode / shortcutEditorOpen / editingShortcutId 变化时,通过 ref + rAF 把滚动容器重置到 scrollTop=0;editor 打开期间隐藏 floating bubblescrollTop=0,且不再看到悬浮球覆盖cat -v,然后点击方向键 / Esc / 自定义快捷键^[[A、^[、^A 或自定义文本,证明字节序列真实进入 tmuxTerminalView 只做 axis lock 与横向手势 delta 上报;TerminalCanvas 统一负责相邻 tab 预览、跟手位移、半屏阈值、回弹/完成动画与最终切 tabTerminalView 的 layout refresh / session refresh / follow auditsyncViewport + 可选 follow 对齐 收成单一 runViewportRefresh() 动作;scheduler/effect 只调这个动作,是否 follow 在执行时通过当前 latch/ref 判断scheduleViewportRefresh() 直接依赖 followMode,会导致 reading/follow 切换时把无关 refresh effect 全部重新建一遍ResizeObserver 回调直接复用统一的 runViewportRefresh(),不要单独调用 syncViewport()syncViewport();这样 follow 对齐逻辑会再次分叉TerminalView 里多个 effect 最终都只是在“判定某个 trigger 是否值得 schedule refresh”becameActive / sessionChanged / layoutChanged),把 refresh 调度并到单一 effect;timeout 差异继续按 trigger 决定TerminalView 剩下的 effect 已经承载 reading 锚定、viewport signal 这类真实状态语义reconcileViewportAfterBufferShift()、emitViewportSignalsForCurrentFrame()),再让 effect 只做 trigger/state bridgeTerminalView、TerminalPage、SessionContext、相关测试都在重复声明 viewport/resize callback 的 shapeandroid/src/lib/types.ts,其余层只 import 使用{ mode, viewportEndIndex, viewportRows } 或 (sessionId, cols, rows),后续改字段时四处漂移TerminalView 之类 renderer component 的 propsSessionContext / worker 内部状态名带实现细节,但 renderer 只关心触发语义followResetToken),由 page/context 做一次最小映射viewportResetNonce 这种 worker 侧命名直接透传到 renderer prop,污染 consumer 心智SessionContext 里普通 request 与 bootstrap request 只有少量字段差异forceBootstrap、modeOverride)updateSessionViewport() 一类 worker 入口逐渐长出 normalize / 判等 / request scheduling 多重职责sendInput() 只发送 input,不本地改 buffer;同时给 active session 标记 input-tail-refresh demand,由 client 本地 30fps head cadence 在 minTailRefreshGapMs 门限下主动发 follow buffer-sync-request + pingbuffer-sync diff,纯 metadata 变更发 buffer-head/info。client 正常模式不再高频主动问 head;只在 resume / reconnect / stale probe 发 buffer-head-request,只在 reading gap repair 发 buffer-sync-requestbuffer-head-request 反向触发 daemon capture 3) 把 reading repair 和正常 live 主链混成一条appForegroundActiveSessionContext lifecycle 在 false->true 时唯一触发 explicit-resume,和冷启动恢复共用同一 transport ownerterminal.transport_lifecycle owner 内用 recent alive truth(lastServerActivityAtRef / lastConnectedBaselineAtRef)给 explicit-resume / active-reentry 加短 keepalive grace;窗口内 missing/closed local socket 只返回 transport-keepalive-grace,超过窗口再走现有 reconnect/throttle owneractive-tick 或显式 input recovery;用户输入撞到 closed socket 仍必须走现有即时恢复路径sessionState=reconnecting + ws=null + no pending open intentreconnectInFlight 当单一真相,导致 transport-unavailable 永久 skipadb shell dumpsys cpuinfo、top -H -p <pid>、dumpsys gfxinfo;重点看 Chrome_IOThread / RenderThread / Slow issue draw commandscursor.visible 当变化条件导致每 96ms 发包) 2) client 端每帧 localStorage.setItem(JSON.stringify(buffer/state)) 3) 全量 scrollback DOM + 常驻 blur.app 只验证 build/package/window/stage 可执行;表单交互与回显优先走浏览器 dev server(同一 renderer 代码)做细粒度验证,再回到桌面壳做 smokews open -> send connect(payload) -> recv connected -> send buffer-head-request;仅有 list-sessions 只能证明 bridge 可达,不能证明 session 已 attachThe supplied data is not valid base64 content.[0xff] -> /w==、[0xee] -> 7g==;错误拼接是 /w==7g==,正确结果必须是合并 bytes [0xff, 0xee] 后重新编码的 /+4=chunkIndex 逐 chunk atob 解码为 Uint8Array,合并 bytes 后重新 btoa 生成唯一 dataBase64;预览可继续使用 dataBytes,保存必须消费重新编码后的完整 base64preview-ready,丢弃/关闭 能退出 sheet;不要只验证预览成功ordered.join('') 拼接每块 base64 2) 把真实 payload 裁成单 chunk 规避问题 3) 只改 UI alert/按钮状态而不修传输 payload 真源/storage/emulated/0/Download 仍显示空目录或读取失败。Directory.ExternalStorage 文档只保证 Android 9 或更老版本;Android 11+ 全盘文件访问即使有 MANAGE_EXTERNAL_STORAGE,也不能把 Capacitor Filesystem 当 owner。StoragePermissionPlugin native 层,由 native 用 Environment.getExternalStorageDirectory() + canonical path guard + java.io.File 执行 readdir/readFile/writeFile/mkdir;UI 只负责权限 request、focus/visibility refresh、错误投影。Filesystem.readdir({ directory: Directory.ExternalStorage }),或把异常清空成“空目录”。pointer/touch move threshold 区分 click 和 drag;超过阈值后进入拖动态并 suppress click,位置持久化到 localStorageAll/None/Manage/Clear/Open checked,用户反馈不能退出或键盘遮挡操作Done;Clear 要同时清 selection 和 expanded state;action row 必须 wrap,避免窄屏/键盘下横向溢出Open 在 history-only/0-session 卡片上空转Open/Enter 按钮才负责打开/恢复 session;不可打开的 group 显示 Details 并展开,禁止死 OpenbridgeHost / bridgePort / authToken 后再 fetchTmuxSessions() / createTmuxSession();directory 只提供 endpoint/session catalogterminal.session_drawer;mirror-fixed 横向裁切属于 terminal.buffer_render renderer projection;adaptive 宽度属于 daemon adaptive width lease owner。mirror-fixed 的 drawer 入口只允许左侧 64 CSS px + 向右 previous 手势:x=56 是正向样本,x=88 必须归 renderer crop pan。Android 不要把热区收成 0-几 px 贴边,系统返回手势会抢。adaptive-phone 中间横滑保持 no-op,不做客户端裁切,不抢 daemon/tmux reflow。mirror-fixed 中间横滑只移动 .term-grid projection offset,并按 session 持久化;不得 resize tmux、不得改 daemon mirror rows/cols。TerminalTabSwipeSurface.test.tsx、TerminalPage.session-drawer.test.tsx、TerminalPage.tab-isolation.test.tsx、TerminalSessionDrawer.test.tsx、TerminalView.dynamic-refresh.test.tsx;真机用 DevTools/截图证明近边热区打开 drawer、点击 drawer row 能切 session、middle swipe 不打开 drawer、mirror-fixed offset 变化并持久化。terminal.session_preview owns selection/mode/grid projection;terminal body truth 仍属于 daemon mirror、client sparse buffer、render store 和 shared TerminalView;transport/reconnect/resize owner 不变。sessionId + bridgeHost + bridgePort + sessionName,有 daemonHostId 时恢复也要匹配。mirror-fixed crop。同一 touch sequence 只能有一个 owner。TerminalView read-only surface:不传 input/resize/viewport callbacks,不 focus DOM,不 copy,不 IME,不改 width mode,不 reconnect。mirror-fixed 横向 crop/pan;这些手势不得冒泡成 tile activation、replacement、preview exit、input、resize、viewport 或 width-mode 写。baseline -> +selected -> baseline。handleSwitchSessionFromChrome(sessionId) 不更新 session-group viewport projection;这会让输入/live owner 与可见 shell owner 分裂。mirror-fixed,升级/重装/冷启动后变回 adaptive-phone。terminalWidthMode 属于 client settings/user preference resource,不属于 renderer、daemon、tmux。viewport 检测只用于首次安装没有偏好时的默认值。zterm:bridge-settings.terminalWidthMode。zterm:terminal-width-mode-preference。mirror-fixed 首屏读取、旧配置缺字段读取 preference、设置变更写双 key;真机升级验证必须在 WebView 可见且 isKeyguardShowing=false 时读 DOM/localStorage,不得用锁屏 invisible WebView 结果当闭环。terminal.quickbar owns bottom shortcut bar pan/crop. Renderer mirror-fixed pan 只管 terminal body;drawer/tab shell swipe 只管 session shell;daemon/tmux 不参与。terminal-quickbar-shell-rows,但触发区域只等于快捷栏展开后的真实高度。data-quickbar-scroll-track 是自身 native horizontal scroll owner;touch 从 scroll track 内开始时,父级 rows 不得 preventDefault(),不得同步改其它 track 的 scrollLeft。TerminalQuickBar.test.tsx 同时覆盖 rows 空白 horizontal pan、vertical no-pan、scroll-track native-owner no-steal、button no-steal、blank shell click still blocked;真机验证必须在 unlocked foreground WebView 上测下方快捷栏 track 原生滚动和空白区域 pan,不用锁屏 invisible WebView 代替。preventDefault();为了横滑破坏按钮点击或 shell click blocking。TerminalQuickBar 是 collapse/reveal 唯一手势 owner;TerminalPage 只持有 quickBarCollapsed projection。renderer、drawer、daemon、tmux 不参与。onCollapsedChange(true);短纵滑和 touch cancel 不改变状态。onCollapsedChange(false);普通 tap 仍可展开。TerminalQuickBar.test.tsx 正反覆盖 vertical collapse/upward reveal/horizontal no-collapse/short no-collapse;TerminalPage.foldable-display-change.test.tsx 覆盖 portrait collapse persistence;真机 CDP 必须完成 expanded -> collapsed -> revealed,并复测 track 横滑只动当前 track。quickBarCollapsed。TerminalQuickBar 生产真实 chrome measured height;TerminalPage 只消费该高度计算 stage bottom reserve。renderer/tmux/daemon 不参与。0。0,只做 Math.max(0, height) 归一;禁止用 height > 0 ? height : current 保留 stale positive height。terminal-stage-shell.style.bottom 和 rect,高度必须随 0 reserve 释放并恢复。Inspired by coding-principals skill.
pnpm config set registry https://registry.npmmirror.compnpm install --no-frozen-lockfilepnpm config set registry https://registry.npmjs.orgcopy mode 长按菜单退化原因是 TerminalView 中加了宿主级触摸拦截 (capture-phase touchstart/pointerdown preventDefault)。copy mode active 时阻止 WebView 处理触摸事件,但 Android 手势系统因此把长按事件吃掉, 表现是有震动反馈但 JS 菜单不弹出。
debug overlay 现在显示 MU(菜单位置)和 CE(结束行),长按后看 MU 是 null 还是有坐标,能直接判定定时器是否到达 copy runtime。
tmux-copy 入口是否仍是 press-owned;该入口不能只依赖 click,必须用 pointerDown 激活、touchEnd fallback,并对同轮 pointer/touch/click 去重。tmux-copy 应改成 armed / commit 两段式,press 只 armed,release 才 commit,click 只作 fallback。CM OFF 且点击 拷贝 不进入 copy mode,不能把入口放在 release commit;Android WebView 仍可能漏 pointerUp/touchEnd。tmux-copy 必须 press-start 立即触发,用显式 press sequence 消费后续 touch/pointer/click,禁止二次 toggle。stopPropagation();否则父级 TerminalTabSwipeSurface 收不到右滑起点,表现为 copy 状态下 session drawer 右滑也失效。TerminalView 里的 copy mode 仍旧不变,先查 TerminalStageShell 的 ReactMemo comparator 是否漏了 copySelection / onLongPressRow;不要继续只修 QuickBar。/health reachability on the new underlay, daemon listener, and client WebSocket/runtime state. Do not call it an endpoint problem if Tailscale IP stays reachable.WebSocket.OPEN as insufficient health truth. A socket can remain OPEN while bound to a dead Wi-Fi underlay. Health requires recent pong or any valid server frame.terminal.transport_lifecycle via src/contexts/session-context-socket-runtime.ts. Do not add UI page reconnect loops, daemon client-network state, fallback endpoints, or per-screen WebSocket rebuild logic./health still reachable, one replacement physical WebSocket, unchanged session/tmux target, monotonic buffer head, input/output recovery within 10s.