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
+264
View File
@@ -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}
+10
View File
@@ -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],
+56 -1
View File
@@ -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
View File
@@ -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() {
+14
View File
@@ -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";
+2
View File
@@ -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