yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! egui widget: the §11 Altitude-2 Steps tab — a per-step list plus a tabbed
//! drill-in.
//!
//! A pure function of the [`StepsView`] view-model, the caller's selection
//! (which step, which tab — viewport ephemera, §5.3, passed in), and the
//! drill-in [`StepDetail`] the caller built for the selected step. Every
//! record renders through [`crate::jsonview`], so the whole tab is "every
//! byte inspectable". The one interaction inside the tab is jsonview's
//! collapse toggle (which owns its own click); step- and tab-selection clicks
//! are shell glue, exactly as the transcript tab's Raw toggle is — so this fn
//! stays a headless shape-walk-tested pure render.

use std::collections::HashSet;

use super::{Doc, StepDetail, StepSummary, StepsView, ToolIo};
use crate::git_tree::Framing;
use crate::theme;

/// Short-oid width for the per-step commit (matches git's default).
const SHORT_OID: usize = 7;

/// Which drill-in tab is showing. Selection is viewport ephemera the caller
/// owns (§5.3); the widget renders the chosen tab, and tab-switch clicks live
/// in the shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepTab {
    Meta,
    Request,
    Staging,
    Response,
    Tools,
}

/// Render the step list, then — when the caller supplies the selected step's
/// [`StepDetail`] — the tabbed drill-in. `collapsed` is the caller-owned
/// jsonview collapse state (§5.3), threaded into every rendered tree.
pub fn render(
    ui: &mut egui::Ui,
    view: &StepsView,
    selected: Option<usize>,
    detail: Option<&StepDetail>,
    tab: StepTab,
    collapsed: &mut HashSet<String>,
) {
    egui::ScrollArea::vertical().show(ui, |ui| {
        if view.steps.is_empty() {
            ui.label("(no steps yet)");
        }
        for (i, step) in view.steps.iter().enumerate() {
            render_summary(ui, step, Some(i) == selected);
        }
        if let Some(detail) = detail {
            ui.separator();
            render_detail(ui, detail, tab, collapsed);
        }
    });
}

fn render_summary(ui: &mut egui::Ui, step: &StepSummary, selected: bool) {
    ui.horizontal(|ui| {
        let (glyph, color) = summary_badge(step);
        ui.colored_label(color, glyph);
        let marker = if selected { "▶" } else { " " };
        ui.monospace(format!("{marker} {}", step.seq));
        ui.label(format!("{} attempts", step.attempts));
        ui.label(format!("{} tok", step.tokens.total_tokens()));
        // The §7.3 no-response wound, spelled out beside the step: without it a
        // `0 attempts · 0 tok` row reads as a quiet step (bl-7f2e).
        if step.no_response {
            ui.colored_label(theme::ICHOR, super::NO_RESPONSE);
        }
        if let Some(commit) = &step.commit {
            ui.monospace(short(commit));
        }
        if let Some(started) = &step.started_at {
            ui.weak(started);
        }
        if let Some(ended) = &step.ended_at {
            ui.weak(format!("→ {ended}"));
        }
    });
}

/// The row's badge. The §7.3 no-response wound outranks the framing read:
/// framing alone classifies it `Killed`, which paints the ash "stopped" badge
/// a mid-stream kill gets — exactly the quiet-step misreading the wound state
/// exists to correct.
fn summary_badge(step: &StepSummary) -> (&'static str, egui::Color32) {
    if step.no_response {
        ("✗", theme::ICHOR)
    } else {
        framing_badge(step.framing)
    }
}

/// Glyph + colour for each §4.4 framing outcome — the same visual grammar as
/// the git_tree state badges (✓ good / ✗ error / ■ dead).
fn framing_badge(framing: Framing) -> (&'static str, egui::Color32) {
    match framing {
        Framing::Complete => ("✓", theme::HYDRA),
        Framing::Failed => ("✗", theme::ICHOR),
        Framing::Killed => ("■", theme::ASH),
    }
}

fn short(oid: &str) -> String {
    oid.chars().take(SHORT_OID).collect()
}

fn render_detail(
    ui: &mut egui::Ui,
    detail: &StepDetail,
    tab: StepTab,
    collapsed: &mut HashSet<String>,
) {
    ui.horizontal(|ui| {
        for (which, label) in [
            (StepTab::Meta, "meta"),
            (StepTab::Request, "request"),
            (StepTab::Staging, "staging"),
            (StepTab::Response, "response"),
            (StepTab::Tools, "tools"),
        ] {
            let text = egui::RichText::new(label);
            ui.label(if which == tab { text.strong() } else { text });
        }
    });
    match tab {
        StepTab::Meta => render_doc(ui, &detail.meta, &format!("{}/meta", detail.seq), collapsed),
        StepTab::Request => render_doc(
            ui,
            &detail.request,
            &format!("{}/request", detail.seq),
            collapsed,
        ),
        StepTab::Staging => render_doc(
            ui,
            &detail.staging,
            &format!("{}/staging", detail.seq),
            collapsed,
        ),
        StepTab::Response => render_response(ui, &detail.response, &detail.seq, collapsed),
        StepTab::Tools => render_tools(ui, &detail.tools, &detail.seq, collapsed),
    }
}

/// A single record: a jsonview tree, or verbatim bytes when it didn't parse
/// (an empty raw doc — a missing file — reads as "(absent)").
fn render_doc(ui: &mut egui::Ui, doc: &Doc, root: &str, collapsed: &mut HashSet<String>) {
    match doc {
        Doc::Json(value) => crate::jsonview::render(ui, value, root, collapsed),
        Doc::Raw(bytes) if bytes.is_empty() => {
            ui.weak("(absent)");
        }
        Doc::Raw(bytes) => {
            ui.monospace(String::from_utf8_lossy(bytes));
        }
    }
}

fn render_response(ui: &mut egui::Ui, events: &[Doc], seq: &str, collapsed: &mut HashSet<String>) {
    if events.is_empty() {
        ui.label("(no events)");
    }
    for (i, event) in events.iter().enumerate() {
        render_doc(ui, event, &format!("{seq}/resp/{i}"), collapsed);
    }
}

fn render_tools(ui: &mut egui::Ui, tools: &[ToolIo], seq: &str, collapsed: &mut HashSet<String>) {
    if tools.is_empty() {
        ui.label("(no tool calls)");
    }
    for tool in tools {
        ui.horizontal(|ui| {
            let (glyph, color) = if tool.is_error {
                ("✗", theme::ICHOR)
            } else {
                ("✓", theme::HYDRA)
            };
            ui.colored_label(color, glyph);
            ui.monospace(&tool.tool_id);
        });
        ui.label("input");
        render_doc(
            ui,
            &tool.input,
            &format!("{seq}/{}/in", tool.tool_id),
            collapsed,
        );
        ui.label("output");
        render_doc(
            ui,
            &tool.output,
            &format!("{seq}/{}/out", tool.tool_id),
            collapsed,
        );
    }
}