yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The selected-conversation center panel (§11 altitude 1). Coverage-excluded
//! glue: the conversation members, the auth-failed detection, and every
//! inspector view-model are tested (`AppModel`, `nav::convs`, `login::auth`,
//! `inspector`); this file only wires widgets. The composer and the activity
//! accessory are bottom panels ([`super::input_bar`], [`super::activity`]) —
//! yog's own plumbing never renders inline between conversation content.

use crate::AppModel;
use crate::budgets;
use crate::cli_outbound::Cli;
use crate::git_tree::Agent;
use crate::nav::convs::root_of;
use crate::theme;
use std::path::{Path, PathBuf};

use super::ShellState;

/// Render the center: the selected conversation — header (root id, member
/// count, whole-tree budget spend), the descent tree only when children exist,
/// the inline Login banner on an auth-failed latest step, and the Altitude-2
/// inspector (Transcript first).
pub fn center(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
    bz: &Cli,
) {
    let Some(ws) = model.focused_workspace().map(PathBuf::from) else {
        bootstrap(ui, model, state, lernie, bl);
        return;
    };
    // "Replay is not a mode": the same view renders it, only the mutating
    // composer is withheld — a replay is read-only (§3.1).
    if model.focused_is_replay() {
        ui.weak("replay · read-only");
    }
    let Some(agent) = model.focused_agent() else {
        ui.weak("select a conversation — or start one below");
        return;
    };
    let agent_id = agent.agent_id.clone();
    let agent_state = agent.state;
    let agents: Vec<Agent> = model
        .focused_tree()
        .map(|t| t.agents.clone())
        .unwrap_or_default();
    let root = root_of(&agents, &agent_id).unwrap_or_else(|| agent_id.clone());
    header(ui, model, &ws, &root);
    members_tree(ui, model, &ws, &agents, &agent_id);
    // A conversation whose latest step is an auth-shaped failure banners Login
    // inline (§11/§8.3) — the Z8 machinery, one click away where the wound is.
    if crate::login::auth::latest_step_auth_failed(&ws, &agent_id, agent_state) {
        ui.colored_label(
            theme::ICHOR,
            "⚠ the last step failed on credentials — log in below",
        );
        let state_root = model.state_root().to_path_buf();
        super::toolchain::login_section(ui, &mut state.login, bz, &state_root);
    }
    // A conversation whose driver died before the model said anything banners
    // the §7.3 wound here rather than leaving a `0 attempts · 0 tok` step to
    // read as quiet (bl-7f2e). It names where the cause lives — the driver's
    // own stderr, captured to its per-spawn sink (§8.1) and folded into the
    // activity trail below — instead of offering to re-run the prompt (§14).
    if crate::steps_view::latest_step_no_response(&ws, &agent_id, agent_state) {
        ui.colored_label(
            theme::ICHOR,
            format!(
                "⚠ {} — the driver's own stderr is in the activity trail below",
                crate::steps_view::NO_RESPONSE
            ),
        );
    }
    ui.separator();
    super::inspector::tabs_and_content(ui, model, &mut state.inspector, &ws);
}

/// The conversation header (§11): root id + whole-tree budget-spent figures, the
/// conversation's own start-flow ball (§3.3, title/status/badge, coloured by the
/// §3.5 join), and the workspace-level bound balls (§3.2 claimant join — balls the
/// agents picked up, attributed to the workspace, not any one conversation).
fn header(ui: &mut egui::Ui, model: &AppModel, ws: &Path, root: &str) {
    ui.heading(root);
    // The per-conversation ball (source 1: the goal stamp), its title and status.
    if let Some(ball) = model.conversation_ball(root) {
        super::conv_ball::header_ball(ui, &ball);
    }
    // Workspace-level balls (§3.2): the claimant join, all the balls this
    // workspace's agents bound — the "one or more" per workspace, distinct from
    // the single per-conversation goal stamp above.
    let ws_balls = model.ws_balls(ws);
    if !ws_balls.is_empty() {
        ui.horizontal(|ui| {
            ui.weak("workspace balls:");
            for ball in &ws_balls {
                match &ball.badge {
                    Some(b) => ui.weak(format!("{} · {b}", ball.id)),
                    None => ui.weak(&ball.id),
                };
            }
        });
    }
    let spend = budgets::spend(ws, root);
    budgets::render(ui, &spend);
}

