T24f Agent: control and cost
HumanInputRequest asks a question with answers to pick and a line to write one; CostBreakdown totals a session and splits it by part. InterruptButton, TokenUsageChart, TraceViewer and EvalResultTable point to chat::stop_button, a stacked BarChart, TimelineProfiler and DataTable. UsageBar shows amounts as its owner writes them. MemoryPanel and HumanInputRequest share agent::line::send_line; the chapter's bool callbacks take forms::OnFlag. DataTable scrolls columns wider than its box sideways, pinned or not, and leaves a plain wheel to the page; sortable headers are Tab stops. TimelineProfiler fits its ruler to its lane, its spans are Tab stops, and a picked span takes accent.
This commit is contained in:
1 parent
061be8a0ff
commit
37e53a5ecb
20 files changed
+934
-180
No files matched your search
@@ -10,6 +10,7 @@ use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
chat::file_icon,
|
||||
collab::Decision,
|
||||
forms::OnFlag,
|
||||
git::{DiffStat, DiffViewer, stat},
|
||||
primitives::{Disclosure, FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
@@ -33,16 +34,10 @@ pub struct FileChange {
|
||||
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 {
|
||||
fn verdict(id: &ElementId, state: ChangeState, on_decide: Option<OnFlag>, cx: &App) -> AnyElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let said = |icon: IconName, word: &'static str, color| {
|
||||
div()
|
||||
@@ -87,7 +82,7 @@ fn verdict(
|
||||
pub struct FileChangeCard {
|
||||
id: ElementId,
|
||||
change: FileChange,
|
||||
on_decide: Option<OnVerdict>,
|
||||
on_decide: Option<OnFlag>,
|
||||
}
|
||||
|
||||
impl FileChangeCard {
|
||||
@@ -113,7 +108,7 @@ impl RenderOnce for FileChangeCard {
|
||||
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 {
|
||||
let decide = self.on_decide.map(|decide| -> OnFlag {
|
||||
Rc::new(move |accepted, window, cx| {
|
||||
log::info!(
|
||||
"file change {path}: {}",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::line::{OnText, send_line};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::UsageBar,
|
||||
forms::TextInput,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{format::currency, tabular},
|
||||
};
|
||||
|
||||
/// A question an agent waits on: a few answers to pick, a line to write one, and, once answered, the answer.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HumanInputRequest {
|
||||
id: ElementId,
|
||||
question: SharedString,
|
||||
field: Entity<TextInput>,
|
||||
choices: Vec<SharedString>,
|
||||
answered: Option<SharedString>,
|
||||
on_answer: OnText,
|
||||
}
|
||||
|
||||
impl HumanInputRequest {
|
||||
/// `field` is the owner's line for a written answer.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
question: impl Into<SharedString>,
|
||||
field: &Entity<TextInput>,
|
||||
on_answer: impl Fn(&str, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
question: question.into(),
|
||||
field: field.clone(),
|
||||
choices: Vec::new(),
|
||||
answered: None,
|
||||
on_answer: Rc::new(on_answer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers to pick with a press.
|
||||
pub fn choices(mut self, choices: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.choices = choices.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// The answer given; the request stops asking.
|
||||
pub fn answered(mut self, answer: impl Into<SharedString>) -> Self {
|
||||
self.answered = Some(answer.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for HumanInputRequest {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let asking = self.answered.is_none();
|
||||
let choices = (asking && !self.choices.is_empty()).then(|| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.children(self.choices.iter().enumerate().map(|(ix, choice)| {
|
||||
let (answer, picked) = (self.on_answer.clone(), choice.clone());
|
||||
Button::new((self.id.clone(), format!("choice-{ix}")), choice.clone())
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("human input: picked {ix}");
|
||||
answer(&picked, window, cx)
|
||||
})
|
||||
}))
|
||||
});
|
||||
let line = asking.then(|| {
|
||||
send_line(
|
||||
&self.field,
|
||||
Button::new((self.id.clone(), "reply"), "Reply").variant(ButtonVariant::Primary),
|
||||
self.on_answer.clone(),
|
||||
"human input",
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_none().child(
|
||||
Icon::new(IconName::CircleHelp)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg)
|
||||
.child(self.question),
|
||||
),
|
||||
)
|
||||
.children(self.answered.map(|answer| {
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
div().flex_none().child(
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.success),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(answer))
|
||||
}))
|
||||
.children(choices)
|
||||
.children(line)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a session cost, as a whole and part by part: the total, then a bar of each part's share with its amount.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CostBreakdown {
|
||||
label: SharedString,
|
||||
code: &'static str,
|
||||
parts: Vec<(SharedString, f64)>,
|
||||
}
|
||||
|
||||
impl CostBreakdown {
|
||||
/// `code` is the currency, such as "USD".
|
||||
pub fn new(label: impl Into<SharedString>, code: &'static str) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
code,
|
||||
parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn part(mut self, name: impl Into<SharedString>, cost: f64) -> Self {
|
||||
assert!(cost >= 0.0 && cost.is_finite(), "a cost of {cost}");
|
||||
self.parts.push((name.into(), cost));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CostBreakdown {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let total: f64 = self.parts.iter().map(|(_, cost)| cost).sum();
|
||||
let code = self.code;
|
||||
let bar = (total > 0.0).then(|| {
|
||||
self.parts.into_iter().fold(
|
||||
UsageBar::new(total).amounts(move |amount| currency(amount, code)),
|
||||
|bar, (name, cost)| bar.part(name, cost),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_baseline()
|
||||
.justify_between()
|
||||
.gap_x_3()
|
||||
.child(div().text_color(colors.fg_muted).child(self.label))
|
||||
.child(
|
||||
tabular(div())
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child(currency(total, code)),
|
||||
),
|
||||
)
|
||||
.children(bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Div, Entity, InteractiveElement, ParentElement, Styled, Window, div};
|
||||
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
forms::{Enter, Input, TextInput},
|
||||
};
|
||||
|
||||
pub(super) type OnText = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
|
||||
/// Hands the field's words to `send` and empties it; blank words are kept out.
|
||||
fn send(
|
||||
field: &Entity<TextInput>,
|
||||
on_send: &OnText,
|
||||
what: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let text = field.read(cx).text().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("{what}: sent {} characters", text.len());
|
||||
on_send(&text, window, cx);
|
||||
field.update(cx, |input, cx| input.set_text("", cx));
|
||||
}
|
||||
|
||||
/// The owner's field beside `button`: Enter or a press sends its words, and the button waits for some.
|
||||
pub(super) fn send_line(
|
||||
field: &Entity<TextInput>,
|
||||
button: Button,
|
||||
on_send: OnText,
|
||||
what: &'static str,
|
||||
cx: &App,
|
||||
) -> Div {
|
||||
let ready = !field.read(cx).text().trim().is_empty();
|
||||
let (typed, pressed, enter) = (field.clone(), field.clone(), on_send.clone());
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
send(&typed, &enter, what, window, cx)
|
||||
})
|
||||
.child(Input::new(field)),
|
||||
)
|
||||
.child(
|
||||
button
|
||||
.disabled(!ready)
|
||||
.on_click(move |_, window, cx| send(&pressed, &on_send, what, window, cx)),
|
||||
)
|
||||
}
|
||||
+12
-39
@@ -1,15 +1,16 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, prelude::*,
|
||||
App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use super::line::{OnText, send_line};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
data_display::{Timeline, TimelineItem, Tone},
|
||||
forms::{Enter, Input, Pick, TextInput},
|
||||
forms::{Pick, TextInput},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
typography::{RelativeTime, format::plural},
|
||||
@@ -24,7 +25,6 @@ pub struct Memory {
|
||||
pub at: Timestamp,
|
||||
}
|
||||
|
||||
type OnAdd = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
type OnForget = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// What an agent keeps between conversations: a field to add a memory, then each one with where it came from, when, and a way to forget it.
|
||||
@@ -33,7 +33,7 @@ pub struct MemoryPanel {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
memories: Vec<Memory>,
|
||||
on_add: Option<OnAdd>,
|
||||
on_add: Option<OnText>,
|
||||
on_forget: Option<OnForget>,
|
||||
}
|
||||
|
||||
@@ -69,46 +69,19 @@ impl MemoryPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the field's words to `add` and empties it; blank words are kept out.
|
||||
fn keep(field: &Entity<TextInput>, add: &OnAdd, window: &mut Window, cx: &mut App) {
|
||||
let text = field.read(cx).text().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("memory: kept {} characters", text.len());
|
||||
add(&text, window, cx);
|
||||
field.update(cx, |input, cx| input.set_text("", cx));
|
||||
}
|
||||
|
||||
impl RenderOnce for MemoryPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let ready = !self.field.read(cx).text().trim().is_empty();
|
||||
let count = self.memories.len();
|
||||
let adding = self.on_add.map(|add| {
|
||||
let (enter, press, typed, pressed) =
|
||||
(add.clone(), add, self.field.clone(), self.field.clone());
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
keep(&typed, &enter, window, cx)
|
||||
})
|
||||
.child(Input::new(&self.field)),
|
||||
)
|
||||
.child(
|
||||
Button::new((self.id.clone(), "add"), "Remember")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.disabled(!ready)
|
||||
.on_click(move |_, window, cx| keep(&pressed, &press, window, cx)),
|
||||
)
|
||||
send_line(
|
||||
&self.field,
|
||||
Button::new((self.id.clone(), "add"), "Remember").variant(ButtonVariant::Secondary),
|
||||
add,
|
||||
"memory",
|
||||
cx,
|
||||
)
|
||||
});
|
||||
div()
|
||||
.w_full()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod artifact;
|
||||
mod changes;
|
||||
mod control;
|
||||
mod environment;
|
||||
mod line;
|
||||
mod memory;
|
||||
mod permission;
|
||||
mod previews;
|
||||
@@ -12,6 +14,7 @@ mod tools;
|
||||
|
||||
pub use artifact::ArtifactPanel;
|
||||
pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview};
|
||||
pub use control::{CostBreakdown, HumanInputRequest};
|
||||
pub use environment::{McpServer, McpServerList, SandboxState, SandboxStatus, ServerState};
|
||||
pub use memory::{Checkpoint, CheckpointList, Memory, MemoryPanel};
|
||||
pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog};
|
||||
|
||||
@@ -8,13 +8,12 @@ use gpui::{
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
chat::CodeBlock,
|
||||
forms::OnFlag,
|
||||
overlays::Dialog,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnAnswer = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// Asks before a tool runs: what it will do and its arguments, then Deny or Run. Escape, the scrim and Deny refuse it. Render it while the call waits.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ToolApprovalDialog {
|
||||
@@ -22,7 +21,7 @@ pub struct ToolApprovalDialog {
|
||||
tool: SharedString,
|
||||
arguments: SharedString,
|
||||
detail: Option<SharedString>,
|
||||
on_answer: OnAnswer,
|
||||
on_answer: OnFlag,
|
||||
}
|
||||
|
||||
impl ToolApprovalDialog {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{press, settle};
|
||||
use crate::{
|
||||
agent::{CostBreakdown, HumanInputRequest},
|
||||
forms::{self, TextInput},
|
||||
primitives::FocusNext,
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// A question with two answers to pick, and the answers given.
|
||||
struct Asked {
|
||||
field: Entity<TextInput>,
|
||||
answers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Asked {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
div().w(px(420.0)).child(
|
||||
HumanInputRequest::new(
|
||||
"asked",
|
||||
"Keep the accents?",
|
||||
&self.field,
|
||||
move |answer, _, cx| {
|
||||
view.update(cx, |asked, _| asked.answers.push(answer.to_string()))
|
||||
},
|
||||
)
|
||||
.choices(["Keep them", "Lift further"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn asked(cx: &mut TestAppContext) -> (Entity<Asked>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|window, cx| Asked {
|
||||
field: cx.new(|cx| TextInput::new(window, cx)),
|
||||
answers: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_pick_answers_the_question(cx: &mut TestAppContext) {
|
||||
let (view, cx) = asked(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
view.read_with(cx, |asked, _| asked.answers.clone()),
|
||||
["Lift further"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_sends_a_written_answer_and_skips_a_blank_one(cx: &mut TestAppContext) {
|
||||
let (view, cx) = asked(cx);
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.focus_next());
|
||||
}
|
||||
cx.simulate_input(" ");
|
||||
press("enter", cx);
|
||||
cx.simulate_input("Lift them to 18%");
|
||||
press("enter", cx);
|
||||
view.read_with(cx, |asked, cx| {
|
||||
assert_eq!(asked.answers, ["Lift them to 18%"]);
|
||||
assert_eq!(asked.field.read(cx).text(), "");
|
||||
});
|
||||
}
|
||||
|
||||
/// A breakdown of a session that has cost nothing yet.
|
||||
struct Nothing;
|
||||
|
||||
impl Render for Nothing {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(320.0))
|
||||
.child(CostBreakdown::new("This session", "USD"))
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_session_that_cost_nothing_draws_its_total(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Nothing);
|
||||
settle(cx);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
mod changes;
|
||||
mod control;
|
||||
mod environment;
|
||||
mod previews;
|
||||
|
||||
|
||||
Reference in new issue
Block a user