ios: full feature/appearance parity with the macOS app (client-side)

Port the Mac's MarkdownText renderer, AppPalette (color-vision modes +
night-softening), appearance settings (theme/text size/color vision),
and BuildBanner channel strip to the iPhone remote. Add session-row
attention/unseen-completion washes and marker icons, Discard action,
branch/worktree options in the new-chat composer, and a transient
error bubble for host wire errors. Stamp the build channel via
NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL,
default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the
notifications prompt so UI previews are scriptable.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-02 16:06:32 -07:00
co-authored by Claude Fable 5
parent 79a28605b3
commit a33ab80313
11 changed files with 843 additions and 47 deletions
+2
View File
@@ -6,5 +6,7 @@
<array>
<string>_nucleic._tcp</string>
</array>
<key>NucleicChannel</key>
<string>$(NUCLEIC_CHANNEL)</string>
</dict>
</plist>
@@ -50,6 +50,42 @@ final class RemoteStore: ObservableObject {
@Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = []
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable {
let id = UUID()
let message: String
let sessionID: SessionID?
}
@Published var lastError: LastError?
private var errorDismissTask: Task<Void, Never>?
/// When each session was last opened on this device, for the green "finished while you
/// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
/// doesn't carry the host's flag, so the phone tracks its own view locally).
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
return raw.reduce(into: [:]) { result, entry in
if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
}
}
private func persistLastOpened() {
let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
}
/// Whether `summary` completed its work after the user last looked at it on this device.
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
let done = summary.status == .finished
|| (summary.status == .awaitingInput && summary.disposition == .completed)
guard done, summary.sessionID != openSessionID else { return false }
guard let opened = lastOpenedAt[summary.sessionID] else { return true }
return summary.updatedAt > opened
}
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
@@ -205,10 +241,17 @@ final class RemoteStore: ObservableObject {
openEvents = []
openApprovals = []
seenSeq.removeAll()
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date()
persistLastOpened()
}
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
private func seedDemoTranscript(_ sessionID: SessionID) {
@@ -237,7 +280,10 @@ final class RemoteStore: ObservableObject {
}
func closeOpen() {
if let id = openSessionID { send(.unsubscribe(id)) }
if let id = openSessionID {
send(.unsubscribe(id))
markOpened(id) // everything up to now has been seen
}
openSessionID = nil
openEvents = []
openApprovals = []
@@ -290,6 +336,8 @@ final class RemoteStore: ObservableObject {
if id == openSessionID { closeOpen() }
}
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(id)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
@@ -370,8 +418,12 @@ final class RemoteStore: ObservableObject {
}
case .approvalResolved(let resolved):
openApprovals.removeAll { $0.id == resolved.id }
case .wireError:
break // surfaced contextually by callers; not fatal
case .wireError(let error):
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
// Losing an approval race isn't an error worth interrupting for; the card
// collapses on the matching `approvalResolved`.
guard error.code != .alreadyResolved else { break }
showError(error.message, sessionID: error.sessionID)
case .failed(let message):
connectivity = .failed(message)
scheduleRetry()
@@ -381,6 +433,24 @@ final class RemoteStore: ObservableObject {
}
}
/// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
/// (matching the Mac's last-error overlay cadence).
private func showError(_ message: String, sessionID: SessionID?) {
let error = LastError(message: message, sessionID: sessionID)
lastError = error
errorDismissTask?.cancel()
errorDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(6))
guard let self, self.lastError == error else { return }
self.lastError = nil
}
}
func dismissError() {
errorDismissTask?.cancel()
lastError = nil
}
private func scheduleRetry() {
guard isPaired else { return }
reconnectAttempts += 1
@@ -14,7 +14,10 @@ struct NucleicRemoteApp: App {
store.onAppear()
// Surface the notifications prompt + register for APNS. The token rides along
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" {
PushRegistrar.shared.requestAuthorizationAndRegister()
}
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
Task { await HeartbeatReporter.reportIfDue() }
}
@@ -30,8 +33,16 @@ struct RootView: View {
@EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
VStack(spacing: 0) {
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
BuildBanner()
Group {
if store.isPaired {
TabView(selection: $tab) {
@@ -51,6 +62,41 @@ struct RootView: View {
PairingIntroView()
}
}
}
.overlay(alignment: .bottom) { ErrorBubble() }
.tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
// The palette resolves against the stored color-vision mode at render time; rebuild
// the hierarchy when it changes so every cached view picks up the remapped colors.
.id(colorVisionRaw)
}
}
/// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error
/// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap
/// dismisses it immediately.
private struct ErrorBubble: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
if let error = store.lastError {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
Text(error.message).lineLimit(3)
Spacer(minLength: 0)
Image(systemName: "xmark").opacity(0.7)
}
.font(.footnote.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Palette.danger, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.bottom, 58) // clear the tab bar
.onTapGesture { store.dismissError() }
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut(duration: 0.2), value: store.lastError)
}
}
}
@@ -0,0 +1,133 @@
import SwiftUI
/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`.
/// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`,
/// see scripts/ios-release.sh); unset builds fall back to runtime detection (Debug → local,
/// TestFlight → beta, App Store → release).
enum BuildChannel {
/// The `dev` channel — red "Local Development Build" banner.
case local
/// The `canary` channel — canary-yellow "Canary" banner with the bird. The bleeding-edge
/// distributed channel, cut before beta (see BUILD.md).
case canary
/// The `beta` channel — blue "Beta" banner.
case beta
/// The `rc` channel — gold "Release Candidate" banner.
case releaseCandidate
/// The `stable` channel — no banner.
case release
}
/// Build identity for the running app: which channel it came from and its build label.
/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from
/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion`
/// plays the same role here.
struct BuildInfo {
let channel: BuildChannel
let buildLabel: String
/// `CFBundleShortVersionString` when present, else `nil`.
let version: String?
/// Resolved once — none of this changes while the app is running.
static let current = BuildInfo()
init() {
let info = Bundle.main.infoDictionary
version = info?["CFBundleShortVersionString"] as? String
buildLabel = (info?["CFBundleVersion"] as? String) ?? "local"
channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String)
}
/// Prefer the archive-time stamp (mirrors the Mac's `NUCLEIC_CHANNEL` build setting);
/// fall back to what the runtime can tell on its own.
private static func detectChannel(stamped: String?) -> BuildChannel {
switch stamped?.lowercased() {
case "dev", "local": return .local
case "canary": return .canary
case "beta": return .beta
case "rc": return .releaseCandidate
case "stable", "release": return .release
default: break
}
#if DEBUG
return .local
#else
// TestFlight installs carry the sandbox receipt; App Store installs don't.
if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { return .beta }
return .release
#endif
}
}
/// Header strip warning that this is a non-release build — the same channel colors, icons,
/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue
/// for beta, gold for a release candidate. The leading edge names the channel; the build
/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for
/// a shipping App Store release.
struct BuildBanner: View {
var info: BuildInfo = .current
var body: some View {
if let style = Style(channel: info.channel) {
HStack(spacing: 8) {
Image(systemName: style.icon)
.font(.system(size: 15, weight: .heavy))
Text(style.label)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 12)
Text(info.buildLabel)
.font(.caption.monospaced())
.opacity(0.85)
Text("EXPERIMENTAL")
.font(.caption2.weight(.heavy))
.tracking(0.9)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.white.opacity(0.16), in: .capsule)
}
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 5)
.frame(maxWidth: .infinity)
.background(style.tint.opacity(0.5))
.accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel))
}
}
/// Per-channel appearance — identical constants to the Mac. `nil` for `.release` so the
/// banner disappears entirely.
private struct Style {
var tint: Color
var label: String
var icon: String
init?(channel: BuildChannel) {
switch channel {
case .local:
tint = Color(red: 0.78, green: 0.18, blue: 0.18) // red
label = "Local Development Build"
icon = "hammer.fill"
case .canary:
tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow
label = "Canary Build"
icon = "bird.fill"
case .beta:
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
label = "Engineering Beta"
icon = "person.2.badge.gearshape.fill"
case .releaseCandidate:
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
label = "Release Candidate"
icon = "checkmark.seal.fill"
case .release:
return nil
}
}
func tooltip(version: String?, build: String) -> String {
let v = version.map { "\($0) " } ?? ""
return "\(label) — \(v)build \(build). Not a release build."
}
}
}
@@ -10,6 +10,10 @@ struct StartChatComposer: View {
@State private var auto = false
@State private var model: String?
@State private var effort = MobileEfforts.fallback
// Advanced options (the Mac new-session sheet's base branch + worktree fields).
@State private var showOptions = false
@State private var baseBranch = ""
@State private var useWorktree = true
private var projects: [WireProject] { store.dashboard.projects }
private var selected: WireProject? {
@@ -42,6 +46,32 @@ struct StartChatComposer: View {
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
Button {
withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() }
} label: {
Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle")
.font(.caption)
.foregroundStyle(Palette.accent)
}
.buttonStyle(.plain)
}
if showOptions {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary)
TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.font(.caption)
}
Toggle(isOn: $useWorktree) {
Label("Run in a worktree", systemImage: "square.stack.3d.up")
.font(.caption)
}
.tint(Palette.accent)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
HStack(alignment: .bottom, spacing: 8) {
TextField("Describe a task…", text: $draft, axis: .vertical)
@@ -51,7 +81,11 @@ struct StartChatComposer: View {
.keyboardDismissable()
Button {
if let project = selected {
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
store.startChat(
in: project.id, message: draft, model: model, effort: effort,
baseBranch: branch.isEmpty ? nil : branch,
useWorktree: useWorktree, auto: auto)
draft = ""
}
} label: {
@@ -11,6 +11,7 @@ struct SessionDetailView: View {
@State private var showRename = false
@State private var renameDraft = ""
@State private var showIntegrate = false
@State private var showDiscard = false
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
@@ -56,6 +57,13 @@ struct SessionDetailView: View {
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
.onDisappear { store.closeOpen() }
}
@@ -147,6 +155,9 @@ struct SessionDetailView: View {
}
Section {
Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") }
Button(role: .destructive) { showDiscard = true } label: {
Label("Discard…", systemImage: "xmark.bin")
}
Button { store.setArchived(sessionID, !summary.archived) } label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
@@ -6,6 +6,7 @@ import NucleicProtocol
struct SessionsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showArchived = false
@State private var path = NavigationPath()
private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions)
@@ -20,7 +21,7 @@ struct SessionsView: View {
}
var body: some View {
NavigationStack {
NavigationStack(path: $path) {
Group {
if store.liveSessions.isEmpty && !showArchived {
ContentUnavailableView(
@@ -33,11 +34,10 @@ struct SessionsView: View {
ForEach(grouped, id: \.title) { group in
Section(group.title.uppercased()) {
ForEach(group.rows, id: \.sessionID) { summary in
NavigationLink {
SessionDetailView(sessionID: summary.sessionID)
} label: {
NavigationLink(value: summary.sessionID) {
SessionRow(summary: summary)
}
.listRowBackground(SessionRowWash(summary: summary))
.swipeActions(edge: .leading) {
Button {
store.setFavorite(summary.sessionID, !summary.favorite)
@@ -65,6 +65,16 @@ struct SessionsView: View {
}
}
.navigationTitle("Sessions")
.navigationDestination(for: SessionID.self) { id in
SessionDetailView(sessionID: id)
}
// Offline UI previews (with NUCLEIC_DEMO): jump straight into one session.
.onAppear {
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"],
!raw.isEmpty, path.isEmpty {
path.append(SessionID(rawValue: raw))
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
@@ -80,11 +90,18 @@ struct SessionsView: View {
}
struct SessionRow: View {
@EnvironmentObject var store: RemoteStore
let summary: WireSessionSummary
/// Show the project name as subtext. Suppressed when the row already lives inside
/// its project's own section (e.g. `ProjectDetailView`), where the name is redundant.
var showProjectName = true
/// Whether Orchestra is latched on (the effort carries the sentinel).
private var orchestra: Bool {
guard let effort = summary.effort else { return false }
return effort.caseInsensitiveCompare(store.modelCatalog.orchestraSentinelOrFallback) == .orderedSame
}
var body: some View {
HStack(spacing: 12) {
Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition))
@@ -97,6 +114,17 @@ struct SessionRow: View {
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
}
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
// The same trailing markers the Mac sidebar row carries: auto-approval
// bolt, Orchestra note, autoship box.
if summary.auto {
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
}
if orchestra {
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
}
if summary.autoShip {
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
}
}
if showProjectName {
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
@@ -121,3 +149,31 @@ struct SessionRow: View {
.padding(.vertical, 2)
}
}
/// The Mac sidebar's row washes, mirrored: a breathing amber fill on a chat that's blocked on
/// you, and a steady green fill on a chat that finished while you weren't looking. Applied as
/// a `listRowBackground` so the wash sits under the whole row.
struct SessionRowWash: View {
@EnvironmentObject var store: RemoteStore
let summary: WireSessionSummary
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pulsing = false
var body: some View {
ZStack {
Color(.secondarySystemGroupedBackground) // the list row's normal surface
if summary.status.needsYou(summary.disposition) {
// Breathing amber (0.10 → 0.30 on a 1.3s ease, matching the Mac); a steady
// mid-tint under Reduce Motion.
Palette.attention.opacity(reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10))
.animation(
reduceMotion ? nil
: .easeInOut(duration: 1.3).repeatForever(autoreverses: true),
value: pulsing)
.onAppear { pulsing = true }
} else if store.unseenCompletion(summary) {
Palette.success.opacity(0.18)
}
}
}
}
@@ -7,10 +7,35 @@ struct SettingsView: View {
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
NavigationStack {
Form {
Section {
Picker("Theme", selection: $appearanceRaw) {
ForEach(AppAppearance.allCases) { appearance in
Text(appearance.label).tag(appearance.rawValue)
}
}
Picker("Text size", selection: $textSizeRaw) {
ForEach(AppTextSize.allCases) { size in
Text(size.label).tag(size.rawValue)
}
}
Picker("Color vision", selection: $colorVisionRaw) {
ForEach(ColorVisionMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
} header: {
Text("Appearance")
} footer: {
Text("Color vision remaps status colors to hues that stay distinguishable. Every status also carries a text label, so color is never the only cue.")
}
Section("Connection") {
LabeledContent("Status", value: store.connectivity.label)
if !store.hostName.isEmpty {
+202 -19
View File
@@ -1,25 +1,208 @@
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.
/// The user's preferred appearance (Settings → Appearance). `system` follows iOS.
/// Same storage key and semantics as the Mac's `AppAppearance`.
enum AppAppearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { rawValue }
static let storageKey = "nucleic.appearance"
var label: String {
switch self {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
/// `nil` means follow the system setting.
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
/// User-selectable global text size (Settings → Appearance), applied app-wide via
/// `.dynamicTypeSize` so the semantic fonts the UI uses scale together. Mirrors the Mac's
/// `AppTextSize` (same storage key and steps).
enum AppTextSize: String, CaseIterable, Identifiable {
case small, medium, large, xLarge, xxLarge
var id: String { rawValue }
static let storageKey = "nucleic.textSize"
var label: String {
switch self {
case .small: "Small"
case .medium: "Default"
case .large: "Large"
case .xLarge: "Larger"
case .xxLarge: "Largest"
}
}
/// `.large` is the system default content size, so "Default" maps to it.
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: .small
case .medium: .large
case .large: .xLarge
case .xLarge: .xxLarge
case .xxLarge: .xxxLarge
}
}
}
/// Color-vision mode. Remaps the categorical palette (status dots, activity grid, accent) to
/// hues that stay distinguishable under the common color-vision deficiencies — the same
/// palettes as the Mac's `ColorVisionMode`/`AppPalette`, same storage key, so both devices
/// read identically for the same user.
enum ColorVisionMode: String, CaseIterable, Identifiable {
case standard, deuteranopia, protanopia, tritanopia
var id: String { rawValue }
static let storageKey = "nucleic.colorVision"
var label: String {
switch self {
case .standard: "Standard"
case .deuteranopia: "Deuteranopia (red–green)"
case .protanopia: "Protanopia (red–green)"
case .tritanopia: "Tritanopia (blue–yellow)"
}
}
/// The persisted mode (Settings → Appearance).
static var stored: ColorVisionMode {
ColorVisionMode(
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? "") ?? .standard
}
}
/// A resolved set of semantic colors for one `ColorVisionMode` — the mobile mirror of the
/// Mac's `AppPalette`, including its night-softening: vivid in light mode, desaturated and
/// dimmed in dark so status accents read calmly instead of glowing.
struct ResolvedPalette {
var accent: Color
var neutral: Color
var active: Color
var attention: Color
var success: Color
var paused: Color
var danger: Color
var frozen: Color
var activityBase: Color
/// The vivid, light-mode categorical colors (identical constants to the Mac's
/// `AppPalette.base`); `make` night-softens them for dark mode.
private static func base(_ mode: ColorVisionMode) -> ResolvedPalette {
switch mode {
case .standard:
ResolvedPalette(
accent: Color(red: 0.04, green: 0.52, blue: 0.50), // deep teal
neutral: .secondary,
active: Color(red: 0.20, green: 0.55, blue: 0.95), // blue
attention: Color(red: 1.00, green: 0.65, blue: 0.15), // amber
success: Color(red: 0.30, green: 0.80, blue: 0.45), // green
paused: Color(red: 0.62, green: 0.49, blue: 0.93), // purple
danger: Color(red: 0.92, green: 0.34, blue: 0.34), // red
frozen: Color(red: 0.26, green: 0.64, blue: 0.96), // cold "iced" blue
activityBase: Color(red: 0.30, green: 0.80, blue: 0.45))
case .deuteranopia, .protanopia:
// Okabe–Ito-derived: avoids relying on red↔green separation.
ResolvedPalette(
accent: Color(red: 0.00, green: 0.50, blue: 0.58), // deep teal
neutral: .secondary,
active: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
attention: Color(red: 0.90, green: 0.62, blue: 0.00), // orange
success: Color(red: 0.34, green: 0.71, blue: 0.91), // sky blue
paused: Color(red: 0.80, green: 0.47, blue: 0.65), // reddish purple
danger: Color(red: 0.84, green: 0.37, blue: 0.00), // vermillion
frozen: Color(red: 0.20, green: 0.58, blue: 0.86), // cool sky blue
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
case .tritanopia:
// Blue↔yellow weak; red↔green is intact, so lean on those plus magenta.
ResolvedPalette(
accent: Color(red: 0.46, green: 0.33, blue: 0.78), // indigo-violet
neutral: .secondary,
active: Color(red: 0.00, green: 0.62, blue: 0.45), // green-teal
attention: Color(red: 0.85, green: 0.20, blue: 0.46), // magenta
success: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
paused: Color(red: 0.67, green: 0.36, blue: 0.66), // purple
danger: Color(red: 0.79, green: 0.10, blue: 0.15), // red
frozen: Color(red: 0.30, green: 0.72, blue: 0.85), // light cyan
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
}
}
static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
var p = base(mode)
p.accent = Palette.nightSoft(p.accent)
p.active = Palette.nightSoft(p.active)
p.attention = Palette.nightSoft(p.attention)
p.success = Palette.nightSoft(p.success)
p.paused = Palette.nightSoft(p.paused)
p.danger = Palette.nightSoft(p.danger)
p.frozen = Palette.nightSoft(p.frozen)
p.activityBase = Palette.nightSoft(p.activityBase)
return p
}
}
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): the same
/// categorical status palette per color-vision mode, night-softened in dark mode, and the
/// same status→color mapping refined by turn disposition — so the two devices read
/// identically. The accessors resolve against the persisted `ColorVisionMode` on each read;
/// the resolved palettes themselves are built once.
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
private static let resolved: [ColorVisionMode: ResolvedPalette] = Dictionary(
uniqueKeysWithValues: ColorVisionMode.allCases.map { ($0, ResolvedPalette.make($0)) })
static var current: ResolvedPalette { resolved[ColorVisionMode.stored] ?? resolved[.standard]! }
static var accent: Color { current.accent }
static var active: Color { current.active }
static var attention: Color { current.attention }
static var success: Color { current.success }
static var paused: Color { current.paused }
static var danger: Color { current.danger }
/// 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 var frozen: Color { current.frozen }
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)
/// The orchestra (orchestration mode) signature gold — the exact appearance-aware pair the
/// Mac's `AppTheme.orchestra` uses: a rich amber-gold in light (dark enough to read on
/// white), a bright warm gold in dark so it glows without burning. A brand color, not a
/// categorical status, so it doesn't remap with color-vision mode.
static let orchestra = dynamic(
light: UIColor(red: 0.80, green: 0.57, blue: 0.09, alpha: 1),
dark: UIColor(red: 1.0, green: 0.82, blue: 0.34, alpha: 1))
/// Primary prose color for chat text — the Mac's `AppTheme.primaryText`: a soft off-white
/// on the deep dark background (reads calmly at night) and a near-black in light.
static let primaryText = dynamic(
light: UIColor(red: 0.24, green: 0.24, blue: 0.27, alpha: 1),
dark: UIColor(red: 0.80, green: 0.81, blue: 0.83, alpha: 1))
/// A `Color` that resolves to `light`/`dark` with the active trait collection (driven by
/// `.preferredColorScheme`) — the UIKit twin of the Mac's `NSColor(name:)` provider.
private static func dynamic(light: UIColor, dark: UIColor) -> Color {
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light })
}
/// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same
/// night-softening curve as the Mac's `AppPalette.nightSoft`.
static func nightSoft(_ base: Color) -> Color {
let ui = UIColor(base)
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
let dark = UIColor(hue: h, saturation: s * 0.68, brightness: min(b, 0.80) * 0.82, alpha: a)
return dynamic(light: ui, dark: dark)
}
/// 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.
@@ -39,9 +222,9 @@ enum Palette {
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
case 1: current.activityBase.opacity(0.4)
case 2: current.activityBase.opacity(0.7)
default: current.activityBase
}
}
@@ -49,7 +232,7 @@ enum Palette {
/// 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)
intensity <= 0 ? Color.secondary.opacity(0.15) : current.activityBase.opacity(intensity)
}
}
@@ -0,0 +1,233 @@
import Foundation
import SwiftUI
/// A lightweight Markdown renderer for chat messages — the exact port of the Mac's
/// `MarkdownText`: fenced code blocks, headings, bullet/numbered lists, GitHub-style tables,
/// and inline emphasis/links/`code`. Not a full CommonMark implementation — just the
/// constructs agents actually emit — so a reply renders identically on both devices.
struct MarkdownText: View {
let markdown: String
/// Base prose size; every other size (headings, code, tables) is derived from it so a
/// response renders on one consistent scale. Scaled with the user's text-size setting
/// (which drives `.dynamicTypeSize`), anchored to the Mac's transcript prose size.
@ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = 15
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(Self.parse(markdown).enumerated()), id: \.offset) { _, block in
switch block {
case .code(let code):
codeBlock(code)
case .text(let text):
textBlock(text)
case .table(let rows):
tableView(rows)
}
}
}
// Anchor the whole response to one base size; inline code and body text
// inherit it, so nothing drifts smaller than the prose around it.
.font(.system(size: bodySize))
// Soft off-white (vs. pure white) so chat prose reads calmly at night.
.foregroundStyle(Palette.primaryText)
}
// MARK: - blocks
private enum Block { case code(String), text(String), table([[String]]) }
/// Parsed-block cache. The same message string is re-parsed on every body re-evaluation
/// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing
/// is independent of `bodySize` — so the source string is a complete key. Bounded;
/// `NSCache` also evicts under memory pressure.
private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } }
private static let blockCache: NSCache<NSString, ParsedBlocks> = {
let cache = NSCache<NSString, ParsedBlocks>()
cache.countLimit = 2048
return cache
}()
private static func parse(_ markdown: String) -> [Block] {
let key = markdown as NSString
if let hit = blockCache.object(forKey: key) { return hit.blocks }
let blocks = parseUncached(markdown)
blockCache.setObject(ParsedBlocks(blocks), forKey: key)
return blocks
}
private static func parseUncached(_ markdown: String) -> [Block] {
var blocks: [Block] = []
var textBuffer: [String] = []
func flush() {
let joined = textBuffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !joined.isEmpty { blocks.append(.text(joined)) }
textBuffer = []
}
let lines = markdown.components(separatedBy: "\n")
var index = 0
while index < lines.count {
if lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
flush()
var code: [String] = []
index += 1
while index < lines.count,
!lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
code.append(lines[index])
index += 1
}
blocks.append(.code(code.joined(separator: "\n")))
index += 1 // consume closing fence
} else if isTableStart(lines, index) {
flush()
var rows: [[String]] = [tableCells(lines[index])]
index += 2 // header row + the |---|--- separator
while index < lines.count, lines[index].contains("|"),
!lines[index].trimmingCharacters(in: .whitespaces).isEmpty {
rows.append(tableCells(lines[index]))
index += 1
}
blocks.append(.table(rows))
} else {
textBuffer.append(lines[index])
index += 1
}
}
flush()
return blocks
}
// MARK: - tables
/// A GitHub-style table: a `|`-bearing header line immediately followed by a
/// `|---|:--:|` separator line.
private static func isTableStart(_ lines: [String], _ index: Int) -> Bool {
guard lines[index].contains("|"), index + 1 < lines.count else { return false }
return isSeparatorRow(lines[index + 1])
}
private static func isSeparatorRow(_ line: String) -> Bool {
let cells = tableCells(line)
guard !cells.isEmpty else { return false }
return cells.allSatisfy { cell in
!cell.isEmpty && cell.allSatisfy { $0 == "-" || $0 == ":" } && cell.contains("-")
}
}
/// Split a table row into trimmed cells, dropping the empties created by the
/// leading/trailing pipes.
private static func tableCells(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("|") { trimmed.removeFirst() }
if trimmed.hasSuffix("|") { trimmed.removeLast() }
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
}
private func tableView(_ rows: [[String]]) -> some View {
let columns = rows.map(\.count).max() ?? 0
return Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 6) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
GridRow {
ForEach(0..<columns, id: \.self) { col in
inline(col < row.count ? row[col] : "")
.font(.system(size: bodySize, weight: rowIndex == 0 ? .semibold : .regular))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if rowIndex == 0, columns > 0 {
Divider().gridCellColumns(columns)
}
}
}
.padding(10)
.background(.quaternary.opacity(0.25), in: .rect(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary, lineWidth: 1))
}
private func codeBlock(_ code: String) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
Text(code)
// Same point size as body/inline code, just monospaced — so code
// doesn't shrink relative to the prose.
.font(.system(size: bodySize, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 6))
}
private func textBlock(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, raw in
lineView(raw)
}
}
// Breathing room between the visual lines of a single wrapped paragraph,
// which the per-line VStack spacing above can't reach.
.lineSpacing(4)
}
@ViewBuilder
private func lineView(_ raw: String) -> some View {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
Color.clear.frame(height: 3)
} else if trimmed.hasPrefix("### ") {
// Headings scale relative to the base prose size so the hierarchy holds
// at any base and never collapses to the body size.
inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold))
} else if trimmed.hasPrefix("## ") {
inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold))
} else if trimmed.hasPrefix("# ") {
inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold))
} else if let bullet = Self.bulletContent(trimmed) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•").foregroundStyle(.secondary)
inline(bullet)
}
} else {
inline(raw)
}
}
/// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil.
private static func bulletContent(_ trimmed: String) -> String? {
for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) {
return String(trimmed.dropFirst(marker.count))
}
// Numbered list: "12. text"
let parts = trimmed.split(separator: " ", maxSplits: 1)
if let first = parts.first, parts.count == 2,
first.hasSuffix("."), Int(first.dropLast()) != nil {
return String(parts[1])
}
return nil
}
private func inline(_ string: String) -> Text {
Text(Self.attributedInline(string))
}
/// Inline-Markdown cache. `AttributedString(markdown:)` is the dominant per-row cost
/// when a transcript first lays out — it runs once per prose line — and the same lines
/// recur across re-renders and reopens, so memoize the parsed result. Independent of
/// `bodySize` (callers apply the font), so the source string is a complete key.
private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } }
private static let inlineCache: NSCache<NSString, InlineBox> = {
let cache = NSCache<NSString, InlineBox>()
cache.countLimit = 16384
return cache
}()
private static func attributedInline(_ string: String) -> AttributedString {
let key = string as NSString
if let hit = inlineCache.object(forKey: key) { return hit.value }
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
// Fall back to the plain string on parse failure — `Text(AttributedString(string))`
// renders identically to `Text(string)`, so callers see no behavioral change.
let parsed = (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
inlineCache.setObject(InlineBox(parsed), forKey: key)
return parsed
}
}
@@ -60,8 +60,9 @@ struct TranscriptRow: View {
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
}
/// Render an assistant/user message as Markdown (inline bold/italic/code/links), preserving the
/// line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
struct MessageBubble: View {
let role: TranscriptItem.Role
let text: String
@@ -69,7 +70,7 @@ struct MessageBubble: View {
var body: some View {
HStack {
if role == .user { Spacer(minLength: 40) }
Text(Self.markdown(text))
MarkdownText(markdown: text)
.textSelection(.enabled)
.padding(10)
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
@@ -79,6 +80,8 @@ struct MessageBubble: View {
}
}
/// Inline-only Markdown for one-line contexts (host-exec purpose lines etc.), where the
/// block renderer above would be overkill.
static func markdown(_ string: String) -> AttributedString {
(try? AttributedString(markdown: string, options: .init(
interpretedSyntax: .inlineOnlyPreservingWhitespace,