yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The composer, docked bottom (§11, §8.2). Coverage-excluded glue: it wires
//! one text box and the short-verb buttons to the tested dispatchers
//! ([`crate::actions::verbs`], [`crate::start`]) and enablement predicates,
//! then asks the model for the on-demand refresh so the operator sees the
//! outcome at once.
//!
//! **The target follows the selection** (§11): a selected agent ⇒ Enter sends
//! a message (the resume gesture); none ⇒ Enter fires a new conversation (a
//! detached prompt into the focused workspace) — one box, one Enter, the
//! S0/S1 gesture. The dir (path-rung) field and the ball actions ride beside
//! it unchanged.
//!
//! No error is ever printed and dropped (INV-2): a spawn failure, a gate
//! failure, and a clean run all leave the same durable `ops.jsonl` line, and the
//! composer re-derives its ichor-red last-failure banner from it after every
//! dispatch ([`AppModel::last_failure`], §7.3). The draft survives a failure —
//! it is RAM until *sent* (§5.3), so a draft only clears on a clean send.

use crate::AppModel;
use crate::actions::{
    ActionsState, draft_clears, message_enabled, new_prompt_enabled, stop_children_offered,
    stop_enabled, verbs,
};
use crate::cli_outbound::{Binary, Cli};
use crate::git_tree::Agent;
use crate::names::SplitMix64;
use crate::start;
use std::io;
use std::path::{Path, PathBuf};

use super::ShellState;

/// The bottom composer (§11): the target line, the one box (Enter fires), the
/// verb buttons, and the ball actions for the focused workspace.
pub fn composer(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
) {
    let Some(ws) = model.focused_workspace().map(PathBuf::from) else {
        return;
    };
    let agents: Vec<Agent> = model
        .focused_tree()
        .map(|t| t.agents.clone())
        .unwrap_or_default();
    // The selection bridge (§11): the conversation list / descent tree pick the
    // target; the composer never grows its own picker.
    state.actions.selected_branch = model.focused_agent().map(|a| a.agent_id.clone());
    let target = state.actions.selected_branch.clone();
    let mint_seed = state.start.mint_seed;

    if let Some(agent) = target.as_deref() {
        ui.weak(format!("→ message {agent}"));
    } else {
        // A new conversation: the greyed identity preview (§3.3), stable
        // frame-to-frame off the held mint seed, plus the optional dir.
        ui.weak(
            start::preview(
                &model.start_bare_inputs(),
                &mut SplitMix64::from_seed(mint_seed),
            )
            .preview,
        );
        ui.horizontal(|ui| {
            ui.label("dir (optional):");
            ui.text_edit_singleline(&mut state.actions.path_dir);
        });
    }

    let text = state.actions.new_prompt_input.clone();
    let edit = ui.add(
        egui::TextEdit::singleline(&mut state.actions.new_prompt_input)
            .desired_width(f32::INFINITY)
            .hint_text(match target {
                Some(_) => "message the selected conversation",
                None => "start a conversation",
            }),
    );
    // The + conversation hand-off (§11): grab the box on the requested frame.
    if state.focus_composer {
        edit.request_focus();
        state.focus_composer = false;
    }
    let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
    let ctx = VerbCtx {
        ws,
        agents,
        text,
        entered,
        mint_seed,
    };
    verb_buttons(ui, model, state, lernie, bl, &ctx);

    let join = model.focused_join().cloned();
    super::ball_bar::actions(ui, model, &mut state.actions, bl, join.as_ref());
    if let Some(failure) = &state.actions.last_failure {
        super::failure_banner(ui, failure);
    }
}

/// The per-frame verb context, bundled (owned — no borrow rides a struct, per
/// the no-named-lifetimes rule) to keep the fns under the argument cap.
struct VerbCtx {
    ws: PathBuf,
    agents: Vec<Agent>,
    text: String,
    entered: bool,
    mint_seed: u64,
}

