From e6ef59e830d743c09ffeb777c5c7aca9b2a622b0 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sun, 21 Jun 2026 04:03:53 -0700 Subject: [PATCH] iOS remote: transcript fidelity + mid-session controls & model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring NucleicRemote closer to desktop parity in two areas (the core sync loop was already at parity — shared protocol, control scope). Transcript fidelity (iOS): a client-side TranscriptProjection coalesces streaming text by messageID and folds each tool call's lifecycle (start/deltas/complete/result/fileChange) into one expandable card — fixing the duplicate started+completed rows. Adds Markdown bubbles, the gold Orchestra card for Task/Agent spawns, and the previously-dropped usage/cost, rate-limit, file-change, turn-boundary and session-started rows, plus a context-window % header badge. Mid-session controls + model catalog (protocol/host/iOS): project the host ModelCatalog over the wire as WireModelCatalog (in Welcome); add 5 control-scope setters (setSessionModel/Effort/Auto/AutoShip/ShipBranch) backed by the existing AppStore.mutateSession + SessionController hooks; enrich WireSessionSummary with model/effort/auto/autoShip/shipBranch/ contextInputTokens (all forward-compatible). The composer gains a model picker and a catalog-driven effort menu (per-backend caps: Codex→xhigh, Grok→auto), and the session header gains a model/effort/auto/autoship control bar. Tests: CBOR round-trips for the new messages, Welcome.modelCatalog, the new summary fields, forward-compat decode of old bytes, and the setters reaching the host. Verified in the Simulator (NUCLEIC_DEMO=1). Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/RemoteStore.swift | 58 +++++ .../NucleicRemote/Views/Composer.swift | 21 +- .../NucleicRemote/Views/ProjectsView.swift | 14 +- .../Views/SessionDetailView.swift | 85 +++++++- .../NucleicRemote/Views/SettingsView.swift | 11 + NucleicRemote/NucleicRemote/Views/Theme.swift | 101 ++++++++- .../Views/Transcript/ToolGroupRow.swift | 142 +++++++++++++ .../Transcript/TranscriptProjection.swift | 200 ++++++++++++++++++ .../NucleicRemote/Views/TranscriptRow.swift | 194 +++++++++++++---- 9 files changed, 763 insertions(+), 63 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift create mode 100644 NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ce3da24..9261ca3 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -38,6 +38,9 @@ final class RemoteStore: ObservableObject { @Published private(set) var sessions: [WireSessionSummary] = [] @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve + /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers. + /// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list. + @Published private(set) var modelCatalog: WireModelCatalog = .empty /// Home / Projects / To-Dos state — the dashboard projection. @Published private(set) var dashboard = DashboardSnapshot.empty @@ -85,6 +88,24 @@ final class RemoteStore: ObservableObject { hostName = "Andrew's Mac" grantedScope = .control capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]) + modelCatalog = WireModelCatalog( + groups: [ + [WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode, + contextBadge: "1M", contextWindow: 1_000_000, + efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"), + WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode, + contextBadge: nil, contextWindow: 200_000, + efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")], + [WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex, + contextBadge: nil, contextWindow: 350_000, + efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")], + [WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok, + contextBadge: nil, contextWindow: 256_000, + efforts: ["auto"], effortNoun: "Reasoning")], + ], + effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"], + orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control", + fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high") let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2") func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String, _ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0, @@ -179,9 +200,37 @@ final class RemoteStore: ObservableObject { openEvents = [] openApprovals = [] seenSeq.removeAll() + if demoMode { seedDemoTranscript(sessionID); return } send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) } + /// 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) { + func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent { + AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode, + nativeType: nil, kind: kind) + } + openEvents = [ + event(1, .sessionStarted(SessionStarted( + backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))), + event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))), + event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))), + event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))), + event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))), + event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))), + event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))), + event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))), + event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))), + event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))), + event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))), + event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))), + event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))), + event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), + event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), + ] + } + func closeOpen() { if let id = openSessionID { send(.unsubscribe(id)) } openSessionID = nil @@ -238,6 +287,14 @@ final class RemoteStore: ObservableObject { func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) } func interrupt(_ id: SessionID) { send(.interrupt(id)) } + // Mid-session model / reasoning / automation — the same affordances the Mac header exposes. + // `nil` model/effort resets the session to the host/app default. + func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) } + func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) } + func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) } + func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) } + func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) } + // MARK: - Plumbing private func send(_ msg: ClientMsg) { @@ -272,6 +329,7 @@ final class RemoteStore: ObservableObject { hostName = welcome.host.hostName capabilities = welcome.capabilities grantedScope = welcome.grantedScope + modelCatalog = welcome.modelCatalog if let payload = pairingPayload, let hostKey = await client?.hostKey() { IdentityStore.savePairedHost(PairedHost( deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName, diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 309d5ab..a065cde 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -8,12 +8,14 @@ struct StartChatComposer: View { @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false + @State private var model: String? @State private var effort = MobileEfforts.fallback private var projects: [WireProject] { store.dashboard.projects } private var selected: WireProject? { projects.first { $0.id == projectID } ?? projects.first } + private var controlled: Bool { selected?.isNucleicControlled ?? false } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -28,7 +30,6 @@ struct StartChatComposer: View { .font(.subheadline) } Spacer() - EffortMenu(effort: $effort, controlled: selected?.isNucleicControlled ?? false) Toggle(isOn: $auto) { Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash") } @@ -36,14 +37,20 @@ struct StartChatComposer: View { .tint(Palette.accent) .font(.caption) } + HStack { + // The model also selects the backend, so the home composer offers every provider. + ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) + Spacer() + EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled) + } HStack(alignment: .bottom, spacing: 8) { TextField("Describe a task…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...5) - .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && (selected?.isNucleicControlled ?? false)) + .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled) Button { if let project = selected { - store.startChat(in: project.id, message: draft, effort: effort, auto: auto) + store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto) draft = "" } } label: { @@ -56,6 +63,14 @@ struct StartChatComposer: View { } } .card() + // Switching to a model with a lower effort cap can't leave an unsupported level selected. + .onChange(of: model) { _, newModel in + let levels = store.modelCatalog.offeredEfforts(forModel: newModel) + let sentinel = store.modelCatalog.orchestraSentinelOrFallback + if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) { + effort = levels.last ?? MobileEfforts.fallback + } + } } } diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 57daaa3..9949340 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -47,6 +47,7 @@ struct ProjectDetailView: View { @EnvironmentObject var store: RemoteStore let project: WireProject @State private var draft = "" + @State private var model: String? @State private var effort = MobileEfforts.fallback private var sessions: [WireSessionSummary] { @@ -60,15 +61,17 @@ struct ProjectDetailView: View { Section { VStack(alignment: .leading, spacing: 8) { HStack { - EffortMenu(effort: $effort, controlled: project.isNucleicControlled) + ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) Spacer() + EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, + controlled: project.isNucleicControlled) } HStack(alignment: .bottom, spacing: 8) { TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder).lineLimit(1...4) .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled) Button { - store.startChat(in: project.id, message: draft, effort: effort) + store.startChat(in: project.id, message: draft, model: model, effort: effort) draft = "" } label: { Image(systemName: "arrow.up.circle.fill").font(.title2) } .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) @@ -88,5 +91,12 @@ struct ProjectDetailView: View { } .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) + .onChange(of: model) { _, newModel in + let levels = store.modelCatalog.offeredEfforts(forModel: newModel) + let sentinel = store.modelCatalog.orchestraSentinelOrFallback + if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) { + effort = levels.last ?? MobileEfforts.fallback + } + } } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 268206a..f65f8e8 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -26,6 +26,8 @@ struct SessionDetailView: View { .padding(.horizontal) .padding(.bottom, 8) + if store.canControl, let summary { controlBar(summary) } + Divider() if tab == 0 { @@ -58,6 +60,67 @@ struct SessionDetailView: View { .onDisappear { store.closeOpen() } } + /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). + private var controlled: Bool { + store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false + } + + private var modelBinding: Binding { + Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) }) + } + private var effortBinding: Binding { + Binding( + get: { + let fallback = store.modelCatalog.fallbackEffort.isEmpty + ? MobileEfforts.fallback : store.modelCatalog.fallbackEffort + return summary?.effort ?? fallback + }, + set: { store.setSessionEffort(sessionID, $0) }) + } + + /// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read + /// from the transcript exactly as the Mac header does. + private var contextPercent: Int? { + let used = store.openEvents.reversed().lazy.compactMap { event -> Int? in + switch event.kind { + case .turnCompleted(let turn): return turn.usage?.contextInputTokens + case .usage(let usage): return usage.contextInputTokens + default: return nil + } + }.first { $0 > 0 } + guard let used else { return nil } + let window = store.modelCatalog.contextWindow(summary?.model) + guard window > 0 else { return nil } + return min(100, Int((Double(used) / Double(window)) * 100)) + } + + /// The model / effort / auto controls for the open session — the mobile echo of the Mac's + /// session header. Reads current state from the summary; each change is a control intent. + @ViewBuilder + private func controlBar(_ summary: WireSessionSummary) -> some View { + HStack(spacing: 10) { + // A session's backend is fixed at creation, so only same-backend models are offered. + ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend) + EffortMenu(effort: effortBinding, catalog: store.modelCatalog, + // No explicit model yet → use the session backend's default, so the menu + // shows that backend's effort range rather than collapsing to one level. + modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku, + controlled: controlled) + Spacer(minLength: 4) + if let percent = contextPercent { + Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent") + .font(.caption2).foregroundStyle(.secondary) + .help("Context window used") + } + Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { + Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") + } + .toggleStyle(.button).tint(Palette.accent).font(.caption2) + } + .padding(.horizontal) + .padding(.bottom, 6) + } + @ViewBuilder private func sessionMenu(_ summary: WireSessionSummary) -> some View { Menu { @@ -68,6 +131,14 @@ struct SessionDetailView: View { Label(summary.favorite ? "Unfavorite" : "Favorite", systemImage: summary.favorite ? "star.slash" : "star") } + // Autoship is a Nucleic Control capability — offered only for Control projects, and + // the host couples it with auto-approval (enabling it turns Auto on). + if controlled { + Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: { + Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship", + systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox") + } + } if summary.status == .running { Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } } @@ -140,19 +211,27 @@ struct SessionDetailView: View { struct TranscriptList: View { let events: [AgentEvent] + @AppStorage("nucleic.showRawEvents") private var showRaw = false + @AppStorage("nucleic.showLockEvents") private var showLockEvents = true + + private var items: [TranscriptItem] { + TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) + } var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { - ForEach(events, id: \.seq) { event in - TranscriptRow(event: event).id(event.seq) + ForEach(items) { item in + TranscriptRow(item: item).id(item.id) } } .padding() } + // Coalescing means item count lags event count; key the autoscroll on the raw stream + // so every streamed delta keeps the view pinned to the bottom. .onChange(of: events.count) { - if let last = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } } + if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 55129c6..bc53eaa 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -3,6 +3,8 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false + @AppStorage("nucleic.showRawEvents") private var showRaw = false + @AppStorage("nucleic.showLockEvents") private var showLockEvents = true var body: some View { NavigationStack { @@ -22,6 +24,15 @@ struct SettingsView: View { .font(.footnote.monospaced()) } + Section { + Toggle("Show lock events", isOn: $showLockEvents) + Toggle("Show raw events", isOn: $showRaw) + } header: { + Text("Transcript") + } footer: { + Text("Raw events are unrecognized backend output, shown for debugging.") + } + Section { Button { showScanner = true diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 7d81651..767a16f 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -137,31 +137,110 @@ enum MobileEfforts { static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort } } -/// A compact effort selector for the remote composers. Orchestra sits below the API levels, -/// set apart with a sparkles glyph and the purple accent. Orchestra is a Nucleic Control -/// capability, so it's only selectable when the target project is under Control — disabled and -/// labeled otherwise. +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 reads as its underlying level so the trigger never shows Orchestra where it can't run. - private var orchestraActive: Bool { controlled && MobileEfforts.isOrchestra(effort) } + /// 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 { - orchestraActive ? effort : (MobileEfforts.isOrchestra(effort) ? MobileEfforts.fallback : effort) + 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(MobileEfforts.levels, id: \.self) { level in + ForEach(levels, id: \.self) { level in Button { effort = level } label: { - if effort == level { Label(level, systemImage: "checkmark") } else { Text(level) } + if effort == level { Label(display(level), systemImage: "checkmark") } + else { Text(display(level)) } } } Divider() - Button { effort = MobileEfforts.orchestraSentinel } label: { + Button { effort = sentinel } label: { Label( controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control", systemImage: orchestraActive ? "checkmark" : "sparkles") @@ -170,7 +249,7 @@ struct EffortMenu: View { } label: { HStack(spacing: 4) { Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3") - Text(MobileEfforts.displayName(triggerEffort)) + Text(display(triggerEffort)) } .font(.caption) .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift new file mode 100644 index 0000000..99e47ec --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -0,0 +1,142 @@ +import SwiftUI +import NucleicProtocol + +/// One tool call as a collapsible card — the mobile echo of the Mac's tool group. Collapsed it +/// shows the tool, a one-line input gist, and a running spinner; expanded it reveals the full +/// input, the result, and any files the call touched. The old remote rendered `started` and +/// `completed` as two separate one-line rows and dropped results/file-changes entirely. +struct ToolGroupRow: View { + let group: ToolGroup + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button { expanded.toggle() } label: { header }.buttonStyle(.plain) + if expanded { details } + } + .padding(10) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder( + group.isError ? Palette.danger.opacity(0.5) : Color.primary.opacity(0.05), lineWidth: 1)) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: ToolGlyph.icon(group.name)) + .font(.caption).foregroundStyle(group.isError ? Palette.danger : .secondary) + Text(group.name).font(.caption.weight(.semibold)) + Text(group.input.compactSummary) + .font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + Spacer(minLength: 4) + if !group.finished { ProgressView().controlSize(.mini) } + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + + @ViewBuilder private var details: some View { + let input = group.input.approvalDetail + if !input.isEmpty { + ToolBlock(label: "Input", text: input, mono: true) + } + if let result = group.result { + ToolBlock(label: group.isError ? "Error" : "Result", + text: result.compactSummary, mono: true, danger: group.isError) + } + if !group.fileChanges.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(group.fileChanges, id: \.path) { change in + Label(change.path, systemImage: ToolGlyph.fileChange(change.change)) + .font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1) + } + } + } + } +} + +/// A subagent spawn (`Task`/`Agent`) — the gold Orchestra card, mirroring the Mac's +/// OrchestrationCard at phone fidelity so an orchestrated run reads distinctly from a plain tool. +struct OrchestrationCard: View { + let group: ToolGroup + @State private var expanded = false + + private var subtitle: String { + group.input["description"]?.stringValue + ?? group.input["prompt"]?.stringValue + ?? group.input.compactSummary + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button { expanded.toggle() } label: { + HStack(spacing: 8) { + Image(systemName: "sparkles").foregroundStyle(Palette.orchestra) + Text(group.finished ? "Orchestrated" : "Orchestrating") + .font(.caption.weight(.semibold)).foregroundStyle(Palette.orchestra) + Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Spacer(minLength: 4) + if !group.finished { ProgressView().controlSize(.mini) } + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if expanded { + ToolBlock(label: "Task", text: group.input.approvalDetail, mono: false) + if let result = group.result { + ToolBlock(label: "Result", text: result.compactSummary, mono: false) + } + } + } + .padding(10) + .background(Palette.orchestra.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Palette.orchestra.opacity(0.35), lineWidth: 1)) + } +} + +/// A labeled, scroll-capped block of monospaced (or prose) text for a tool's input/result. +private struct ToolBlock: View { + let label: String + let text: String + var mono: Bool = true + var danger: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()).font(.caption2.weight(.semibold)).foregroundStyle(.tertiary) + Text(text) + .font(mono ? .caption.monospaced() : .caption) + .foregroundStyle(danger ? Palette.danger : .secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxHeight: 220) + } + } +} + +/// SF Symbols for the common tools + file-change kinds, so a glance reads the action. +enum ToolGlyph { + static func icon(_ name: String) -> String { + switch name { + case "Bash", "Shell": return "terminal" + case "Read": return "doc.text" + case "Edit", "Write", "MultiEdit", "NotebookEdit": return "pencil" + case "Grep", "Glob", "Search": return "magnifyingglass" + case "WebFetch", "WebSearch": return "globe" + case "Task", "Agent": return "sparkles" + case "TodoWrite": return "checklist" + case "AskUserQuestion": return "questionmark.bubble" + default: return "wrench.and.screwdriver" + } + } + static func fileChange(_ kind: FileChange.ChangeKind) -> String { + switch kind { + case .add: return "plus.circle" + case .update: return "pencil.circle" + case .delete: return "minus.circle" + case .rename: return "arrow.triangle.branch" + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift new file mode 100644 index 0000000..563bf7c --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -0,0 +1,200 @@ +import Foundation +import NucleicProtocol + +/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at +/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle; +/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble +/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's +/// `TranscriptProjection` at phone fidelity.) +struct TranscriptItem: Identifiable, Equatable { + let id: String + /// The seq of the first event that created this item — stable scroll anchor + ordering. + let seq: UInt64 + var kind: Kind + + enum Role: Equatable { case user, assistant } + + enum Kind: Equatable { + case message(role: Role, text: String) + case thinking(text: String) + case tool(ToolGroup) + /// A `Task`/`Agent` spawn — rendered as the gold Orchestra card. + case orchestration(ToolGroup) + case sessionStarted(model: String, cwd: String) + case usage(Usage) + case rateLimit(RateLimit) + case turnBoundary + case approval(toolName: String) + case runFinished(outcome: RunFinished.Outcome) + case error(message: String) + case note(text: String, icon: String?, lockEvent: Bool) + case raw(type: String, body: String) + } +} + +/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes. +struct ToolGroup: Equatable { + var toolCallID: String + var name: String + var input: JSONValue + var result: JSONValue? + var isError: Bool = false + var finished: Bool = false + /// Paths the call touched (from `fileChange` events tagged with this tool call). + var fileChanges: [FilePatch] = [] + + struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind } + + /// Subagent orchestration (`Task`/`Agent`) gets the gold card treatment, like the Mac. + var isOrchestration: Bool { name == "Task" || name == "Agent" } + var isAskUserQuestion: Bool { name == "AskUserQuestion" } +} + +enum TranscriptProjection { + /// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough + /// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed). + static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] { + var items: [TranscriptItem] = [] + var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing) + var thinkingIndex: [String: Int] = [:] + var toolIndex: [String: Int] = [:] // toolCallID → items index + + for event in events { + switch event.kind { + case .userText(let chunk): + coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex) + case .assistantText(let chunk): + coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex) + case .thinking(let chunk): + coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex) + + case .toolCallStarted(let call), .toolCallCompleted(let call): + let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }() + upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex) + case .toolCallInputDelta: + break // the final `toolCallCompleted` carries the assembled input + case .toolResult(let result): + attachResult(result, into: &items, index: toolIndex) + case .fileChange(let change): + attachFileChange(change, seq: event.seq, into: &items, index: toolIndex) + + case .sessionStarted(let started): + items.append(.init(id: "start-\(event.seq)", seq: event.seq, + kind: .sessionStarted(model: started.model, cwd: started.cwd))) + case .usage(let usage): + items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage))) + case .rateLimit(let limit): + items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit))) + case .turnCompleted: + items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary)) + case .approvalRequested(let req): + items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName))) + case .approvalResolved: + break // dismissal is reflected in the live approval card, not the transcript + case .runFinished(let finished): + items.append(.init(id: "fin-\(event.seq)", seq: event.seq, + kind: .runFinished(outcome: finished.outcome))) + case .error(let err): + items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message))) + case .note(let note): + if note.lockEvent && !showLockEvents { break } + items.append(.init(id: "note-\(event.seq)", seq: event.seq, + kind: .note(text: note.text, icon: note.icon, lockEvent: note.lockEvent))) + case .raw(let raw): + guard showRaw else { break } + items.append(.init(id: "raw-\(event.seq)", seq: event.seq, + kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary))) + } + } + return items + } + + // MARK: - Coalescing helpers + + private static func coalesceText( + _ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64, + into items: inout [TranscriptItem], index: inout [String: Int] + ) { + if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind { + // A non-partial chunk is the authoritative full text; partials accumulate. + let text = chunk.isPartial ? existing + chunk.text : chunk.text + items[i].kind = .message(role: r, text: text) + } else { + index[chunk.messageID] = items.count + items.append(.init(id: "msg-\(chunk.messageID)", seq: seq, + kind: .message(role: role, text: chunk.text))) + } + } + + private static func coalesceThinking( + _ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int] + ) { + if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind { + items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text) + } else { + index[chunk.messageID] = items.count + items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text))) + } + } + + private static func upsertTool( + _ call: ToolCall, finished: Bool, seq: UInt64, + into items: inout [TranscriptItem], index: inout [String: Int] + ) { + if let i = index[call.toolCallID], var group = currentGroup(items[i]) { + group.name = call.name + if !call.input.isEmptyValue { group.input = call.input } + group.finished = group.finished || finished + items[i].kind = wrap(group) + } else { + index[call.toolCallID] = items.count + let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished) + items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group))) + } + } + + private static func attachResult( + _ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int] + ) { + guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return } + group.result = result.content + group.isError = result.isError + group.finished = true + items[i].kind = wrap(group) + } + + private static func attachFileChange( + _ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int] + ) { + if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) { + group.fileChanges.append(.init(path: change.path, change: change.kind)) + items[i].kind = wrap(group) + } + // Untagged file changes are folded into the diff stat, not the transcript. + } + + /// Extract a tool group from either the plain or orchestration kind. + private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? { + switch item.kind { + case .tool(let g), .orchestration(let g): return g + default: return nil + } + } + + /// Wrap a group in the right kind — orchestration spawns get the gold card. + private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind { + group.isOrchestration ? .orchestration(group) : .tool(group) + } +} + +extension JSONValue { + /// Whether this value carries nothing worth keeping (so a later, fuller input wins). + var isEmptyValue: Bool { + switch self { + case .null: return true + case .object(let o): return o.isEmpty + case .string(let s): return s.isEmpty + default: return false + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index a15be01..e8bfeea 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -1,68 +1,174 @@ import SwiftUI import NucleicProtocol -/// Read-friendly rendering of one transcript event (UX_IOS §4). Assistant/user text as -/// bubbles; tool calls as collapsible rows; everything else compact. +/// Read-friendly rendering of one projected transcript row (UX_IOS §4) — the mobile echo of the +/// Mac transcript. Assistant/user prose as Markdown bubbles, tool calls as collapsible cards, +/// Orchestra spawns in gold, plus the informational rows (usage/cost, rate limits, turn +/// boundaries, run outcome) the older remote dropped. struct TranscriptRow: View { - let event: AgentEvent - @State private var expanded = false + let item: TranscriptItem var body: some View { - switch event.kind { - case .userText(let chunk): - bubble(chunk.text, role: .user) - case .assistantText(let chunk): - bubble(chunk.text, role: .assistant) - case .thinking(let chunk): - Text(chunk.text) - .font(.callout.italic()) - .foregroundStyle(.secondary) - case .toolCallStarted(let call), .toolCallCompleted(let call): - toolRow(name: call.name, detail: call.input.compactSummary) - case .toolResult(let result): - DisclosureGroup("Result") { - Text(result.content.compactSummary) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - } - .font(.caption) - case .approvalRequested(let req): - Label("Approval requested: \(req.toolName)", systemImage: "exclamationmark.triangle.fill") - .font(.caption).foregroundStyle(.orange) - case .runFinished(let finished): - Label("Run \(finished.outcome.rawValue)", systemImage: "flag.checkered") + switch item.kind { + case .message(let role, let text): + MessageBubble(role: role, text: text) + case .thinking(let text): + ThinkingRow(text: text) + case .tool(let group): + ToolGroupRow(group: group) + case .orchestration(let group): + OrchestrationCard(group: group) + case .sessionStarted(let model, let cwd): + InfoLine(icon: "play.circle", text: "Session started · \(modelLabel(model))", detail: cwd) + case .usage(let usage): + UsageRow(usage: usage) + case .rateLimit(let limit): + RateLimitRow(limit: limit) + case .turnBoundary: + TurnBoundaryRow() + case .approval(let toolName): + Label("Approval requested: \(toolName)", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(Palette.attention) + case .runFinished(let outcome): + RunFinishedRow(outcome: outcome) + case .error(let message): + Label(message, systemImage: "xmark.octagon.fill") + .font(.caption).foregroundStyle(Palette.danger) + case .note(let text, let icon, _): + Label(text, systemImage: icon ?? "arrow.triangle.branch") .font(.caption).foregroundStyle(.secondary) - case .error(let err): - Label(err.message, systemImage: "xmark.octagon.fill") - .font(.caption).foregroundStyle(.red) - case .note(let note): - Label(note.text, systemImage: note.icon ?? "arrow.triangle.branch") - .font(.caption).foregroundStyle(.secondary) - default: - EmptyView() + case .raw(let type, let body): + Text("[\(type)] \(body)").font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(2) } } - private enum Role { case user, assistant } + private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model } +} - @ViewBuilder - private func bubble(_ text: String, role: Role) -> some View { +/// 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. +struct MessageBubble: View { + let role: TranscriptItem.Role + let text: String + + var body: some View { HStack { if role == .user { Spacer(minLength: 40) } - Text(text) + Text(Self.markdown(text)) + .textSelection(.enabled) .padding(10) - .background(role == .user ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground), + .background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) .frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading) if role == .assistant { Spacer(minLength: 40) } } } - private func toolRow(name: String, detail: String) -> some View { + static func markdown(_ string: String) -> AttributedString { + (try? AttributedString(markdown: string, options: .init( + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible))) ?? AttributedString(string) + } +} + +struct ThinkingRow: View { + let text: String + var body: some View { + Text(text) + .font(.callout.italic()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// A muted single-line note with an optional trailing detail (e.g. session-started cwd). +struct InfoLine: View { + let icon: String + let text: String + var detail: String? = nil + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + Text(text) + if let detail { Text(detail).lineLimit(1).truncationMode(.middle).foregroundStyle(.tertiary) } + Spacer(minLength: 0) + } + .font(.caption).foregroundStyle(.secondary) + } +} + +/// Per-turn token accounting + cost — the signal the desktop shows and the old remote dropped. +struct UsageRow: View { + let usage: Usage + var body: some View { + HStack(spacing: 10) { + if let input = usage.inputTokens { metric("arrow.down", tokens(input)) } + if let output = usage.outputTokens { metric("arrow.up", tokens(output)) } + if let cost = usage.costUSD, cost > 0 { + Text(String(format: "$%.4f", cost)).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .font(.caption2.monospacedDigit()).foregroundStyle(.secondary) + } + private func metric(_ icon: String, _ value: String) -> some View { + HStack(spacing: 2) { Image(systemName: icon); Text(value) } + } + private func tokens(_ n: Int) -> String { + n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)" + } +} + +/// A rate-limit warning so the user sees a window tightening before a turn fails — first-class, +/// not buried as a raw event. +struct RateLimitRow: View { + let limit: RateLimit + private var warn: Bool { (limit.status ?? "").contains("warning") || limit.status == "rejected" } + var body: some View { + Label(text, systemImage: warn ? "exclamationmark.circle.fill" : "clock.arrow.circlepath") + .font(.caption).foregroundStyle(warn ? Palette.attention : .secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + private var text: String { + var parts = ["Rate limit"] + if let type = limit.rateLimitType { parts.append(type.replacingOccurrences(of: "_", with: " ")) } + if let status = limit.status { parts.append(status.replacingOccurrences(of: "_", with: " ")) } + if let resets = limit.resetsAt { + parts.append("· resets \(resets.formatted(date: .omitted, time: .shortened))") + } + return parts.joined(separator: " ") + } +} + +struct TurnBoundaryRow: View { + var body: some View { HStack(spacing: 8) { - Image(systemName: "wrench.and.screwdriver").font(.caption).foregroundStyle(.secondary) - Text(name).font(.caption.weight(.semibold)) - Text(detail).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1) + Text("turn").font(.caption2).foregroundStyle(.tertiary) + Rectangle().fill(Color.primary.opacity(0.08)).frame(height: 1) + } + } +} + +struct RunFinishedRow: View { + let outcome: RunFinished.Outcome + var body: some View { + Label(label, systemImage: icon).font(.caption).foregroundStyle(color) + } + private var label: String { "Run \(outcome.rawValue)" } + private var icon: String { + switch outcome { + case .completed: "flag.checkered" + case .interrupted: "stop.circle" + case .errored: "xmark.octagon" + case .maxTurns: "flag.checkered" + } + } + private var color: Color { + switch outcome { + case .completed, .maxTurns: Palette.success + case .interrupted: Palette.paused + case .errored: Palette.danger } } }