import GameController 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 /// T-iOS-29 · "在当前目录开新会话":toolbar 常驻入口 + exit 横幅上的 /// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在 /// `AppCoordinator.openNewSessionInCurrentCwd`)。nil = 两处入口都隐藏 /// (预览/无 coordinator 的测试环境)。 var onNewSessionInCwd: (@MainActor () -> Void)? = nil /// T-iPad-3 · 上下文菜单「结束会话」动作 —— wiring 侧路由到 /// `APIClient.killSession`(带 Origin 的 G 端点)。nil = 无 kill 通道时 /// 菜单不呈现该项(预览/未布线环境)。 var onKillSession: (@MainActor () -> Void)? = nil /// T-iPad-3 · 硬件键盘在场标记(`GCKeyboard.coalesced != nil`),随 /// 连接/断开通知更新,驱动 `KeyBarVisibility` 的自动默认。 @State private var hasHardwareKeyboard = GCKeyboard.coalesced != nil /// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。 @State private var keyBarUserOverride: Bool? /// T-iOS-33 · 终端内搜索面板状态(引擎在 `makeUIView` 里绑定)。 @State private var searchModel = TerminalSearchModel() /// T-iOS-31 · 语音 PTT 状态机 + 它的 epoch 源。两者在 `init` 里成对建立, /// 因为 PTT 的注入出口/只读判据/epoch 都来自本屏的 `viewModel`。 @State private var voiceEpochSource: VoiceEpochSource @State private var voiceModel: VoicePTTViewModel /// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。 @Environment(\.accessibilityReduceMotion) private var reduceMotion private enum Copy { static let newSessionInCwd = "在当前目录开新会话" static let showKeyBar = "显示快捷键栏" static let hideKeyBar = "隐藏快捷键栏" } /// 显式 `init`(成员逐一对齐旧的合成 init,调用点不变):语音 PTT 的 /// ViewModel 必须在 `@State` 初值里就拿到本屏的 `viewModel`,才能把注入出口 /// 接到 `sendInput`、把 epoch 接到 sessionId/连接世代上。 /// /// SwiftUI 只保留**首次**的 `@State` 初值 —— 这正确,因为 /// `TerminalContainerView` 用 `.id(controller.generation)` 换代时会整屏重建, /// 一代之内 `controller.terminalViewModel` 是稳定的。 init( viewModel: TerminalViewModel, onNewSessionInCwd: (@MainActor () -> Void)? = nil, onKillSession: (@MainActor () -> Void)? = nil ) { self.viewModel = viewModel self.onNewSessionInCwd = onNewSessionInCwd self.onKillSession = onKillSession let epochSource = VoiceEpochSource() _voiceEpochSource = State(initialValue: epochSource) _voiceModel = State(initialValue: VoicePTTViewModel( dictation: SpeechDictation(), clock: ContinuousClock(), epoch: { epochSource.epoch }, isReadOnly: { viewModel.isReadOnly }, inject: { text in viewModel.sendInput(text) } )) } /// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。 private var isKeyBarVisible: Bool { KeyBarVisibility.isVisible( hardwareKeyboardPresent: hasHardwareKeyboard, userOverride: keyBarUserOverride ) } var body: some View { TerminalHostView( viewModel: viewModel, keyBarVisible: isKeyBarVisible, searchModel: searchModel, voiceModel: voiceModel, onNewSessionInCwd: onNewSessionInCwd, onKillSession: onKillSession ) .ignoresSafeArea(.container, edges: .bottom) .overlay(alignment: .top) { if let model = viewModel.bannerModel { ReconnectBanner(model: model, onNewSession: onNewSessionInCwd) .padding(.horizontal, DS.Space.md12) .padding(.top, DS.Space.sm8) .transition(.move(edge: .top).combined(with: .opacity)) } } // T-iOS-33 · 搜索条钉在右上 —— 位置对齐 web `#searchbox` // (`position:fixed; top; right`, style.css:812)。 .overlay(alignment: .topTrailing) { if searchModel.isPresented { TerminalSearchBar(model: searchModel) .padding(.horizontal, DS.Space.sm8) .padding(.top, DS.Space.sm8) .transition(.move(edge: .top).combined(with: .opacity)) } } // T-iOS-31 · PTT 卡贴底(紧邻键栏上的 🎤 键)。 .overlay(alignment: .bottom) { VoicePTTBanner(model: voiceModel) .padding(.horizontal, DS.Space.md12) .padding(.bottom, DS.Space.xl20) .transition(.move(edge: .bottom).combined(with: .opacity)) } .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: viewModel.bannerModel ) .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: searchModel.isPresented ) .animation( DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: voiceModel.phase ) .toolbar { searchToolbarItem newSessionToolbarItem keyBarToggleToolbarItem } .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in hasHardwareKeyboard = true } .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in hasHardwareKeyboard = GCKeyboard.coalesced != nil } // T-iOS-31 · epoch 源跟住会话身份与连接世代:任一变化都作废在飞的口述。 .onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) } .onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) } .onAppear { viewModel.start() voiceEpochSource.noteSession(viewModel.sessionId) } } /// T-iOS-33 · 搜索开关(镜像 web toolbar 的 🔍:开/关同一个键)。 @ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { if searchModel.isPresented { searchModel.dismiss() } else { searchModel.present() } } label: { Label( searchModel.isPresented ? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open, systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill" : "magnifyingglass" ) } .accessibilityIdentifier("terminal.searchToggleButton") } } /// T-iPad-3 · KeyBar 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的 /// iPhone 默认工具栏因此逐屏不变(零回归);有硬件键盘时(自动隐藏后) /// 用户可一键取回/再隐。切换即写显式覆盖,压过自动默认。 @ToolbarContentBuilder private var keyBarToggleToolbarItem: some ToolbarContent { if hasHardwareKeyboard { ToolbarItem(placement: .topBarTrailing) { Button { keyBarUserOverride = !isKeyBarVisible } label: { Label( isKeyBarVisible ? Copy.hideKeyBar : Copy.showKeyBar, systemImage: isKeyBarVisible ? "keyboard.chevron.compact.down" : "keyboard" ) } .accessibilityIdentifier("terminal.keyBarToggleButton") } } } /// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh /// session in the active session's cwd, if known. @ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { if let onNewSessionInCwd { Button { onNewSessionInCwd() } label: { Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder") } .accessibilityIdentifier("terminal.newInCwdButton") } } } } // MARK: - Terminal theme /// Refined terminal theme (精致原生) matching the desktop: a fixed warm-dark /// canvas (#100F0D bg / #ECE9E3 fg, web --bg/--text) so the terminal reads the /// same on iOS and desktop regardless of app appearance, with caret/selection /// in the one gold accent. Every value routes through `DS` (no literals); the /// glyph font is Dynamic-Type-aware SF Mono so terminal text respects the /// user's text-size choice at launch. private enum TerminalTheme { @MainActor static func apply(to terminal: TerminalView) { terminal.font = UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize, weight: .regular ) terminal.nativeBackgroundColor = UIColor(DS.Palette.terminalBackground) terminal.nativeForegroundColor = UIColor(DS.Palette.terminalForeground) terminal.caretColor = DS.Palette.accentUIColor() terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor() } } // 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 /// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility` /// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。 let keyBarVisible: Bool /// T-iOS-33 · 搜索面板 —— `makeUIView` 把 SwiftTerm 视图绑给它做检索引擎。 let searchModel: TerminalSearchModel /// T-iOS-31 · PTT 状态机 —— 键栏 🎤 键的按下/松手出口。 let voiceModel: VoicePTTViewModel var onNewSessionInCwd: (@MainActor () -> Void)? = nil var onKillSession: (@MainActor () -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(viewModel: viewModel) } func makeUIView(context: Context) -> KeyCommandTerminalView { let terminal = KeyCommandTerminalView(frame: .zero) terminal.terminalDelegate = context.coordinator TerminalTheme.apply(to: terminal) let viewModel = viewModel terminal.onKeyCommand = { key in viewModel.send(key: key) } // T-iOS-33 · SwiftTerm 自带搜索即检索引擎(纯读,绝不产生 PTY 字节)。 searchModel.attach(terminal) // T-iOS-31 · 🎤 键:按下开录、松手收尾,全程不经 KeyByteMap。 let voiceModel = voiceModel let keyBar = KeyBarView(voice: KeyBarVoiceHandlers( onDown: { Task { await voiceModel.pressDown() } }, onUp: { Task { await voiceModel.pressUp() } } )) keyBar.onKey = { key in viewModel.send(key: key) } terminal.installKeyBar(keyBar, visible: keyBarVisible) // T-iPad-3 · 指针右键/长按上下文菜单(仅 iPad 安装 —— iPhone 长按保持 // SwiftTerm 选区手势)。动作复用既有通道,无新增网络路径。 terminal.installPointerContextMenuIfSupported( onNewInCwd: onNewSessionInCwd, onKill: onKillSession ) // 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. The only push is the KeyBar // visibility (hardware-keyboard aware / user toggle) — a no-op unless it // actually changed, so iPhone (always-visible) never reloads input views. uiView.setKeyBarVisible(keyBarVisible) } /// 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) } /// OSC 0/2 title (T-iOS-23): hostile input — the VM sanitizes /// (TitleSanitizer) before any UI/registry use, then the wiring /// surfaces it on the session-list row. func setTerminalTitle(source: TerminalView, title: String) { viewModel.setTerminalTitle(title) } // cwd surfaces in the session list via the server (T-iOS-13), not // from the local emulator — deliberate no-op. 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)? /// Retained KeyBar so visibility can toggle it in/out of `inputAccessoryView` /// (T-iPad-3). The last-applied value avoids reloading input views when the /// visibility did not change (iPhone stays byte-identical). private var keyBar: KeyBarView? private var appliedKeyBarVisible = true /// Retained so the interaction's delegate outlives menu presentation. private var contextMenuDelegate: TerminalContextMenuInteractionDelegate? 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) } // MARK: - KeyBar install / visibility (T-iPad-3) /// Install the KeyBar as `inputAccessoryView`, honoring the initial /// visibility (hidden when a hardware keyboard makes it redundant). func installKeyBar(_ bar: KeyBarView, visible: Bool) { keyBar = bar appliedKeyBarVisible = visible inputAccessoryView = visible ? bar : nil } /// Apply a visibility change; a no-op when unchanged so no needless /// `reloadInputViews()` (the KeyByteMap routing on `keyBar.onKey` is /// untouched — the same bar is only detached/re-attached). func setKeyBarVisible(_ visible: Bool) { guard visible != appliedKeyBarVisible else { return } appliedKeyBarVisible = visible inputAccessoryView = visible ? keyBar : nil reloadInputViews() } // MARK: - Pointer context menu (T-iPad-3, iPad only) /// Install the secondary-click / long-press context menu — iPad only, so /// iPhone long-press keeps SwiftTerm's native selection gesture (zero /// regression). The delegate builds a fresh model per presentation so the /// copy item reflects the live selection. func installPointerContextMenuIfSupported( onNewInCwd: (@MainActor () -> Void)?, onKill: (@MainActor () -> Void)? ) { // `UIDevice.current.userInterfaceIdiom` (not `traitCollection`, which can // be `.unspecified` before the view joins a window at makeUIView time). guard TerminalContextMenu.isPointerMenuEnabled( idiom: UIDevice.current.userInterfaceIdiom ) else { return } let delegate = TerminalContextMenuInteractionDelegate { [weak self] in TerminalContextMenuModel( onNewInCwd: onNewInCwd, onKill: onKill, onCopySelection: { [weak self] in self?.copySelectionToPasteboard() }, hasSelection: { [weak self] in self?.hasActiveSelection ?? false } ) } contextMenuDelegate = delegate addInteraction(UIContextMenuInteraction(delegate: delegate)) } // NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be // a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy` // eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm // v1.14.0 promoted the very same thing to `public var hasActiveSelection` // (`selection?.active ?? false`) on its own view, which then COLLIDED with // the local one and pinned the dependency at 1.13.0 (`override` cannot fix a // `public`-but-not-`open` property). The local copy is therefore gone and // every caller — the pointer context menu, the find-bar tests — now reads // upstream's property, so the pin rides at 1.15.0. Do not reintroduce it. /// Copy the current selection via SwiftTerm's own `copy(_:)` (selection → /// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream /// is untouched (invariant preserved — same as pointer hover highlight). func copySelectionToPasteboard() { copy(nil) } } // MARK: - Terminal search binding (T-iOS-33) /// The find bar's engine is SwiftTerm's own scrollback search /// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the /// match (that selection IS the highlight) and `clearSearch` drops both. Pure /// read — no PTY byte is produced, so search also works on an exited session. extension KeyCommandTerminalView: TerminalSearching { func searchNext(term: String) -> Bool { findNext(term) } func searchPrevious(term: String) -> Bool { findPrevious(term) } func searchClear() { clearSearch() } }