notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e7763d873568f9d689b3a23b4806d2d0035dd5c7
parent d5ae0ae2c0b51c9c1063a38ca5f1e9fbaa049223
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 26 Jan 2026 00:32:44 -0800

dave: add tool result metadata display from message stream

Extract tool results from session-scoped UserMessage.extra instead of
global hooks which caused cross-session pollution. Tool uses are tracked
from ContentBlock::ToolUse and correlated with results via tool_use_id.

Adds ToolResult type for UI display with human-readable summaries
(e.g., "resolv.conf (3 lines)" for Read tool).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/notedeck_dave/src/lib.rs | 9++++++++-
Mcrates/notedeck_dave/src/messages.rs | 14++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 30+++++++++++++++++++++++++++++-
Acrates/notedeck_dave/tests/tool_result_integration.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 380 insertions(+), 16 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -1,10 +1,12 @@ use crate::backend::traits::AiBackend; -use crate::messages::{DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse}; +use crate::messages::{ + DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse, ToolResult, +}; use crate::tools::Tool; use crate::Message; use claude_agent_sdk_rs::{ ClaudeAgentOptions, ClaudeClient, ContentBlock, Message as ClaudeMessage, PermissionMode, - PermissionResult, PermissionResultAllow, PermissionResultDeny, TextBlock, + PermissionResult, PermissionResultAllow, PermissionResultDeny, TextBlock, ToolUseBlock, }; use futures::future::BoxFuture; use futures::StreamExt; @@ -53,8 +55,9 @@ impl ClaudeBackend { Message::ToolCalls(_) | Message::ToolResponse(_) | Message::Error(_) - | Message::PermissionRequest(_) => { - // Skip tool-related, error, and permission messages + | Message::PermissionRequest(_) + | Message::ToolResult(_) => { + // Skip tool-related, error, permission, and tool result messages } } } @@ -201,16 +204,17 @@ impl AiBackend for ClaudeBackend { // for can_use_tool callbacks // For follow-up messages, use --continue to resume the last conversation let stderr_callback = Arc::new(stderr_callback); + let options = if is_first_message { ClaudeAgentOptions::builder() .permission_mode(PermissionMode::Default) - .stderr_callback(stderr_callback) + .stderr_callback(stderr_callback.clone()) .can_use_tool(can_use_tool) .build() } else { ClaudeAgentOptions::builder() .permission_mode(PermissionMode::Default) - .stderr_callback(stderr_callback) + .stderr_callback(stderr_callback.clone()) .can_use_tool(can_use_tool) .continue_conversation(true) .build() @@ -230,20 +234,32 @@ impl AiBackend for ClaudeBackend { } let mut stream = client.receive_response(); + // Track pending tool uses: tool_use_id -> (tool_name, tool_input) + let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new(); + while let Some(result) = stream.next().await { match result { Ok(message) => match message { ClaudeMessage::Assistant(assistant_msg) => { for block in &assistant_msg.message.content { - if let ContentBlock::Text(TextBlock { text }) = block { - if let Err(err) = tx.send(DaveApiResponse::Token(text.clone())) - { - tracing::error!("Failed to send token to UI: {}", err); - drop(stream); - let _ = client.disconnect().await; - return; + match block { + ContentBlock::Text(TextBlock { text }) => { + if let Err(err) = + tx.send(DaveApiResponse::Token(text.clone())) + { + tracing::error!("Failed to send token to UI: {}", err); + drop(stream); + let _ = client.disconnect().await; + return; + } + ctx.request_repaint(); } - ctx.request_repaint(); + ContentBlock::ToolUse(ToolUseBlock { id, name, input }) => { + // Store for later correlation with tool result + pending_tools + .insert(id.clone(), (name.clone(), input.clone())); + } + _ => {} } } } @@ -256,6 +272,37 @@ impl AiBackend for ClaudeBackend { } break; } + ClaudeMessage::User(user_msg) => { + // Tool results come in user_msg.extra, not content + // Structure: extra["tool_use_result"] has the result, + // extra["message"]["content"][0]["tool_use_id"] has the correlation ID + if let Some(tool_use_result) = user_msg.extra.get("tool_use_result") { + // Get tool_use_id from message.content[0].tool_use_id + let tool_use_id = user_msg + .extra + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + .and_then(|arr| arr.first()) + .and_then(|item| item.get("tool_use_id")) + .and_then(|id| id.as_str()); + + if let Some(tool_use_id) = tool_use_id { + if let Some((tool_name, tool_input)) = + pending_tools.remove(tool_use_id) + { + let summary = format_tool_summary( + &tool_name, + &tool_input, + tool_use_result, + ); + let tool_result = ToolResult { tool_name, summary }; + let _ = tx.send(DaveApiResponse::ToolResult(tool_result)); + ctx.request_repaint(); + } + } + } + } _ => {} }, Err(err) => { @@ -276,3 +323,115 @@ impl AiBackend for ClaudeBackend { (rx, Some(handle)) } } + +/// Extract string content from a tool response, handling various JSON structures +fn extract_response_content(response: &serde_json::Value) -> Option<String> { + // Try direct string first + if let Some(s) = response.as_str() { + return Some(s.to_string()); + } + // Try "content" field (common wrapper) + if let Some(s) = response.get("content").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Try file.content for Read tool responses + if let Some(s) = response + .get("file") + .and_then(|f| f.get("content")) + .and_then(|v| v.as_str()) + { + return Some(s.to_string()); + } + // Try "output" field + if let Some(s) = response.get("output").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Try "result" field + if let Some(s) = response.get("result").and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + // Fallback: serialize the whole response if it's not null + if !response.is_null() { + return Some(response.to_string()); + } + None +} + +/// Format a human-readable summary for tool execution results +fn format_tool_summary( + tool_name: &str, + input: &serde_json::Value, + response: &serde_json::Value, +) -> String { + match tool_name { + "Read" => { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + // Try to get numLines directly from file metadata (most accurate) + let lines = response + .get("file") + .and_then(|f| f.get("numLines").or_else(|| f.get("totalLines"))) + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + // Fallback to counting lines in content + .or_else(|| { + extract_response_content(response) + .as_ref() + .map(|s| s.lines().count()) + }) + .unwrap_or(0); + format!("{} ({} lines)", filename, lines) + } + "Write" => { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + let bytes = input + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.len()) + .unwrap_or(0); + format!("{} ({} bytes)", filename, bytes) + } + "Bash" => { + let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); + // Truncate long commands + let cmd_display = if cmd.len() > 40 { + format!("{}...", &cmd[..37]) + } else { + cmd.to_string() + }; + let output_len = extract_response_content(response) + .as_ref() + .map(|s| s.len()) + .unwrap_or(0); + if output_len > 0 { + format!("`{}` ({} chars)", cmd_display, output_len) + } else { + format!("`{}`", cmd_display) + } + } + "Grep" => { + let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("'{}'", pattern) + } + "Glob" => { + let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + format!("'{}'", pattern) + } + "Edit" => { + let file = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let filename = file.rsplit('/').next().unwrap_or(file); + filename.to_string() + } + _ => String::new(), + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -21,7 +21,9 @@ use std::sync::Arc; pub use avatar::DaveAvatar; pub use config::{AiProvider, DaveSettings, ModelConfig}; -pub use messages::{DaveApiResponse, Message, PermissionResponse, PermissionResponseType}; +pub use messages::{ + DaveApiResponse, Message, PermissionResponse, PermissionResponseType, ToolResult, +}; pub use quaternion::Quaternion; pub use session::{ChatSession, SessionId, SessionManager}; pub use tools::{ @@ -238,6 +240,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .chat .push(Message::PermissionRequest(pending.request)); } + + DaveApiResponse::ToolResult(result) => { + tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); + session.chat.push(Message::ToolResult(result)); + } } } diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -41,6 +41,13 @@ pub enum PermissionResponseType { Denied, } +/// Metadata about a completed tool execution +#[derive(Debug, Clone)] +pub struct ToolResult { + pub tool_name: String, + pub summary: String, // e.g., "154 lines", "exit 0", "3 matches" +} + #[derive(Debug, Clone)] pub enum Message { System(String), @@ -51,6 +58,8 @@ pub enum Message { ToolResponse(ToolResponse), /// A permission request from the AI that needs user response PermissionRequest(PermissionRequest), + /// Result metadata from a completed tool execution + ToolResult(ToolResult), } /// The ai backends response. Since we are using streaming APIs these are @@ -61,6 +70,8 @@ pub enum DaveApiResponse { Failed(String), /// A permission request that needs to be displayed to the user PermissionRequest(PendingPermission), + /// Metadata from a completed tool execution + ToolResult(ToolResult), } impl Message { @@ -115,6 +126,9 @@ impl Message { // Permission requests are UI-only, not sent to the API Message::PermissionRequest(_) => None, + + // Tool results are UI-only, not sent to the API + Message::ToolResult(_) => None, } } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,6 +1,8 @@ use crate::{ config::DaveSettings, - messages::{Message, PermissionRequest, PermissionResponse, PermissionResponseType}, + messages::{ + Message, PermissionRequest, PermissionResponse, PermissionResponseType, ToolResult, + }, tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse}, }; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; @@ -186,6 +188,9 @@ impl<'a> DaveUi<'a> { response = DaveResponse::new(action); } } + Message::ToolResult(result) => { + Self::tool_result_ui(result, ui); + } }; } @@ -358,6 +363,29 @@ impl<'a> DaveUi<'a> { }); } + /// Render tool result metadata as a compact line + fn tool_result_ui(result: &ToolResult, ui: &mut egui::Ui) { + // Compact single-line display with subdued styling + ui.horizontal(|ui| { + // Tool name in slightly brighter text + ui.add(egui::Label::new( + egui::RichText::new(&result.tool_name) + .size(11.0) + .color(ui.visuals().text_color().gamma_multiply(0.6)) + .monospace(), + )); + // Summary in more subdued text + if !result.summary.is_empty() { + ui.add(egui::Label::new( + egui::RichText::new(&result.summary) + .size(11.0) + .color(ui.visuals().text_color().gamma_multiply(0.4)) + .monospace(), + )); + } + }); + } + fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) { ui.add(search_icon(16.0, 16.0)); ui.add_space(8.0); diff --git a/crates/notedeck_dave/tests/tool_result_integration.rs b/crates/notedeck_dave/tests/tool_result_integration.rs @@ -0,0 +1,156 @@ +//! Integration test for tool result metadata display +//! +//! Tests that tool results are captured from the message stream +//! by correlating ToolUse and ToolResult content blocks. + +use claude_agent_sdk_rs::{ContentBlock, ToolResultBlock, ToolResultContent, ToolUseBlock}; +use std::collections::HashMap; + +/// Unit test that verifies ToolUse and ToolResult correlation logic +#[test] +fn test_tool_use_result_correlation() { + // Simulate the pending_tools tracking + let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new(); + let mut tool_results: Vec<(String, String, serde_json::Value)> = Vec::new(); + + // Simulate receiving a ToolUse block in an Assistant message + let tool_use = ToolUseBlock { + id: "toolu_123".to_string(), + name: "Read".to_string(), + input: serde_json::json!({"file_path": "/etc/hostname"}), + }; + + // Store the tool use (as the main code does) + pending_tools.insert( + tool_use.id.clone(), + (tool_use.name.clone(), tool_use.input.clone()), + ); + + assert_eq!(pending_tools.len(), 1); + assert!(pending_tools.contains_key("toolu_123")); + + // Simulate receiving a ToolResult block in a User message + let tool_result = ToolResultBlock { + tool_use_id: "toolu_123".to_string(), + content: Some(ToolResultContent::Text("hostname content".to_string())), + is_error: Some(false), + }; + + // Correlate the result (as the main code does) + if let Some((tool_name, _tool_input)) = pending_tools.remove(&tool_result.tool_use_id) { + let response = match &tool_result.content { + Some(ToolResultContent::Text(s)) => serde_json::Value::String(s.clone()), + Some(ToolResultContent::Blocks(blocks)) => { + serde_json::Value::Array(blocks.iter().cloned().collect()) + } + None => serde_json::Value::Null, + }; + tool_results.push((tool_name, tool_result.tool_use_id.clone(), response)); + } + + // Verify correlation worked + assert!( + pending_tools.is_empty(), + "Tool should be removed after correlation" + ); + assert_eq!(tool_results.len(), 1); + assert_eq!(tool_results[0].0, "Read"); + assert_eq!(tool_results[0].1, "toolu_123"); + assert_eq!( + tool_results[0].2, + serde_json::Value::String("hostname content".to_string()) + ); +} + +/// Test that unmatched tool results don't cause issues +#[test] +fn test_unmatched_tool_result() { + let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new(); + let mut tool_results: Vec<(String, String)> = Vec::new(); + + // ToolResult without a matching ToolUse + let tool_result = ToolResultBlock { + tool_use_id: "toolu_unknown".to_string(), + content: Some(ToolResultContent::Text("some content".to_string())), + is_error: None, + }; + + // Try to correlate - should not find a match + if let Some((tool_name, _tool_input)) = pending_tools.remove(&tool_result.tool_use_id) { + tool_results.push((tool_name, tool_result.tool_use_id.clone())); + } + + // No results should be added + assert!(tool_results.is_empty()); +} + +/// Test multiple tools in sequence +#[test] +fn test_multiple_tools_correlation() { + let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new(); + let mut tool_results: Vec<String> = Vec::new(); + + // Add multiple tool uses + pending_tools.insert( + "toolu_1".to_string(), + ("Read".to_string(), serde_json::json!({})), + ); + pending_tools.insert( + "toolu_2".to_string(), + ("Bash".to_string(), serde_json::json!({})), + ); + pending_tools.insert( + "toolu_3".to_string(), + ("Grep".to_string(), serde_json::json!({})), + ); + + assert_eq!(pending_tools.len(), 3); + + // Process results in different order + for tool_use_id in ["toolu_2", "toolu_1", "toolu_3"] { + if let Some((tool_name, _)) = pending_tools.remove(tool_use_id) { + tool_results.push(tool_name); + } + } + + assert!(pending_tools.is_empty()); + assert_eq!(tool_results, vec!["Bash", "Read", "Grep"]); +} + +/// Test ContentBlock pattern matching +#[test] +fn test_content_block_matching() { + let blocks: Vec<ContentBlock> = vec![ + ContentBlock::Text(claude_agent_sdk_rs::TextBlock { + text: "Some text".to_string(), + }), + ContentBlock::ToolUse(ToolUseBlock { + id: "tool_1".to_string(), + name: "Read".to_string(), + input: serde_json::json!({"file_path": "/test"}), + }), + ContentBlock::ToolResult(ToolResultBlock { + tool_use_id: "tool_1".to_string(), + content: Some(ToolResultContent::Text("result".to_string())), + is_error: None, + }), + ]; + + let mut tool_uses = Vec::new(); + let mut tool_results = Vec::new(); + + for block in &blocks { + match block { + ContentBlock::ToolUse(tu) => { + tool_uses.push(tu.name.clone()); + } + ContentBlock::ToolResult(tr) => { + tool_results.push(tr.tool_use_id.clone()); + } + _ => {} + } + } + + assert_eq!(tool_uses, vec!["Read"]); + assert_eq!(tool_results, vec!["tool_1"]); +}