Files
terminalX/CLAUDE.md
kid 5d1abdc7ac feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法
  偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测
  格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏
  格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证)
- 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 +
  TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容
- 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部
  tab,侧边栏改列活动会话;底部动作条合并状态行
- Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需,
  缺失时 socket 静默超时)

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

129 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md — terminalX
原生苹果生态终端工具,解决 iPad/iPhone 远程运维 macOS/Linux + vibe coding 痛点。
平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**。四大核心:**libghostty** 引擎 · **mosh** · **Tailscale(tsnet 用户态)** · **tmux control-mode hook**
iOS/iPadOS 只做远程(沙盒禁 fork/execmacOS 支持本地 PTY。
> **新会话必读**:先读 `docs/HANDOFF.md`(项目全貌 + 里程碑状态 + 如何继续)。本文件只讲操作规范。
## ⚠️ 铁律:绝不臆想工具结果
调用任何工具后,在**真实结果返回前**绝不预测/编造/续写其输出(不写假想的 `BUILD SUCCEEDED`、日志、`ls`、截图内容)。一次调用→停→读真实返回→再推理。想说预期只在调用**之前**用将来时。发现自己编造要**硬停**重新核实。(曾因此误诊,教训见 git 历史 / HANDOFF。
## 环境约束(关键,决定一切构建方式)
- 构建机走**带认证的 HTTPS 代理**(疑似国内网)。规律:`github.com` release blob 可经 `curl` 下;**`go.dev`/`ghcr.io`(brew bottle) 被代理 abort****SwiftPM 不支持带认证代理** → 所有 SPM 依赖必须 **vendor 本地化**(见 `vendor/`。Go 模块走 `goproxy.cn`
- **Go 1.26.5 在 `~/.local/go`**(不在默认 PATH经 aliyun 镜像 curl 装);**gomobile/gobind 在 `~/go/bin`**。`go env` 已固化 `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。
## 构建 & 验证命令
```sh
# 纯逻辑单测(无需 Xcode/模拟器)
swift test --package-path packages/TXCore # 50 tests (40 swift-testing + 10 XCTest)
# 重建 SSH 库 xcframeworklibssh2+mbedTLS改 vendor/libssh2|mbedtls 后)
bash vendor/build/build-cssh.sh all # 产出 artifacts/CSSHCore.xcframework (library-only!)
# 重建 mosh 库 xcframeworkmosh C++ + protobuf-lite改 vendor/mosh|protobuf 或 build/mosh/* 后)
bash vendor/build/build-mosh.sh all # 产出 artifacts/MoshCore.xcframework (library-only!)
# 注:首次需 host protoc 生成 .pb.cc已 checkin无需重跑
# cmake -S vendor/protobuf -B vendor/protobuf/build-host -G Ninja -Dprotobuf_BUILD_TESTS=OFF && \
# cmake --build vendor/protobuf/build-host --target protoc && \
# vendor/protobuf/build-host/protoc --proto_path=vendor/mosh/src/protobufs --cpp_out=vendor/mosh/src/protobufs *.proto
# 重建 tsnet 桥(改 vendor/tsnet-bridge/tsnetbridge/bridge.go 后)
export GOROOT=$HOME/.local/go; export PATH=$GOROOT/bin:$HOME/go/bin:$PATH
cd vendor/tsnet-bridge && gomobile bind -target=ios,iossimulator \
-o ../../artifacts/TsnetBridge.xcframework ./tsnetbridge
# 构建 app改 project.yml 后先 xcodegen generate
cd apps/TerminalX && xcodegen generate --spec project.yml
xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-destination 'platform=iOS Simulator,id=<UDID>' \
-clonedSourcePackagesDirPath /tmp/tx-spm build
```
## 真机部署用户体验用iPad Pro 13″ M5
```sh
IPAD=454D7605-FA87-51F4-BA62-5E58B640188E # 变了用 xcrun devicectl list devices
cd apps/TerminalX && xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-configuration Release -destination "id=$IPAD" \
-clonedSourcePackagesDirPath /tmp/tx-spm -allowProvisioningUpdates build
APP=~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Release-iphoneos/TerminalX.app
xcrun devicectl device install app --device $IPAD "$APP"
xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx
```
自动签名已配好Team J75A6S5938。**LAN 直连需要「本地网络」权限**Info.plist 已带 `NSLocalNetworkUsageDescription`(没有它系统可能不弹框直接静默拒绝);重装后首次连 LAN 主机会弹授权框,被拒的表现是 **socket 纯超时(约 12s别的 app 正常**——去 设置→隐私与安全性→本地网络 打开开关2026-07-26 真机踩坑)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 ReleaseDebug 的 SwiftUI 开销会让动效发闷)。
## 无头验证(本环境无 GUI用 simctl 截图取证)
iPad Pro 13(M5) 模拟器 UDID`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
```sh
UDID=2EA9055E-4557-4B20-82AF-56A68AC1D454
APP=$(find ~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Debug-iphonesimulator -maxdepth 1 -name TerminalX.app | head -1)
xcrun simctl boot $UDID; xcrun simctl install $UDID "$APP"
xcrun simctl launch $UDID ai.athom.terminalx --args <见下>
sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具看 /tmp/x.png
```
**启动参数NSUserDefaults 参数域,自动连接,供无头验证)**
- 直连 SSH`-txHost H -txPort 22 -txUser U -txPass PW -txAutoCommand "echo hi"`
- tsnet SSH额外加 `-txTsnetKey <authkey>`-txHost 填 tailnet IP
- 纯 tsnet 自检(加入+对端枚举):只给 `-txTsnetKey <authkey>`(不给 -txHost
- **mosh 直连(LAN)**`-txHost H -txPort 22 -txUser U -txPass PW -txMosh 1`(登录后跑 mosh-server→解析 MOSH CONNECT→mosh_main 直连 UDP
- **mosh over tsnet**:额外加 `-txTsnetKey <authkey>`-txHost 填 tailnet IPrelay=loopback↔tsnet UDP
- 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`
- **tmux 自动进入**:默认开(`SavedHost.useTmux`)。`-txTmux 0` 关闭(回归 raw 终端路径)、`-txTmuxSession <名>` 指定会话名(默认 `main`)。
- **M4 publickey 认证**`-txPubkeyAuth 1`(不给 -txPassSigningKeyProvider 造 P-256 key日志打 authorized_keys 行,加到服务器后重连免密登录)
- **M4 host key mismatch 测试**`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
**UI 改造期无头验证(推荐走脚本)**`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
- **`-txLandscape 1`**app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
- **`-txUIFixture 1`**:造 3 条**假会话**mac-studio/ubuntu-cn2/nas-home含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`DEBUG only`-txHost` 互斥。
- **`terminalx://ui/<动作>`**`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `dismiss` / `session/<n>` / `sidebar/toggle``sidebar/pin` 兼容保留=toggle悬浮态已砍`split*` 已随「放弃移动端分屏」删除)。比坐标点击稳(不依赖 idb companion不随布局失效。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E``#11121C` 目测分不出,必须采样像素。
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView``router.route` 判断。
- **`-txSlowMotion 1`**:所有动效 ×12DEBUG`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到**三个不同的中间值**;硬切时三张一模一样。
- 设计稿本体在 `docs/design/``UI-DESIGN-HANDOFF.md`(token/规格/交互全表) + `terminalX-iPadOS.dc.html`(13 屏画板) + `render-screens.py`(拆屏,配 headless Chrome 逐屏截 PNG 对拍)。
**M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'``wake pulse``TXM3 recovered` 时间差判 <3s。**模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。**
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图288pt 全宽排版+railWidth 图标列,收起=左侧裁切列活动会话tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:✕⌄独立按钮组+弱化标题+`TmuxWindowTabGroup` window tab 组,终端 pane 从带下开始不再被悬浮胶囊遮内容;底部动作条含合并状态行;分屏已整体移除)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。
- UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12)`build/mosh/`(手写 config.h+CMakeLists)。
- `artifacts/` — 预编译 xcframework**有意提交**,非 gitignore`CSSHCore`/`TsnetBridge`/`MoshCore`
## 约定 & 坑
- **CSSHCore/MoshCore 必须 library-only**(无 Headers/modulemapSwift module 由包内 `CSSH`/`CMosh` C target 提供。否则与 GhosttyKit 撞 `include/module.modulemap`
- **mosh 构建**:绕过 autotools 用 CMake 直编 `src/*.cc`(手写 `build/mosh/config.h`crypto 用 CommonCrypto(`USE_APPLE_COMMON_CRYPTO_AES=1`);用 `terminaldisplayinit_ios.cc`(无 terminfo);已 patch 3 处 vendored 源(`::bind` 消歧义 / curses include 包 `#if !IOS_CONTROLLER` / 去 iOS 禁用 `system()`)protobuf 只需 lite runtime`.pb.cc` 已 checkin。
- **protoc 装不了不是问题**:用源码 CMake 编一个 host protoc 生成 `.pb.cc` 即可(一次性,产物 checkin
- **一进程一个 Go runtime、只一次 gomobile bind**(全在 `tsnetbridge` 包)。
- **Swift `\r\n` 是单个 Character扩展字形簇**:判行尾用 `Character.isNewline`,不能 `c != "\r"``MoshConnectScanner` 曾踩)。
- **Swift6 `SendNonSendable` pass 可能崩溃**`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
- **tmux format 的字段分隔符不能用 tab**:真机实测 tab 经 PTY 送到 tmux 后**变成下划线**`list-windows`/`list-sessions` 整行分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`(可打印、不被 PTY 处理、`#` 会被 tmux format 当特殊字符所以不能用)。**排查提醒**`os_log` 会把控制字符显示成 `_`,光看日志分不出「真下划线」还是「被显示替换的 tab」——要打印 `parts.count` 才能确诊。
- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**`tmuxSummary` 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd
- **tmux 不会自己进**`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
- **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **客户端格子数的权威 = ghostty 实测,绝不是像素除法**2026-07-26 真机 Claude Code 花屏根因):`容器px ÷ 整数cell px` 复刻不了 ghostty 内部的取整/内边距,实测**偏大 1 行/列**(算得 164×51真实 163×50→ 远端按更宽排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,全屏 TUI 重绘几轮积累成行首碎片。修法:① kickoff 用 raw surface 实测格子(`setClientSize(screenGrid)`,不再被像素换算覆盖);② **单 pane window 的 pane 帧==容器帧,其 resize 上报的实测格子就是客户端格子**`paneViewportChanged``refresh-client -C`(帧只由容器决定、与 tmux 布局无关,故无反馈环);③ 单 pane 时去掉 `TmuxPaneView` 的 1pt padding 圈surface 像素帧必须与 raw/容器完全一致);④ 像素除法只留作「全是多 pane window」时的兜底。验证法`tput cols` 宽的字符标尺应恰好一行不换行(模拟器连本机 sshd 即可复现/回归)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除ghostty 释放 grid内容全丢② matchedGeometry最小化缩回首页卡要求两端同层级独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
- **侧边栏两态(收起⇄固定展开,悬浮态已被用户裁决砍掉)必须是同一个视图身份**`TerminalSidebar`):内容恒按 288pt 全宽排版,收起只是容器宽度收到 railWidth + `clipped()`"收起=展开在左侧裁切");最左 railWidth 是图标列,两态同尺寸同位置,明细随展开淡入;视图直接参与布局,收起=明细淡出+终端从右盖上来(用户定稿效果)。开合入口只有两个:**Logo 位**(收起态显 » 展开键,展开态还原 Logo——将来 App Logo 设计与展开按钮一体)与头部 « 收起键。曾经"轨道+面板两套视图 + overlay/pinned 两个面板实例",三态切换视图身份互换 → 动画混乱硬切。
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 resize。读 `screenGrid` 的视图(标题胶囊 `164×50`)靠 `GridBox.onGridChange → session.objectWillChange` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。**tmux 模式下 `screenGrid` 冻结**RawTerminalView 已卸载)→ 胶囊改读 `TmuxController.clientGrid`@Published,经 objectWillChange 转发刷新)。
- **`clipped()` 只裁绘制不裁触摸**:收起态下侧边栏被裁掉的明细区(整行按钮)会伸进终端里抢点击 → 整行按钮用 `LeadingHitShape``contentShape` 限制命中区到当前可见宽度Animatable纯明细控件再加 `allowsHitTesting(expanded)`
- **状态变更必须包在 `TX.Motion` 的动画事务里**`AppRouter` 的 route/modal/sidebar 全走 `animated{}`。裸改 `@Published` → 视图上写好的 `.transition` 拿不到事务,**静默退化成硬切**(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走 `View.txShadow(TX.Elevation.*)`,别手写 `.shadow`(深色底上大半径高透明度会糊成脏斑)。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角不描边(描边用负 padding `top:-1/trailing:-1` 推出屏外。Stage Manager 小窗同理。
- **沉浸页的安全区由底栏自己让位**:整屏 `ignoresSafeArea()`,底部区高 = `TX.Layout.footBand(safeBottom)` = 34 + 安全区,内容**居中**(贴住物理底边又让开 home indicator。**坑**:只放开顶边时,颜色型 `.background` 会溢进底部安全区、内容却停在上沿 → 屏幕最下面一条 24pt 空带 + 轨道底色铺满,一眼没对齐;遮罩态/Pin 态面板脚还会差 24pt一钉住就跳。安全区读数用 `TXScreenMetrics``GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0
- **`.frame(height:)` 压不住内容驱动的最小高度**:它只是给了个框,不声明撑满的子视图按自身理想高度**居中**放进去 → 一排卡片高矮不齐。要么子视图自己 `.frame(maxHeight: .infinity)`,要么(内容天生比框高时,如 `TXTerminalMirror` 的行堆叠)把内容放进 `Color.clear``overlay` —— overlay 不参与父视图定尺,尺寸完全由外部决定,溢出交给 `clipped()`
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。
- **嵌套 ObservableObject 不冒泡**`SessionManager``@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
- 提交/推送 git 只在用户明确要求时main 分支已有 6 次提交,未 push、无 remote
- 与用户始终用**简体中文**交流。
</content>