generated from nhcarrigan/template
feat: add AskUserQuestion tool support
Implements support for Claude's AskUserQuestion tool, allowing Claude to ask the user questions with multiple choice options during a conversation. - 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 - 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 Closes #51
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -169,3 +169,14 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn answer_question(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserQuestionEvent {
|
||||
pub id: String,
|
||||
pub question: String,
|
||||
pub header: Option<String>,
|
||||
pub options: Vec<QuestionOption>,
|
||||
pub multi_select: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+89
-11
@@ -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(());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user