Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/src/ai/blocklist/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2076,7 +2076,13 @@ impl AIBlock {
} else {
format!("MCP Tool: {name} ({display_input})")
};
self.handle_mcp_tool_stream_update(action_id, &command_text, ctx);
self.handle_mcp_tool_stream_update(
action_id,
&command_text,
name.to_string(),
display_input,
ctx,
);
}
AIAgentAction {
id: action_id,
Expand Down Expand Up @@ -3521,12 +3527,15 @@ impl AIBlock {
&mut self,
action_id: &AIAgentActionId,
command_text: &str,
mcp_name: String,
mcp_args: serde_json::Value,
ctx: &mut ViewContext<Self>,
) {
match self.requested_mcp_tools.get_mut(action_id) {
Some(requested_mcp_tool) => {
requested_mcp_tool.view.update(ctx, |view, ctx| {
view.apply_streamed_update(command_text, ctx);
view.update_mcp_request(mcp_name, mcp_args);
ctx.notify();
});
}
Expand All @@ -3547,6 +3556,7 @@ impl AIBlock {
ctx,
);
view.apply_streamed_update(command_text, ctx);
view.update_mcp_request(mcp_name, mcp_args);
view
});
let action_id_clone = action_id.clone();
Expand Down
106 changes: 106 additions & 0 deletions app/src/ai/blocklist/inline_action/requested_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::model::block::Block;
use crate::terminal::TerminalModel;
use crate::ui_components::blended_colors;
use crate::ui_components::json_tree::{JsonTreeState, PathSegment};
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme};
use crate::view_components::compactible_action_button::{
Expand Down Expand Up @@ -159,6 +160,65 @@ pub fn init(app: &mut AppContext) {
)]);
}

/// Structured representation of an MCP tool call request, held so the
/// detail view can render it as a JSON tree rather than a flat string.
pub struct McpRequest {
pub name: String,
pub args: serde_json::Value,
}

/// The normalized, renderable form of a `CallMCPToolResult`.
///
/// Converts the raw result (which may carry text content, structured JSON, an
/// error message, or a cancellation signal) into a form the tree-rendering
/// layer can act on without further conditionals.
pub(crate) enum McpRenderable {
Tree(serde_json::Value),
Error(String),
Cancelled,
}

