From 56023a9e930ed17b6c5742ff414b354f1a05c9e8 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 29 Jul 2026 16:47:30 -0700 Subject: [PATCH] Merge nucleic/jolly-quartz-toad-vbsw into dev --- .../Views/ApprovalCardView.swift | 104 ++++++++++++++++++ .../Views/SessionDetailView.swift | 13 ++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift index 9b019ab..e2582d9 100644 --- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift +++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift @@ -162,6 +162,110 @@ struct ApprovalCardView: View { } } +/// 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. +struct ExitPlanModeApprovalCardView: View { + @EnvironmentObject var store: RemoteStore + let approval: ApprovalRequest + let plan: ExitPlanMode.Plan? + var availableHeight: CGFloat = 0 + + @State private var revising = false + @State private var feedback = "" + + private var canRespond: Bool { store.connectivity.isLive } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 7) { + Image(systemName: "list.bullet.clipboard").foregroundStyle(Palette.accent) + Text("Review plan").font(.subheadline.weight(.semibold)) + } + + if let plan { + BoundedCardScroll(maxHeight: middleMaxHeight) { + ExitPlanModeCard(plan: plan) + } + } else { + // Claude versions that don't include the plan Markdown in the permission payload + // still need the three-way review. State that plainly instead of degrading to the + // generic permission card (which would incorrectly offer Allow Always). + Text("Claude is ready to leave plan mode and begin implementation.") + .font(.footnote).foregroundStyle(.secondary) + } + + if revising { + VStack(alignment: .leading, spacing: 6) { + Text("What should Claude change?") + .font(.footnote.weight(.semibold)) + TextField( + "Describe the changes you want in the plan…", + text: $feedback, + axis: .vertical) + .lineLimit(2...5) + .textFieldStyle(.roundedBorder) + } + HStack(spacing: 10) { + Button("Cancel") { + feedback = "" + revising = false + } + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + + Button("Send Revision") { submitRevision() } + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + .disabled(!canRespond || ExitPlanMode.revisionReason(feedback: feedback) == nil) + .keyboardShortcut(.defaultAction) + } + } else { + HStack(spacing: 8) { + Button(role: .destructive) { + store.respond(approval, .deny(reason: ExitPlanMode.rejectionReason)) + } label: { + Text("Deny").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(!canRespond) + .keyboardShortcut(.cancelAction) + + Button("Revise") { revising = true } + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + + Button { + store.respond(approval, .allow()) + } label: { + Text("Accept").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canRespond) + .keyboardShortcut(.defaultAction) + } + } + } + .padding() + .glassSurface(cornerRadius: 20) + .frame(maxHeight: availableHeight > 0 ? availableHeight - 12 : nil, alignment: .bottom) + .onChange(of: approval.id) { _, _ in + revising = false + feedback = "" + } + } + + private var middleMaxHeight: CGFloat { + let base = availableHeight > 0 ? availableHeight - 12 : 640 + return max(140, base - (revising ? 300 : 210)) + } + + private func submitRevision() { + guard let reason = ExitPlanMode.revisionReason(feedback: feedback) else { return } + store.respond(approval, .deny(reason: reason)) + } +} + /// The full content of an approval, wrapped and selectable. It no longer scrolls on its own — the /// enclosing ``BoundedCardScroll`` owns the scrolling for the whole card — so it simply sizes to /// its (already length-capped) text. The text wraps; it never scrolls horizontally. diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index df5784d..064a1cf 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -440,6 +440,11 @@ struct SessionDetailView: View { { AskUserQuestionCardView( approval: approval, questions: questions, availableHeight: availableHeight) + } else if approval.toolName == ExitPlanMode.toolName { + ExitPlanModeApprovalCardView( + approval: approval, + plan: ExitPlanMode.plan(from: approval.input), + availableHeight: availableHeight) } else { ApprovalCardView(approval: approval, availableHeight: availableHeight) // So a host_exec approval's working-directory line abbreviates the "cd" @@ -648,9 +653,11 @@ struct SessionDetailView: View { // approval card in that state, but a resolved-but-not-yet-cleared gate can flash through // here — say what's actually being waited on rather than claiming the agent is working. if let approval = store.openApprovals.first { - return approval.toolName == AskUserQuestion.toolName - ? "Waiting for answers…" - : "Waiting for approval…" + switch approval.toolName { + case AskUserQuestion.toolName: return "Waiting for answers…" + case ExitPlanMode.toolName: return "Waiting for plan review…" + default: return "Waiting for approval…" + } } for event in store.openEvents.reversed() { // Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events