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:
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