yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Interaction glue for the conversation-first shell (DESIGN §11 three
//! altitudes): the top bar (attention strip + workspace tab bar), the
//! conversation-list side panel (navigator), the selected-conversation center
//! (workspace), the bottom composer + activity accessory, and the short-verb
//! dispatch (input bar, §8.2).
//!
//! Pure egui — `Response::clicked()` is unreachable in headless tests, so this
//! tree is coverage-excluded alongside `main.rs` (the established precedent,
//! §12). Everything a click *calls* — attention rollups, the tab bar and
//! conversation list builds, the seen-acknowledgement, pin/collapse mutations,
//! the verb dispatchers + their enablement predicates, the ball fetch/join —
//! lives in tested modules (`AppModel`, `nav`, `attention`, `actions`,
//! `opslog`); this tree only wires widgets.

mod activity;
mod ball_bar;
mod config_edit;
mod config_marks;
mod conv_ball;
mod fire;
mod input_bar;
mod inspector;
mod navigator;
mod start_pane;
mod toolchain;
mod workspace;

#[cfg(test)]
mod acceptance;

pub use config_edit::ConfigState;

use crate::AppModel;
use crate::actions::ActionsState;
use crate::cli_outbound::Cli;
use crate::keymap::{self, Key, KeyAction};
use crate::start::Prepared;
use crate::steps_view::StepTab;
use crate::xdg::Env;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

/// The transient start-flow input (RAM, §5.3 carve-out): the per-project
/// new-ball drafts and, after [`start_pane`] runs `prepare`, the pending
/// detached prompt — its editable goal and the (workspace, worktree) it fires
/// against. Discarded on exit; nothing here is durable (§8.1 draft is RAM).
#[derive(Default)]
pub struct StartState {
    /// New-ball (title, body) drafts keyed by project path.
    pub new_ball: HashMap<PathBuf, (String, String)>,
    /// The composer's editable goal + targets, `Some` once `prepare` succeeds.
    pub pending: Option<Prepared>,
    /// The name-mint RNG seed (RAM, §5.3): held stable across frames so the
    /// bare-rung preview (`You are <name>.`, §3.3) predicts the same name each
    /// frame *and* at fire — a fresh `SplitMix64::from_seed(mint_seed)` for both
    /// the pure preview read and [`start::prepare`]. Seeded once from entropy.
    pub mint_seed: u64,
    /// The start surface's **last failure** (§5.3 RAM item, §7.3): the ichor-red
    /// banner it paints — argv + stderr tail of the most recent failed start step
    /// or detached prompt, refreshed from [`AppModel::last_failure`] after each
    /// attempt (`None` clears it). The proven wound this closes: a failing seed
    /// step printed to stderr and the composer silently never opened.
    pub last_failure: Option<crate::opslog::SurfaceFailure>,
}

/// The §11 Altitude-2 inspector's RAM ephemera (§5.3, per-instance viewport
/// state — *which data you look at*, never durable): the Transcript Raw toggle,
/// the Steps selection + drill-in tab, the jsonview collapse set threaded into
/// the Steps trees, and the Files selected-entry index. All re-derive at
/// startup; nothing here reaches `ui.json`.
pub struct InspectorState {
    pub raw: bool,
    pub step_sel: Option<usize>,
    pub step_tab: StepTab,
    pub json_collapsed: HashSet<String>,
    /// The Files tab's selected-entry index (drives its preview, §11).
    pub files_sel: Option<usize>,
}

impl Default for InspectorState {
    fn default() -> Self {
        Self {
            raw: false,
            step_sel: None,
            step_tab: StepTab::Meta,
            json_collapsed: HashSet::new(),
            files_sel: None,
        }
    }
}

/// The toolchain pane's login surface RAM (§5.3, §8.3): the provider rows derived
/// from `bz --dump-config` (cached, #20/#21) and the one active streamed
/// `bz --login` run, if any. Instance-local by nature — a device code is for the
/// human at *this* keyboard — and discarded on exit.
#[derive(Default)]
pub struct LoginHolder {
    pub providers: Vec<String>,
    pub run: Option<crate::login::LoginRun>,
}

/// Every RAM surface the shell owns across frames (§3.5: the frontend holds no
/// durable state; this is all discarded on exit). Bundled so the window's one
/// render entry takes a single mutable handle instead of a widening param list.
pub struct ShellState {
    pub actions: ActionsState,
    pub start: StartState,
    pub inspector: InspectorState,
    pub config: ConfigState,
    pub login: LoginHolder,
    /// One-shot request to focus the composer's text box (§11: the +
    /// conversation affordance); consumed by the composer on its next frame.
    pub focus_composer: bool,
    /// The conversation-list organizing view (§11, §15 Z9): `false` = flat by
    /// recency (the default), `true` = grouped by ball. Viewport ephemera (§13.1):
    /// which ordering you look at, not data — RAM, no `ui.json` field.
    pub group_by_ball: bool,
}

impl ShellState {
    /// Fold the config editors from the env snapshot (their paths + runners);
    /// the rest default. A missing brazen/lernie file loads as an empty draft,
    /// not an error (§9), so this only fails on an unexpected io error.
    pub fn new(env: &Env) -> std::io::Result<Self> {
        Ok(Self {
            actions: ActionsState::default(),
            start: StartState {
                mint_seed: entropy_seed(),
                ..StartState::default()
            },
            inspector: InspectorState::default(),
            config: ConfigState::new(env)?,
            login: LoginHolder::default(),
            focus_composer: false,
            group_by_ball: false,
        })
    }
}

/// Render the whole window (§11): the top bar (attention strip + workspace tab
/// bar), the activity accessory and composer (bottom), the conversation-list
/// side panel, and the selected conversation (center). `lernie`/`bl` are the
/// two mutating-verb binaries (§8.2); `bz` drives the §8.3 Login surfaces.
pub fn render(
    ctx: &egui::Context,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
    bz: &Cli,
) {
    handle_keys(ctx, model);
    egui::TopBottomPanel::top("top-bar")
        .show(ctx, |ui| navigator::top_bar(ui, model, state, lernie, bl));
    // Bottom accessories stack outermost-first: activity at the very bottom
    // (§11 — the demoted ops pane, never inline in conversation space), then
    // the composer, then — when a start is mid-flight — the editable goal.
    egui::TopBottomPanel::bottom("activity").show(ctx, |ui| activity::accessory(ui, model));
    let composer_open =
        model.focused_workspace().is_some() && !model.focused_is_replay() && !state.config.active;
    if composer_open {
        // A multi-row default: a panel's first frame is its default height
        // (content height only lands the next frame), so without this the
        // composer's verb row is culled for one frame at every appearance.
        egui::TopBottomPanel::bottom("composer")
            .default_height(96.0)
            .show(ctx, |ui| {
                input_bar::composer(ui, model, state, lernie, bl);
            });
    }
    if state.start.pending.is_some() {
        egui::TopBottomPanel::bottom("start-composer")
            .default_height(240.0)
            .show(ctx, |ui| {
                start_pane::composer(ui, model, &mut state.start, lernie);
            });
    }
    // Bound the side panel's default width so altitude-1 (the center) has room
    // at the 900px default window; the panel stays resizable.
    egui::SidePanel::left("conversations")
        .default_width(260.0)
        .show(ctx, |ui| {
            navigator::side_panel(ui, model, state, lernie, bl, bz);
        });
    egui::CentralPanel::default().show(ctx, |ui| {
        if state.config.active {
            config_edit::center(ui, model, &mut state.config, lernie);
        } else {
            workspace::center(ui, model, state, lernie, bl, bz);
        }
    });
}

/// egui `Key` → digit value for the tab-select keys (§11); the shell's half of
/// the keymap split, the excluded event plumbing.
const DIGIT_KEYS: [(egui::Key, u8); 9] = [
    (egui::Key::Num1, 1),
    (egui::Key::Num2, 2),
    (egui::Key::Num3, 3),
    (egui::Key::Num4, 4),
    (egui::Key::Num5, 5),
    (egui::Key::Num6, 6),
    (egui::Key::Num7, 7),
    (egui::Key::Num8, 8),
    (egui::Key::Num9, 9),
];

/// Lift keyboard-nav presses out of the egui frame and dispatch them through
/// the pure [`keymap`] (§11). Suppressed while a text field wants keyboard
/// input, so typing in the composer never steals ↑/↓/digits. Excluded event
/// plumbing: the mapping ([`keymap::keymap`]) and the [`AppModel`] calls it
/// targets are tested; only this egui lift is not.
fn handle_keys(ctx: &egui::Context, model: &mut AppModel) {
    if ctx.wants_keyboard_input() {
        return;
    }
    let pressed: Vec<Key> = ctx.input(|i| {
        let mut keys = Vec::new();
        if i.key_pressed(egui::Key::ArrowUp) {
            keys.push(Key::Up);
        }
        if i.key_pressed(egui::Key::ArrowDown) {
            keys.push(Key::Down);
        }
        for (egui_key, n) in DIGIT_KEYS {
            if i.key_pressed(egui_key) {
                keys.push(Key::Digit(n));
            }
        }
        keys
    });
    for action in pressed.into_iter().filter_map(keymap::keymap) {
        match action {
            KeyAction::RosterPrev => model.roster_step(-1),
            KeyAction::RosterNext => model.roster_step(1),
            KeyAction::Tab(tab) => model.select_tab(tab),
        }
    }
}

/// Paint a surface's last-failure banner (§7.3): the attempted argv and its
/// stderr tail in ichor red (`theme::ICHOR` — never a restated RGB). The
/// originating surface calls this with the [`SurfaceFailure`] it holds (§5.3);
/// the durable fact is the expandable ops-pane row. Excluded shell paint — the
/// view-model it renders ([`AppModel::last_failure`]) is covered.
pub(super) fn failure_banner(ui: &mut egui::Ui, failure: &crate::opslog::SurfaceFailure) {
    ui.colored_label(crate::theme::ICHOR, format!("⚠ {}", failure.argv));
    if !failure.stderr_tail.is_empty() {
        ui.colored_label(crate::theme::ICHOR, &failure.stderr_tail);
    }
}

/// A one-shot entropy seed for the name-mint RNG (§3.1), held stable in
/// [`StartState::mint_seed`] so the bare-rung preview and its fire agree. Not
/// secret — the mint is collision-avoidance, and the occupied-set check is what
/// guarantees uniqueness (mirrors `names::SplitMix64::from_entropy`).
fn entropy_seed() -> u64 {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_nanos() as u64);
    nanos ^ (u64::from(std::process::id()) << 32)
}

/// The wall-clock `ops.jsonl` timestamp (§4.2), minted at the shell boundary so
/// the verb dispatchers stay clock-free and testable. Unix seconds as a string
/// — the crate's timestamp convention (`git_tree` renders `committerdate:unix`
/// the same way); opslog treats `ts` as opaque, so no date dependency is pulled.
pub(crate) fn now_ts() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs())
        .to_string()
}