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.
204 lines
6.7 KiB
Rust
204 lines
6.7 KiB
Rust
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)
|
|
}
|
|
}
|