feat: tmux 多 pane 分屏(pane-per-surface + refresh-client -C 尺寸协调)

- TmuxController 重构为 pane-per-surface:TmuxPaneSurface(session+state+gate/pane)
  + TmuxWindow(panes 字典/visibleLayout 渲染/fullLayout diff) + surfaceByPane O(1) 路由
- 尺寸走路线 b:attach 发 refresh-client -C WxH 让 tmux 布局,pane surface 尺寸取 layout
  rect(%output 按 tmux pane 宽高排版,一致才不换行/清屏错乱);surface resize 只断言不反向驱动
- applyLayout reconcile(增删留、复用不重建);新 pane capture-pane %end 前丢弃 %output
- 输入绑 pane→send-keys -t %self;tap→select-pane;ContentView 按 rect 绝对定位分屏 + 焦点边框
- 前置修复:tmux 网关字节改 TmuxByteChannel(AsyncStream 单消费者)保序,防多 pane 打碎协议
- 验证(192.168.9.199 直连 tmux -CC attach):左右 pane 各渲染 LEFT/RIGHT 输出、实时、无错乱

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 17:30:17 +08:00
parent 866df1a323
commit 9a27338222
5 changed files with 239 additions and 82 deletions

View File

@@ -111,6 +111,27 @@ final class OutputGate: @unchecked Sendable {
}
}
/// tmux enter / bytes AsyncStream 线
/// " chunk Task" pane %
enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) }
/// 线 tmux continuation线 yield线
final class TmuxByteChannel: @unchecked Sendable {
let stream: AsyncStream<TmuxByteEvent>
private let cont: AsyncStream<TmuxByteEvent>.Continuation
init() { (stream, cont) = AsyncStream.makeStream() }
func enter(_ b: [UInt8]) { cont.yield(.enter(b)) }
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
}
/// 线tmux attach `refresh-client -C`
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var v: (cols: Int, rows: Int) = (80, 24)
var value: (cols: Int, rows: Int) { lock.lock(); defer { lock.unlock() }; return v }
func set(cols: Int, rows: Int) { lock.lock(); v = (max(1, cols), max(1, rows)); lock.unlock() }
}
/// M0 SSH SSHSession(libssh2) GhosttyKit in-memory
/// TXCore.SessionMachine 线退/
@MainActor
@@ -152,9 +173,14 @@ final class SSHTerminalModel: ObservableObject {
private var bgTask: UIBackgroundTaskIdentifier = .invalid
#endif
private let tmuxChannel = TmuxByteChannel()
/// raw surface resize tmux attach refresh-client -C
let screenGrid = GridBox()
init() {
let holder = TransportHolder()
let moshHolder = MoshHolder()
let grid = screenGrid
let session = InMemoryTerminalSession(
// /resizemosh mosh SSH transport
write: { data in
@@ -162,6 +188,7 @@ final class SSHTerminalModel: ObservableObject {
else { holder.transport?.send(data) }
},
resize: { vp in
grid.set(cols: Int(vp.columns), rows: Int(vp.rows)) // tmux
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
}
@@ -173,6 +200,18 @@ final class SSHTerminalModel: ObservableObject {
self.session = session
self.state = state
self.gate = OutputGate(session: session)
// tmux enter controller+bytes
let ch = tmuxChannel
Task { @MainActor [weak self] in
for await ev in ch.stream {
guard let self else { continue }
switch ev {
case .enter(let initial): self.enterTmux(initialBytes: initial)
case .bytes(let b): self.tmuxController?.feed(Data(b))
}
}
}
}
// MARK: -
@@ -285,18 +324,18 @@ final class SSHTerminalModel: ObservableObject {
private lazy var tmuxRouter = TmuxRouter(
rawGate: gate,
enterGateway: { [weak self] after in
Task { @MainActor in self?.enterTmux(initialBytes: after) }
},
gatewayBytes: { [weak self] bytes in
Task { @MainActor in self?.tmuxController?.feed(Data(bytes)) }
}
// 线enter bytes
enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) },
gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) }
)
private func enterTmux(initialBytes: [UInt8]) {
let holder = self.holder
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
// attach refresh-client -C tmux
let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows)
tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
}