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:
@@ -280,6 +280,26 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ElicitationEvent {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub server_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ElicitationResultEvent {
|
||||||
|
pub action: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentStartEvent {
|
pub struct AgentStartEvent {
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
@@ -566,4 +586,63 @@ mod tests {
|
|||||||
panic!("Expected RateLimitEvent variant");
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-3
@@ -15,9 +15,10 @@ use crate::process_ext::HideWindow;
|
|||||||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||||
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
|
||||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
|
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
|
||||||
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
StateChangeEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||||
|
WorktreeEvent, WorktreeInfo,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -1051,11 +1052,15 @@ fn handle_stderr(
|
|||||||
// Hook events are informational — emit with distinct types instead of error
|
// Hook events are informational — emit with distinct types instead of error
|
||||||
let is_worktree_create = line.contains("[WorktreeCreate Hook]");
|
let is_worktree_create = line.contains("[WorktreeCreate Hook]");
|
||||||
let is_worktree_remove = line.contains("[WorktreeRemove 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 {
|
let line_type = if is_worktree_create || is_worktree_remove {
|
||||||
"worktree"
|
"worktree"
|
||||||
} else if line.contains("[ConfigChange Hook]") {
|
} else if line.contains("[ConfigChange Hook]") {
|
||||||
"config-change"
|
"config-change"
|
||||||
|
} else if is_elicitation || is_elicitation_result {
|
||||||
|
"elicitation"
|
||||||
} else {
|
} else {
|
||||||
"error"
|
"error"
|
||||||
};
|
};
|
||||||
@@ -1097,6 +1102,57 @@ fn handle_stderr(
|
|||||||
parent_tool_use_id: None,
|
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 {
|
} else {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"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.
|
/// Extract text content from a ToolResult's `content` field.
|
||||||
/// The content may be a JSON string or an array of typed content blocks.
|
/// 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> {
|
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);
|
let result = build_combined_settings_arg(Some(""), None);
|
||||||
assert_eq!(result, "{}");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore, hasElicitationPending } from "$lib/stores/claude";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { ElicitationEvent } from "$lib/types/messages";
|
||||||
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
|
let isVisible = $state(false);
|
||||||
|
let elicitation: ElicitationEvent | null = $state(null);
|
||||||
|
let response = $state("");
|
||||||
|
let grantedToolsList: string[] = $state([]);
|
||||||
|
let workingDirectory = $state("");
|
||||||
|
|
||||||
|
hasElicitationPending.subscribe((pending) => {
|
||||||
|
isVisible = pending;
|
||||||
|
if (!pending) {
|
||||||
|
response = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.pendingElicitation.subscribe((e) => {
|
||||||
|
elicitation = e;
|
||||||
|
if (e) {
|
||||||
|
characterState.setState("permission");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
|
grantedToolsList = Array.from(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
||||||
|
workingDirectory = dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmitAndReconnect() {
|
||||||
|
if (!elicitation || !response.trim()) return;
|
||||||
|
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
const responseText = response.trim();
|
||||||
|
const elicitationMessage = elicitation.message;
|
||||||
|
const conversationHistory = claudeStore.getConversationHistory();
|
||||||
|
|
||||||
|
claudeStore.addLine("system", `MCP response submitted. Reconnecting with context...`);
|
||||||
|
claudeStore.clearElicitation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
|
await invoke("stop_claude", { conversationId });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const config = configStore.getConfig();
|
||||||
|
await invoke("start_claude", {
|
||||||
|
conversationId,
|
||||||
|
options: {
|
||||||
|
working_dir: workingDirectory || "/home/naomi",
|
||||||
|
model: config.model || null,
|
||||||
|
api_key: config.api_key || null,
|
||||||
|
custom_instructions: config.custom_instructions || null,
|
||||||
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
|
allowed_tools: grantedToolsList,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
include_git_instructions: config.include_git_instructions ?? true,
|
||||||
|
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||||
|
auto_memory_directory: config.auto_memory_directory || null,
|
||||||
|
model_overrides: config.model_overrides || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeConversation = get(conversationsStore.activeConversation);
|
||||||
|
if (activeConversation) {
|
||||||
|
await updateDiscordRpc(
|
||||||
|
activeConversation.name,
|
||||||
|
config.model || "claude",
|
||||||
|
activeConversation.startedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
if (conversationHistory) {
|
||||||
|
const contextMessage = `[CONTEXT RESTORATION]
|
||||||
|
I just responded to an MCP server elicitation request. Here's our conversation so far:
|
||||||
|
|
||||||
|
${conversationHistory}
|
||||||
|
|
||||||
|
The MCP server asked: "${elicitationMessage}"
|
||||||
|
My response: "${responseText}"
|
||||||
|
|
||||||
|
Please continue where we left off, taking my response 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.clearElicitation();
|
||||||
|
claudeStore.addLine("system", "MCP elicitation dismissed");
|
||||||
|
characterState.setTemporaryState("idle", 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!isVisible || !elicitation) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubmit(): boolean {
|
||||||
|
return response.trim().length > 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isVisible && elicitation}
|
||||||
|
<div
|
||||||
|
class="elicitation-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="elicitation-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)]">MCP Server Request</h2>
|
||||||
|
{#if elicitation.server_name}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">from: {elicitation.server_name}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">Input required from MCP server</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-[var(--text-primary)]">{elicitation.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<textarea
|
||||||
|
bind:value={response}
|
||||||
|
placeholder="Type your response 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="4"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Submit & Reconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -22,6 +22,7 @@ export const claudeStore = {
|
|||||||
terminalLines: conversationsStore.terminalLines,
|
terminalLines: conversationsStore.terminalLines,
|
||||||
pendingPermission: conversationsStore.pendingPermission,
|
pendingPermission: conversationsStore.pendingPermission,
|
||||||
pendingQuestion: conversationsStore.pendingQuestion,
|
pendingQuestion: conversationsStore.pendingQuestion,
|
||||||
|
pendingElicitation: conversationsStore.pendingElicitation,
|
||||||
isProcessing: conversationsStore.isProcessing,
|
isProcessing: conversationsStore.isProcessing,
|
||||||
grantedTools: conversationsStore.grantedTools,
|
grantedTools: conversationsStore.grantedTools,
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||||
@@ -57,6 +58,10 @@ export const claudeStore = {
|
|||||||
clearQuestion: conversationsStore.clearQuestion,
|
clearQuestion: conversationsStore.clearQuestion,
|
||||||
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
||||||
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
||||||
|
requestElicitation: conversationsStore.requestElicitation,
|
||||||
|
clearElicitation: conversationsStore.clearElicitation,
|
||||||
|
requestElicitationForConversation: conversationsStore.requestElicitationForConversation,
|
||||||
|
clearElicitationForConversation: conversationsStore.clearElicitationForConversation,
|
||||||
grantTool: conversationsStore.grantTool,
|
grantTool: conversationsStore.grantTool,
|
||||||
revokeAllTools: conversationsStore.revokeAllTools,
|
revokeAllTools: conversationsStore.revokeAllTools,
|
||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
@@ -126,6 +131,12 @@ export const hasQuestionPending = derived(
|
|||||||
($conversation) => $conversation?.pendingQuestion !== null
|
($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)
|
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||||
export const isClaudeProcessing = derived(
|
export const isClaudeProcessing = derived(
|
||||||
[claudeStore.connectionStatus, characterState],
|
[claudeStore.connectionStatus, characterState],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { writable, derived, get } from "svelte/store";
|
|||||||
import type {
|
import type {
|
||||||
TerminalLine,
|
TerminalLine,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
ElicitationEvent,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -32,6 +33,7 @@ export interface Conversation {
|
|||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermissions: PermissionRequest[];
|
pendingPermissions: PermissionRequest[];
|
||||||
pendingQuestion: UserQuestionEvent | null;
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
|
pendingElicitation: ElicitationEvent | null;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
@@ -157,6 +159,7 @@ function createConversationsStore() {
|
|||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
pendingQuestion: null,
|
pendingQuestion: null,
|
||||||
|
pendingElicitation: null,
|
||||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
@@ -221,6 +224,10 @@ function createConversationsStore() {
|
|||||||
($conv) => $conv?.pendingPermissions || []
|
($conv) => $conv?.pendingPermissions || []
|
||||||
);
|
);
|
||||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
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 scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||||
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||||
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
|
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
|
||||||
@@ -234,6 +241,7 @@ function createConversationsStore() {
|
|||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
||||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||||
|
pendingElicitation: { subscribe: pendingElicitation.subscribe },
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
@@ -399,6 +407,52 @@ function createConversationsStore() {
|
|||||||
return convs;
|
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),
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
|
|||||||
+31
-2
@@ -8,6 +8,7 @@ import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
|
|||||||
import { initAchievementsListener } from "$lib/stores/achievements";
|
import { initAchievementsListener } from "$lib/stores/achievements";
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
ElicitationEvent,
|
||||||
PermissionPromptEvent,
|
PermissionPromptEvent,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
@@ -406,7 +407,8 @@ export async function initializeTauriListeners() {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change",
|
| "config-change"
|
||||||
|
| "elicitation",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -425,7 +427,8 @@ export async function initializeTauriListeners() {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change",
|
| "config-change"
|
||||||
|
| "elicitation",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -611,6 +614,32 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(questionUnlisten);
|
unlisteners.push(questionUnlisten);
|
||||||
|
|
||||||
|
const elicitationUnlisten = await listen<ElicitationEvent>("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() {
|
export function cleanupTauriListeners() {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export interface TerminalLine {
|
|||||||
| "rate-limit"
|
| "rate-limit"
|
||||||
| "compact-prompt"
|
| "compact-prompt"
|
||||||
| "worktree"
|
| "worktree"
|
||||||
| "config-change";
|
| "config-change"
|
||||||
|
| "elicitation";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@@ -162,6 +163,19 @@ export interface UserQuestionEvent {
|
|||||||
conversation_id?: string;
|
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 type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||||
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||||
|
import ElicitationModal from "$lib/components/ElicitationModal.svelte";
|
||||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||||
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
|
||||||
import ToastContainer from "$lib/components/ToastContainer.svelte";
|
import ToastContainer from "$lib/components/ToastContainer.svelte";
|
||||||
@@ -593,6 +594,7 @@
|
|||||||
|
|
||||||
<PermissionModal />
|
<PermissionModal />
|
||||||
<UserQuestionModal />
|
<UserQuestionModal />
|
||||||
|
<ElicitationModal />
|
||||||
<ConfigSidebar />
|
<ConfigSidebar />
|
||||||
<AchievementsPanel
|
<AchievementsPanel
|
||||||
bind:isOpen={achievementPanelOpen}
|
bind:isOpen={achievementPanelOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user