yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The start-flow shell surface (DESIGN §3.4, §8.1, §11): the ▶ Start affordance
//! on startable ball rows, the new-ball entry (title+body RAM drafts), and the
//! editable goal composer whose Send fires the detached prompt.
//!
//! Coverage-excluded interaction glue: every decision — the startable set, the
//! plan, the mint/create/claim/ensure orchestration ([`start::prepare`]), the
//! detached prompt ([`start::execute_prompt`]) — lives in tested modules
//! (`AppModel`, `crate::start`). This tree only wires widgets. The ▶ Start /
//! Create-&-Start paths route through the ball rung; the bare rung is the input
//! bar's Enter ([`super::input_bar`]); the path rung's picker is Z4's.

use super::StartState;
use crate::AppModel;
use crate::actions::verbs;
use crate::cli_outbound::Cli;
use crate::names::SplitMix64;
use crate::start::{self, BallSpec, Deps, Payload, StartInputs};
use std::path::{Path, PathBuf};

/// The start affordances in the balls section (§11): the empty-project hint,
/// a ▶ Start + Assign per ready ball, a ▶ Continue per bound ball, and a
/// per-project new-ball form. Clicking a start runs `prepare` and opens the
/// composer; Assign/Continue route through the covered verbs/planner. The
/// workspace mint moved to the tab bar's + ([`super::navigator`], §11).
pub fn startable(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    start: &mut StartState,
    lernie: &Cli,
    bl: &Cli,
) {
    // The start surface's last failure (§7.3): a failed seed/claim/mint step no
    // longer prints to a stderr nobody reads — it renders here in ichor red, and
    // the composer that would have opened stays closed with the reason shown.
    if let Some(failure) = &start.last_failure {
        super::failure_banner(ui, failure);
    }
    // STORIES S3-T5: with zero projects, the paved interim for adding one.
    if let Some(hint) = model.empty_project_hint() {
        ui.weak(format!("No projects yet — add one with: {hint}"));
    }
    let target = model.focused_ws_name();
    for inputs in model.startable() {
        ready_row(ui, model, start, lernie, bl, inputs, target.as_deref());
    }
    // ▶ Continue: resume a bound ball into its own workspace (§8.1, addendum).
    for inputs in model.resumable() {
        if ui
            .button(format!("▶ Continue {}", start_label(&inputs)))
            .clicked()
        {
            run_prepare(model, start, lernie, bl, inputs);
        }
    }
    ui.separator();
    for project in model.project_paths() {
        new_ball_form(ui, model, start, lernie, bl, &project);
    }
}

/// One ready ball: ▶ Start (the ball rung — claim + composer) and, when a
/// workspace is focused, Assign it there (`bl claim <id> --as <target>`, §8.2)
/// without starting a conversation.
fn ready_row(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    start: &mut StartState,
    lernie: &Cli,
    bl: &Cli,
    inputs: StartInputs,
    target: Option<&str>,
) {
    let assign =
        target.and_then(|to| ball_ref(&inputs.payload).map(|(p, id)| (p, id, to.to_owned())));
    ui.horizontal(|ui| {
        if ui.button(format!("▶ {}", start_label(&inputs))).clicked() {
            run_prepare(model, start, lernie, bl, inputs);
        } else if let Some((project, id, to)) = assign
            && ui.button(format!("assign → {to}")).clicked()
        {
            let gate = model.toolchain().clone();
            let sr = model.state_root().to_path_buf();
            let _ = verbs::assign(&gate, bl, &sr, &super::now_ts(), &project, &id, &to);
            model.after_bl_verb(&project);
            start.last_failure = model.last_failure();
        }
    });
}

/// The (project, id) of an existing-ball payload — the Assign target's ball;
/// `None` for a new-ball payload (nothing to assign yet).
fn ball_ref(payload: &Payload) -> Option<(PathBuf, String)> {
    match payload {
        Payload::Ball {
            project,
            ball: BallSpec::Existing { id, .. },
        } => Some((project.clone(), id.clone())),
        _ => None,
    }
}

/// The Start-button label for a ball-rung entry: `<id>: <title>` (existing) or
/// the title (new). Bare/path rungs are not offered here (they are the input
/// bar's Enter and Z4's picker).
fn start_label(inputs: &StartInputs) -> String {
    match &inputs.payload {
        Payload::Ball {
            ball: BallSpec::Existing { id, title, .. },
            ..
        } => format!("{id}: {title}"),
        Payload::Ball {
            ball: BallSpec::New { title, .. },
            ..
        } => title.clone(),
        Payload::Bare => "(new conversation)".to_owned(),
        Payload::Path { dir } => dir.display().to_string(),
    }
}

