import SessionCore import UIKit /// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring /// `public/keybar.ts` — the Claude Code keys a phone keyboard can't produce, /// most-used first. EVERY label→bytes lookup resolves through /// `KeyByteMap` (SessionCore) — no byte literal lives in this file. /// /// The bar is installed as the terminal's `inputAccessoryView` and sends via /// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap /// never pops or fights the soft keyboard — same reason the web bar bypasses /// xterm and calls `ws.send`). // MARK: - Visibility policy (T-iPad-3) /// Pure predicate deciding whether the soft-keyboard KeyBar /// (`inputAccessoryView`) should show — THE single decision point (mirrors /// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views): /// - a hardware keyboard makes the on-screen key bar redundant → default hidden; /// - no hardware keyboard → shown (unchanged iPhone behavior — zero regression); /// - an explicit user toggle (`userOverride`) always wins over the auto default. /// /// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call /// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`). enum KeyBarVisibility { /// - Parameters: /// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`. /// - userOverride: nil = follow the auto default; true/false = the user /// explicitly forced show/hide (wins over the hardware-driven default). static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool { if let userOverride { return userOverride } return !hardwareKeyboardPresent } } // MARK: - Layout data (mirror of KEYBAR_BUTTONS) /// One key-bar button: glyph, short function caption (shown under the glyph, /// self-documenting on touch) and the full title for accessibility. struct KeyBarButtonSpec: Equatable, Sendable { let key: KeyByteMap.Key let label: String let caption: String let title: String let isPrimary: Bool init(key: KeyByteMap.Key, label: String, caption: String, title: String, isPrimary: Bool = false) { self.key = key self.label = label self.caption = caption self.title = title self.isPrimary = isPrimary } } /// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS` /// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31). enum KeyBarLayout { static let buttons: [KeyBarButtonSpec] = [ KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断", title: "Esc — interrupt Claude / dismiss", isPrimary: true), KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯", title: "Esc Esc — rewind / clear draft"), KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式", title: "Shift+Tab — cycle plan / auto-accept mode"), KeyBarButtonSpec(key: .arrowUp, label: "↑", caption: "上一个", title: "Up — previous option / history"), KeyBarButtonSpec(key: .arrowDown, label: "↓", caption: "下一个", title: "Down — next option / history"), KeyBarButtonSpec(key: .enter, label: "⏎", caption: "确认", title: "Enter — confirm"), KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消", title: "Ctrl+C — cancel / quit"), KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史", title: "Ctrl+R — reverse-search command history"), KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情", title: "Ctrl+O — expand transcript / tool detail"), KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘", title: "Ctrl+L — redraw screen"), KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务", title: "Ctrl+T — toggle task list"), KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台", title: "Ctrl+B — background running task"), KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出", title: "Ctrl+D — exit session (EOF)"), KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全", title: "Tab — complete / toggle"), KeyBarButtonSpec(key: .arrowLeft, label: "←", caption: "左移", title: "Left — move cursor left"), KeyBarButtonSpec(key: .arrowRight, label: "→", caption: "右移", title: "Right — move cursor right"), KeyBarButtonSpec(key: .slash, label: "/", caption: "命令", title: "Slash — command launcher"), ] } private enum KeyBarMetrics { static let barHeight: CGFloat = 52 static let buttonSpacing: CGFloat = 6 static let contentInset: CGFloat = 8 static let buttonInsets = NSDirectionalEdgeInsets( top: 4, leading: 9, bottom: 4, trailing: 9 ) } // MARK: - KeyBarView (inputAccessoryView) /// Horizontally scrolling key bar, installed as the terminal's /// `inputAccessoryView`. Taps call `onKey` — the screen routes them to /// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`. final class KeyBarView: UIInputView { /// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel. var onKey: (@MainActor (KeyByteMap.Key) -> Void)? /// Built buttons in layout order (test-visible). private(set) var keyButtons: [UIButton] = [] init() { super.init( frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight), inputViewStyle: .keyboard ) allowsSelfSizing = true buildBar() } @available(*, unavailable, message: "KeyBarView is code-built only") required init?(coder: NSCoder) { return nil } override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight) } private func buildBar() { let stack = UIStackView() stack.axis = .horizontal stack.spacing = KeyBarMetrics.buttonSpacing stack.alignment = .center stack.translatesAutoresizingMaskIntoConstraints = false for spec in KeyBarLayout.buttons { let button = makeButton(for: spec) keyButtons = keyButtons + [button] stack.addArrangedSubview(button) } let scroll = UIScrollView() scroll.showsHorizontalScrollIndicator = false scroll.translatesAutoresizingMaskIntoConstraints = false scroll.addSubview(stack) addSubview(scroll) NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: leadingAnchor), scroll.trailingAnchor.constraint(equalTo: trailingAnchor), scroll.topAnchor.constraint(equalTo: topAnchor), scroll.bottomAnchor.constraint(equalTo: bottomAnchor), stack.leadingAnchor.constraint( equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: KeyBarMetrics.contentInset ), stack.trailingAnchor.constraint( equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -KeyBarMetrics.contentInset ), stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), ]) } private func makeButton(for spec: KeyBarButtonSpec) -> UIButton { var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .plain() var label = AttributedString(spec.label) label.font = UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize, weight: .semibold ) config.attributedTitle = label var caption = AttributedString(spec.caption) caption.font = UIFont.preferredFont(forTextStyle: .caption2) caption.foregroundColor = .secondaryLabel config.attributedSubtitle = caption config.titleAlignment = .center config.contentInsets = KeyBarMetrics.buttonInsets let button = UIButton(configuration: config) button.accessibilityLabel = spec.title button.addAction( UIAction { [weak self] _ in self?.onKey?(spec.key) }, for: .touchUpInside ) return button } } // MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping) /// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping /// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc, /// Shift+Tab and the Ctrl chords are registered — /// - arrows stay SwiftTerm-native: a fixed CSI override would break /// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`); /// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers; /// - Esc·Esc is simply two Esc presses. /// UIKeyCommand interception replaces (never duplicates) the responder's own /// press handling, so each chord is sent exactly once. enum HardwareKeyCommands { struct Chord: Equatable { let key: KeyByteMap.Key let input: String let modifiers: UIKeyModifierFlags } static let chords: [Chord] = [ Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []), Chord(key: .shiftTab, input: "\t", modifiers: .shift), Chord(key: .ctrlC, input: "c", modifiers: .control), Chord(key: .ctrlR, input: "r", modifiers: .control), Chord(key: .ctrlO, input: "o", modifiers: .control), Chord(key: .ctrlL, input: "l", modifiers: .control), Chord(key: .ctrlT, input: "t", modifiers: .control), Chord(key: .ctrlB, input: "b", modifiers: .control), Chord(key: .ctrlD, input: "d", modifiers: .control), ] /// One prioritized `UIKeyCommand` per chord, all firing `action`. /// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.) @MainActor static func build(action: Selector) -> [UIKeyCommand] { chords.map { chord in let command = UIKeyCommand( action: action, input: chord.input, modifierFlags: chord.modifiers ) command.wantsPriorityOverSystemBehavior = true return command } } /// Reverse lookup for the command handler: which chord fired. @MainActor static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? { chords.first { $0.input == command.input && $0.modifiers == command.modifierFlags }?.key } }