diff --git a/Sources/ClaudeCodeCore/Models/AskUserQuestionModels.swift b/Sources/ClaudeCodeCore/Models/AskUserQuestionModels.swift new file mode 100644 index 0000000..eb26465 --- /dev/null +++ b/Sources/ClaudeCodeCore/Models/AskUserQuestionModels.swift @@ -0,0 +1,88 @@ +// +// AskUserQuestionModels.swift +// ClaudeCodeUI +// +// Created for AskUserQuestion tool support +// + +import Foundation + +/// Represents a single option within a question +public struct QuestionOption: Codable, Equatable, Identifiable { + public let id: UUID + + /// The display text for this option + public let label: String + + /// Explanation of what this option means + public let description: String + + public init(id: UUID = UUID(), label: String, description: String) { + self.id = id + self.label = label + self.description = description + } +} + +/// Represents a single question with its options +public struct Question: Codable, Equatable, Identifiable { + public let id: UUID + + /// The complete question text + public let question: String + + /// Short label for display (max 12 chars) + public let header: String + + /// Available options for this question + public let options: [QuestionOption] + + /// Whether multiple selections are allowed + public let multiSelect: Bool + + public init( + id: UUID = UUID(), + question: String, + header: String, + options: [QuestionOption], + multiSelect: Bool + ) { + self.id = id + self.question = question + self.header = header + self.options = options + self.multiSelect = multiSelect + } +} + +/// Represents a set of questions from an AskUserQuestion tool call +public struct QuestionSet: Codable, Equatable { + /// The questions to be answered + public let questions: [Question] + + /// The tool use ID for sending back the response + public let toolUseId: String + + public init(questions: [Question], toolUseId: String) { + self.questions = questions + self.toolUseId = toolUseId + } +} + +/// Represents a user's answer to a question +public struct QuestionAnswer: Codable, Equatable { + /// Index of the question being answered + public let questionIndex: Int + + /// Selected option labels (can be multiple for multiSelect) + public let selectedLabels: [String] + + /// Custom text if "Other" was selected + public let otherText: String? + + public init(questionIndex: Int, selectedLabels: [String], otherText: String? = nil) { + self.questionIndex = questionIndex + self.selectedLabels = selectedLabels + self.otherText = otherText + } +} diff --git a/Sources/ClaudeCodeCore/ToolFormatting/Formatters/AskUserQuestionFormatter.swift b/Sources/ClaudeCodeCore/ToolFormatting/Formatters/AskUserQuestionFormatter.swift new file mode 100644 index 0000000..8b93c18 --- /dev/null +++ b/Sources/ClaudeCodeCore/ToolFormatting/Formatters/AskUserQuestionFormatter.swift @@ -0,0 +1,27 @@ +// +// AskUserQuestionFormatter.swift +// ClaudeCodeUI +// +// Created for AskUserQuestion tool support +// + +import Foundation + +/// Formatter for AskUserQuestion tool display +struct AskUserQuestionFormatter: ToolFormatterProtocol { + + func formatOutput(_ output: String, tool: ToolType) -> (String, ToolDisplayFormatter.ToolContentFormatter.ContentType) { + // Output is typically the user's answers + return (output, .plainText) + } + + func extractKeyParameters(_ arguments: String, tool: ToolType) -> String? { + // Extract number of questions for header display + if let jsonDict = arguments.toDictionary(), + let questions = jsonDict["questions"] as? [[String: Any]] { + let count = questions.count + return "\(count) question\(count == 1 ? "" : "s")" + } + return nil + } +} diff --git a/Sources/ClaudeCodeCore/ToolFormatting/ToolDisplayFormatter.swift b/Sources/ClaudeCodeCore/ToolFormatting/ToolDisplayFormatter.swift index d3cb867..8645cd3 100644 --- a/Sources/ClaudeCodeCore/ToolFormatting/ToolDisplayFormatter.swift +++ b/Sources/ClaudeCodeCore/ToolFormatting/ToolDisplayFormatter.swift @@ -55,6 +55,8 @@ public struct ToolDisplayFormatter { return TaskToolFormatter() case .exitPlanMode: return PlainTextToolFormatter() + case .askUserQuestion: + return AskUserQuestionFormatter() default: // Check format type as fallback switch tool.formatType { diff --git a/Sources/ClaudeCodeCore/ToolFormatting/ToolType.swift b/Sources/ClaudeCodeCore/ToolFormatting/ToolType.swift index bade065..75d2d36 100644 --- a/Sources/ClaudeCodeCore/ToolFormatting/ToolType.swift +++ b/Sources/ClaudeCodeCore/ToolFormatting/ToolType.swift @@ -68,7 +68,8 @@ public enum ClaudeCodeTool: String, ToolType, CaseIterable { case webFetch = "WebFetch" case todoWrite = "TodoWrite" case webSearch = "WebSearch" - + case askUserQuestion = "AskUserQuestion" + public var identifier: String { rawValue } public var friendlyName: String { @@ -88,6 +89,7 @@ public enum ClaudeCodeTool: String, ToolType, CaseIterable { case .webFetch: return "Fetch Web Content" case .todoWrite: return "Todo List" case .webSearch: return "Web Search" + case .askUserQuestion: return "Ask User Question" } } @@ -108,6 +110,7 @@ public enum ClaudeCodeTool: String, ToolType, CaseIterable { case .webFetch: return "globe" case .todoWrite: return "checklist" case .webSearch: return "magnifyingglass.circle" + case .askUserQuestion: return "questionmark.circle" } } @@ -136,6 +139,7 @@ public enum ClaudeCodeTool: String, ToolType, CaseIterable { case .todoWrite: return .todos case .task: return .markdown case .exitPlanMode: return .plainText + case .askUserQuestion: return .plainText } } @@ -163,6 +167,7 @@ public enum ClaudeCodeTool: String, ToolType, CaseIterable { case .webSearch: return ["query", "allowed_domains", "blocked_domains"] case .task: return ["description", "prompt"] case .exitPlanMode: return ["plan"] + case .askUserQuestion: return ["questions"] } } diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageRow.swift b/Sources/ClaudeCodeCore/UI/ChatMessageRow.swift index e617fc4..8452c3d 100644 --- a/Sources/ClaudeCodeCore/UI/ChatMessageRow.swift +++ b/Sources/ClaudeCodeCore/UI/ChatMessageRow.swift @@ -55,7 +55,7 @@ struct ChatMessageRow: View { switch message.messageType { case .toolUse, .toolResult, .toolError, .toolDenied, .thinking, .webSearch: return true - case .text: + case .text, .askUserQuestion: return false } } @@ -406,6 +406,8 @@ struct ChatMessageRow: View { return Color(red: 90/255, green: 200/255, blue: 250/255) case .webSearch: return Color(red: 0/255, green: 199/255, blue: 190/255) + case .askUserQuestion: + return Color(red: 147/255, green: 51/255, blue: 234/255) } } diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageView/AskUserQuestionView.swift b/Sources/ClaudeCodeCore/UI/ChatMessageView/AskUserQuestionView.swift new file mode 100644 index 0000000..aff0ad6 --- /dev/null +++ b/Sources/ClaudeCodeCore/UI/ChatMessageView/AskUserQuestionView.swift @@ -0,0 +1,243 @@ +// +// AskUserQuestionView.swift +// ClaudeCodeUI +// +// Created for AskUserQuestion tool support +// + +import SwiftUI + +/// A view that displays multiple questions for the user to answer +public struct AskUserQuestionView: View { + let messageId: UUID + let questionSet: QuestionSet + let viewModel: ChatViewModel + let isResolved: Bool + + @State private var answers: [Int: QuestionAnswerState] = [:] + @State private var isSubmitting = false + @State private var isExpanded = true + + @Environment(\.colorScheme) private var colorScheme + + // Track answer state for each question + struct QuestionAnswerState { + var selectedOptions: Set = [] + var otherText: String = "" + } + + public init( + messageId: UUID, + questionSet: QuestionSet, + viewModel: ChatViewModel, + isResolved: Bool = false + ) { + self.messageId = messageId + self.questionSet = questionSet + self.viewModel = viewModel + self.isResolved = isResolved + + // Initialize answer state for each question + var initialAnswers: [Int: QuestionAnswerState] = [:] + for (index, _) in questionSet.questions.enumerated() { + initialAnswers[index] = QuestionAnswerState() + } + _answers = State(initialValue: initialAnswers) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: isResolved ? "checkmark.circle.fill" : "questionmark.circle.fill") + .font(.system(size: 14)) + .foregroundColor(isResolved ? .green : .blue) + + Text(isResolved ? "Questions Answered" : "Questions") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.primary) + + Text("(\(questionSet.questions.count))") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + }) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(headerBackground) + + // Divider + Rectangle() + .fill(borderColor) + .frame(height: 1) + + if isExpanded { + // Questions content + ScrollView { + VStack(alignment: .leading, spacing: 16) { + ForEach(Array(questionSet.questions.enumerated()), id: \.offset) { index, question in + QuestionCardView( + question: question, + questionIndex: index, + selectedOptions: binding(for: index, keyPath: \.selectedOptions), + otherText: binding(for: index, keyPath: \.otherText) + ) + } + } + .padding(16) + } + .frame(maxHeight: 500) + .background(contentBackground) + + // Only show submit button if not resolved + if !isResolved { + // Divider before submit button + Rectangle() + .fill(borderColor) + .frame(height: 1) + + // Submit button + HStack { + Spacer() + + Button(action: { + handleSubmit() + }) { + HStack(spacing: 6) { + if isSubmitting { + ProgressView() + .controlSize(.small) + .scaleEffect(0.8) + } + + Text(isSubmitting ? "Submitting..." : "Submit Answers") + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .disabled(!isValidToSubmit || isSubmitting) + } + .padding(12) + .background(actionButtonBackground) + } + } + } + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(borderColor, lineWidth: 1) + ) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } + + // MARK: - Helpers + + private func binding(for index: Int, keyPath: WritableKeyPath) -> Binding { + Binding( + get: { answers[index, default: QuestionAnswerState()][keyPath: keyPath] }, + set: { answers[index, default: QuestionAnswerState()][keyPath: keyPath] = $0 } + ) + } + + private var isValidToSubmit: Bool { + // Check that each question has at least one answer + for (index, _) in questionSet.questions.enumerated() { + guard let answerState = answers[index] else { return false } + + let hasSelectedOptions = !answerState.selectedOptions.isEmpty + let hasOtherText = !answerState.otherText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + if !hasSelectedOptions && !hasOtherText { + return false + } + } + return true + } + + // MARK: - Styling + + private var headerBackground: Color { + let baseColor = isResolved ? Color.green.opacity(0.15) : Color.blue.opacity(0.1) + return colorScheme == .dark ? baseColor : baseColor.opacity(0.8) + } + + private var contentBackground: Color { + colorScheme == .dark + ? Color(NSColor.controlBackgroundColor).opacity(0.5) + : Color(white: 0.98) + } + + private var actionButtonBackground: Color { + colorScheme == .dark + ? Color(white: 0.12) + : Color(white: 0.98) + } + + private var borderColor: Color { + if isResolved { + return Color.green.opacity(0.3) + } + return colorScheme == .dark + ? Color(white: 0.25) + : Color(white: 0.85) + } + + // MARK: - Action Handlers + + private func handleSubmit() { + isSubmitting = true + + // Collect all answers + var questionAnswers: [QuestionAnswer] = [] + + for (index, question) in questionSet.questions.enumerated() { + guard let answerState = answers[index] else { continue } + + var selectedLabels = Array(answerState.selectedOptions) + var otherText: String? = nil + + // If user provided "Other" text, include it + if !answerState.otherText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + otherText = answerState.otherText + // Add a marker for "Other" if no options were selected + if selectedLabels.isEmpty { + selectedLabels.append("Other") + } + } + + questionAnswers.append(QuestionAnswer( + questionIndex: index, + selectedLabels: selectedLabels, + otherText: otherText + )) + } + + // Submit answers via the view model + viewModel.submitQuestionAnswers( + toolUseId: questionSet.toolUseId, + answers: questionAnswers, + messageId: messageId + ) + + // The submission will trigger a continuation of the conversation + // Reset submitting state after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isSubmitting = false + } + } +} diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageView/ChatMessageView.swift b/Sources/ClaudeCodeCore/UI/ChatMessageView/ChatMessageView.swift index f51b575..e348472 100644 --- a/Sources/ClaudeCodeCore/UI/ChatMessageView/ChatMessageView.swift +++ b/Sources/ClaudeCodeCore/UI/ChatMessageView/ChatMessageView.swift @@ -271,7 +271,7 @@ struct ChatMessageView: View { switch message.messageType { case .toolUse, .toolResult, .toolError, .toolDenied, .thinking, .webSearch: return true - case .text: + case .text, .askUserQuestion: return false } } diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageContentView.swift b/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageContentView.swift index 4eb9ebc..3197ca5 100644 --- a/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageContentView.swift +++ b/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageContentView.swift @@ -156,12 +156,12 @@ struct MessageContentView: View { /// Determines if the message type should be displayed in a collapsible format. /// Tool-related messages (toolUse, toolResult, toolError, toolDenied, thinking, webSearch) are collapsible, - /// while plain text messages are not. + /// while plain text messages and askUserQuestion messages are not. private var isCollapsible: Bool { switch message.messageType { case .toolUse, .toolResult, .toolError, .toolDenied, .thinking, .webSearch: return true - case .text: + case .text, .askUserQuestion: return false } } @@ -202,6 +202,17 @@ struct MessageContentView: View { private var contentView: some View { if isCollapsible { collapsibleContent + } else if message.messageType == .askUserQuestion, + let questionSet = message.questionSet, + let viewModel = viewModel { + // Render AskUserQuestion UI + AskUserQuestionView( + messageId: message.id, + questionSet: questionSet, + viewModel: viewModel, + isResolved: false // TODO: Track resolution state + ) + .padding(.horizontal, horizontalPadding) } else if message.role == .assistant && message.messageType == .text { // Use formatted text for assistant messages MessageTextFormatterView( diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageTextFormatterView.swift b/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageTextFormatterView.swift index bf12321..480dbac 100644 --- a/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageTextFormatterView.swift +++ b/Sources/ClaudeCodeCore/UI/ChatMessageView/MessageTextFormatterView.swift @@ -96,6 +96,8 @@ struct MessageTextFormatterView: View { return SwiftUI.Color(red: 90/255, green: 200/255, blue: 250/255) case .webSearch: return SwiftUI.Color(red: 0/255, green: 199/255, blue: 190/255) + case .askUserQuestion: + return SwiftUI.Color(red: 147/255, green: 51/255, blue: 234/255) } } } diff --git a/Sources/ClaudeCodeCore/UI/ChatMessageView/QuestionCardView.swift b/Sources/ClaudeCodeCore/UI/ChatMessageView/QuestionCardView.swift new file mode 100644 index 0000000..190652c --- /dev/null +++ b/Sources/ClaudeCodeCore/UI/ChatMessageView/QuestionCardView.swift @@ -0,0 +1,172 @@ +// +// QuestionCardView.swift +// ClaudeCodeUI +// +// Created for AskUserQuestion tool support +// + +import SwiftUI + +/// A card view for displaying a single question with its options +struct QuestionCardView: View { + let question: Question + let questionIndex: Int + @Binding var selectedOptions: Set + @Binding var otherText: String + + @Environment(\.colorScheme) private var colorScheme + @State private var isOtherSelected = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header chip + HStack { + Text(question.header) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue) + .cornerRadius(4) + + Spacer() + } + + // Question text + Text(question.question) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + + // Options + VStack(alignment: .leading, spacing: 8) { + ForEach(question.options) { option in + optionView(option) + } + } + + // "Other" text field (always visible) + VStack(alignment: .leading, spacing: 6) { + Text("Other:") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + TextField("Enter custom response...", text: $otherText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) + .onChange(of: otherText) { _, newValue in + // If user types in "Other", mark it as selected + if !newValue.isEmpty { + isOtherSelected = true + if !question.multiSelect { + // For single select, clear other options + selectedOptions.removeAll() + } + } else { + isOtherSelected = false + } + } + } + } + .padding(16) + .background(cardBackground) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(borderColor, lineWidth: 1) + ) + } + + @ViewBuilder + private func optionView(_ option: QuestionOption) -> some View { + let isSelected = selectedOptions.contains(option.label) + + Button(action: { + toggleOption(option.label) + }) { + HStack(alignment: .top, spacing: 12) { + // Selection indicator + if question.multiSelect { + // Checkbox for multi-select + Image(systemName: isSelected ? "checkmark.square.fill" : "square") + .font(.system(size: 16)) + .foregroundColor(isSelected ? .blue : .secondary) + } else { + // Radio button for single-select + Image(systemName: isSelected ? "circle.fill" : "circle") + .font(.system(size: 16)) + .foregroundColor(isSelected ? .blue : .secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(option.label) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + + Text(option.description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(10) + .background(isSelected ? selectionBackground : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue.opacity(0.5) : Color.clear, lineWidth: 1.5) + ) + } + + private func toggleOption(_ label: String) { + // Clear "Other" text when selecting an option + otherText = "" + isOtherSelected = false + + if question.multiSelect { + // Multi-select: toggle the option + if selectedOptions.contains(label) { + selectedOptions.remove(label) + } else { + selectedOptions.insert(label) + } + } else { + // Single-select: replace selection + selectedOptions.removeAll() + selectedOptions.insert(label) + } + } + + // MARK: - Styling + + private var cardBackground: Color { + colorScheme == .dark + ? Color(NSColor.controlBackgroundColor) + : Color.white + } + + private var selectionBackground: Color { + colorScheme == .dark + ? Color.blue.opacity(0.15) + : Color.blue.opacity(0.08) + } + + private var borderColor: Color { + colorScheme == .dark + ? Color(white: 0.25) + : Color(white: 0.85) + } +} diff --git a/Sources/ClaudeCodeCore/ViewModels/ChatMessage.swift b/Sources/ClaudeCodeCore/ViewModels/ChatMessage.swift index 7785ce3..bf9e110 100644 --- a/Sources/ClaudeCodeCore/ViewModels/ChatMessage.swift +++ b/Sources/ClaudeCodeCore/ViewModels/ChatMessage.swift @@ -62,7 +62,11 @@ public struct ChatMessage: Identifiable, Equatable, Codable { /// The approval status for plan messages (ExitPlanMode tool) /// - Note: Used to track whether a plan has been approved, denied, or approved with auto-accept public var planApprovalStatus: PlanApprovalStatus? - + + /// Question data for AskUserQuestion tool messages + /// - Note: Only populated for askUserQuestion message types + public var questionSet: QuestionSet? + public init( id: UUID = UUID(), role: MessageRole, @@ -78,7 +82,8 @@ public struct ChatMessage: Identifiable, Equatable, Codable { wasCancelled: Bool = false, taskGroupId: UUID? = nil, isTaskContainer: Bool = false, - planApprovalStatus: PlanApprovalStatus? = nil + planApprovalStatus: PlanApprovalStatus? = nil, + questionSet: QuestionSet? = nil ) { self.id = id self.role = role @@ -95,6 +100,7 @@ public struct ChatMessage: Identifiable, Equatable, Codable { self.taskGroupId = taskGroupId self.isTaskContainer = isTaskContainer self.planApprovalStatus = planApprovalStatus + self.questionSet = questionSet } public static func == (lhs: ChatMessage, rhs: ChatMessage) -> Bool { @@ -110,7 +116,8 @@ public struct ChatMessage: Identifiable, Equatable, Codable { lhs.wasCancelled == rhs.wasCancelled && lhs.taskGroupId == rhs.taskGroupId && lhs.isTaskContainer == rhs.isTaskContainer && - lhs.planApprovalStatus == rhs.planApprovalStatus + lhs.planApprovalStatus == rhs.planApprovalStatus && + lhs.questionSet == rhs.questionSet } } @@ -130,6 +137,8 @@ public enum MessageType: String, Codable { case thinking /// Web search results case webSearch + /// Questions posed to the user requiring input + case askUserQuestion } /// Defines who sent the message or what generated it @@ -150,6 +159,8 @@ public enum MessageRole: String, Codable { case toolDenied /// Assistant's thinking process case thinking + /// Questions posed to user + case askUserQuestion } /// Structured data extracted from tool inputs for enhanced UI display diff --git a/Sources/ClaudeCodeCore/ViewModels/ChatViewModel.swift b/Sources/ClaudeCodeCore/ViewModels/ChatViewModel.swift index b2277c0..19c68c4 100644 --- a/Sources/ClaudeCodeCore/ViewModels/ChatViewModel.swift +++ b/Sources/ClaudeCodeCore/ViewModels/ChatViewModel.swift @@ -1469,5 +1469,49 @@ EOF public func updatePlanApprovalStatus(messageId: UUID, status: PlanApprovalStatus) { messageStore.updatePlanApprovalStatus(id: messageId, status: status) } + + // MARK: - AskUserQuestion + + /// Submits answers to user questions and continues the conversation + public func submitQuestionAnswers(toolUseId: String, answers: [QuestionAnswer], messageId: UUID) { + // Format answers into the expected response format + var formattedAnswers: [String: String] = [:] + + for answer in answers { + // Use the question index as the key (or could use question text) + let key = "question_\(answer.questionIndex)" + + // Combine selected options and other text + var value = answer.selectedLabels.joined(separator: ", ") + + if let otherText = answer.otherText, !otherText.isEmpty { + if !value.isEmpty { + value += " (Other: \(otherText))" + } else { + value = "Other: \(otherText)" + } + } + + formattedAnswers[key] = value + } + + // Convert to JSON string for the tool result + if let jsonData = try? JSONSerialization.data(withJSONObject: formattedAnswers, options: [.prettyPrinted]), + let jsonString = String(data: jsonData, encoding: .utf8) { + + // Send as a hidden message that will be formatted as a tool result + // The format should match what Claude expects for AskUserQuestion tool results + let answerMessage = """ + User answers to questions: + \(jsonString) + """ + + // Send the message to continue the conversation + sendMessage(answerMessage) + + // Mark the question message as resolved + // (We could add a resolution status to ChatMessage similar to planApprovalStatus) + } + } } diff --git a/Sources/ClaudeCodeCore/ViewModels/MessageFactory.swift b/Sources/ClaudeCodeCore/ViewModels/MessageFactory.swift index 799c138..c576136 100644 --- a/Sources/ClaudeCodeCore/ViewModels/MessageFactory.swift +++ b/Sources/ClaudeCodeCore/ViewModels/MessageFactory.swift @@ -137,6 +137,25 @@ public struct MessageFactory { messageType: .webSearch ) } + + /// Creates an AskUserQuestion message with questions for the user to answer + /// - Parameters: + /// - questionSet: The set of questions to be answered + /// - taskGroupId: Optional group ID for Task tool execution tracking + /// - Returns: A ChatMessage configured as an askUserQuestion message + static func askUserQuestionMessage(questionSet: QuestionSet, taskGroupId: UUID? = nil) -> ChatMessage { + let questionCount = questionSet.questions.count + let content = "ASK USER QUESTION: \(questionCount) question\(questionCount == 1 ? "" : "s")" + + return ChatMessage( + role: .askUserQuestion, + content: content, + messageType: .askUserQuestion, + toolName: "AskUserQuestion", + taskGroupId: taskGroupId, + questionSet: questionSet + ) + } } extension ContentItem { diff --git a/Sources/ClaudeCodeCore/ViewModels/StreamProcessor.swift b/Sources/ClaudeCodeCore/ViewModels/StreamProcessor.swift index b695ada..e2cded4 100644 --- a/Sources/ClaudeCodeCore/ViewModels/StreamProcessor.swift +++ b/Sources/ClaudeCodeCore/ViewModels/StreamProcessor.swift @@ -34,6 +34,9 @@ final class StreamProcessor { // Track if we just processed an ExitPlanMode tool to skip its result private var skipNextToolResult = false + // Track if we're waiting for AskUserQuestion answers + private var pendingQuestionToolId: String? + /// Gets the currently active session ID (pending or current) /// Returns the pending session ID if streaming is in progress, otherwise the current session ID var activeSessionId: String? { @@ -411,6 +414,12 @@ final class StreamProcessor { return } + // Check for AskUserQuestion tool + if toolUse.name == "AskUserQuestion" { + handleAskUserQuestion(toolUse, state: state) + return + } + // Mark that we've processed a tool use state.hasProcessedToolUse = true @@ -647,6 +656,69 @@ final class StreamProcessor { ClaudeCodeLogger.shared.stream("Setting flag to skip next tool result for ExitPlanMode") } + private func handleAskUserQuestion(_ toolUse: MessageResponse.Content.ToolUse, state: StreamState) { + ClaudeCodeLogger.shared.stream("Handling AskUserQuestion tool") + + // Extract questions array from tool input + guard case .array(let questionsArray) = toolUse.input["questions"] else { + ClaudeCodeLogger.shared.stream("Error: questions not found or not an array") + return + } + + // Parse questions + var questions: [Question] = [] + for questionItem in questionsArray { + guard case .dictionary(let questionDict) = questionItem else { continue } + + // Extract question fields + guard case .string(let questionText) = questionDict["question"], + case .string(let header) = questionDict["header"], + case .bool(let multiSelect) = questionDict["multiSelect"], + case .array(let optionsArray) = questionDict["options"] else { + continue + } + + // Parse options + var options: [QuestionOption] = [] + for optionItem in optionsArray { + guard case .dictionary(let optionDict) = optionItem, + case .string(let label) = optionDict["label"], + case .string(let description) = optionDict["description"] else { + continue + } + options.append(QuestionOption(label: label, description: description)) + } + + questions.append(Question( + question: questionText, + header: header, + options: options, + multiSelect: multiSelect + )) + } + + // Create QuestionSet with the tool use ID + let questionSet = QuestionSet(questions: questions, toolUseId: toolUse.id) + + // Create a message with the question set + let questionMessage = MessageFactory.askUserQuestionMessage( + questionSet: questionSet, + taskGroupId: state.currentTaskGroupId + ) + + messageStore.addMessage(questionMessage) + + // Mark that we processed a tool use + state.hasProcessedToolUse = true + + // Store the tool ID so we can match answers later + pendingQuestionToolId = toolUse.id + + // Skip the automatic tool result - the UI will send back the user's answers + skipNextToolResult = true + ClaudeCodeLogger.shared.stream("Setting flag to skip next tool result for AskUserQuestion") + } + // Callback to get parent view model - needs to be set during initialization private var getParentViewModel: (() -> ChatViewModel?)?