From 06810537a9c05a225756391a24d4c8ff10dbf44a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Fri, 23 Jan 2026 14:11:18 -0800 Subject: [PATCH] feat: add AskUserQuestion tool support (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements support for Claude's `AskUserQuestion` tool, allowing Claude to ask the user questions with multiple choice options during a conversation. ## Changes - Add `UserQuestionEvent` and `QuestionOption` types (Rust and TypeScript) - Detect `AskUserQuestion` in permission denials and emit `claude:question` event - Create `UserQuestionModal` component with option selection and custom answer input - Use stop/reconnect approach (same as `PermissionModal`) since Claude API doesn't accept tool_result for permission-denied tools - Add `pendingQuestion` to conversation store and `hasQuestionPending` derived store ## Technical Notes We discovered that Claude Code's permission denial system doesn't allow sending tool results back directly - the API rejects them with "unexpected tool_use_id found in tool_result blocks". The solution was to use the same stop/reconnect pattern that permissions use: stop the session, reconnect with context, and include the user's answer in the context restoration message. ## Test Plan - [x] Build compiles without errors (Rust + TypeScript) - [x] Question modal appears when Claude uses `AskUserQuestion` - [x] Can select options and submit answer - [x] Answer is properly restored to Claude after reconnect Closes #51 --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Hikari Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/60 --- src-tauri/src/bridge_manager.rs | 8 + src-tauri/src/commands.rs | 11 + src-tauri/src/lib.rs | 1 + src-tauri/src/types.rs | 18 ++ src-tauri/src/wsl_bridge.rs | 100 +++++++- src/lib/components/UserQuestionModal.svelte | 264 ++++++++++++++++++++ src/lib/stores/claude.ts | 10 + src/lib/stores/conversations.ts | 57 ++++- src/lib/tauri.ts | 25 +- src/lib/types/messages.ts | 14 ++ src/routes/+page.svelte | 2 + 11 files changed, 497 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/UserQuestionModal.svelte diff --git a/src-tauri/src/bridge_manager.rs b/src-tauri/src/bridge_manager.rs index 086ab78..6dd95d3 100644 --- a/src-tauri/src/bridge_manager.rs +++ b/src-tauri/src/bridge_manager.rs @@ -79,6 +79,14 @@ impl BridgeManager { } } + pub fn send_tool_result(&mut self, conversation_id: &str, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + if let Some(bridge) = self.bridges.get_mut(conversation_id) { + bridge.send_tool_result(tool_use_id, result) + } else { + Err("No Claude instance found for this conversation".to_string()) + } + } + pub fn is_claude_running(&self, conversation_id: &str) -> bool { self.bridges.get(conversation_id) .map(|b| b.is_running()) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d970133..43677ef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -169,3 +169,14 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result, + conversation_id: String, + tool_use_id: String, + answers: serde_json::Value, +) -> Result<(), String> { + let mut manager = bridge_manager.lock(); + manager.send_tool_result(&conversation_id, &tool_use_id, answers) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2b0a18..5655334 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,6 +47,7 @@ pub fn run() { save_config, get_usage_stats, load_saved_achievements, + answer_question, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 873e9fc..a4594e8 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuestionOption { + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserQuestionEvent { + pub id: String, + pub question: String, + pub header: Option, + pub options: Vec, + pub multi_select: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 1477eb1..899366d 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -11,7 +11,7 @@ use std::os::windows::process::CommandExt; use crate::config::ClaudeStartOptions; use crate::stats::{UsageStats, StatsUpdateEvent}; use parking_lot::RwLock; -use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent}; +use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent, UserQuestionEvent, QuestionOption}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; @@ -350,6 +350,35 @@ impl WslBridge { Ok(()) } + pub fn send_tool_result(&mut self, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + let stdin = self.stdin.as_mut().ok_or("Process not running")?; + + // The content should be a JSON string representation of the result + let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?; + + let input = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content_str + }] + } + }); + + let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?; + + stdin + .write_all(format!("{}\n", json_line).as_bytes()) + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + + stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; + + Ok(()) + } + pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // we have to kill the process. This is the only reliable way to stop it. @@ -640,19 +669,68 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Check for permission denials and emit prompts for each if let Some(denials) = permission_denials { + let mut has_regular_denials = false; + for denial in denials { - let description = format_tool_description(&denial.tool_name, &denial.tool_input); - let _ = app.emit("claude:permission", PermissionPromptEvent { - id: denial.tool_use_id.clone(), - tool_name: denial.tool_name.clone(), - tool_input: denial.tool_input.clone(), - description, - conversation_id: conversation_id.clone(), - }); + // Special handling for AskUserQuestion tool + if denial.tool_name == "AskUserQuestion" { + if let Some(questions) = denial.tool_input.get("questions").and_then(|q| q.as_array()) { + // For now, handle the first question (most common case) + if let Some(first_question) = questions.first() { + let question_text = first_question.get("question") + .and_then(|q| q.as_str()) + .unwrap_or("Claude has a question for you") + .to_string(); + + let header = first_question.get("header") + .and_then(|h| h.as_str()) + .map(|s| s.to_string()); + + let multi_select = first_question.get("multiSelect") + .and_then(|m| m.as_bool()) + .unwrap_or(false); + + let options: Vec = first_question.get("options") + .and_then(|opts| opts.as_array()) + .map(|opts| { + opts.iter().filter_map(|opt| { + let label = opt.get("label").and_then(|l| l.as_str())?; + let description = opt.get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + Some(QuestionOption { + label: label.to_string(), + description, + }) + }).collect() + }) + .unwrap_or_default(); + + let _ = app.emit("claude:question", UserQuestionEvent { + id: denial.tool_use_id.clone(), + question: question_text, + header, + options, + multi_select, + conversation_id: conversation_id.clone(), + }); + } + } + } else { + has_regular_denials = true; + let description = format_tool_description(&denial.tool_name, &denial.tool_input); + let _ = app.emit("claude:permission", PermissionPromptEvent { + id: denial.tool_use_id.clone(), + tool_name: denial.tool_name.clone(), + tool_input: denial.tool_input.clone(), + description, + conversation_id: conversation_id.clone(), + }); + } } - // Show permission state if there were denials - if !denials.is_empty() { + // Show permission state if there were any denials (questions or regular) + if has_regular_denials || !denials.is_empty() { emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); return Ok(()); } diff --git a/src/lib/components/UserQuestionModal.svelte b/src/lib/components/UserQuestionModal.svelte new file mode 100644 index 0000000..d22d5e1 --- /dev/null +++ b/src/lib/components/UserQuestionModal.svelte @@ -0,0 +1,264 @@ + + + + +{#if isVisible && question} +
+
+
+
+ ? +
+
+

+ {question.header || "Question"} +

+

Hikari needs your input

+
+
+ +
+

{question.question}

+
+ +
+ {#each question.options as option (option.label)} + + {/each} + + +
+ + {#if showCustomInput} +
+ +
+ {/if} + +
+ + +
+
+
+{/if} diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index f8a92bb..d912df0 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -21,6 +21,7 @@ export const claudeStore = { currentWorkingDirectory: conversationsStore.currentWorkingDirectory, terminalLines: conversationsStore.terminalLines, pendingPermission: conversationsStore.pendingPermission, + pendingQuestion: conversationsStore.pendingQuestion, isProcessing: conversationsStore.isProcessing, grantedTools: conversationsStore.grantedTools, pendingRetryMessage: conversationsStore.pendingRetryMessage, @@ -49,6 +50,10 @@ export const claudeStore = { clearPermission: conversationsStore.clearPermission, requestPermissionForConversation: conversationsStore.requestPermissionForConversation, clearPermissionForConversation: conversationsStore.clearPermissionForConversation, + requestQuestion: conversationsStore.requestQuestion, + clearQuestion: conversationsStore.clearQuestion, + requestQuestionForConversation: conversationsStore.requestQuestionForConversation, + clearQuestionForConversation: conversationsStore.clearQuestionForConversation, grantTool: conversationsStore.grantTool, revokeAllTools: conversationsStore.revokeAllTools, isToolGranted: conversationsStore.isToolGranted, @@ -89,6 +94,11 @@ export const hasPermissionPending = derived( ($conversation) => $conversation?.pendingPermission !== null ); +export const hasQuestionPending = derived( + claudeStore.activeConversation, + ($conversation) => $conversation?.pendingQuestion !== null +); + // Derived store to check if Claude is currently processing (can be interrupted) export const isClaudeProcessing = derived( [claudeStore.connectionStatus, characterState], diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index e5a21cc..3a8b8ac 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -1,5 +1,10 @@ import { writable, derived, get } from "svelte/store"; -import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages"; +import type { + TerminalLine, + ConnectionStatus, + PermissionRequest, + UserQuestionEvent, +} from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { cleanupConversationTracking } from "$lib/tauri"; import { characterState } from "$lib/stores/character"; @@ -15,6 +20,7 @@ export interface Conversation { isProcessing: boolean; grantedTools: Set; pendingPermission: PermissionRequest | null; + pendingQuestion: UserQuestionEvent | null; createdAt: Date; lastActivityAt: Date; } @@ -48,6 +54,7 @@ function createConversationsStore() { isProcessing: false, grantedTools: new Set(), pendingPermission: null, + pendingQuestion: null, createdAt: new Date(), lastActivityAt: new Date(), }; @@ -98,6 +105,7 @@ function createConversationsStore() { activeConversation, ($conv) => $conv?.pendingPermission || null ); + const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); return { // Expose derived stores for compatibility @@ -106,6 +114,7 @@ function createConversationsStore() { currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, terminalLines: { subscribe: terminalLines.subscribe }, pendingPermission: { subscribe: pendingPermission.subscribe }, + pendingQuestion: { subscribe: pendingQuestion.subscribe }, isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, @@ -199,6 +208,52 @@ function createConversationsStore() { return convs; }); }, + requestQuestion: (question: UserQuestionEvent) => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingQuestion = question; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearQuestion: () => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingQuestion = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingQuestion = question; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearQuestionForConversation: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingQuestion = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), // Conversation management diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 30a0eaf..a1f7ec5 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -6,7 +6,11 @@ import { characterState } from "$lib/stores/character"; import { configStore } from "$lib/stores/config"; import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import { initAchievementsListener } from "$lib/stores/achievements"; -import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages"; +import type { + ConnectionStatus, + PermissionPromptEvent, + UserQuestionEvent, +} from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { initializeNotificationRules, @@ -317,6 +321,25 @@ export async function initializeTauriListeners() { } }); unlisteners.push(permissionUnlisten); + + const questionUnlisten = await listen("claude:question", (event) => { + const questionEvent = event.payload; + + // Store question request for the specific conversation + if (questionEvent.conversation_id) { + claudeStore.requestQuestionForConversation(questionEvent.conversation_id, questionEvent); + claudeStore.addLineToConversation( + questionEvent.conversation_id, + "system", + `Question: ${questionEvent.question}` + ); + } else { + // Fallback to active conversation if no conversation_id + claudeStore.requestQuestion(questionEvent); + claudeStore.addLine("system", `Question: ${questionEvent.question}`); + } + }); + unlisteners.push(questionUnlisten); } export function cleanupTauriListeners() { diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 1527310..9eb6834 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -126,4 +126,18 @@ export interface PermissionPromptEvent { conversation_id?: string; } +export interface QuestionOption { + label: string; + description?: string; +} + +export interface UserQuestionEvent { + id: string; + question: string; + header?: string; + options: QuestionOption[]; + multi_select: boolean; + conversation_id?: string; +} + export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 360aedb..1d6e0d9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -14,6 +14,7 @@ import StatusBar from "$lib/components/StatusBar.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte"; + import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import AchievementNotification from "$lib/components/AchievementNotification.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; @@ -139,6 +140,7 @@ +