generated from nhcarrigan/template
feat: add AskUserQuestion tool support #60
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
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: SvelteSet<string> = new SvelteSet();
|
||||
let customAnswer = $state("");
|
||||
let showCustomInput = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
|
||||
hasQuestionPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
if (!pending) {
|
||||
selectedOptions = new SvelteSet();
|
||||
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) {
|
||||
if (selectedOptions.has(label)) {
|
||||
selectedOptions.delete(label);
|
||||
} else {
|
||||
selectedOptions.add(label);
|
||||
}
|
||||
} else {
|
||||
selectedOptions.clear();
|
||||
selectedOptions.add(label);
|
||||
}
|
||||
showCustomInput = false;
|
||||
}
|
||||
|
||||
function selectCustom() {
|
||||
showCustomInput = true;
|
||||
selectedOptions.clear();
|
||||
}
|
||||
|
||||
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 (option.label)}
|
||||
<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,
|
||||
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],
|
||||
|
||||
@@ -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<string>;
|
||||
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
|
||||
|
||||
+24
-1
@@ -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<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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 @@
|
||||
</main>
|
||||
|
||||
<PermissionModal />
|
||||
<UserQuestionModal />
|
||||
<ConfigSidebar />
|
||||
<AchievementNotification />
|
||||
<AchievementsPanel
|
||||
|
||||
Reference in New Issue
Block a user