feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)

按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从
「从不自动进」修成「连接前探测 → 让用户选会话」。

设计令牌与导航地基
- Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端)
- vendor JetBrains Mono 四权重(附 OFL 许可)
- AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦
- SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提)

按设计稿落地的屏
- 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次)
- 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips
- 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条
- 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭

tmux 真实链路(真机查出并修掉 4 个 bug)
- 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端)
- format 分隔符 tab 经 PTY 变成下划线 → 改用 |:|
- controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新)
- 当前会话名不能靠 session_attached 反推 → 改用 display-message

传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+)
无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法
TXCore 45 tests 绿(新增 5 个会话探测单测)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-25 22:59:35 +08:00
parent 6dd23275ff
commit 28e9cfc207
46 changed files with 10237 additions and 1204 deletions

View File

@@ -2,86 +2,210 @@ import SwiftUI
import GhosttyTerminal
import GhosttyTheme
/// **app UI** ****`ghosttyName` GhosttyTheme
/// app = `static let`
struct TXPalette: Equatable, Identifiable {
// MARK: - docs/design/UI-DESIGN-HANDOFF.md
//
//
// TXAccent Nocturne blurple****线
// TXChrome app chrome Catppuccin flavor 4
// TXFlavor Catppuccin flavor ****libghostty +
// TXPalette palette
/// Nocturne blurple
enum TXAccent {
/// `--color-accent`线
static let base = Color(hex: 0x9184D9)
/// accent-600
static let border = Color(hex: 0x796CBF)
/// accent-900
static let selectedBg = Color(hex: 0x2B2741)
/// accent-800 inset stroke 1pt
static let selectedStroke = Color(hex: 0x423A6A)
/// accent-400
static let text = Color(hex: 0xB5ABFC)
/// accent-300 hover/
static let textBright = Color(hex: 0xD2CEFD)
/// accent-500
static let mid = Color(hex: 0x968AE0)
/// accent-700 inset
static let dim = Color(hex: 0x5D5294)
}
/// macOS 沿/
enum TXWindowDot {
static let close = Color(hex: 0xFF5F57)
static let minimize = Color(hex: 0xFEBC2E)
}
/// app chrome **** TXAccent
struct TXChrome: Equatable {
/// / pane
let bgDeep: UInt32
/// /
let bg: UInt32
/// / /
let surfaceDark: UInt32
///
let surface: UInt32
/// 线
let divider: UInt32
///
let border: UInt32
/// /
let borderStrong: UInt32
/// /
let borderDialog: UInt32
///
let text: UInt32
/// neutral-300
let text2: UInt32
/// neutral-400
let text3: UInt32
/// neutral-500
let text4: UInt32
/// / neutral-600
let textWeak: UInt32
/// neutral-700
let textFaint: UInt32
}
extension TXChrome {
/// Mocha = **** flavor HSL docs/design/render-screens.py
static let mocha = TXChrome(
bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532,
divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E,
text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB,
textWeak: 0x75798C, textFaint: 0x4A4D5E)
/// Macchiato flavor base Mocha /
static let macchiato = TXChrome(
bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E,
divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869,
text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0,
textWeak: 0x788091, textFaint: 0x515869)
static let frappe = TXChrome(
bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49,
divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574,
text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2,
textWeak: 0x818793, textFaint: 0x5E6574)
/// Latte flavor""
static let latte = TXChrome(
bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3,
divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA,
text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994,
textWeak: 0x8E92A6, textFaint: 0xB0B4C4)
}
/// Catppuccin flavor +
struct TXFlavor: Equatable {
let id: String
let name: String
let isDark: Bool
/// GhosttyTheme app Catppuccin flavor
/// GhosttyTheme
let ghosttyName: String
let bg: Color
let surface: Color
let surfaceElevated: Color
let accent: Color
let accentSoft: Color
let textPrimary: Color
let textSecondary: Color
let textTertiary: Color
let border: Color
let borderStrong: Color
let running: Color
let mosh: Color
let warning: Color
let offline: Color
// Catppuccin
let green: UInt32 // 线
let red: UInt32 // error
let yellow: UInt32 // warninggit
let blue: UInt32 //
let teal: UInt32 // mosh
let peach: UInt32 //
let overlay: UInt32 // 线
/// Catppuccin base
/// libghostty pane
let terminalBg: UInt32
}
// MARK: - Palette
/// **app chrome** ****`ghosttyName` GhosttyTheme
/// Nocturne blurple = `static let`
struct TXPalette: Equatable, Identifiable {
let flavor: TXFlavor
let chrome: TXChrome
var id: String { flavor.id }
var name: String { flavor.name }
var isDark: Bool { flavor.isDark }
var ghosttyName: String { flavor.ghosttyName }
/// GhosttyTheme TerminalTheme退
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: ghosttyName)?.toTerminalTheme() ?? .default
GhosttyThemeCatalog.theme(named: flavor.ghosttyName)?.toTerminalTheme() ?? .default
}
// MARK: chrome token
var bgDeep: Color { Color(hex: chrome.bgDeep) }
var bg: Color { Color(hex: chrome.bg) }
var surfaceDark: Color { Color(hex: chrome.surfaceDark) }
var surface: Color { Color(hex: chrome.surface) }
var divider: Color { Color(hex: chrome.divider) }
var border: Color { Color(hex: chrome.border) }
var borderStrong: Color { Color(hex: chrome.borderStrong) }
var borderDialog: Color { Color(hex: chrome.borderDialog) }
var text: Color { Color(hex: chrome.text) }
var text2: Color { Color(hex: chrome.text2) }
var text3: Color { Color(hex: chrome.text3) }
var text4: Color { Color(hex: chrome.text4) }
var textWeak: Color { Color(hex: chrome.textWeak) }
var textFaint: Color { Color(hex: chrome.textFaint) }
// MARK: flavor
/// 线 / /
var online: Color { Color(hex: flavor.green) }
/// error
var danger: Color { Color(hex: flavor.red) }
///
var reconnecting: Color { Color(hex: flavor.peach) }
var linkGood: Color { Color(hex: flavor.green) }
var linkPath: Color { Color(hex: flavor.blue) }
/// /
var terminalBackground: Color { Color(hex: flavor.terminalBg) }
// MARK:
// 稿 chrome/TXAccent
var surfaceElevated: Color { divider }
var accent: Color { TXAccent.base }
var accentSoft: Color { TXAccent.selectedBg }
var textPrimary: Color { text }
var textSecondary: Color { text3 }
var textTertiary: Color { textWeak }
var running: Color { online }
var mosh: Color { Color(hex: flavor.teal) }
var warning: Color { Color(hex: flavor.yellow) }
var offline: Color { Color(hex: flavor.overlay) }
}
extension TXPalette {
/// Catppuccin flavor paletteapp
private static func catppuccin(
id: String, name: String, ghostty: String, isDark: Bool,
base: UInt32, surface0: UInt32, surface1: UInt32,
text: UInt32, subtext0: UInt32, overlay0: UInt32,
green: UInt32, teal: UInt32, yellow: UInt32
) -> TXPalette {
TXPalette(
id: id, name: name, isDark: isDark, ghosttyName: ghostty,
bg: Color(hex: base),
surface: Color(hex: surface0),
surfaceElevated: Color(hex: surface1),
accent: Color(hex: green),
accentSoft: Color(hex: green).opacity(0.16),
textPrimary: Color(hex: text),
textSecondary: Color(hex: subtext0),
textTertiary: Color(hex: overlay0),
border: (isDark ? Color.white : Color.black).opacity(0.08),
borderStrong: (isDark ? Color.white : Color.black).opacity(0.14),
running: Color(hex: green),
mosh: Color(hex: teal),
warning: Color(hex: yellow),
offline: Color(hex: overlay0)
)
}
static let catppuccinMocha = TXPalette(
flavor: TXFlavor(
id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha",
green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA,
teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E),
chrome: .mocha)
static let catppuccinMocha = catppuccin(
id: "catppuccin-mocha", name: "Catppuccin Mocha", ghostty: "Catppuccin Mocha", isDark: true,
base: 0x1E1E2E, surface0: 0x313244, surface1: 0x45475A,
text: 0xCDD6F4, subtext0: 0xA6ADC8, overlay0: 0x6C7086,
green: 0xA6E3A1, teal: 0x94E2D5, yellow: 0xF9E2AF)
static let catppuccinMacchiato = TXPalette(
flavor: TXFlavor(
id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato",
green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4,
teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A),
chrome: .macchiato)
static let catppuccinMacchiato = catppuccin(
id: "catppuccin-macchiato", name: "Catppuccin Macchiato", ghostty: "Catppuccin Macchiato", isDark: true,
base: 0x24273A, surface0: 0x363A4F, surface1: 0x494D64,
text: 0xCAD3F5, subtext0: 0xA5ADCB, overlay0: 0x6E738D,
green: 0xA6DA95, teal: 0x8BD5CA, yellow: 0xEED49F)
static let catppuccinFrappe = TXPalette(
flavor: TXFlavor(
id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe",
green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE,
teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446),
chrome: .frappe)
static let catppuccinFrappe = catppuccin(
id: "catppuccin-frappe", name: "Catppuccin Frappé", ghostty: "Catppuccin Frappe", isDark: true,
base: 0x303446, surface0: 0x414559, surface1: 0x51576D,
text: 0xC6D0F5, subtext0: 0xA5ADCE, overlay0: 0x737994,
green: 0xA6D189, teal: 0x81C8BE, yellow: 0xE5C890)
static let catppuccinLatte = catppuccin(
id: "catppuccin-latte", name: "Catppuccin Latte", ghostty: "Catppuccin Latte", isDark: false,
base: 0xEFF1F5, surface0: 0xCCD0DA, surface1: 0xBCC0CC,
text: 0x4C4F69, subtext0: 0x6C6F85, overlay0: 0x9CA0B0,
green: 0x40A02B, teal: 0x179299, yellow: 0xDF8E1D)
static let catppuccinLatte = TXPalette(
flavor: TXFlavor(
id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte",
green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5,
teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5),
chrome: .latte)
///
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
@@ -118,24 +242,91 @@ extension TerminalTheme {
}
}
/// //
// MARK: -
/// / / /
enum TX {
enum Radius {
static let card: CGFloat = 16
static let chip: CGFloat = 10
static let field: CGFloat = 12
/// /
static let badge: CGFloat = 6
///
static let control: CGFloat = 9
///
static let card: CGFloat = 12
///
static let dialog: CGFloat = 15
/// chrome/
static let capsule: CGFloat = 11
//
static let chip: CGFloat = 9
static let field: CGFloat = 9
}
/// 2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40
enum Space {
static let hairline: CGFloat = 2
static let xs: CGFloat = 6
static let s: CGFloat = 10
static let m: CGFloat = 16
static let l: CGFloat = 22
static let xl: CGFloat = 32
static let l: CGFloat = 20
static let xl: CGFloat = 26
static let xxl: CGFloat = 34
/// /
static let screenGutter: CGFloat = 40
}
/// pt `.compact`
enum Layout {
/// 48
static let railWidth: CGFloat = 56
static let railWidthCompact: CGFloat = 48
///
static let sidebarWidth: CGFloat = 288
/// pane
static let paneGap: CGFloat = 2
///
static let paneDragHit: CGFloat = 12
///
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// ****线 y
static let footHeight: CGFloat = 34
}
enum Font {
/// JetBrains Monochrome SwiftUI 退 PingFang SC
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.system(size: size, weight: weight, design: .monospaced)
.custom(psName(weight), fixedSize: size)
}
///
static func monoScaled(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.custom(psName(weight), size: size)
}
private static func psName(_ weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold, .heavy, .black: return "JetBrainsMono-Bold"
case .semibold: return "JetBrainsMono-SemiBold"
case .medium: return "JetBrainsMono-Medium"
default: return "JetBrainsMono-Regular"
}
}
/// 600 10px, ls .09em, uppercase
static let sectionLabel = mono(10, .semibold)
/// 500 34px
static let screenTitle = mono(34, .medium)
/// 500 24px
static let pageTitle = mono(24, .medium)
/// 500 13px
static let listRow = mono(13, .medium)
/// / 400 11px
static let listMeta = mono(11)
/// / 500 12px
static let badge = mono(12, .medium)
}
}