T24c: Agent, changes

- FileChangeCard: a proposed change to one file, its path and counts,
  Reject and Accept in a slot that keeps presses off the header; opened,
  the diff through a headless git::DiffViewer with its folds opening
- MultiFileDiffReview: every file at once, the total counts, how many are
  reviewed, Reject all and Accept all; verdicts as collab::Decision
- git::DiffViewer gains headless, leaving the file's header to its owner
- CommandExecutionCard is terminal::CommandBlock
- gallery: three proposed files and a command's block
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 08:37:45 -04:00
1 parent 859a9051d8
commit 0a44a2d905
9 files changed
+591 -26

No files matched your search

+93
View File
@@ -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<FileChange> {
[
("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<FileChange>, decision: Decision) -> Vec<FileChange> {
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) },
)),
)
}
+10 -1
View File
@@ -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()
}
+311
View File
@@ -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<dyn Fn(bool, &mut Window, &mut App)>;
type OnDecision = Rc<dyn Fn(Decision, &mut Window, &mut App)>;
/// 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<OnVerdict>,
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<OnVerdict>,
}
impl FileChangeCard {
pub fn new(id: impl Into<ElementId>, 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<usize>, 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<FileChange>,
on_decide: Option<OnDecision>,
}
impl MultiFileDiffReview {
pub fn new(id: impl Into<ElementId>, changes: impl IntoIterator<Item = FileChange>) -> 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,
}
}))
}
}
+2
View File
@@ -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};
+137
View File
@@ -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<bool>,
height: Rc<Cell<Pixels>>,
}
impl Render for Card {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Decision>,
}
impl Render for Review {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Review>, &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)]
);
}
@@ -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 {
+1
View File
@@ -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,
+30 -20
View File
@@ -78,6 +78,7 @@ pub struct DiffViewer {
open: Vec<usize>,
on_open: Option<OnIndex>,
on_layout: Option<OnLayout>,
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()
+3 -3
View File
@@ -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