notedeck

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

commit 8e9f47c06c80afd64b07394c353f261e47e82a4f
parent 7b4430340c1d6f091bcc628b6aa47a36ecabac4d
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 16:44:56 -0800

dave: add file_update to ExecutedTool for diff rendering

Pre-compute FileUpdate in backend threads so the UI can render diffs
without per-frame work. Codex patch_apply_begin and item/completed
fileChange events build FileUpdate via unified diff or write content.
Claude backend uses FileUpdate::from_tool_call for Edit/Write tools.
The field is #[serde(skip)] to keep serialization unchanged.

The UI shows a collapsible diff view on executed Edit/Write tool
results, reusing the existing file_update_ui renderer.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 4+++-
Mcrates/notedeck_dave/src/backend/codex.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 1+
Mcrates/notedeck_dave/src/messages.rs | 3+++
Mcrates/notedeck_dave/src/session_loader.rs | 1+
Mcrates/notedeck_dave/src/ui/dave.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
6 files changed, 143 insertions(+), 15 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -4,6 +4,7 @@ use crate::backend::tool_summary::{ extract_response_content, format_tool_summary, truncate_output, }; use crate::backend::traits::AiBackend; +use crate::file_update::FileUpdate; use crate::messages::{ CompactionInfo, DaveApiResponse, ExecutedTool, ParsedMarkdown, PendingPermission, PermissionRequest, PermissionResponse, SubagentInfo, SubagentStatus, @@ -544,7 +545,8 @@ async fn session_actor( // Attach parent subagent context (top of stack) let parent_task_id = subagent_stack.last().cloned(); let summary = format_tool_summary(&tool_name, &tool_input, &result_value); - let tool_result = ExecutedTool { tool_name, summary, parent_task_id }; + let file_update = FileUpdate::from_tool_call(&tool_name, &tool_input); + let tool_result = ExecutedTool { tool_name, summary, parent_task_id, file_update }; let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result)); ctx.request_repaint(); } diff --git a/crates/notedeck_dave/src/backend/codex.rs b/crates/notedeck_dave/src/backend/codex.rs @@ -5,6 +5,7 @@ use super::codex_protocol::*; use super::tool_summary::{format_tool_summary, truncate_output}; use crate::auto_accept::AutoAcceptRules; use crate::backend::traits::AiBackend; +use crate::file_update::{FileUpdate, FileUpdateType}; use crate::messages::{ CompactionInfo, DaveApiResponse, ExecutedTool, PendingPermission, PermissionRequest, PermissionResponse, SessionInfo, SubagentInfo, SubagentStatus, @@ -631,6 +632,60 @@ fn handle_codex_message( return HandleResult::TurnDone; } + "codex/event/patch_apply_begin" => { + // Legacy event carrying full file-change details (paths + diffs). + // The V2 `item/completed` for fileChange is sparse, so we extract + // the diff from the legacy event and emit ToolResults here. + if let Some(params) = &msg.params { + if let Some(changes) = params + .get("msg") + .and_then(|m| m.get("changes")) + .and_then(|c| c.as_object()) + { + for (path, change) in changes { + let change_type = change + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("update"); + + let (tool_name, diff_text) = match change_type { + "add" => { + let content = + change.get("content").and_then(|c| c.as_str()).unwrap_or(""); + ("Write", content.to_string()) + } + "delete" => ("Edit", "(file deleted)".to_string()), + _ => { + // "update" — has unified_diff + let diff = change + .get("unified_diff") + .and_then(|d| d.as_str()) + .unwrap_or(""); + ("Edit", diff.to_string()) + } + }; + + let tool_input = serde_json::json!({ + "file_path": path, + "diff": diff_text, + }); + let result_value = serde_json::json!({ "status": "ok" }); + let summary = format_tool_summary(tool_name, &tool_input, &result_value); + + let file_update = + make_codex_file_update(path, tool_name, change_type, &diff_text); + let _ = response_tx.send(DaveApiResponse::ToolResult(ExecutedTool { + tool_name: tool_name.to_string(), + summary, + parent_task_id: subagent_stack.last().cloned(), + file_update, + })); + } + ctx.request_repaint(); + } + } + } + "codex/event/error" | "error" => { let err_msg: String = extract_codex_error(&msg); tracing::warn!("Codex error: {}", err_msg); @@ -705,6 +760,24 @@ fn check_approval_or_forward( } } +/// Build a `FileUpdate` from codex file-change data. +fn make_codex_file_update( + path: &str, + tool_name: &str, + change_type: &str, + diff_text: &str, +) -> Option<FileUpdate> { + let update_type = match (tool_name, change_type) { + ("Write", _) | (_, "add") | (_, "create") => FileUpdateType::Write { + content: diff_text.to_string(), + }, + _ => FileUpdateType::UnifiedDiff { + diff: diff_text.to_string(), + }, + }; + Some(FileUpdate::new(path.to_string(), update_type)) +} + /// Handle a completed item from Codex. fn handle_item_completed( completed: &ItemCompletedParams, @@ -727,6 +800,7 @@ fn handle_item_completed( tool_name: "Bash".to_string(), summary, parent_task_id, + file_update: None, })); ctx.request_repaint(); } @@ -754,10 +828,17 @@ fn handle_item_completed( let summary = format_tool_summary(tool_name, &tool_input, &result_value); let parent_task_id = subagent_stack.last().cloned(); + let file_update = make_codex_file_update( + &file_path, + tool_name, + kind_str, + diff.as_deref().unwrap_or(""), + ); let _ = response_tx.send(DaveApiResponse::ToolResult(ExecutedTool { tool_name: tool_name.to_string(), summary, parent_task_id, + file_update, })); ctx.request_repaint(); } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1975,6 +1975,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tool_name, summary, parent_task_id: None, + file_update: None, }, ))); } diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -121,6 +121,9 @@ pub struct ExecutedTool { pub summary: String, // e.g., "154 lines", "exit 0", "3 matches" /// Which subagent (Task tool_use_id) produced this result, if any pub parent_task_id: Option<String>, + /// Pre-computed file update for diff rendering (not serialized) + #[serde(skip)] + pub file_update: Option<crate::file_update::FileUpdate>, } /// Session initialization info from Claude Code CLI diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -178,6 +178,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> .to_string(), summary, parent_task_id: None, + file_update: None, }, ))) } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -932,25 +932,65 @@ impl<'a> DaveUi<'a> { /// Render tool result metadata as a compact line fn executed_tool_ui(result: &ExecutedTool, 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() { + if let Some(file_update) = &result.file_update { + // File edit with diff — show collapsible header with inline diff + let expand_id = ui.id().with("exec_diff").with(&result.summary); + let expanded: bool = ui.data(|d| d.get_temp(expand_id).unwrap_or(false)); + + let header_resp = ui.horizontal(|ui| { + let arrow = if expanded { "▼" } else { "▶" }; + ui.add(egui::Label::new( + egui::RichText::new(arrow) + .size(10.0) + .color(ui.visuals().text_color().gamma_multiply(0.5)), + )); ui.add(egui::Label::new( - egui::RichText::new(&result.summary) + egui::RichText::new(&result.tool_name) .size(11.0) - .color(ui.visuals().text_color().gamma_multiply(0.4)) + .color(ui.visuals().text_color().gamma_multiply(0.6)) .monospace(), )); + 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(), + )); + } + }); + + if header_resp + .response + .interact(egui::Sense::click()) + .clicked() + { + ui.data_mut(|d| d.insert_temp(expand_id, !expanded)); } - }); + + if expanded { + diff::file_path_header(file_update, ui); + diff::file_update_ui(file_update, false, ui); + } + } else { + // Compact single-line display with subdued styling + ui.horizontal(|ui| { + ui.add(egui::Label::new( + egui::RichText::new(&result.tool_name) + .size(11.0) + .color(ui.visuals().text_color().gamma_multiply(0.6)) + .monospace(), + )); + 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(), + )); + } + }); + } } /// Render compaction complete notification