From 994b450fe4322dba9c93f73e54f68bc02029848f Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 16 May 2026 03:46:24 +0200 Subject: [PATCH] fix: fresh connect uses resize+snapshot instead of full history replay; wire onInput --- Sources/Core/Sessions/SessionConnection.swift | 33 +++++++++--- Sources/UI/Terminal/MainTerminalView.swift | 51 +++++++++++++++---- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Sources/Core/Sessions/SessionConnection.swift b/Sources/Core/Sessions/SessionConnection.swift index 694c3d8..ca05771 100644 --- a/Sources/Core/Sessions/SessionConnection.swift +++ b/Sources/Core/Sessions/SessionConnection.swift @@ -35,6 +35,10 @@ public final class SessionConnection: ObservableObject { /// Emits every JSON frame received from the server. public let stateEvents = PassthroughSubject() + /// Emits snapshot content ready to feed directly into SwiftTerm: + /// ESC[H + ESC[2J (clear+home) followed by the pane's current content. + public let snapshots = PassthroughSubject() + /// Tracks the WebSocket lifecycle. @Published public private(set) var connectionState: ConnectionState = .disconnected @@ -97,23 +101,40 @@ public final class SessionConnection: ObservableObject { } .store(in: &cancellables) - // JSON frames → `stateEvents` subject. + // JSON frames → `stateEvents` + snapshot converter. ws.incomingJSON .sink { [weak self] frame in - self?.stateEvents.send(frame) + guard let self else { return } + if case .snapshot(_, let base64) = frame { + // Decode base64 → text, prepend clear+home, normalise line endings. + if let raw = Data(base64Encoded: base64), + let text = String(data: raw, encoding: .utf8) { + let header = "\u{1B}[H\u{1B}[2J" // cursor home + clear screen + let body = text.replacingOccurrences(of: "\n", with: "\r\n") + self.snapshots.send(Data((header + body).utf8)) + } + } + self.stateEvents.send(frame) } .store(in: &cancellables) - // Once connected, send the resume frame. + // Once connected, send the appropriate opening frame. + // + // • lastSeq == nil → fresh attach: live output already flows; caller + // will send resize then snapshotRequest. No frame sent here. + // • lastSeq != nil → reconnect after gap: replay missed output. ws.connectionState .filter { $0 == .connected } .first() .sink { [weak self, weak ws, lastSeq] _ in guard let self, let ws else { return } - Task { @MainActor [self, ws, lastSeq] in - try? await ws.send(.resume(lastSeq: lastSeq)) - _ = self // silence unused-capture warning + if let seq = lastSeq { + Task { @MainActor [self, ws, seq] in + try? await ws.send(.resume(lastSeq: seq)) + _ = self + } } + // fresh connect: caller drives resize → snapshotRequest } .store(in: &cancellables) diff --git a/Sources/UI/Terminal/MainTerminalView.swift b/Sources/UI/Terminal/MainTerminalView.swift index b2c038d..354a2db 100644 --- a/Sources/UI/Terminal/MainTerminalView.swift +++ b/Sources/UI/Terminal/MainTerminalView.swift @@ -62,7 +62,7 @@ struct MainTerminalView: View { statusText = "Connecting to \(sessionId)…" let conn = SessionConnection(id: sessionId, credential: credential) - // Wire stream → terminal + // Wire live ANSI stream → terminal conn.stream .receive(on: DispatchQueue.main) .sink { [terminalVC] data in @@ -70,6 +70,14 @@ struct MainTerminalView: View { } .store(in: &cancellables) + // Wire snapshots → terminal (already contains ESC[H + ESC[2J prefix) + conn.snapshots + .receive(on: DispatchQueue.main) + .sink { [terminalVC] data in + terminalVC.feed(data: data) + } + .store(in: &cancellables) + // Wire connection state → status text conn.$connectionState .receive(on: DispatchQueue.main) @@ -88,17 +96,38 @@ struct MainTerminalView: View { Task { try? await conn.send(.resize(cols: cols, rows: rows)) } } - connection = conn - await conn.resume(from: conn.scrollback.sizeBytes > 0 - ? ResumeCursor().lastSeq(for: sessionId) - : nil) - - // Send the current terminal size immediately after connecting - // so tmux resizes before the first output frame arrives. - let (cols, rows) = terminalVC.terminalSize - if cols > 0 && rows > 0 { - try? await conn.send(.resize(cols: cols, rows: rows)) + // Wire keyboard input from SwiftTerm → sidecar. + terminalVC.onInput = { [weak conn] data in + guard let conn, + let text = String(data: data, encoding: .utf8) else { return } + Task { try? await conn.send(.keys(data: text)) } } + + // On first connection: resize → brief pause (SIGWINCH propagation) + // → snapshot. This avoids replaying history at the wrong size. + conn.$connectionState + .filter { $0 == .connected } + .first() + .receive(on: DispatchQueue.main) + .sink { [weak conn, terminalVC] _ in + Task { @MainActor in + let (cols, rows) = terminalVC.terminalSize + if cols > 0 && rows > 0 { + try? await conn?.send(.resize(cols: cols, rows: rows)) + } + // Let tmux propagate SIGWINCH before snapshotting. + try? await Task.sleep(nanoseconds: 250_000_000) + try? await conn?.send(.snapshotRequest) + } + } + .store(in: &cancellables) + + connection = conn + + let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0 + ? ResumeCursor().lastSeq(for: sessionId) + : nil + await conn.resume(from: lastSeq) } catch { statusText = "Error: \(error.localizedDescription)" }