Merge nucleic/hazy-umber-lemur-pmrf into dev

This commit is contained in:
2026-08-09 21:23:49 -07:00
parent a67f91cff7
commit 35ce68df3e
2 changed files with 65 additions and 2 deletions
@@ -68,7 +68,7 @@ struct ApprovalCardView: View {
// allow: every one must be a deliberate, one-off approval, never granted in a // 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. // way that lets the next one through unseen. Mirrors the Mac.
if approval.risk != .destructive, if approval.risk != .destructive,
approval.toolName != "mcp__nucleic__copy_artifact", !SandboxToolDisplay.isCopyArtifact(approval.toolName),
!store.capabilities.allowAlwaysScopes.isEmpty { !store.capabilities.allowAlwaysScopes.isEmpty {
Menu("Allow always…") { Menu("Allow always…") {
ForEach(store.capabilities.allowAlwaysScopes, id: \.self) { scope in 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. /// 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 /// 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 { private enum Detail {
case commit(GitCommitSummary.Commit, command: String) case commit(GitCommitSummary.Commit, command: String)
case host(HostCommandSummary.Summary, command: String) case host(HostCommandSummary.Summary, command: String)
case copyArtifact(source: String, destination: String)
case text(String) case text(String)
case none case none
} }
private var detail: Detail { 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, if let command = approval.input["command"]?.stringValue,
let commit = GitCommitSummary.parse(command) { let commit = GitCommitSummary.parse(command) {
return .commit(commit, command: command) return .commit(commit, command: command)
@@ -128,6 +137,8 @@ struct ApprovalCardView: View {
HostCommandBreakdown(summary: parsed) HostCommandBreakdown(summary: parsed)
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
ApprovalDetailBox(text: Self.truncatedForDisplay(command)) ApprovalDetailBox(text: Self.truncatedForDisplay(command))
case .copyArtifact(let source, let destination):
CopyArtifactApprovalDetail(source: source, destination: destination)
case .text(let text): case .text(let text):
ApprovalDetailBox(text: Self.truncatedForDisplay(text)) ApprovalDetailBox(text: Self.truncatedForDisplay(text))
case .none: 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 /// 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: /// 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. /// accept the plan, deny it and remain in plan mode, or return concrete revision feedback.
@@ -24,6 +24,12 @@ enum SandboxToolDisplay {
toolName.hasPrefix(mcpPrefix) ? String(toolName.dropFirst(mcpPrefix.count)) : toolName 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 /// 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. /// file doesn't speak for, so the caller keeps the tool name unchanged.
static func label(for toolName: String) -> String? { static func label(for toolName: String) -> String? {