/// Normalizes a `CallMCPToolResult` into a `McpRenderable` for display.
///
/// Prefers `structured_content` when present; otherwise tries to parse joined
/// text content as JSON; falls back to wrapping the raw text as a JSON
/// string value so the tree renderer always receives a `serde_json::Value`.
pub(crate) fn mcp_result_to_renderable(result: &CallMCPToolResult) -> McpRenderable {
match result {
CallMCPToolResult::Success { result } => {
if let Some(v) = &result.structured_content {
return McpRenderable::Tree(v.clone());
}
let text = result
.content
.iter()
.filter_map(|c| {
if let rmcp::model::RawContent::Text(t) = &c.raw {
Some(t.text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
McpRenderable::Tree(v)
} else {
McpRenderable::Tree(serde_json::Value::String(text))
}
}
CallMCPToolResult::Error(e) => McpRenderable::Error(e.clone()),
CallMCPToolResult::Cancelled => McpRenderable::Cancelled,
}
}

/// Identifies which of the two JSON trees (request or response) an action targets.
#[derive(Debug, Clone)]
pub enum McpTree {
Request,
Response,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestedActionViewType {
Command,
Expand Down Expand Up @@ -199,6 +259,18 @@ pub enum RequestedCommandViewAction {
ToggleExpanded,
OpenActiveAgentProfileEditor,
SelectText,
/// Toggle the expanded/collapsed state of an object or array node in the
/// MCP request or response JSON tree.
ToggleJsonNode {
path: Vec<PathSegment>,
tree: McpTree,
},
/// Toggle the expanded/collapsed state of a long string value in the MCP
/// request or response JSON tree.
ToggleJsonString {
path: Vec<PathSegment>,
tree: McpTree,
},
}

pub struct RequestedCommandView {
Expand Down Expand Up @@ -244,6 +316,14 @@ pub struct RequestedCommandView {
// Selection support for MCP tool call detail text
mcp_content_selection_handle: SelectionHandle,
mcp_content_selected_text: Arc<std::sync::RwLock<Option<String>>>,

// Structured request data and per-tree expansion state for JSON tree rendering.
// `mcp_request` is populated from the stream as soon as the tool name
// and arguments are known. Separate states ensure request-tree paths start
// at depth 0 and are not confused with response-tree paths.
mcp_request: Option<McpRequest>,
mcp_request_tree_state: JsonTreeState,
mcp_response_tree_state: JsonTreeState,
}

impl RequestedCommandView {
Expand Down Expand Up @@ -478,6 +558,9 @@ impl RequestedCommandView {
ai_block_view_id,
mcp_content_selection_handle: SelectionHandle::default(),
mcp_content_selected_text: Arc::new(std::sync::RwLock::new(None)),
mcp_request: None,
mcp_request_tree_state: Default::default(),
mcp_response_tree_state: Default::default(),
}
}

Expand Down Expand Up @@ -972,6 +1055,14 @@ impl RequestedCommandView {
}
}

/// Stores the structured MCP tool request data for JSON tree rendering.
///
/// Called each time a stream update arrives so the view always reflects
/// the latest known arguments.
pub(crate) fn update_mcp_request(&mut self, name: String, args: serde_json::Value) {
self.mcp_request = Some(McpRequest { name, args });
}

/// Extracts the tool name from MCP tool command text, removing parameters.
/// For example, "tool_name(param1, param2)" becomes "tool_name".
fn extract_mcp_tool_name(&self, command_text: &str) -> String {
Expand Down Expand Up @@ -1616,6 +1707,21 @@ impl TypedActionView for RequestedCommandView {
RequestedCommandViewAction::SelectText => {
ctx.emit(RequestedCommandViewEvent::TextSelected);
}
RequestedCommandViewAction::ToggleJsonNode { path, tree } => {
let depth = path.len();
match tree {
McpTree::Request => self.mcp_request_tree_state.toggle(path, depth),
McpTree::Response => self.mcp_response_tree_state.toggle(path, depth),
}
ctx.notify();
}
RequestedCommandViewAction::ToggleJsonString { path, tree } => {
match tree {
McpTree::Request => self.mcp_request_tree_state.toggle_string(path),
McpTree::Response => self.mcp_response_tree_state.toggle_string(path),
}
ctx.notify();
}
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions app/src/ui_components/json_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ impl JsonTreeState {
/// If no explicit state exists, the new state is the inverse of the
/// default (derived from depth). Callers must pass `depth` so we know
/// what the default would have been.
pub fn toggle(&mut self, path: Vec<PathSegment>, depth: usize) {
let current = self.is_expanded(&path, depth);
self.node_expansion.insert(path, !current);
pub fn toggle(&mut self, path: &[PathSegment], depth: usize) {
let current = self.is_expanded(path, depth);
self.node_expansion.insert(path.to_vec(), !current);
}

/// Toggles the expansion state of the long string at `path`.
pub fn toggle_string(&mut self, path: Vec<PathSegment>) {
let current = self.is_string_expanded(&path);
self.string_expansion.insert(path, !current);
pub fn toggle_string(&mut self, path: &[PathSegment]) {
let current = self.is_string_expanded(path);
self.string_expansion.insert(path.to_vec(), !current);
}
}

Expand Down
140 changes: 135 additions & 5 deletions app/src/ui_components/json_tree_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Pure-logic unit tests for the `json_tree` component (Phase 1, APP-2527).
//! Pure-logic unit tests for the `json_tree` component.
//!
//! These tests cover only the data-layer functions and types: annotation
//! formatting, long-string detection, state management, and value rendering.
Expand Down Expand Up @@ -98,7 +98,7 @@ mod tests {
assert!(state.is_expanded(&path_b, 0));

// Toggle path A.
state.toggle(path_a.clone(), 0);
state.toggle(&path_a, 0);

// A is now collapsed.
assert!(!state.is_expanded(&path_a, 0));
Expand All @@ -115,11 +115,11 @@ mod tests {
assert!(!state.is_expanded(&path, 1));

// First toggle: collapsed → expanded.
state.toggle(path.clone(), 1);
state.toggle(&path, 1);
assert!(state.is_expanded(&path, 1));

// Second toggle: expanded → collapsed again.
state.toggle(path.clone(), 1);
state.toggle(&path, 1);
assert!(!state.is_expanded(&path, 1));
}

Expand All @@ -137,12 +137,54 @@ mod tests {
assert!(!state.is_expanded(&child, 1));

// Toggle parent only.
state.toggle(parent.clone(), 1);
state.toggle(&parent, 1);

assert!(state.is_expanded(&parent, 1));
assert!(!state.is_expanded(&child, 1));
}

// -----------------------------------------------------------------------
// JsonTreeState — long-string expansion (toggle_string / is_string_expanded)
// -----------------------------------------------------------------------

#[test]
fn string_collapsed_by_default() {
let state = JsonTreeState::default();
let path = vec![PathSegment::Key("summary".to_string())];
assert!(!state.is_string_expanded(&path));
}

#[test]
fn toggle_string_expands_then_collapses() {
let path = vec![PathSegment::Key("body".to_string())];
let mut state = JsonTreeState::default();

// Default: collapsed.
assert!(!state.is_string_expanded(&path));

// First toggle: collapsed → expanded.
state.toggle_string(&path);
assert!(state.is_string_expanded(&path));

// Second toggle: expanded → collapsed.
state.toggle_string(&path);
assert!(!state.is_string_expanded(&path));
}

#[test]
fn toggle_string_is_independent_of_node_expansion() {
let path = vec![PathSegment::Key("note".to_string())];
let mut state = JsonTreeState::default();

// Toggling a string does not affect node expansion state for the same path.
state.toggle_string(&path);
assert!(state.is_string_expanded(&path));
// Node expansion at depth 0 is still the default (expanded).
assert!(state.is_expanded(&path, 0));
// Node expansion at depth 1 is still the default (collapsed).
assert!(!state.is_expanded(&path, 1));
}

// -----------------------------------------------------------------------
// JsonTreeState — default expansion
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -260,6 +302,94 @@ mod tests {
}
}

// -----------------------------------------------------------------------
// mcp_result_to_renderable
// -----------------------------------------------------------------------

#[test]
fn mcp_result_success_with_structured_content_returns_tree() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};

let value = serde_json::json!({"count": 42, "files": ["a.rs", "b.rs"]});
let result = rmcp::model::CallToolResult::structured(value.clone());
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });

match renderable {
McpRenderable::Tree(v) => assert_eq!(v, value),
_ => panic!("expected Tree variant"),
}
}

#[test]
fn mcp_result_success_with_json_text_content_returns_parsed_tree() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};

let json_str = r#"{"status": "ok", "value": 7}"#;
let content = vec![rmcp::model::Content::text(json_str)];
let result = rmcp::model::CallToolResult::success(content);
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });

let expected: serde_json::Value = serde_json::from_str(json_str).unwrap();
match renderable {
McpRenderable::Tree(v) => assert_eq!(v, expected),
_ => panic!("expected Tree variant with parsed JSON"),
}
}

#[test]
fn mcp_result_success_with_non_json_text_returns_string_tree() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};

let plain_text = "just some plain text output";
let content = vec![rmcp::model::Content::text(plain_text)];
let result = rmcp::model::CallToolResult::success(content);
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Success { result });

match renderable {
McpRenderable::Tree(serde_json::Value::String(s)) => {
assert_eq!(s, plain_text);
}
_ => panic!("expected Tree(String) variant"),
}
}

#[test]
fn mcp_result_error_returns_error_variant() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};

let msg = "tool not found".to_string();
let renderable = mcp_result_to_renderable(&CallMCPToolResult::Error(msg.clone()));

match renderable {
McpRenderable::Error(e) => assert_eq!(e, msg),
_ => panic!("expected Error variant"),
}
}

#[test]
fn mcp_result_cancelled_returns_cancelled_variant() {
use crate::ai::agent::CallMCPToolResult;
use crate::ai::blocklist::inline_action::requested_command::{
mcp_result_to_renderable, McpRenderable,
};

let renderable = mcp_result_to_renderable(&CallMCPToolResult::Cancelled);

assert!(matches!(renderable, McpRenderable::Cancelled));
}

// -----------------------------------------------------------------------
// Path segment equality (required for HashMap key correctness)
// -----------------------------------------------------------------------
Expand Down