commit 3b3507c3f99da463bf38375d3a3c1d194b53f83e
parent 25ac44edfffd86a1003a938bea8e426cc061354c
Author: William Casarin <jb55@jb55.com>
Date: Mon, 26 Jan 2026 16:14:05 -0800
dave: add diff renderer for Edit/Write tool permission requests
Adds a unified diff view for file modification tool calls (Edit and
Write) in the permission request UI. When Claude proposes file changes,
users now see a colored diff with:
- Red lines for deletions (-)
- Green lines for additions (+)
- Line numbers for Edit operations
- Scrollable code block with monospace font
New files:
- file_update.rs: FileUpdate types and diff computation using `similar`
- ui/diff.rs: Diff rendering UI components
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
7 files changed, 290 insertions(+), 53 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4137,6 +4137,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "similar",
"tokio",
"tracing",
"uuid",
diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml
@@ -27,6 +27,7 @@ futures = "0.3.31"
dashmap = "6"
#reqwest = "0.12.15"
egui_extras = { workspace = true }
+similar = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs
@@ -0,0 +1,110 @@
+use serde_json::Value;
+use similar::{ChangeTag, TextDiff};
+
+/// Represents a proposed file modification from an AI tool call
+#[derive(Debug, Clone)]
+pub struct FileUpdate {
+ pub file_path: String,
+ pub update_type: FileUpdateType,
+}
+
+#[derive(Debug, Clone)]
+pub enum FileUpdateType {
+ /// Edit: replace old_string with new_string
+ Edit { old_string: String, new_string: String },
+ /// Write: create/overwrite entire file
+ Write { content: String },
+}
+
+/// A single line in a diff
+#[derive(Debug, Clone)]
+pub struct DiffLine {
+ pub tag: DiffTag,
+ pub content: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiffTag {
+ Equal,
+ Delete,
+ Insert,
+}
+
+impl From<ChangeTag> for DiffTag {
+ fn from(tag: ChangeTag) -> Self {
+ match tag {
+ ChangeTag::Equal => DiffTag::Equal,
+ ChangeTag::Delete => DiffTag::Delete,
+ ChangeTag::Insert => DiffTag::Insert,
+ }
+ }
+}
+
+impl FileUpdate {
+ /// Try to parse a FileUpdate from a tool name and tool input JSON
+ pub fn from_tool_call(tool_name: &str, tool_input: &Value) -> Option<Self> {
+ let obj = tool_input.as_object()?;
+
+ match tool_name {
+ "Edit" => {
+ let file_path = obj.get("file_path")?.as_str()?.to_string();
+ let old_string = obj.get("old_string")?.as_str()?.to_string();
+ let new_string = obj.get("new_string")?.as_str()?.to_string();
+
+ Some(FileUpdate {
+ file_path,
+ update_type: FileUpdateType::Edit {
+ old_string,
+ new_string,
+ },
+ })
+ }
+ "Write" => {
+ let file_path = obj.get("file_path")?.as_str()?.to_string();
+ let content = obj.get("content")?.as_str()?.to_string();
+
+ Some(FileUpdate {
+ file_path,
+ update_type: FileUpdateType::Write { content },
+ })
+ }
+ _ => None,
+ }
+ }
+
+ /// Compute the diff lines for this update
+ pub fn compute_diff(&self) -> Vec<DiffLine> {
+ match &self.update_type {
+ FileUpdateType::Edit {
+ old_string,
+ new_string,
+ } => {
+ let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str());
+ diff.iter_all_changes()
+ .map(|change| DiffLine {
+ tag: change.tag().into(),
+ content: change.value().to_string(),
+ })
+ .collect()
+ }
+ FileUpdateType::Write { content } => {
+ // For writes, everything is an insertion
+ content
+ .lines()
+ .map(|line| DiffLine {
+ tag: DiffTag::Insert,
+ content: format!("{}\n", line),
+ })
+ .collect()
+ }
+ }
+ }
+
+ /// Get the filename portion of the path for display
+ pub fn filename(&self) -> &str {
+ self.file_path
+ .rsplit('/')
+ .next()
+ .unwrap_or(&self.file_path)
+ }
+}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -2,6 +2,7 @@ mod agent_status;
mod avatar;
mod backend;
mod config;
+pub mod file_update;
pub(crate) mod mesh;
mod messages;
mod quaternion;
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -1,10 +1,12 @@
use crate::{
config::DaveSettings,
+ file_update::FileUpdate,
messages::{
Message, PermissionRequest, PermissionResponse, PermissionResponseType, ToolResult,
},
tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
};
+use super::diff;
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
use nostrdb::{Ndb, Transaction};
use notedeck::{
@@ -270,65 +272,87 @@ impl<'a> DaveUi<'a> {
});
}
None => {
- // Parse tool input for display
- let obj = request.tool_input.as_object();
- let description = obj
- .and_then(|o| o.get("description"))
- .and_then(|v| v.as_str());
- let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
- let single_value = obj
- .filter(|o| o.len() == 1)
- .and_then(|o| o.values().next())
- .and_then(|v| v.as_str());
-
- // Pending state: Show Allow/Deny buttons
- egui::Frame::new()
- .fill(ui.visuals().widgets.noninteractive.bg_fill)
- .inner_margin(inner_margin)
- .corner_radius(corner_radius)
- .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
- .show(ui, |ui| {
- // Tool info display
- if let Some(desc) = description {
- // Format: ToolName: description
+ // Check if this is a file update (Edit or Write tool)
+ if let Some(file_update) =
+ FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
+ {
+ // Render file update with diff view
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ // Header with file path and buttons
ui.horizontal(|ui| {
- ui.label(egui::RichText::new(&request.tool_name).strong());
- ui.label(desc);
-
+ diff::file_path_header(&file_update, ui);
Self::permission_buttons(request, ui, &mut action);
});
- // Command on next line if present
- if let Some(cmd) = command {
+
+ // Diff view
+ diff::file_update_ui(&file_update, ui);
+ });
+ } else {
+ // Parse tool input for display (existing logic)
+ let obj = request.tool_input.as_object();
+ let description = obj
+ .and_then(|o| o.get("description"))
+ .and_then(|v| v.as_str());
+ let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
+ let single_value = obj
+ .filter(|o| o.len() == 1)
+ .and_then(|o| o.values().next())
+ .and_then(|v| v.as_str());
+
+ // Pending state: Show Allow/Deny buttons
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ // Tool info display
+ if let Some(desc) = description {
+ // Format: ToolName: description
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+ ui.label(desc);
+
+ Self::permission_buttons(request, ui, &mut action);
+ });
+ // Command on next line if present
+ if let Some(cmd) = command {
+ ui.add(
+ egui::Label::new(egui::RichText::new(cmd).monospace())
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ }
+ } else if let Some(value) = single_value {
+ // Format: ToolName `value`
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+ ui.label(egui::RichText::new(value).monospace());
+
+ Self::permission_buttons(request, ui, &mut action);
+ });
+ } else {
+ // Fallback: show JSON
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+
+ Self::permission_buttons(request, ui, &mut action);
+ });
+ let formatted = serde_json::to_string_pretty(&request.tool_input)
+ .unwrap_or_else(|_| request.tool_input.to_string());
ui.add(
- egui::Label::new(egui::RichText::new(cmd).monospace())
- .wrap_mode(egui::TextWrapMode::Wrap),
+ egui::Label::new(
+ egui::RichText::new(formatted).monospace().size(11.0),
+ )
+ .wrap_mode(egui::TextWrapMode::Wrap),
);
}
- } else if let Some(value) = single_value {
- // Format: ToolName `value`
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new(&request.tool_name).strong());
- ui.label(egui::RichText::new(value).monospace());
-
- Self::permission_buttons(request, ui, &mut action);
- });
- } else {
- // Fallback: show JSON
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new(&request.tool_name).strong());
-
- Self::permission_buttons(request, ui, &mut action);
- });
- let formatted = serde_json::to_string_pretty(&request.tool_input)
- .unwrap_or_else(|_| request.tool_input.to_string());
- ui.add(
- egui::Label::new(
- egui::RichText::new(formatted).monospace().size(11.0),
- )
- .wrap_mode(egui::TextWrapMode::Wrap),
- );
- }
- });
+ });
+ }
}
}
diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs
@@ -0,0 +1,99 @@
+use super::super::file_update::{DiffLine, DiffTag, FileUpdate, FileUpdateType};
+use egui::{Color32, RichText, Ui};
+
+/// Colors for diff rendering
+const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60);
+const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60);
+const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128);
+
+/// Render a file update diff view
+pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) {
+ let diff_lines = update.compute_diff();
+
+ // Code block frame
+ egui::Frame::new()
+ .fill(ui.visuals().extreme_bg_color)
+ .inner_margin(8.0)
+ .corner_radius(4.0)
+ .show(ui, |ui| {
+ egui::ScrollArea::vertical()
+ .max_height(300.0)
+ .show(ui, |ui| {
+ render_diff_lines(&diff_lines, &update.update_type, ui);
+ });
+ });
+}
+
+/// Render the diff lines with proper coloring
+fn render_diff_lines(lines: &[DiffLine], update_type: &FileUpdateType, ui: &mut Ui) {
+ // Track line numbers for old and new
+ let mut old_line = 1usize;
+ let mut new_line = 1usize;
+
+ for diff_line in lines {
+ ui.horizontal(|ui| {
+ // Line number gutter
+ let (old_num, new_num) = match diff_line.tag {
+ DiffTag::Equal => {
+ let result = (Some(old_line), Some(new_line));
+ old_line += 1;
+ new_line += 1;
+ result
+ }
+ DiffTag::Delete => {
+ let result = (Some(old_line), None);
+ old_line += 1;
+ result
+ }
+ DiffTag::Insert => {
+ let result = (None, Some(new_line));
+ new_line += 1;
+ result
+ }
+ };
+
+ // Render line numbers (only for edits, not writes)
+ if matches!(update_type, FileUpdateType::Edit { .. }) {
+ let old_str = old_num.map(|n| format!("{:4}", n)).unwrap_or_else(|| " ".to_string());
+ let new_str = new_num.map(|n| format!("{:4}", n)).unwrap_or_else(|| " ".to_string());
+
+ ui.label(
+ RichText::new(format!("{} {}", old_str, new_str))
+ .monospace()
+ .size(11.0)
+ .color(LINE_NUMBER_COLOR),
+ );
+ }
+
+ // Render the prefix and content
+ let (prefix, color) = match diff_line.tag {
+ DiffTag::Equal => (" ", ui.visuals().text_color()),
+ DiffTag::Delete => ("-", DELETE_COLOR),
+ DiffTag::Insert => ("+", INSERT_COLOR),
+ };
+
+ // Remove trailing newline for display
+ let content = diff_line.content.trim_end_matches('\n');
+
+ ui.label(
+ RichText::new(format!("{} {}", prefix, content))
+ .monospace()
+ .size(12.0)
+ .color(color),
+ );
+ });
+ }
+}
+
+/// Render the file path header
+pub fn file_path_header(update: &FileUpdate, ui: &mut Ui) {
+ let type_label = match &update.update_type {
+ FileUpdateType::Edit { .. } => "Edit",
+ FileUpdateType::Write { .. } => "Write",
+ };
+
+ ui.horizontal(|ui| {
+ ui.label(RichText::new(type_label).strong());
+ ui.label(RichText::new(&update.file_path).monospace());
+ });
+}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -1,4 +1,5 @@
mod dave;
+pub mod diff;
pub mod scene;
pub mod session_list;
mod settings;