/// The compact descent tree — rendered **only when the conversation has
/// children** (§11): one selectable row per member (badge, ✉n, pulsing tool
/// chip); selecting is the §6 acknowledgement gesture and retargets the
/// inspector.
fn members_tree(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    ws: &Path,
    agents: &[Agent],
    selected: &str,
) {
    let rows = model.conversation_members();
    if rows.len() <= 1 {
        return;
    }
    for row in rows {
        let Some(agent) = agents.get(row.index) else {
            continue;
        };
        member_row(ui, model, ws, agent, row.depth, selected);
    }
}

/// One member row: indent, state badge (+ §10 "?"), the selectable id, marks,
/// pending mail, and a pulsing chip per in-flight tool call.
fn member_row(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    ws: &Path,
    agent: &Agent,
    depth: usize,
    selected: &str,
) {
    ui.horizontal(|ui| {
        if depth > 0 {
            ui.monospace("  ".repeat(depth));
        }
        let (glyph, color) = theme::state_badge(agent.state);
        ui.colored_label(color, glyph);
        if agent.state_uncertain {
            ui.colored_label(theme::SIGIL, "?");
        }
        let is_selected = agent.agent_id == selected;
        if ui.selectable_label(is_selected, &agent.agent_id).clicked() {
            model.focus_agent(ws, &agent.agent_id);
        }
        if agent.conflicted_oid.is_some() {
            ui.colored_label(theme::ICHOR, "declined-transfer");
        }
        if agent.budget_oid.is_some() {
            ui.colored_label(theme::BRAZEN, "budget-exhausted");
        }
        if agent.pending_messages > 0 {
            ui.colored_label(theme::BRAZEN, format!("✉{}", agent.pending_messages));
        }
        for call in &agent.tool_calls {
            if call.state == crate::git_tree::ToolCallState::InFlight {
                let time = ui.ctx().input(|i| i.time);
                ui.colored_label(theme::pulse(time), format!("⚙ {}", call.tool_id));
                ui.ctx().request_repaint_after(theme::PULSE_REPAINT_DELAY);
            }
        }
    });
}

/// The empty-world placeholder + bootstrap composer (§3.4, STORIES S0): the
/// wordmark, the greyed `You are <name>.` preview (the pre-mint prediction), and
/// one box whose Enter runs the bare-rung bootstrap — mint, `lernie new`, and the
/// detached prompt — through the same planner. No wizard, no dead end.
fn bootstrap(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
) {
    let seed = state.start.mint_seed;
    ui.vertical_centered(|ui| {
        crate::theme::wordmark(ui);
        ui.weak(crate::theme::TAGLINE);
        let inputs = model.start_bare_inputs();
        ui.weak(
            crate::start::preview(&inputs, &mut crate::names::SplitMix64::from_seed(seed)).preview,
        );
    });
    let text = state.actions.new_prompt_input.clone();
    // Stack the composer vertically (STORIES S0 step 1): invitation, full-width
    // box, then a visible Start — so it fits altitude-1 at the 900px default
    // window instead of running off-screen in one horizontal row.
    ui.label("start a conversation:");
    let edit = ui.add(
        egui::TextEdit::singleline(&mut state.actions.new_prompt_input)
            .desired_width(f32::INFINITY)
            .hint_text("say what you want done"),
    );
    // Open focused: grab the box once, the first frame the bootstrap appears, so
    // the operator can type without a click — a once-only memory flag, never
    // re-stealing focus on later frames.
    let focused_once = egui::Id::new("bootstrap-composer-focused");
    if !ui.data(|d| d.get_temp::<bool>(focused_once).unwrap_or(false)) {
        edit.request_focus();
        ui.data_mut(|d| d.insert_temp(focused_once, true));
    }
    // Enter (lost_focus + Enter) or the Start button both ride the same bare-rung
    // fire (§3.4); the draft clears only on a clean send.
    let submit_enabled = crate::actions::new_prompt_enabled(&text);
    let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
    let clicked = ui
        .add_enabled(submit_enabled, egui::Button::new("Start"))
        .clicked();
    if submit_enabled
        && (clicked || entered)
        && super::fire::fire_bare(model, &mut state.actions, lernie, bl, seed, &text)
    {
        state.actions.new_prompt_input.clear();
    }
    if let Some(failure) = &state.actions.last_failure {
        super::failure_banner(ui, failure);
    }
}