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:
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal file
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-14 · "What happened while I was away" banner rendered above the
|
||||
/// terminal after a reconnect. Render-only: `GateViewModel` owns the state —
|
||||
/// an ALL-ZERO digest never reaches this view (the VM keeps `digest` nil),
|
||||
/// the collapsed row auto-fades after `Tunables.digestFadeDelay`, and manual
|
||||
/// expansion (which cancels the fade) reveals the recent entries.
|
||||
struct AwayDigestView: View {
|
||||
let digest: AwayDigest
|
||||
let isExpanded: Bool
|
||||
let onExpand: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
private enum Metrics {
|
||||
static let spacing: CGFloat = 8
|
||||
static let rowSpacing: CGFloat = 4
|
||||
static let horizontalPadding: CGFloat = 14
|
||||
static let verticalPadding: CGFloat = 10
|
||||
static let cornerRadius: CGFloat = 12
|
||||
/// Recent entries shown when expanded (newest kept — the engine's
|
||||
/// digest is uncapped, the banner must not be).
|
||||
static let maxRecentRows = 12
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let prefix = "离开期间:"
|
||||
static let separator = " · "
|
||||
static let expandTitle = "展开明细"
|
||||
static let dismissTitle = "关闭摘要"
|
||||
}
|
||||
|
||||
/// Summary line: non-zero parts only, joined web-statusline style. A
|
||||
/// non-empty digest whose counted signals are all zero (e.g. only `user`
|
||||
/// events) falls back to a plain activity count so the row is never blank.
|
||||
static func summary(for digest: AwayDigest) -> String {
|
||||
let parts = [
|
||||
digest.toolRuns > 0 ? "工具调用 \(digest.toolRuns) 次" : nil,
|
||||
digest.waitingCount > 0 ? "等待审批 \(digest.waitingCount) 次" : nil,
|
||||
digest.sawDone ? "已完成" : nil,
|
||||
digest.sawStuck ? "曾卡住" : nil,
|
||||
].compactMap { $0 }
|
||||
guard !parts.isEmpty else {
|
||||
return "\(Copy.prefix)活动 \(digest.recent.count) 条"
|
||||
}
|
||||
return Copy.prefix + parts.joined(separator: Copy.separator)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.spacing) {
|
||||
summaryRow
|
||||
if isExpanded {
|
||||
recentRows
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Metrics.horizontalPadding)
|
||||
.padding(.vertical, Metrics.verticalPadding)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var summaryRow: some View {
|
||||
HStack(spacing: Metrics.spacing) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Self.summary(for: digest))
|
||||
.font(.footnote.weight(.medium))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: Metrics.spacing)
|
||||
if !isExpanded {
|
||||
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
/// Newest `maxRecentRows` entries, oldest→newest. Server text (`label`) is
|
||||
/// untrusted display input — rendered as plain Text only.
|
||||
private var recentRows: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
|
||||
ForEach(
|
||||
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
|
||||
id: \.offset
|
||||
) { _, event in
|
||||
recentRow(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recentRow(_ event: TimelineEvent) -> some View {
|
||||
HStack(spacing: Metrics.spacing) {
|
||||
Text(Self.time(of: event))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(event.label)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private static func time(of event: TimelineEvent) -> String {
|
||||
let millisecondsPerSecond = 1_000.0
|
||||
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
|
||||
return date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
}
|
||||
86
ios/App/WebTerm/Components/GateBanner.swift
Normal file
86
ios/App/WebTerm/Components/GateBanner.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// Copy + wire pairing for one gate button — the SINGLE label source for both
|
||||
/// gate surfaces (`GateBanner`, `PlanGateSheet`). Labels mirror
|
||||
/// public/tabs.ts:334-350 byte-for-byte; the affordance→ClientMessage mapping
|
||||
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
|
||||
struct GateChoiceSpec: Equatable {
|
||||
let label: String
|
||||
let affordance: GateState.Affordance
|
||||
|
||||
/// Ordered button set for `gate` (tool → two, plan → three), driven by the
|
||||
/// frozen `GateState.affordances` list.
|
||||
static func specs(for gate: GateState) -> [GateChoiceSpec] {
|
||||
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
|
||||
}
|
||||
|
||||
static func label(for affordance: GateState.Affordance) -> String {
|
||||
switch affordance {
|
||||
case .approveAuto: return "✓ Approve + Auto"
|
||||
case .approveReview: return "✓ Approve + Review"
|
||||
case .keepPlanning: return "✎ Keep Planning"
|
||||
case .approve: return "✓ Approve"
|
||||
case .reject: return "✗ Reject"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
|
||||
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
|
||||
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
|
||||
/// rendered — the VM drops stale taps (first-line guard); wiring into
|
||||
/// TerminalScreen is T-iOS-15.
|
||||
struct GateBanner: View {
|
||||
let gate: GateState
|
||||
let onDecide: (GateState.Affordance, Int) -> Void
|
||||
|
||||
private enum Metrics {
|
||||
static let spacing: CGFloat = 8
|
||||
static let horizontalPadding: CGFloat = 14
|
||||
static let verticalPadding: CGFloat = 10
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let messageLineLimit = 2
|
||||
}
|
||||
|
||||
/// Banner headline, mirror of public/tabs.ts:329:
|
||||
/// `Claude wants to use ${pendingTool ?? 'a tool'}`. `detail` is
|
||||
/// server-supplied display text (untrusted) — rendered as plain Text only.
|
||||
static func message(for gate: GateState) -> String {
|
||||
"Claude wants to use \(gate.detail ?? "a tool")"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.spacing) {
|
||||
Text(Self.message(for: gate))
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(Metrics.messageLineLimit)
|
||||
HStack(spacing: Metrics.spacing) {
|
||||
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
|
||||
decisionButton(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Metrics.horizontalPadding)
|
||||
.padding(.vertical, Metrics.verticalPadding)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
|
||||
Button(spec.label) {
|
||||
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.tint(tint(for: spec.affordance))
|
||||
}
|
||||
|
||||
private func tint(for affordance: GateState.Affordance) -> Color {
|
||||
switch affordance {
|
||||
case .approve, .approveAuto, .approveReview: return .green
|
||||
case .reject: return .red
|
||||
case .keepPlanning: return .indigo
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal file
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto /
|
||||
/// Approve+Review / Keep Planning — mapping mirrors public/tabs.ts:345-347
|
||||
/// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default /
|
||||
/// reject; never raw `auto`, and NO allowAutoMode gating — plan §7 T-iOS-14).
|
||||
///
|
||||
/// Render-only: state lives in `GateViewModel`; every tap reports the
|
||||
/// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap
|
||||
/// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15.
|
||||
struct PlanGateSheet: View {
|
||||
/// Mirror of public/tabs.ts:326 plan-gate headline.
|
||||
static let title = "Claude finished planning — how should it proceed?"
|
||||
|
||||
let gate: GateState
|
||||
let onDecide: (GateState.Affordance, Int) -> Void
|
||||
|
||||
private enum Metrics {
|
||||
static let spacing: CGFloat = 12
|
||||
static let padding: CGFloat = 20
|
||||
static let captionSpacing: CGFloat = 2
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Metrics.spacing) {
|
||||
Text(Self.title)
|
||||
.font(.headline)
|
||||
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
|
||||
choiceButton(spec)
|
||||
}
|
||||
}
|
||||
.padding(Metrics.padding)
|
||||
.presentationDetents([.medium])
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func choiceButton(_ spec: GateChoiceSpec) -> some View {
|
||||
Button {
|
||||
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Metrics.captionSpacing) {
|
||||
Text(spec.label)
|
||||
.font(.body.weight(.medium))
|
||||
Text(Self.caption(for: spec.affordance))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(tint(for: spec.affordance))
|
||||
}
|
||||
|
||||
/// Secondary line under each choice: what the wire mode actually does.
|
||||
static func caption(for affordance: GateState.Affordance) -> String {
|
||||
switch affordance {
|
||||
case .approveAuto: return "执行计划,编辑自动接受(acceptEdits)"
|
||||
case .approveReview: return "执行计划,每次编辑需确认(default)"
|
||||
case .keepPlanning: return "继续规划,不执行"
|
||||
case .approve: return "允许本次工具调用"
|
||||
case .reject: return "拒绝本次工具调用"
|
||||
}
|
||||
}
|
||||
|
||||
private func tint(for affordance: GateState.Affordance) -> Color {
|
||||
switch affordance {
|
||||
case .approve, .approveAuto, .approveReview: return .green
|
||||
case .reject: return .red
|
||||
case .keepPlanning: return .indigo
|
||||
}
|
||||
}
|
||||
}
|
||||
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal file
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal
|
||||
/// must NEVER "look connected but dead" — every non-live state is explicit).
|
||||
///
|
||||
/// Render input is the `Model` enum only; the mapping from engine events to a
|
||||
/// `Model` lives in `TerminalViewModel.bannerModel` (tested there).
|
||||
struct ReconnectBanner: View {
|
||||
/// What the banner says. Terminal phases (`failed`/`exited`) win over
|
||||
/// transient connection states (mapping in `TerminalViewModel.bannerModel`).
|
||||
enum Model: Equatable {
|
||||
case connecting
|
||||
case reconnecting(attempt: Int, retryIn: Duration)
|
||||
/// Non-retryable failure: actionable copy, NO spinner, NO retry loop.
|
||||
case failed(message: String)
|
||||
/// Session over — terminal is read-only from here on.
|
||||
case exited(code: Int, reason: String?)
|
||||
}
|
||||
|
||||
let model: Model
|
||||
|
||||
private enum Metrics {
|
||||
static let contentSpacing: CGFloat = 8
|
||||
static let horizontalPadding: CGFloat = 14
|
||||
static let verticalPadding: CGFloat = 8
|
||||
static let backgroundOpacity = 0.9
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.contentSpacing) {
|
||||
if isSpinning {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: iconName)
|
||||
}
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.horizontal, Metrics.horizontalPadding)
|
||||
.padding(.vertical, Metrics.verticalPadding)
|
||||
.background(tint.opacity(Metrics.backgroundOpacity), in: Capsule())
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var isSpinning: Bool {
|
||||
switch model {
|
||||
case .connecting, .reconnecting: return true
|
||||
case .failed, .exited: return false
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch model {
|
||||
case .failed: return "exclamationmark.octagon.fill"
|
||||
case .exited: return "flag.checkered"
|
||||
case .connecting, .reconnecting: return "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch model {
|
||||
case .connecting: return .gray
|
||||
case .reconnecting: return .orange
|
||||
case .failed: return .red
|
||||
case .exited: return .indigo
|
||||
}
|
||||
}
|
||||
|
||||
private var text: String {
|
||||
switch model {
|
||||
case .connecting:
|
||||
return "连接中…"
|
||||
case .reconnecting(let attempt, let retryIn):
|
||||
return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…"
|
||||
case .failed(let message):
|
||||
return message
|
||||
case .exited(let code, let reason):
|
||||
return Self.exitText(code: code, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads
|
||||
/// as a conclusion. `reason` is server-supplied display text (untrusted —
|
||||
/// rendered as plain text only).
|
||||
private static func exitText(code: Int, reason: String?) -> String {
|
||||
if code == WireConstants.spawnFailedExitCode {
|
||||
return "启动 shell 失败:\(reason ?? "未知原因")"
|
||||
}
|
||||
let suffix = reason.map { "(\($0))" } ?? ""
|
||||
return "会话已退出 exit \(code)\(suffix) · 终端已只读"
|
||||
}
|
||||
}
|
||||
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal file
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Telemetry chips for a session-list row — the iOS mirror of the
|
||||
/// web's `renderTelemetryGauge` (public/preview-grid.ts:197): context-usage %
|
||||
/// (warn > 80), $cost to 4 places, model chip, PR badge.
|
||||
///
|
||||
/// Staleness: chips grey out when `StatusTelemetry.at` is STRICTLY older than
|
||||
/// `Tunables.telemetryStaleTtlMs` (same `>` rule as the web's `tg-stale`).
|
||||
/// The comparison lives in `Model`, computed from an injected `nowMs` so the
|
||||
/// ViewModel tests are deterministic (task RED list).
|
||||
///
|
||||
/// Security (mirrors SEC-H5/SEC-L5): every telemetry string is server-supplied
|
||||
/// UNTRUSTED input — rendered as plain `Text` only; the PR badge becomes a
|
||||
/// tappable `Link` iff its URL parses as https, otherwise it renders as text.
|
||||
struct TelemetryChips: View {
|
||||
/// Immutable render input, derived once per list rebuild.
|
||||
struct Model: Equatable, Sendable {
|
||||
let contextUsedPct: Double?
|
||||
/// Pre-formatted `$X.XXXX` (mirrors the web's `toFixed(4)`).
|
||||
let costText: String?
|
||||
let modelName: String?
|
||||
/// Pre-formatted `PR #N`.
|
||||
let prText: String?
|
||||
let prReviewState: String?
|
||||
/// Tappable PR destination — https URLs ONLY (SEC-L5 mirror), else nil.
|
||||
let prURL: URL?
|
||||
/// True when `at` is strictly older than `Tunables.telemetryStaleTtlMs`.
|
||||
let isStale: Bool
|
||||
|
||||
/// nil when the session has no telemetry at all (row renders no chips).
|
||||
init?(telemetry: StatusTelemetry?, nowMs: Int) {
|
||||
guard let telemetry else { return nil }
|
||||
isStale = nowMs - telemetry.at > Tunables.telemetryStaleTtlMs
|
||||
contextUsedPct = telemetry.contextUsedPct
|
||||
costText = telemetry.costUsd.map { String(format: Format.cost, $0) }
|
||||
modelName = telemetry.model
|
||||
if let pr = telemetry.pr {
|
||||
prText = "PR #\(pr.number)"
|
||||
prReviewState = pr.reviewState
|
||||
prURL = Self.httpsOnlyURL(pr.url)
|
||||
} else {
|
||||
prText = nil
|
||||
prReviewState = nil
|
||||
prURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `ctx NN%` — same rounding as the web label (`Math.round`).
|
||||
var contextText: String? {
|
||||
contextUsedPct.map { "ctx \(Int($0.rounded()))%" }
|
||||
}
|
||||
|
||||
/// Mirrors the web's `tg-ctx-warn` threshold (strictly > 80).
|
||||
var isContextWarning: Bool {
|
||||
(contextUsedPct ?? 0) > Format.contextWarnPct
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
contextUsedPct == nil && costText == nil && modelName == nil && prText == nil
|
||||
}
|
||||
|
||||
/// SEC-L5 mirror: only https URLs are ever offered as link targets.
|
||||
static func httpsOnlyURL(_ raw: String) -> URL? {
|
||||
guard let url = URL(string: raw),
|
||||
url.scheme?.lowercased() == Format.httpsScheme
|
||||
else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
let model: Model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Metrics.chipSpacing) {
|
||||
if let contextText = model.contextText {
|
||||
Text(contextText)
|
||||
.foregroundStyle(model.isContextWarning ? Color.orange : Color.secondary)
|
||||
}
|
||||
if let costText = model.costText {
|
||||
chip(costText)
|
||||
}
|
||||
if let modelName = model.modelName {
|
||||
chip(modelName)
|
||||
}
|
||||
prBadge
|
||||
}
|
||||
.font(.caption2.monospacedDigit())
|
||||
.lineLimit(1)
|
||||
.opacity(model.isStale ? Metrics.staleOpacity : 1)
|
||||
.grayscale(model.isStale ? 1 : 0)
|
||||
}
|
||||
|
||||
@ViewBuilder private var prBadge: some View {
|
||||
if let prText = model.prText {
|
||||
let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText
|
||||
if let url = model.prURL {
|
||||
Link(label, destination: url)
|
||||
} else {
|
||||
chip(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chip(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Metrics.chipHorizontalPadding)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
|
||||
// MARK: - Named constants (no magic numbers, plan §4)
|
||||
|
||||
private enum Metrics {
|
||||
static let chipSpacing: CGFloat = 6
|
||||
static let chipHorizontalPadding: CGFloat = 5
|
||||
static let staleOpacity = 0.45
|
||||
}
|
||||
|
||||
private enum Format {
|
||||
/// Mirrors web `$${costUsd.toFixed(4)}`.
|
||||
static let cost = "$%.4f"
|
||||
/// Mirrors web `tg-ctx-warn` (`contextUsedPct > 80`).
|
||||
static let contextWarnPct = 80.0
|
||||
static let httpsScheme = "https"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user