import SwiftUI import NucleicProtocol /// Answer UI for Claude Code's built-in `AskUserQuestion` tool — the mobile mirror of /// the Mac's `AskUserQuestionBar`. Unlike a permission gate, this *collects* the user's /// choice per question and folds the selections into the approval reply's `updatedInput` /// (see `AskUserQuestion`); replying with a plain allow makes the CLI report "the user /// did not answer the questions", which is exactly the bug the generic `ApprovalCardView` /// produced here before. /// /// Questions are shown one at a time as a wizard — Back/Next between them, Submit on the /// last — so a multi-question ask reads as a focused sequence rather than one long form. struct AskUserQuestionCardView: View { @EnvironmentObject var store: RemoteStore let approval: ApprovalRequest let questions: [AskUserQuestion.Question] /// Selected option labels per question text. @State private var selected: [String: Set] = [:] /// Whether the free-text "Other" answer is active per question text. @State private var otherActive: [String: Bool] = [:] /// The free-text "Other" answer per question text. @State private var otherText: [String: String] = [:] /// Index of the question currently shown (the wizard step). @State private var step = 0 private var submitEnabled: Bool { store.connectivity.isLive } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 6) { Image(systemName: "questionmark.bubble.fill").foregroundStyle(Palette.attention) Text(questions.count > 1 ? "Questions" : "Question") .font(.subheadline.weight(.semibold)) } if let question = currentQuestion { VStack(alignment: .leading, spacing: 8) { if questions.count > 1 { Text("Question \(step + 1) of \(questions.count)") .font(.caption).foregroundStyle(.secondary) } if let header = question.header, !header.isEmpty { Text(header.uppercased()) .font(.caption).bold().foregroundStyle(.secondary) } Text(question.question).font(.headline) ForEach(question.options) { option in optionRow(question, label: option.label, description: option.description) } otherRow(question) } } HStack(spacing: 10) { Button(role: .destructive) { respond(.deny(reason: nil)) } label: { Text("Deny").frame(maxWidth: .infinity) } .buttonStyle(.bordered) .keyboardShortcut(.cancelAction) if step > 0 { Button("Back") { step -= 1 } .buttonStyle(.bordered) } if isLastStep { Button { submit() } label: { Text("Submit").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(!allAnswered || !submitEnabled) .keyboardShortcut(.defaultAction) } else { Button { step += 1 } label: { Text("Next").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(!currentAnswered) .keyboardShortcut(.defaultAction) } } } .padding() .glassSurface(cornerRadius: 20) } // MARK: - Wizard state /// The question for the active step, or `nil` if the index is somehow out of range. private var currentQuestion: AskUserQuestion.Question? { questions.indices.contains(step) ? questions[step] : nil } private var isLastStep: Bool { step >= questions.count - 1 } /// Whether the question on the current step has at least one answer — gates `Next`. private var currentAnswered: Bool { guard let currentQuestion else { return false } return !answers(for: currentQuestion).isEmpty } private var allAnswered: Bool { questions.allSatisfy { !answers(for: $0).isEmpty } } // MARK: - Rows @ViewBuilder private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?) -> some View { let isOn = selected[question.question]?.contains(label) ?? false Button { toggle(question, label) } label: { HStack(alignment: .top, spacing: 8) { Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) .foregroundStyle(isOn ? Palette.attention : .secondary) VStack(alignment: .leading, spacing: 2) { Text(label).font(.body) .fixedSize(horizontal: false, vertical: true) if let description, !description.isEmpty { // Let the subtext wrap freely — inside a Button label with a trailing // Spacer, an unconstrained Text truncates to one line and hides the rest. Text(description).font(.subheadline).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } Spacer(minLength: 0) } .contentShape(Rectangle()) } .buttonStyle(.plain) } @ViewBuilder private func otherRow(_ question: AskUserQuestion.Question) -> some View { let isOn = otherActive[question.question] ?? false HStack(alignment: .center, spacing: 8) { Button { toggleOther(question) } label: { Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn)) .foregroundStyle(isOn ? Palette.attention : .secondary) } .buttonStyle(.plain) TextField( "Other…", text: Binding( get: { otherText[question.question] ?? "" }, set: { newValue in otherText[question.question] = newValue if !newValue.isEmpty { activateOther(question) } }) ) .textFieldStyle(.roundedBorder) } } private func symbol(multiSelect: Bool, on: Bool) -> String { if multiSelect { return on ? "checkmark.square.fill" : "square" } return on ? "largecircle.fill.circle" : "circle" } // MARK: - Selection private func toggle(_ question: AskUserQuestion.Question, _ label: String) { var set = selected[question.question] ?? [] if question.multiSelect { if set.contains(label) { set.remove(label) } else { set.insert(label) } } else { set = set.contains(label) ? [] : [label] otherActive[question.question] = false } selected[question.question] = set } private func toggleOther(_ question: AskUserQuestion.Question) { let next = !(otherActive[question.question] ?? false) otherActive[question.question] = next if next, !question.multiSelect { selected[question.question] = [] } } private func activateOther(_ question: AskUserQuestion.Question) { otherActive[question.question] = true if !question.multiSelect { selected[question.question] = [] } } /// Chosen labels for a question: picked options plus any non-empty "Other". private func answers(for question: AskUserQuestion.Question) -> [String] { var labels = Array(selected[question.question] ?? []) if otherActive[question.question] ?? false { let custom = (otherText[question.question] ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) if !custom.isEmpty { labels.append(custom) } } return labels } // MARK: - Submit private func submit() { var selections: [String: [String]] = [:] for question in questions { selections[question.question] = answers(for: question) } let updatedInput = AskUserQuestion.updatedInput( original: approval.input, selections: selections) respond(.allow(updatedInput: updatedInput)) } private func respond(_ decision: Decision) { store.respond(approval, decision) } }