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 /// T-iOS-29 · "开新会话" on the EXITED banner only (session over → the /// natural next step; manager.ts:145-153 — the old session is done for /// good). nil = affordance hidden. Not offered on `.failed`: that copy /// asks the user to change a knob first, not to spawn again. var onNewSession: (@MainActor () -> Void)? = nil private enum Metrics { static let contentSpacing: CGFloat = 8 static let horizontalPadding: CGFloat = 14 static let verticalPadding: CGFloat = 8 static let backgroundOpacity = 0.9 } private enum Copy { static let newSession = "开新会话" } var body: some View { HStack(spacing: Metrics.contentSpacing) { if isSpinning { ProgressView() .controlSize(.small) } else { Image(systemName: iconName) } Text(text) .font(.footnote) .multilineTextAlignment(.leading) if let onNewSession, Self.isNewSessionActionAvailable(for: model) { Button(Copy.newSession) { onNewSession() } .font(.footnote.bold()) .buttonStyle(.plain) .accessibilityIdentifier("terminal.exitNewSessionButton") } } .padding(.horizontal, Metrics.horizontalPadding) .padding(.vertical, Metrics.verticalPadding) .background(tint.opacity(Metrics.backgroundOpacity), in: Capsule()) .foregroundStyle(.white) .accessibilityElement(children: .combine) } /// The new-session affordance appears on the `.exited` banner only — /// pure predicate so the T-iOS-29 tests pin the rule. static func isNewSessionActionAvailable(for model: Model) -> Bool { if case .exited = model { return true } return false } 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) · 终端已只读" } }