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
@@ -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.
|
||||
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user