/// Send (Enter or click; message when an agent is selected, else new
/// conversation), Scan, and Stop (+children) for a selected live agent.
fn verb_buttons(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
    ctx: &VerbCtx,
) {
    let actions = &mut state.actions;
    let selected = actions.selected_branch.clone();
    let state_root = model.state_root().to_path_buf();
    // The phase-1 gate (§16.6 W5, §16.4): a Mismatch/Missing on `lernie` refuses
    // its verbs while rendering continues. The owned clone is the gate the
    // tested dispatchers consult; `lernie_ok` is only the cosmetic greying.
    let gate = model.toolchain().clone();
    let lernie_ok = model.toolchain().permits(Binary::Lernie);
    ui.horizontal(|ui| {
        if let Some(agent) = selected.as_deref() {
            let msg_on = message_enabled(Some(agent), &ctx.text, &ctx.agents) && lernie_ok;
            let send = ui
                .add_enabled(msg_on, egui::Button::new("Message"))
                .clicked();
            if msg_on && (send || ctx.entered) {
                // The draft is RAM until *sent*: clear only on a clean
                // deposit (§5.3).
                let sent = verbs::message(
                    &gate,
                    lernie,
                    &state_root,
                    &super::now_ts(),
                    &ctx.ws,
                    agent,
                    &ctx.text,
                );
                if after_lernie(sent, model, actions) {
                    actions.new_prompt_input.clear();
                }
            }
            stop_controls(
                ui,
                model,
                actions,
                lernie,
                (&gate, lernie_ok),
                &ctx.ws,
                &ctx.agents,
            );
        } else {
            let on = new_prompt_enabled(&ctx.text) && lernie_ok;
            let send = ui
                .add_enabled(on, egui::Button::new("New prompt"))
                .clicked();
            if on && (send || ctx.entered) {
                // The bare / path rung (§3.4): Enter → prompt into the focused
                // workspace. A non-empty dir fires the path rung; the whole
                // flow rides the one planner (§8.1), read back below.
                if super::fire::fire_start(model, actions, lernie, bl, ctx.mint_seed, &ctx.text) {
                    actions.new_prompt_input.clear();
                    actions.path_dir.clear();
                }
            }
        }
        if ui
            .add_enabled(lernie_ok, egui::Button::new("Scan"))
            .clicked()
        {
            let scanned = verbs::scan(&gate, lernie, &state_root, &super::now_ts(), &ctx.ws);
            after_lernie(scanned, model, actions);
        }
    });
}

/// Stop (+ the children cascade when descendants exist) for the selected agent.
fn stop_controls(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    actions: &mut ActionsState,
    lernie: &Cli,
    gate: (&crate::world::toolgate::ToolchainState, bool),
    ws: &Path,
    agents: &[Agent],
) {
    let Some(agent) = actions.selected_branch.clone() else {
        return;
    };
    if stop_children_offered(&agent, agents) {
        ui.checkbox(&mut actions.stop_children, "children");
    }
    let stop_on = stop_enabled(Some(&agent), agents) && gate.1;
    if ui.add_enabled(stop_on, egui::Button::new("Stop")).clicked() {
        let state_root = model.state_root().to_path_buf();
        let stopped = verbs::stop(
            gate.0,
            lernie,
            &state_root,
            &super::now_ts(),
            ws,
            &agent,
            actions.stop_children,
        );
        after_lernie(stopped, model, actions);
    }
}

/// Fold a `lernie` verb's aftermath: refresh the ops tail and re-derive the
/// composer's last-failure banner (§5.3/§7.3); return whether it cleanly
/// succeeded ([`draft_clears`], the covered predicate) so a draft clears only on a
/// clean send. The `Outcome` is otherwise discarded — every result is the durable
/// ops line the pane and banner read.
fn after_lernie(
    result: io::Result<verbs::Outcome>,
    model: &mut AppModel,
    actions: &mut ActionsState,
) -> bool {
    model.after_lernie_verb();
    actions.last_failure = model.last_failure();
    draft_clears(&result)
}