Files
terminalX/docs/HANDOFF.md
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00

69 KiB
Raw Permalink Blame History

terminalX 交接文档HANDOFF

目的:让没有上下文的新会话读完即可掌握项目全貌并继续工作。配合 ../CLAUDE.md(操作规范/命令)与项目记忆(~/.claude/projects/-Users-kid-...-terminalX/memory/,本项目会话会自动注入)一起看。 最后更新2026-07-24。


0. 一句话现状

一个原生 iPad SSH 终端已可用并真机验证libghostty 渲染 + 键盘输入 + SSH 直连 + SSH-over-tsnet(Tailscale 用户态) + tmux -CC 原生 tab + 多 pane 分屏 + mosh(LAN + over-tsnet 均已端到端验证) + 断线自动重连 + mosh 挂起→恢复<3s(M3)。四大核心需求libghostty/tailscale/tmux/mosh 均已验证)。仅在 iOS 模拟器验证(未上真机/未签名)。

2026-07-24 本轮:修复 3 个渲染/tmux 显示 bug + 完成 UI 产品化第一波(对标 Moshi。详见 §10。UI 已从 demo 态变为深色 Catppuccin 主题 + 主机管理页 + 终端 chrome + 统一主题切换。 mosh 状态2026-07-24 全部验证通过)mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 MoshCore.xcframeworkCommonCrypto 后端,含 arm64 模拟器 slicetsnet 桥加 UDP relay(StartMoshRelay)MoshSession(pipe↔FILE*/pthread/SIGWINCH)+MoshConnectScanner(TXCore)+SSHTerminalModel 编排SSH 引导 mosh-server→解析 MOSH CONNECT→接管

  • LAN 直连端到端验证192.168.9.199mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
  • mosh over tsnet 端到端验证vohive-vm 100.69.201.101app 日志 parsed MOSH CONNECT port=60002relay up localPort=63542activateMosh started;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。R1(tsnet UDP 数据报语义) 证伪
  • 复现 tsnet模拟器已持久化 tsnet 节点状态(~/Library/.../tsnet-main)-txTsnetKey 传任意非空串即可凭存量身份重连(节点未被 tailnet 清理时);节点被清理则需新 ephemeral key。

1. 项目愿景与硬约束

  • 目标iPad/iPhone 远程轻量运维 macOS/Linux + vibe coding。平台优先级 iPadOS > iOS > macOS(Apple Silicon)
  • iOS/iPadOS 只远程(沙盒禁 fork/exec不能跑本地 shellmacOS 才支持本地 PTY。
  • 分发目标 App StoreM5已暂缓
  • 四大核心:libghostty(终端引擎) · mosh · Tailscale tsnet(用户态,非 NetworkExtension) · tmux control-mode(-CC) hook

2. 关键架构决策(已定,勿推翻)

  1. 终端引擎用 libghostty,经社区 GhosttyKitLakr233/libghostty-spm,已 vendor 到 vendor/libghostty-spm)。TerminalViewStateObservableObject;喂字节入口 InMemoryTerminalSession.receive(Data)SwiftUI 视图 TerminalSurfaceView(context:)
  2. Tailscale 走 tsnet 用户态Gogomobile bind → TsnetBridge.xcframework不用 NetworkExtension(避开 VPN entitlement + 50MB 内存墙)。
  3. SSH 用 libssh2 + mbedTLSCSSHCore.xcframework),支持传入外部 fd。
  4. SSH-over-tsnet = fd 桥tsnet.Dial() 得 net.Conn无真实 fdsocketpair(AF_UNIX) + 双向 io.Copy 泵 → 把另一端 fd 交给 libssh2。见 vendor/tsnet-bridge/tsnetbridge/bridge.go: DialTCPFD
  5. 一进程只一个 Go runtime、只一次 gomobile bindC/C++ 库符号隐藏只导出 C API。
  6. 多账号(未实现):多 tsnet 节点并存;每 host 绑 egress profile(Direct/tailnet)禁止多 tailnet 并发盲探(重叠 100.x/同名会误连)。

3. 环境(决定构建方式,极重要)

  • 带认证的 HTTPS 代理HTTPS_PROXY=...@...:38128)。github.com release blob 能 curlgo.dev / ghcr.io 被代理 abortSwiftPM 不支持带认证代理 → 所有 SPM 依赖 vendor 本地化。Go 模块用 goproxy.cn
  • Go 1.26.5 在 ~/.local/go(不在默认 PATH经 aliyun 镜像 curl 装),gomobile/gobind 在 ~/go/bingo env -w GOPROXY=https://goproxy.cn GOSUMDB=off 已固化。
  • 工具链Xcode 26.6 / Swift 6.3 / xcodegen 2.46 / zig 0.15.2 / cmake 4.4 / ninja 1.13 / git。

4. 里程碑状态

里程碑 状态 真机实证
M0 SSH 直连终端 完成 libghostty 渲染(SGR/CJK)、键盘输入、连接;白屏 bug 修复(OutputGate);会话状态机+自动重连TXCore 单测总数见 §5=50
M1 tsnet + SSH-over-tsnet 完成 app 内 tsnet 加入 tailnet(IP 100.74.60.108);对端枚举;SSH-over-tsnet 登录 Ubuntu shell(服务端 Last login from 100.x 证明走 tailnet
M1.5 tmux -CC 原生 tab MVP 直连跑 tmux -CC attach → 渲染 fish/tmux 两原生 tab + 活动窗口内容
dial 重试打磨 TsnetManager.dialFD 6 次退避重试,解决 tsnet 首连路径预热超时
R5 Go+gvisor+C+++Swift 同进程 retire 全程真机无崩溃
M2 mosh 完成LAN + tsnet 均验证) libmoshios(C++)+protobuf-lite→MoshCore.xcframeworkUDP relay over tsnetLAN 直连 + mosh-over-tsnet 均端到端验证通过R1 证伪)
M3 生命周期深化 mosh 快恢复(真机深挂起待补) scenePhase FSM + mosh 挂起→恢复<3s 已验证模拟器15/30s 冻结→前台wake pulse→1.3s 收到服务器包,"已恢复");冷启动重连(SSP 序号+key 持久化)划入 M4/M5
M4 安全 完成(真机 SE 硬件待补) host key TOFU pin(首次信任+不符告警,指纹对齐 ssh-keygenMITM 关闭)+ publickey 认证P-256 sign_callback→SecKeyCreateSignature→DER→SSH mpint免密登录 192.168.9.199 验证通过SE 优先/未签名回退软件+文件SE 生成在模拟器 hw=true 可达,真机 SE 硬件签名+keychain 持久化待补)
M5 合规提审 ⏸️ 暂缓 用户指示暂缓

