use crate::state::{Detail, State, outcome_word};
use egui::{CentralPanel, Color32, Panel, RichText, ScrollArea, Ui};
use ostraka_core::record::Event;
use ostraka_runtime::index::BackendUsage;
const APPROVED: Color32 = Color32::from_rgb(0x3f, 0xa2, 0x5f);
const REFUSED: Color32 = Color32::from_rgb(0xc0, 0x4a, 0x4a);
const UNFINISHED: Color32 = Color32::from_rgb(0x8a, 0x8a, 0x8a);
const CONTEXT: Color32 = Color32::from_rgb(0x88, 0x88, 0x88);
pub fn draw(ui: &mut Ui, state: &mut State) {
keys(ui.ctx(), state);
state.ensure_loaded();
Panel::top("head").show(ui, |ui| head(ui, state));
Panel::bottom("foot").show(ui, |ui| foot(ui, state));
Panel::left("runs")
.resizable(true)
.default_size(360.0)
.min_size(220.0)
.show(ui, |ui| runs(ui, state));
CentralPanel::default().show(ui, |ui| detail(ui, state));
}
fn keys(ctx: &egui::Context, state: &mut State) {
if ctx.memory(|m| m.focused().is_some()) {
return;
}
ctx.input(|i| {
use egui::Key;
if i.key_pressed(Key::J) || i.key_pressed(Key::ArrowDown) {
state.move_by(1);
}
if i.key_pressed(Key::K) || i.key_pressed(Key::ArrowUp) {
state.move_by(-1);
}
if i.key_pressed(Key::R) {
state.reload();
}
});
}
fn head(ui: &mut Ui, state: &State) {
ui.horizontal(|ui| {
ui.heading("Ostraka");
ui.label(RichText::new(state.project.display().to_string()).color(CONTEXT));
let (shown, total) = (state.matching.len(), state.runs.len());
let count = if shown == total {
format!("{total} run{}", if total == 1 { "" } else { "s" })
} else {
format!("{shown} of {total} runs")
};
ui.label(RichText::new(count).color(CONTEXT));
});
}
fn foot(ui: &mut Ui, state: &State) {
let backends = state.backends();
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("tokens").color(CONTEXT));
if backends.is_empty() {
ui.label(
RichText::new("no backend on these runs reported what it spent").color(CONTEXT),
);
}
for (i, backend) in backends.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").color(CONTEXT));
}
ui.label(RichText::new(&backend.adapter).strong());
ui.label(describe_backend(backend));
}
});
if let Some(status) = &state.status {
ui.label(RichText::new(status).color(Color32::from_rgb(0xb8, 0x8a, 0x2a)));
}
}
pub fn describe_backend(backend: &BackendUsage) -> String {
let about = if backend.approximate { "~" } else { "" };
if backend.input == 0 && backend.output == 0 {
return format!("{about}{} total", compact(backend.total));
}
let split = format!(
"{about}{} in / {about}{} out",
compact(backend.input),
compact(backend.output)
);
if backend.total == 0 {
split
} else {
format!("{split} · {about}{} total", compact(backend.total))
}
}
pub fn compact(n: u64) -> String {
match n {
0..=999 => n.to_string(),
1_000..=999_999 => format!("{:.1}k", n as f64 / 1_000.0),
_ => format!("{:.1}M", n as f64 / 1_000_000.0),
}
}
fn runs(ui: &mut Ui, state: &mut State) {
ui.add_space(4.0);
let filter = ui.add(
egui::TextEdit::singleline(&mut state.filter)
.hint_text("filter by task, id or outcome")
.desired_width(f32::INFINITY),
);
if filter.changed() {
state.refilter();
}
ui.add_space(4.0);
ui.separator();
if state.matching.is_empty() {
ui.add_space(8.0);
ui.label(RichText::new(if state.runs.is_empty() {
"No runs yet. `ostraka run` makes one."
} else {
"Nothing matches this filter."
}));
return;
}
let mut clicked = None;
ScrollArea::vertical().show(ui, |ui| {
for position in 0..state.matching.len() {
let Some(run) = state.runs.get(state.matching[position]) else {
continue;
};
let colour = match outcome_word(run) {
"approved" => APPROVED,
"refused" | "failed" => REFUSED,
_ => UNFINISHED,
};
let label = format!(
"{} {}",
outcome_word(run),
first_line(&described(&run.prompt))
);
let response = ui
.selectable_label(
state.selected == Some(position),
RichText::new(label).color(colour),
)
.on_hover_text(described(&run.prompt));
if response.clicked() {
clicked = Some(position);
}
}
});
if let Some(position) = clicked {
state.select(position);
}
}
fn detail(ui: &mut Ui, state: &mut State) {
let Some(run) = state.selected_run().cloned() else {
ui.label("Select a run.");
return;
};
ui.horizontal(|ui| {
ui.label(RichText::new(&run.run_id).strong());
let colour = match outcome_word(&run) {
"approved" => APPROVED,
"refused" | "failed" => REFUSED,
_ => UNFINISHED,
};
ui.label(RichText::new(outcome_word(&run)).color(colour));
ui.add_enabled_ui(state.can_promote(), |ui| {
if ui
.button("Promote")
.on_hover_text("Gives the run's commit a branch. Merges nothing.")
.clicked()
{
state.promote_selected();
}
});
});
ui.add_space(4.0);
ui.label(RichText::new(described(&run.prompt)));
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} ({})", run.author, run.adapter)).color(CONTEXT));
if let Some(reviewer) = &run.reviewer {
ui.label(RichText::new(format!("reviewed by {reviewer}")).color(CONTEXT));
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
for tab in Detail::ALL {
if ui
.selectable_label(state.detail == tab, tab.title())
.clicked()
{
state.show(tab);
}
}
});
ui.separator();
ScrollArea::vertical().show(ui, |ui| match state.detail {
Detail::Checks => checks(ui, state),
Detail::Events => events(ui, state),
Detail::Diff => diff(ui, state),
});
}
fn checks(ui: &mut Ui, state: &State) {
let Some(record) = &state.record else {
ui.label(RichText::new("reading the record…").color(CONTEXT));
return;
};
if record.checks.is_empty() {
ui.label(RichText::new("no checks ran").color(CONTEXT));
return;
}
for check in &record.checks {
let (word, colour) = if check.passed() {
("pass", APPROVED)
} else {
("FAIL", REFUSED)
};
ui.horizontal(|ui| {
ui.label(RichText::new(word).color(colour).monospace());
ui.label(RichText::new(&check.name).monospace());
ui.label(RichText::new(format!("{}ms", check.duration_ms)).color(CONTEXT));
});
if !check.passed() {
for stream in [&check.stderr, &check.stdout] {
if !stream.trim().is_empty() {
ui.label(RichText::new(stream.trim()).monospace().color(CONTEXT));
}
}
}
}
}
fn events(ui: &mut Ui, state: &State) {
if state.events.is_empty() {
ui.label(RichText::new("no events recorded").color(CONTEXT));
return;
}
for event in &state.events {
let (kind, text) = match event {
Event::Message { text, .. } => ("said", text.clone()),
Event::ToolUse { name, .. } => ("tool", name.clone()),
Event::Error { message, .. } => ("error", message.clone()),
Event::Finished {
exit_code,
files_touched,
} => (
"done",
format!(
"exit {}, {} file(s)",
exit_code.map(|c| c.to_string()).unwrap_or("?".into()),
files_touched.len()
),
),
};
ui.horizontal_top(|ui| {
ui.label(RichText::new(kind).color(CONTEXT).monospace());
ui.label(RichText::new(text).monospace());
});
}
}
fn diff(ui: &mut Ui, state: &State) {
match &state.diff {
None => {
ui.label(RichText::new("reading the change…").color(CONTEXT));
}
Some(None) => {
ui.label(RichText::new("This run produced no commit.").color(CONTEXT));
ui.label(
RichText::new("It was refused, or its branch has since been merged away.")
.color(CONTEXT),
);
}
Some(Some(text)) => {
for line in text.lines() {
let colour = match line.as_bytes().first() {
Some(b'+') if !line.starts_with("+++") => APPROVED,
Some(b'-') if !line.starts_with("---") => REFUSED,
_ => CONTEXT,
};
ui.label(RichText::new(line).monospace().color(colour));
}
}
}
}
pub fn described(prompt: &str) -> String {
if prompt.trim().is_empty() {
"(recorded before runs kept the task text)".to_string()
} else {
prompt.to_string()
}
}
fn first_line(text: &str) -> String {
let line = text.lines().next().unwrap_or_default();
if line.chars().count() > 46 {
format!("{}…", line.chars().take(45).collect::<String>())
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn backend(input: u64, output: u64, total: u64, approximate: bool) -> BackendUsage {
BackendUsage {
adapter: "v".into(),
input,
output,
total,
runs: 1,
approximate,
}
}
#[test]
fn a_combined_figure_is_shown_as_a_total_not_as_a_split() {
assert_eq!(
describe_backend(&backend(0, 0, 14_709, false)),
"14.7k total"
);
}
#[test]
fn a_rounding_vendor_is_marked_as_an_estimate() {
assert_eq!(
describe_backend(&backend(10_600, 296, 0, true)),
"~10.6k in / ~296 out"
);
}
#[test]
fn counts_stay_exact_below_a_thousand() {
assert_eq!(compact(0), "0");
assert_eq!(compact(999), "999");
assert_eq!(compact(1_000), "1.0k");
assert_eq!(compact(159_460), "159.5k");
assert_eq!(compact(2_500_000), "2.5M");
}
#[test]
fn an_old_record_says_why_its_task_is_blank() {
assert!(described(" ").contains("before runs kept"));
assert_eq!(described("add a test"), "add a test");
}
}