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:
ZacharyZhang-NY committed 2026-09-26 11:39:24 -04:00
1 parent 061be8a0ff
commit 37e53a5ecb
20 files changed
+934 -180

No files matched your search

+4 -9
View File
@@ -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}: {}",
+203
View File
@@ -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)
}
}
+58
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -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};
+2 -3
View File
@@ -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 {
+96
View File
@@ -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);
}
+1
View File
@@ -4,6 +4,7 @@ use gpui::{
};
mod changes;
mod control;
mod environment;
mod previews;