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 NO_AIM: &str = "Ctrl+X picks a workspace to begin in, or a conversation";
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);
let said = if model.aim.is_some() {
NO_CONVERSATION
} else {
NO_AIM
};
theme::paint::empty(ui, said);
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 failed = theme::accent(theme::State::Error);
let (mark, ink) = match row.weight {
_ if row.failed => (glyph::FAILED, failed),
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 = if row.failed {
failed
} else {
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 calls = steps
.iter()
.filter(|row| matches!(row.kind, Kind::Call | Kind::Answered))
.count();
let head = format!(
"{} {TRAIL} ({calls})",
if open { glyph::OPEN } else { glyph::SHUT },
);
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 red = theme::accent(theme::State::Error);
let ended = |ui: &mut egui::Ui| {
let (mark, ink) = if row.failed {
(glyph::FAILED, red)
} else {
(glyph::DONE, theme::INK_WEAK)
};
ui.label(egui::RichText::new(mark).color(ink));
};
let (mark, ink) = match row.kind {
Kind::Call | Kind::Answered => (glyph::CALL, theme::BRAND),
_ => (glyph::NOTE, theme::INK_WEAK),
};
if row.kind == Kind::Result {
ended(ui);
} else {
ui.label(egui::RichText::new(mark).color(ink));
}
ui.vertical(|ui| {
let who = if row.failed { red } else { theme::INK_WEAK };
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(&row.who).color(who));
if row.kind == Kind::Answered {
ended(ui);
}
});
match &row.fold {
Some(fold) => folded(ui, at, row, fold),
None if row.said.is_empty() => {}
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;