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:
co-authored by
Claude Opus 4.8
parent
e3b6ccc297
commit
e6ef59e830
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user