use crate::{
model::{
session::{ChatSession, nothing_said},
workspace::Workspace,
},
view::root,
};
use artifact::session::chat::{ChatItem, ToolStatus};
use bezel::ui::scroll as scrollbars;
use bezel::{
agent::orbs::{OrbSize, OrbState, engine::Frame, orb_element},
gpui::{
AnyElement, Context, Empty, Pixels, ScrollHandle, SharedString, Window, canvas, div,
prelude::*, px,
},
motion::Painter,
theme::{TextStyle, Theme, Typeset, ink},
ui::{
icons,
scroll::{self, FollowState},
tooltip::Tooltip,
widgets::{Layout, Status, Takeover},
},
};
use cacp::schema::ToolKind;
use markdown::{
BlockLayouts, Selection,
selectable::{self, Pointer},
};
use std::{
cell::{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 MARK: f32 = 16.;
const MARK_THICK: f32 = 2.;
const MARK_PAD: f32 = 5.;
const RAIL_INSET: f32 = 12.;
const ASKED_MAX: usize = 80;
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,
pub(crate) footer_height: Rc<Cell<Pixels>>,
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) | ChatItem::Notice { 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;
}
if nothing_said(&items[start..ix]) {
start = ix;
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 + usize::from(matches!(items[start], ChatItem::User(_)))),
});
start = ix;
}
turns
}
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 [u8] {
match kind {
ToolKind::Read => icons::text::Book,
ToolKind::Edit => icons::text::Pen,
ToolKind::Delete => icons::files::Trash,
ToolKind::Move => icons::arrows::ArrowRight,
ToolKind::Search => icons::text::Search,
ToolKind::Execute => icons::development::Terminal,
ToolKind::Think => icons::devices::Cpu,
ToolKind::Fetch => icons::navigation::Globe,
ToolKind::SwitchMode => icons::account::SlidersHorizontal,
_ => icons::layout::LayoutGrid,
}
}
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
+ f32::from(chat.transcript.footer_height.get())
.max(root::composer_height())
+ root::COMPOSER_BOTTOM))
.flex()
.flex_col()
.children(zones),
)
.child(scroll::follow(
&chat.transcript.scroll,
&chat.transcript.follow,
)),
)
.child(
scrollbars::Overlay::new(
format!("transcript-bar-{id}"),
&chat.transcript.scroll,
bezel::gpui::Axis::Vertical,
)
.end_inset(
chat.transcript
.footer_height
.get()
.max(px(root::composer_height()))
+ px(root::COMPOSER_BOTTOM),
),
)
.child(rail(
chat,
&turns,
window.viewport_size().width - chat.transcript.scroll.bounds().right(),
))
.into_any_element()
}
fn active_turn(handle: &ScrollHandle, count: usize) -> usize {
let last = count.saturating_sub(1);
if scroll::at_bottom(handle.max_offset().y, handle.offset().y, px(0.5)) {
last
} else {
handle.top_item().min(last)
}
}
fn rail(chat: &ChatSession, turns: &[Turn], room: Pixels) -> AnyElement {
if turns.is_empty() || room < px(scroll::RAIL_ROOM + 2. * MARK_PAD) {
return Empty.into_any_element();
}
let handle = chat.transcript.scroll.clone();
let count = turns.len();
let at = active_turn(&handle, count);
div()
.absolute()
.top_0()
.bottom_0()
.left(px(RAIL_INSET))
.flex()
.flex_col()
.items_center()
.justify_center()
.overflow_hidden()
.child(
canvas(
move |_, window, _| {
if active_turn(&handle, count) != at {
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute(),
)
.children(turns.iter().enumerate().map(|(ix, turn)| {
let asked = match chat.items.get(turn.range.start) {
Some(ChatItem::User(text)) => Some(SharedString::from(clipped(text, ASKED_MAX))),
_ => None,
}
.filter(|asked| !asked.is_empty());
let handle = chat.transcript.scroll.clone();
div()
.id(("rail-mark", ix))
.p(px(MARK_PAD))
.group("rail-mark")
.cursor_pointer()
.when_some(asked, |mark, asked| {
mark.tooltip(move |window, cx| Tooltip::text(asked.clone(), window, cx))
})
.on_click(move |_, window, _| {
handle.scroll_to_item(ix);
window.refresh();
})
.child(
div()
.w(px(MARK))
.h(px(MARK_THICK))
.rounded_full()
.bg(if ix == at { ink(0.6) } else { ink(0.2) })
.group_hover("rail-mark", |mark| mark.bg(ink(0.32))),
)
}))
.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 + usize::from(matches!(chat.items[first], ChatItem::User(_))))..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, .. } => div()
.opacity(0.65)
.child(prose(chat, ix, text, window, cx))
.into_any_element(),
ChatItem::Process { command, output } => {
let (command, output) = (command.clone(), output.clone());
process(chat, ix, &command, &output, cx)
}
_ => 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) | ChatItem::Notice { text, .. } => div()
.text_style(TextStyle::Callout)
.text_color(theme.text_muted)
.when(matches!(item, ChatItem::Notice { .. }), |el| {
el.opacity(0.65)
})
.child(text.clone())
.into_any_element(),
_ => div().into_any_element(),
}
}));
}
ix += run.len();
}
out
}
pub const HEAD_MAX: usize = 24;
const TITLE_MAX: usize = 72;
pub fn title(label: &str) -> (SharedString, Option<SharedString>, Option<SharedString>) {
let label = label.trim();
let line = one_line(label);
let cut = line
.char_indices()
.nth(HEAD_MAX)
.map_or(line.len(), |(at, _)| at);
let head = line[..cut].find(char::is_whitespace).unwrap_or(cut);
let (name, rest) = line.split_at(head);
let rest = rest.trim();
(
SharedString::from(name.to_owned()),
(!rest.is_empty()).then(|| SharedString::from(rest.to_owned())),
(line != label || line.chars().count() > TITLE_MAX)
.then(|| SharedString::from(label.to_owned())),
)
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn clipped(text: &str, max: usize) -> String {
let line = one_line(text);
match line.char_indices().nth(max) {
Some((at, _)) => format!("{}…", line[..at].trim_end()),
None => line,
}
}
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"));
let (name, rest, full) = title(label);
div()
.when(!first, |el| el.border_t_1().border_color(theme.border))
.child(
theme
.step_row(
tool_icon(*kind),
name,
rest,
meta,
failed,
(!output.is_empty() || full.is_some()).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_some(full.filter(|_| open), |el, full| {
el.child(theme.step_output(("tool-title", ix), full))
})
.when(open && !output.is_empty(), |el| {
el.child(theme.step_output(("tool-output", ix), output.clone()))
})
.into_any_element()
}
fn process(
chat: &ChatSession,
ix: usize,
command: &str,
output: &str,
cx: &mut Context<Workspace>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let id = chat.id;
let open = chat.transcript.output.contains(&ix);
let (name, rest, full) = title(command);
div()
.child(
theme
.step_row(
tool_icon(ToolKind::Execute),
name,
rest,
None,
false,
(!output.is_empty() || full.is_some()).then_some(open),
)
.hover(|el| el.bg(theme.element_hover))
.id(("process", 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_some(full.filter(|_| open), |el, full| {
el.child(theme.step_output(("process-title", ix), full))
})
.when(open && !output.is_empty(), |el| {
el.child(theme.step_output(
("process-output", ix),
SharedString::from(output.to_owned()),
))
})
.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()
}
#[cfg(test)]
#[path = "../../../tests/unit/transcript_turns.rs"]
mod tests;
#[cfg(test)]
#[path = "../../../tests/unit/transcript_rail.rs"]
mod rail_tests;