// MainTerminalView.swift // Wires SessionConnection → TerminalViewController + ModifierBar // MVP: auto-picks the first session (or creates one named "pi") import Combine import SwiftUI @MainActor struct MainTerminalView: View { let credential: SidecarCredential @State private var terminalVC = TerminalViewController() @State private var connection: SessionConnection? = nil @State private var statusText = "Connecting…" @State private var currentPiState: PiState? = nil @State private var sessionName = "" @State private var showSwitcher = false @State private var showSettings = false // T-2.11 @State private var activeSessionId: String? = nil // T-2.6 @State private var lastCapturedSeq: UInt64? = nil // T-2.10: seq captured on background @State private var cancellables = Set() @StateObject private var registry = SessionRegistry() // T-2.6 @EnvironmentObject var appState: AppState /// True when running under XCUITest. Skips the live SwiftTerm view + WS /// connection, which otherwise keep the app non-idle and cause every /// XCUITest interaction to block for ~120 s. private var isUITest: Bool { ProcessInfo.processInfo.arguments.contains("--uitest") } /// True when the stub-connection mode is active (uitest + background lifecycle). private var isUITestStub: Bool { let args = ProcessInfo.processInfo.arguments return args.contains("--uitest") && args.contains("--uitest-with-stub-connection") } var body: some View { VStack(spacing: 0) { // ── Status bar ────────────────────────────────────────── StatusBar( sessionName: sessionName, connectionStatus: statusText, piState: $currentPiState, onSwitcher: { showSwitcher = true }, onUnpair: { appState.unpair() }, onSettings: { showSettings = true } // T-2.11 ) // ── Terminal ──────────────────────────────────────────── if isUITest { Color.black .overlay(Text("UITest mode").foregroundStyle(.white).font(.caption)) .accessibilityIdentifier("terminal.placeholder") } else { TerminalViewRepresentable(controller: terminalVC) .ignoresSafeArea(edges: .bottom) } Divider() // ── Modifier bar ──────────────────────────────────────── ModifierBar { frame in Task { try? await connection?.send(frame) } } .padding(.vertical, 4) .background(Color(uiColor: .secondarySystemBackground)) } .task { await initialBootstrap() } .task { await registry.refresh(credential: credential) } // T-2.6 .onReceive(appState.lifecycleTransitions) { isBackground in // T-2.10 Task { @MainActor in if isBackground { guard let conn = connection else { return } if isUITestStub { lastCapturedSeq = 1 // stub sentinel: ensures "Reconnecting…" } else { lastCapturedSeq = conn.scrollback.sizeBytes > 0 ? ResumeCursor().lastSeq(for: conn.id) : nil } await conn.suspend() } else { guard !appState.isLocked, let conn = connection else { return } await conn.resume(from: lastCapturedSeq) } } } .onChange(of: activeSessionId) { _, newId in guard let newId else { return } // UI-test mode: no terminal view, no WS — just update the label. if isUITest { sessionName = newId; return } // Avoid reconnect storm if id already matches the current connection. if connection?.id == newId { return } Task { if let oldConn = connection { await oldConn.suspend() } cancellables.removeAll() connection = nil currentPiState = nil sessionName = "" terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8)) await bootstrap(sessionId: newId) } } .sheet(isPresented: $showSwitcher) { // T-2.6 SessionSwitcher(registry: registry, credential: credential) { session in activeSessionId = session.id } } .sheet(isPresented: $showSettings) { // T-2.11 SettingsView(credential: credential) .environmentObject(appState) } } // MARK: - Bootstrap private func initialBootstrap() async { // UI-test mode: skip the live WebSocket. SwiftTerm's constant redraw // on incoming frames prevents XCUITest's idle wait from completing, // making every .tap() block for ~120 s. Status bar, modifier bar, and // both sheets still render normally. if ProcessInfo.processInfo.arguments.contains("--uitest") { if isUITestStub { await bootstrapStubConnection() } else { statusText = "● uitest" sessionName = "uitest" } return } statusText = "Looking for sessions…" do { let sessionId = try await resolveSession() await bootstrap(sessionId: sessionId) } catch { statusText = "Error: \(error.localizedDescription)" } } private func bootstrap(sessionId: String) async { // Keep activeSessionId in sync. The .onChange handler guards against // re-entry via `connection?.id == newId` check (connection is still nil here // on first call, but we set it below before any further state change). activeSessionId = sessionId statusText = "Connecting to \(sessionId)…" let conn = SessionConnection(id: sessionId, credential: credential) // Wire live ANSI stream → terminal conn.stream .receive(on: DispatchQueue.main) .sink { [terminalVC] data in terminalVC.feed(data: data) } .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 // T-2.10: show "Reconnecting…" if isStreamFrozen (lastSeq was non-nil) conn.$connectionState .receive(on: DispatchQueue.main) .sink { [weak conn] state in switch state { case .connected: statusText = "● \(sessionId)" case .connecting: statusText = conn?.isStreamFrozen == true ? "Reconnecting…" : "Connecting…" case .disconnected: statusText = "Disconnected" } } .store(in: &cancellables) // Wire resize callback — fires on layout + font changes. terminalVC.onResize = { [weak conn] cols, rows in guard let conn else { return } Task { 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: // 1. Clear SwiftTerm immediately (removes stale content) // 2. Send resize so tmux + shell know the real dimensions // 3. Wait for SIGWINCH to propagate and shell to redraw // 4. Snapshot the now-stable screen state conn.$connectionState .filter { $0 == .connected } .first() .receive(on: DispatchQueue.main) .sink { [weak conn, terminalVC] _ in // Clear immediately — don't show stale/mismatched content. terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8)) Task { @MainActor in let (cols, rows) = terminalVC.terminalSize if cols > 0 && rows > 0 { try? await conn?.send(.resize(cols: cols, rows: rows)) } // 600 ms: SIGWINCH → fish/bash redraws → tmux pane settles. // SSH sessions need extra round-trip time. try? await Task.sleep(nanoseconds: 600_000_000) try? await conn?.send(.snapshotRequest) } } .store(in: &cancellables) // Wire stateEvents → currentPiState conn.stateEvents .compactMap { event -> PiState? in if case .state(let s, _, _) = event { return s } else { return nil } } .receive(on: DispatchQueue.main) .sink { state in currentPiState = state } .store(in: &cancellables) // Wire stateEvents → sessionName conn.stateEvents .compactMap { event -> String? in if case .sessionMeta(let name, _, _) = event { return name } else { return nil } } .receive(on: DispatchQueue.main) .sink { name in sessionName = name } .store(in: &cancellables) connection = conn let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0 ? ResumeCursor().lastSeq(for: sessionId) : nil await conn.resume(from: lastSeq) } // MARK: - Stub connection (uitest-with-stub-connection) /// Creates an in-process stub SessionConnection for UI tests that need /// to exercise the background/foreground lifecycle without a real sidecar. private func bootstrapStubConnection() async { let stubId = "stub" sessionName = stubId activeSessionId = stubId let conn = SessionConnection(id: stubId, credential: credential) conn.stubMode = true conn.$connectionState .receive(on: DispatchQueue.main) .sink { [weak conn] state in switch state { case .connected: statusText = "● \(stubId)" case .connecting: statusText = conn?.isStreamFrozen == true ? "Reconnecting…" : "Connecting…" case .disconnected: statusText = "Disconnected" } } .store(in: &cancellables) connection = conn await conn.resume(from: nil) } // MARK: - Session resolution /// Returns the first existing session id, or creates one named "pi". private func resolveSession() async throws -> String { let url = URL(string: "http://\(credential.host):\(credential.port)/sessions")! var req = URLRequest(url: url) req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: req) struct SessionItem: Decodable { let id: String } let sessions = (try? JSONDecoder().decode([SessionItem].self, from: data)) ?? [] if let first = sessions.first { return first.id } // No sessions — create one var createReq = URLRequest(url: url) createReq.httpMethod = "POST" createReq.setValue("application/json", forHTTPHeaderField: "Content-Type") createReq.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization") createReq.httpBody = try? JSONEncoder().encode(["name": "pi"]) let (createData, _) = try await URLSession.shared.data(for: createReq) struct Created: Decodable { let id: String } if let created = try? JSONDecoder().decode(Created.self, from: createData) { return created.id } throw URLError(.cannotParseResponse) } }