From 277a76b85c0baf4c8ae58df9b24e3d9b02159375 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 --- Sources/NucleicApp/ModelCatalog.swift | 34 +++ Sources/NucleicApp/NucleicApp.swift | 3 + Sources/NucleicCore/AppStore.swift | 52 +++++ .../NucleicCore/Sync/ConnectionHandler.swift | 18 +- Sources/NucleicCore/Sync/SyncHostBridge.swift | 8 + .../Sync/MessageEnvelope.swift | 48 +++++ .../NucleicProtocol/Sync/ModelCatalog.swift | 122 +++++++++++ .../NucleicProtocol/Sync/WireMessages.swift | 74 ++++++- Tests/NucleicCoreTests/SyncHostTests.swift | 39 ++++ Tests/NucleicCoreTests/SyncTestSupport.swift | 13 ++ .../WireMessageTests.swift | 87 ++++++++ .../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/Views/Theme.swift | 101 ++++++++- .../Views/Transcript/ToolGroupRow.swift | 142 +++++++++++++ .../Transcript/TranscriptProjection.swift | 200 ++++++++++++++++++ .../NucleicRemote/Views/TranscriptRow.swift | 194 +++++++++++++---- 20 files changed, 1259 insertions(+), 65 deletions(-) create mode 100644 Sources/NucleicProtocol/Sync/ModelCatalog.swift create mode 100644 ios/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift create mode 100644 ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift diff --git a/Sources/NucleicApp/ModelCatalog.swift b/Sources/NucleicApp/ModelCatalog.swift index 3306698d..f5bf8298 100644 --- a/Sources/NucleicApp/ModelCatalog.swift +++ b/Sources/NucleicApp/ModelCatalog.swift @@ -224,6 +224,40 @@ enum ModelCatalog { static var storedDefaultAutoShip: Bool { UserDefaults.standard.bool(forKey: defaultAutoShipKey) } + + /// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers + /// mirror this catalog — same per-backend effort caps, context windows, and grouping — + /// without re-deriving any backend rules on-device. Built from this single source of truth. + static var wireCatalog: WireModelCatalog { + let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in + group.map { sku in + WireModelCatalog.Model( + sku: sku, + displayName: displayName(sku), + backend: BackendID.forModel(sku) ?? .claudeCode, + contextBadge: contextBadge(for: sku), + contextWindow: contextWindow(for: sku), + efforts: efforts(for: sku), + effortNoun: effortNoun(for: sku)) + } + } + // Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the + // orchestra sentinel; the phone shows the raw level for everything else. + var effortNames: [String: String] = [:] + for sku in models { + for level in efforts(for: sku) where effortDisplayName(level) != level { + effortNames[level] = effortDisplayName(level) + } + } + effortNames[orchestraEffort] = effortDisplayName(orchestraEffort) // "Orchestra" + return WireModelCatalog( + groups: groups, + effortDisplayNames: effortNames, + orchestraSentinel: orchestraEffort, + orchestraRequiresControlNote: orchestraRequiresControlNote, + fallbackModel: fallbackModel, + fallbackEffort: fallbackEffort) + } } diff --git a/Sources/NucleicApp/NucleicApp.swift b/Sources/NucleicApp/NucleicApp.swift index 13dc926c..bdbe5f00 100644 --- a/Sources/NucleicApp/NucleicApp.swift +++ b/Sources/NucleicApp/NucleicApp.swift @@ -84,6 +84,9 @@ struct NucleicApp: App { store.autoArchiveIdleInterval = AutoArchivePolicy.stored.interval store.archivedWorktreeCleanupInterval = ArchivedWorktreeCleanupPolicy.stored.interval store.setEnabledStatusProviders(StatusIndicatorSettings.storedEnabledProviders) + // Hand the sync host the model/effort catalog so paired phones render the same + // pickers (SYNC §5.2). Static data, injected once since ModelCatalog lives here. + store.setSyncModelCatalog(ModelCatalog.wireCatalog) _store = State(initialValue: store) } catch { fatalError("Could not open the Nucleic store at \(support.path): \(error)") diff --git a/Sources/NucleicCore/AppStore.swift b/Sources/NucleicCore/AppStore.swift index ddfa5d60..5324c55e 100644 --- a/Sources/NucleicCore/AppStore.swift +++ b/Sources/NucleicCore/AppStore.swift @@ -3709,6 +3709,16 @@ public final class AppStore: ConflictArbiter { syncBroadcastContinuation?.yield(event) } + /// The model/effort catalog projected to paired phones. Injected by the app layer at + /// startup because `ModelCatalog` lives in NucleicApp; `.empty` until then (a phone that + /// receives an empty catalog falls back to its built-in effort list). + private var injectedModelCatalog: WireModelCatalog = .empty + + /// Hand the sync host the wire model catalog the app built from its `ModelCatalog`. + public func setSyncModelCatalog(_ catalog: WireModelCatalog) { + injectedModelCatalog = catalog + } + /// Map a host `Session` to the wire `SessionSummary` the phone renders. fileprivate func wireSummary(for session: Session, pendingApprovals: Int = 0) -> WireSessionSummary { WireSessionSummary( @@ -3726,6 +3736,11 @@ public final class AppStore: ConflictArbiter { favorite: session.favorite, archived: session.archived, queuedMessage: session.queuedMessage, + model: session.model, + effort: session.effort, + auto: session.auto, + autoShip: session.autoShip, + shipBranch: session.shipBranch, updatedAt: session.updatedAt) } } @@ -3746,6 +3761,8 @@ extension AppStore: SyncHostBridge { allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern]) } + public var modelCatalog: WireModelCatalog { injectedModelCatalog } + public func sessionSummaries() async -> [WireSessionSummary] { var result: [WireSessionSummary] = [] for (_, controller) in controllers { @@ -3905,6 +3922,41 @@ extension AppStore: SyncHostBridge { await controller.interrupt() } + // MARK: Mid-session model / reasoning / automation (control scope) + + public func setSessionModel(_ id: SessionID, _ model: String?) async throws { + try await mutateSessionForRemote(id) { await $0.setModel(model) } + } + public func setSessionEffort(_ id: SessionID, _ effort: String?) async throws { + try await mutateSessionForRemote(id) { await $0.setEffort(effort) } + } + public func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws { + try await mutateSessionForRemote(id) { await $0.setAuto(auto) } + } + public func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws { + try await mutateSessionForRemote(id) { await $0.setAutoShip(autoShip) } + } + public func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws { + try await mutateSessionForRemote(id) { await $0.setShipBranch(branch) } + } + + /// Apply a setter to a session addressed by id, then push the refreshed summary so the + /// phone's header reflects the change immediately (these mutations emit no transcript + /// event). Mirrors `sendInput`'s explicit `sessionUpdated` broadcast. Throws + /// `.unknownSession` if the session is gone (so the phone surfaces it). + private func mutateSessionForRemote( + _ id: SessionID, _ body: (SessionController) async -> Void + ) async throws { + guard let controller = controllers[id] else { + throw WireError(code: .unknownSession, message: "no such session", sessionID: id) + } + await body(controller) + let snapshot = await controller.snapshot + if id == openSessionID { openSession = snapshot.session } + broadcast(.sessionUpdated( + wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals.count))) + } + public func broadcasts() async -> AsyncStream { AsyncStream { continuation in syncBroadcastContinuation = continuation } } diff --git a/Sources/NucleicCore/Sync/ConnectionHandler.swift b/Sources/NucleicCore/Sync/ConnectionHandler.swift index df1f2690..b32eaa33 100644 --- a/Sources/NucleicCore/Sync/ConnectionHandler.swift +++ b/Sources/NucleicCore/Sync/ConnectionHandler.swift @@ -164,7 +164,8 @@ actor ConnectionHandler { let welcome = Welcome( grantedScope: grantedScope, host: await bridge.hostInfo, - capabilities: await bridge.capabilities) + capabilities: await bridge.capabilities, + modelCatalog: await bridge.modelCatalog) send(.welcome(welcome)) } @@ -244,6 +245,21 @@ actor ConnectionHandler { case .discard(let id): guard requireControl(id) else { return } await guardedCall(sessionID: id) { try await self.bridge.discard(id) } + case .setSessionModel(let id, let model): + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { try await self.bridge.setSessionModel(id, model) } + case .setSessionEffort(let id, let effort): + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { try await self.bridge.setSessionEffort(id, effort) } + case .setSessionAuto(let id, let flag): + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { try await self.bridge.setSessionAuto(id, flag) } + case .setSessionAutoShip(let id, let flag): + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { try await self.bridge.setSessionAutoShip(id, flag) } + case .setSessionShipBranch(let id, let branch): + guard requireControl(id) else { return } + await guardedCall(sessionID: id) { try await self.bridge.setSessionShipBranch(id, branch) } } } diff --git a/Sources/NucleicCore/Sync/SyncHostBridge.swift b/Sources/NucleicCore/Sync/SyncHostBridge.swift index 9bb33c7c..14433f47 100644 --- a/Sources/NucleicCore/Sync/SyncHostBridge.swift +++ b/Sources/NucleicCore/Sync/SyncHostBridge.swift @@ -10,6 +10,8 @@ import NucleicProtocol public protocol SyncHostBridge: Sendable { var hostInfo: HostInfo { get async } var capabilities: WireCapabilities { get async } + /// The model/effort catalog the phone's pickers mirror (SYNC §5.2), sent in `Welcome`. + var modelCatalog: WireModelCatalog { get async } /// Current session list, newest-activity first (the phone sorts attention-first itself). func sessionSummaries() async -> [WireSessionSummary] @@ -41,6 +43,12 @@ public protocol SyncHostBridge: Sendable { func deleteSessionRemote(_ id: SessionID) async throws func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws func discard(_ id: SessionID) async throws + // Mid-session model / reasoning / automation changes (the Mac header's affordances). + func setSessionModel(_ id: SessionID, _ model: String?) async throws + func setSessionEffort(_ id: SessionID, _ effort: String?) async throws + func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws + func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws + func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws /// Live host-side changes to fan out to subscribed clients. One shared stream; `SyncHost` /// multiplexes per-connection filtering/verbosity on top. diff --git a/Sources/NucleicProtocol/Sync/MessageEnvelope.swift b/Sources/NucleicProtocol/Sync/MessageEnvelope.swift index 34c8a5fa..8994b327 100644 --- a/Sources/NucleicProtocol/Sync/MessageEnvelope.swift +++ b/Sources/NucleicProtocol/Sync/MessageEnvelope.swift @@ -25,6 +25,13 @@ public enum ClientMsg: Sendable, Equatable { case deleteSession(SessionID) case integrate(SessionID, IntegrationMode) case discard(SessionID) + // Control scope: change a session's model / reasoning / automation mid-stream (the same + // affordances the Mac header exposes). `nil` model/effort resets to the host/app default. + case setSessionModel(SessionID, String?) + case setSessionEffort(SessionID, String?) + case setSessionAuto(SessionID, Bool) + case setSessionAutoShip(SessionID, Bool) + case setSessionShipBranch(SessionID, String?) } /// Host → Client messages (SYNC_PROTOCOL §5.2). Forward-compatible: unknown tags decode to @@ -50,6 +57,7 @@ public enum HostMsg: Sendable, Equatable { private enum EnvelopeKey: String, CodingKey { case t, sessionID, approvalID, decision, input, tag case request, todoID, projectID, todoStatus, title, flag, mode + case model, effort, branch } extension ClientMsg: Codable { @@ -100,6 +108,26 @@ extension ClientMsg: Codable { try c.decode(SessionID.self, forKey: .sessionID), try c.decode(IntegrationMode.self, forKey: .mode)) case "discard": self = .discard(try c.decode(SessionID.self, forKey: .sessionID)) + case "setSessionModel": + self = .setSessionModel( + try c.decode(SessionID.self, forKey: .sessionID), + try c.decodeIfPresent(String.self, forKey: .model)) + case "setSessionEffort": + self = .setSessionEffort( + try c.decode(SessionID.self, forKey: .sessionID), + try c.decodeIfPresent(String.self, forKey: .effort)) + case "setSessionAuto": + self = .setSessionAuto( + try c.decode(SessionID.self, forKey: .sessionID), + try c.decode(Bool.self, forKey: .flag)) + case "setSessionAutoShip": + self = .setSessionAutoShip( + try c.decode(SessionID.self, forKey: .sessionID), + try c.decode(Bool.self, forKey: .flag)) + case "setSessionShipBranch": + self = .setSessionShipBranch( + try c.decode(SessionID.self, forKey: .sessionID), + try c.decodeIfPresent(String.self, forKey: .branch)) case let other: throw DecodingError.dataCorruptedError( forKey: .t, in: c, debugDescription: "Unknown ClientMsg \(other)") @@ -165,6 +193,26 @@ extension ClientMsg: Codable { try c.encode(mode, forKey: .mode) case .discard(let id): try c.encode("discard", forKey: .t); try c.encode(id, forKey: .sessionID) + case .setSessionModel(let id, let model): + try c.encode("setSessionModel", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encodeIfPresent(model, forKey: .model) + case .setSessionEffort(let id, let effort): + try c.encode("setSessionEffort", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encodeIfPresent(effort, forKey: .effort) + case .setSessionAuto(let id, let flag): + try c.encode("setSessionAuto", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encode(flag, forKey: .flag) + case .setSessionAutoShip(let id, let flag): + try c.encode("setSessionAutoShip", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encode(flag, forKey: .flag) + case .setSessionShipBranch(let id, let branch): + try c.encode("setSessionShipBranch", forKey: .t) + try c.encode(id, forKey: .sessionID) + try c.encodeIfPresent(branch, forKey: .branch) } } } diff --git a/Sources/NucleicProtocol/Sync/ModelCatalog.swift b/Sources/NucleicProtocol/Sync/ModelCatalog.swift new file mode 100644 index 00000000..2590b48f --- /dev/null +++ b/Sources/NucleicProtocol/Sync/ModelCatalog.swift @@ -0,0 +1,122 @@ +import Foundation + +/// The model/effort catalog, projected over the wire so the phone's composer and session +/// header can offer the *same* model and reasoning choices the Mac does — with the same +/// per-backend effort caps (Codex tops out at `xhigh`, Grok exposes only `auto`) and the +/// same context-window sizes — without re-deriving any backend rules on-device. +/// +/// The host builds this from its `ModelCatalog` (a single source of truth) and sends it once +/// in `Welcome`. Everything the phone needs to render a picker is precomputed here; the phone +/// treats it as opaque data (SYNC_PROTOCOL §5.2). +public struct WireModelCatalog: Sendable, Codable, Equatable { + + /// One selectable model SKU, fully described. The `sku` is the exact string the phone + /// passes back in `StartChatRequest.model` / `setSessionModel`; it also selects the + /// backend (`BackendID.forModel`), already resolved into `backend` here. + public struct Model: Sendable, Codable, Equatable, Identifiable { + public let sku: String + public let displayName: String // "Opus 4.8" + public let backend: BackendID + /// Context-window annotation that distinguishes same-named SKUs ("1M" vs "256K"); nil otherwise. + public let contextBadge: String? + public let contextWindow: Int // approximate input window, tokens + /// Supported effort levels, lowest→highest, with per-backend caps already applied + /// (so the phone shows exactly these and never an unsupported one). + public let efforts: [String] + /// The reasoning control's noun for this model — "Effort" (Claude) or "Reasoning" (Codex/Grok). + public let effortNoun: String + + public var id: String { sku } + + public init( + sku: String, displayName: String, backend: BackendID, contextBadge: String?, + contextWindow: Int, efforts: [String], effortNoun: String + ) { + self.sku = sku + self.displayName = displayName + self.backend = backend + self.contextBadge = contextBadge + self.contextWindow = contextWindow + self.efforts = efforts + self.effortNoun = effortNoun + } + } + + /// Models split into per-provider runs (Claude, then GPT, then Grok), preserving order so + /// the phone's model menu can set each provider apart with a divider — mirrors the Mac. + public let groups: [[Model]] + /// Pretty labels for non-identity effort levels (e.g. `"auto" → "Auto"`, the orchestra + /// sentinel → "Orchestra"); plain API levels fall back to themselves. + public let effortDisplayNames: [String: String] + /// The effort-menu sentinel that selects Orchestra (an orchestration mode, not an API level). + public let orchestraSentinel: String + /// Short grayed annotation shown when Orchestra is unavailable (non-Control project). + public let orchestraRequiresControlNote: String + public let fallbackModel: String + public let fallbackEffort: String + + public init( + groups: [[Model]], effortDisplayNames: [String: String], orchestraSentinel: String, + orchestraRequiresControlNote: String, fallbackModel: String, fallbackEffort: String + ) { + self.groups = groups + self.effortDisplayNames = effortDisplayNames + self.orchestraSentinel = orchestraSentinel + self.orchestraRequiresControlNote = orchestraRequiresControlNote + self.fallbackModel = fallbackModel + self.fallbackEffort = fallbackEffort + } + + // MARK: - Convenience lookups (phone-side, pure reads) + + public var allModels: [Model] { groups.flatMap { $0 } } + + public func model(_ sku: String?) -> Model? { + guard let sku else { return nil } + return allModels.first { $0.sku == sku } + } + + /// SKUs that run on `backend` — for the in-session model menu, where the backend is fixed + /// at creation. Returns the SKUs grouped contiguously by provider, flattened. + public func models(for backend: BackendID) -> [Model] { + allModels.filter { model in + switch backend { + case .claudeCode: return model.backend == .claudeCode + case .codex, .codexExec: return model.backend == .codex + case .grok: return model.backend == .grok + } + } + } + + /// Effort levels supported by `sku`, or the global fallback set if unknown. + public func efforts(forModel sku: String?) -> [String] { + model(sku)?.efforts ?? [fallbackEffort] + } + + public func displayName(_ sku: String?) -> String { + model(sku)?.displayName ?? sku ?? fallbackModel + } + + public func contextBadge(_ sku: String?) -> String? { model(sku)?.contextBadge } + + public func contextWindow(_ sku: String?) -> Int { model(sku)?.contextWindow ?? 200_000 } + + public func effortNoun(forModel sku: String?) -> String { model(sku)?.effortNoun ?? "Effort" } + + /// Pretty label for an effort level — Orchestra/Auto get named labels; plain levels read as-is. + public func effortDisplayName(_ effort: String) -> String { + effortDisplayNames[effort] ?? effort + } + + /// Whether `effort` selects Orchestra (case-insensitive against the sentinel). + public func isOrchestra(_ effort: String?) -> Bool { + guard let effort else { return false } + return effort.caseInsensitiveCompare(orchestraSentinel) == .orderedSame + } + + /// An empty catalog — the default before a `Welcome` arrives and the host-side fallback. + public static let empty = WireModelCatalog( + groups: [], effortDisplayNames: [:], orchestraSentinel: "orchestra", + orchestraRequiresControlNote: "Requires Nucleic Control", + fallbackModel: "", fallbackEffort: "high") +} diff --git a/Sources/NucleicProtocol/Sync/WireMessages.swift b/Sources/NucleicProtocol/Sync/WireMessages.swift index 8a70b764..0b2c00ab 100644 --- a/Sources/NucleicProtocol/Sync/WireMessages.swift +++ b/Sources/NucleicProtocol/Sync/WireMessages.swift @@ -88,15 +88,34 @@ public struct Welcome: Sendable, Codable, Equatable { public let grantedScope: DeviceScope public let host: HostInfo public let capabilities: WireCapabilities + /// The model/effort catalog the phone's pickers render (SYNC §5.2). Optional on the wire so + /// a phone talking to a host that predates the field decodes it as an empty catalog and falls + /// back to its built-in effort list. + public let modelCatalog: WireModelCatalog public init( protocolVersion: Int = SyncProtocol.version, - grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities + grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities, + modelCatalog: WireModelCatalog = .empty ) { self.protocolVersion = protocolVersion self.grantedScope = grantedScope self.host = host self.capabilities = capabilities + self.modelCatalog = modelCatalog + } + + private enum CodingKeys: String, CodingKey { + case protocolVersion, grantedScope, host, capabilities, modelCatalog + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion) + self.grantedScope = try c.decode(DeviceScope.self, forKey: .grantedScope) + self.host = try c.decode(HostInfo.self, forKey: .host) + self.capabilities = try c.decode(WireCapabilities.self, forKey: .capabilities) + self.modelCatalog = try c.decodeIfPresent(WireModelCatalog.self, forKey: .modelCatalog) ?? .empty } } @@ -134,6 +153,19 @@ public struct SessionSummary: Sendable, Codable, Equatable { /// the current turn finishes. `nil` = nothing queued. (Optional so older hosts/clients /// that omit the key decode it as "no queued message".) public let queuedMessage: String? + /// The session's current model SKU / effort level — what the phone's header pickers show as + /// selected and mutate via `setSessionModel` / `setSessionEffort`. `nil` = host/app default. + public let model: String? + public let effort: String? + /// Auto-approval and autoship state (mirrors the Mac header toggles). Default `false` so + /// summaries from a host that predates these fields decode cleanly. + public let auto: Bool + public let autoShip: Bool + /// Per-session autoship destination override; `nil` inherits the project default. + public let shipBranch: String? + /// The latest turn's context-window occupancy (input tokens of the final model call), so the + /// phone can show a context-usage badge without scanning the transcript. `nil` if unknown. + public let contextInputTokens: Int? public let updatedAt: Date public init( @@ -141,6 +173,8 @@ public struct SessionSummary: Sendable, Codable, Equatable { status: SessionStatus, disposition: TurnDisposition? = nil, title: String, branch: String, lastSeq: UInt64, diffStat: DiffStat?, pendingApprovalCount: Int = 0, favorite: Bool = false, archived: Bool = false, queuedMessage: String? = nil, + model: String? = nil, effort: String? = nil, auto: Bool = false, autoShip: Bool = false, + shipBranch: String? = nil, contextInputTokens: Int? = nil, updatedAt: Date ) { self.sessionID = sessionID @@ -157,8 +191,46 @@ public struct SessionSummary: Sendable, Codable, Equatable { self.favorite = favorite self.archived = archived self.queuedMessage = queuedMessage + self.model = model + self.effort = effort + self.auto = auto + self.autoShip = autoShip + self.shipBranch = shipBranch + self.contextInputTokens = contextInputTokens self.updatedAt = updatedAt } + + private enum CodingKeys: String, CodingKey { + case sessionID, projectID, projectName, backend, status, disposition, title, branch + case lastSeq, diffStat, pendingApprovalCount, favorite, archived, queuedMessage + case model, effort, auto, autoShip, shipBranch, contextInputTokens, updatedAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.sessionID = try c.decode(SessionID.self, forKey: .sessionID) + self.projectID = try c.decode(String.self, forKey: .projectID) + self.projectName = try c.decode(String.self, forKey: .projectName) + self.backend = try c.decode(BackendID.self, forKey: .backend) + self.status = try c.decode(SessionStatus.self, forKey: .status) + self.disposition = try c.decodeIfPresent(TurnDisposition.self, forKey: .disposition) + self.title = try c.decode(String.self, forKey: .title) + self.branch = try c.decode(String.self, forKey: .branch) + self.lastSeq = try c.decode(UInt64.self, forKey: .lastSeq) + self.diffStat = try c.decodeIfPresent(DiffStat.self, forKey: .diffStat) + self.pendingApprovalCount = try c.decodeIfPresent(Int.self, forKey: .pendingApprovalCount) ?? 0 + self.favorite = try c.decodeIfPresent(Bool.self, forKey: .favorite) ?? false + self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false + self.queuedMessage = try c.decodeIfPresent(String.self, forKey: .queuedMessage) + // New fields — tolerate summaries from a host that predates them. + self.model = try c.decodeIfPresent(String.self, forKey: .model) + self.effort = try c.decodeIfPresent(String.self, forKey: .effort) + self.auto = try c.decodeIfPresent(Bool.self, forKey: .auto) ?? false + self.autoShip = try c.decodeIfPresent(Bool.self, forKey: .autoShip) ?? false + self.shipBranch = try c.decodeIfPresent(String.self, forKey: .shipBranch) + self.contextInputTokens = try c.decodeIfPresent(Int.self, forKey: .contextInputTokens) + self.updatedAt = try c.decode(Date.self, forKey: .updatedAt) + } } /// How the phone asks the host to land a session's branch (control scope). Mirrors the Mac's diff --git a/Tests/NucleicCoreTests/SyncHostTests.swift b/Tests/NucleicCoreTests/SyncHostTests.swift index 0faf75c9..c2751633 100644 --- a/Tests/NucleicCoreTests/SyncHostTests.swift +++ b/Tests/NucleicCoreTests/SyncHostTests.swift @@ -147,6 +147,45 @@ import NucleicProtocol await host.stop() } + @Test func welcomeCarriesModelCatalog() async throws { + let catalog = WireModelCatalog( + groups: [[WireModelCatalog.Model( + sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode, + contextBadge: "256K", contextWindow: 256_000, efforts: ["low", "high"], + effortNoun: "Effort")]], + effortDisplayNames: ["orchestra": "Orchestra"], orchestraSentinel: "orchestra", + orchestraRequiresControlNote: "Requires Nucleic Control", + fallbackModel: "claude-opus-4-8", fallbackEffort: "high") + let bridge = FakeSyncBridge(modelCatalog: catalog, sessions: [summary("s1")]) + let (host, _, _, recorder, _) = try await makePaired(bridge: bridge) + + let ready = await recorder.waitFor { if case .ready = $0 { return true } else { return false } } + guard case .ready(let welcome) = ready else { Issue.record("no welcome"); return } + #expect(welcome.modelCatalog == catalog) + #expect(welcome.modelCatalog.efforts(forModel: "claude-opus-4-8") == ["low", "high"]) + await host.stop() + } + + @Test func midSessionSettersReachHost() async throws { + let sid = SessionID(rawValue: "s1") + let bridge = FakeSyncBridge(sessions: [summary("s1")]) + let (host, _, client, recorder, _) = try await makePaired(bridge: bridge) + _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } } + + await client.send(.setSessionModel(sid, "claude-sonnet-4-6")) + await client.send(.setSessionEffort(sid, "xhigh")) + await client.send(.setSessionAuto(sid, true)) + await client.send(.setSessionAutoShip(sid, true)) + await client.send(.setSessionShipBranch(sid, "main")) + + #expect(await poll { await bridge.sessionModels }.first?.1 == "claude-sonnet-4-6") + #expect(await poll { await bridge.sessionEfforts }.first?.1 == "xhigh") + #expect(await poll { await bridge.sessionAutos }.first?.1 == true) + #expect(await poll { await bridge.sessionAutoShips }.first?.1 == true) + #expect(await poll { await bridge.sessionShipBranches }.first?.1 == "main") + await host.stop() + } + @Test func dashboardRequestReturnsSnapshot() async throws { let bridge = FakeSyncBridge(sessions: [summary("s1")]) let snapshot = DashboardSnapshot( diff --git a/Tests/NucleicCoreTests/SyncTestSupport.swift b/Tests/NucleicCoreTests/SyncTestSupport.swift index faaaa9b1..27bdbdf0 100644 --- a/Tests/NucleicCoreTests/SyncTestSupport.swift +++ b/Tests/NucleicCoreTests/SyncTestSupport.swift @@ -64,6 +64,7 @@ final class ManualListener: SyncListener, @unchecked Sendable { actor FakeSyncBridge: SyncHostBridge { let hostInfo: HostInfo let capabilities: WireCapabilities + let modelCatalog: WireModelCatalog private var sessions: [WireSessionSummary] private var snapshots: [SessionID: SessionSnapshot] @@ -77,11 +78,13 @@ actor FakeSyncBridge: SyncHostBridge { init( hostInfo: HostInfo = HostInfo(hostID: "host", hostName: "Andrew's Mac"), capabilities: WireCapabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]), + modelCatalog: WireModelCatalog = .empty, sessions: [WireSessionSummary] = [], snapshots: [SessionID: SessionSnapshot] = [:] ) { self.hostInfo = hostInfo self.capabilities = capabilities + self.modelCatalog = modelCatalog self.sessions = sessions self.snapshots = snapshots } @@ -108,6 +111,11 @@ actor FakeSyncBridge: SyncHostBridge { private(set) var deletedSessions: [SessionID] = [] private(set) var integrations: [(SessionID, IntegrationMode)] = [] private(set) var discards: [SessionID] = [] + private(set) var sessionModels: [(SessionID, String?)] = [] + private(set) var sessionEfforts: [(SessionID, String?)] = [] + private(set) var sessionAutos: [(SessionID, Bool)] = [] + private(set) var sessionAutoShips: [(SessionID, Bool)] = [] + private(set) var sessionShipBranches: [(SessionID, String?)] = [] var dashboard = DashboardSnapshot.empty func dashboardSnapshot() async -> DashboardSnapshot { dashboard } @@ -122,6 +130,11 @@ actor FakeSyncBridge: SyncHostBridge { func deleteSessionRemote(_ id: SessionID) async throws { deletedSessions.append(id) } func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws { integrations.append((id, mode)) } func discard(_ id: SessionID) async throws { discards.append(id) } + func setSessionModel(_ id: SessionID, _ model: String?) async throws { sessionModels.append((id, model)) } + func setSessionEffort(_ id: SessionID, _ effort: String?) async throws { sessionEfforts.append((id, effort)) } + func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws { sessionAutos.append((id, auto)) } + func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws { sessionAutoShips.append((id, autoShip)) } + func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws { sessionShipBranches.append((id, branch)) } func broadcasts() -> AsyncStream { AsyncStream { continuation in broadcastContinuation = continuation } diff --git a/Tests/NucleicProtocolTests/WireMessageTests.swift b/Tests/NucleicProtocolTests/WireMessageTests.swift index a3cdf9da..33a6c6f3 100644 --- a/Tests/NucleicProtocolTests/WireMessageTests.swift +++ b/Tests/NucleicProtocolTests/WireMessageTests.swift @@ -44,10 +44,97 @@ import Testing .deleteSession(sid), .integrate(sid, .squash), .discard(sid), + // Mid-session controls — exercise both the value and the nil (reset) variants. + .setSessionModel(sid, "claude-sonnet-4-6"), + .setSessionModel(sid, nil), + .setSessionEffort(sid, "xhigh"), + .setSessionEffort(sid, nil), + .setSessionAuto(sid, true), + .setSessionAutoShip(sid, false), + .setSessionShipBranch(sid, "main"), + .setSessionShipBranch(sid, nil), ] for msg in msgs { #expect(try roundTrip(msg) == msg) } } + private let catalog = WireModelCatalog( + groups: [ + [WireModelCatalog.Model( + sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode, + contextBadge: "256K", contextWindow: 256_000, + efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")], + [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") + + @Test func modelCatalogRoundTripsAndLooksUp() throws { + #expect(try roundTrip(catalog) == catalog) + // The phone reads everything it needs straight off the projection — no backend logic. + #expect(catalog.models(for: .grok).map(\.sku) == ["grok-build"]) + #expect(catalog.efforts(forModel: "grok-build") == ["auto"]) + #expect(catalog.efforts(forModel: "claude-opus-4-8").last == "max") + #expect(catalog.contextWindow("claude-opus-4-8") == 256_000) + #expect(catalog.effortNoun(forModel: "grok-build") == "Reasoning") + #expect(catalog.effortDisplayName("auto") == "Auto") + #expect(catalog.isOrchestra("orchestra")) + #expect(!catalog.isOrchestra("high")) + } + + @Test func welcomeCarriesCatalog() throws { + let welcome = Welcome( + grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Andrew's Mac"), + capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session]), + modelCatalog: catalog) + #expect(try roundTrip(HostMsg.welcome(welcome)) == .welcome(welcome)) + } + + @Test func sessionSummaryNewFieldsRoundTrip() throws { + let rich = SessionSummary( + sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA", + backend: .claudeCode, status: .running, title: "t", branch: "b", lastSeq: 5, + diffStat: nil, model: "claude-sonnet-4-6", effort: "xhigh", auto: true, + autoShip: true, shipBranch: "main", contextInputTokens: 84_000, + updatedAt: Date(timeIntervalSince1970: 1)) + let back = try roundTrip(rich) + #expect(back.model == "claude-sonnet-4-6") + #expect(back.effort == "xhigh") + #expect(back.auto == true) + #expect(back.autoShip == true) + #expect(back.shipBranch == "main") + #expect(back.contextInputTokens == 84_000) + } + + @Test func sessionSummaryToleratesMissingNewFields() throws { + // A summary encoded by a host that predates the model/effort/auto/autoShip fields: + // it must decode with safe defaults rather than throwing (SYNC §9 forward-compat). + struct LegacySummary: Encodable { + let sessionID = SessionID(rawValue: "s1") + let projectID = "p1" + let projectName = "ProjA" + let backend = BackendID.claudeCode + let status = SessionStatus.running + let title = "t" + let branch = "b" + let lastSeq: UInt64 = 5 + let updatedAt = Date(timeIntervalSince1970: 1) + } + let data = try CBOREncoder().encode(LegacySummary()) + let decoded = try CBORDecoder().decode(SessionSummary.self, from: data) + #expect(decoded.model == nil) + #expect(decoded.effort == nil) + #expect(decoded.auto == false) + #expect(decoded.autoShip == false) + #expect(decoded.shipBranch == nil) + #expect(decoded.contextInputTokens == nil) + // Pre-existing non-optional fields also fall back rather than throwing. + #expect(decoded.favorite == false) + #expect(decoded.pendingApprovalCount == 0) + } + @Test func dashboardRoundTrips() throws { let snapshot = DashboardSnapshot( counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5), diff --git a/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index ce3da24e..9261ca39 100644 --- a/ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/Composer.swift b/ios/NucleicRemote/NucleicRemote/Views/Composer.swift index 309d5aba..a065cde3 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/ios/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 57daaa3e..99493407 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 268206a2..f65f8e80 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 55129c66..bc53eaac 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/Theme.swift b/ios/NucleicRemote/NucleicRemote/Views/Theme.swift index 7d816510..767a16f2 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/ios/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift new file mode 100644 index 00000000..99e47ec6 --- /dev/null +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift new file mode 100644 index 00000000..563bf7cb --- /dev/null +++ b/ios/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/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index a15be015..e8bfeeae 100644 --- a/ios/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/ios/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 } } }