/// A per-project new-ball form (§8.1): title + body RAM drafts and a
/// Create-&-Start button that mints the ball and enters the start flow.
fn new_ball_form(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    start: &mut StartState,
    lernie: &Cli,
    bl: &Cli,
    project: &Path,
) {
    let (mut title, mut body) = start.new_ball.get(project).cloned().unwrap_or_default();
    let mut create = false;
    ui.collapsing(format!("+ new ball · {}", project.display()), |ui| {
        ui.text_edit_singleline(&mut title);
        ui.text_edit_multiline(&mut body);
        create = ui
            .add_enabled(
                crate::actions::create_ball_enabled(&title),
                egui::Button::new("Create & Start"),
            )
            .clicked();
    });
    if create {
        let inputs = model.new_ball_inputs(project, &title, &body);
        start.new_ball.remove(project);
        run_prepare(model, start, lernie, bl, inputs);
    } else {
        start.new_ball.insert(project.to_path_buf(), (title, body));
    }
}

/// Run `prepare` (mint?/seed?/new?/create?/claim?/compose) and, on success, open
/// the composer with its editable goal. On either outcome refresh the affected
/// project's balls + the ops tail so the mutations and their log lines are
/// visible at once (§8.2). The mint RNG is seeded from the held `mint_seed` so
/// the fire agrees with any preview (§3.3). `pub(super)`: the tab bar's +
/// mint ([`super::navigator`]) rides the same path.
pub(super) fn run_prepare(
    model: &mut AppModel,
    start: &mut StartState,
    lernie: &Cli,
    bl: &Cli,
    inputs: StartInputs,
) {
    let project = payload_project(&inputs.payload);
    let ts = super::now_ts();
    let mut rng = SplitMix64::from_seed(start.mint_seed);
    let result = {
        let deps = Deps {
            bl: bl.clone(),
            lernie: lernie.clone(),
            state_root: model.state_root().to_path_buf(),
            gate: model.toolchain().clone(),
        };
        start::prepare(&deps, &inputs, &mut rng, &ts)
    };
    if let Ok(prepared) = result {
        start.pending = Some(prepared);
    }
    match project {
        Some(p) => model.after_bl_verb(&p),
        None => model.after_lernie_verb(),
    }
    start.last_failure = model.last_failure();
}

/// The ball rung's project (for the after-verb ball refresh); `None` for the
/// bare/path rungs, which mutate no ball.
fn payload_project(payload: &Payload) -> Option<PathBuf> {
    match payload {
        Payload::Ball { project, .. } => Some(project.clone()),
        Payload::Bare | Payload::Path { .. } => None,
    }
}

/// The editable goal composer (§8.1, §3.3): the greyed identity preview, the
/// editable payload prefill, then Send fires `lernie prompt` detached (identity
/// stamped at fire, `YOG_NAME` layered); Cancel drops the draft.
pub fn composer(ui: &mut egui::Ui, model: &mut AppModel, start: &mut StartState, lernie: &Cli) {
    let Some(pending) = start.pending.as_mut() else {
        return;
    };
    let (mut send, mut cancel) = (false, false);
    ui.weak(start::identity_preview(&pending.name));
    ui.label(format!("Start goal → {}", pending.workspace.display()));
    egui::ScrollArea::vertical()
        .max_height(160.0)
        .show(ui, |ui| ui.text_edit_multiline(&mut pending.goal));
    ui.horizontal(|ui| {
        send = ui.button("Send (detached prompt)").clicked();
        cancel = ui.button("Cancel").clicked();
    });
    if send && let Some(p) = start.pending.take() {
        // The detached prompt's spawn (success or failure) rode its own ops line
        // (§8.1); the banner reads it back. A failed launch keeps the composer
        // open with the edited goal so the operator can retry (RAM until sent).
        let launched = start::execute_prompt(
            lernie,
            model.state_root(),
            &super::now_ts(),
            &p.name,
            &p.cwd,
            &p.workspace,
            &p.goal,
        )
        .is_ok();
        model.after_lernie_verb();
        start.last_failure = model.last_failure();
        if !launched {
            start.pending = Some(p);
        }
    } else if cancel {
        start.pending = None;
    }
}