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>
This commit is contained in:
kid
2026-07-26 11:11:20 +08:00
parent 5707d70dff
commit 5d1abdc7ac
13 changed files with 411 additions and 693 deletions

View File

@@ -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 tabwindow 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<Void, Never>?
@@ -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
/// TUIClaude 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: - / / resizeUI-4
// MARK: - / resizeUI-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 panetmux 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() }