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:
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