diff --git a/examples/gallery/pages/agent/changes.rs b/examples/gallery/pages/agent/changes.rs new file mode 100644 index 0000000..605574a --- /dev/null +++ b/examples/gallery/pages/agent/changes.rs @@ -0,0 +1,93 @@ +use std::time::Duration; + +use ely_gpui_component::{ + agent::{ChangeState, FileChange, MultiFileDiffReview}, + collab::Decision, + terminal::{CommandBlock, CommandState}, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const LIFT_OLD: &str = + "pub fn lift(color: Hsla, by: f32) -> Hsla {\n color.mix(white(), by)\n}\n"; +const LIFT_NEW: &str = "pub fn lift(color: Hsla, by: f32, dark: bool) -> Hsla {\n let by = if dark { by * 1.5 } else { by };\n color.mix(white(), by.min(1.0))\n}\n"; +const TOKENS_OLD: &str = + "pub const ACCENT_LIFT: f32 = 0.12;\npub const SURFACE_LIFT: f32 = 0.04;\n"; +const TOKENS_NEW: &str = + "pub const ACCENT_LIFT: f32 = 0.18;\npub const SURFACE_LIFT: f32 = 0.04;\n"; +const NOTE_NEW: &str = "## 0.9\n\nDark themes lift their accents further.\n"; + +fn changes() -> Vec { + [ + ("src/theme/lift.rs", LIFT_OLD, LIFT_NEW), + ("src/theme/tokens.rs", TOKENS_OLD, TOKENS_NEW), + ("CHANGELOG.md", "", NOTE_NEW), + ] + .into_iter() + .map(|(path, old, new)| FileChange { + path: path.into(), + old: old.into(), + new: new.into(), + state: ChangeState::Proposed, + }) + .collect() +} + +/// `changes` after `decision`. +fn decided(mut changes: Vec, decision: Decision) -> Vec { + let verdict = |accepted| { + if accepted { + ChangeState::Accepted + } else { + ChangeState::Rejected + } + }; + match decision { + Decision::Accept(ix) => changes[ix].state = verdict(true), + Decision::Reject(ix) => changes[ix].state = verdict(false), + Decision::AcceptAll | Decision::RejectAll => { + let state = verdict(decision == Decision::AcceptAll); + for change in changes + .iter_mut() + .filter(|change| change.state == ChangeState::Proposed) + { + change.state = state; + } + } + } + changes +} + +pub fn changes_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let files = keep("agent-changes", changes, window, cx); + let now = files.read(cx).clone(); + section( + "FileChangeCard / MultiFileDiff Review / CommandExecutionCard", + "What an agent would change, file by file: how much moves, the diff a press away, and a verdict each or for all. A command it ran is terminal::CommandBlock.", + cx, + ) + .child( + probe( + "agent-changes", + div().w(px(640.)).child( + MultiFileDiffReview::new("agent-review", now).on_decide(move |decision, _, cx| { + let next = decided(files.read(cx).clone(), decision); + set(&files, next, cx) + }), + ), + ), + ) + .child( + div().w(px(640.)).child(CommandBlock::new( + "agent-command", + "~/ely", + "cargo test --lib theme", + "\u{1b}[32m Compiling\u{1b}[0m ely-gpui-component v0.9.0\n\u{1b}[32m Finished\u{1b}[0m test profile in 8.4s\nrunning 12 tests\ntest theme::lift_keeps_hue ... \u{1b}[32mok\u{1b}[0m\ntest theme::dark_lifts_further ... \u{1b}[32mok\u{1b}[0m\n\ntest result: \u{1b}[32mok\u{1b}[0m. 12 passed; 0 failed", + CommandState::Done { code: 0, took: Duration::from_millis(9_200) }, + )), + ) +} diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs index ce1dfdc..96d28b3 100644 --- a/examples/gallery/pages/agent/mod.rs +++ b/examples/gallery/pages/agent/mod.rs @@ -1,3 +1,4 @@ +mod changes; mod progress; mod tools; @@ -10,7 +11,7 @@ pub const PAGE: Page = Page { number: 24, slug: "agent", title: "Agent", - summary: "An assistant at work: the tools it calls, what it asks before it acts, and how far it has come.", + summary: "An assistant at work: the tools it calls, what it asks before it acts, how far it has come and what it would change.", render, script: SCRIPT, }; @@ -31,6 +32,13 @@ const SCRIPT: &[Step] = &[ Step::Click("agent-advance"), Step::Wait(300), Step::Shot("plan"), + Step::DownAt("agent-changes", 30.0, 51.0), + Step::UpAt("agent-changes", 30.0, 51.0), + Step::Wait(300), + Step::Shot("changes"), + Step::DownAt("agent-changes", 30.0, 51.0), + Step::UpAt("agent-changes", 30.0, 51.0), + Step::Wait(200), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -39,5 +47,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(tools::approval(window, cx)) .child(progress::steps(window, cx)) .child(progress::agents(window, cx)) + .child(changes::changes_section(window, cx)) .into_any_element() } diff --git a/src/agent/changes.rs b/src/agent/changes.rs new file mode 100644 index 0000000..30c45fe --- /dev/null +++ b/src/agent/changes.rs @@ -0,0 +1,311 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, transparent_black, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + chat::file_icon, + collab::Decision, + git::{DiffStat, DiffViewer, stat}, + primitives::{Disclosure, FocusRing, Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// Where a proposed change stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChangeState { + Proposed, + Accepted, + Rejected, +} + +/// A change an agent proposes to one file: its path, the text before and after, and where it stands. +#[derive(Clone, Debug, PartialEq)] +pub struct FileChange { + pub path: SharedString, + pub old: SharedString, + pub new: SharedString, + pub state: ChangeState, +} + +type OnVerdict = Rc; +type OnDecision = Rc; + +/// Reject and Accept while the change is proposed; after, a quiet word for how it ended. +fn verdict( + id: &ElementId, + state: ChangeState, + on_decide: Option, + cx: &App, +) -> AnyElement { + let colors = &cx.theme().colors; + let said = |icon: IconName, word: &'static str, color| { + div() + .flex() + .items_center() + .gap_1() + .text_color(color) + .child(Icon::new(icon).size(IconSize::Xs).color(color)) + .child(word) + .into_any_element() + }; + match (state, on_decide) { + (ChangeState::Proposed, Some(decide)) => { + let (reject, accept) = (decide.clone(), decide); + div() + .flex() + .items_center() + .gap_1() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new((id.clone(), "reject"), "Reject") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| reject(false, window, cx)), + ) + .child( + Button::new((id.clone(), "accept"), "Accept") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| accept(true, window, cx)), + ) + .into_any_element() + } + (ChangeState::Proposed, None) => said(IconName::Clock, "Proposed", colors.fg_subtle), + (ChangeState::Accepted, _) => said(IconName::Check, "Accepted", colors.success), + (ChangeState::Rejected, _) => said(IconName::X, "Rejected", colors.fg_subtle), + } +} + +/// One file an agent would change: its path and how much moves, Reject or Accept while proposed; opened, the diff. +#[derive(IntoElement)] +pub struct FileChangeCard { + id: ElementId, + change: FileChange, + on_decide: Option, +} + +impl FileChangeCard { + pub fn new(id: impl Into, change: FileChange) -> Self { + Self { + id: id.into(), + change, + on_decide: None, + } + } + + /// `on_decide` gets true to accept, false to reject. + pub fn on_decide(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_decide = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FileChangeCard { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let folds = window.use_keyed_state((self.id.clone(), "folds"), cx, |_, _| Vec::new()); + let (open, unfolded) = (*state.read(cx), folds.read(cx).clone()); + let (added, removed) = stat(&self.change.old, &self.change.new); + let path = self.change.path.clone(); + let decide = self.on_decide.map(|decide| -> OnVerdict { + Rc::new(move |accepted, window, cx| { + log::info!( + "file change {path}: {}", + if accepted { "accepted" } else { "rejected" } + ); + decide(accepted, window, cx) + }) + }); + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .w_full() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .id((self.id.clone(), "header")) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(transparent_black()) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + state.update(cx, |open, cx| { + *open = !*open; + log::info!("file change: open {open}"); + cx.notify(); + }) + }) + .child(Disclosure::new((self.id.clone(), "chevron"), open).size(IconSize::Sm)) + .child( + Icon::new(file_icon(&self.change.path)) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_family(theme.mono_family.clone()) + .child(Ellipsis::new(self.change.path.clone())), + ) + .child(div().flex_none().child(DiffStat::new(added, removed))) + .child(div().flex_none().child(verdict( + &self.id, + self.change.state, + decide, + cx, + ))), + ) + .when(open, |card| { + card.child( + div() + .h(theme.list_max_height()) + .border_t_1() + .border_color(colors.border) + .child( + DiffViewer::new( + (self.id.clone(), "diff"), + self.change.path, + &self.change.old, + &self.change.new, + ) + .headless() + .open(unfolded) + .on_open(move |ix, _, cx| { + folds.update(cx, |folds: &mut Vec, cx| { + folds.push(ix); + cx.notify(); + }) + }), + ), + ) + }) + } +} + +/// Every file an agent would change, reviewed together: how many and how much, all at once or a card each. +#[derive(IntoElement)] +pub struct MultiFileDiffReview { + id: ElementId, + changes: Vec, + on_decide: Option, +} + +impl MultiFileDiffReview { + pub fn new(id: impl Into, changes: impl IntoIterator) -> Self { + Self { + id: id.into(), + changes: changes.into_iter().collect(), + on_decide: None, + } + } + + /// `on_decide` gets one file's verdict, by index, or all of them. + pub fn on_decide( + mut self, + handler: impl Fn(Decision, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_decide = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MultiFileDiffReview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (added, removed) = self + .changes + .iter() + .fold((0, 0), |(added, removed), change| { + let (more, less) = stat(&change.old, &change.new); + (added + more, removed + less) + }); + let open = self + .changes + .iter() + .filter(|change| change.state == ChangeState::Proposed) + .count(); + let count = self.changes.len(); + let all = self.on_decide.clone().filter(|_| open > 0).map(|decide| { + let (reject, accept) = (decide.clone(), decide); + div() + .flex() + .items_center() + .gap_1() + .child( + Button::new((self.id.clone(), "reject-all"), "Reject all") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("file changes: reject all"); + reject(Decision::RejectAll, window, cx) + }), + ) + .child( + Button::new((self.id.clone(), "accept-all"), "Accept all") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("file changes: accept all"); + accept(Decision::AcceptAll, window, cx) + }), + ) + }); + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .flex_none() + .font_weight(FontWeight::MEDIUM) + .child(format!("{count} files changed")), + ) + .child(div().flex_none().child(DiffStat::new(added, removed))) + .child(div().flex_1().min_w_0().text_color(colors.fg_subtle).child( + Ellipsis::new(format!("{} of {count} reviewed", count - open)), + )) + .children(all), + ) + .children(self.changes.into_iter().enumerate().map(|(ix, change)| { + let card = FileChangeCard::new((self.id.clone(), format!("file-{ix}")), change); + match self.on_decide.clone() { + Some(decide) => card.on_decide(move |accepted, window, cx| { + let decision = if accepted { + Decision::Accept(ix) + } else { + Decision::Reject(ix) + }; + decide(decision, window, cx) + }), + None => card, + } + })) + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index fbc04cc..4e54e06 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,9 +1,11 @@ +mod changes; mod permission; mod progress; #[cfg(all(test, feature = "test-support"))] mod tests; mod tools; +pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview}; pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog}; pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList}; pub use tools::{ToolCallCard, ToolCallGroup}; diff --git a/src/agent/tests/changes.rs b/src/agent/tests/changes.rs new file mode 100644 index 0000000..92d9fa9 --- /dev/null +++ b/src/agent/tests/changes.rs @@ -0,0 +1,137 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + Context, Entity, IntoElement, KeyBinding, Modifiers, ParentElement, Pixels, Render, Styled, + TestAppContext, VisualTestContext, Window, canvas, div, point, px, +}; + +use super::{press, settle}; +use crate::{ + agent::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview}, + collab::Decision, + forms, + primitives::FocusNext, + theme::Theme, +}; + +fn change(path: &str) -> FileChange { + FileChange { + path: path.to_string().into(), + old: "let lift = 0.12;\n".into(), + new: "let lift = 0.18;\n".into(), + state: ChangeState::Proposed, + } +} + +fn setup(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); +} + +/// One change card in a 400px column, its verdicts and the column's height. +struct Card { + verdicts: Vec, + height: Rc>, +} + +impl Render for Card { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (view, height) = (cx.entity(), self.height.clone()); + div() + .relative() + .w(px(400.0)) + .child( + FileChangeCard::new("card", change("src/theme/lift.rs")).on_decide( + move |accepted, _, cx| view.update(cx, |card, _| card.verdicts.push(accepted)), + ), + ) + .child( + canvas( + move |bounds, _, _| height.set(bounds.size.height), + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + } +} + +#[gpui::test] +fn a_press_on_accept_decides_and_leaves_the_card_shut(cx: &mut TestAppContext) { + setup(cx); + let height = Rc::new(Cell::new(Pixels::ZERO)); + let seen = height.clone(); + let (view, cx) = cx.add_window_view(|_, _| Card { + verdicts: Vec::new(), + height: seen, + }); + settle(cx); + let shut = height.get(); + let accept = point(px(400.0 - 16.0), shut / 2.0); + cx.simulate_mouse_move(accept, None, Modifiers::none()); + cx.simulate_click(accept, Modifiers::none()); + settle(cx); + assert_eq!(view.read_with(cx, |card, _| card.verdicts.clone()), [true]); + assert_eq!(height.get(), shut, "the press stays with the button"); + let chevron = point(px(14.0), shut / 2.0); + cx.simulate_mouse_move(chevron, None, Modifiers::none()); + cx.simulate_click(chevron, Modifiers::none()); + settle(cx); + assert!(height.get() > shut, "the header opens the diff"); +} + +/// Three proposed changes under review, and the decisions made. +struct Review { + decisions: Vec, +} + +impl Render for Review { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + div().w(px(560.0)).child( + MultiFileDiffReview::new("review", ["a.rs", "b.rs", "c.rs"].map(change)).on_decide( + move |decision, _, cx| view.update(cx, |review, _| review.decisions.push(decision)), + ), + ) + } +} + +fn review(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Review { + decisions: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, cx) +} + +#[gpui::test] +fn accept_all_decides_every_file(cx: &mut TestAppContext) { + let (view, cx) = review(cx); + cx.update(|window, _| window.focus_next()); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!( + view.read_with(cx, |review, _| review.decisions.clone()), + [Decision::AcceptAll] + ); +} + +#[gpui::test] +fn a_cards_verdict_names_its_file(cx: &mut TestAppContext) { + let (view, cx) = review(cx); + for _ in 0..8 { + cx.update(|window, _| window.focus_next()); + } + press("enter", cx); + assert_eq!( + view.read_with(cx, |review, _| review.decisions.clone()), + [Decision::Accept(1)] + ); +} diff --git a/src/agent/tests.rs b/src/agent/tests/mod.rs similarity index 97% rename from src/agent/tests.rs rename to src/agent/tests/mod.rs index dcf52ec..601e41f 100644 --- a/src/agent/tests.rs +++ b/src/agent/tests/mod.rs @@ -3,6 +3,8 @@ use gpui::{ TestAppContext, VisualTestContext, Window, div, }; +mod changes; + use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog}; use crate::{forms, primitives::FocusNext, theme::Theme}; @@ -32,14 +34,14 @@ impl Render for Asking { } } -fn settle(cx: &mut VisualTestContext) { +pub(super) fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); cx.update(|window, _| window.refresh()); cx.run_until_parked(); } /// Presses and releases `key`, a frame apart. -fn press(key: &str, cx: &mut VisualTestContext) { +pub(super) fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); settle(cx); cx.simulate_event(KeyUpEvent { diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 2ff2256..6704e7c 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -28,6 +28,7 @@ pub use code::CodeBlock; pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton}; pub use conversations::{Conversation, ConversationItem, ConversationList, new_chat_button}; pub use list::{DateSeparator, MessageList, ScrollToBottomButton}; +pub(crate) use media::file_icon; pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard}; pub use message::{ ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role, diff --git a/src/git/viewer.rs b/src/git/viewer.rs index 70a5fc7..9a1b22a 100644 --- a/src/git/viewer.rs +++ b/src/git/viewer.rs @@ -78,6 +78,7 @@ pub struct DiffViewer { open: Vec, on_open: Option, on_layout: Option, + headless: bool, } impl DiffViewer { @@ -96,9 +97,16 @@ impl DiffViewer { open: Vec::new(), on_open: None, on_layout: None, + headless: false, } } + /// Leaves the file's header, its path, counts and layout, to the owner, as a change card draws it. + pub fn headless(mut self) -> Self { + self.headless = true; + self + } + pub fn layout(mut self, layout: DiffLayout) -> Self { self.layout = layout; self @@ -308,26 +316,28 @@ impl RenderOnce for DiffViewer { .flex() .flex_col() .text_size(theme.text_size(TextSize::Sm)) - .child( - div() - .flex() - .items_center() - .gap_3() - .px_2() - .py_1p5() - .border_b_1() - .border_color(colors.border) - .child( - div() - .flex_1() - .min_w_0() - .font_weight(FontWeight::MEDIUM) - .text_color(colors.fg) - .child(self.path), - ) - .child(DiffStat::new(self.stat.0, self.stat.1)) - .child(layout), - ) + .when(!self.headless, |view| { + view.child( + div() + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(self.path), + ) + .child(DiffStat::new(self.stat.0, self.stat.1)) + .child(layout), + ) + }) .child( div() .flex_1() diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 4aa3219..a73d643 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -179,9 +179,9 @@ ## T24c Agent · Changes -- [ ] FileChangeCard -- [ ] MultiFileDiff Review -- [ ] CommandExecutionCard +- [x] FileChangeCard +- [x] MultiFileDiff Review → `agent::MultiFileDiffReview`, verdicts as `collab::Decision` +- [x] CommandExecutionCard → `terminal::CommandBlock` ## T24d Agent · Previews