import SwiftUI import NucleicProtocol /// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): a deep /// teal accent, the same categorical status palette, and the same status→color mapping /// refined by turn disposition — so the two devices read identically. Pure SwiftUI Color /// (no AppKit), adapting to light/dark via the system. enum Palette { static let accent = Color(red: 0.04, green: 0.52, blue: 0.50) // deep teal static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // blue static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // amber static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red /// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's /// `AppPalette.frozen`. static let frozen = Color(red: 0.26, green: 0.64, blue: 0.96) static let neutral = Color.secondary /// The orchestra (orchestration mode) signature gold — mirrors the Mac's /// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the /// composer's golden ring. static let orchestra = Color(red: 0.90, green: 0.66, blue: 0.16) /// Dot/accent color for a session's status, refined by the last turn's disposition — a /// finished-the-work turn reads as "done" (success) rather than the calm "ready" accent. static func status(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> Color { if status == .awaitingInput, disposition == .completed { return success } switch status { case .idle, .provisioning: return neutral case .running: return active case .awaitingApproval: return attention case .awaitingInput: return accent case .finished: return success case .interrupted: return paused case .error: return danger } } static func activity(level: Int) -> Color { switch level { case 0: Color.secondary.opacity(0.15) case 1: success.opacity(0.4) case 2: success.opacity(0.7) default: success } } /// Activity-grid shade for a continuous brightness in `0…1` (relative to the user's own /// activity, outliers excluded — see `ActivityScale`). `0` is an empty day's neutral gray. /// Mirrors the Mac's `AppPalette.activity(intensity:)`. static func activity(intensity: Double) -> Color { intensity <= 0 ? Color.secondary.opacity(0.15) : success.opacity(intensity) } } /// Status glyph + label, matching the Mac semantics (UX_MACOS §1). enum StatusStyle { static func symbol(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String { if status == .awaitingInput, disposition == .completed { return "checkmark.circle.fill" } switch status { case .idle: return "circle" case .provisioning: return "circle.dotted" case .running: return "circle.lefthalf.filled" case .awaitingApproval: return "exclamationmark.triangle.fill" case .awaitingInput: return "bubble.left.fill" case .finished: return "checkmark.circle.fill" case .interrupted: return "stop.circle" case .error: return "xmark.octagon.fill" } } static func label(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String { if status == .awaitingInput, disposition == .completed { return "Done" } return status.displayName } /// Attention-first ordering for the session list (UX_IOS §3). static func sortRank(_ summary: WireSessionSummary) -> Int { switch summary.status { case .awaitingApproval: return 0 case .awaitingInput: return summary.disposition == .completed ? 4 : 1 case .running, .provisioning: return 2 case .idle: return 3 case .finished, .interrupted, .error: return 5 } } } extension SessionStatus { /// "Needs you" vs background work, mirroring the Mac sidebar's disposition refinement. func needsYou(_ disposition: TurnDisposition?) -> Bool { switch self { case .awaitingApproval: return true case .awaitingInput: return disposition != .completed default: return false } } } extension Risk { var isHigh: Bool { self == .destructive || self == .network || self == .hostExec } var label: String { self == .hostExec ? "host machine" : rawValue } var color: Color { switch self { case .destructive, .network, .hostExec: return Palette.danger case .execute: return Palette.attention case .write: return Color(red: 0.85, green: 0.75, blue: 0.2) case .readOnly, .unknown: return .secondary } } } /// Adds a "Done" button to the keyboard's accessory toolbar so any focused composer can always /// dismiss the keyboard. The multiline composers (`axis: .vertical`) turn Return into a newline, /// so without this there's no way to release the keyboard once it opens. The modifier carries its /// own `@FocusState` and binds it to the field, keeping callers to a single `.keyboardDismissable()`. struct KeyboardDismissable: ViewModifier { @FocusState private var focused: Bool func body(content: Content) -> some View { content .focused($focused) .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button { focused = false } label: { Label("Done", systemImage: "keyboard.chevron.compact.down") } } } } } /// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`. struct CardBackground: ViewModifier { var padding: CGFloat = 14 func body(content: Content) -> some View { content .padding(padding) .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14) .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)) } } extension View { func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) } /// Apply the teal accent app-wide. func nucleicTint() -> some View { tint(Palette.accent) } /// Give a text field a way to release the keyboard. Without this, iOS leaves a focused /// composer no dismiss affordance (no Return key on a vertical/multiline field, nothing to /// tap) and the keyboard stays up. See `KeyboardDismissable`. func keyboardDismissable() -> some View { modifier(KeyboardDismissable()) } /// Wrap a composer field in the orchestra purple ring while `active` (mirrors the Mac's /// animated composer glow, lighter touch). func orchestraGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View { modifier(OrchestraGlowModifier(active: active, cornerRadius: cornerRadius)) } } /// The effort levels the remote offers, mirroring the Mac's `ModelCatalog.efforts` plus /// orchestra. `orchestraSentinel` MUST stay equal to the host's `OrchestrationMode.effortSentinel` /// — it's sent verbatim over the sync wire and resolved on the host (to `xhigh` + standing /// consent), so a mismatch would silently disable the mode. enum MobileEfforts { static let levels = ["low", "medium", "high", "xhigh", "max"] static let orchestraSentinel = "orchestra" static let fallback = "high" static func isOrchestra(_ effort: String) -> Bool { effort == orchestraSentinel } static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort } } extension WireModelCatalog { /// Effort levels to offer for `sku`. When no model is selected yet, fall back to the default /// model's range (not the lone fallback level), and to the built-in list before a `Welcome` /// catalog has arrived (an empty catalog, e.g. demo / older host). func offeredEfforts(forModel sku: String?) -> [String] { guard !groups.isEmpty else { return MobileEfforts.levels } let levels = efforts(forModel: sku ?? fallbackModel) return levels.isEmpty ? MobileEfforts.levels : levels } /// The orchestra sentinel, or the built-in default when no catalog is present. var orchestraSentinelOrFallback: String { orchestraSentinel.isEmpty ? MobileEfforts.orchestraSentinel : orchestraSentinel } /// Pretty label for an effort level, tolerating the empty (pre-`Welcome`) catalog. func offeredEffortDisplayName(_ effort: String) -> String { groups.isEmpty ? MobileEfforts.displayName(effort) : effortDisplayName(effort) } } /// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` — the /// home composer, where the model also selects the backend — shows every provider group with /// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that /// provider's models. Selecting sets the SKU; the binding's `nil` means "host default". struct ModelMenu: View { @Binding var model: String? let catalog: WireModelCatalog var backend: BackendID? = nil private var groups: [[WireModelCatalog.Model]] { if let backend { let models = catalog.models(for: backend) return models.isEmpty ? [] : [models] } return catalog.groups } private var label: String { model.map { catalog.displayName($0) } ?? "Default model" } var body: some View { Menu { ForEach(Array(groups.enumerated()), id: \.offset) { index, group in if index > 0 { Divider() } ForEach(group) { item in Button { model = item.sku } label: { menuLabel(item) } } } } label: { HStack(spacing: 4) { Image(systemName: "cpu") Text(label) if let badge = model.flatMap({ catalog.contextBadge($0) }) { Text(badge).foregroundStyle(.secondary) } } .font(.caption) .foregroundStyle(Palette.accent) } .disabled(groups.isEmpty) } @ViewBuilder private func menuLabel(_ item: WireModelCatalog.Model) -> some View { let name = item.contextBadge.map { "\(item.displayName) · \($0)" } ?? item.displayName if item.sku == model { Label(name, systemImage: "checkmark") } else { Text(name) } } } /// A compact effort selector for the remote composers, driven by the wire catalog so it offers /// exactly the levels the chosen model supports (Codex caps at xhigh, Grok exposes only Auto). /// Orchestra sits below the API levels with a sparkles glyph and gold accent; it's a Nucleic /// Control capability, so it's only selectable when the target project is under Control. struct EffortMenu: View { @Binding var effort: String let catalog: WireModelCatalog /// The model whose effort caps apply (nil → fall back to the built-in level list). var modelSKU: String? = nil /// Whether the target project is under Nucleic Control (gates Orchestra). var controlled: Bool = false private var levels: [String] { catalog.offeredEfforts(forModel: modelSKU) } private var sentinel: String { catalog.orchestraSentinelOrFallback } private func isOrchestra(_ e: String) -> Bool { e.caseInsensitiveCompare(sentinel) == .orderedSame } private func display(_ e: String) -> String { isOrchestra(e) ? "Orchestra" : catalog.offeredEffortDisplayName(e) } /// Orchestra only counts as active where it's permitted; a stray selection on a non-Control /// project (or a level the current model doesn't support) reads as the nearest supported level /// so the trigger never shows something that can't run. private var orchestraActive: Bool { controlled && isOrchestra(effort) } private var triggerEffort: String { if orchestraActive { return effort } if isOrchestra(effort) { return levels.last ?? MobileEfforts.fallback } return levels.contains(effort) ? effort : (levels.last ?? MobileEfforts.fallback) } var body: some View { Menu { ForEach(levels, id: \.self) { level in Button { effort = level } label: { if effort == level { Label(display(level), systemImage: "checkmark") } else { Text(display(level)) } } } Divider() Button { effort = sentinel } label: { Label( controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control", systemImage: orchestraActive ? "checkmark" : "sparkles") } .disabled(!controlled) } label: { HStack(spacing: 4) { Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3") Text(display(triggerEffort)) } .font(.caption) .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) } } } /// A bright golden ring that throbs around a composer while orchestra is selected — the /// remote echo of the Mac's `OrchestraGlow`: the same bold, high-amplitude swell on a slow /// beat with a long pause between pulses. Honors Reduce Motion (holds steady). struct OrchestraGlowModifier: ViewModifier { var active: Bool var cornerRadius: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var pulsing = false func body(content: Content) -> some View { let animating = active && pulsing && !reduceMotion return content .overlay { if active { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .strokeBorder(Palette.orchestra.opacity(animating ? 0.95 : 0.5), lineWidth: animating ? 2.2 : 1.3) .shadow(color: Palette.orchestra.opacity(animating ? 0.7 : 0.22), radius: animating ? 13 : 4) .shadow(color: Palette.orchestra.opacity(animating ? 0.4 : 0.06), radius: animating ? 20 : 4) .animation( reduceMotion ? .default : .easeInOut(duration: 2.6).repeatForever(autoreverses: true), value: pulsing) .allowsHitTesting(false) } } .onAppear { pulsing = active } .onChange(of: active) { _, now in pulsing = now } } }