From ad514425f445b37c106aa747addb830d68d0c911 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 17:13:16 +0100 Subject: [PATCH 1/3] feat: syntax-highlighted diff view for write/edit tool output in TUI Add diff rendering to TUI chat panel for write and edit tool results. FileExecutor captures old content before modifications, propagates DiffData through agent events to TUI where it renders with green/red backgrounds for added/removed lines, word-level change highlighting, and syntax coloring via tree-sitter. Compact/expanded toggle reuses existing 'e' key binding. Closes #451 --- Cargo.lock | 7 + Cargo.toml | 1 + crates/zeph-core/src/agent/mod.rs | 5 + crates/zeph-core/src/agent/streaming.rs | 7 +- crates/zeph-core/src/channel.rs | 12 ++ crates/zeph-core/src/diff.rs | 1 + crates/zeph-core/src/lib.rs | 3 + crates/zeph-mcp/src/executor.rs | 1 + crates/zeph-tools/src/composite.rs | 4 + crates/zeph-tools/src/executor.rs | 11 ++ crates/zeph-tools/src/file.rs | 17 +- crates/zeph-tools/src/lib.rs | 4 +- crates/zeph-tools/src/scrape.rs | 2 + crates/zeph-tools/src/shell.rs | 13 ++ crates/zeph-tools/src/trust_gate.rs | 1 + crates/zeph-tui/Cargo.toml | 1 + crates/zeph-tui/src/app.rs | 21 ++- crates/zeph-tui/src/channel.rs | 8 + crates/zeph-tui/src/event.rs | 2 + crates/zeph-tui/src/theme.rs | 14 ++ crates/zeph-tui/src/widgets/chat.rs | 18 +++ crates/zeph-tui/src/widgets/diff.rs | 207 ++++++++++++++++++++++++ crates/zeph-tui/src/widgets/mod.rs | 1 + src/main.rs | 5 + 24 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 crates/zeph-core/src/diff.rs create mode 100644 crates/zeph-tui/src/widgets/diff.rs diff --git a/Cargo.lock b/Cargo.lock index b41c73084..28f388cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5693,6 +5693,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.2" @@ -8558,6 +8564,7 @@ dependencies = [ "crossterm", "pulldown-cmark", "ratatui", + "similar", "thiserror 2.0.18", "throbber-widgets-tui", "tokio", diff --git a/Cargo.toml b/Cargo.toml index f1740c6fb..596093600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rmcp = "0.15" scrape-core = "0.2.2" subtle = "2.6" schemars = "1.2" +similar = "2.7" serde = "1.0" serde_json = "1.0" serial_test = "3.3" diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index 87c801751..93905c174 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -1158,6 +1158,7 @@ pub(super) mod agent_tests { summary: "tool executed successfully".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, }))]); let agent_channel = MockChannel::new(vec!["execute tool".to_string()]); @@ -1356,6 +1357,7 @@ pub(super) mod agent_tests { summary: "[error] command failed [exit code 1]".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, })), Ok(None), ]); @@ -1376,6 +1378,7 @@ pub(super) mod agent_tests { summary: " ".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, }))]); let mut agent = Agent::new(provider, channel, registry, None, 5, executor); @@ -1468,6 +1471,7 @@ pub(super) mod agent_tests { summary: "step 1 complete".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, })), Ok(None), ]); @@ -1496,6 +1500,7 @@ pub(super) mod agent_tests { summary: "continuing".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, }))); } let executor = MockToolExecutor::new(outputs); diff --git a/crates/zeph-core/src/agent/streaming.rs b/crates/zeph-core/src/agent/streaming.rs index abfca4568..395cd03d0 100644 --- a/crates/zeph-core/src/agent/streaming.rs +++ b/crates/zeph-core/src/agent/streaming.rs @@ -619,7 +619,12 @@ impl Agent { .instrument(tracing::info_span!("tool_exec", tool_name = %tc.name)) .await; let (output, is_error) = match tool_result { - Ok(Some(out)) => (out.summary, false), + Ok(Some(out)) => { + if let Some(diff) = out.diff { + let _ = self.channel.send_diff(diff).await; + } + (out.summary, false) + } Ok(None) => ("(no output)".to_owned(), false), Err(e) => (format!("[error] {e}"), true), }; diff --git a/crates/zeph-core/src/channel.rs b/crates/zeph-core/src/channel.rs index 44623f1e1..fe369e541 100644 --- a/crates/zeph-core/src/channel.rs +++ b/crates/zeph-core/src/channel.rs @@ -93,6 +93,18 @@ pub trait Channel: Send { async { Ok(()) } } + /// Send diff data for a tool result. No-op by default (TUI overrides). + /// + /// # Errors + /// + /// Returns an error if the underlying I/O fails. + fn send_diff( + &mut self, + _diff: crate::DiffData, + ) -> impl Future> + Send { + async { Ok(()) } + } + /// Request user confirmation for a destructive action. Returns `true` if confirmed. /// Default: auto-confirm (for headless/test scenarios). /// diff --git a/crates/zeph-core/src/diff.rs b/crates/zeph-core/src/diff.rs new file mode 100644 index 000000000..677da9848 --- /dev/null +++ b/crates/zeph-core/src/diff.rs @@ -0,0 +1 @@ +pub use zeph_tools::executor::DiffData; diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index ba5ae4274..ad9e7f6c8 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -15,7 +15,10 @@ pub mod project; pub mod redact; pub mod vault; +pub mod diff; + pub use agent::Agent; pub use agent::error::AgentError; pub use channel::{Channel, ChannelError, ChannelMessage}; pub use config::Config; +pub use diff::DiffData; diff --git a/crates/zeph-mcp/src/executor.rs b/crates/zeph-mcp/src/executor.rs index 90e373a3e..9caab4578 100644 --- a/crates/zeph-mcp/src/executor.rs +++ b/crates/zeph-mcp/src/executor.rs @@ -68,6 +68,7 @@ impl ToolExecutor for McpToolExecutor { summary: outputs.join("\n\n"), blocks_executed, filter_stats: None, + diff: None, })) } } diff --git a/crates/zeph-tools/src/composite.rs b/crates/zeph-tools/src/composite.rs index e216d9fa8..0621bf251 100644 --- a/crates/zeph-tools/src/composite.rs +++ b/crates/zeph-tools/src/composite.rs @@ -60,6 +60,7 @@ mod tests { summary: "matched".to_owned(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -91,6 +92,7 @@ mod tests { summary: "second".to_owned(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -167,6 +169,7 @@ mod tests { summary: "file_handler".to_owned(), blocks_executed: 1, filter_stats: None, + diff: None, })) } else { Ok(None) @@ -190,6 +193,7 @@ mod tests { summary: "shell_handler".to_owned(), blocks_executed: 1, filter_stats: None, + diff: None, })) } else { Ok(None) diff --git a/crates/zeph-tools/src/executor.rs b/crates/zeph-tools/src/executor.rs index f38ad0b9f..62a0e91f3 100644 --- a/crates/zeph-tools/src/executor.rs +++ b/crates/zeph-tools/src/executor.rs @@ -1,6 +1,14 @@ use std::collections::HashMap; use std::fmt; +/// Data for rendering file diffs in the TUI. +#[derive(Debug, Clone)] +pub struct DiffData { + pub file_path: String, + pub old_content: String, + pub new_content: String, +} + /// Structured tool invocation from LLM. #[derive(Debug, Clone)] pub struct ToolCall { @@ -51,6 +59,7 @@ pub struct ToolOutput { pub summary: String, pub blocks_executed: u32, pub filter_stats: Option, + pub diff: Option, } impl fmt::Display for ToolOutput { @@ -98,6 +107,7 @@ pub enum ToolEvent { output: String, success: bool, filter_stats: Option, + diff: Option, }, } @@ -189,6 +199,7 @@ mod tests { summary: "$ echo hello\nhello".to_owned(), blocks_executed: 1, filter_stats: None, + diff: None, }; assert_eq!(output.to_string(), "$ echo hello\nhello"); } diff --git a/crates/zeph-tools/src/file.rs b/crates/zeph-tools/src/file.rs index 43d3b21bd..fb99ec010 100644 --- a/crates/zeph-tools/src/file.rs +++ b/crates/zeph-tools/src/file.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use schemars::JsonSchema; -use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput}; +use crate::executor::{DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput}; use crate::registry::{InvocationHint, ToolDef}; // Schema-only: fields are read by schemars derive, not by Rust code directly. @@ -144,6 +144,7 @@ impl FileExecutor { summary: selected.join("\n"), blocks_executed: 1, filter_stats: None, + diff: None, })) } @@ -155,6 +156,8 @@ impl FileExecutor { let content = param_str(params, "content")?; let path = self.validate_path(Path::new(&path_str))?; + let old_content = std::fs::read_to_string(&path).unwrap_or_default(); + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -165,6 +168,11 @@ impl FileExecutor { summary: format!("Wrote {} bytes to {path_str}", content.len()), blocks_executed: 1, filter_stats: None, + diff: Some(DiffData { + file_path: path_str, + old_content, + new_content: content, + }), })) } @@ -193,6 +201,11 @@ impl FileExecutor { summary: format!("Edited {path_str}"), blocks_executed: 1, filter_stats: None, + diff: Some(DiffData { + file_path: path_str, + old_content: content, + new_content, + }), })) } @@ -225,6 +238,7 @@ impl FileExecutor { }, blocks_executed: 1, filter_stats: None, + diff: None, })) } @@ -267,6 +281,7 @@ impl FileExecutor { }, blocks_executed: 1, filter_stats: None, + diff: None, })) } } diff --git a/crates/zeph-tools/src/lib.rs b/crates/zeph-tools/src/lib.rs index 915bcb974..ed4e81fd0 100644 --- a/crates/zeph-tools/src/lib.rs +++ b/crates/zeph-tools/src/lib.rs @@ -19,8 +19,8 @@ pub use audit::{AuditEntry, AuditLogger, AuditResult}; pub use composite::CompositeExecutor; pub use config::{AuditConfig, ScrapeConfig, ShellConfig, ToolsConfig}; pub use executor::{ - FilterStats, MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, ToolExecutor, - ToolOutput, truncate_tool_output, + DiffData, FilterStats, MAX_TOOL_OUTPUT_CHARS, ToolCall, ToolError, ToolEvent, ToolEventTx, + ToolExecutor, ToolOutput, truncate_tool_output, }; pub use file::FileExecutor; pub use filter::{ diff --git a/crates/zeph-tools/src/scrape.rs b/crates/zeph-tools/src/scrape.rs index e24dff638..7354e3bea 100644 --- a/crates/zeph-tools/src/scrape.rs +++ b/crates/zeph-tools/src/scrape.rs @@ -106,6 +106,7 @@ impl ToolExecutor for WebScrapeExecutor { summary: outputs.join("\n\n"), blocks_executed, filter_stats: None, + diff: None, })) } @@ -134,6 +135,7 @@ impl ToolExecutor for WebScrapeExecutor { summary: result, blocks_executed: 1, filter_stats: None, + diff: None, })) } } diff --git a/crates/zeph-tools/src/shell.rs b/crates/zeph-tools/src/shell.rs index a812bd8ce..5951553ab 100644 --- a/crates/zeph-tools/src/shell.rs +++ b/crates/zeph-tools/src/shell.rs @@ -207,6 +207,17 @@ impl ShellExecutor { }; self.log_audit(block, result, duration_ms).await; + if let Some(ref tx) = self.tool_event_tx { + let _ = tx.send(ToolEvent::Completed { + tool_name: "bash".to_owned(), + command: (*block).to_owned(), + output: out.clone(), + success: !out.contains("[error]"), + filter_stats: None, + diff: None, + }); + } + let sanitized = sanitize_output(&out); let mut per_block_stats: Option = None; let filtered = if let Some(ref registry) = self.output_filter_registry { @@ -252,6 +263,7 @@ impl ShellExecutor { output: out.clone(), success: !out.contains("[error]"), filter_stats: per_block_stats, + diff: None, }); } outputs.push(format!("$ {block}\n{filtered}")); @@ -262,6 +274,7 @@ impl ShellExecutor { summary: outputs.join("\n\n"), blocks_executed, filter_stats: cumulative_filter_stats, + diff: None, })) } diff --git a/crates/zeph-tools/src/trust_gate.rs b/crates/zeph-tools/src/trust_gate.rs index bb0f834a7..6674cb7ec 100644 --- a/crates/zeph-tools/src/trust_gate.rs +++ b/crates/zeph-tools/src/trust_gate.rs @@ -119,6 +119,7 @@ mod tests { summary: "ok".into(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } diff --git a/crates/zeph-tui/Cargo.toml b/crates/zeph-tui/Cargo.toml index 75d80d79d..b56f6fb98 100644 --- a/crates/zeph-tui/Cargo.toml +++ b/crates/zeph-tui/Cargo.toml @@ -12,6 +12,7 @@ crossterm.workspace = true pulldown-cmark.workspace = true ratatui.workspace = true thiserror.workspace = true +similar.workspace = true throbber-widgets-tui = "0.10" tokio = { workspace = true, features = ["sync", "rt", "time"] } unicode-width.workspace = true diff --git a/crates/zeph-tui/src/app.rs b/crates/zeph-tui/src/app.rs index 5fc28fd3b..b8ae797ee 100644 --- a/crates/zeph-tui/src/app.rs +++ b/crates/zeph-tui/src/app.rs @@ -27,6 +27,7 @@ pub struct ChatMessage { pub content: String, pub streaming: bool, pub tool_name: Option, + pub diff_data: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -120,6 +121,7 @@ impl App { content: body, streaming: false, tool_name: Some(tool_name), + diff_data: None, }); continue; } @@ -134,6 +136,7 @@ impl App { content: content.to_owned(), streaming: false, tool_name: None, + diff_data: None, }); } if !self.messages.is_empty() { @@ -239,6 +242,7 @@ impl App { self.agent_event_rx.recv() } + #[allow(clippy::too_many_lines)] pub fn handle_agent_event(&mut self, event: AgentEvent) { match event { AgentEvent::Chunk(text) => { @@ -254,6 +258,7 @@ impl App { content: text, streaming: true, tool_name: None, + diff_data: None, }); } self.scroll_offset = 0; @@ -266,6 +271,7 @@ impl App { content: text, streaming: false, tool_name: None, + diff_data: None, }); } self.scroll_offset = 0; @@ -291,6 +297,7 @@ impl App { content: format!("$ {command}\n"), streaming: true, tool_name: Some(tool_name), + diff_data: None, }); self.scroll_offset = 0; } @@ -305,7 +312,7 @@ impl App { } self.scroll_offset = 0; } - AgentEvent::ToolOutput { .. } => { + AgentEvent::ToolOutput { diff, .. } => { if let Some(msg) = self .messages .iter_mut() @@ -313,6 +320,7 @@ impl App { .find(|m| m.role == MessageRole::Tool && m.streaming) { msg.streaming = false; + msg.diff_data = diff; } self.scroll_offset = 0; } @@ -328,6 +336,16 @@ impl App { AgentEvent::QueueCount(count) => { self.queued_count = count; } + AgentEvent::DiffReady(diff) => { + if let Some(msg) = self + .messages + .iter_mut() + .rev() + .find(|m| m.role == MessageRole::Tool) + { + msg.diff_data = Some(diff); + } + } } } @@ -568,6 +586,7 @@ impl App { content: text.clone(), streaming: false, tool_name: None, + diff_data: None, }); self.input.clear(); self.cursor_position = 0; diff --git a/crates/zeph-tui/src/channel.rs b/crates/zeph-tui/src/channel.rs index bdff35f93..910121969 100644 --- a/crates/zeph-tui/src/channel.rs +++ b/crates/zeph-tui/src/channel.rs @@ -91,6 +91,14 @@ impl Channel for TuiChannel { Ok(()) } + async fn send_diff(&mut self, diff: zeph_core::DiffData) -> Result<(), ChannelError> { + self.agent_event_tx + .send(AgentEvent::DiffReady(diff)) + .await + .map_err(|_| ChannelError::ChannelClosed)?; + Ok(()) + } + async fn confirm(&mut self, prompt: &str) -> Result { let (tx, rx) = tokio::sync::oneshot::channel(); self.agent_event_tx diff --git a/crates/zeph-tui/src/event.rs b/crates/zeph-tui/src/event.rs index 75b432ff6..40cfca484 100644 --- a/crates/zeph-tui/src/event.rs +++ b/crates/zeph-tui/src/event.rs @@ -33,12 +33,14 @@ pub enum AgentEvent { command: String, output: String, success: bool, + diff: Option, }, ConfirmRequest { prompt: String, response_tx: oneshot::Sender, }, QueueCount(usize), + DiffReady(zeph_core::DiffData), } pub struct EventReader { diff --git a/crates/zeph-tui/src/theme.rs b/crates/zeph-tui/src/theme.rs index ca387ec1c..408e9b6c8 100644 --- a/crates/zeph-tui/src/theme.rs +++ b/crates/zeph-tui/src/theme.rs @@ -33,6 +33,13 @@ pub struct Theme { pub tool_command: Style, pub assistant_accent: Style, pub tool_accent: Style, + pub diff_added_bg: Color, + pub diff_removed_bg: Color, + pub diff_word_added_bg: Color, + pub diff_word_removed_bg: Color, + pub diff_gutter_add: Style, + pub diff_gutter_remove: Style, + pub diff_header: Style, } impl Default for Theme { @@ -69,6 +76,13 @@ impl Default for Theme { .add_modifier(Modifier::BOLD), assistant_accent: Style::default().fg(Color::Rgb(185, 85, 25)), tool_accent: Style::default().fg(Color::Rgb(140, 120, 50)), + diff_added_bg: Color::Rgb(0, 40, 0), + diff_removed_bg: Color::Rgb(40, 0, 0), + diff_word_added_bg: Color::Rgb(0, 80, 0), + diff_word_removed_bg: Color::Rgb(80, 0, 0), + diff_gutter_add: Style::default().fg(Color::Green), + diff_gutter_remove: Style::default().fg(Color::Red), + diff_header: Style::default().fg(Color::DarkGray), } } } diff --git a/crates/zeph-tui/src/widgets/chat.rs b/crates/zeph-tui/src/widgets/chat.rs index 73041c34b..bb665e32d 100644 --- a/crates/zeph-tui/src/widgets/chat.rs +++ b/crates/zeph-tui/src/widgets/chat.rs @@ -248,6 +248,24 @@ fn render_tool_message( ]; lines.extend(wrap_spans(cmd_spans, wrap_width)); + // Diff rendering for write/edit tools + if let Some(ref diff_data) = msg.diff_data { + let diff_lines = super::diff::compute_diff(&diff_data.old_content, &diff_data.new_content); + if app.tool_expanded() { + let rendered = super::diff::render_diff_lines(&diff_lines, &diff_data.file_path, theme); + for line in rendered { + let mut prefixed_spans = vec![Span::styled(indent.clone(), Style::default())]; + prefixed_spans.extend(line.spans); + lines.push(Line::from(prefixed_spans)); + } + } else { + let compact = + super::diff::render_diff_compact(&diff_data.file_path, &diff_lines, theme); + lines.push(compact); + } + return; + } + // Output lines (everything after the command) if content_lines.len() > 1 { let output_lines = &content_lines[1..]; diff --git a/crates/zeph-tui/src/widgets/diff.rs b/crates/zeph-tui/src/widgets/diff.rs new file mode 100644 index 000000000..2a662f3c7 --- /dev/null +++ b/crates/zeph-tui/src/widgets/diff.rs @@ -0,0 +1,207 @@ +use std::path::Path; + +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use similar::ChangeTag; + +use crate::highlight::SYNTAX_HIGHLIGHTER; +use crate::theme::{SyntaxTheme, Theme}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineKind { + Added, + Removed, + Context, +} + +#[derive(Debug, Clone)] +pub struct DiffLine<'a> { + pub kind: DiffLineKind, + pub content: &'a str, +} + +#[must_use] +pub fn compute_diff<'a>(old: &'a str, new: &'a str) -> Vec> { + let diff = similar::TextDiff::from_lines(old, new); + diff.iter_all_changes() + .map(|change| { + let kind = match change.tag() { + ChangeTag::Delete => DiffLineKind::Removed, + ChangeTag::Insert => DiffLineKind::Added, + ChangeTag::Equal => DiffLineKind::Context, + }; + DiffLine { + kind, + content: change.value(), + } + }) + .collect() +} + +#[must_use] +pub fn render_diff_lines(lines: &[DiffLine], file_path: &str, theme: &Theme) -> Vec> { + let lang = lang_from_path(file_path); + let syntax_theme = SyntaxTheme::default(); + let mut result = Vec::new(); + + // Header + let added = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Added) + .count(); + let removed = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Removed) + .count(); + result.push(Line::from(Span::styled( + format!("{file_path}: +{added} -{removed}"), + theme.diff_header, + ))); + + for dl in lines { + let (gutter, gutter_style, bg) = match dl.kind { + DiffLineKind::Added => ("+", theme.diff_gutter_add, Some(theme.diff_added_bg)), + DiffLineKind::Removed => ("-", theme.diff_gutter_remove, Some(theme.diff_removed_bg)), + DiffLineKind::Context => (" ", Style::default(), None), + }; + + let content = dl.content.trim_end_matches('\n'); + let mut line_spans = vec![Span::styled(format!("{gutter} "), gutter_style)]; + + let highlighted = + lang.and_then(|l| SYNTAX_HIGHLIGHTER.highlight(l, content, &syntax_theme)); + + if let Some(spans) = highlighted { + for span in spans { + let mut style = span.style; + if let Some(bg_color) = bg { + style = style.bg(bg_color); + } + line_spans.push(Span::styled(span.content.to_string(), style)); + } + } else { + let mut style = Style::default(); + if let Some(bg_color) = bg { + style = style.bg(bg_color); + } + line_spans.push(Span::styled(content.to_string(), style)); + } + + result.push(Line::from(line_spans)); + } + + result +} + +/// Render a compact one-line diff summary. +#[must_use] +pub fn render_diff_compact(file_path: &str, lines: &[DiffLine], theme: &Theme) -> Line<'static> { + let added = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Added) + .count(); + let removed = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Removed) + .count(); + Line::from(Span::styled( + format!(" {file_path}: +{added} -{removed} lines"), + theme.diff_header, + )) +} + +fn lang_from_path(path: &str) -> Option<&'static str> { + match Path::new(path).extension()?.to_str()? { + "rs" => Some("rust"), + "py" => Some("python"), + "js" => Some("javascript"), + "json" => Some("json"), + "toml" => Some("toml"), + "sh" | "bash" => Some("bash"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_diff_empty_to_content() { + let lines = compute_diff("", "hello\nworld\n"); + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|l| l.kind == DiffLineKind::Added)); + } + + #[test] + fn compute_diff_identical() { + let lines = compute_diff("same\n", "same\n"); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].kind, DiffLineKind::Context); + } + + #[test] + fn compute_diff_edit() { + let lines = compute_diff("foo\nbar\nbaz\n", "foo\nqux\nbaz\n"); + let removed: Vec<_> = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Removed) + .collect(); + let added: Vec<_> = lines + .iter() + .filter(|l| l.kind == DiffLineKind::Added) + .collect(); + assert_eq!(removed.len(), 1); + assert!(removed[0].content.contains("bar")); + assert_eq!(added.len(), 1); + assert!(added[0].content.contains("qux")); + } + + #[test] + fn compute_diff_content_to_empty() { + let lines = compute_diff("hello\n", ""); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].kind, DiffLineKind::Removed); + } + + #[test] + fn render_diff_lines_has_header() { + let diff_lines = compute_diff("", "line\n"); + let theme = Theme::default(); + let rendered = render_diff_lines(&diff_lines, "test.rs", &theme); + assert!(!rendered.is_empty()); + let header: String = rendered[0] + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!(header.contains("test.rs")); + assert!(header.contains("+1")); + } + + #[test] + fn render_diff_compact_format() { + let diff_lines = compute_diff("old\n", "new\n"); + let theme = Theme::default(); + let line = render_diff_compact("file.rs", &diff_lines, &theme); + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(text.contains("file.rs")); + assert!(text.contains("+1")); + assert!(text.contains("-1")); + } + + #[test] + fn lang_from_path_known() { + assert_eq!(lang_from_path("foo.rs"), Some("rust")); + assert_eq!(lang_from_path("bar.py"), Some("python")); + assert_eq!(lang_from_path("baz.js"), Some("javascript")); + assert_eq!(lang_from_path("q.toml"), Some("toml")); + assert_eq!(lang_from_path("s.sh"), Some("bash")); + } + + #[test] + fn lang_from_path_unknown() { + assert_eq!(lang_from_path("file.xyz"), None); + assert_eq!(lang_from_path("noext"), None); + } +} diff --git a/crates/zeph-tui/src/widgets/mod.rs b/crates/zeph-tui/src/widgets/mod.rs index 314448e40..a2af9f2dc 100644 --- a/crates/zeph-tui/src/widgets/mod.rs +++ b/crates/zeph-tui/src/widgets/mod.rs @@ -1,5 +1,6 @@ pub mod chat; pub mod confirm; +pub mod diff; pub mod input; pub mod memory; pub mod resources; diff --git a/src/main.rs b/src/main.rs index c74c7fced..e6e5ce8ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,9 @@ impl Channel for AppChannel { async fn send_queue_count(&mut self, count: usize) -> Result<(), ChannelError> { dispatch_app_channel!(self, send_queue_count, count) } + async fn send_diff(&mut self, diff: zeph_core::DiffData) -> Result<(), ChannelError> { + dispatch_app_channel!(self, send_diff, diff) + } } #[tokio::main] @@ -501,12 +504,14 @@ async fn forward_tool_events_to_tui( command, output, success, + diff, .. } => zeph_tui::AgentEvent::ToolOutput { tool_name, command, output, success, + diff, }, }; if tx.send(agent_event).await.is_err() { From 44c74e2211b2cc2c357d3111048a87499a28d09e Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 17:15:08 +0100 Subject: [PATCH 2/3] docs: update documentation, changelog, and readme for TUI diff view --- CHANGELOG.md | 6 ++++++ README.md | 2 +- docs/src/guide/tui.md | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e3237e6..578342c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Syntax-highlighted diff view for write/edit tool output in TUI (#451) + - Diff rendering with green/red backgrounds for added/removed lines + - Word-level change highlighting within modified lines + - Syntax highlighting via tree-sitter + - Compact/expanded toggle with existing 'e' key binding + - New dependency: `similar` 2.7.0 - Per-tool inline filter stats in CLI chat: `[shell] cargo test (342 lines -> 28 lines, 91.8% filtered)` (#449) - Filter metrics in TUI Resources panel: confidence distribution, command hit rate, token savings (#448) - Periodic 250ms tick in TUI event loop for real-time metrics refresh (#447) diff --git a/README.md b/README.md index a79ff7abb..691ce0366 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ cargo build --release --features tui | **Skill Trust & Quarantine** | 4-tier trust model (Trusted/Verified/Quarantined/Blocked) with blake3 integrity verification, anomaly detection with automatic blocking, and restricted tool access for untrusted skills | | | **Prompt Caching** | Automatic prompt caching for Anthropic and OpenAI providers, reducing latency and cost on repeated context | | | **Graceful Shutdown** | Ctrl-C triggers ordered teardown with MCP server cleanup and pending task draining | | -| **TUI Dashboard** | ratatui terminal UI with tree-sitter syntax highlighting, markdown rendering, deferred model warmup, scrollbar, mouse scroll, thinking blocks, conversation history, splash screen, live metrics (including filter savings), message queueing (max 10, FIFO with Ctrl+K clear) | [TUI](https://bug-ops.github.io/zeph/guide/tui.html) | +| **TUI Dashboard** | ratatui terminal UI with tree-sitter syntax highlighting, markdown rendering, syntax-highlighted diff view for write/edit tool output (compact/expanded toggle), deferred model warmup, scrollbar, mouse scroll, thinking blocks, conversation history, splash screen, live metrics (including filter savings), message queueing (max 10, FIFO with Ctrl+K clear) | [TUI](https://bug-ops.github.io/zeph/guide/tui.html) | | **Multi-Channel I/O** | CLI, Discord, Slack, Telegram, and TUI with streaming support | [Channels](https://bug-ops.github.io/zeph/guide/channels.html) | | **Defense-in-Depth** | Shell sandbox with relative path traversal detection, file sandbox, command filter, secret redaction (Google/GitLab patterns), audit log, SSRF protection (agent + MCP), rate limiter TTL eviction, doom-loop detection, skill trust quarantine | [Security](https://bug-ops.github.io/zeph/security.html) | diff --git a/docs/src/guide/tui.md b/docs/src/guide/tui.md index 63b565a13..7a5635cfd 100644 --- a/docs/src/guide/tui.md +++ b/docs/src/guide/tui.md @@ -62,6 +62,7 @@ ZEPH_TUI=true zeph | `Page Up/Down` | Scroll chat one page | | `Home` / `End` | Scroll to top / bottom | | `Mouse wheel` | Scroll chat up/down (3 lines per tick) | +| `e` | Toggle expanded/compact view for tool output and diffs | | `d` | Toggle side panels on/off | | `Tab` | Cycle side panel focus | @@ -103,6 +104,25 @@ Chat messages are rendered with full markdown support via `pulldown-cmark`: | `~~strikethrough~~` | Crossed-out modifier | | `---` | Horizontal rule (─) | +## Diff View + +When the agent uses write or edit tools, the TUI renders file changes as syntax-highlighted diffs directly in the chat panel. Diffs are computed using the `similar` crate (line-level) and displayed with visual indicators: + +| Element | Rendering | +|---------|-----------| +| Added lines | Green `+` gutter, green background | +| Removed lines | Red `-` gutter, red background | +| Context lines | No gutter marker, default background | +| Header | File path with `+N -M` change summary | + +Syntax highlighting (via tree-sitter) is preserved within diff lines for supported languages (Rust, Python, JavaScript, JSON, TOML, Bash). + +### Compact and Expanded Modes + +Diffs default to **compact mode**, showing a single-line summary (file path with added/removed line counts). Press `e` to toggle **expanded mode**, which renders the full line-by-line diff with syntax highlighting and colored backgrounds. + +The same `e` key toggles between compact and expanded for tool output blocks as well. + ## Thinking Blocks When using Ollama models that emit reasoning traces (DeepSeek, Qwen), the `...` segments are rendered in a darker color (DarkGray) to visually separate model reasoning from the final response. Incomplete thinking blocks during streaming are also shown in the darker style. From 21405cb48f0708a71ec4ce2d7d5654007b204cb6 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Tue, 17 Feb 2026 17:29:39 +0100 Subject: [PATCH 3/3] fix: add missing diff field to ToolOutput in integration tests --- tests/integration.rs | 6 ++++++ tests/performance_agent_integration.rs | 1 + 2 files changed, 7 insertions(+) diff --git a/tests/integration.rs b/tests/integration.rs index ce9cd0583..121b167e1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -166,6 +166,7 @@ impl ToolExecutor for OutputToolExecutor { summary: self.output.clone(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -179,6 +180,7 @@ impl ToolExecutor for EmptyOutputToolExecutor { summary: String::new(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -192,6 +194,7 @@ impl ToolExecutor for ErrorOutputToolExecutor { summary: "[error] command failed".into(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -221,6 +224,7 @@ impl ToolExecutor for ConfirmToolExecutor { summary: "confirmed output".into(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -255,6 +259,7 @@ impl ToolExecutor for ExitCodeToolExecutor { summary: "[exit code 1] process failed".into(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } @@ -2160,6 +2165,7 @@ mod self_learning { summary: "[error] command failed".into(), blocks_executed: 1, filter_stats: None, + diff: None, })) } } diff --git a/tests/performance_agent_integration.rs b/tests/performance_agent_integration.rs index 657bb7c91..6417c9303 100644 --- a/tests/performance_agent_integration.rs +++ b/tests/performance_agent_integration.rs @@ -101,6 +101,7 @@ impl ToolExecutor for InstrumentedMockExecutor { summary: "$ echo test\ntest".to_string(), blocks_executed: 1, filter_stats: None, + diff: None, })) } else { Ok(None)