use crate::{
model::{
session::{ChatItem, ChatSession, ToolStatus},
workspace::Workspace,
},
view::root,
};
use bezel::{
agent::orbs::{OrbSize, OrbState, engine::Frame, orb_element},
gpui::{AnyElement, Context, ScrollHandle, SharedString, Window, div, prelude::*, px},
motion::Painter,
theme::{TextStyle, Theme, Typeset},
ui::{
icons,
scroll::{self, FollowState},
widgets::{Layout, Status, Takeover},
},
};
use cacp::schema::ToolKind;
use markdown::{
BlockLayouts, Selection,
selectable::{self, Pointer},
};
use std::{
cell::RefCell,
collections::{HashMap, HashSet, hash_map::DefaultHasher},
hash::{Hash, Hasher},
ops::Range,
rc::Rc,
time::Duration,
};
const CONTENT_MAX_WIDTH: f32 = 720.;
const PAD: f32 = 28.;
const ORB_FPS: f32 = 30.;
const ORB_LEASE: Duration = Duration::from_millis(300);
const ORB_STILL: f32 = 0.6;
#[derive(Default)]
pub struct State {
scroll: ScrollHandle,
follow: FollowState,
work: HashMap<usize, Takeover>,
output: HashSet<usize>,
selection: Option<(usize, Selection)>,
dragging: bool,
layouts: RefCell<HashMap<usize, BlockLayouts>>,
orb: Rc<RefCell<Frame>>,
pub(crate) mark: Rc<RefCell<Frame>>,
}
impl State {
fn layouts(&self, ix: usize) -> BlockLayouts {
self.layouts.borrow_mut().entry(ix).or_default().clone()
}
fn selection(&self, ix: usize) -> Option<Selection> {
self.selection
.filter(|(item, _)| *item == ix)
.map(|(_, selection)| selection)
}
pub fn point(&mut self, ix: usize, pointer: Pointer) {
match pointer {
Pointer::Down(cursor) => {
self.selection = Some((ix, Selection::at(cursor)));
self.dragging = true;
}
Pointer::Move(cursor) => {
if let Some((item, selection)) = self.selection.filter(|(item, _)| *item == ix) {
self.selection = Some((item, selection.extend_to(cursor)));
}
}
Pointer::Up => self.dragging = false,
}
}
pub fn copied(&self, chat: &ChatSession) -> Option<String> {
let (ix, selection) = self.selection?;
let doc = markdown::parse(item_text(chat.items.get(ix)?)?);
let text = selectable::copied(&doc, selection);
(!text.is_empty()).then_some(text)
}
}
fn item_text(item: &ChatItem) -> Option<&str> {
match item {
ChatItem::User(text) | ChatItem::Agent(text) => Some(text),
_ => None,
}
}
struct Turn {
range: Range<usize>,
answer_from: usize,
}
fn turns(items: &[ChatItem]) -> Vec<Turn> {
let mut turns = Vec::new();
let mut start = 0;
for ix in 1..=items.len() {
if ix < items.len() && !matches!(items[ix], ChatItem::User(_)) {
continue;
}
let interim =
|item: &ChatItem| matches!(item, ChatItem::Tool { .. } | ChatItem::Thinking { .. });
let answer_from = items[start..ix]
.iter()
.rposition(interim)
.map_or(start, |last| start + last + 1);
turns.push(Turn {
range: start..ix,
answer_from: answer_from.max(start + 1),
});
start = ix;
}
turns
}
fn notice(theme: &Theme, text: &str, failed: bool) -> AnyElement {
let strip = if failed {
theme.error_strip(SharedString::from(text.to_owned()))
} else {
theme.warning_strip(SharedString::from(text.to_owned()))
};
strip.mt(px(0.)).into_any_element()
}
fn prose(
chat: &ChatSession,
ix: usize,
text: &str,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> AnyElement {
let id = chat.id;
selectable::render(
("transcript-prose", ix),
&markdown::parse(text),
&chat.transcript.layouts(ix),
chat.transcript.selection(ix),
chat.transcript.dragging,
window,
cx,
move |workspace, pointer, cx| {
workspace.with_session(id, cx, |chat| chat.transcript.point(ix, pointer));
},
)
}
fn tool_icon(kind: ToolKind) -> &'static str {
match kind {
ToolKind::Read => icons::files::BOOK,
ToolKind::Edit => icons::editing::PEN,
ToolKind::Delete => icons::files::TRASH_BIN_MINIMALISTIC,
ToolKind::Move => icons::arrows::ARROW_RIGHT,
ToolKind::Search => icons::system::MAGNIFER,
ToolKind::Execute => icons::devices::TERMINAL,
ToolKind::Think => icons::devices::CPU,
ToolKind::Fetch => icons::devices::GLOBAL,
ToolKind::SwitchMode => icons::system::TUNING,
_ => icons::system::WIDGET,
}
}
pub fn render(chat: &ChatSession, window: &mut Window, cx: &mut Context<Workspace>) -> AnyElement {
let id = chat.id;
let turns = turns(&chat.items);
let last = turns.len().saturating_sub(1);
let mut zones: Vec<AnyElement> = Vec::new();
for (position, turn) in turns.iter().enumerate() {
let running = chat.streaming && position == last;
zones.push(zone(chat, turn, running, window, cx));
}
if let Some(turn) = turns
.last()
.filter(|turn| chat.streaming && turn.range.len() <= 1)
{
zones.push(working(chat, turn.range.start, cx));
}
div()
.flex_1()
.min_h_0()
.relative()
.flex()
.justify_center()
.child(
div()
.relative()
.w_full()
.max_w(px(CONTENT_MAX_WIDTH))
.child(
div()
.id(("transcript", id))
.size_full()
.overflow_y_scroll()
.track_scroll(&chat.transcript.scroll)
.px(px(24.))
.pt(px(PAD))
.pb(px(PAD + root::composer_height() + root::COMPOSER_BOTTOM))
.flex()
.flex_col()
.children(zones),
)
.child(scroll::follow(
&chat.transcript.scroll,
&chat.transcript.follow,
)),
)
.child(scroll::rail(
SharedString::from(format!("transcript-rail-{id}")),
&chat.transcript.scroll,
turns.len(),
window.viewport_size().width - chat.transcript.scroll.bounds().right(),
))
.into_any_element()
}
fn zone(
chat: &ChatSession,
turn: &Turn,
running: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let first = turn.range.start;
let body = (first + 1).min(turn.range.end)..turn.answer_from;
let steps = chat.items[body.clone()]
.iter()
.filter(|item| matches!(item, ChatItem::Tool { .. }))
.count();
let open = chat
.transcript
.work
.get(&first)
.copied()
.unwrap_or_default()
.get(running);
let mut zone = div().flex().flex_col().gap(px(10.)).pb(px(28.));
if let Some(ChatItem::User(text)) = chat.items.get(first) {
zone = zone.child(
div()
.self_end()
.max_w(px(440.))
.px(px(14.))
.py(px(9.))
.rounded(px(Theme::surface_radius()))
.bg(theme.surface_raised)
.text_style(TextStyle::Body)
.text_color(theme.text)
.child(prose(chat, first, text, window, cx)),
);
}
if !body.is_empty() {
zone = zone.child(work_header(chat.id, first, steps, open, cx));
if open {
zone = zone.child(
div()
.ml(px(10.))
.pl(px(12.))
.border_l_1()
.border_color(theme.border)
.flex()
.flex_col()
.gap(px(8.))
.children(work(chat, body, cx)),
);
}
}
for ix in turn.answer_from..turn.range.end {
zone = zone.child(match &chat.items[ix] {
ChatItem::Agent(text) => prose(chat, ix, text, window, cx),
ChatItem::Notice { text, failed } => notice(&theme, text, *failed),
_ => div().into_any_element(),
});
}
zone.into_any_element()
}
fn work_header(
id: u64,
turn: usize,
steps: usize,
open: bool,
cx: &mut Context<Workspace>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let label = match steps {
0 => "Thought".to_owned(),
1 => "Worked · 1 step".to_owned(),
n => format!("Worked · {n} steps"),
};
div()
.id(("work", turn))
.self_start()
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.px(px(4.))
.py(px(5.))
.rounded(px(Theme::control_radius()))
.cursor_pointer()
.hover(|el| el.bg(theme.element_hover))
.on_click(cx.listener(move |this, _, _, cx| {
this.with_session(id, cx, |chat| {
let running = chat.streaming;
chat.transcript
.work
.entry(turn)
.or_default()
.toggle(running);
});
}))
.child(theme.disclosure(open))
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_muted)
.child(label),
)
.into_any_element()
}
fn work(chat: &ChatSession, body: Range<usize>, cx: &mut Context<Workspace>) -> Vec<AnyElement> {
let theme = Theme::of(cx).clone();
let is_tool = |item: &ChatItem| matches!(item, ChatItem::Tool { .. });
let mut out = Vec::new();
let mut ix = body.start;
for run in chat.items[body].chunk_by(|a, b| is_tool(a) == is_tool(b)) {
if is_tool(&run[0]) {
let start = ix;
out.push(
div()
.rounded(px(Theme::panel_radius()))
.border_1()
.border_color(theme.border)
.overflow_hidden()
.children((start..start + run.len()).map(|i| tool(chat, i, i == start, cx)))
.into_any_element(),
);
} else {
out.extend(run.iter().map(|item| {
match item {
ChatItem::Thinking { text, .. } => div()
.flex()
.flex_row()
.items_start()
.gap(px(6.))
.text_style(TextStyle::Callout)
.text_color(theme.text_muted.opacity(0.7))
.child(
icons::icon(icons::devices::CPU)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(text.clone())
.into_any_element(),
ChatItem::Agent(text) => div()
.text_style(TextStyle::Callout)
.text_color(theme.text_muted)
.child(text.clone())
.into_any_element(),
ChatItem::Notice { text, failed } => notice(&theme, text, *failed),
_ => div().into_any_element(),
}
}));
}
ix += run.len();
}
out
}
fn tool(chat: &ChatSession, ix: usize, first: bool, cx: &mut Context<Workspace>) -> AnyElement {
let theme = Theme::of(cx).clone();
let ChatItem::Tool {
kind,
label,
status,
output,
..
} = &chat.items[ix]
else {
return div().into_any_element();
};
let id = chat.id;
let open = chat.transcript.output.contains(&ix);
let failed = *status == ToolStatus::Failure;
let meta = (*status == ToolStatus::Running).then(|| SharedString::from("running"));
div()
.when(!first, |el| el.border_t_1().border_color(theme.border))
.child(
theme
.step_row(
tool_icon(*kind),
label.clone(),
None,
meta,
failed,
(!output.is_empty()).then_some(open),
)
.hover(|el| el.bg(theme.element_hover))
.id(("tool", ix))
.on_click(cx.listener(move |this, _, _, cx| {
this.with_session(id, cx, |chat| {
if !chat.transcript.output.insert(ix) {
chat.transcript.output.remove(&ix);
}
});
})),
)
.when(open && !output.is_empty(), |el| {
el.child(theme.step_output(("tool-output", ix), output.clone()))
})
.into_any_element()
}
const WORKING: [&str; 40] = [
"Thinking",
"Brewing",
"Cultivating",
"Simmering",
"Percolating",
"Distilling",
"Weaving",
"Conjuring",
"Steeping",
"Fermenting",
"Crystallizing",
"Synthesizing",
"Composing",
"Pondering",
"Unraveling",
"Forging",
"Kindling",
"Gathering",
"Polishing",
"Assembling",
"Decoding",
"Untangling",
"Refining",
"Shaping",
"Hatching",
"Coalescing",
"Contemplating",
"Illuminating",
"Molding",
"Calibrating",
"Churning",
"Marinating",
"Incubating",
"Digesting",
"Sprouting",
"Condensing",
"Mulling",
"Concocting",
"Ruminating",
"Orchestrating",
];
fn verb(question: &str) -> &'static str {
WORKING[asked_hash(question) % WORKING.len()]
}
pub fn orb_of(chat: &ChatSession) -> OrbState {
let at = chat
.items
.iter()
.rposition(|item| matches!(item, ChatItem::User(_)))
.unwrap_or(0);
let question = chat.items.get(at).and_then(item_text).unwrap_or_default();
OrbState::ALL_STATES[asked_hash(question) % OrbState::ALL_STATES.len()]
}
pub fn orb<V: 'static>(
state: OrbState,
since: Duration,
frame: &Rc<RefCell<Frame>>,
cx: &mut Context<V>,
) -> AnyElement {
Painter::of(cx).lease(ORB_FPS, ORB_LEASE, cx);
let t = match cx.reduce_motion() {
true => ORB_STILL,
false => since.as_secs_f32(),
};
orb_element(state, OrbSize::Inline, t, frame).into_any_element()
}
fn asked_hash(question: &str) -> usize {
let mut hash = DefaultHasher::new();
question.hash(&mut hash);
hash.finish() as usize
}
fn since(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
match secs < 60 {
true => format!("{secs}s"),
false => format!("{}m {}s", secs / 60, secs % 60),
}
}
fn tokens(spent: u64) -> String {
match spent {
n if n < 1_000 => n.to_string(),
n if n < 100_000 => format!("{:.1}k", n as f64 / 1_000.),
n => format!("{}k", n / 1_000),
}
}
fn spend(chat: &ChatSession, theme: &Theme) -> Option<AnyElement> {
let mut parts = Vec::new();
if let Some(elapsed) = chat.elapsed().filter(|elapsed| elapsed.as_secs() > 0) {
parts.push(since(elapsed));
}
if let Some(spent) = chat.spent().filter(|spent| *spent > 0) {
parts.push(format!("{} tokens", tokens(spent)));
}
(!parts.is_empty()).then(|| {
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint.opacity(0.6))
.child(format!("({})", parts.join(" · ")))
.into_any_element()
})
}
fn working(chat: &ChatSession, at: usize, cx: &mut Context<Workspace>) -> AnyElement {
let theme = Theme::of(cx).clone();
let asked = chat.items.get(at).and_then(item_text).unwrap_or_default();
let state = orb_of(chat);
let since = chat.elapsed().unwrap_or_default();
div()
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.pb(px(28.))
.child(orb(state, since, &chat.transcript.orb, cx))
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child(format!("{}…", verb(asked))),
)
.children(spend(chat, &theme))
.into_any_element()
}