diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist
index 8eb7f6c..119a3e9 100644
--- a/NucleicRemote/NucleicRemote/Info.plist
+++ b/NucleicRemote/NucleicRemote/Info.plist
@@ -6,5 +6,7 @@
_nucleic._tcp
+ NucleicChannel
+ $(NUCLEIC_CHANNEL)
diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
index 1698841..cec5d59 100644
--- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
+++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
@@ -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?
+
+ /// 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
diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift
index 4d5fd12..fbed750 100644
--- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift
+++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift
@@ -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).
- PushRegistrar.shared.requestAuthorizationAndRegister()
+ // 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,27 +33,70 @@ 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 {
- Group {
- if store.isPaired {
- TabView(selection: $tab) {
- HomeView()
- .tabItem { Label("Home", systemImage: "house") }.tag(0)
- SessionsView()
- .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
- .badge(store.needsYouCount).tag(1)
- ProjectsView()
- .tabItem { Label("Projects", systemImage: "folder") }.tag(2)
- TodosView()
- .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
- SettingsView()
- .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
+ 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) {
+ HomeView()
+ .tabItem { Label("Home", systemImage: "house") }.tag(0)
+ SessionsView()
+ .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
+ .badge(store.needsYouCount).tag(1)
+ ProjectsView()
+ .tabItem { Label("Projects", systemImage: "folder") }.tag(2)
+ TodosView()
+ .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
+ SettingsView()
+ .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
+ }
+ } else {
+ PairingIntroView()
}
- } else {
- 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)
+ }
}
}
diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift
new file mode 100644
index 0000000..500e9da
--- /dev/null
+++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift
@@ -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."
+ }
+ }
+}
diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift
index a06edc2..b7897a7 100644
--- a/NucleicRemote/NucleicRemote/Views/Composer.swift
+++ b/NucleicRemote/NucleicRemote/Views/Composer.swift
@@ -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: {
diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
index 1fd2c07..ea38e32 100644
--- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
+++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
@@ -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")
diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift
index cc454dd..8f7dd67 100644
--- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift
+++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift
index 94d020f..d657298 100644
--- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift
+++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift
@@ -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 {
diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift
index 596d822..e50b5fd 100644
--- a/NucleicRemote/NucleicRemote/Views/Theme.swift
+++ b/NucleicRemote/NucleicRemote/Views/Theme.swift
@@ -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)
}
}
diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift
new file mode 100644
index 0000000..4655543
--- /dev/null
+++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift
@@ -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 = {
+ let cache = NSCache()
+ 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.. 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 = {
+ let cache = NSCache()
+ 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
+ }
+}
diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
index d2474f1..b73264b 100644
--- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
+++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
@@ -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,