diff --git a/CLAUDE.md b/CLAUDE.md index 6aa236a..85c6867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ APP=~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Release-iph xcrun devicectl device install app --device $IPAD "$APP" xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx ``` -自动签名已配好(Team J75A6S5938)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 SwiftUI 开销会让动效发闷)。 +自动签名已配好(Team J75A6S5938)。**LAN 直连需要「本地网络」权限**:Info.plist 已带 `NSLocalNetworkUsageDescription`(没有它系统可能不弹框直接静默拒绝);重装后首次连 LAN 主机会弹授权框,被拒的表现是 **socket 纯超时(约 12s),别的 app 正常**——去 设置→隐私与安全性→本地网络 打开开关(2026-07-26 真机踩坑)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 SwiftUI 开销会让动效发闷)。 ## 无头验证(本环境无 GUI,用 simctl 截图取证) iPad Pro 13(M5) 模拟器 UDID:`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。 @@ -77,7 +77,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 **UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh [-- <启动参数>]` 一步完成装/启/截图/修正方向。 - **`-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`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。 +- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `dismiss` / `session/` / `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`**:所有动效 ×12(DEBUG)。`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到**三个不同的中间值**;硬切时三张一模一样。 @@ -88,7 +88,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 ## 仓库结构 - `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 图标列,收起=左侧裁切)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。 +- `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`。 @@ -108,11 +108,12 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 - **`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 宽高排版,二者不一致必换行/清屏错乱);pane surface 的 resize 回调**只断言不反向驱动 tmux**(防反馈环)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 +- **tmux 多 pane 尺寸走"路线 b"**:app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 +- **客户端格子数的权威 = 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` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。 +- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 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 小窗同理。 diff --git a/apps/TerminalX/TerminalX-Info.plist b/apps/TerminalX/TerminalX-Info.plist index c57dcf9..71b4382 100644 --- a/apps/TerminalX/TerminalX-Info.plist +++ b/apps/TerminalX/TerminalX-Info.plist @@ -12,6 +12,11 @@ JetBrainsMono-SemiBold.ttf JetBrainsMono-Bold.ttf + + NSLocalNetworkUsageDescription + terminalX 需要访问本地网络,才能通过 SSH/mosh 连接局域网内的服务器与设备。 CFBundleURLTypes diff --git a/apps/TerminalX/iOS/AppRouter.swift b/apps/TerminalX/iOS/AppRouter.swift index e858803..a1ec768 100644 --- a/apps/TerminalX/iOS/AppRouter.swift +++ b/apps/TerminalX/iOS/AppRouter.swift @@ -32,13 +32,11 @@ enum SidebarState: Equatable { } } -/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动 +/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W 快捷键与无头 URL 驱动 /// 都要能打开它,视图内部状态触达不到。 enum AppModal: Equatable { /// 关闭确认(3c) case closeConfirm - /// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D) - case splitMenu(vertical: Bool) } /// 导航状态持有者。视图只读它、只经方法改它。 @@ -46,13 +44,13 @@ enum AppModal: Equatable { final class AppRouter: ObservableObject { @Published var route: AppRoute = .home @Published var sidebar: SidebarState = .collapsed - /// 当前模态浮层(关闭确认 / 分屏菜单)。 + /// 当前模态浮层(关闭确认)。 @Published var modal: AppModal? /// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms easeOut,响应「减弱动态」)。 /// /// **路由 / 侧边栏 / 浮层的每一次变更都必须包在这条曲线里**,否则视图上的 `transition` - /// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认、分屏菜单曾都是这么"啪"地跳出来的。 + /// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认曾都是这么"啪"地跳出来的。 /// 收在 router 里的另一个好处:点击、⌘ 快捷键、无头 URL 三条入口天然同一手感。 private func animated(_ change: () -> Void) { withAnimation(TX.Motion.standard, change) @@ -76,7 +74,7 @@ final class AppRouter: ObservableObject { animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed } } - /// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。 + /// 打开/关闭模态浮层(红点、⌘W 与无头 URL 共用同一入口)。 func present(_ modal: AppModal) { animated { self.modal = modal } } func dismissModal() { animated { modal = nil } } } diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 06ce7c5..bd5f7ba 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -196,7 +196,7 @@ struct TerminalScreen: View { // 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的 // 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起), // 反之展开。开合各触发一次终端 resize。 - TerminalSidebar(manager: manager, router: router, session: session, + TerminalSidebar(manager: manager, router: router, tailscaleStore: tailscaleStore, railWidth: railWidth, safeBottom: safeBottom, onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost, @@ -204,7 +204,7 @@ struct TerminalScreen: View { stage(safeTop: safeTop, safeBottom: safeBottom) } .background(p.bgDeep) - // 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。 + // 关闭确认(3c):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。 .overlay { switch router.modal { case .closeConfirm: @@ -213,13 +213,6 @@ struct TerminalScreen: View { onDetach: { router.dismissModal(); closeSession(kill: false) }, onKill: { router.dismissModal(); closeSession(kill: true) }, onCancel: { router.dismissModal() }) - case .splitMenu(let vertical): - SplitMenuView(session: session, vertical: vertical, - onPick: { target in - router.dismissModal() - performSplit(target, vertical: vertical) - }, - onCancel: { router.dismissModal() }) case nil: EmptyView() } @@ -258,47 +251,48 @@ struct TerminalScreen: View { // MARK: - 舞台 private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View { - ZStack(alignment: .top) { - VStack(spacing: 0) { - // 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。 - if session.tmuxUnavailable { - NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } }, - onDismiss: { - withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() } - }) - .padding(.top, TX.Layout.capsuleInset) - .transition(.opacity) - } - // 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上; - // 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。 - paneArea - // 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。 - .padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(p.terminalBackground) - .clipShape(paneShape) - .overlay( - paneShape.strokeBorder(p.border, lineWidth: 1) - // 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕 - // 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。 - .padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1)) - ) - // 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。 - // 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。 - .padding(.leading, TX.Layout.paneGap) - TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom) + VStack(spacing: 0) { + // 顶带(真实布局行,用户定稿):✕/⌄ 按钮组 + 弱化标题 + tmux window tab 组, + // 全在终端**外**的 chrome 带上 —— 终端 pane 从带下方开始,不再被悬浮胶囊遮内容。 + TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession) + .padding(.horizontal, TX.Space.m) + // iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。 + .padding(.top, TX.Layout.capsuleTop(safeTop)) + .padding(.bottom, TX.Space.s) + // 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。 + if session.tmuxUnavailable { + NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } }, + onDismiss: { + withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() } + }) + .transition(.opacity) } - capsules(safeTop: safeTop) + paneArea + // 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。 + .padding(.top, TX.Space.s) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(p.terminalBackground) + .clipShape(paneShape) + .overlay( + paneShape.strokeBorder(p.border, lineWidth: 1) + // 右边与屏幕边缘齐平,那条描边推出屏外:留着就是贴在屏幕边上的 + // 多余框线,还会在屏幕圆角处被切出斜口。顶边现在朝 chrome,描边保留。 + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1)) + ) + // 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。 + // 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。 + .padding(.leading, TX.Layout.paneGap) + TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom) } .background(p.bgDeep) } /// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。 /// - /// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角, + /// 终端右侧贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角, /// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。 - /// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条) - /// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。 + /// 故右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、顶边(挨顶带)、 + /// 底边(挨提示条)才是真正需要圆角把 pane 和 chrome 分开的地方。 private var paneShape: UnevenRoundedRectangle { UnevenRoundedRectangle( topLeadingRadius: TX.Radius.pane, @@ -328,17 +322,6 @@ struct TerminalScreen: View { } } - private func capsules(safeTop: CGFloat) -> some View { - HStack(alignment: .top) { - TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession) - Spacer(minLength: TX.Space.m) - TerminalStatusCapsule(session: session) - } - .padding(.horizontal, TX.Space.m) - // iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。 - .padding(.top, TX.Layout.capsuleTop(safeTop)) - } - /// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。 private func requestClose() { router.present(.closeConfirm) @@ -365,47 +348,11 @@ struct TerminalScreen: View { router.goHome() } - /// 分屏菜单选择后立即分屏(设计:选完不再问方向)。 - private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) { - guard let tmux = session.tmuxController else { return } - switch target { - case .currentSession: - tmux.splitWindow(vertical: vertical) - case .otherSession(let name): - tmux.splitWindow(vertical: vertical, joinSession: name) - case .newSession: - // 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。 - tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)") - case .nativeWindow: - break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4 - } - } - - /// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。 - private func requestSplit(vertical: Bool) { - session.tmuxController?.listSessions() - router.present(.splitMenu(vertical: vertical)) - } - - /// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」—— - /// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。 - private var barContext: String { - if !session.isLive { return session.phaseText } - if let t = session.tmuxSummary { - let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)" - return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")" - } - if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" } - return session.displayName - } - - /// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。 + /// 底部动作(**触控可点**,不是快捷键提示)。新建 window 在原生窗口下灰显。 + /// (分屏入口已随「放弃移动端分屏」移除;左侧状态行见 `TerminalActionBar.statusLine`。) private var barActions: [TerminalActionBar.Action] { let tmux = session.tmuxController return [ - .init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) { - requestSplit(vertical: true) - }, .init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window", enabled: tmux != nil) { tmux?.newWindow() @@ -489,12 +436,16 @@ struct TmuxWindowView: View { let root = layout.root.rect let cw = geo.size.width / CGFloat(max(1, root.width)) let ch = geo.size.height / CGFloat(max(1, root.height)) + let items = leaves(layout.root) ZStack(alignment: .topLeading) { - ForEach(leaves(layout.root), id: \.pane.raw) { item in + ForEach(items, id: \.pane.raw) { item in if let surface = window.panes[item.pane] { TmuxPaneView( surface: surface, isActive: window.activePane == item.pane, + // 单 pane:surface 帧必须与容器完全一致(其实测格子是 + // 客户端尺寸的权威,见 paneViewportChanged),不留缝。 + solo: items.count == 1, onTap: { controller.selectPane(item.pane, in: window.id) }, onReady: { controller.markPaneReady(item.pane) } ) @@ -651,6 +602,8 @@ struct PaneSplitter: View { struct TmuxPaneView: View { @ObservedObject var surface: TmuxPaneSurface let isActive: Bool + /// window 里只有这一个 pane:不留 pane 缝,surface 像素帧 == 容器帧(尺寸权威所需)。 + let solo: Bool let onTap: () -> Void let onReady: () -> Void @FocusState private var focused: Bool @@ -672,7 +625,7 @@ struct TmuxPaneView: View { Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() } } } - .padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt + .padding(solo ? 0 : TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt;单 pane 不缩 .onChange(of: isActive) { _, active in focused = active } .task { for _ in 0 ..< 300 where surface.state.surfaceSize == nil { diff --git a/apps/TerminalX/iOS/RailSidebar.swift b/apps/TerminalX/iOS/RailSidebar.swift index 5d44d46..69e2ba7 100644 --- a/apps/TerminalX/iOS/RailSidebar.swift +++ b/apps/TerminalX/iOS/RailSidebar.swift @@ -5,13 +5,13 @@ import SwiftUI // 动画模型(关键,别再拆回"轨道 + 面板"两套视图): // 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()` // —— 「收起 = 展开在左侧裁切一部分」的字面实现。 -// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 / +// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / window 徽标 / // 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细 // (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。 // 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向 // —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。 // -// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在 +// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/window 行)在 // 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape` // 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外 // `allowsHitTesting(expanded)`。 @@ -32,8 +32,6 @@ private struct LeadingHitShape: Shape { struct TerminalSidebar: View { @ObservedObject var manager: SessionManager @ObservedObject var router: AppRouter - /// 当前会话(tmux 段列它的 window)。 - @ObservedObject var session: TerminalSession @ObservedObject var tailscaleStore: TailscaleStore /// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。 let railWidth: CGFloat @@ -56,6 +54,8 @@ struct TerminalSidebar: View { .clipped() } + // 活动会话(主机连接)的切换在侧边栏;tmux window 切换已上移为终端顶部 Tab 条 + // (TmuxWindowTabGroup,用户定稿),侧边栏不再重复列 window。 private var content: some View { VStack(alignment: .leading, spacing: 0) { header @@ -65,9 +65,6 @@ struct TerminalSidebar: View { sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)") sessionRows newHostRow - tmuxDivider - tmuxLabelRow - tmuxRows Spacer(minLength: TX.Space.s) tailscaleRow footRow @@ -231,101 +228,6 @@ struct TerminalSidebar: View { .accessibilityLabel("连接新主机") } - // MARK: - tmux 段 - - /// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。 - private var tmuxDivider: some View { - ZStack(alignment: .leading) { - shortDivider - .frame(width: railWidth) - .opacity(expanded ? 0 : 1) - LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)], - startPoint: .leading, endPoint: .trailing) - .frame(height: 1) - .padding(.horizontal, TX.Space.m) - .opacity(expanded ? 1 : 0) - } - .padding(.vertical, TX.Space.s) - } - - /// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写); - /// 展开 = 完整分组标题(tmux · 主机 · 会话名)。 - private var tmuxLabelRow: some View { - ZStack(alignment: .leading) { - Text(session.tmuxSummary == nil ? "窗口" : "tmux") - .font(TX.Font.mono(9, .semibold)) - .tracking(0.9) - .foregroundStyle(TXAccent.mid) - .frame(width: railWidth) - .opacity(expanded ? 0 : 1) - TXSectionLabel(text: tmuxHeader) - .padding(.horizontal, TX.Space.m) - .opacity(expanded ? 1 : 0) - } - .frame(height: 22) - .padding(.bottom, 4) - } - - @ViewBuilder private var tmuxRows: some View { - if let tmux = session.tmuxController { - ForEach(tmux.windows) { win in - SidebarWindowRow(window: win, - isSelected: win.id == tmux.activeWindowID, - expanded: expanded, railWidth: railWidth, - hitWidth: currentWidth) { - tmux.selectWindow(win.id) - } - } - newWindowRow { tmux.newWindow() } - } else if let summary = session.tmuxSummary { - // 已知是 tmux 会话但 controller 还没 attach:按概览占位,保持两态节奏稳定。 - ForEach(0 ..< max(1, summary.windows), id: \.self) { i in - WindowRowBody( - index: "\(i)", - title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)", - subtitle: i == 0 && summary.panes > 0 - ? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane", - isSelected: i == 0, - expanded: expanded, railWidth: railWidth) - } - newWindowRow {} - } else { - Text("无 tmux · 客户端窗口") - .font(TX.Font.mono(11.5)) - .foregroundStyle(p.textWeak) - .padding(.leading, railWidth) - .frame(height: 32, alignment: .leading) - .opacity(expanded ? 1 : 0) - } - } - - /// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。 - private func newWindowRow(_ action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 0) { - Color.clear.frame(width: railWidth, height: 1) - Text("新建 window") - .font(TX.Font.mono(12)) - .foregroundStyle(p.text4) - Spacer(minLength: 0) - Text("⌘T") - .font(TX.Font.mono(10.5)) - .foregroundStyle(p.textFaint) - .padding(.trailing, TX.Space.m) - } - .frame(height: 32) - } - .buttonStyle(.txPress) - .opacity(expanded ? 1 : 0) - .allowsHitTesting(expanded) - } - - private var tmuxHeader: String { - let host = session.displayName - if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" } - return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)" - } - // MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮) /// tailnet 状态:点在图标列(两态可见),文字是明细。 @@ -391,6 +293,7 @@ enum RailSidebarStyle { } // MARK: - 行 +// (window 行已随「window 切换上移为顶部 Tab」移除,见 TerminalChrome.TmuxWindowTabGroup。) /// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。 /// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。 @@ -471,88 +374,3 @@ struct SidebarSessionRow: View { } } } - -/// window 行:图标列 26×22 编号徽标(两态同位,选中态徽标自己高亮)+ 明细(title + cwd/panes)。 -struct SidebarWindowRow: View { - @ObservedObject var window: TmuxWindow - let isSelected: Bool - let expanded: Bool - let railWidth: CGFloat - let hitWidth: CGFloat - let onTap: () -> Void - - private var subtitle: String { - var parts: [String] = [] - if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) } - let n = window.panes.count - if n > 1 { parts.append("\(n) panes") } - return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ") - } - - var body: some View { - Button(action: onTap) { - WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle, - isSelected: isSelected, expanded: expanded, railWidth: railWidth) - .contentShape(LeadingHitShape(width: hitWidth)) - } - .buttonStyle(.txPress) - } -} - -/// window 行的视觉本体(SidebarWindowRow 与占位行共用)。 -struct WindowRowBody: View { - let index: String - let title: String - let subtitle: String - let isSelected: Bool - let expanded: Bool - let railWidth: CGFloat - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } - - var body: some View { - HStack(spacing: 0) { - Text(index) - .font(TX.Font.mono(11, .semibold)) - .foregroundStyle(isSelected ? TXAccent.text : p.text4) - .frame(width: 26, height: 22) - .background(isSelected ? TXAccent.selectedBg : .clear, - in: RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) - ) - .frame(width: railWidth) - HStack(spacing: TX.Space.s) { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(TX.Font.mono(13, isSelected ? .medium : .regular)) - .foregroundStyle(isSelected ? p.text : p.text2) - .lineLimit(1) - Text(subtitle) - .font(TX.Font.mono(10.5)) - .foregroundStyle(p.textWeak) - .lineLimit(1) - } - Spacer(minLength: 0) - if isSelected { - TXStatusDot(color: p.online, size: 7) - } - } - .padding(.trailing, TX.Space.m) - .opacity(expanded ? 1 : 0) - } - .frame(height: 44) - .background( - RoundedRectangle(cornerRadius: TX.Radius.control) - .fill(TXAccent.selectedBg.opacity(0.75)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.control) - .strokeBorder(TXAccent.selectedStroke, lineWidth: 1) - ) - .padding(.horizontal, TX.Space.xs) - .opacity(isSelected && expanded ? 1 : 0) - ) - .animation(TX.Motion.quick, value: isSelected) - } -} diff --git a/apps/TerminalX/iOS/SessionManager.swift b/apps/TerminalX/iOS/SessionManager.swift index 7555e9a..fd0a904 100644 --- a/apps/TerminalX/iOS/SessionManager.swift +++ b/apps/TerminalX/iOS/SessionManager.swift @@ -166,8 +166,7 @@ final class SessionManager: ObservableObject { guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue } let s = makeSession() s.injectFixture(host: host, lines: spec.lines, link: spec.link, - mosh: host.useMosh, tmux: spec.tmux, - tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name)) + mosh: host.useMosh, tmux: spec.tmux) // 活跃时刻错开,让首页卡片排序稳定可对拍。 s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60) sessions.append(s) diff --git a/apps/TerminalX/iOS/TerminalChrome.swift b/apps/TerminalX/iOS/TerminalChrome.swift index 6ea79ee..bbc8b4c 100644 --- a/apps/TerminalX/iOS/TerminalChrome.swift +++ b/apps/TerminalX/iOS/TerminalChrome.swift @@ -1,39 +1,20 @@ import SwiftUI -// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。 +// 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在 +// 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。 // -// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。 -// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。 +// 顶带层级(一眼一个主角): +// [✕ ⌄ 独立按钮组] · 弱化标题行(纯文本,逐级降灰) · [tab 组 —— 唯一有容器感的元素] +// tab 组 = tmux window 的段式切换槽(window 映射原生 tab 的惯例);活动会话切换在侧边栏; +// 状态文字在底部动作条的合并状态行(见 TerminalActionBar.statusLine)。 -/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。 +/// 终端顶带:窗口按钮组 + 弱化标题 + tmux window tab 组。 /// -/// **扁平**:分层交给描边和材质,投影只留一点点(`TX.Elevation.floating`)。原先那道 -/// `0 10px 30px rgba(0,0,0,.55)` 在终端文字上糊出一大团脏斑 —— 胶囊底本身是半透的, -/// 投影一重就成了"漏墨"。底色相应提到 .92,少了投影仍然读得清。 -private struct FloatingCapsule: View { - @EnvironmentObject var theme: TXThemeManager - @ViewBuilder let content: Content - private var p: TXPalette { theme.palette } - - var body: some View { - content - .padding(.horizontal, TX.Space.s) - .frame(height: 34) - .background { - RoundedRectangle(cornerRadius: TX.Radius.capsule) - .fill(p.surfaceDark.opacity(0.92)) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule)) - } - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.capsule) - .strokeBorder(p.border, lineWidth: 1) - ) - .txShadow(TX.Elevation.floating) - } -} - -/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。 -struct TerminalTitleCapsule: View { +/// 与旧「标题胶囊」的三点不同(用户定稿):① ✕/⌄ **分离成独立悬浮按钮组**,不再和标题挤在 +/// 一个胶囊里 —— 危险操作(关闭)与身份信息(主机名)不该共用一个容器;② 标题**弱化**为 +/// 纯文本行(顶带在终端外,无需胶囊底衬托对比度);③ tab 组是顶带唯一带容器的元素, +/// 视觉重心让给「切 window」这个高频动作。 +struct TerminalTopBar: View { @ObservedObject var session: TerminalSession let onClose: () -> Void let onMinimize: () -> Void @@ -41,51 +22,55 @@ struct TerminalTitleCapsule: View { private var p: TXPalette { theme.palette } var body: some View { - FloatingCapsule { + HStack(spacing: TX.Space.m) { + // 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。 HStack(spacing: TX.Space.xs) { - // 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮, - // 详见 windowButton。 windowButton(icon: "xmark", tint: TXWindowDot.close, label: "关闭会话", action: onClose) - // 向下的 chevron 而不是双向内收箭头:后者在 9.5pt 下糊成一团认不出。 - // 向下同时对上设计里的「下滑最小化」手势语义。 + // 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。 windowButton(icon: "chevron.down", tint: TXWindowDot.minimize, label: "最小化到首页", action: onMinimize) - Rectangle().fill(p.border).frame(width: 1, height: 14) - .padding(.leading, 4) - .padding(.trailing, 2) - TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6) - Text(session.displayName) - .font(TX.Font.mono(12.5, .medium)) - .foregroundStyle(p.text) - .lineLimit(1) - if !contextLine.isEmpty { - Text(contextLine) - .font(TX.Font.mono(11.5)) - .foregroundStyle(p.textWeak) - .lineLimit(1) - } - // 终端格子数(设计稿 08 的「· 168×44」)。侧边栏开合会 resize —— - // 数字经 GridBox.onGridChange → session.objectWillChange 跟着刷新。 - Text(gridLabel) - .font(TX.Font.mono(11.5)) - .foregroundStyle(p.textFaint) } + titleLine + TmuxWindowTabGroup(session: session) + Spacer(minLength: 0) + } + .frame(height: 40) + } + + /// 弱化标题行:状态点 + 主机名(text2) + 「window title · cwd」(textWeak) + 格子数(textFaint)。 + /// 主机身份已由侧边栏选中行承担,这里只是就近确认,故整行不超过 text2 的强度。 + private var titleLine: some View { + HStack(spacing: TX.Space.xs) { + TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5) + Text(session.displayName) + .font(TX.Font.mono(12, .medium)) + .foregroundStyle(p.text2) + .lineLimit(1) + if !contextLine.isEmpty { + Text(contextLine) + .font(TX.Font.mono(11)) + .foregroundStyle(p.textWeak) + .lineLimit(1) + } + // 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange → + // session.objectWillChange 跟着刷新。 + Text(gridLabel) + .font(TX.Font.mono(10.5)) + .foregroundStyle(p.textFaint) } } - /// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。 + /// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。 + /// 无 tmux(没有 tab 组)时退回连接状态文案。 private var contextLine: String { guard let t = session.tmuxSummary else { return session.isLive ? "" : session.phaseText } - var parts: [String] = [] - if !t.currentTitle.isEmpty { parts.append(t.currentTitle) } - if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) } - return parts.joined(separator: " · ") + return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd) } - /// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。 + /// `/Users/yz/src/x` → `~/src/x`(终端习惯写法)。 static func abbreviate(_ path: String) -> String { let home = NSHomeDirectory() if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) } @@ -99,31 +84,29 @@ struct TerminalTitleCapsule: View { } private var gridLabel: String { + // tmux 模式读 controller 的客户端格子(raw 的 screenGrid 随 RawTerminalView 卸载而冻结, + // 侧边栏开合后会显示旧值);raw 模式仍读 surface 实报的 screenGrid。 + if let g = session.tmuxController?.clientGrid, g.cols > 0 { + return "\(g.cols)×\(g.rows)" + } let g = session.screenGrid.value return "\(g.cols)×\(g.rows)" } - /// 关闭 / 最小化按钮。 - /// - /// 原来是 macOS 交通灯式的 13pt 纯色圆点,三处不合适:① 纯色块**不说明自己是干什么的** —— - /// mac 上靠 hover 才浮出字形,触屏没有 hover,于是永远只是两个色点;② 13pt 命中目标在 - /// 触屏上太小,而"关闭"误触代价很高;③ 圆点是 mac 窗口的语汇,与本 app 通篇的圆角矩形 - /// 控件语汇不搭。 - /// - /// 现在:22pt 圆角方块 + **常显字形**(`✕` 关闭 / `⌄` 收下去 = 缩回首页卡片),颜色语义保留 - /// (红=关闭、琥珀=最小化),底色只上 14% 不铺满 —— 与徽标/控件同一套形状语言,命中 26×34。 + /// 关闭 / 最小化按钮:28pt 圆角方块 + **常显字形**(触屏没有 hover,纯色点说明不了自己), + /// 颜色语义保留(红=关闭、琥珀=最小化),底色只上 14% —— 与全 app 的圆角矩形控件同语汇。 private func windowButton(icon: String, tint: Color, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) - .font(.system(size: 9.5, weight: .bold)) + .font(.system(size: 10.5, weight: .bold)) .foregroundStyle(tint) - .frame(width: 22, height: 22) - .background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7)) + .frame(width: 28, height: 28) + .background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 8)) .overlay( - RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1) + RoundedRectangle(cornerRadius: 8).strokeBorder(tint.opacity(0.45), lineWidth: 1) ) - .frame(width: 26, height: 34) + .frame(width: 30, height: 40) .contentShape(Rectangle()) } .buttonStyle(.txPressTight) @@ -131,84 +114,123 @@ struct TerminalTitleCapsule: View { } } -/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。 +/// tmux window 的 **tab 组**:一条 surfaceDark 圆角槽,window 是槽内的段式 chip。 /// -/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格 -/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。 -struct TerminalStatusCapsule: View { +/// 「组」的语义:这些 tab 同属当前 tmux 会话 —— 容器即分组,不再各自悬浮。选中 chip 用 +/// accent 底+描边浮起,未选 chip 透明贴槽底;尾部 `+` 新建 window(浏览器 tab 组惯例)。 +/// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。 +struct TmuxWindowTabGroup: View { @ObservedObject var session: TerminalSession + + var body: some View { + if let tmux = session.tmuxController { + WindowTabGroupRow(controller: tmux) + } else if let summary = session.tmuxSummary { + TabGroupSlot { + ForEach(0 ..< max(1, summary.windows), id: \.self) { i in + WindowTabChipBody( + index: "\(i)", + title: i == 0 && !summary.currentTitle.isEmpty + ? summary.currentTitle : "window \(i)", + isSelected: i == 0) + } + } + } + } +} + +/// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。 +private struct WindowTabGroupRow: View { + @ObservedObject var controller: TmuxController @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { - FloatingCapsule { - HStack(spacing: 0) { - cell(egressText, color: egressColor, dot: true) - separator - cell(transportText, color: transportColor, dot: false) - separator - cell(tmuxText, color: p.text2, dot: false) + TabGroupSlot { + ForEach(controller.windows) { win in + WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) { + controller.selectWindow(win.id) + } } + // 组内 `+`:新建 window 的就近入口(底部动作条的大按钮仍在)。 + Button { controller.newWindow() } label: { + Image(systemName: "plus") + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(p.text4) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.txPressTight) + .accessibilityLabel("新建 window") } } +} - private var separator: some View { - Rectangle().fill(p.border).frame(width: 1, height: 16) - .padding(.horizontal, TX.Space.s) +/// tab 组的槽体:内衬 3pt,chip 挤在同一条容器里 —— 「同组」的视觉本体。条目多时横向滚动。 +private struct TabGroupSlot: View { + @ViewBuilder let content: Content + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { + // 槽体(底+描边)挂在**内容层**而不是 ScrollView 上:ScrollView 恒占满剩余宽度, + // 挂它身上就是一条拖到屏幕边的空槽 —— 槽必须紧贴 chip 才读作「一组」。 + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { content } + .padding(3) + .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.capsule)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.capsule) + .strokeBorder(p.border, lineWidth: 1) + ) + } + .frame(height: 36) } +} - private func cell(_ text: String, color: Color, dot: Bool) -> some View { +/// 单个 window chip(title/index 是 window 自己的 @Published,须直接订阅才会刷新)。 +private struct WindowTabChip: View { + @ObservedObject var window: TmuxWindow + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected) + } + .buttonStyle(.txPressTight) + .accessibilityLabel("切换到 window \(window.index)") + } +} + +/// chip 的视觉本体(真实 window 与占位共用):`编号 标题`,选中浮起、未选贴槽底。 +private struct WindowTabChipBody: View { + let index: String + let title: String + let isSelected: Bool + @EnvironmentObject var theme: TXThemeManager + private var p: TXPalette { theme.palette } + + var body: some View { HStack(spacing: 5) { - if dot { TXStatusDot(color: color, size: 6) } - Text(text) - .font(TX.Font.mono(12, .medium)) - .foregroundStyle(color) + Text(index) + .font(TX.Font.mono(10.5, .semibold)) + .foregroundStyle(isSelected ? TXAccent.text : p.textFaint) + Text(title) + .font(TX.Font.mono(12, isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? p.text : p.text3) .lineLimit(1) } - } - - // 第一格:网络可达性(tsnet 出口 / 直连) - private var egressText: String { - switch session.link { - case .relay: "tsnet" - case .direct: "direct" - case .connecting: "连接中" - case .reconnecting: "tsnet" - case .offline: "离线" - } - } - - private var egressColor: Color { - switch session.link { - case .direct, .relay: p.online - case .connecting: p.text4 - case .reconnecting: p.reconnecting - case .offline: p.offline - } - } - - // 第二格:传输层(mosh / SSH) - private var transportText: String { - if case .reconnecting(let n) = session.link { - return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)" - } - return session.moshEngaged ? "mosh" : "ssh" - } - - private var transportColor: Color { - if case .reconnecting = session.link { return p.reconnecting } - // 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。 - return p.text2 - } - - // 第三格:会话复用层 - private var tmuxText: String { - if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" } - // 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。 - if session.pendingSessionChoice != nil { return "选择 tmux 会话…" } - if session.tmuxSkipped { return "原生窗口 · 不经 tmux" } - if session.tmuxUnavailable { return "无 tmux · 客户端窗口" } - return session.isLive ? "检测 tmux…" : "—" + .padding(.horizontal, TX.Space.s) + .frame(height: 28) + .background(isSelected ? TXAccent.selectedBg : .clear, + in: RoundedRectangle(cornerRadius: TX.Radius.capsule - 3)) + .overlay( + RoundedRectangle(cornerRadius: TX.Radius.capsule - 3) + .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .animation(TX.Motion.quick, value: isSelected) } } @@ -402,17 +424,22 @@ struct TmuxInstallSheet: View { } } -/// 舞台底部动作条。 +/// 舞台底部动作条 + **左下状态行**。 /// -/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「竖分屏 / 新 window / 回首页」 -/// 只写成 `⌘D` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作 +/// 左下是合并后的**唯一**状态出口(原右上状态胶囊的三格分层徽标 + 原左下上下文文字, +/// 二者信息重复,用户裁决合并到这里):`[dot 链路] | 传输 | 会话概览`。 +/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀/红;重连中 → 第二格转琥珀; +/// tmux 缺失 → 第三格写「无 tmux · 客户端窗口」而非报错。主机名不再重复出现 +/// (标题胶囊与当前 tab 已有)。 +/// +/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「新 window / 回首页」 +/// 只写成 `⌘T` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作 /// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。 /// /// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮 /// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。) struct TerminalActionBar: View { - /// 左侧上下文说明(会话/链路状态一句话)。 - let context: String + @ObservedObject var session: TerminalSession let actions: [Action] let safeBottom: CGFloat @EnvironmentObject var theme: TXThemeManager @@ -423,20 +450,17 @@ struct TerminalActionBar: View { let id: String let icon: String let title: String - /// 不可用时灰显但仍在(如非 tmux 会话不能分屏)——不隐藏,免得布局跳。 + /// 不可用时灰显但仍在(如原生窗口不能新建 window)——不隐藏,免得布局跳。 var enabled: Bool = true let run: () -> Void } - /// 窄屏只留前两个:四个在 iPhone 上放不下,会折行把 34pt 的带撑破。 + /// 窄屏只留前两个:多了在 iPhone 上放不下,会折行把 34pt 的带撑破。 private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions } var body: some View { HStack(spacing: TX.Space.s) { - Text(context) - .font(TX.Font.mono(11.5)) - .foregroundStyle(p.textWeak) - .lineLimit(1) + statusLine Spacer(minLength: TX.Space.s) ForEach(shown) { action in Button(action: action.run) { @@ -465,6 +489,80 @@ struct TerminalActionBar: View { } .padding(.horizontal, TX.Space.m) .frame(height: TX.Layout.footBand(safeBottom)) - .animation(TX.Motion.standard, value: context) + .animation(TX.Motion.standard, value: contextText) + } + + // MARK: - 状态行(原右上状态胶囊与旧左下文字的合并体) + + private var statusLine: some View { + HStack(spacing: 0) { + cell(egressText, color: egressColor, dot: true) + separator + cell(transportText, color: transportColor, dot: false) + separator + cell(contextText, color: p.text3, dot: false) + } + } + + private var separator: some View { + Rectangle().fill(p.border).frame(width: 1, height: 12) + .padding(.horizontal, TX.Space.xs) + } + + private func cell(_ text: String, color: Color, dot: Bool) -> some View { + HStack(spacing: 5) { + if dot { TXStatusDot(color: color, size: 6) } + Text(text) + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(color) + .lineLimit(1) + } + } + + // 第一格:网络可达性(tsnet 出口 / 直连) + private var egressText: String { + switch session.link { + case .relay: "tsnet" + case .direct: "direct" + case .connecting: "连接中" + case .reconnecting: "tsnet" + case .offline: "离线" + } + } + + private var egressColor: Color { + switch session.link { + case .direct, .relay: p.online + case .connecting: p.text4 + case .reconnecting: p.reconnecting + case .offline: p.offline + } + } + + // 第二格:传输层(mosh / SSH) + private var transportText: String { + if case .reconnecting(let n) = session.link { + return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)" + } + return session.moshEngaged ? "mosh" : "ssh" + } + + private var transportColor: Color { + if case .reconnecting = session.link { return p.reconnecting } + // 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。 + return p.text3 + } + + // 第三格:会话复用层概览(旧左下「会话状态一句话」并入这里,windows 数保留)。 + private var contextText: String { + if let t = session.tmuxSummary { + let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)" + return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")" + } + // 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。 + if session.pendingSessionChoice != nil { return "选择 tmux 会话…" } + if session.tmuxSkipped { return "原生窗口 · 不经 tmux" } + if session.tmuxUnavailable { return "无 tmux · 客户端窗口" } + return session.isLive ? "检测 tmux…" : session.phaseText } } diff --git a/apps/TerminalX/iOS/TerminalDialogs.swift b/apps/TerminalX/iOS/TerminalDialogs.swift index 506b1ca..3989aa6 100644 --- a/apps/TerminalX/iOS/TerminalDialogs.swift +++ b/apps/TerminalX/iOS/TerminalDialogs.swift @@ -1,6 +1,6 @@ import SwiftUI -// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。 +// 设计稿 `3c 关闭确认`。(`3b 新建分屏菜单` 已随「放弃移动端分屏」移除。) // // 语义分工(务必别混): // - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。 @@ -137,148 +137,3 @@ struct CloseConfirmDialog: View { .buttonStyle(.txPress) } } - -// MARK: - 新建分屏菜单(3b) - -/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」: -/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。 -/// -/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。 -struct SplitMenuView: View { - @ObservedObject var session: TerminalSession - /// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D) - let vertical: Bool - let onPick: (SplitTarget) -> Void - let onCancel: () -> Void - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } - - enum SplitTarget { - case currentSession - case otherSession(String) - case newSession - /// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。 - case nativeWindow - } - - /// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端 - /// attach 着其它会话时会把它们错误地排除掉。) - private var detached: [TmuxSessionInfo] { - let current = session.tmuxSummary?.name ?? "" - return session.tmuxSessionList.filter { $0.name != current } - } - - var body: some View { - TXModalScrim(onDismiss: onCancel) { - VStack(alignment: .leading, spacing: 0) { - header - Rectangle().fill(p.divider).frame(height: 1) - if session.tmuxSummary != nil { - TXSectionLabel(text: "tmux 会话") - .padding(.horizontal, TX.Space.m) - .padding(.top, TX.Space.s) - .padding(.bottom, 2) - row(title: currentName, detail: currentDetail, dot: p.online, - tag: "当前", selected: true) { onPick(.currentSession) } - ForEach(detached) { s in - // detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。 - row(title: s.name, - detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached", - dot: p.online, - tag: s.lastActivity.map { SessionCard.relative($0) }) { - onPick(.otherSession(s.name)) - } - } - row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) { - onPick(.newSession) - } - Rectangle().fill(p.divider).frame(height: 1) - .padding(.vertical, TX.Space.xs) - } - // 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。 - row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)", - dot: nil, tag: nil, disabled: true) {} - } - .padding(.bottom, TX.Space.xs) - .frame(width: 360) - .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.dialog - 1) - .strokeBorder(p.borderStrong, lineWidth: 1) - ) - .txShadow(TX.Elevation.dialog) - } - } - - private var header: some View { - HStack { - Text(vertical ? "竖分屏到…" : "横分屏到…") - .font(TX.Font.mono(13, .medium)) - .foregroundStyle(p.text) - Spacer() - Text(session.displayName) - .font(TX.Font.mono(11)) - .foregroundStyle(TXAccent.mid) - } - .padding(.horizontal, TX.Space.m) - .frame(height: 44) - } - - private var currentName: String { - guard let t = session.tmuxSummary else { return session.displayName } - return t.name.isEmpty ? "当前会话" : t.name - } - - private var currentDetail: String { - guard let t = session.tmuxSummary else { return "" } - var parts: [String] = [] - if !t.currentTitle.isEmpty { parts.append(t.currentTitle) } - parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")") - if t.panes > 0 { parts.append("\(t.panes) panes") } - return parts.joined(separator: " · ") - } - - /// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。 - private func row(title: String, detail: String?, dot: Color?, tag: String?, - selected: Bool = false, disabled: Bool = false, - action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: TX.Space.s) { - ZStack { - if let dot { TXStatusDot(color: dot, size: 7) } - } - .frame(width: 20) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(TX.Font.mono(13, .medium)) - .foregroundStyle(disabled ? p.textWeak : p.text) - if let detail, !detail.isEmpty { - Text(detail) - .font(TX.Font.mono(10.5)) - .foregroundStyle(p.textWeak) - .lineLimit(1) - } - } - Spacer(minLength: TX.Space.xs) - if let tag { - Text(tag) - .font(TX.Font.mono(10.5, .medium)) - .foregroundStyle(selected ? TXAccent.text : TXAccent.mid) - } - } - .padding(.horizontal, TX.Space.s) - .frame(height: 40) - .frame(maxWidth: .infinity, alignment: .leading) - .background(selected ? TXAccent.selectedBg : .clear, - in: RoundedRectangle(cornerRadius: TX.Radius.control)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.control) - .strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1) - ) - .padding(.horizontal, TX.Space.xs) - .contentShape(Rectangle()) - } - .buttonStyle(.txPress) - .disabled(disabled) - } -} diff --git a/apps/TerminalX/iOS/TerminalSession.swift b/apps/TerminalX/iOS/TerminalSession.swift index 05837ca..3c52064 100644 --- a/apps/TerminalX/iOS/TerminalSession.swift +++ b/apps/TerminalX/iOS/TerminalSession.swift @@ -225,14 +225,6 @@ final class TerminalSession: ObservableObject, Identifiable { /// 假会话注入的 tmux 概览(真实会话走 `tmuxSummary` 的派生分支)。 private var injectedTmuxSummary: TmuxSummary? - /// 假会话注入的 tmux 会话列表(分屏菜单的 detached 行;真实会话走 controller.sessions)。 - private(set) var injectedTmuxSessions: [TmuxSessionInfo] = [] - - /// 分屏菜单的候选会话:真实会话用 controller 的 list-sessions 结果,假会话用注入值。 - var tmuxSessionList: [TmuxSessionInfo] { - if let c = tmuxController, !c.sessions.isEmpty { return c.sessions } - return injectedTmuxSessions - } /// tmux 概览:假会话用注入值,真实会话从 controller 现算。 var tmuxSummary: TmuxSummary? { @@ -579,10 +571,9 @@ final class TerminalSession: ObservableObject, Identifiable { /// 只为让首页会话卡/轨道头像/状态徽标有内容可截图对拍(见 UIPreviewFixture)。 /// 走 `gate` 而非 `session.receive`,故 surface 未 attach 时会先缓冲、就绪后 flush。 func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool, - tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) { + tmux: TmuxSummary? = nil) { isSyntheticFixture = true injectedTmuxSummary = tmux - injectedTmuxSessions = tmuxSessions self.host = host connectionTitle = host.name isLive = true diff --git a/apps/TerminalX/iOS/Theme.swift b/apps/TerminalX/iOS/Theme.swift index 1fece8b..5dcd59e 100644 --- a/apps/TerminalX/iOS/Theme.swift +++ b/apps/TerminalX/iOS/Theme.swift @@ -293,8 +293,6 @@ enum TX { static let paneDragHit: CGFloat = 12 /// 触控命中区下限 static let minTouch: CGFloat = 44 - /// 终端主 pane 顶部给浮起胶囊预留 - static let capsuleInset: CGFloat = 86 /// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条 /// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区 /// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。 diff --git a/apps/TerminalX/iOS/TmuxController.swift b/apps/TerminalX/iOS/TmuxController.swift index a1ec044..182fac2 100644 --- a/apps/TerminalX/iOS/TmuxController.swift +++ b/apps/TerminalX/iOS/TmuxController.swift @@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable { init(id: TmuxPaneID, cols: Int, rows: Int, onInput: @escaping @Sendable (Data) -> Void, - onMetrics: @escaping @Sendable (Int, Int) -> Void) { + onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) { self.id = id self.grid = (cols, rows) let session = InMemoryTerminalSession( write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self - // tmux 模式:surface resize 只回报 cell 像素供换算,绝不反向驱动 tmux(防反馈环)。 - resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) } + // tmux 模式:surface resize 上报**真实格子 + cell 像素**(cols/rows/cellW/cellH)。 + // controller 只在「单 pane window」时把它当客户端尺寸的权威(见 paneViewportChanged), + // 多 pane 下仅刷新 cell 缓存 —— 依旧不构成反馈环。 + resize: { vp in onViewport(Int(vp.columns), Int(vp.rows), + Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) } ) let state = TerminalViewState() state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) @@ -54,16 +57,6 @@ final class TmuxWindow: ObservableObject, Identifiable { } } -/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。 -struct TmuxSessionInfo: Identifiable, Equatable { - var id: String { name } - let name: String - let windows: Int - /// 是否已被某客户端 attach(当前会话即 attached 的那个)。 - let isAttached: Bool - let lastActivity: Date? -} - /// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。 /// 尺寸走"路线 b":app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 /// layout rect(%output 按 tmux pane 宽高排版,二者一致才不换行/清屏错乱)。 @@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable { final class TmuxController: ObservableObject { @Published private(set) var windows: [TmuxWindow] = [] @Published var activeWindowID: TmuxWindowID? - /// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。 - @Published private(set) var sessions: [TmuxSessionInfo] = [] private let parser = TmuxControlParser() private var windowByID: [TmuxWindowID: TmuxWindow] = [:] @@ -82,6 +73,8 @@ final class TmuxController: ObservableObject { var onExit: (() -> Void)? private var clientCols = 80, clientRows = 24 + /// 客户端格子对外快照(顶栏胶囊显示用 —— tmux 模式下 raw 的 screenGrid 已随 RawTerminalView 卸载冻结)。 + @Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0) private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子) private var lastSentCols = 0, lastSentRows = 0 private var resizeDebounce: Task? @@ -89,12 +82,11 @@ final class TmuxController: ObservableObject { // control-mode 命令应答 FIFO 匹配。 private var attachAcked = false private enum PendingKind: CustomStringConvertible { - case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID) + case ignore, listWindows, currentSession, capturePane(TmuxPaneID) var description: String { switch self { case .ignore: "ignore" case .listWindows: "listWindows" - case .listSessions: "listSessions" case .currentSession: "currentSession" case .capturePane(let p): "capturePane(%\(p.raw))" } @@ -122,8 +114,9 @@ final class TmuxController: ObservableObject { private func kickoffIfReady() { guard attachAcked, containerKnown, !kickoffDone else { return } kickoffDone = true + lastSentCols = clientCols; lastSentRows = clientRows // 后续实测同值时不再重发 NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)") - sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子 + sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface 实测格子(非像素换算) pending.append(.ignore) let sep = Self.fieldSep sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"") @@ -132,30 +125,64 @@ final class TmuxController: ObservableObject { // 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。 sendCommand("display-message -p \"#{session_name}\"") pending.append(.currentSession) - listSessions() // 分屏菜单要列全部会话 } /// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。 - func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) } + func setClientSize(cols: Int, rows: Int) { + clientCols = max(1, cols); clientRows = max(1, rows) + clientGrid = (clientCols, clientRows) + } /// cell 设备像素(来自 raw 终端或 pane surface metrics)。 func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } } - /// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。 - func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) } + + /// pane surface 的真实格子上报 —— **客户端尺寸的权威来源**。 + /// + /// 像素除法(containerResized)复刻不了 ghostty 内部的取整/内边距:真机与模拟器实测 + /// 「容器 px ÷ 整数 cell px」比 surface 真实格子**偏大 1 行/列**(如算得 164×51、真实 163×50)。 + /// 偏大意味着远端按更宽的行排版、surface 按真实宽度自动换行 → 每条满宽行掉字符, + /// 全屏 TUI(Claude Code 等)重绘几轮就积累成行首碎片(真机花屏根因)。 + /// 单 pane window 的 pane 帧恒等于容器帧,其 ghostty 实测格子就是客户端应有的格子 —— + /// 用它发 refresh-client。帧尺寸只由容器决定、不受 tmux 布局影响,故没有反馈环。 + func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) { + setCellPixels(w: cellW, h: cellH) + guard kickoffDone, cols > 0, rows > 0 else { return } + // 多 pane window 的 pane 帧只是容器的比例切片,格子代表不了客户端整体 → 只刷 cell 缓存。 + guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return } + guard cols != lastSentCols || rows != lastSentRows else { return } + lastSentCols = cols; lastSentRows = rows + clientCols = cols; clientRows = rows + clientGrid = (cols, rows) + resizeDebounce?.cancel() + resizeDebounce = Task { [weak self] in + try? await Task.sleep(nanoseconds: 120_000_000) // 合并侧边栏动画期间的连续变化 + guard !Task.isCancelled, let self else { return } + NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)") + self.sendCommand("refresh-client -C \(cols)x\(rows)") + } + } /// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。 - /// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。 + /// 尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",**仅作兜底**:有单 pane window 在场时 + /// 让位给 paneViewportChanged 的实测格子(像素除法会偏大 1 行/列,见其注释)。 func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) { guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return } + containerKnown = true + if !kickoffDone { + // kickoff 初值不用像素换算覆盖 —— setClientSize 已带 raw surface 的真实格子 + // (attach 前 RawTerminalView 与 TmuxStage 同槽同尺寸,ghostty 实测值可直接沿用)。 + NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)") + kickoffIfReady() + return + } + if windows.contains(where: { $0.panes.count == 1 }) { return } // 实测路径接管 let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx))) let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx))) guard cols != lastSentCols || rows != lastSentRows else { return } lastSentCols = cols; lastSentRows = rows clientCols = cols; clientRows = rows + clientGrid = (cols, rows) NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)") - // 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。 - containerKnown = true - if !kickoffDone { kickoffIfReady(); return } resizeDebounce?.cancel() resizeDebounce = Task { [weak self] in try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化 @@ -256,7 +283,9 @@ final class TmuxController: ObservableObject { let surface = TmuxPaneSurface( id: p, cols: cols, rows: rows, onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) }, - onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } } + onViewport: { [weak self] c, r, cw, ch in + Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) } + } ) surfaceByPane[p] = surface win.panes[p] = surface @@ -293,22 +322,6 @@ final class TmuxController: ObservableObject { if parts[2] == "1" { activeWindowID = w } applyLayout(window: w, full: String(parts[3]), visible: String(parts[4])) } - case .listSessions: - sessions = r.lines.compactMap { line in - let f = line.components(separatedBy: Self.fieldSep) - guard f.count >= 3 else { return nil } - let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil - return TmuxSessionInfo(name: f[0], - windows: Int(f[1]) ?? 1, - isAttached: f[2] == "1", - lastActivity: activity) - } - NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })") - case .currentSession: - if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { - currentSessionName = name - NSLog("TMUXDBG currentSession=\(name)") - } case .currentSession: if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { currentSessionName = name @@ -379,7 +392,10 @@ final class TmuxController: ObservableObject { func newWindow() { sendCommand("new-window") } func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") } - // MARK: - 关闭 / 分屏 / resize(UI-4) + // MARK: - 关闭 / resize(UI-4) + + // 注:app 内主动分屏(split-window / join-pane)已随「放弃移动端分屏」整体移除; + // 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。 /// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。 /// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。 @@ -388,24 +404,6 @@ final class TmuxController: ObservableObject { /// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。 func killSession() { sendCommand("kill-session") } - /// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。 - /// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface - /// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。 - func splitWindow(vertical: Bool, joinSession: String? = nil) { - if let s = joinSession { - // 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。 - sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):") - } else { - sendCommand("split-window \(vertical ? "-h" : "-v")") - } - } - - /// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。 - func splitIntoNewSession(vertical: Bool, name: String) { - sendCommand("new-session -d -s \(name)") - sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):") - } - /// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。 /// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。 func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) { @@ -413,13 +411,6 @@ final class TmuxController: ObservableObject { if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") } } - /// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。 - func listSessions() { - let sep = Self.fieldSep - sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"") - pending.append(.listSessions) - } - /// 某 pane 的 surface 就绪 → 放行其缓冲输出。 func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() } diff --git a/apps/TerminalX/iOS/UIPreviewFixture.swift b/apps/TerminalX/iOS/UIPreviewFixture.swift index 097d113..97c5a78 100644 --- a/apps/TerminalX/iOS/UIPreviewFixture.swift +++ b/apps/TerminalX/iOS/UIPreviewFixture.swift @@ -150,18 +150,6 @@ enum UIPreviewFixture { ]), ] - /// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。 - static func tmuxSessions(current: String) -> [TmuxSessionInfo] { - // 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。 - [ - TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil), - TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false, - lastActivity: Date(timeIntervalSinceNow: -3 * 86400)), - TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false, - lastActivity: Date(timeIntervalSinceNow: -86400)), - ] - } - /// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。 static func hostFromLaunchArgs() -> SavedHost? { let d = UserDefaults.standard @@ -186,8 +174,7 @@ enum UIPreviewFixture { /// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。 /// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。 /// - /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h` - /// · `split-now/v|h`(直接分屏) · `resize/`(resize-pane) + /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `resize/`(resize-pane) /// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器) /// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin` @MainActor @@ -204,16 +191,8 @@ enum UIPreviewFixture { case "close-now": // 跳过确认直接关(回归脚本用)。 if let id = router.route.sessionID { router.goHome(); manager.close(id) } - case "split": - guard router.route.sessionID != nil else { return false } - router.present(.splitMenu(vertical: path.dropFirst().first != "h")) case "dismiss": router.dismissModal() - case "split-now": - // 跳过菜单直接对当前会话分屏(验证 split-window 链路)。 - guard let id = router.route.sessionID, let s = manager.session(id), - let tmux = s.tmuxController else { return false } - tmux.splitWindow(vertical: path.dropFirst().first != "h") case "pick": // 会话选择器:attach 指定会话(`ui/pick/`)。此时通常还在首页,故优先取待选择的会话。 guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session), diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3b16e27..2555a2f 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -162,7 +162,7 @@ | UI-1 | 设计令牌地基(Nocturne chrome + blurple + JetBrains Mono) | ✅ 完成 | | UI-2 | 导航与多会话状态模型(AppRouter/SessionManager/SessionCanvas) | ✅ 完成 | | UI-3 | 终端主界面:56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | ✅ 完成(pane 缝拖拽待做) | -| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(pane 拖拽待真机 tmux 验证) | +| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(分屏功能后被用户裁决整体移除,见 12.3k) | | UI-5 | 设置·外观 + 键盘体系(GCKeyboard 检测 + 全套 ⌘ 快捷键) | 待做 | | UI-6 | 竖屏参数化 + iPhone 首版 | 待做 | @@ -359,6 +359,38 @@ - **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox` 加 `onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。 - 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pt(easeOut 先快后慢),无硬切;收起态 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 入口:底部动作条「竖分屏」按钮、分屏菜单 `SplitMenuView`(12.3d 的 3b 卡片整个删除)、`AppModal.splitMenu`。 +- 逻辑:`TmuxController.splitWindow/splitIntoNewSession`、`list-sessions` 管线(`TmuxSessionInfo`/`sessions`/`listSessions()`/`PendingKind.listSessions`——只服务分屏菜单)、`TerminalSession.tmuxSessionList`/`injectedTmuxSessions`、fixture 的 `tmuxSessions()`、无头 URL `ui/split`/`ui/split-now`。 +- **保留**:多 pane 的**被动渲染**与 `PaneSplitter` 拖拽/`resizePane`(别的客户端建好的分屏 attach 进来仍须按 layout 正确显示)、`ui/resize/`。 +- 顺带修掉 `TmuxController` 响应 switch 里重复的 `case .currentSession`(死代码)。 + +**2. tmux window Tab(`TmuxWindowTabsBar`,TerminalChrome.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`+`FloatingCapsule`):`stage` 从 ZStack overlay 改回 VStack 首行,终端 pane 从带下方开始 —— 86pt 预留带(`TX.Layout.capsuleInset`,已删令牌)与「胶囊遮内容」问题一并消失。pane 顶边现在朝 chrome:topLeading 收圆角、顶描边恢复(不再 `top:-1` 推出屏外);右边仍贴屏幕物理边缘不收角。pane 内容留 `TX.Space.s` 呼吸位。 +- **顶带层级(一眼一个主角)**:`[✕ ⌄ 独立按钮组] · 弱化标题 · [tab 组]`。按钮升到 28pt 圆角方块自成一簇(危险操作与身份信息不共容器);标题降为纯文本行(状态点 5pt + 主机名 mono12 text2 + cwd textWeak + 格子数 textFaint),**当前 window title 从标题里删掉** —— 它已由 tab 组选中 chip 承担,写两遍是重复;tab 组是顶带唯一带容器的元素。 +- **tab 组(`TmuxWindowTabGroup`)**:surfaceDark 圆角槽(radius 11)内衬 3pt,window 是槽内段式 chip(选中 = selectedBg+描边浮起,未选 = 透明贴槽底),尾部 `+` 新建 window。**坑:槽体背景必须挂在 ScrollView 的内容层** —— 挂 ScrollView 自身就是一条拖到屏幕右边的空槽(ScrollView 恒占满剩余宽度)。占位态(controller 未 attach 但有 tmuxSummary)共用 chip 本体;原生窗口不渲染 tab 组。 +- 验证:fixture 截图 ×3(tmux 态顶带 / 原生窗口态无 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.clientGrid`(`screenGrid` 随 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 ` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。