import SessionCore import SwiftTerm import SwiftUI import UIKit /// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner. /// /// Data flow (byte-shuttle preserved end to end): /// - inbound: `SessionEvent.output` → `TerminalViewModel` sink → `feed(text:)` /// (opaque ANSI/UTF-8 — the app NEVER parses terminal semantics); /// - outbound: SwiftTerm delegate `send` → `viewModel.sendInput`, /// `sizeChanged` → `viewModel.sendResize`; KeyBar taps and hardware /// `UIKeyCommand`s go through `viewModel.send(key:)` — every label→bytes /// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft /// keyboard never pops (mirrors the web bar's ws.send bypass). /// - IME: NO custom keydown/composition interception — SwiftTerm manages /// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11). /// /// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in /// T-iOS-15 — this screen only renders and routes. struct TerminalScreen: View { let viewModel: TerminalViewModel private enum Metrics { static let bannerHorizontalPadding: CGFloat = 12 static let bannerTopPadding: CGFloat = 8 } var body: some View { TerminalHostView(viewModel: viewModel) .ignoresSafeArea(.container, edges: .bottom) .overlay(alignment: .top) { if let model = viewModel.bannerModel { ReconnectBanner(model: model) .padding(.horizontal, Metrics.bannerHorizontalPadding) .padding(.top, Metrics.bannerTopPadding) .transition(.move(edge: .top).combined(with: .opacity)) } } .animation(.default, value: viewModel.bannerModel) .onAppear { viewModel.start() } } } // MARK: - SwiftTerm bridge /// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The /// KeyBar is installed as `inputAccessoryView`; hardware chords come from the /// `KeyCommandTerminalView` subclass. Both route through the ViewModel. private struct TerminalHostView: UIViewRepresentable { let viewModel: TerminalViewModel func makeCoordinator() -> Coordinator { Coordinator(viewModel: viewModel) } func makeUIView(context: Context) -> KeyCommandTerminalView { let terminal = KeyCommandTerminalView(frame: .zero) terminal.terminalDelegate = context.coordinator let viewModel = viewModel terminal.onKeyCommand = { key in viewModel.send(key: key) } let keyBar = KeyBarView() keyBar.onKey = { key in viewModel.send(key: key) } terminal.inputAccessoryView = keyBar // Output sink: buffered replay flushes now, live bytes follow. // @MainActor-typed closure — feeding off the main actor cannot compile. viewModel.attachTerminalSink { [weak terminal] text in terminal?.feed(text: text) } return terminal } func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) { // State-driven UI lives in SwiftUI (banner overlay); the terminal view // itself is driven by the sink/delegate, nothing to push here. } /// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is /// `@preconcurrency` — SwiftTerm only calls it from the main thread (the /// view itself is `@MainActor`), which the runtime check enforces. @MainActor final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate { private let viewModel: TerminalViewModel init(viewModel: TerminalViewModel) { self.viewModel = viewModel } /// User typed into SwiftTerm (soft/hardware keyboard, IME result): /// raw bytes, passed through verbatim (invariant #9). func send(source: TerminalView, data: ArraySlice) { viewModel.sendInput(String(decoding: data, as: UTF8.self)) } /// Layout produced a new grid → server `resize` (SIGWINCH). This is /// also the latest-writer-wins size claim (v0.4 sizing model). func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { guard newCols > 0, newRows > 0 else { return } // pre-layout noise viewModel.sendResize(cols: newCols, rows: newRows) } /// Tapped link (OSC 8 / detected URL — mirrors web M2 WebLinksAddon). /// The link string is untrusted terminal output: http(s) only. func requestOpenLink(source: TerminalView, link: String, params: [String: String]) { guard let url = URL(string: link), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return } UIApplication.shared.open(url) } // Title/cwd surface in the session list via the server (T-iOS-13/23), // not from the local emulator — deliberate no-ops. func setTerminalTitle(source: TerminalView, title: String) {} func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} func scrolled(source: TerminalView, position: Double) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} /// OSC 52 lets the HOST write the device clipboard silently — declined /// (server output is untrusted input; plan §4). func clipboardCopy(source: TerminalView, content: Data) {} } } /// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the /// SAME `KeyByteMap` mapping as the key bar (scope decision documented on /// `HardwareKeyCommands`). Everything else — rendering, selection, IME — /// is stock SwiftTerm. final class KeyCommandTerminalView: TerminalView { /// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`. var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)? override var keyCommands: [UIKeyCommand]? { (super.keyCommands ?? []) + HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:))) } @objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) { guard let key = HardwareKeyCommands.key(matching: sender) else { return } onKeyCommand?(key) } }