generated from nhcarrigan/template
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.
This commit is contained in:
+190
-3
@@ -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<SubagentStopData> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ElicitationData {
|
||||
message: String,
|
||||
server_name: Option<String>,
|
||||
request_id: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user