diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 28245944e39deca7fb2b3f86902f114420d31d20..3faf767c7020763eadc7db6c93af42f650a07434 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -1028,6 +1028,11 @@ impl ActionLog { .collect() } + /// Returns the total number of lines added and removed across all unreviewed buffers. + pub fn diff_stats(&self, cx: &App) -> DiffStats { + DiffStats::all_files(&self.changed_buffers(cx), cx) + } + /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers @@ -1044,6 +1049,46 @@ impl ActionLog { } } +#[derive(Default, Debug, Clone, Copy)] +pub struct DiffStats { + pub lines_added: u32, + pub lines_removed: u32, +} + +impl DiffStats { + pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { + let mut stats = DiffStats::default(); + let diff_snapshot = diff.snapshot(cx); + let buffer_snapshot = buffer.snapshot(); + let base_text = diff_snapshot.base_text(); + + for hunk in diff_snapshot.hunks(&buffer_snapshot) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + stats.lines_added += added_rows; + + let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; + let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; + let removed_rows = base_end.saturating_sub(base_start); + stats.lines_removed += removed_rows; + } + + stats + } + + pub fn all_files( + changed_buffers: &BTreeMap, Entity>, + cx: &App, + ) -> Self { + let mut total = DiffStats::default(); + for (buffer, diff) in changed_buffers { + let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + total.lines_added += stats.lines_added; + total.lines_removed += stats.lines_removed; + } + total + } +} + #[derive(Clone)] pub struct ActionLogTelemetry { pub agent_telemetry_id: SharedString, diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 2fd86f9c9d91abb7d5b08bd7a779b93592f2011c..b896741cee26e14ed372480f80d6cf8302db180b 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -5,7 +5,7 @@ use acp_thread::{ UserMessageId, }; use acp_thread::{AgentConnection, Plan}; -use action_log::{ActionLog, ActionLogTelemetry}; +use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::AgentServer; @@ -46,7 +46,7 @@ use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; -use text::{Anchor, ToPoint as _}; +use text::Anchor; use theme::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 771d80f08306838e756a2ea3dd8aa4b378cfd402..d4d23f5a0a0722afc5c588a355a6a9de1b59d194 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -156,43 +156,6 @@ impl ThreadFeedbackState { } } -#[derive(Default, Clone, Copy)] -struct DiffStats { - lines_added: u32, - lines_removed: u32, -} - -impl DiffStats { - fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; - } - - stats - } - - fn all_files(changed_buffers: &BTreeMap, Entity>, cx: &App) -> Self { - let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - total.lines_added += stats.lines_added; - total.lines_removed += stats.lines_removed; - } - total - } -} - pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, } diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2679807388eb6261f9bc32be10c10ed500078b22..ae3a4f0ccb9df6073ae24a9c482b6c56de0ea968 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,6 @@ use crate::{AgentPanel, AgentPanelEvent, NewThread}; use acp_thread::ThreadStatus; +use action_log::DiffStats; use agent::ThreadStore; use agent_client_protocol as acp; use agent_settings::AgentSettings; @@ -73,6 +74,7 @@ struct ActiveThreadInfo { icon: IconName, icon_from_external_svg: Option, is_background: bool, + diff_stats: DiffStats, } impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { @@ -98,6 +100,7 @@ struct ThreadEntry { is_live: bool, is_background: bool, highlight_positions: Vec, + diff_stats: DiffStats, } #[derive(Clone)] @@ -402,6 +405,8 @@ impl Sidebar { } }; + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + ActiveThreadInfo { session_id, title, @@ -409,6 +414,7 @@ impl Sidebar { icon, icon_from_external_svg, is_background, + diff_stats, } }) .collect() @@ -472,6 +478,7 @@ impl Sidebar { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }); } } @@ -497,6 +504,7 @@ impl Sidebar { thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; + thread.diff_stats = info.diff_stats; } } @@ -1171,6 +1179,12 @@ impl Sidebar { .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) .notified(has_notification) + .when(thread.diff_stats.lines_added > 0, |this| { + this.added(thread.diff_stats.lines_added as usize) + }) + .when(thread.diff_stats.lines_removed > 0, |this| { + this.removed(thread.diff_stats.lines_removed as usize) + }) .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) .focused(is_selected) .on_click(cx.listener(move |this, _, window, cx| { @@ -1987,6 +2001,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Running status ListEntry::Thread(ThreadEntry { @@ -2005,6 +2020,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Active thread with Error status ListEntry::Thread(ThreadEntry { @@ -2023,6 +2039,7 @@ mod tests { is_live: true, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Thread with WaitingForConfirmation status, not active ListEntry::Thread(ThreadEntry { @@ -2041,6 +2058,7 @@ mod tests { is_live: false, is_background: false, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // Background thread that completed (should show notification) ListEntry::Thread(ThreadEntry { @@ -2059,6 +2077,7 @@ mod tests { is_live: true, is_background: true, highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 3c08bd946710f76ccf49f933b82091a3bcb06e08..edc685159f5c9edc5fa872e9d453d0b81fa9cb16 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -227,6 +227,12 @@ impl RenderOnce for ThreadItem { .gradient_stop(0.8) .group_name("thread-item"); + let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let added_count = self.added.unwrap_or(0); + let removed_count = self.removed.unwrap_or(0); + let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); + v_flex() .id(self.id.clone()) .group("thread-item") @@ -235,7 +241,7 @@ impl RenderOnce for ThreadItem { .cursor_pointer() .w_full() .map(|this| { - if self.worktree.is_some() { + if has_worktree || has_diff_stats { this.p_2() } else { this.px_2().py_1() @@ -300,35 +306,24 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - // TODO: Uncomment the elements below when we're ready to expose this data - // .child(dot_separator()) - // .child( - // Label::new(self.timestamp) - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // .child( - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5), - // ) - // .when(has_no_changes, |this| { - // this.child( - // Label::new("No Changes") - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // }) - .when(self.added.is_some() || self.removed.is_some(), |this| { + .when(has_diff_stats, |this| { this.child(DiffStat::new( - self.id, - self.added.unwrap_or(0), - self.removed.unwrap_or(0), + diff_stat_id.clone(), + added_count, + removed_count, )) }), ) }) + .when(!has_worktree && has_diff_stats, |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .child(DiffStat::new(diff_stat_id, added_count, removed_count)), + ) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } }