feat: add AskUserQuestion tool support (#60)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m12s
CI / Build Linux (push) Successful in 16m41s
CI / Build Windows (cross-compile) (push) Successful in 27m0s

## 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 <hikari@nhcarrigan.com>
Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2026-01-23 14:11:18 -08:00
parent 94991796be
commit 06810537a9
11 changed files with 497 additions and 13 deletions
+89 -11
View File
@@ -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<RwLock<UsageStats>
// 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<QuestionOption> = 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(());
}