// 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 cancellables = Set() @EnvironmentObject var appState: AppState var body: some View { VStack(spacing: 0) { // ── Status bar ────────────────────────────────────────── HStack { Text(statusText) .font(.caption.monospaced()) .foregroundStyle(.secondary) Spacer() Button("Unpair") { appState.unpair() } .font(.caption) .foregroundStyle(.red) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color(uiColor: .systemBackground)) Divider() // ── 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() } } // 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 stream → terminal conn.stream .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)) } } 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)) } } 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) } }