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 96c4fe01a6
commit a29cc8bc45
20 files changed
+934 -180

No files matched your search

+1
View File
@@ -137,6 +137,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space.
- A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below.
- Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box.
- gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). An x-only scroll box also turns a plain wheel sideways unless `restrict_scroll_to_axis` is set through `style()`; a table's sets it, so the page keeps the wheel.
- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::glyphs` and `lists::ListItem` do.
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture.
+175
View File
@@ -0,0 +1,175 @@
use std::rc::Rc;
use ely_gpui_component::{
agent::{AgentState, AgentStatus, CostBreakdown, HumanInputRequest},
charts::{BarChart, Series},
chat::{continue_button, stop_button},
data_display::Tone,
debug::{Span, TimelineProfiler},
forms::TextInput,
tables::{Cell, Column, DataTable, Row},
};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px, rems};
use crate::{
probe::probe,
ui::{keep, section, set},
};
pub fn interrupt_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep("agent-run", || AgentState::Working, window, cx);
let answer = keep("agent-answer", || None::<String>, window, cx);
let field = window.use_keyed_state("agent-answer-field", cx, |window, cx| {
TextInput::new(window, cx).placeholder("Or write an answer")
});
let (now_state, now_answer) = (*state.read(cx), answer.read(cx).clone());
let (stop, go) = (state.clone(), state);
let control = match now_state {
AgentState::Working => {
stop_button("agent-stop").on_click(move |_, _, cx| set(&stop, AgentState::Waiting, cx))
}
_ => continue_button("agent-stop")
.on_click(move |_, _, cx| set(&go, AgentState::Working, cx)),
};
let request = HumanInputRequest::new(
"agent-ask",
"The dark accents read 4.1:1 on the card. Lift them to pass 4.5:1, or keep the brand color?",
&field,
move |text, _, cx| set(&answer, Some(text.to_string()), cx),
)
.choices(["Lift to 4.5:1", "Keep the brand color"]);
let request = match now_answer {
Some(text) => request.answered(text),
None => request,
};
section(
"InterruptButton / HumanInputRequest",
"A way to stop an agent mid-task and pick it up again; and a question it waits on, with answers to pick or a line to write one. Once answered, the card keeps the answer.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_3()
.child(AgentStatus::new("agent-run-status", now_state))
.child(probe("agent-stop", control)),
)
.child(probe("agent-ask", div().w(px(460.)).child(request)))
}
pub fn cost_section(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let turns = ["Turn 1", "Turn 2", "Turn 3", "Turn 4", "Turn 5", "Turn 6"];
section(
"TokenUsageChart / CostBreakdown",
"Tokens each turn spent, split into what the model read, what it wrote and what came from the cache; and what the session cost, part by part.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_start()
.gap_8()
.child(
div().w(px(420.)).child(
BarChart::new("agent-tokens", turns)
.series(Series::new("Input", [3_200., 8_400., 12_800., 6_100., 15_300., 9_700.]))
.series(Series::new("Output", [900., 2_100., 3_400., 1_200., 4_800., 2_600.]))
.series(Series::new("Cached", [0., 2_800., 7_900., 5_400., 11_200., 8_300.]))
.stacked(),
),
)
.child(
div().w(px(320.)).child(
CostBreakdown::new("This session", "USD")
.part("Input", 0.42)
.part("Output", 1.18)
.part("Cache reads", 0.07)
.part("Tools", 0.25),
),
),
)
}
fn trace() -> Rc<Vec<Span>> {
let span = |track, name: &str, start, end| Span {
track,
name: name.to_string().into(),
start,
end,
};
Rc::new(vec![
span(0, "lift the accents", 0.0, 4_200.0),
span(1, "turn 1", 0.0, 2_600.0),
span(1, "turn 2", 2_600.0, 4_200.0),
span(2, "plan", 0.0, 900.0),
span(2, "read_file", 950.0, 1_100.0),
span(2, "edit", 1_150.0, 2_600.0),
span(2, "run_tests", 2_650.0, 3_900.0),
span(2, "answer", 3_950.0, 4_200.0),
])
}
fn evals() -> Vec<Row> {
let row = |case: &'static str, pass: bool, score: f32, tokens: f64, seconds: f64| {
let (word, tone) = match pass {
true => ("Pass", Tone::Success),
false => ("Fail", Tone::Danger),
};
Row::new(
case,
[
case.into(),
Cell::Tag(word.into(), tone),
Cell::Progress(score),
Cell::Number(tokens),
Cell::Number(seconds),
],
)
};
vec![
row("Fix a failing test", true, 0.96, 18_400., 42.1),
row("Rename across files", true, 0.91, 9_800., 18.6),
row("Add a settings page", false, 0.48, 31_200., 96.4),
row("Explain a stack trace", true, 0.88, 4_100., 7.9),
row("Migrate a schema", false, 0.62, 22_700., 71.3),
]
}
pub fn trace_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let range = keep("agent-trace-range", || (0.0_f64, 4_600.0_f64), window, cx);
let picked = keep("agent-trace-span", || Some(5_usize), window, cx);
let (now_range, now_picked) = (*range.read(cx), *picked.read(cx));
let timeline = TimelineProfiler::new(
"agent-trace",
["Session", "Turn", "Call"],
trace(),
now_range,
)
.on_range(move |next, _, cx| set(&range, next, cx))
.on_select(move |ix, _, cx| set(&picked, Some(ix), cx));
let timeline = match now_picked {
Some(ix) => timeline.selected(ix),
None => timeline,
};
let columns = vec![
Column::new("case", "Case"),
Column::new("result", "Result").width(rems(6.)),
Column::new("score", "Score").width(rems(10.)),
Column::new("tokens", "Tokens").width(rems(6.)).end(),
Column::new("time", "Time")
.width(rems(6.))
.end()
.decimals(1)
.suffix(" s"),
];
section(
"TraceViewer / EvalResultTable",
"A run as spans on a track for each depth: the session, its turns, and the calls in each; the wheel zooms and a press picks a span. Then how an agent did on a set of cases, sortable by any column.",
cx,
)
.child(div().w(px(760.)).child(timeline))
.child(div().w(px(760.)).child(DataTable::new("agent-evals", columns).rows(evals())))
}
+11
View File
@@ -1,4 +1,5 @@
mod changes;
mod control;
mod environment;
mod previews;
mod progress;
@@ -44,6 +45,13 @@ const SCRIPT: &[Step] = &[
Step::Click("agent-pointer-next"),
Step::Wait(400),
Step::Shot("pointer"),
Step::Click("agent-stop"),
Step::Wait(300),
Step::Shot("interrupted"),
Step::DownAt("agent-ask", 57.0, 79.0),
Step::UpAt("agent-ask", 57.0, 79.0),
Step::Wait(300),
Step::Shot("answered"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
@@ -58,5 +66,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(environment::sandbox_section(window, cx))
.child(environment::connectors_section(window, cx))
.child(environment::memory_section(window, cx))
.child(control::interrupt_section(window, cx))
.child(control::cost_section(window, cx))
.child(control::trace_section(window, cx))
.into_any_element()
}
+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;
+32 -3
View File
@@ -1,4 +1,7 @@
use std::f32::consts::{FRAC_PI_4, TAU};
use std::{
f32::consts::{FRAC_PI_4, TAU},
rc::Rc,
};
use gpui::{
Animation, AnimationExt, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement,
@@ -282,11 +285,14 @@ impl RenderOnce for Gauge {
}
}
/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share.
type Amounts = Rc<dyn Fn(f64) -> String>;
/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share or amount.
#[derive(IntoElement)]
pub struct UsageBar {
total: f64,
parts: Vec<(SharedString, f64)>,
amounts: Option<Amounts>,
}
impl UsageBar {
@@ -299,9 +305,16 @@ impl UsageBar {
Self {
total,
parts: Vec::new(),
amounts: None,
}
}
/// Shows each part's amount, as `write` puts it, in place of its share.
pub fn amounts(mut self, write: impl Fn(f64) -> String + 'static) -> Self {
self.amounts = Some(Rc::new(write));
self
}
pub fn part(mut self, name: impl Into<SharedString>, amount: f64) -> Self {
assert!(amount >= 0.0, "a part cannot be negative, got {amount}");
self.parts.push((name.into(), amount));
@@ -320,6 +333,14 @@ impl UsageBar {
);
(used, used >= self.total * (1.0 - 1e-9))
}
/// A part's figure in the legend: its amount as `amounts` writes it, or its share.
fn figure(&self, amount: f64) -> String {
match &self.amounts {
Some(write) => write(amount),
None => format::percent(amount / self.total, 0, false),
}
}
}
impl RenderOnce for UsageBar {
@@ -372,7 +393,7 @@ impl RenderOnce for UsageBar {
.child(
tabular(div())
.text_color(colors.fg_muted)
.child(format::percent(amount / total, 0, false)),
.child(self.figure(*amount)),
)
});
div().flex().flex_col().gap_2().child(bar).child(
@@ -398,6 +419,14 @@ mod tests {
assert!(!UsageBar::new(1.0).part("a", 0.5).used().1);
}
#[test]
fn a_part_reads_as_its_share_or_as_written() {
let bar = UsageBar::new(4.0).part("a", 1.0);
assert_eq!(bar.figure(1.0), "25%");
let bar = bar.amounts(|amount| format!("{amount} GB"));
assert_eq!(bar.figure(1.0), "1 GB");
}
#[test]
fn a_share_turns_amber_then_red() {
let limits = (0.8, 0.95);
+46 -3
View File
@@ -1,11 +1,11 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, Modifiers, ParentElement, Render, Styled, TestAppContext,
VisualTestContext, Window, div, point, px,
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, Styled,
TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{Breakpoint, BreakpointList, Disassembly, Instruction};
use super::{Breakpoint, BreakpointList, Disassembly, Instruction, Span, TimelineProfiler};
use crate::theme::Theme;
fn settle(cx: &mut VisualTestContext) {
@@ -95,3 +95,46 @@ fn a_breakpoints_box_toggles_without_opening_it(cx: &mut TestAppContext) {
settle(cx);
assert_eq!(*opens.borrow(), [0], "the row still opens");
}
/// Two spans on one track; it keeps the spans picked.
struct Spans(Rc<RefCell<Vec<usize>>>);
impl Render for Spans {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let store = self.0.clone();
let span = |name: &str, start, end| Span {
track: 0,
name: name.to_string().into(),
start,
end,
};
let spans = vec![span("a", 0.0, 4.0), span("b", 5.0, 9.0)];
div().w(px(400.0)).child(
TimelineProfiler::new("spans", ["Main"], spans, (0.0, 10.0))
.on_select(move |ix, _, _| store.borrow_mut().push(ix)),
)
}
}
#[gpui::test]
fn tab_reaches_each_span_and_enter_picks_it(cx: &mut TestAppContext) {
cx.update(Theme::init);
let picked = Rc::new(RefCell::new(Vec::new()));
let store = picked.clone();
let (_, cx) = cx.add_window_view(|_, _| Spans(store));
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| window.focus_next());
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter");
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").expect("a key"),
});
settle(cx);
assert_eq!(
*picked.borrow(),
[1],
"the second Tab stands on the second span"
);
}
+53 -29
View File
@@ -1,14 +1,15 @@
use std::{cell::Cell, rc::Rc};
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton,
App, Bounds, ElementId, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, ScrollWheelEvent, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*, relative,
Styled, Window, div, prelude::*, relative,
};
use super::OnIndex;
use crate::{
charts::nice_step,
charts::{measure, nice_step},
primitives::FocusRing,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, LEADING, format::decimals},
};
@@ -30,9 +31,26 @@ fn tick_label(time: f64, step: f64) -> String {
format!("{time:.*} ms", decimals(step))
}
/// Ticks along the ruler, about.
/// Ticks along the ruler, at most.
const TICKS: usize = 6;
/// The ruler's step and ticks: as many as leave `room` for each label across `width`, each with room before the end.
fn ruler(range: (f64, f64), width: f32, room: f32) -> (f64, Vec<f64>) {
let length = range.1 - range.0;
let across = move |span: f64| (span / length) as f32 * width;
let step = (2..=TICKS)
.rev()
.map(|count| nice_step(length, count))
.find(|step| across(*step) >= room)
.unwrap_or_else(|| nice_step(length, 1));
let first = (range.0 / step).ceil() as i64;
let ticks = (first..)
.map(|n| n as f64 * step)
.take_while(|time| across(range.1 - time) >= room)
.collect();
(step, ticks)
}
/// `range` scaled by `factor` around the point at `at` across it, from 0 to 1; above 1 widens, below narrows.
pub fn zoomed(range: (f64, f64), at: f64, factor: f64) -> (f64, f64) {
assert!(
@@ -108,6 +126,8 @@ impl TimelineProfiler {
impl RenderOnce for TimelineProfiler {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let lane: Entity<Bounds<Pixels>> =
window.use_keyed_state((self.id.clone(), "lane"), cx, |_, _| Bounds::default());
let theme = cx.theme();
let colors = theme.colors.clone();
let line = theme
@@ -116,14 +136,10 @@ impl RenderOnce for TimelineProfiler {
let (from, to) = self.range;
let length = to - from;
let at = move |time: f64| ((time - from) / length) as f32;
let step = nice_step(length, TICKS);
let first = (from / step).ceil() as i64;
let ticks: Vec<f64> = (first..)
.map(|n| n as f64 * step)
.take_while(|time| *time < to)
.collect();
let lane = Rc::new(Cell::new(Bounds::<Pixels>::default()));
let (measure, wheel_lane) = (lane.clone(), lane);
let room = (theme.label_width() * 0.4).to_pixels(window.rem_size());
let width = lane.read(cx).size.width;
let (step, ticks) = ruler(self.range, f32::from(width), f32::from(room));
let wheel_lane = lane.clone();
let on_range = self.on_range.clone();
let label = theme.label_width() * 0.6;
let row = theme.control_height(ControlSize::Md);
@@ -166,7 +182,7 @@ impl RenderOnce for TimelineProfiler {
let Some(on_range) = &on_range else {
return;
};
let bounds = wheel_lane.get();
let bounds = *wheel_lane.read(cx);
let delta = event.delta.pixel_delta(line);
let (x, y) = (f64::from(f32::from(delta.x)), f64::from(f32::from(delta.y)));
cx.stop_propagation();
@@ -208,17 +224,19 @@ impl RenderOnce for TimelineProfiler {
.bg(if lit { color } else { color.opacity(0.55) })
.border_1()
.border_color(if lit {
colors.focus
colors.accent
} else {
color.opacity(0.0)
})
.text_color(colors.fg)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.when_some(pick, |bar, pick| {
bar.on_click(move |_, window, cx| pick(ix, window, cx))
bar.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_click(move |_, window, cx| pick(ix, window, cx))
})
.child(Ellipsis::new(span.name.clone()))
});
@@ -241,18 +259,8 @@ impl RenderOnce for TimelineProfiler {
.h_full()
.overflow_hidden()
.bg(colors.hover.opacity(0.5))
.when(track == 0, |lane| {
let measure = measure.clone();
lane.child(
canvas(
move |bounds, _, _| measure.set(bounds),
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(track == 0, |row| {
row.child(measure(lane.clone(), |bounds| bounds))
})
.children(bars),
)
@@ -290,4 +298,20 @@ mod tests {
);
assert_eq!(zoomed((10.0, 20.0), 1.0, 2.0), (0.0, 20.0));
}
#[test]
fn the_ruler_keeps_a_label_of_room_between_ticks() {
let narrow = ruler((0.0, 4_400.0), 184.0, 64.0);
assert_eq!(
narrow,
(2_000.0, vec![0.0, 2_000.0]),
"4000 has no room before the end"
);
let wide = ruler((0.0, 4_600.0), 664.0, 64.0).1;
assert_eq!(wide, [0.0, 1_000.0, 2_000.0, 3_000.0, 4_000.0]);
assert!(
ruler((0.0, 4_400.0), 0.0, 64.0).1.is_empty(),
"an unmeasured lane holds none"
);
}
}
+28 -4
View File
@@ -1,9 +1,9 @@
use std::{collections::HashSet, rc::Rc};
use gpui::{
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, Rems, SharedString, Stateful, StatefulInteractiveElement, Styled,
Window, div, prelude::*, transparent_black,
};
use super::{Align, Column, Row, cell::draw, model::tint, table::View};
@@ -32,6 +32,13 @@ pub(super) fn sized<E: Styled>(cell: E, column: &Column, narrowest: Rems) -> E {
}
}
/// A box a table's columns scroll sideways in; a plain wheel passes to the page.
pub(super) fn sideways(id: &ElementId) -> Stateful<Div> {
let mut sideways = div().id((id.clone(), "sideways")).overflow_x_scroll();
sideways.style().restrict_scroll_to_axis = Some(true);
sideways
}
/// What the body draws, one after another.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Line {
@@ -76,6 +83,21 @@ impl Body {
self.lead * (usize::from(self.select.is_some()) + usize::from(self.detail.is_some())) as f32
}
/// The least width columns `cols` draw in: fixed widths, the narrowest share for the rest, and the leading cells.
pub(super) fn least(&self, cols: &[usize], lead: bool, rem: Pixels) -> Pixels {
let start = match lead {
true => self.lead_width().to_pixels(rem),
false => Pixels::ZERO,
};
cols.iter().fold(start, |sum, col| {
sum + match (self.widths[*col], self.columns[*col].width) {
(Some(width), _) => width,
(None, Some(width)) => width.to_pixels(rem),
(None, None) => self.narrowest.to_pixels(rem),
}
})
}
/// Line `at` over columns `cols`; `lead` draws the leading cells.
pub(super) fn line(
&self,
@@ -266,7 +288,9 @@ impl Body {
.h_full()
.flex()
.items_center()
.px_3();
.px_3()
.border_x_1()
.border_color(transparent_black());
let frame = match self.widths[col] {
Some(width) => frame
.w(width)
+9 -4
View File
@@ -1,13 +1,13 @@
use gpui::{
App, DragMoveEvent, ElementId, Entity, EntityId, FontWeight, InteractiveElement, IntoElement,
ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, canvas, div,
prelude::*,
MouseButton, ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled,
canvas, div, prelude::*, transparent_black,
};
use super::{Column, body::sized, table::View};
use crate::layout::seeded::Seeded;
use crate::{
primitives::{DragGhost, Icon, IconName},
primitives::{DragGhost, FocusRing, Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
@@ -111,6 +111,8 @@ pub(crate) fn header(
.items_center()
.gap_1()
.px_3()
.border_1()
.border_color(transparent_black())
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted);
@@ -120,8 +122,11 @@ pub(crate) fn header(
};
cell.when(column.sortable, |cell| {
let key = key.clone();
cell.cursor_pointer()
cell.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |event, _, cx| {
let join = event.modifiers().shift;
sorter.update(cx, |sorting, cx| {
+69 -60
View File
@@ -6,12 +6,12 @@ use std::{
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement,
Styled, UniformListScrollHandle, Window, div, prelude::*, uniform_list,
Styled, UniformListScrollHandle, Window, div, prelude::*, transparent_black, uniform_list,
};
use super::{
Aggregate, Cell, Column, FilterRule,
body::{Body, Detail, Line, OnEdit, Select, sized},
body::{Body, Detail, Line, OnEdit, Select, sideways, sized},
header::header,
model::{figure, filtered, page, range},
rules::{groups, kept, sorted_by},
@@ -380,7 +380,12 @@ impl RenderOnce for DataTable {
)
.child(value)
});
let cell = div().px_3().flex().items_center();
let cell = div()
.px_3()
.border_x_1()
.border_color(transparent_black())
.flex()
.items_center();
match widths[*col] {
Some(width) => cell.w(width).flex_none(),
None => sized(cell, column, narrowest),
@@ -400,64 +405,68 @@ impl RenderOnce for DataTable {
.children(lines)
.children(figures(cols, lead, cx))
};
let table: AnyElement = if total == 0 {
let theme = cx.theme();
div()
.flex()
.flex_col()
.child(heads(&shown_columns, true, cx))
.child(
div()
.flex()
.justify_center()
.py_6()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_subtle)
.child("Nothing matches."),
)
.into_any_element()
} else if self.virtualized {
let (scroll, count, cols, drawn) = (
view.read(cx).scroll.clone(),
body.lines.len(),
shown_columns.clone(),
body.clone(),
);
div()
.flex()
.flex_col()
.size_full()
.child(heads(&shown_columns, true, cx))
.child(
uniform_list((id.clone(), "rows"), count, move |range, window, cx| {
range
.map(|at| drawn.line(at, &cols, true, window, cx))
.collect()
})
.track_scroll(scroll)
.flex_1()
.min_h_0(),
)
.children(figures(&shown_columns, true, cx))
.into_any_element()
} else if pinned_any {
let (held, moving): (Vec<usize>, Vec<usize>) =
shown_columns.iter().partition(|col| columns[**col].pinned);
div()
.flex()
.child(part(&held, true, window, cx).flex_none())
.child(
div()
.id((id.clone(), "sideways"))
let table: AnyElement =
if total == 0 {
let theme = cx.theme();
let least = body.least(&shown_columns, true, rem);
sideways(&id)
.child(
div()
.flex()
.flex_col()
.min_w(least)
.child(heads(&shown_columns, true, cx))
.child(
div()
.flex()
.justify_center()
.py_6()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_subtle)
.child("Nothing matches."),
),
)
.into_any_element()
} else if self.virtualized {
let (scroll, count, cols, drawn) = (
view.read(cx).scroll.clone(),
body.lines.len(),
shown_columns.clone(),
body.clone(),
);
div()
.flex()
.flex_col()
.size_full()
.child(heads(&shown_columns, true, cx))
.child(
uniform_list((id.clone(), "rows"), count, move |range, window, cx| {
range
.map(|at| drawn.line(at, &cols, true, window, cx))
.collect()
})
.track_scroll(scroll)
.flex_1()
.min_w_0()
.overflow_x_scroll()
.child(part(&moving, false, window, cx)),
)
.into_any_element()
} else {
part(&shown_columns, true, window, cx).into_any_element()
};
.min_h_0(),
)
.children(figures(&shown_columns, true, cx))
.into_any_element()
} else if pinned_any {
let (held, moving): (Vec<usize>, Vec<usize>) =
shown_columns.iter().partition(|col| columns[**col].pinned);
div()
.flex()
.child(part(&held, true, window, cx).flex_none())
.child(sideways(&id).flex_1().min_w_0().child(
part(&moving, false, window, cx).min_w(body.least(&moving, false, rem)),
))
.into_any_element()
} else {
let least = body.least(&shown_columns, true, rem);
sideways(&id)
.child(part(&shown_columns, true, window, cx).min_w(least))
.into_any_element()
};
let pager = pages.filter(|pages| *pages > 1).map(|pages| {
let size = self.page_size.expect("pages come from a page size");
let (first, last) = (page_at * size + 1, ((page_at + 1) * size).min(total));
+95
View File
@@ -0,0 +1,95 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, Render, Styled, TestAppContext, Window,
point, px,
};
use super::{edit_at, settle};
use crate::{
tables::{Column, DataTable, Row},
theme::Theme,
};
/// Three editable columns 200 wide in a table 280 wide, the first pinned or not; it keeps the column of each edit.
struct Wide(Rc<RefCell<Vec<String>>>, bool);
impl Render for Wide {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.0.clone();
let column = |key: &'static str| Column::new(key, key).width(gpui::rems(12.5)).editable();
let first = if self.1 {
column("a").pinned()
} else {
column("a")
};
DataTable::new("wide", [first, column("b"), column("c")])
.rows(vec![Row::new("r", ["A".into(), "B".into(), "C".into()])])
.on_edit(move |_, column, _, _, _| heard.borrow_mut().push(column.to_string()))
.w(px(280.0))
}
}
#[gpui::test]
fn columns_past_the_table_scroll_sideways_and_a_plain_wheel_leaves_them(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
for pinned in [false, true] {
let heard = Rc::new(RefCell::new(Vec::new()));
let seen = heard.clone();
let (_, cx) = cx.add_window_view(|_, _| Wide(seen, pinned));
settle(cx);
let cell = point(px(250.0), px(54.0));
for delta in [point(px(0.0), px(-2000.0)), point(px(-2000.0), px(0.0))] {
cx.simulate_event(gpui::ScrollWheelEvent {
position: cell,
delta: gpui::ScrollDelta::Pixels(delta),
modifiers: Modifiers::none(),
touch_phase: gpui::TouchPhase::Moved,
});
settle(cx);
edit_at(cell, "x", cx);
}
assert_eq!(*heard.borrow(), ["b", "c"], "pinned {pinned}");
}
}
/// Two rows under a sortable, editable name; it keeps the row of each edit.
struct Sorting(Rc<RefCell<Vec<String>>>);
impl Render for Sorting {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.0.clone();
DataTable::new("sorting", [Column::new("name", "Name").editable()])
.rows(vec![
Row::new("bea", ["Bea".into()]),
Row::new("ada", ["Ada".into()]),
])
.on_edit(move |row, _, _, _, _| heard.borrow_mut().push(row.to_string()))
.w(px(400.0))
}
}
#[gpui::test]
fn tab_reaches_a_header_and_enter_sorts_by_it(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
let heard = Rc::new(RefCell::new(Vec::new()));
let seen = heard.clone();
let (_, cx) = cx.add_window_view(|_, _| Sorting(seen));
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("enter");
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").expect("a key"),
});
settle(cx);
edit_at(point(px(120.0), px(54.0)), "x", cx);
assert_eq!(*heard.borrow(), ["ada"], "Ada rises to the top");
}
+30 -20
View File
@@ -9,6 +9,8 @@ use gpui::{
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
mod columns;
use super::{Column, DataTable, FilterBuilder, FilterRule, Row};
use crate::{
primitives::{FocusNext, FocusPrev, FocusScope},
@@ -39,6 +41,29 @@ fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
}
/// Edits the cell at `at`: a double press, then `text` and Enter.
fn edit_at(at: gpui::Point<gpui::Pixels>, text: &str, cx: &mut VisualTestContext) {
for count in [1, 2] {
cx.simulate_event(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position: at,
modifiers: Modifiers::none(),
click_count: count,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
button: gpui::MouseButton::Left,
position: at,
modifiers: Modifiers::none(),
click_count: count,
});
}
settle(cx);
cx.simulate_input(text);
cx.simulate_keystrokes("enter");
settle(cx);
}
fn picks(seen: &Rc<RefCell<Vec<SharedString>>>) -> Vec<String> {
seen.borrow().iter().map(|key| key.to_string()).collect()
}
@@ -159,26 +184,11 @@ fn a_row_opens_its_detail_and_a_double_press_edits_a_cell(cx: &mut TestAppContex
cx.simulate_click(point(px(14.0), px(54.0)), Modifiers::none());
settle(cx);
assert_eq!(heard.borrow().last().map(String::as_str), Some("detail a"));
let name = point(px(120.0), px(54.0 + 36.0 + 40.0 + 24.0));
for count in [1, 2] {
cx.simulate_event(gpui::MouseDownEvent {
button: gpui::MouseButton::Left,
position: name,
modifiers: Modifiers::none(),
click_count: count,
first_mouse: false,
});
cx.simulate_event(gpui::MouseUpEvent {
button: gpui::MouseButton::Left,
position: name,
modifiers: Modifiers::none(),
click_count: count,
});
}
settle(cx);
cx.simulate_input("Alan Kay");
cx.simulate_keystrokes("enter");
settle(cx);
edit_at(
point(px(120.0), px(54.0 + 36.0 + 40.0 + 24.0)),
"Alan Kay",
cx,
);
assert_eq!(
heard
.borrow()
+6 -6
View File
@@ -201,12 +201,12 @@
## T24f Agent · Control and cost
- [ ] InterruptButton
- [ ] HumanInputRequest
- [ ] TokenUsageChart
- [ ] CostBreakdown
- [ ] TraceViewer
- [ ] EvalResultTable
- [x] InterruptButton → `chat::stop_button`
- [x] HumanInputRequest
- [x] TokenUsageChart → `charts::BarChart` (stacked)
- [x] CostBreakdown
- [x] TraceViewer → `debug::TimelineProfiler` (a track per depth)
- [x] EvalResultTable → `tables::DataTable` (Tag and Progress cells)
## T25 Generative — `src/generative`