pub mod fold;
pub mod panel;
pub mod rows;
pub use fold::Fold;
pub use rows::{Kind, Row, rows};
use crate::ui::theme::{self, glyph};
pub const HEADING: &str = "conversation";
pub const NO_CONVERSATION: &str = "Ctrl+X picks a conversation, or begin one in the box below";
pub const LIVE: &str = "«live»";
pub const TRAIL: &str = "tool calls";
pub const RESPONSE: &str = "response";
pub fn render(ui: &mut egui::Ui, model: &crate::ui::Model) {
let Some(conversation) = model.conversation.as_ref() else {
panel::seat(ui, model);
theme::paint::empty(ui, NO_CONVERSATION);
return;
};
egui::ScrollArea::vertical()
.id_salt(conversation)
.stick_to_bottom(true)
.auto_shrink([false, false])
.show(ui, |ui| {
panel::conversation(ui, model, conversation);
let all = rows(&model.transcript, model.live.as_ref());
let mut at = 0;
let mut turns = 0;
let mut after_trail = false;
while let Some(row) = all.get(at) {
let rest = all.get(at..).unwrap_or(&[]);
let run = rest.iter().take_while(|row| row.kind != Kind::Said).count();
if run > 0 {
ui.add_space(theme::space::S);
trail(ui, at, rest.get(..run).unwrap_or(&[]));
at += run;
after_trail = true;
continue;
}
if row.weight == theme::Speaker::Operator {
if turns > 0 {
parting(ui);
}
turns += 1;
} else if after_trail {
response(ui);
}
ui.add_space(theme::space::S);
said(ui, row);
after_trail = false;
at += 1;
}
});
}
fn parting(ui: &mut egui::Ui) {
ui.add_space(theme::space::M);
let (rect, _) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 0.0, theme::HAIRLINE);
}
fn response(ui: &mut egui::Ui) {
ui.add_space(theme::space::XS);
let x = ui.cursor().min.x + theme::space::S;
let top = ui.cursor().min.y;
ui.horizontal(|ui| {
ui.add_space(theme::space::L);
ui.label(egui::RichText::new(RESPONSE).small().color(theme::INK_WEAK));
});
let middle = ui.min_rect().max.y - theme::space::S;
theme::paint::elbow(ui, x, top, middle, x + theme::space::S);
}
fn said(ui: &mut egui::Ui, row: &Row) {
let (mark, ink) = match row.weight {
theme::Speaker::Operator => (glyph::PROMPT, theme::BRAND),
theme::Speaker::Model => (glyph::MODEL, theme::INK_WEAK),
_ => (glyph::NOTE, theme::INK_WEAK),
};
ui.horizontal_top(|ui| {
ui.label(egui::RichText::new(mark).color(ink));
ui.vertical(|ui| {
let who = theme::speaker(row.weight);
ui.label(egui::RichText::new(&row.who).color(who).strong());
theme::paint::prose(ui, &row.said, theme::INK);
});
});
}
fn trail(ui: &mut egui::Ui, at: usize, steps: &[Row]) {
let id = ui.make_persistent_id(("trail", at));
let mut open = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(true);
let head = format!(
"{} {TRAIL} ({})",
if open { glyph::OPEN } else { glyph::SHUT },
steps.len()
);
if ui
.add(
egui::Label::new(egui::RichText::new(head).color(theme::INK_WEAK))
.sense(egui::Sense::click()),
)
.clicked()
{
open = !open;
ui.data_mut(|d| d.insert_temp(id, open));
}
if !open {
return;
}
let x = ui.cursor().min.x + theme::space::S;
for (i, row) in steps.iter().enumerate() {
let top = ui.cursor().min.y;
ui.horizontal_top(|ui| {
ui.add_space(theme::space::L);
step(ui, at + i, row);
});
let bottom = ui.min_rect().max.y;
let middle = top + theme::space::S;
theme::paint::elbow(ui, x, top, middle, x + theme::space::S);
if i + 1 < steps.len() {
theme::paint::rail(ui, x, middle, bottom);
}
}
}
fn step(ui: &mut egui::Ui, at: usize, row: &Row) {
let (mark, ink) = match (row.kind, row.failed) {
(Kind::Result, true) => (glyph::FAILED, theme::accent(theme::State::Error)),
(Kind::Result, false) => (glyph::DONE, theme::INK_WEAK),
(Kind::Call, _) => (glyph::CALL, theme::BRAND),
_ => (glyph::NOTE, theme::INK_WEAK),
};
ui.label(egui::RichText::new(mark).color(ink));
ui.vertical(|ui| {
let who = if row.failed {
theme::accent(theme::State::Error)
} else {
theme::INK_WEAK
};
ui.label(egui::RichText::new(&row.who).color(who));
match &row.fold {
Some(fold) => folded(ui, at, row, fold),
None => {
theme::paint::prose(ui, &row.said, theme::INK_WEAK);
}
}
});
}
fn folded(ui: &mut egui::Ui, at: usize, row: &Row, fold: &Fold) {
let id = ui.make_persistent_id(at);
let mut open = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
if ui
.button(if open { fold::UNFOLD } else { &fold.whole })
.clicked()
{
open = !open;
ui.data_mut(|d| d.insert_temp(id, open));
}
if open {
theme::paint::prose(ui, &row.said, theme::INK);
} else {
theme::paint::prose(ui, &fold.head, theme::INK_WEAK);
}
}
#[cfg(test)]
mod tests;