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
+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