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 {
|
pub fn is_claude_running(&self, conversation_id: &str) -> bool {
|
||||||
self.bridges.get(conversation_id)
|
self.bridges.get(conversation_id)
|
||||||
.map(|b| b.is_running())
|
.map(|b| b.is_running())
|
||||||
|
|||||||
@@ -169,3 +169,14 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
|
|||||||
|
|
||||||
Ok(events)
|
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,
|
save_config,
|
||||||
get_usage_stats,
|
get_usage_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
|
answer_question,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
send_simple_notification,
|
send_simple_notification,
|
||||||
send_windows_toast,
|
send_windows_toast,
|
||||||
|
|||||||
@@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent {
|
|||||||
pub conversation_id: Option<String>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+89
-11
@@ -11,7 +11,7 @@ use std::os::windows::process::CommandExt;
|
|||||||
use crate::config::ClaudeStartOptions;
|
use crate::config::ClaudeStartOptions;
|
||||||
use crate::stats::{UsageStats, StatsUpdateEvent};
|
use crate::stats::{UsageStats, StatsUpdateEvent};
|
||||||
use parking_lot::RwLock;
|
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};
|
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||||
|
|
||||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||||
@@ -350,6 +350,35 @@ impl WslBridge {
|
|||||||
Ok(())
|
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> {
|
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
|
||||||
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
// 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.
|
// 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
|
// Check for permission denials and emit prompts for each
|
||||||
if let Some(denials) = permission_denials {
|
if let Some(denials) = permission_denials {
|
||||||
|
let mut has_regular_denials = false;
|
||||||
|
|
||||||
for denial in denials {
|
for denial in denials {
|
||||||
let description = format_tool_description(&denial.tool_name, &denial.tool_input);
|
// Special handling for AskUserQuestion tool
|
||||||
let _ = app.emit("claude:permission", PermissionPromptEvent {
|
if denial.tool_name == "AskUserQuestion" {
|
||||||
id: denial.tool_use_id.clone(),
|
if let Some(questions) = denial.tool_input.get("questions").and_then(|q| q.as_array()) {
|
||||||
tool_name: denial.tool_name.clone(),
|
// For now, handle the first question (most common case)
|
||||||
tool_input: denial.tool_input.clone(),
|
if let Some(first_question) = questions.first() {
|
||||||
description,
|
let question_text = first_question.get("question")
|
||||||
conversation_id: conversation_id.clone(),
|
.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
|
// Show permission state if there were any denials (questions or regular)
|
||||||
if !denials.is_empty() {
|
if has_regular_denials || !denials.is_empty() {
|
||||||
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||||
|
|
||||||
|
let isVisible = $state(false);
|
||||||
|
let question: UserQuestionEvent | null = $state(null);
|
||||||
|
let selectedOptions: Set<string> = $state(new Set());
|
||||||
|
let customAnswer = $state("");
|
||||||
|
let showCustomInput = $state(false);
|
||||||
|
let grantedToolsList: string[] = $state([]);
|
||||||
|
let workingDirectory = $state("");
|
||||||
|
|
||||||
|
hasQuestionPending.subscribe((pending) => {
|
||||||
|
isVisible = pending;
|
||||||
|
if (!pending) {
|
||||||
|
selectedOptions = new Set();
|
||||||
|
customAnswer = "";
|
||||||
|
showCustomInput = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.pendingQuestion.subscribe((q) => {
|
||||||
|
question = q;
|
||||||
|
if (q) {
|
||||||
|
characterState.setState("permission");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
|
grantedToolsList = Array.from(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
||||||
|
workingDirectory = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleOption(label: string) {
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
if (question.multi_select) {
|
||||||
|
const newSet = new Set(selectedOptions);
|
||||||
|
if (newSet.has(label)) {
|
||||||
|
newSet.delete(label);
|
||||||
|
} else {
|
||||||
|
newSet.add(label);
|
||||||
|
}
|
||||||
|
selectedOptions = newSet;
|
||||||
|
} else {
|
||||||
|
selectedOptions = new Set([label]);
|
||||||
|
}
|
||||||
|
showCustomInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCustom() {
|
||||||
|
showCustomInput = true;
|
||||||
|
selectedOptions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitAndReconnect() {
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
let answerText: string;
|
||||||
|
|
||||||
|
if (showCustomInput && customAnswer.trim()) {
|
||||||
|
answerText = customAnswer.trim();
|
||||||
|
} else if (selectedOptions.size > 0) {
|
||||||
|
if (question.multi_select) {
|
||||||
|
answerText = Array.from(selectedOptions).join(", ");
|
||||||
|
} else {
|
||||||
|
answerText = Array.from(selectedOptions)[0];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionText = question.question;
|
||||||
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
claudeStore.addLine("system", `Answer: ${answerText}. Reconnecting with context...`);
|
||||||
|
claudeStore.clearQuestion();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
allowed_tools: grantedToolsList,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (conversationHistory) {
|
||||||
|
const contextMessage = `[CONTEXT RESTORATION]
|
||||||
|
I just answered your question. Here's our conversation so far:
|
||||||
|
|
||||||
|
${conversationHistory}
|
||||||
|
|
||||||
|
You asked me: "${questionText}"
|
||||||
|
My answer: "${answerText}"
|
||||||
|
|
||||||
|
Please continue where we left off, taking my answer into account.`;
|
||||||
|
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message: contextMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reconnect:", error);
|
||||||
|
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
claudeStore.clearQuestion();
|
||||||
|
claudeStore.addLine("system", "Question dismissed");
|
||||||
|
characterState.setTemporaryState("idle", 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!isVisible || !question) return;
|
||||||
|
|
||||||
|
if (event.key === "Enter" && !showCustomInput) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedOptions.size > 0) {
|
||||||
|
handleSubmitAndReconnect();
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmit(): boolean {
|
||||||
|
return selectedOptions.size > 0 || (showCustomInput && customAnswer.trim().length > 0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isVisible && question}
|
||||||
|
<div
|
||||||
|
class="question-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="question-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-xl">?</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||||
|
{question.header || "Question"}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">Hikari needs your input</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-[var(--text-primary)]">{question.question}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
{#each question.options as option}
|
||||||
|
<button
|
||||||
|
onclick={() => toggleOption(option.label)}
|
||||||
|
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {selectedOptions.has(
|
||||||
|
option.label
|
||||||
|
)
|
||||||
|
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
||||||
|
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="mt-0.5 w-5 h-5 rounded-{question.multi_select
|
||||||
|
? 'sm'
|
||||||
|
: 'full'} border-2 flex items-center justify-center {selectedOptions.has(
|
||||||
|
option.label
|
||||||
|
)
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{#if selectedOptions.has(option.label)}
|
||||||
|
<span class="text-white text-xs">{question.multi_select ? "x" : "x"}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">{option.label}</div>
|
||||||
|
{#if option.description}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] mt-1">{option.description}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={selectCustom}
|
||||||
|
class="w-full text-left px-4 py-3 rounded-lg border transition-colors {showCustomInput
|
||||||
|
? 'bg-[var(--accent-primary)]/20 border-[var(--accent-primary)] text-[var(--text-primary)]'
|
||||||
|
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center {showCustomInput
|
||||||
|
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]'
|
||||||
|
: 'border-[var(--text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{#if showCustomInput}
|
||||||
|
<span class="text-white text-xs">x</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">Other</div>
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] mt-1">Provide a custom answer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCustomInput}
|
||||||
|
<div class="mb-4">
|
||||||
|
<textarea
|
||||||
|
bind:value={customAnswer}
|
||||||
|
placeholder="Type your answer here..."
|
||||||
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={handleDismiss}
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleSubmitAndReconnect}
|
||||||
|
disabled={!canSubmit()}
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Answer & Reconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -21,6 +21,7 @@ export const claudeStore = {
|
|||||||
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
||||||
terminalLines: conversationsStore.terminalLines,
|
terminalLines: conversationsStore.terminalLines,
|
||||||
pendingPermission: conversationsStore.pendingPermission,
|
pendingPermission: conversationsStore.pendingPermission,
|
||||||
|
pendingQuestion: conversationsStore.pendingQuestion,
|
||||||
isProcessing: conversationsStore.isProcessing,
|
isProcessing: conversationsStore.isProcessing,
|
||||||
grantedTools: conversationsStore.grantedTools,
|
grantedTools: conversationsStore.grantedTools,
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||||
@@ -49,6 +50,10 @@ export const claudeStore = {
|
|||||||
clearPermission: conversationsStore.clearPermission,
|
clearPermission: conversationsStore.clearPermission,
|
||||||
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
|
requestPermissionForConversation: conversationsStore.requestPermissionForConversation,
|
||||||
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
|
clearPermissionForConversation: conversationsStore.clearPermissionForConversation,
|
||||||
|
requestQuestion: conversationsStore.requestQuestion,
|
||||||
|
clearQuestion: conversationsStore.clearQuestion,
|
||||||
|
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
||||||
|
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
||||||
grantTool: conversationsStore.grantTool,
|
grantTool: conversationsStore.grantTool,
|
||||||
revokeAllTools: conversationsStore.revokeAllTools,
|
revokeAllTools: conversationsStore.revokeAllTools,
|
||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
@@ -89,6 +94,11 @@ export const hasPermissionPending = derived(
|
|||||||
($conversation) => $conversation?.pendingPermission !== null
|
($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)
|
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||||
export const isClaudeProcessing = derived(
|
export const isClaudeProcessing = derived(
|
||||||
[claudeStore.connectionStatus, characterState],
|
[claudeStore.connectionStatus, characterState],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable, derived, get } from "svelte/store";
|
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 type { CharacterState } from "$lib/types/states";
|
||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
@@ -15,6 +15,7 @@ export interface Conversation {
|
|||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermission: PermissionRequest | null;
|
pendingPermission: PermissionRequest | null;
|
||||||
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,7 @@ function createConversationsStore() {
|
|||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingQuestion: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -98,6 +100,10 @@ function createConversationsStore() {
|
|||||||
activeConversation,
|
activeConversation,
|
||||||
($conv) => $conv?.pendingPermission || null
|
($conv) => $conv?.pendingPermission || null
|
||||||
);
|
);
|
||||||
|
const pendingQuestion = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.pendingQuestion || null
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Expose derived stores for compatibility
|
// Expose derived stores for compatibility
|
||||||
@@ -106,6 +112,7 @@ function createConversationsStore() {
|
|||||||
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
||||||
terminalLines: { subscribe: terminalLines.subscribe },
|
terminalLines: { subscribe: terminalLines.subscribe },
|
||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
@@ -199,6 +206,52 @@ function createConversationsStore() {
|
|||||||
return convs;
|
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),
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
|
|||||||
+20
-1
@@ -6,7 +6,7 @@ import { characterState } from "$lib/stores/character";
|
|||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
||||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
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 type { CharacterState } from "$lib/types/states";
|
||||||
import {
|
import {
|
||||||
initializeNotificationRules,
|
initializeNotificationRules,
|
||||||
@@ -317,6 +317,25 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(permissionUnlisten);
|
unlisteners.push(permissionUnlisten);
|
||||||
|
|
||||||
|
const questionUnlisten = await listen<UserQuestionEvent>("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() {
|
export function cleanupTauriListeners() {
|
||||||
|
|||||||
@@ -126,4 +126,18 @@ export interface PermissionPromptEvent {
|
|||||||
conversation_id?: string;
|
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";
|
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import StatusBar from "$lib/components/StatusBar.svelte";
|
import StatusBar from "$lib/components/StatusBar.svelte";
|
||||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||||
|
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||||
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
|
||||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PermissionModal />
|
<PermissionModal />
|
||||||
|
<UserQuestionModal />
|
||||||
<ConfigSidebar />
|
<ConfigSidebar />
|
||||||
<AchievementNotification />
|
<AchievementNotification />
|
||||||
<AchievementsPanel
|
<AchievementsPanel
|
||||||
|
|||||||
Reference in New Issue
Block a user