// 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 cancellables = Set() @StateObject private var registry = SessionRegistry() // T-2.6 @EnvironmentObject var appState: AppState 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 ──────────────────────────────────────────── 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 bootstrap() } .task { await registry.refresh(credential: credential) } // T-2.6 .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 bootstrap() async { statusText = "Looking for sessions…" do { let sessionId = try await resolveSession() 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 conn.$connectionState .receive(on: DispatchQueue.main) .sink { state in switch state { case .connected: statusText = "● \(sessionId)" case .connecting: statusText = "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) } catch { statusText = "Error: \(error.localizedDescription)" } } // 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) } }