feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
227
ios/App/WebTerm/Components/KeyBar.swift
Normal file
227
ios/App/WebTerm/Components/KeyBar.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
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: - 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user