notedeck

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

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:
MCargo.lock | 1+
Mcrates/notedeck_dave/Cargo.toml | 1+
Acrates/notedeck_dave/src/file_update.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 1+
Mcrates/notedeck_dave/src/ui/dave.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Acrates/notedeck_dave/src/ui/diff.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 1+
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;