diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index f63cee1..e697b37 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -68,7 +68,7 @@ struct ApprovalCardView: View { // allow: every one must be a deliberate, one-off approval, never granted in a // way that lets the next one through unseen. Mirrors the Mac. if approval.risk != .destructive, - approval.toolName != "mcp__nucleic__copy_artifact", + !SandboxToolDisplay.isCopyArtifact(approval.toolName), !store.capabilities.allowAlwaysScopes.isEmpty { Menu("Allow always…") { ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in @@ -92,15 +92,24 @@ struct ApprovalCardView: View { /// The kinds of detail an approval carries, resolved once so the body and `hasDetail` agree. /// A git commit reads as a structured commit card (subject + Markdown body); a host_exec gate - /// lays out its parsed breakdown + exact command; every other tool shows its untruncated detail. + /// lays out its parsed breakdown + exact command; copy_artifact labels its two path endpoints; + /// every other tool shows its untruncated detail. private enum Detail { case commit(GitCommitSummary.Commit, command: String) case host(HostCommandSummary.Summary, command: String) + case copyArtifact(source: String, destination: String) case text(String) case none } private var detail: Detail { + if SandboxToolDisplay.isCopyArtifact(approval.toolName), + let source = approval.input["source"]?.stringValue, + !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let destination = approval.input["destination"]?.stringValue, + !destination.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .copyArtifact(source: source, destination: destination) + } if let command = approval.input["command"]?.stringValue, let commit = GitCommitSummary.parse(command) { return .commit(commit, command: command) @@ -128,6 +137,8 @@ struct ApprovalCardView: View { HostCommandBreakdown(summary: parsed) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) ApprovalDetailBox(text: Self.truncatedForDisplay(command)) + case .copyArtifact(let source, let destination): + CopyArtifactApprovalDetail(source: source, destination: destination) case .text(let text): ApprovalDetailBox(text: Self.truncatedForDisplay(text)) case .none: @@ -164,6 +175,52 @@ struct ApprovalCardView: View { } } +/// The mobile rendering of a `copy_artifact` gate mirrors the Mac: both paths remain verbatim and +/// selectable, but the host -> worktree boundary is explicit instead of buried in a JSON object. +private struct CopyArtifactApprovalDetail: View { + let source: String + let destination: String + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + pathRow(label: "Source on host", systemImage: "desktopcomputer", path: source) + + HStack(spacing: 8) { + Image(systemName: "arrow.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + .frame(width: 16) + Divider() + } + + pathRow(label: "Destination in worktree", systemImage: "folder", path: destination) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) + } + + private func pathRow(label: String, systemImage: String, path: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: systemImage) + .font(.callout) + .foregroundStyle(.orange) + .frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(path) + .font(.caption.monospaced()) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + /// Claude's plan-mode exit is a review, not an ordinary permission gate. It always pauses even /// when Auto is enabled and offers the same three outcomes as Claude's first-party surface: /// accept the plan, deny it and remain in plan mode, or return concrete revision feedback. diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift index 0407758..792805a 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/SandboxToolDisplay.swift @@ -24,6 +24,12 @@ enum SandboxToolDisplay { toolName.hasPrefix(mcpPrefix) ? String(toolName.dropFirst(mcpPrefix.count)) : toolName } + /// Approval cards use this to select the source -> destination rendering while accepting both + /// qualified MCP names and bare names from older transcript/relay payloads. + static func isCopyArtifact(_ toolName: String) -> Bool { + bareName(toolName) == "copy_artifact" + } + /// The human label that replaces the raw tool name in a card headline. Nil for anything this /// file doesn't speak for, so the caller keeps the tool name unchanged. static func label(for toolName: String) -> String? {