5. 代码地图与数据流

  • packages/TXCore/Sources/TXCore/
    • Tmux/TmuxControlParser.swift TmuxEvent.swift TmuxIDs.swift TmuxOutputDecoder.swift TmuxLayout.swift TmuxControlSequence.swiftDCS 检测)—— 纯解析。TXCore 全部单测共 5040 swift-testing + 10 XCTesttmux 解析/SessionMachine含mosh三相/MoshConnect/SSHWireswift test --package-path packages/TXCore 全绿。
    • Session/SessionMachine.swift —— reduce(event)->[Effect] 会话状态机(指数退避重连/后台冻结/前台恢复)。M3 加 mosh 三相 moshActive/moshParked/moshResuming + 事件 moshEstablished/moshHealthy/moshExited/resumeWatchdogFired + 效果 nudgeResume/scheduleResumeWatchdog/teardownMoshmosh 接管即拆 SSH、后台冻结不重建、前台打唤醒脉冲等 SSP 续、两次脉冲无效兜底全量重建。
  • packages/TXTransport/Sources/
    • TXTransport/Transport.swift(协议+SSHConfig+SSHErrorSSHSession.swiftlibssh2preconnectedFD 支持外部 fd事件循环 poll+非阻塞)。
    • TXTransport/MoshSession.swiftM2驱动 mosh_mainpipe↔FILE* 桥in/out、Foundation.Thread 跑阻塞主循环+pthread_self()pthread_kill(SIGWINCH) resize、忽略 SIGPIPE、setenv UTF-8 locale。
    • CSSH/C targetlibssh2 头 + modulemapimport CSSH)、CSSHCorebinaryTargetlibrary-only
    • CMosh/C targetmoshiosbridge.h(mosh_main)+modulemap+占位 shim.cimport CMosh)、MoshCorebinaryTargetlibrary-onlyTXTransport 链 libc++/libzmosh 是 C++ 且 compressor 用 zlibCommonCrypto 属 libSystem 自动解析)。
    • TXCore/Mosh/MoshConnect.swiftMoshConnectScanner 流式扫描 MOSH CONNECT <port> <key>(注意 Swift 视 \r\n 为单 Character行尾判定须用 isNewline)。
    • TXCore/SSH/M4SSHWire(string/mpint 编码)/ECDSAConv(DER→SSH 签名·P256 blob)/HostKey(opensshFingerprint·evaluate·HostTriple·KnownHostsStore 协议)SSHWireTests 10 单测(真实指纹向量+高位 DER
  • M4 安全TXTransport SSHConfig.hostKeyVerifier(闭包)+SSHError.hostKeyMismatch+SSHSigner 协议+Authentication.publicKeyCallbackSSHSession 握手后 libssh2_session_hostkey 校验、sshSignCallback(@convention(c)+malloc 出参)接 libssh2_userauth_publickey。appKeychainKnownHostsStore(文件后端 TOFU pin)、SigningKeyProvider(SE 优先/软件回退+文件持久化,SecKeyCreateSignature)、mismatch alert(ContentView)。
  • M3 快恢复链bridge.goNode.WakeUp()(tsnet InjectEvent+MagicSock.Rebind/ReSTUN) 与 MoshRelay.Rebind()(同端口重开 loopback、泵按 socket 生成容错)iosclient.ccg_mosh_last_heard(收到服务器包即更新,导出 mosh_last_heard_ms()) 并恢复 SIGCONT 全屏重绘;MoshSession.nudge()(SIGCONT) + lastHeardMs()SSHTerminalModel 前台执行"唤醒脉冲三连"(WakeUp+Rebind+nudge)+healthy 轮询(探针增长→moshHealthy)+恢复看门狗+beginBackgroundTask
  • apps/TerminalX/iOS/
    • SSHTerminalModel.swift —— 核心编排:OutputGate(surface-ready 缓冲)、TransportHolderTsnetManager(tsnet 节点 up/dial+重试)、TsnetErrorTmuxRouter(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、autoConnectIfConfigured(启动参数)。
    • ContentView.swift —— 分支:tmuxController != nilTmuxTabbedView(tab 条+活动窗口);否则 RawTerminalViewTsnetProbeView(纯自检)ConnectionForm
    • TmuxController.swift —— tmux 网关:消费解析事件;pane-per-surfaceTmuxPaneSurface=session+state+gate/paneTmuxWindow 持 panes 字典 + visibleLayout(渲染)/fullLayout(diff)surfaceByPane O(1) 路由 %outputapplyLayout reconcile(增删留)attach: refresh-client -C WxHlist-windows(带 layout)→每 pane capture-paneawaitingCapture 丢弃快照前 %output输入绑 pane→send-keys -t %selftap→select-pane。字节经 SSHTerminalModelTmuxByteChannel(AsyncStream 单消费者)保序入网关。
    • TsnetProbe.swift —— M1 自检视图(加入 tailnet + 对端列表)。
    • TerminalXApp.swift —— @main。
  • 数据流(直连)transport.onBytes → TmuxRouter.feed → (raw) OutputGate.deliversession.receive→libghostty(检测到 DCS) → TmuxController.feed→解析→各 window。输入libghostty→session write 闭包→transport.sendtmux 模式→send-keys
  • 数据流(tsnet)TsnetManager.ensureUp(auth key)→dialFD(socketpair fd)→SSHSession(preconnectedFD:)

6. 已知问题 / TODO继续工作的入口

  • M4 安全(已完成,剩真机 SEhost key TOFU pin(SSHConfig.hostKeyVerifier→app KeychainKnownHostsStore 文件后端) + publickey 认证(Authentication.publicKeyCallback(SSHSigner)SSHSession C sshSignCallbackSigningKeyProvider)。:真机验证 SE 硬件密钥生成+签名+keychain 持久化(未签名/模拟器回退软件+文件);libssh2_session_method_pref 锁定 pinned hostkey 类型防算法漂移误报fable 建议未做M4DBG NSLog 埋点可清理。
  • tmux 多 pane 分屏 + 动态 resize 已做pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach refresh-client -C 设尺寸 + iPadOS 拖拽窗口/旋转 → onChange(geo.size)containerResized(点×displayScale÷cell像素)→debounce→refresh-client -C→%layout-change→更新 frame,验证:容器 1032x1280@2x cell16x35→129x73tmux 精确采纳)。剩余:完整 scrollback仍靠 capture-pane 抓当前屏pause 流控(%pause/%continue,防单 pane 刷屏split/kill pane 手势tmux<3.1 降级路径未做。
  • tsnet 首连:已加 dial 重试;更优是等 peer online 再 dial读 PeersJSON online 状态)。
  • 签名/真机CODE_SIGNING_ALLOWED=NO,仅模拟器;上真机/TestFlight 需配置签名 + entitlementstsnet 用户态无需 NE entitlement利好审核
  • gitmain 分支已有 6 次提交(初始 + M2/M3/tmux多pane/tmux resize/M4a/M4b工作区干净未 push 远端(无 remote。commit/push 只在用户明确要求时。artifacts/*.xcframeworkCSSHCore/TsnetBridge/MoshCore+ vendor/mosh+vendor/protobuf 源码均有意提交(克隆即可构建,无需网络);构建中间产物(vendor/build/mosh-outvendor/protobuf/build-*)已 gitignore。
  • 多平台iOS target 已跑macOS target本地 PTY, TXPTY)未建。

7. 下一步建议M0~M4 + tmux 多pane/resize 均已完成并验证;以下按价值)

  1. 真机化(最大缺口,多项验证只在模拟器):配置签名 + entitlementstsnet 用户态无需 NE利好审核→ 上真机/TestFlight。真机上补验三件模拟器测不了的SE 硬件密钥生成+签名+keychain 持久化M4b 现回退软件+文件;SigningKeyProvider 已备 SE 路径);②mosh 深挂起恢复(锁屏数分钟过 WG 180s 过期线 / socket defunctMoshRelay.Rebind+hop_port);③签名后 Keychain 可用(当前未签名 SecItem 全 -34018host key pin 才回退到文件)。
  2. 产品化:主机列表持久化(签名后用 Keychain 存凭据/host 配置)、多账号多 tsnet 节点(架构决策 §2.6:每 host 绑 egress禁多 tailnet 盲探、软键盘运维工具栏GhosttyKit 内建,接线即可)、多标签(非 tmux
  3. 打磨(可选,不阻塞)
    • 清理诊断埋点:SSHTerminalModel/TmuxController/iosclient.ccNSLog("MOSHDBG/TXM3/TMUXDBG/M4DBG …") + moshiosbridge.cc 的 fwrite("Hello from the Bridge!")
    • moshactivateMosh 写死 80x24 初值(靠首次 resize 纠正mosh 接管后 idle SSH 未关;MoshSession.close() 后 mosh 线程可能滞留;远端累积 detached mosh-server每次 mosh-server new)需清理策略。
    • tmuxpause 流控(%pause/%continue 防单 pane 刷屏)split/kill pane 手势;外接键盘 Cmd+Opt+方向 导航 pane完整 scrollback现靠 capture-pane 抓当前屏tmux<3.1 降级路径。
    • M4libssh2_session_method_pref 锁定 pinned hostkey 类型防算法漂移误报host key pin 的 UI 管理(查看/删除已信任)。
    • tsnet 首连:更优是等 peer online 再 dial读 PeersJSON online 状态)。
  4. 冷启动 mosh 重连(大活)mosh 进程被杀后接原 detached 会话——受 SSP 序号防重放限制,需持久化 MOSH_KEY+port 且改 mosh 序列化客户端状态Blink 式)。价值中等,成本高。
  5. macOS target:本地 PTYTXPTY)未建。
  6. M5 合规提审:用户指示暂缓。

8. 测试资源(用户提供,凭据勿写入仓库/勿硬编码,每次向用户索取)

  • 一台局域网 macOS(有 tmux + mosh-server,如 MacBook-Air-M1fish 为默认 shellmosh-server/opt/homebrew/bin 且交互 shell PATH 可见)、一台 tailnet 内 online 的 Ubuntu VM(vohive-vm)、一个 tailnet auth keyephemeral可能过期用户会给新的。用于直连 SSH / SSH-over-tsnet / tmux / M2 mosh 端到端验证。凭据每次向用户即时索取。
  • tailnet 是 tail3b5057.ts.net已见节点vohive-vm(linux)、iphone-air、ipad-pro、macbook-air-m1、macbook-air。

9. 工作纪律(务必遵守)

  • 绝不臆想工具结果:调用后等真实返回再推理,绝不编造 BUILD/日志/截图。曾因连续编造工具输出(含假 BUILD SUCCEEDED、错误归因 GhosttyKit而误诊——真相全靠老实 Read 完整 log。此规则已写入全局 ~/.claude/CLAUDE.md
  • 每步用真实证据验证:swift test 看真实计数、xcodebuild 看真实 BUILD 结果、模拟器 screenshot + Read 看真实画面。
  • 与用户用简体中文commit/push 只在明确要求时。

10. UI 产品化 + 渲染修复2026-07-24 本轮)

10.1 修复的 3 个渲染/tmux 显示 buge2e 复现+修复+回归,均模拟器 idb 点击+截图取证)

  1. 终端底部残影旧帧钉屏底ghostty 在 iOS 用 addSublayer: 挂 IOSurfaceLayerMetal.deinit 从不 removeFromSuperlayer(上游 iOS-only 缺陷SwiftUI attach 抖动导致同一 view 多次建 surface 时旧层泄漏、冻结小尺寸首帧,被 CA topLeft gravity 钉在屏底。修vendor UITerminalView.platformSetup 建 surface 前清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子(tearDownSurface 后回调)在 commonInit 里摘子层。(诊断经 Fable 顾问在 ghostty 源码层锚定。)
  2. tmux 切 tab 切回旧 tab 内容消失TmuxTabbedView 原只渲 activeWindow 且 .id(win.id),切走销毁 pane 视图→释放 surface→内容丢失。修改成所有窗口常驻挂载、仅激活可见ZStack+opacity+allowsHitTestingsurface 不释放、内容保留、后台窗口持续收 %output。
  3. tmux pane 内容钉底/顶部残行pane 的 ghostty grid 由容器像素几何决定tab 条吃高度→73 行),而 attach 用 raw 全屏 75 行 capture75 行快照喂 73 行 grid 溢出上滚。修:(A) 容器几何测量上移到 TmuxTabbedView 内容槽(破鸡生蛋死锁);(B) TmuxController attach kickoff 改 attachAcked ∧ containerKnown 双条件(kickoffIfReady(C) capture-pane 铺快照前剔尾部空行。

10.2 UI 产品化(对标 Moshi getmoshi.app深蓝黑底 + 终端绿 + 圆角卡片)

  • 设计系统(颜色封装,为主题系统)apps/TerminalX/iOS/Theme.swiftTXPalette(所有色角色 struct)+TXThemeManager:ObservableObject(环境注入);结构量走 enum TX(圆角/间距/等宽字体)。
  • 统一主题:从 GhosttyTheme 抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),每套同时定义 app 配色+终端配色(ghosttyNameterminalTheme)。设置里选主题 → app 与终端一起变色 + 持久化(UserDefaults(txThemeStorageKey))ContentView .onChange(palette.id)→model.applyTerminalTheme();新终端/pane 经 TerminalTheme.txDefault 读持久化。
  • 主屏/主机管理页(ContentView.HomeView):主机卡(HostCard)+FAB 加主机(AddHostSheet)+设置(SettingsStubView 含主题选择器)HostStore(Application Support/hosts.json 持久化,密码暂存本地/TODO Keychain);点卡片 SSHTerminalModel.connect(to:)
  • 终端外壳 chrome(ContentView.TerminalScreen+TerminalTopBar):顶栏(圆形返回钮+会话标题model.connectionTitle+传输徽章model.moshEngaged?mosh青:SSH绿+连接态点+状态提示)TmuxTabChip 绿 pill 化;底部 GhosttyKit 工具栏深色自适应;终端默认 Catppuccin Mocha。
  • 验证iPad mini(A17 Pro) live 验证全流程(加主机→连接→终端→切 tab→分屏点焦点→主题切换 app+终端同步)。

10.3 待续UI

  • 会话缩略卡区Moshi 首屏实时终端预览横滑,需会话管理);设置页字体/工具栏配置;导航壳打磨;终端工具栏 accent tintinputAccessoryStyle SwiftUI 未暴露需桥接)。
  • 无头 UI 测试:idb ui tap x yPython3.14 需事件循环 shim /tmp/idbrun.py;坐标=屏幕像素×0.5,注意设备朝向)。诊断埋点(GKDBG/txDbg*)已清理MOSHDBG/TMUXDBG/M4DBG 仍在(旧 cleanup-todo)。

11. Hosts / Keychain / Tailscale 三分离管理2026-07-24 本轮,对标 Termius

把连接拆成 Hosts / Keychain(凭据) / Tailscale 连接 三者分别管理iPad 优先。设计阶段咨询 Fable 顾问Q1Q5 裁决)。BUILD SUCCEEDED + 模拟器 idb 截图全流程验证 + TXCore 40 tests 绿。

11.1 数据模型app 层新文件)

  • Credential.swiftCredential(凭据 meta无机密) + CredentialSecret(自描述机密)。Kind: password / privateKey(source: pasted/imported/generated, publicKeyBlob) / secureEnclave(占位)。
  • CredentialStore.swift@MainActor protocol CredentialStore(list/secret(for:)/upsert/deletemeta 与机密分离) + FileCredentialStore(沙盒 credentials.json,版本化信封 {"scheme":"plaintext-v1"}0600+isExcludedFromBackup+completeFileProtection)。不自造加密:无根信任下伪安全;签名后换 Keychain 后端即可(协议已抽象)。
  • TailscaleStore.swiftTailscaleConnection(每个=一个常驻 tsnet 节点;stateDirName="tsnet-<uuid>" 创建即固化终身不变——tsnet 无文件锁,同 Dir 双 Up 损坏状态) + TailscaleStore(tailnets.json无机密authKey 注册后即弃)。
  • HostStore.swiftSavedHost 改为 auth: AuthRef(.inlinePassword / .credential(id)) + tailscaleID: UUID?;自定义 decoder 迁移旧 inline username/password 为 .inlinePassword(不造幽灵凭据)。

11.2 签名映射 CredentialAuth.swift(复用既有三认证路径,传输层零改动)

  • password → .password;生成软件 P-256 → SigningKeyProvider.from(x963:).publicKeyCallback(路径A);粘贴/导入 PEM → .privateKey(libssh2 publickey_frommemory 路径B)。
  • 关键:SSHSession.authenticate() 三分支(.password/.privateKey/.publicKeyCallback)早已全部实现,本次无需动 TXTransport。
  • SigningKeyProviderfrom(x963:)/generateSoftwareP256()/secureEnclaveAvailable()(非持久探测,签名真机自动点亮 Face ID UI)。openssh-key-v1 mbedTLS 不认 → UI 警告提示 ssh-keygen -p -m PEM 转换。

11.3 多 tsnet 节点 TsnetRegistry.swift(替换旧单例 TsnetManager

  • 单例 @unchecked Sendable+锁,按 connID 懒启动/Up 后常驻/LRU 限 3/in-flight 去重。Fable 经 tsnet v1.102.0 源码确认一进程多 tsnet.Server 官方支持dial 走具体 node 句柄无全局路由→重叠 100.x 不误连;红线仅 stateDir 唯一)。
  • 坑:TsnetbridgeNode/MoshRelay 非 Sendable,不能在 Task.detached 捕获或跨 actor 传 → mosh relay/wakeUp 收进 registry 按 connID 操作model 只留 activeConnID(不再持 node

11.4 连接编排(SSHTerminalModel

  • connect(to:) 解析 AuthRef+tailscaleID → EgressPlan(.direct/.tsnet(connID,stateDir,hostname,authKey))startConnect 统一构造 confighost key pin 命名空间=egressPlan.egressKey(direct/tsnet-<uuid>)。删实例级 tsnetAuthKey/TsnetManager 单例。
  • 无头验证:-txTsnetKey 合成临时连接(固定 connID + stateDir tsnet-main 复用模拟器身份)→ 启动参数矩阵不回归(已验证直连 SSH 到 127.0.0.1:22 走通 host key 校验+密码认证)。
  • model.bind(credentials:tailscale:) 由 ContentView 注入两 Store。

11.5 UIContentView + 4 个 View 文件 + UIComponents.swift

  • RootSplitView=NavigationSplitView 侧栏(§连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情iPhone 紧凑自动坍缩。终端改 .fullScreenCover($model.showsTerminal) 全屏盖(退出回列表,状态保留)。
  • HostsView.swift(现只剩 HostEditorView认证 segmented[直接输入/从 Keychain 选]+出口 Picker[直连/各 Tailscale可就地新建];旧 HostsListView/HostCard/SessionStatusRow 已删——无人引用且"点卡片立即进终端+复用会话"与定稿流程相悖)、KeychainView.swift(列表+CredentialEditorView密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、TailscaleView.swift(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP编辑器带 onSave 供主机页就地新建后自动选中)、SettingsView.swift(主题+关于 + KnownHostsView pin 查看/删除)。
  • 直接输入自动对象化2026-07-26 用户定稿):主机编辑里「直接输入」的用户名/密码在保存时自动落成 Keychain 凭据(同用户名同密码去重复用),主机只存 .credential(id) 引用;.inlinePassword 仅保留给启动参数/fixture 合成主机与旧数据迁移。Tailscale 同理:主机页可「新建 Tailscale 连接」sheet 打开编辑器,保存即自动选中)。三对象(主机/凭据/Tailscale 连接)各自增删改查、互相引用。

11.6 未验证(需用户实时资源)

真 tsnet 多节点 Up需 authkey、密钥/凭据成功登录(需服务器信任 key、mosh over 新 egress plan。这些在有凭据时按 §8 流程补测。


12. Demo → 商业化 UI/UE 改造2026-07-25 起,按 Claude Design 定稿)

设计定稿hifi含精确 tokendocs/design/UI-DESIGN-HANDOFF.md + terminalX-iPadOS.dc.html13 屏画板)。 渲染对拍:python3 docs/design/render-screens.py 拆屏 → headless Chrome 逐屏截 PNG。规划级裁决咨询 Fable 顾问。

总判断:差距不在样式而在结构。三处必须先拆的地基——① chrome 从 Catppuccin 派生改为 Nocturne 中性阶 + 恒定 blurpleNavigationSplitView+fullScreenCover 改路由 + 会话常驻画布;③ 单会话模型改多会话并存。

12.1 路线图6 阶段UI-1/UI-2 已完成)

阶段 内容 状态
UI-1 设计令牌地基Nocturne chrome + blurple + JetBrains Mono 完成
UI-2 导航与多会话状态模型AppRouter/SessionManager/SessionCanvas 完成
UI-3 终端主界面56pt 轨道 + 浮起双胶囊 + 侧边栏三态 完成pane 缝拖拽待做)
UI-4 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 完成(分屏功能后被用户裁决整体移除,见 12.3k
UI-5 设置·外观 + 键盘体系GCKeyboard 检测 + 全套 ⌘ 快捷键) 待做
UI-6 竖屏参数化 + iPhone 首版 待做

12.2 UI-1 设计令牌(Theme.swift 重写)

  • 三层分工:TXAccentNocturne blurple 8 档,恒定不随主题变,只做线/点/选中底/文字)· TXChromechrome 中性阶 14 档,4 张预生成表)· TXFlavorCatppuccin flavor驱动终端 + 状态语义色)。TXPalette 是门面,旧取色名(p.accent/p.textPrimary…)保留为 computed逐屏重写时再换直通名。
  • chrome 表推导:Mocha 用设计交付的精确锚点值Macchiato/Frappé 按 flavor base 相对 Mocha 的 HSL 差平移文字阶抬升幅度收一半保对比度Latte 是唯一浅色,整体反转手工写(仍不用纯黑纯白)。切 flavor → chrome 变阶、accent 不变。
  • JetBrains Mono 四权重 vendor 进 apps/TerminalX/Resources/Fonts/(附 OFL.txtOFL-1.1 要求随分发)。必须平铺 bundle 根UIAppFonts 值不带目录)→ project.yml 用 group 而非 folder reference。TX.Font.mono 按 weight 映射 PostScript 名,缺失时 SwiftUI 自动回退。

12.3 UI-2 导航与多会话(新 AppRouter.swift/SessionManager.swiftSSHTerminalModelTerminalSession

  • AppRoute = .home | .terminal(UUID)SidebarState = .collapsed|.overlay|.pinnedoverlay 不占位、不 resizepinned 钉住各 resize 一次)。
  • TerminalSession 加身份:id/host/isLive(原 showsTerminal不再驱动导航)/isMinimized/lastActiveAt/link(LinkState 五态,由 phase+egress 推导)/displayName/initials/isTmuxSessionTXCore、传输层零改动50 tests 保持绿)。
  • SessionManager:多实例持有、open(host:) 总是新建会话2026-07-26 用户定稿:会话与主机不是一对一,再点同一主机 = 再开一条并行会话;早期"复用同主机会话"的语义已废除)、activate(_:)(选中既有会话:聚焦 + 已断开的自动重连并重新判定 tmux/原生)、focus/minimize/close、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。会话列表持久显示(无论连接状态),只有用户显式关闭才移除。
  • ContentViewRootView(ZStack{SessionCanvas, HomeShell})SessionCanvas 常驻挂载所有会话(详见 CLAUDE.md 坑:摘除即丢内容 + matchedGeometry 要求同层级。host key alert 下移到 TerminalScreen(每会话一份,后台会话也能弹安全告警)。
  • 无头验证脚手架DEBUGUIPreviewFixture 假会话 + terminalx://ui/* 导航驱动 + scripts/tx-shot.sh 截图定向。:假会话必须 isSyntheticFixture=true 从生命周期广播里排除,否则 scenePhase→active 广播经 syncUI() 把注入状态覆盖回 idle。
  • 验收四步截图取证全通过A 终端会话1 → B ui/minimize 回首页3 会话并存 + mac-studio 标「最小化中」置顶)→ C ui/session/2 切会话2独立内容 + tsnet 徽标)→ D ui/session/1 回会话1 内容原样

12.3b UI-3/UI-4 两屏还原(新文件 TXParts / RailSidebar / TerminalChrome / HomeView

  • TXParts.swift — 设计稿反复出现的小件:TXStatusDot(8pt + 2pt 挖底描边) · TXInitialsAvatar(26/38pt 两字缩写 + 右上状态点) · TXSectionLabel(600 10px ls .09em uppercase) · TXBadge · TXChip · TXSearchField(32pt) · TXButton · TXIconButton(视觉 34、命中 44) · TXTerminalMirror(终端文本镜像,底对齐)。
  • RailSidebar.swiftRailView(56pt/竖屏 48pt 轨道logo 不可点 → 搜索 → 首页 → 主机头像+状态点 → 虚线+ → TMUX 段 window 数字 → 状态点 → »展开 → 齿轮) + SidebarPanel(288pt遮罩/Pin 共用同一套内容) + SidebarSessionRow/SidebarWindowRow开合语义唯一logo 不可点折叠态»展开在轨道底、展开态«收起在标题右Pin 是独立按钮只在展开态出现。
  • TerminalChrome.swift — 浮起胶囊(.ultraThinMaterial + 半透底 + 描边 + 圆角 11左上标题胶囊红黄点/竖分隔/状态点/主机名/window title+ 右上三格状态胶囊(tsnet | mosh | tmux只染出问题的那层+ 底部 34pt 提示条(文案随侧边栏三态切换)。
  • HomeView.swift — 页头34px 标题 + 副行 + 220pt 搜索 + 新建连接 + 齿轮)→ 活动会话 3 列 SessionCard(卡头状态/主机名/tmux/链路 · 文本镜像 · 卡脚 windows·panes·上次活跃 + ⌘n + 回到会话)→ 全部主机 4 列 HostTile + 筛选 chips + 添加卡。重连/离线卡:镜像 blur(1.5) + rgba(24,26,40,.72) 遮罩 + 转圈 + 「屏幕内容已缓存,恢复后原样接回」。
  • 舞台pane 区顶部留 TX.Layout.capsuleInset(86pt) 预留带 + clipped() 防溢出pane 之间 2pt 缝(各让 1ptTmuxStage 去掉 tab 条window 切换移到轨道/侧边栏),保留「全 window 常驻挂载 + opacity」与容器几何单一来源。
  • 缩略卡的「真实回滚」TerminalSession.captureSnapshot()readViewportText() 抓尾部 32 行(剔尾部空行),首页可见时每 1.5s 轮询 + 最小化前抓一次。没有第二个 surface
  • fixture 扩展:UIPreviewFixture.hosts(7 台 + 分组/os)、tailnets、每条会话的 TmuxSummary 注入;HostStore.injectFixtureHosts/TailscaleStore.injectFixtureConnections 只进内存不写盘
  • 验收:四态截图对拍设计稿 —— 折叠轨道(08) · 遮罩态(00) · Pin 态(01终端真的变窄) · 首页(09)。TXCore 50 tests 保持绿。

12.3c 像素级走查修正(用户走查 + 逐屏对拍2026-07-25 二轮)

走查方法(后续每屏都照这个来scripts/tx-shot.sh 截图 → python3 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同一区域上下/左右并排 → 再对元素列做垂直投影扫描(逐行找与底色不同的段),把元素 y 坐标量化对比。目测靠不住(缩略图里 #1E1E2E#11121C 分不出)。

修正项:

  1. 沉浸不彻底(最严重):原来把 86pt 预留带做成"pane 容器整体下移",胶囊浮在舞台底色上。改为 pane 容器从舞台顶部就开始(终端底色 + 边框铺满),只把 surface 内容 padding(.top, 86) —— 预留带铺的是 flavor.terminalBg,故与终端内容视觉连续,胶囊真正"悬浮在终端之上"。像素采样验证:预留带与内容区同为 #1E1E2E
  2. 终端页隐藏系统状态栏(设计稿 1b/3a 顶部就是胶囊/logo1e/1g 才有状态栏)+ ignoresSafeArea(edges: .top)statusBarHidden 不能挂在 TerminalScreen 上——它常驻挂载SessionCanvas会连首页一起隐藏要挂在 RootView 上按 router.route 判断。
  3. 侧边栏动画:三态切换统一走 AppRouter.sidebarAnimation.easeOut(0.24),设计要求 200260ms面板 .move(edge: .leading) 滑入 + 遮罩 .opacity 淡入。
  4. 底部两区同高:侧边栏脚与舞台提示条共用 TX.Layout.footHeight(34pt),故两侧分割线落在同一 y侧边栏脚原来的深色块 + 齿轮去掉,改 1pt divider(设计稿如此)。
  5. 轨道结构:搜索/首页/设置是裸图标(无底无框),只有 logo 与展开键有框(TXIconButton(bare:)纵向节奏按扫描到的设计坐标写死logo 28 · 头像 174/220/266 · TMUX 标签 388 · 数字 408 起TMUX 段标签用 accent-500 而非中性灰。
  6. 侧边栏面板logo 30pt、会话行 48pt、window 行 44pt实测值分组分隔线改渐隐 LinearGradient占位分支补回漏掉的「新建 window ⌘T」行。对齐后与设计稿偏差 ≤9ptwindow 区完全重合)。
  7. 缩略卡:卡头/卡脚分隔线用 1pt RectangleDivider().overlay() 压不住默认色);行距 fontSize×0.6(设计 11px/1.5启发式着色 —— readViewportText() 只给纯文本,故按行首符号/关键字上色(绿 error◇▸[..]灰 提示符行亮),逼近真实终端密度。

12.3d UI-4 交互闭环(关闭确认 / 分屏菜单 / pane 拖拽)

  • TmuxController 新增命令(纯追加,未动既有链路):detach()(detach-client) · killSession() · splitWindow(vertical:joinSession:)split-window / 跨会话用 join-pane -s不嵌套 tmux)· splitIntoNewSession()new-session -d + join-pane)· resizePane(cols:rows:) · listSessions()(新 PendingKind.listSessions,结果进 @Published sessions: [TmuxSessionInfo])。
  • AppModal 提到 AppRouter(不是视图 @State⌘W/⌘D 快捷键与无头 URL 驱动都要能打开浮层,视图内部状态触达不到。
  • 关闭确认 CloseConfirmDialog3c436/404pttmux → 推荐「仅断开 ⏎」(detach-client,服务端继续跑) + 次要红字「结束会话」(kill-session再点一次才执行);原生窗口 → 琥珀警告 + 危险按钮(设计精确色 #8d5560 / rgba(92,58,63,.35) / #f5a0ac)。closeSession(kill:) 先发 tmux 命令、延迟 250ms 再 teardown 传输层(让 %exit 走既有 onExit)。
  • 分屏菜单 SplitMenuView3b360pt不问方向⌘D/⌘⇧D 本就是两个入口),只列「分到哪里」——当前会话(选中底 + 「当前」)· detached 会话(绿点,因为进程仍在服务端跑 + 上次活跃)· 新建 tmux 会话。前导列统一 20pt 使各行文字左缘一致。原生窗口项灰显标「二期」(需要一台主机多条 SSH channel见 §12.4),不装作能用。
  • pane 拖拽 PaneSplitter:从布局树内部节点算出相邻子节点边界 → 热区 12pt + 把手 44×3pt。拖动中只动视觉(把手位移 + 转强调色),不改 pane frame、不发命令松手才发一次 resize-pane,等 %layout-change 回来更新真实 frame与"路线 b"一致:客户端不自行重排)。
  • 最小化过渡:离开终端时 opacity + scaleEffect(0.97)240ms easeOut。用 scaleEffect 而非 frame 动画 —— 前者只是渲染变换不触发 PTY resize,后者会让 tmux 反复重排。完整 matchedGeometry 缩回卡片未做fable 裁决:不对 TerminalSurfaceView 本体做 matchedGeometry
  • 验收三个浮层逐个对拍设计稿3c 两套文案 + 3b 菜单);最小化往返内容原样、日志无 crashTXCore 50 tests 绿。pane 拖拽只做了静态编译与逻辑走查,没有真实 tmux 多 pane 会话验证过fixture 没有 controller——有测试主机时必须补。

12.3e 接真实数据 + 空状态UI-5 之前的必要一步)

之前几轮都靠 -txUIFixture 假会话对拍设计稿,真实路径下有数据缺口和裸露的空白屏。本轮补齐:

真实数据打通

  • tmux 会话名kickoff 时顺带 listSessions()currentSessionName = list-sessions 里 attached 的那个 → 状态胶囊/侧边栏标题显示真实 tmux <会话名>(拿不到就只显示 tmux,不编造)。
  • cwdlist-windows format 追加 #{pane_current_path}TmuxWindow.cwd标题胶囊显示「window title · ~/src/x」侧边栏 window 行副行显示「cwd · n panes」。abbreviate()/home/u/x/Users/u/x 收成 ~/x
  • 主机的分组 / 系统HostEditorView 新增「归类」区(分组输入 + 已有分组一键选 chips + 系统描述)。此前 SavedHost.group/os 只有 fixture 在写,真实主机永远没有分组 → 首页 chips 恒只有「全部」。
  • IP 主机名的缩写:10.255.255.1 按字母规则会得到无意义的 11 → 纯 IP 取首段前两位(10)。

空状态(真实路径必经)

  • 首页首次运行0 主机 0 会话):终端图标 + 「还没有主机」+ 能力说明 + 「新建连接 / 先配置 Tailscale」。
  • 有主机无会话:虚线提示条「还没有在跑的会话 —— 点下面任意主机即可连接」。
  • 搜索/筛选无命中:说明是分组还是搜索造成的 + 「清除筛选」。
  • 缩略卡无输出:「等待输出…」占位(真实会话刚连上时 readViewportText() 还是空的)。
  • 侧边栏无会话:「没有在跑的会话」。
  • 终端区 TerminalStateOverlay:连接中(转圈 + 分阶段文案 + 已等待 Ns + ≥5s 后出「取消连接」)/ 连接失败(错误详情 + 返回首页 + 重试)/ 已关闭(重新连接)。已连接与重连中不渲染此层,不遮挡终端内容。
  • 路由兜底:route 指向的会话已被关闭 → 自动回首页,不停在空画布上黑屏。

传输层修一处真实可用性问题(TXTransport

  • SSHSession.connect() 原来用裸 Darwin.connect阻塞到系统 TCP 超时75s+,连不上的主机会让用户对着「连接中」干等。改为 connectWithTimeout():置非阻塞 → connect → poll(POLLOUT) 等超时 → SO_ERROR(可写≠成功,拒绝连接也会变可写)→ 恢复阻塞libssh2 用阻塞模式)。超时值走新增的 SSHConfig.connectTimeoutSec(默认 12s。tsnet 路径(preconnectedFD)不经此处,不受影响。
  • 验证:不可达地址 12s 后报「失败socket("连接失败12s 超时)…")」,失败态覆盖层给出重试/返回。

12.3f tmux 自动进入(真机反馈修复:连上永远显示「无 tmux」

根因:全代码库没有任何地方发起 tmux -CC attachTmuxRouter 只被动检测传输字节流里的 DCS 前导,之前所有 tmux 端到端验证都靠 -txAutoCommand "tmux -CC …" 手动触发 → 真实点击连接只有 raw shell状态胶囊第三格恒为「无 tmux · 客户端窗口」。不是检测坏了,是从来没人发起。

修复

  • SavedHostuseTmux: Bool = true(旧数据迁移也默认 true+ tmuxSession: String?(默认 main主机编辑器新增「会话」区tmux 开关 + 会话名 + mosh 开关,带说明)。
  • TerminalSession.scheduleTmuxBootstrap()连接建立后mosh 模式则等 mosh 接管后)延迟 2.5s 等 shell 提示符 → 发 tmux -CC new -A -s <名>-A = 同名会话存在就接回(断线原样恢复的关键)。
  • 探测降级4s 内没进 control mode → tmuxUnavailable = true → 终端顶部行内横幅(设计 2c说明「关掉 App 后服务端不会继续跑」+「安装并启用 tmux」+ 可关闭)。装完自动重试一次。
  • exitTmux() 重置引导标记 → 重连后重新 attach。
  • 无头参数:-txTmux 0|1-txTmuxSession <名>

真机端到端验证通过192.168.99.83macOS + fish + tmux 3.7b)。过程中查出 3 个真实 bug

  1. tab 分隔符被 PTY 吃掉list-windows -F "…\t…" 的 tab 经 PTY 到 tmux 后变成下划线,整行 components(separatedBy:) 分不开(count=1)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 TmuxController.fieldSep = "|:|"排查坑os_log 把控制字符显示成 _,光看日志无法区分「真下划线」与「被显示替换的 tab」我因此先误判为 os_log 显示问题——最后靠打印 parts.count 才确诊。
  2. UI 不刷新(正是 CLAUDE.md 里「嵌套 ObservableObject 不冒泡」那条坑的另一面):tmuxSummary/window 列表是读 controller 的 computed property而轨道/胶囊/侧边栏只订阅 session → 数据解析对了 UI 也不动。修法:TerminalSession 用 Combine 订阅 controller.objectWillChange 并转发自己的 objectWillChange(一处解决所有读 controller 的视图)。
  3. 会话名猜错currentSessionName 原来取 list-sessionssession_attached==1 的那个,结果把用户自己终端 attach 的 demo 当成了当前会话。改用 display-message -p "#{session_name}"(权威:本客户端所在会话)。分屏菜单的「当前」判定也随之改为按会话名比较。
  4. 顺带修正:轨道/侧边栏的 window 编号用 #{window_index}(用户可见的 0/1/2…而非内部 window_id@2/@3…

验证结果(服务器侧交叉验证):

  • tmux 自动进入 ✓ 状态胶囊 direct | ssh | tmux main、标题胶囊 fish · ~ 163×50、轨道 window 0/1/2/3/4(与服务器 idx=0..4 active=@3/idx=1 完全吻合)
  • ⌘D 分屏 ✓split-window -h 后服务器 list-panes%7 164x50 变为 %7 82x50 + %8 81x50app 端双 pane 左右并排渲染、2pt 缝、拖拽把手、活动 pane blurple 边框
  • resize-paneui/resize/110 后服务器 pane 宽度变为 110(另一侧自动收缩到 53——拖拽条的命令链路已验证手势本身是标准 DragGesture
  • 会话服务端持久 ✓app 退出后 tmux ls 里测试会话仍在(验证完已 kill 清理)
  • 新增无头验证命令:ui/split-now/v|h(跳过菜单直接分屏)、ui/resize/<cols>

12.3g 连接后的会话选择器(用户定稿交互,替换掉「自动 attach 固定会话」)

为什么改:之前实现是连上就 tmux -CC new -A -s main —— 替用户决定了 attach 哪个会话。真实运维里服务器上常有多个会话各跑不同任务,必须让用户挑。

新流程SSH 连上 → 普通 shell 里跑探测 → 弹选择器 sheet → 按选择 attach。没装 tmux 时不弹框(直接原生终端 + 安装引导横幅,不打扰)。

  • TXCore/Tmux/TmuxSessionProbe.swift(纯逻辑 + 5 个单测):流式扫描 BEGIN…END 标记块,产出 .available(version:sessions:) / .unavailable。三个坑都在注释里:
    1. PTY 回显:命令本身会被回显,标记若字面出现在命令里,回显行会被当成结果起点 → 写成相邻字符串拼接 "__TX""_SESSIONS_BEGIN"shell 输出完整标记,回显的是拼接前的样子)。
    2. 三态要分开:「没装 tmux」和「装了但没会话」的 stderr 都被 2>/dev/null 吞掉、标记之间同样是空的,只能靠 tmux -V 的版本行区分 —— 两者的正确 UI 完全不同(安装引导 vs 让用户新建)。
    3. 别脏屏:探测命令与输出会打在用户屏幕上 → 命令末尾接 printf '\033[H\033[2J\033[3J'(归位+清屏+清 scrollback不依赖 terminfo。刚连上时清掉的只有 motd/shell 欢迎语,首屏反而更干净。
  • OutputTapraw 字节的旁路(同时喂终端渲染与探测扫描器)。抽 RawOutputSink 协议让 TmuxRouter 能接受 gate 或 tap —— 不动 OutputGate 的缓冲/flush 语义(那是修白屏的关键)。
  • SessionPickerSheet.swift(按用户给的设计图):分段控件 >_ Tmux | Herdr(置灰) | 最近(置灰) + 右侧「跳过 ▷|」;会话行 = 大字会话名 + 「活跃」徽标(被别的客户端占用时另标「已被接入」)+ 副行「N 个窗口 · M 分钟前」;底部「+ 新建 tmux 会话…」(点开就地输入名字)。.presentationDetents([.medium, .large])
  • TerminalSessionpendingSessionChoice / attachTmuxSession / createTmuxSession / skipTmuxtmuxSkipped 让状态胶囊显示「原生窗口 · 不经 tmux」而不是谎报「无 tmux」探测 6s 超时兜底按无 tmux 处理。
  • 无头命令:ui/pick/<会话名> · ui/pick-new/<名> · ui/pick-skip

时机修正(用户第二轮反馈):选择器属于连接流程的一部分 —— 点主机后留在首页探测、在首页弹选择器、选完才进终端页;不是先把用户丢进空终端再弹框。

  • SessionManageropen(host:) 不再立刻 router.enter;改为 awaitingEnter 集合 + @Published enterRequest(视图层消费后进终端)。evaluateEnter 的就绪条件:不等选择 ∧ 不在探测 ∧(已建立 / 无 tmux / 选了原生 / 失败)。失败也进终端 —— 那里有「重试 / 返回首页」,比留在首页只有一行错误更有出路。
  • isProbingTmux 必须在 .connected 那一刻就置位。否则「连接建立」与「探测开始(延迟 2.5s)」之间有空窗,evaluateEnter 看到 isLive == true 就把人送进终端页 —— 第一次改完就是这个症状(选择器弹在终端页上)。
  • SessionManager 也要转发每条会话的 objectWillChange(同 TerminalSession 转发 controller 那条坑),否则首页/选择器不会因会话内部变化刷新。
  • 首页新增 ConnectingRow(正在连接 / 等待选择 / 失败+重试+取消);liveSessions 排除正在连接的会话,避免同一会话既有进度行又有卡片(且卡片里的 tmux 信息此刻还没定,会先显示成「原生窗口」再跳变)。
  • 无头验证真实路径:-txOpenAsHost 1(把启动参数造成一台内存主机后走 open(host:),即探测→选择→进终端;与老的 -txHost 直连路径互斥)。

措辞修正:右上不再是「跳过」(读起来像"跳过某个步骤"、含义模糊),改为**「原生终端」**+ terminal 图标 —— 它是与 tmux 并列的一种连接方式。skipTmux()useNativeTerminal(),状态胶囊第三格显示「原生窗口 · 不经 tmux」。首版只有 Tmux 与原生两种,所以不做别的出口。

真机验证192.168.99.83)三条出路全过

  • 探测 → probe: tmux 3.7b sessions=["demo", "main"],选择器如设计图渲染,终端首屏干净(清屏生效)
  • attach 已有attach -t democurrentSession=demo
  • 新建new -A -s uitestcurrentSession=uitest,服务器 tmux ls 出现 uitest: 1 windows (attached)(验完已 kill
  • 原生终端 → 状态胶囊 direct | ssh | 原生窗口 · 不经 tmux
  • 完整真实路径-txOpenAsHost)→ 首页「正在连接 192.168.99.83」→ 首页上弹选择器(标题「连接到 …」)→ 选 main → 进终端页 tmux main + 轨道 window 0/1/2/3/4

12.3h 质感走查:动效 / 阴影 / 圆角 / 安全区用户第三轮走查2026-07-25

用户反馈四类瑕疵(风格保持不变,只修质感):浮层"特别难展开和消失"缺过渡、胶囊阴影太重不够扁平、终端小圆角与 iPad 屏幕大圆角打架(全屏时右上角被吃掉)、底部混乱没考虑安全区。逐条修完并补了同类问题:

1. 动效令牌化 + 补齐缺失过渡(TX.Motion

  • TX.Motionstandard(0.24 easeOut) / quick(0.14) / slide(edge) / pop(0.96→1 缩放+淡入) / pushBack(首页推远)。统一响应「减弱动态」UIAccessibility.isReduceMotionEnabled → 时长减半 + 位移退化为纯淡入淡出)。
  • 根因AppRouter 里只有侧边栏包了 withAnimationroute / modal 的变更裸着改 → 视图上写好的 .transition 拿不到动画事务,全退化成硬切。现在 enter/goHome/present/dismissModal/toggleSidebar/togglePin 全部经 animated{},点击 / ⌘ 快捷键 / 无头 URL 三条入口自动同一手感。
  • TXModalScrim 拆成两段转场(遮罩 .opacity + 卡片 TX.Motion.pop):整块套一个 .transition(.opacity) 只是一起变淡,没有弹出感。
  • 补:无 tmux 横幅显隐、TmuxInstallSheetTerminalStateOverlay(连上后淡出)、提示条文案随三态切换、会话卡增减、选中态/状态点变色。
  • 按压反馈:新 TXPressStyle(缩放 + 压暗 140ms替换全部 .buttonStyle(.plain)。深色界面里 .plain 完全没有按下反馈,主机卡/会话卡这种"点下去要等几百毫秒"的入口尤其伤手感。
  • 会话卡改成整张一个 Button,卡脚「回到会话 →」退化为文字提示(同一动作做成嵌套 Button 只会抢命中区 + 两套按压反馈)。
  • -txSlowMotion 1DEBUG把所有动效 ×12simctl io screenshot 自身要几百毫秒,抓不到 240ms 的中间帧,没这个开关就无法用截图证明"动效真的在跑"。验证方法:连发三张截图,采样同一像素应得到三个不同的中间值(硬切时三张完全一样)。

2. 阴影扁平化(TX.Elevation,刻意偏离设计初稿) 设计稿给的是 CSS 值(胶囊 0 10px 30px rgba(0,0,0,.55)、对话框 0 30px 80px .7),在深色底上会糊成一团脏斑 —— 半透胶囊叠重投影像"漏墨",终端文字从投影里穿过去。改为:floating(.22/7/y2) · panel(.28/16/x4) · dialog(.34/16/y6) · card(.16/4/y2) · cardFocused(.24/8/y3),分层交给描边与材质,胶囊底不透明度提到 .92 补偿。统一经 View.txShadow(_:),视图里不许再手写 .shadow。 顺带补上遮罩态侧边栏浮层一直漏掉的投影(设计要求 24px 0,故 panel 是唯一带 x 位移的)。

3. 圆角不再与屏幕圆角打架(paneShape 终端右上角贴着屏幕物理边缘,那里设备本身是 ~22pt 大圆角 —— 再画 6pt 小圆角必然被"吃掉"(小圆角整段落在屏幕圆角之外,切口处露出底色三角)。改为 UnevenRoundedRectangle只在朝 chrome 的角收圆角(左上/左下/右下 6pt顶边与右边直接铺到物理边缘、不收角不描边由屏幕自己的圆角收口Stage Manager 小窗同理,窗圆角接管)。

  • 描边用负 paddingtop:-1, trailing:-1)把顶边/右边那两条推出屏外:留着就是贴在屏幕边上的一圈多余框线。
  • 顺带取消顶边/右边的 2pt 缝2pt 是pane 之间的缝,不该出现在屏幕边缘),终端格子从 163×48 涨到 164×50。

4. 安全区chrome 铺满物理屏,底栏自己让位(TX.Layout.footBand / capsuleTop

  • 原来 .ignoresSafeArea(edges: .top) 只放开顶边 → 底部内容停在安全区上沿,屏幕最下面留一条 24pt 的空带("没铺到底"),而颜色型 .background 默认会溢进安全区,于是轨道底色铺满、内容却停在 24pt 之上,一眼就是没对齐。
  • 现在整屏 .ignoresSafeArea(),底部区高度 = footHeight(34) + safeBottom,内容在其中居中 → 既贴住物理底边又让开 home indicator。提示条 / 侧边栏脚 / 轨道齿轮三者中心线同一条 y995上方分割线仍同在 964。
  • 顺带修掉一个状态跳变遮罩态面板铺满全高、Pin 态被安全区推高 24pt一钉住面板脚就跳一下。现在两态完全一致。
  • 安全区读数经新 TXScreenMetricsGeometryReader 必须在 ignoresSafeArea 之前,否则读到 0
  • 胶囊顶距:capsuleTop(safeTop) —— iPad 隐藏状态栏后无遮挡用设计值 14灵动岛/刘海机型safeTop>30按硬件下移。iPhone 17 Pro 模拟器实测胶囊落在岛下方、底栏在 indicator 之上。

5. 同类问题(走查中发现,一并修)

  • 会话卡不等高(一排卡上下边对不齐):TXTerminalMirror 的行堆叠有内容驱动的最小高度,外层 .frame(height:) 压不住它 —— .frame(height:) 只是给了个框,不想撑满的子视图会按自身理想高度居中放进去。改为把行堆叠放进 Color.clear 的 overlayoverlay 不参与父视图定尺),尺寸完全由外部决定,溢出的旧行被 clipped() 切掉(正是想要的:底对齐留最新输出)。
  • 会话卡从不换行的 HStackLazyVGrid(最多 3 列):第 4 条会话进来原先会把所有卡压扁。高度由死写 560 改为 min(560, max(300, 可用高度×0.56)),竖屏/分屏/小 iPad 上不再把「全部主机」挤出屏外。
  • 首页滚动内容铺到物理底边(ignoresSafeArea(.bottom) + 内容 padding 补 safeBottom末行能完整滚到 indicator 之上。
  • 窄窗口适配:主机网格 4→2 列、左右留白 40→20、搜索框 220 固定改可伸缩、提示条只留 2 条4 条在 iPhone 上折行把 34pt 带撑破,且强制单行 lineLimit(1).fixedSize())。
  • 胶囊红/黄圆点命中区 22×30 → 26×3413pt 视觉不变):关闭这种误触代价高的动作不该给这么小的目标。
  • 搜索框补聚焦态描边(键盘焦点可见,无障碍底线)。
  • 删掉 HomeView嵌套重复定义ConnectingRow(与文件级那份一字不差,编译能过但是死代码)。
  • 遮罩浓度令牌化 TX.Scrim(对话框 .5 / 侧边栏 .42),不再散落 Color(hex: 0x0A0B12)

验收iPad 13″ 横屏逐屏对拍(终端 / Pin / 遮罩 / 关闭确认 / 分屏菜单 / 首页)+ iPhone 17 Pro 竖屏(灵动岛 + indicator像素采样确认 —— 右上角 (1365,0) 为终端底色(不再是底色三角)、两态面板脚同在 964/982991、三张会话卡同为 166..710、遮罩把终端文字亮度 185→97TXCore 45 tests 绿。

12.3i 触控化与标签规范(用户第四轮走查 + 真机部署2026-07-26

  1. 首页会话卡固定 3 格 4:3LazyVGrid 列数写死 3不再 min(3, count)),不足 3 条自然从左往右排、卡片尺寸不随会话数跳动;高度改 .aspectRatio(4/3, contentMode: .fit),删掉按屏高算高度那段。
  2. 技术标签规范(新 TXTech
    • 拼写:照各自官方写法 —— 缩写全大写(SSH)、项目名按作者写法(mosh/tmux/tsnet 本来小写)、产品名照抄(macOS/TrueNAS)。TXSectionLabel 不再 .uppercased() —— 它会嵌主机名/会话名,之前渲染成 TMUX · MAC-STUDIO · DEV 把名字全改错了;轨道标签同步改 tmux
    • 配色:传输协议冷色区分(mosh teal / SSH blue二者互斥所以一行只出现一个tsnet 用 app 强调色(招牌能力);凭据名回中性(那是用户命的名,不是技术标签,避免一排全彩色)。主机卡图标染发行版品牌色Ubuntu 橙→peach、Debian/树莓→red、Fedora/TrueNAS→blue、Arch→teal、macOS→中性银认不出的回中性。徽标只染文字+描边、底色 12%。
  3. 关闭/最小化按钮重做:原 macOS 交通灯式 13pt 纯色圆点三处不合适 —— 纯色块不说明自己干什么mac 靠 hover 浮字形,触屏没有 hover、13pt 命中目标太小、圆点是 mac 窗口语汇与本 app 圆角矩形语汇不搭。改 22pt 圆角方块 + 常显字形 / ),保留红=关闭、琥珀=最小化的颜色语义,底色 14% 不铺满,命中 26×34间距放宽。 而非双向内收箭头:后者在 9.5pt 下糊成一团,且向下正好对上设计的「下滑最小化」手势。
  4. 侧边栏自身的平移动画(新 leadingColumn:原来是 if pinned { 面板 } else { 轨道 } —— 两者互换只能硬切,用户看到面板"消失"、轨道"出现",没有平移。改为轨道常驻在底、面板叠在上列宽随三态动画56/56/288+ clipped():收起时面板向左滑出并被裁掉、轨道从背后露出。慢放实测 1.2s 处列宽正处于中间态≈140pt确认逐帧插值而非硬切。
  5. 去掉命令面板:底部 ⌘K 命令面板 与首页搜索框的 ⌘K 提示一并删除(快捷键体系属 UI-5未实现的提示等于假承诺
  6. 底部改动作条 TerminalActionBar(原 TerminalHintBariPad 大概率没接键盘,「竖分屏 / 新建 window / 回首页」只写成 ⌘D 文字提示等于这些功能对触控用户不存在 → 改成常驻可点按钮(图标 + 文案26pt 高胶囊)。非 tmux 会话下前两个灰显但保留(不隐藏,免得布局跳)。左侧上下文文案改为真实会话状态mac-studio · tmux dev · 3 windows)—— 此前写死「已连接妙控键盘 · 附加行已收起」,没接键盘时那是假话。措辞与侧边栏统一为「新建 window」。

真机部署(用户体验用)-configuration Release -destination 'id=<iPad UDID>' -allowProvisioningUpdates 自动签名Apple Development: JIANKUAN YANG / Team J75A6S5938xcrun devicectl device install app + process launchRelease 里所有 #if DEBUG 的 fixture / terminalx://ui / -txSlowMotion 全部编译不进去,所以真机跑的必然是真实数据路径(首次运行落在「还没有主机」空状态);签名后 Keychain/Secure Enclave 可用,凭据走系统钥匙串而非文件回退。

12.3j 侧边栏三态动画重构统一视图身份用户第五轮反馈「动画混乱」2026-07-26

用户判词:三态切换动画混乱;收起态应理解为「展开态在左侧裁切一部分」,允许改 UI 让两态最左侧图标同尺寸同位置。

根源12.3i 的 leadingColumn 只修了一半):① 轨道 RailView 与面板 SidebarPanel 是两套视图,元素尺寸/位置互不对应logo 34/30、头像 38/26、徽标 34×30/26×22纵向节奏也不同→ 就算列宽在动画,内容仍是两张不相干的画面交叠;② 遮罩态面板挂全屏 overlay、Pin 态面板是 leadingColumn 里另一个实例 → overlay⇄pinned 切换视图身份互换,一个滑出一个滑入互相打架;③ 遮罩/投影靠 if 插拔。

重构RailSidebar.swift 整文件重写为 TerminalSidebarContentView.screen 重组):

  1. 三态唯一视图:内容恒按 288pt 全宽排版;收起 = 容器宽度收到 railWidth + clipped()(字面实现「左侧裁切」)。最左 railWidth 为图标列logo 30 / 裸图标 17pt / 头像 30 / window 徽标 26×22 / 状态点 / 开合键 / 齿轮,两态同尺寸同位置;右侧明细(标题/副行/快捷键/选中底)opacity(expanded) 淡入淡出。
  2. 行对应关系:轨道的 24pt 短分隔线 ⇄ 面板的分组标题(同一行两种密度,交叉淡化);轨道 tmux 迷你标签 ⇄ tmux · 主机 · 会话 分组标题;搜索裸图标 ⇄ 搜索框放大镜位置几乎重合底部统一为「tailnet 状态行 / 开合键行 / 分割线+齿轮 footBand」两态元素不增不减。
  3. 组合:舞台前的占位列只在 Pin 态推到 288保持「钉住/解除各 resize 一次」);遮罩与侧边栏常驻在同一 overlay 里,遮罩动 opacity、投影(Elevation.panel)动 opacity —— 遮罩⇄Pin 时侧边栏本体一动不动,只有遮罩淡出+舞台平移。
  4. 新坑:clipped() 只裁绘制不裁触摸 —— 收起态下整行按钮被裁掉的明细区会伸进终端抢点击。整行按钮用 LeadingHitShapeAnimatable contentShape,命中区=当前可见宽度);纯明细控件(搜索框/Pin/新建 window再加 allowsHitTesting(expanded)
  5. 验证fixture + ×12 慢放 + 逐帧像素采样):收起→遮罩(遮罩逐帧变暗 30→29→22、遮罩→收起面板前沿扫过采样点+遮罩渐浅、遮罩→Pin胶囊中间帧 709px vs 终值 826px舞台平移中、Pin→收起427→362均有中间帧无硬切。

用户复核后追加裁决(同日)——砍掉悬浮态,只留两态Pin→收起的"明细淡出 + 终端从右盖上来"效果被点名保留,展开即反向。

  • SidebarState 只剩 collapsed|pinnedtogglePin/dismissOverlaySidebar/遮罩/浮层投影全删;TerminalSidebar 直接进 HStack 参与布局无占位列开合就是它的宽度动画56⇄288各触发一次 resize点终端不再收起侧边栏(固定是持久态)。
  • 开合入口收敛(此前顶部/底部两个收起键冲突、底部那枚缩在左下角不协调Logo 位=开合主入口(收起态显 » 展开键、展开态还原 Logo同框同位交叉淡化——用户定稿将来 App Logo 设计与展开按钮一体);头部 « 收起键保留底部开合行删除Pin 键删除(两态下无意义)。terminalx://ui/sidebar/pin 兼容保留=toggle。
  • 顺带修掉暴露的旧缺陷:标题胶囊的格子数读 screenGrid.value 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ GridBoxonGridChange(去重)、TerminalSession 转发 objectWillChange。验证:展开 135×50 / 收起 164×50 实时跟随。
  • 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pteaseOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。

12.3k 放弃移动端分屏 + tmux window Tab 上移 + 状态出口合并用户裁决2026-07-26

用户判词:全面放弃移动端的分屏操作非核心功能tmux 的切换从侧边栏移到顶部作 Tab活动会话(主机连接)的切换仍在侧边栏;右上状态文字移到左下并与既有文字合并(原两处信息重复)。 (第一版误把「会话列表」理解成主机连接列表上移为 Tab用户纠正 + AskUser 确认Tab = tmux window(与 tmux -CC「window 映射为原生 tab」惯例一致非 tmux session 切换——后者需 switch-client + 窗口全量重建,未做。)

1. 删分屏(入口 + 逻辑)

  • UI 入口:底部动作条「竖分屏」按钮、分屏菜单 SplitMenuView12.3d 的 3b 卡片整个删除)、AppModal.splitMenu
  • 逻辑:TmuxController.splitWindow/splitIntoNewSessionlist-sessions 管线(TmuxSessionInfo/sessions/listSessions()/PendingKind.listSessions——只服务分屏菜单)、TerminalSession.tmuxSessionList/injectedTmuxSessions、fixture 的 tmuxSessions()、无头 URL ui/split/ui/split-now
  • 保留:多 pane 的被动渲染PaneSplitter 拖拽/resizePane(别的客户端建好的分屏 attach 进来仍须按 layout 正确显示)、ui/resize/<cols>
  • 顺带修掉 TmuxController 响应 switch 里重复的 case .currentSession(死代码)。

2. tmux window TabTmuxWindowTabsBarTerminalChrome.swift:顶带 = 左标题胶囊 + window Tab 条(右上状态胶囊撤销)。一个 window 一个浮起胶囊 tab编号徽标 + 标题),点击 selectWindow(链路复用旧侧边栏 window 行);当前 window accent 高亮controller 未 attach 但有 tmuxSummary 时按占位渲染fixture/接回前),原生窗口不显示。三层嵌套订阅各自拆视图(WindowTabsRow 订阅 controller、WindowTab 订阅 window——title/index 是 window 自己的 @Published。侧边栏 tmux 段(tmuxDivider/tmuxLabelRow/tmuxRows/SidebarWindowRow/WindowRowBody/「新建 window」行删除「新建 window」入口保留在底部动作条侧边栏 = 首页 / 活动会话(保留,主机连接切换) / 连接新主机 / tailnet / 设置,不再要 session 参数。 3. 状态出口合并(TerminalActionBar.statusLine:原右上三格分层徽标(tsnet|mosh|tmux)与原左下上下文文字合并为左下唯一状态行:[dot 链路] | 传输(重连计数) | 会话概览(tmux 名 · N windows / 原生窗口 / 无 tmux / phaseText)。主机名不再出现(标题胶囊与当前 tab 已有)。「只染出问题的那层”规则原样保留。

  • 验证fixture 三会话截图对拍window tab 高亮/占位 tab/合并状态行/侧边栏两态排版含活动会话段)+ ui/session/2 切换 + 开合 resize 格子数跟随164×50⇄135×50。真机 tmux 下点 tab 切 window 待验证(链路与旧侧边栏 window 行相同)。

12.3l 顶带落地重设计:按钮分离 / 标题弱化 / tab 组用户定稿2026-07-26

用户判词按钮、title、tab 可以在终端区域按钮分离出来单独悬浮标题弱化tab 要有「组」的概念。

  • 浮起胶囊废除,顶带落地为真实 chrome 行TerminalTopBar 替换 TerminalTitleCapsule+FloatingCapsulestage 从 ZStack overlay 改回 VStack 首行,终端 pane 从带下方开始 —— 86pt 预留带(TX.Layout.capsuleInset已删令牌与「胶囊遮内容」问题一并消失。pane 顶边现在朝 chrometopLeading 收圆角、顶描边恢复(不再 top:-1 推出屏外右边仍贴屏幕物理边缘不收角。pane 内容留 TX.Space.s 呼吸位。
  • 顶带层级(一眼一个主角)[✕ ⌄ 独立按钮组] · 弱化标题 · [tab 组]。按钮升到 28pt 圆角方块自成一簇(危险操作与身份信息不共容器);标题降为纯文本行(状态点 5pt + 主机名 mono12 text2 + cwd textWeak + 格子数 textFaint当前 window title 从标题里删掉 —— 它已由 tab 组选中 chip 承担写两遍是重复tab 组是顶带唯一带容器的元素。
  • tab 组(TmuxWindowTabGroupsurfaceDark 圆角槽radius 11内衬 3ptwindow 是槽内段式 chip选中 = selectedBg+描边浮起,未选 = 透明贴槽底),尾部 + 新建 window。坑:槽体背景必须挂在 ScrollView 的内容层 —— 挂 ScrollView 自身就是一条拖到屏幕右边的空槽ScrollView 恒占满剩余宽度。占位态controller 未 attach 但有 tmuxSummary共用 chip 本体;原生窗口不渲染 tab 组。
  • 验证fixture 截图 ×3tmux 态顶带 / 原生窗口态无 tab 组 / 展开侧边栏组合态),标题去重与槽体紧贴成组均确认。

12.3m 换行错乱根因修复:客户端格子的权威改为 ghostty 实测真机报障2026-07-26

用户报障:无论侧边栏收起/展开终端Claude Code 等全屏 TUI换行错乱、行首冒碎片竞品完美。

  • 根因(模拟器连本机 sshd 复现实锤):containerResized 用「容器px ÷ 整数cell px」算 tmux 客户端格子,复刻不了 ghostty 内部取整/内边距 —— 实测算得 164×51、ghostty 真实 163×50(列差来自 TmuxPaneView 的 1pt padding 圈,行差来自像素除法本身)。远端按 164 列排版、surface 按 163 列自动换行 → 每条满宽行掉字符,重绘积累成花屏。
  • 修复TmuxController + ContentView + TerminalChrome):① pane resize 回调从「只报 cell 像素」升级为上报完整实测格子paneViewportChanged);② 单 pane window 的 pane 帧==容器帧 → 它的实测格子就是客户端格子,据此发 refresh-client -C(帧只由容器决定,无反馈环);③ kickoff 初值改用 raw surface 实测格子(setClientSize 不再被像素换算覆盖);④ 单 pane 时去掉 pane 的 1pt padding 圈(solo);⑤ 像素除法只兜底「全是多 pane window」的场景⑥ 顶栏格子数 tmux 模式改读 TmuxController.clientGridscreenGrid 随 RawTerminalView 卸载已冻结)。
  • 验证(模拟器 → 本机 sshd + tmux 3.7b 端到端):tput cols 宽字符标尺恰好一行不换行;侧边栏收起 164×50 ⇄ 展开 135×50 双态各验证;top 全屏重绘干净;顶栏胶囊数字随开合正确跳变。回归法已写入 CLAUDE.md模拟器 -txPubkeyAuth 1 连 127.0.0.1,公钥临时入 authorized_keys

12.4 首版明确推迟Fable 裁决,勿在 UI-3~6 里偷偷做)

mosh <RTT> 实时数字(需改 vendor/mosh iosclient 导出 SRTT属传输层· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」交给 tmux relayout· 缩略卡 ANSI 彩色(用 readViewportText() 文本镜像,绝不做离屏 Metal/双 surface——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关ghostty 无运行时 mutation灰显占位