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:
1 parent
859a9051d8
commit
0a44a2d905
9 files changed
+591
-26
No files matched your search
@@ -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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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 {
|
||||
Reference in new issue
Block a user