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:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View 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, oldestnewest. 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)
}
}

View 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 affordanceClientMessage 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
}
}
}

View 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 labelbytes 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
}
}

View 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
}
}
}

View 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) · 终端已只读"
}
}

View 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"
}
}