From efdc7af58aec2b387e37434f555fd1865664d51f Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 09:21:32 -0700 Subject: [PATCH] feat: handle Elicitation and ElicitationResult hook events (#223) Parses [Elicitation Hook] and [ElicitationResult Hook] from Claude Code stderr, emits claude:elicitation and claude:elicitation-result Tauri events, and renders an ElicitationModal for MCP server input requests. --- src-tauri/src/types.rs | 79 +++++++++ src-tauri/src/wsl_bridge.rs | 193 ++++++++++++++++++++- src/lib/components/ElicitationModal.svelte | 187 ++++++++++++++++++++ src/lib/stores/claude.ts | 11 ++ src/lib/stores/conversations.ts | 54 ++++++ src/lib/tauri.ts | 33 +++- src/lib/types/messages.ts | 16 +- src/routes/+page.svelte | 2 + 8 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 src/lib/components/ElicitationModal.svelte diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f73cb4b..d603b47 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -280,6 +280,26 @@ pub struct UserQuestionEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationEvent { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationResultEvent { + pub action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -566,4 +586,63 @@ mod tests { panic!("Expected RateLimitEvent variant"); } } + + #[test] + fn test_elicitation_event_serialization() { + let event = ElicitationEvent { + message: "Please provide the API endpoint".to_string(), + server_name: Some("my-server".to_string()), + request_id: Some("req-123".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"message\":\"Please provide the API endpoint\"")); + assert!(serialized.contains("\"server_name\":\"my-server\"")); + assert!(serialized.contains("\"request_id\":\"req-123\"")); + assert!(serialized.contains("\"conversation_id\":\"conv-abc\"")); + } + + #[test] + fn test_elicitation_event_omits_none_fields() { + let event = ElicitationEvent { + message: "Enter your token".to_string(), + server_name: None, + request_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"message\":\"Enter your token\"")); + assert!(!serialized.contains("server_name")); + assert!(!serialized.contains("request_id")); + assert!(!serialized.contains("conversation_id")); + } + + #[test] + fn test_elicitation_result_event_serialization() { + let event = ElicitationResultEvent { + action: "accept".to_string(), + request_id: Some("req-123".to_string()), + conversation_id: Some("conv-abc".to_string()), + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"action\":\"accept\"")); + assert!(serialized.contains("\"request_id\":\"req-123\"")); + } + + #[test] + fn test_elicitation_result_event_cancel_omits_none_fields() { + let event = ElicitationResultEvent { + action: "cancel".to_string(), + request_id: None, + conversation_id: None, + }; + + let serialized = serde_json::to_string(&event).unwrap(); + assert!(serialized.contains("\"action\":\"cancel\"")); + assert!(!serialized.contains("request_id")); + assert!(!serialized.contains("conversation_id")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 1d720c8..240dc7d 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -15,9 +15,10 @@ use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, - ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent, - PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem, - TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, + ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, + OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, + StateChangeEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, + WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1051,11 +1052,15 @@ fn handle_stderr( // Hook events are informational — emit with distinct types instead of error let is_worktree_create = line.contains("[WorktreeCreate Hook]"); let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); + let is_elicitation = line.contains("[Elicitation Hook]"); + let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" } else if line.contains("[ConfigChange Hook]") { "config-change" + } else if is_elicitation || is_elicitation_result { + "elicitation" } else { "error" }; @@ -1097,6 +1102,57 @@ fn handle_stderr( parent_tool_use_id: None, }, ); + } else if is_elicitation { + let data = parse_elicitation_hook(&line); + let friendly_content = + format!("MCP server requesting input: {}", data.message); + + let _ = app.emit( + "claude:elicitation", + ElicitationEvent { + message: data.message, + server_name: data.server_name, + request_id: data.request_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "elicitation".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); + } else if is_elicitation_result { + let data = parse_elicitation_result_hook(&line); + let friendly_content = + format!("MCP elicitation completed: {}", data.action); + + let _ = app.emit( + "claude:elicitation-result", + ElicitationResultEvent { + action: data.action, + request_id: data.request_id, + conversation_id: conversation_id.clone(), + }, + ); + + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "elicitation".to_string(), + content: friendly_content, + tool_name: None, + conversation_id: conversation_id.clone(), + cost: None, + parent_tool_use_id: None, + }, + ); } else { let _ = app.emit( "claude:output", @@ -1235,6 +1291,73 @@ fn parse_subagent_stop_hook(line: &str) -> Option { }) } +#[derive(Debug)] +struct ElicitationData { + message: String, + server_name: Option, + request_id: Option, +} + +fn parse_elicitation_hook(line: &str) -> ElicitationData { + let message = extract_quoted_value(line, "message").unwrap_or_else(|| { + line.split("[Elicitation Hook]") + .nth(1) + .unwrap_or("") + .trim() + .to_string() + }); + + let server_name = extract_debug_string_value(line, "server_name"); + let request_id = extract_debug_string_value(line, "request_id"); + + ElicitationData { message, server_name, request_id } +} + +#[derive(Debug)] +struct ElicitationResultData { + action: String, + request_id: Option, +} + +fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData { + let action = + extract_quoted_value(line, "action").unwrap_or_else(|| "unknown".to_string()); + + let request_id = extract_debug_string_value(line, "request_id"); + + ElicitationResultData { action, request_id } +} + +/// Extracts a double-quoted string value from a `key="value"` pair in a hook line. +/// Handles escape sequences within the quoted value. +fn extract_quoted_value(line: &str, key: &str) -> Option { + let prefix = format!("{}=\"", key); + let start_idx = line.find(&prefix)? + prefix.len(); + let rest = &line[start_idx..]; + + let mut result = String::new(); + let mut chars = rest.chars(); + loop { + match chars.next() { + Some('"') => return Some(result), + Some('\\') => match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => break, + }, + Some(c) => result.push(c), + None => break, + } + } + None +} + /// Extract text content from a ToolResult's `content` field. /// The content may be a JSON string or an array of typed content blocks. fn extract_tool_result_text(content: &serde_json::Value) -> Option { @@ -3191,4 +3314,68 @@ mod tests { let result = build_combined_settings_arg(Some(""), None); assert_eq!(result, "{}"); } + + #[test] + fn test_extract_quoted_value_basic() { + let line = r#"[Elicitation Hook] message="Hello world", server_name=Some("srv")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, Some("Hello world".to_string())); + } + + #[test] + fn test_extract_quoted_value_with_escapes() { + let line = r#"[Elicitation Hook] message="Line one\nLine two", request_id=Some("r1")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, Some("Line one\nLine two".to_string())); + } + + #[test] + fn test_extract_quoted_value_missing() { + let line = r#"[Elicitation Hook] server_name=Some("srv")"#; + let result = extract_quoted_value(line, "message"); + assert_eq!(result, None); + } + + #[test] + fn test_parse_elicitation_hook_with_all_fields() { + let line = r#"[Elicitation Hook] message="Please enter your API key", server_name=Some("my-mcp"), request_id=Some("req-456")"#; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "Please enter your API key"); + assert_eq!(data.server_name, Some("my-mcp".to_string())); + assert_eq!(data.request_id, Some("req-456".to_string())); + } + + #[test] + fn test_parse_elicitation_hook_missing_optional_fields() { + let line = r#"[Elicitation Hook] message="What is the endpoint?", server_name=None, request_id=None"#; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "What is the endpoint?"); + assert_eq!(data.server_name, None); + assert_eq!(data.request_id, None); + } + + #[test] + fn test_parse_elicitation_hook_invalid_line() { + let line = "[Elicitation Hook] some unstructured data"; + let data = parse_elicitation_hook(line); + assert_eq!(data.message, "some unstructured data"); + assert_eq!(data.server_name, None); + assert_eq!(data.request_id, None); + } + + #[test] + fn test_parse_elicitation_result_hook_accept() { + let line = r#"[ElicitationResult Hook] action="accept", request_id=Some("req-789")"#; + let data = parse_elicitation_result_hook(line); + assert_eq!(data.action, "accept"); + assert_eq!(data.request_id, Some("req-789".to_string())); + } + + #[test] + fn test_parse_elicitation_result_hook_cancel() { + let line = r#"[ElicitationResult Hook] action="cancel", request_id=None"#; + let data = parse_elicitation_result_hook(line); + assert_eq!(data.action, "cancel"); + assert_eq!(data.request_id, None); + } } diff --git a/src/lib/components/ElicitationModal.svelte b/src/lib/components/ElicitationModal.svelte new file mode 100644 index 0000000..47d82a3 --- /dev/null +++ b/src/lib/components/ElicitationModal.svelte @@ -0,0 +1,187 @@ + + + + +{#if isVisible && elicitation} +
+
+
+
+ 💬 +
+
+

MCP Server Request

+ {#if elicitation.server_name} +

from: {elicitation.server_name}

+ {:else} +

Input required from MCP server

+ {/if} +
+
+ +
+

{elicitation.message}

+
+ +
+ +
+ +
+ + +
+
+
+{/if} diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 3b3cdcb..246e8b5 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -22,6 +22,7 @@ export const claudeStore = { terminalLines: conversationsStore.terminalLines, pendingPermission: conversationsStore.pendingPermission, pendingQuestion: conversationsStore.pendingQuestion, + pendingElicitation: conversationsStore.pendingElicitation, isProcessing: conversationsStore.isProcessing, grantedTools: conversationsStore.grantedTools, pendingRetryMessage: conversationsStore.pendingRetryMessage, @@ -57,6 +58,10 @@ export const claudeStore = { clearQuestion: conversationsStore.clearQuestion, requestQuestionForConversation: conversationsStore.requestQuestionForConversation, clearQuestionForConversation: conversationsStore.clearQuestionForConversation, + requestElicitation: conversationsStore.requestElicitation, + clearElicitation: conversationsStore.clearElicitation, + requestElicitationForConversation: conversationsStore.requestElicitationForConversation, + clearElicitationForConversation: conversationsStore.clearElicitationForConversation, grantTool: conversationsStore.grantTool, revokeAllTools: conversationsStore.revokeAllTools, isToolGranted: conversationsStore.isToolGranted, @@ -126,6 +131,12 @@ export const hasQuestionPending = derived( ($conversation) => $conversation?.pendingQuestion !== null ); +export const hasElicitationPending = derived( + claudeStore.activeConversation, + ($conversation) => + $conversation?.pendingElicitation !== null && $conversation?.pendingElicitation !== undefined +); + // Derived store to check if Claude is currently processing (can be interrupted) export const isClaudeProcessing = derived( [claudeStore.connectionStatus, characterState], diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index cf8bf31..f34615a 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -2,6 +2,7 @@ import { writable, derived, get } from "svelte/store"; import type { TerminalLine, ConnectionStatus, + ElicitationEvent, PermissionRequest, UserQuestionEvent, Attachment, @@ -32,6 +33,7 @@ export interface Conversation { grantedTools: Set; pendingPermissions: PermissionRequest[]; pendingQuestion: UserQuestionEvent | null; + pendingElicitation: ElicitationEvent | null; scrollPosition: number; createdAt: Date; lastActivityAt: Date; @@ -157,6 +159,7 @@ function createConversationsStore() { grantedTools: new Set(), pendingPermissions: [], pendingQuestion: null, + pendingElicitation: null, scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll) createdAt: new Date(), lastActivityAt: new Date(), @@ -221,6 +224,10 @@ function createConversationsStore() { ($conv) => $conv?.pendingPermissions || [] ); const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null); + const pendingElicitation = derived( + activeConversation, + ($conv) => $conv?.pendingElicitation ?? null + ); const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1); const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []); const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null); @@ -234,6 +241,7 @@ function createConversationsStore() { pendingPermission: { subscribe: pendingPermission.subscribe }, pendingPermissions: { subscribe: pendingPermissions.subscribe }, pendingQuestion: { subscribe: pendingQuestion.subscribe }, + pendingElicitation: { subscribe: pendingElicitation.subscribe }, isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, @@ -399,6 +407,52 @@ function createConversationsStore() { return convs; }); }, + requestElicitation: (elicitation: ElicitationEvent) => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingElicitation = elicitation; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearElicitation: () => { + const activeId = get(activeConversationId); + if (!activeId) return; + + conversations.update((convs) => { + const conv = convs.get(activeId); + if (conv) { + conv.pendingElicitation = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + requestElicitationForConversation: (conversationId: string, elicitation: ElicitationEvent) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingElicitation = elicitation; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, + clearElicitationForConversation: (conversationId: string) => { + conversations.update((convs) => { + const conv = convs.get(conversationId); + if (conv) { + conv.pendingElicitation = null; + conv.lastActivityAt = new Date(); + } + return convs; + }); + }, setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), // Conversation management diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c1e5252..7684450 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -8,6 +8,7 @@ import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import { initAchievementsListener } from "$lib/stores/achievements"; import type { ConnectionStatus, + ElicitationEvent, PermissionPromptEvent, UserQuestionEvent, } from "$lib/types/messages"; @@ -406,7 +407,8 @@ export async function initializeTauriListeners() { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change", + | "config-change" + | "elicitation", content, tool_name || undefined, costData, @@ -425,7 +427,8 @@ export async function initializeTauriListeners() { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change", + | "config-change" + | "elicitation", content, tool_name || undefined, costData, @@ -611,6 +614,32 @@ export async function initializeTauriListeners() { } }); unlisteners.push(questionUnlisten); + + const elicitationUnlisten = await listen("claude:elicitation", (event) => { + const elicitationEvent = event.payload; + if (elicitationEvent.conversation_id) { + claudeStore.requestElicitationForConversation( + elicitationEvent.conversation_id, + elicitationEvent + ); + } else { + claudeStore.requestElicitation(elicitationEvent); + } + }); + unlisteners.push(elicitationUnlisten); + + const elicitationResultUnlisten = await listen<{ conversation_id?: string }>( + "claude:elicitation-result", + (event) => { + const { conversation_id } = event.payload; + if (conversation_id) { + claudeStore.clearElicitationForConversation(conversation_id); + } else { + claudeStore.clearElicitation(); + } + } + ); + unlisteners.push(elicitationResultUnlisten); } export function cleanupTauriListeners() { diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index 966ebb1..bc2387b 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -10,7 +10,8 @@ export interface TerminalLine { | "rate-limit" | "compact-prompt" | "worktree" - | "config-change"; + | "config-change" + | "elicitation"; content: string; timestamp: Date; toolName?: string; @@ -162,6 +163,19 @@ export interface UserQuestionEvent { conversation_id?: string; } +export interface ElicitationEvent { + message: string; + server_name?: string; + request_id?: string; + conversation_id?: string; +} + +export interface ElicitationResultEvent { + action: string; + request_id?: string; + conversation_id?: string; +} + export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export interface Attachment { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5aae88d..e77d9e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -36,6 +36,7 @@ import type { CharacterState } from "$lib/types/states"; import PermissionModal from "$lib/components/PermissionModal.svelte"; import UserQuestionModal from "$lib/components/UserQuestionModal.svelte"; + import ElicitationModal from "$lib/components/ElicitationModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; import ToastContainer from "$lib/components/ToastContainer.svelte"; @@ -593,6 +594,7 @@ +