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 e3b6ccc297
commit e6ef59e830
9 changed files with 763 additions and 63 deletions
@@ -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
+90 -11
View File
@@ -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
}
}
}