iOS remote: transcript fidelity + mid-session controls & model catalog

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 <[email protected]>
This commit is contained in:
Nucleic
2026-06-21 04:03:53 -07:00
co-authored by Claude Opus 4.8
parent 91b223166f
commit 277a76b85c
20 changed files with 1259 additions and 65 deletions
+34
View File
@@ -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)
}
}
+3
View File
@@ -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)")
+52
View File
@@ -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<HostBroadcast> {
AsyncStream { continuation in syncBroadcastContinuation = continuation }
}
@@ -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) }
}
}
@@ -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.
@@ -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)
}
}
}
@@ -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, lowesthighest, 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")
}
@@ -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
@@ -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(
@@ -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<HostBroadcast> {
AsyncStream { continuation in broadcastContinuation = continuation }
@@ -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),
@@ -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,
@@ -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
}
}
}
}
@@ -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
}
}
}
}
@@ -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<String?> {
Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
}
private var effortBinding: Binding<String> {
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) } }
}
}
}
@@ -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
@@ -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)
@@ -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"
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}