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
@@ -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"
}
}
}