diff --git a/src/main.rs b/src/main.rs index a7f3557..6f5f310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,9 +12,10 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use logging::{LogBuffer, LogBufferLayer}; -use mcp::McpClient; +use mcp::{McpClient, ResponseMessage}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use std::sync::Arc; use tracing::Level; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -78,8 +79,9 @@ async fn run_tui( .await .context("Failed to initialize MCP client")?; + let client = Arc::new(client); let mut app = App::new(debug_mode); - let res = run_tui_loop(&mut terminal, &mut app, &client, log_buffer).await; + let res = run_tui_loop(&mut terminal, &mut app, client.clone(), log_buffer).await; disable_raw_mode()?; execute!( @@ -97,29 +99,80 @@ async fn run_tui( async fn run_tui_loop( terminal: &mut Terminal>, app: &mut App, - client: &McpClient, + client: Arc, log_buffer: LogBuffer, ) -> Result<()> { - app.load_data(client).await?; + app.load_data(&client).await?; loop { // Update logs in the background - app.update_logs(client).await; + app.update_logs(&client).await; // Update debug logs from buffer app.update_debug_logs(log_buffer.get_all()); + // Poll for tool call result (tool call runs in a spawned task so we can receive elicitation mid-call) + if let Some(rx) = app.tool_call_pending_rx.as_mut() { + match rx.try_recv() { + Ok((tool_name, result)) => app.apply_pending_tool_result(tool_name, result), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + app.tool_call_pending_rx = None; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + } + } + + // Poll for server-originated requests (e.g. elicitation/create) + if let Some(ResponseMessage::Notification(req)) = client.try_recv_server_message().await { + if req.method == "elicitation/create" { + let id = req.id.clone().unwrap_or(serde_json::Value::Null); + if let Err(e) = app.start_elicitation(id, req.params, &client).await { + app.error_message = Some(format!("Elicitation error: {}", e)); + } + } + } + terminal.draw(|f| render_ui(f, app))?; if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { - if app.tool_call_input_mode { - // Handle tool call input mode + // Elicitation first: server may send it during tool/prompt form; user must respond + if app.elicitation_input_mode { + match key.code { + KeyCode::Esc => app.cancel_elicitation(&client).await, + KeyCode::Enter => app.execute_elicitation_accept(&client).await, + KeyCode::Tab => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + app.previous_input_field(); + } else { + app.next_input_field(); + } + } + KeyCode::BackTab => app.previous_input_field(), + KeyCode::Backspace => app.delete_current_input(), + KeyCode::Up => app.scroll_tool_input_up(), + KeyCode::Down => app.scroll_tool_input_down(), + KeyCode::Char(c) => { + // Ctrl+D = decline (so plain 'd'/'D' can be typed in fields) + if (c == 'd' || c == 'D') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + app.decline_elicitation(&client).await + } else { + app.update_current_input(c) + } + } + _ => {} + } + } else if app.tool_call_input_mode { + // Handle tool call input mode (or waiting for response) match key.code { KeyCode::Esc => app.cancel_tool_call(), KeyCode::Enter => { - app.execute_tool_call(client).await; + if app.tool_call_pending_rx.is_none() { + app.execute_tool_call(client.clone()); + } } KeyCode::Tab => { if key.modifiers.contains(KeyModifiers::SHIFT) { @@ -140,7 +193,7 @@ async fn run_tui_loop( match key.code { KeyCode::Esc => app.cancel_prompt_input(), KeyCode::Enter => { - app.execute_prompt_get(client).await; + app.execute_prompt_get(&client).await; } KeyCode::Tab => { if key.modifiers.contains(KeyModifiers::SHIFT) { @@ -163,7 +216,7 @@ async fn run_tui_loop( KeyCode::Char('c') | KeyCode::Char('C') => match app.current_tab { tui::Tab::Tools => app.start_tool_call(), tui::Tab::Prompts => app.start_prompt_get(), - tui::Tab::Resources => app.read_resource(client).await, + tui::Tab::Resources => app.read_resource(&client).await, _ => {} }, KeyCode::Down => app.next_item(), @@ -178,24 +231,24 @@ async fn run_tui_loop( KeyCode::Char('c') | KeyCode::Char('C') => match app.current_tab { tui::Tab::Tools => app.start_tool_call(), tui::Tab::Prompts => app.start_prompt_get(), - tui::Tab::Resources => app.read_resource(client).await, + tui::Tab::Resources => app.read_resource(&client).await, _ => {} }, KeyCode::Tab => { app.current_tab = app.current_tab.next(app.debug_mode); - app.load_data(client).await?; + app.load_data(&client).await?; } KeyCode::BackTab => { app.current_tab = app.current_tab.previous(app.debug_mode); - app.load_data(client).await?; + app.load_data(&client).await?; } KeyCode::Left => { app.current_tab = app.current_tab.previous(app.debug_mode); - app.load_data(client).await?; + app.load_data(&client).await?; } KeyCode::Right => { app.current_tab = app.current_tab.next(app.debug_mode); - app.load_data(client).await?; + app.load_data(&client).await?; } KeyCode::Down => app.next_item(), KeyCode::Up => app.previous_item(), @@ -203,7 +256,7 @@ async fn run_tui_loop( KeyCode::PageUp => app.page_up(), KeyCode::Enter => app.show_detail(), KeyCode::Char('r') | KeyCode::Char('R') => { - app.load_data(client).await?; + app.load_data(&client).await?; } KeyCode::Char('e') | KeyCode::Char('E') => { app.scroll_to_bottom(); diff --git a/src/mcp/client.rs b/src/mcp/client.rs index dcc77ef..6217022 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -14,19 +14,17 @@ pub struct McpClient { child: Arc>, stdin: Arc>, request_id: AtomicI64, - #[allow(dead_code)] + #[allow(dead_code)] // held to keep channel alive for read_loop response_tx: mpsc::UnboundedSender, - #[allow(dead_code)] response_rx: Arc>>, pending_requests: Arc>>>, server_info: Arc>>, log_rx: Arc>>, } -enum ResponseMessage { +pub enum ResponseMessage { #[allow(dead_code)] Response(JsonRpcResponse), - #[allow(dead_code)] Notification(JsonRpcRequest), } @@ -97,7 +95,12 @@ impl McpClient { debug!("Received: {}", trimmed); - if let Ok(response) = serde_json::from_str::(trimmed) { + // Try JsonRpcRequest first: server-originated requests (e.g. elicitation/create) + // have "method" and would incorrectly parse as JsonRpcResponse (extra fields + // ignored), then be mistaken for a response to our pending request. + if let Ok(request) = serde_json::from_str::(trimmed) { + let _ = response_tx.send(ResponseMessage::Notification(request)); + } else if let Ok(response) = serde_json::from_str::(trimmed) { if let Value::Number(id) = &response.id { if let Some(id) = id.as_i64() { let mut pending = pending_requests.lock().await; @@ -108,9 +111,6 @@ impl McpClient { } } let _ = response_tx.send(ResponseMessage::Response(response)); - } else if let Ok(notification) = serde_json::from_str::(trimmed) - { - let _ = response_tx.send(ResponseMessage::Notification(notification)); } else { warn!("Failed to parse message: {}", trimmed); } @@ -204,10 +204,11 @@ impl McpClient { pub async fn initialize(&self) -> Result { let params = InitializeParams { - protocol_version: "2024-11-05".to_string(), + protocol_version: "2025-11-25".to_string(), capabilities: ClientCapabilities { roots: None, sampling: None, + elicitation: Some(ElicitationCapability::default()), }, client_info: Implementation { name: env!("CARGO_PKG_NAME").to_string(), @@ -293,6 +294,25 @@ impl McpClient { logs } + /// Non-blocking receive of server-originated requests or notifications (e.g. elicitation/create). + pub async fn try_recv_server_message(&self) -> Option { + let mut rx = self.response_rx.lock().await; + rx.try_recv().ok() + } + + /// Send a JSON-RPC response to the server (e.g. in reply to elicitation/create). + pub async fn send_response(&self, response: JsonRpcResponse) -> Result<()> { + let json = serde_json::to_string(&response)?; + debug!("Sending response: {}", json); + + let mut stdin = self.stdin.lock().await; + stdin.write_all(json.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await?; + + Ok(()) + } + pub async fn shutdown(&self) -> Result<()> { let _ = self.child.lock().await.kill().await; Ok(()) @@ -387,6 +407,7 @@ mod tests { capabilities: ClientCapabilities { roots: None, sampling: None, + elicitation: None, }, client_info: Implementation { name: env!("CARGO_PKG_NAME").to_string(), diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 757f6eb..b36f05d 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,4 +1,4 @@ pub mod client; pub mod protocol; -pub use client::McpClient; +pub use client::{McpClient, ResponseMessage}; diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index cfb8e53..9f5bcfa 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -61,12 +61,33 @@ pub struct InitializeParams { pub client_info: Implementation, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ElicitationCapability {} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub roots: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sampling: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation: Option, +} + +/// Params for server-originated `elicitation/create` request (form mode). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElicitationCreateParams { + #[serde(default)] + pub mode: Option, + pub message: String, + #[serde(rename = "requestedSchema")] + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(rename = "elicitationId")] + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -358,6 +379,7 @@ mod tests { capabilities: ClientCapabilities { roots: Some(RootsCapability { list_changed: true }), sampling: None, + elicitation: None, }, client_info: Implementation { name: "test_client".to_string(), @@ -381,6 +403,7 @@ mod tests { capabilities: ClientCapabilities { roots: None, sampling: None, + elicitation: None, }, client_info: Implementation { name: "test".to_string(), @@ -641,6 +664,7 @@ mod tests { capabilities: ClientCapabilities { roots: None, sampling: None, + elicitation: None, }, client_info: Implementation { name: "test".to_string(), diff --git a/src/tui/app.rs b/src/tui/app.rs index 800ac10..ed98119 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -5,6 +5,8 @@ use anyhow::Result; use serde::Serialize; use serde_json::Value; use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::mpsc; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -87,6 +89,10 @@ pub struct App { pub tool_call_input_mode: bool, pub tool_call_inputs: HashMap, pub tool_call_result: Option, + /// When Some, a tool call is in flight; poll this to receive the result without blocking the TUI loop. + pub tool_call_pending_rx: Option)>>, + /// Name of the tool currently being called (shown in waiting panel). + pub pending_tool_name: Option, pub input_field_index: usize, pub input_fields: Vec, pub tool_input_scroll: usize, @@ -96,6 +102,16 @@ pub struct App { pub prompt_result: Option, // Resource read state pub resource_read_result: Option>, + // Elicitation state + pub elicitation_input_mode: bool, + pub pending_elicitation: Option, + pub elicitation_inputs: HashMap, +} + +#[derive(Debug, Clone)] +pub struct PendingElicitation { + pub id: Value, + pub message: String, } #[derive(Debug, Clone)] @@ -104,6 +120,8 @@ pub struct InputField { pub field_type: String, pub required: bool, pub description: Option, + /// Allowed values when the schema specifies "enum" (e.g. elicitation requestedSchema). + pub enum_values: Option>, } impl App { @@ -131,6 +149,8 @@ impl App { tool_call_input_mode: false, tool_call_inputs: HashMap::new(), tool_call_result: None, + tool_call_pending_rx: None, + pending_tool_name: None, input_field_index: 0, input_fields: Vec::new(), tool_input_scroll: 0, @@ -138,6 +158,9 @@ impl App { prompt_inputs: HashMap::new(), prompt_result: None, resource_read_result: None, + elicitation_input_mode: false, + pending_elicitation: None, + elicitation_inputs: HashMap::new(), } } @@ -460,7 +483,13 @@ impl App { } let field_name = &self.input_fields[self.input_field_index].name; - if self.tool_call_input_mode { + if self.elicitation_input_mode { + self.elicitation_inputs + .entry(field_name.clone()) + .or_default() + .push(c); + self.error_message = None; + } else if self.tool_call_input_mode { self.tool_call_inputs .entry(field_name.clone()) .or_default() @@ -479,7 +508,12 @@ impl App { } let field_name = &self.input_fields[self.input_field_index].name; - if self.tool_call_input_mode { + if self.elicitation_input_mode { + if let Some(value) = self.elicitation_inputs.get_mut(field_name) { + value.pop(); + } + self.error_message = None; + } else if self.tool_call_input_mode { if let Some(value) = self.tool_call_inputs.get_mut(field_name) { value.pop(); } @@ -490,7 +524,8 @@ impl App { } } - pub async fn execute_tool_call(&mut self, client: &McpClient) { + /// Starts the tool call in a background task; the TUI loop must poll `tool_call_pending_rx` and call `apply_pending_tool_result` when a result arrives. + pub fn execute_tool_call(&mut self, client: Arc) { if self.tools.is_empty() { return; } @@ -559,25 +594,41 @@ impl App { } } - // Call the tool + // Run the tool call in a spawned task so the TUI loop can keep polling for elicitation. let tool_name = tool.name.clone(); - match client - .call_tool( - &tool_name, - if arguments.is_empty() { - None - } else { - Some(arguments) - }, - ) - .await - { - Ok(result) => { - self.tool_call_result = Some(result.clone()); - self.tool_call_input_mode = false; + self.pending_tool_name = Some(tool_name.clone()); + let arguments_opt = if arguments.is_empty() { + None + } else { + Some(arguments) + }; + let (tx, rx) = mpsc::channel(1); + let client = Arc::clone(&client); + tokio::spawn(async move { + let result = client.call_tool(&tool_name, arguments_opt).await; + if let Err(payload) = tx.send((tool_name, result)).await { + let (name, res) = payload.0; + tracing::debug!( + "tool call '{}' completed but result was dropped (receiver closed)", + name + ); + if let Err(e) = res { + tracing::debug!(" tool had returned error: {}", e); + } + } + }); + self.tool_call_pending_rx = Some(rx); + } - // Show result in detail view - let detail = format_tool_result(&tool_name, &result); + /// Apply a tool call result received from the pending channel (called from the TUI loop). + pub fn apply_pending_tool_result(&mut self, tool_name: String, result: Result) { + self.tool_call_pending_rx = None; + self.pending_tool_name = None; + match result { + Ok(res) => { + self.tool_call_result = Some(res.clone()); + self.tool_call_input_mode = false; + let detail = format_tool_result(&tool_name, &res); self.detail_view = Some(detail); } Err(e) => { @@ -587,6 +638,8 @@ impl App { } pub fn cancel_tool_call(&mut self) { + self.tool_call_pending_rx = None; // drop receiver; in-flight call result will be ignored + self.pending_tool_name = None; self.tool_call_input_mode = false; self.tool_call_inputs.clear(); self.input_fields.clear(); @@ -619,6 +672,7 @@ impl App { field_type: "string".to_string(), required: arg.required.unwrap_or(false), description: arg.description.clone(), + enum_values: None, }) .collect() } else { @@ -700,6 +754,190 @@ impl App { self.tool_input_scroll = 0; } + /// Start or replace an elicitation form from a server elicitation/create request. + pub async fn start_elicitation( + &mut self, + id: Value, + params: Option, + client: &McpClient, + ) -> Result<()> { + // If already in elicitation mode, send cancel to the previous request + if self.elicitation_input_mode { + if let Some(pending) = self.pending_elicitation.take() { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: pending.id, + result: Some(serde_json::json!({ "action": "cancel" })), + error: None, + }; + let _ = client.send_response(response).await; + } + self.elicitation_input_mode = false; + self.elicitation_inputs.clear(); + self.input_fields.clear(); + } + + let params_value = params.ok_or_else(|| anyhow::anyhow!("Elicitation params missing"))?; + let params: ElicitationCreateParams = serde_json::from_value(params_value) + .map_err(|e| anyhow::anyhow!("Invalid elicitation params: {}", e))?; + + let mode = params.mode.as_deref().unwrap_or("form"); + if mode == "url" { + self.error_message = Some("URL mode elicitation is not supported".to_string()); + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(serde_json::json!({ "action": "cancel" })), + error: None, + }; + client.send_response(response).await?; + return Ok(()); + } + + self.input_fields = params + .requested_schema + .as_ref() + .map(parse_input_schema) + .unwrap_or_default(); + self.elicitation_inputs.clear(); + self.pending_elicitation = Some(PendingElicitation { + id: id.clone(), + message: params.message, + }); + self.input_field_index = 0; + self.tool_input_scroll = 0; + self.elicitation_input_mode = true; + Ok(()) + } + + pub async fn execute_elicitation_accept(&mut self, client: &McpClient) { + let pending = match self.pending_elicitation.as_ref() { + Some(p) => p.clone(), + None => return, + }; + + for field in &self.input_fields { + if field.required { + let value = self + .elicitation_inputs + .get(&field.name) + .map(|s| s.trim()) + .unwrap_or(""); + if value.is_empty() { + self.error_message = Some(format!("Required field '{}' is empty", field.name)); + return; + } + } + } + + let mut content = serde_json::Map::new(); + for field in &self.input_fields { + if let Some(value_str) = self.elicitation_inputs.get(&field.name) { + let value_str = value_str.trim(); + if !value_str.is_empty() { + if let Some(allowed) = &field.enum_values { + if !allowed.iter().any(|v| v == value_str) { + self.error_message = Some(format!( + "Invalid value for '{}': \"{}\" is not allowed. Choose one of: {}.", + field.name, + value_str, + allowed.join(", ") + )); + return; + } + } + let json_value = match field.field_type.as_str() { + "number" | "integer" => { + if let Ok(num) = value_str.parse::() { + Value::Number(num.into()) + } else if let Ok(num) = value_str.parse::() { + Value::Number( + serde_json::Number::from_f64(num).unwrap_or_else(|| 0.into()), + ) + } else { + self.error_message = + Some(format!("'{}' must be a number", field.name)); + return; + } + } + "boolean" => match value_str.to_lowercase().as_str() { + "true" | "yes" | "1" => Value::Bool(true), + "false" | "no" | "0" => Value::Bool(false), + _ => { + self.error_message = + Some(format!("'{}' must be true or false", field.name)); + return; + } + }, + "array" | "object" => match serde_json::from_str(value_str) { + Ok(v) => v, + Err(_) => { + self.error_message = + Some(format!("'{}' must be valid JSON", field.name)); + return; + } + }, + _ => Value::String(value_str.to_string()), + }; + content.insert(field.name.clone(), json_value); + } + } + } + + let result_value = serde_json::json!({ + "action": "accept", + "content": content + }); + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: pending.id, + result: Some(result_value), + error: None, + }; + + match client.send_response(response).await { + Ok(()) => { + self.elicitation_input_mode = false; + self.pending_elicitation = None; + self.elicitation_inputs.clear(); + self.input_fields.clear(); + } + Err(e) => { + self.error_message = Some(format!("Failed to send elicitation response: {}", e)); + } + } + } + + pub async fn cancel_elicitation(&mut self, client: &McpClient) { + if let Some(pending) = self.pending_elicitation.take() { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: pending.id, + result: Some(serde_json::json!({ "action": "cancel" })), + error: None, + }; + let _ = client.send_response(response).await; + } + self.elicitation_input_mode = false; + self.elicitation_inputs.clear(); + self.input_fields.clear(); + } + + pub async fn decline_elicitation(&mut self, client: &McpClient) { + if let Some(pending) = self.pending_elicitation.take() { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: pending.id, + result: Some(serde_json::json!({ "action": "decline" })), + error: None, + }; + let _ = client.send_response(response).await; + } + self.elicitation_input_mode = false; + self.elicitation_inputs.clear(); + self.input_fields.clear(); + } + pub async fn read_resource(&mut self, client: &McpClient) { if self.resources.is_empty() { return; @@ -792,11 +1030,23 @@ fn parse_input_schema(schema: &Value) -> Vec { let required = required_fields.contains(name); + let enum_values = prop.get("enum").and_then(|e| e.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| { + v.as_str() + .map(String::from) + .or_else(|| v.as_i64().map(|n| n.to_string())) + .or_else(|| v.as_f64().map(|n| n.to_string())) + }) + .collect::>() + }); + fields.push(InputField { name: name.clone(), field_type, required, description, + enum_values, }); } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index aa3aea4..c4b8017 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6,6 +6,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}, Frame, }; +use std::time::{SystemTime, UNIX_EPOCH}; pub fn render_ui(f: &mut Frame, app: &App) { let chunks = Layout::default() @@ -27,8 +28,12 @@ pub fn render_ui(f: &mut Frame, app: &App) { render_help(f, app, chunks[2]); - // Render tool call input form as overlay - if app.tool_call_input_mode { + // Render tool call waiting panel (spinner) when a call is in flight + if app.tool_call_pending_rx.is_some() { + render_tool_call_waiting(f, app); + } + // Render tool call input form as overlay when not waiting + else if app.tool_call_input_mode { render_tool_input_form(f, app); } @@ -36,6 +41,11 @@ pub fn render_ui(f: &mut Frame, app: &App) { if app.prompt_input_mode { render_prompt_input_form(f, app); } + + // Render elicitation form as overlay + if app.elicitation_input_mode { + render_elicitation_form(f, app); + } } fn render_tabs(f: &mut Frame, app: &App, area: Rect) { @@ -86,13 +96,16 @@ fn render_content(f: &mut Frame, app: &App, area: Rect) { return; } + // When in elicitation form, show errors inside the form overlay instead of replacing content if let Some(error) = &app.error_message { - let error_widget = Paragraph::new(error.as_str()) - .block(Block::default().borders(Borders::ALL).title("Error")) - .style(Style::default().fg(Color::Red)) - .wrap(Wrap { trim: true }); - f.render_widget(error_widget, area); - return; + if !app.elicitation_input_mode { + let error_widget = Paragraph::new(error.as_str()) + .block(Block::default().borders(Borders::ALL).title("Error")) + .style(Style::default().fg(Color::Red)) + .wrap(Wrap { trim: true }); + f.render_widget(error_widget, area); + return; + } } match app.current_tab { @@ -433,30 +446,41 @@ fn render_debug_logs(f: &mut Frame, app: &App, area: Rect) { } fn render_help(f: &mut Frame, app: &App, area: Rect) { - let help_text = match (app.tool_call_input_mode, app.prompt_input_mode, &app.detail_view, app.current_tab) { - (true, _, _, _) => + let help_text = match ( + app.tool_call_input_mode, + app.tool_call_pending_rx.is_some(), + app.prompt_input_mode, + app.elicitation_input_mode, + &app.detail_view, + app.current_tab, + ) { + (_, _, _, true, _, _) => + "TAB/Shift+TAB: Navigate Fields | ↑/↓: Scroll | Type: Enter Value | ENTER: Submit | ESC: Cancel | Ctrl+D: Decline", + (true, true, _, _, _, _) => + "Waiting for tool response… ESC: Cancel", + (true, _, _, _, _, _) => "TAB/Shift+TAB: Navigate Fields | ↑/↓: Scroll | Type: Enter Value | ENTER: Execute | ESC: Cancel", - (_, true, _, _) => + (_, _, true, _, _, _) => "TAB/Shift+TAB: Navigate Fields | ↑/↓: Scroll | Type: Enter Value | ENTER: Get Prompt | ESC: Cancel", - (_, _, Some(_), Tab::Tools) => + (_, _, _, _, Some(_), Tab::Tools) => "↑/↓: Scroll | C: Call Tool | ESC: Close | Q: Quit", - (_, _, Some(_), Tab::Prompts) => + (_, _, _, _, Some(_), Tab::Prompts) => "↑/↓: Scroll | C: Get Prompt | ESC: Close | Q: Quit", - (_, _, Some(_), Tab::Resources) => + (_, _, _, _, Some(_), Tab::Resources) => "↑/↓: Scroll | C: Read Resource | ESC: Close | Q: Quit", - (_, _, Some(_), _) => + (_, _, _, _, Some(_), _) => "↑/↓: Scroll | ESC: Close | Q: Quit", - (_, _, None, Tab::ServerLogs) => + (_, _, _, _, None, Tab::ServerLogs) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Scroll | E: Jump to End | S: Save Logs | R: Refresh | Q: Quit", - (_, _, None, Tab::DebugLogs) => + (_, _, _, _, None, Tab::DebugLogs) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Scroll | E: Jump to End | S: Save Logs | R: Refresh | Q: Quit", - (_, _, None, Tab::ServerInfo) => + (_, _, _, _, None, Tab::ServerInfo) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Scroll | ENTER: Details | R: Refresh | Q: Quit", - (_, _, None, Tab::Tools) => + (_, _, _, _, None, Tab::Tools) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Navigate | ENTER: Details | C: Call Tool | R: Refresh | Q: Quit", - (_, _, None, Tab::Prompts) => + (_, _, _, _, None, Tab::Prompts) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Navigate | ENTER: Details | C: Get Prompt | R: Refresh | Q: Quit", - (_, _, None, Tab::Resources) => + (_, _, _, _, None, Tab::Resources) => "TAB: Next Tab | ←/→: Switch Tabs | ↑/↓: Navigate | ENTER: Details | C: Read Resource | R: Refresh | Q: Quit", }; @@ -468,6 +492,59 @@ fn render_help(f: &mut Frame, app: &App, area: Rect) { f.render_widget(help, area); } +/// Spinner frames (one per 100ms gives smooth rotation at ~10 fps). +const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +fn render_tool_call_waiting(f: &mut Frame, app: &App) { + let area = f.area(); + let popup_width = 44; + let popup_height = 5; + let popup_area = Rect { + x: area.width.saturating_sub(popup_width) / 2, + y: area.height.saturating_sub(popup_height) / 2, + width: popup_width, + height: popup_height, + }; + + f.render_widget(Clear, popup_area); + + let tool_name = app.pending_tool_name.as_deref().unwrap_or("tool"); + let frame_idx = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| (d.as_millis() / 100) as usize) + .unwrap_or(0); + let spinner = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.len()]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Tool call in progress ") + .style(Style::default().bg(Color::Black)); + f.render_widget(block, popup_area); + + let inner = Rect { + x: popup_area.x + 2, + y: popup_area.y + 2, + width: popup_area.width.saturating_sub(4), + height: popup_area.height.saturating_sub(4), + }; + let text = vec![Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::raw("Calling "), + Span::styled( + tool_name, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw("…"), + ])]; + let paragraph = Paragraph::new(text) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, inner); +} + fn render_tool_input_form(f: &mut Frame, app: &App) { // Calculate centered popup area let area = f.area(); @@ -553,6 +630,16 @@ fn render_tool_input_form(f: &mut Frame, app: &App) { ))); } + if let Some(opts) = &field.enum_values { + if !opts.is_empty() { + let hint = format!(" Allowed: {}", opts.join(", ")); + lines.push(Line::from(Span::styled( + hint, + Style::default().fg(Color::DarkGray), + ))); + } + } + let value_style = if is_current { Style::default() .fg(Color::Green) @@ -715,3 +802,168 @@ fn render_prompt_input_form(f: &mut Frame, app: &App) { f.render_widget(paragraph, inner); } } + +fn render_elicitation_form(f: &mut Frame, app: &App) { + let area = f.area(); + let message_lines = app + .pending_elicitation + .as_ref() + .map(|p| p.message.lines().count()) + .unwrap_or(0) + .max(1); + let error_lines = app + .error_message + .as_ref() + .map(|e| e.lines().count()) + .unwrap_or(0); + let popup_width = area.width.saturating_sub(10).min(80); + let popup_height = (message_lines as u16 + + (error_lines as u16).saturating_add(2) + + (app.input_fields.len() as u16 * 3) + + 8) + .min(area.height.saturating_sub(4)); + + let popup_area = Rect { + x: (area.width.saturating_sub(popup_width)) / 2, + y: (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + f.render_widget(Clear, popup_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title("Elicitation Request (from server)") + .style(Style::default().bg(Color::Black)); + f.render_widget(block, popup_area); + + let inner = Rect { + x: popup_area.x + 2, + y: popup_area.y + 2, + width: popup_area.width.saturating_sub(4), + height: popup_area.height.saturating_sub(4), + }; + + let mut lines = Vec::new(); + + if let Some(error) = &app.error_message { + lines.push(Line::from(Span::styled( + "Error:", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + for line in error.lines() { + lines.push(Line::from(Span::styled( + line, + Style::default().fg(Color::Red), + ))); + } + lines.push(Line::from("")); + } + + if let Some(pending) = &app.pending_elicitation { + lines.push(Line::from(Span::styled( + &pending.message, + Style::default().fg(Color::White), + ))); + lines.push(Line::from("")); + } + + if app.input_fields.is_empty() { + lines.push(Line::from("No fields requested.")); + lines.push(Line::from("")); + lines.push(Line::from( + "Press ENTER to submit, Ctrl+D to decline, or ESC to cancel.", + )); + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, inner); + } else { + for (i, field) in app.input_fields.iter().enumerate() { + let is_current = i == app.input_field_index; + let value = app + .elicitation_inputs + .get(&field.name) + .map(|s| s.as_str()) + .unwrap_or(""); + + let field_label = format!( + "{} ({}{})", + field.name, + field.field_type, + if field.required { ", required" } else { "" } + ); + + let label_style = if is_current { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::from(Span::styled(field_label, label_style))); + + if let Some(desc) = &field.description { + lines.push(Line::from(Span::styled( + format!(" {}", desc), + Style::default().fg(Color::Gray), + ))); + } + + if let Some(opts) = &field.enum_values { + if !opts.is_empty() { + let hint = format!(" Allowed: {}", opts.join(", ")); + lines.push(Line::from(Span::styled( + hint, + Style::default().fg(Color::DarkGray), + ))); + } + } + + let value_style = if is_current { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + + let display_value = if is_current && value.is_empty() { + "_" + } else if value.is_empty() { + "(empty)" + } else { + value + }; + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(display_value, value_style), + if is_current { + Span::styled("█", Style::default().fg(Color::Green)) + } else { + Span::raw("") + }, + ])); + + if i < app.input_fields.len() - 1 { + lines.push(Line::from("")); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "ENTER = submit · Ctrl+D = decline · ESC = cancel", + Style::default().fg(Color::DarkGray), + ))); + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((app.tool_input_scroll as u16, 0)); + + f.render_widget(paragraph, inner); + } +}