yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! User actions issued through `cli_outbound` (ARCH §3.4 / §3.5).
//!
//! This root holds every **enablement predicate** (pure, no egui): whether an
//! action is offered for the current selection. The **short, piped, logged**
//! verbs — message/stop/scan and `bl` close/unclaim/create/update — live in
//! [`verbs`], which appends each outcome to `ops.jsonl` (§8.2, §15 Y16); the
//! **detached** `lernie prompt` (the new-root launch, §8.1) is now the one
//! [`start::execute_prompt`](crate::start::execute_prompt) path (Y17 moved it
//! onto `spawn_detached`, unifying the composer's new-prompt with the start
//! flow's final prompt — one detached, logged launch, no piped-and-drained
//! variant whose `Stream` drop would SIGTERM the loop on yog's exit).
//!
//! Predicate discipline (§8.2): **Stop** needs a Live/InFlight executor to
//! signal ([`stop_enabled`]); **Message** is the resume gesture and works on
//! *any* selected agent ([`message_enabled`], ARCH §2.9 — no resume verb);
//! **Close** needs the ball bound to a local workspace ([`close_enabled`] =
//! `JoinState::Bound`); **Unclaim**/release and **Move** need the same
//! ([`unclaim_enabled`], [`move_enabled`]); **Assign** needs a ready, unclaimed
//! ball ([`assign_enabled`] = `JoinState::ReadyStartable`) — each predicate
//! refuses exactly what the underlying `bl` verb would (§8.2/§3.5); **Scan** is
//! unconditional (offered for any focused workspace — no predicate). All are pure
//! functions of inputs and carry no egui dependency, reusable by any future
//! frontend. Per §3.5 the UI holds no persistent state — `ActionsState` is
//! in-memory only and discarded on exit.

pub mod verbs;

use crate::git_tree::{Agent, AgentState};
use crate::projects::join::JoinState;
use std::io;

/// Ephemeral action-surface state. Held in memory by the running
/// frontend and discarded on exit (ARCH §3.5: frontends hold no
/// persistent state).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ActionsState {
    /// In-progress text for the composer (§11: one box, targeting either a
    /// new-root prompt or a message to the selected agent). Disabled while
    /// empty (or whitespace-only).
    pub new_prompt_input: String,
    /// The composer's optional **work directory** (§3.4 path rung, STORIES S2): a
    /// RAM draft. Empty ⇒ the bare rung; set ⇒ a new prompt fires `Payload::Path`
    /// with this directory as the target preamble + driver cwd.
    pub path_dir: String,
    /// User-selected agent id (§2.3 — the id is the address; `lernie
    /// stop`/`message` take it, not the `agents/*` ref). Gates Stop (which
    /// also needs the agent live) and Message.
    pub selected_branch: Option<String>,
    /// Whether Stop should cascade to the selected agent's descendants
    /// (§8.2 `--stop-children`; the §11 composer's children checkbox). Offered
    /// only when the agent actually has descendants ([`stop_children_offered`]).
    pub stop_children: bool,
    /// The composer surface's **last failure** (§5.3 RAM item, §7.3): the
    /// ichor-red banner it paints — argv + stderr tail of the most recent failed
    /// verb, refreshed from [`AppModel::last_failure`](crate::AppModel::last_failure)
    /// after every dispatch (`None` clears it). The durable fact is the ops line;
    /// this is the RAM projection the surface holds, so the two never diverge.
    pub last_failure: Option<crate::opslog::SurfaceFailure>,
}

/// True iff the new-prompt input has at least one non-whitespace
/// character. Pure derivation per §3.5; no I/O.
pub fn new_prompt_enabled(input: &str) -> bool {
    !input.trim().is_empty()
}

/// A new ball's Create & Start is offered iff its title is non-blank (§8.1): a
/// ball with no title has nothing to name the work. A distinct §3.5 rule from
/// [`new_prompt_enabled`] though the current bodies coincide (as [`close_enabled`]
/// / [`unclaim_enabled`] / [`move_enabled`] share `Bound`) — each names the rule
/// its `bl`/start path enforces. Covered here, not inlined in coverage-excluded
/// shell glue.
pub fn create_ball_enabled(title: &str) -> bool {
    !title.trim().is_empty()
}

/// A composer draft is RAM until it is cleanly *deposited* (§5.3, STORIES S1): a
/// message clears its draft iff the verb both launched (`Ok`) and exited 0
/// ([`Outcome::ok`](verbs::Outcome::ok)) — every failure keeps the text so the
/// operator can retry. Pinned here as a covered predicate, never in coverage-
/// excluded shell glue, so a regression that ate the draft on failure fails a
/// test instead of slipping through.
pub fn draft_clears(result: &io::Result<verbs::Outcome>) -> bool {
    result.as_ref().is_ok_and(verbs::Outcome::ok)
}

/// True iff `selected_branch` names an agent (by id, §2.3) in `agents`
/// that is **live** — [`AgentState::Live`] or [`AgentState::InFlight`],
/// the two states where a driver holds the executor lock (§2.11). Stop
/// targets a live executor (§2.9), and it is wanted precisely during tool
/// execution (a `Live` agent between model calls), not only mid-model-call
/// — so both live states are stoppable. Returns `false` for `None`, for an
/// id not present, and for a `Quiescent` or `Stopped` agent (no executor
/// to signal).
pub fn stop_enabled(selected_branch: Option<&str>, agents: &[Agent]) -> bool {
    let Some(name) = selected_branch else {
        return false;
    };
    agents
        .iter()
        .any(|a| a.agent_id == name && matches!(a.state, AgentState::Live | AgentState::InFlight))
}

/// Message is the resume gesture (§8.2, ARCH §2.9: no resume verb — the
/// deposit restarts a driver). Unlike [`stop_enabled`] it is *not* gated on
/// agent state: a Quiescent or Stopped agent is precisely what you message to
/// continue it. Enabled iff an agent is selected, present in `agents`, and the
/// composer text is non-blank. `false` for no selection, an id absent from the
/// set, or whitespace-only text.
pub fn message_enabled(selected: Option<&str>, content: &str, agents: &[Agent]) -> bool {
    !content.trim().is_empty()
        && selected.is_some_and(|name| agents.iter().any(|a| a.agent_id == name))
}

/// Stop offers `--stop-children` iff `agent_id` has a descendant in the id set
/// (§8.2) — another agent whose id extends `<agent_id>-…` (the hyphenated
/// descent, §2.3). Pure over the ids; no lone agent offers it.
pub fn stop_children_offered(agent_id: &str, agents: &[Agent]) -> bool {
    let prefix = format!("{agent_id}-");
    agents
        .iter()
        .any(|a| a.agent_id != agent_id && a.agent_id.starts_with(&prefix))
}

/// Close is offered iff the focused ball is bound to a local workspace — exactly
/// [`JoinState::Bound`] (§8.2, §3.5): the ball's claimant names a workspace here,
/// so there is a loop to deliver from.
pub fn close_enabled(state: JoinState) -> bool {
    matches!(state, JoinState::Bound)
}

/// Unclaim is offered iff the focused ball is bound to a local workspace —
/// [`JoinState::Bound`] (§8.2, §3.5). Release is `bl unclaim <id> --as <name>`.
pub fn unclaim_enabled(state: JoinState) -> bool {
    matches!(state, JoinState::Bound)
}

/// Assign is offered iff the ball is **ready and unclaimed** —
/// [`JoinState::ReadyStartable`] (§8.2, §3.5: "ready ball → ▶ Start or Assign to
/// an existing workspace"). Assign binds an unbound ball, so a bound / blocked /
/// claimed-elsewhere / delivered ball refuses — exactly what `bl claim` refuses.
pub fn assign_enabled(state: JoinState) -> bool {
    matches!(state, JoinState::ReadyStartable)
}

/// Move is offered iff the ball is bound to a local workspace —
/// [`JoinState::Bound`] (§8.2, §3.5). Move is unclaim-then-claim (release + assign
/// to another workspace): only a ball this yog owns can be re-homed, so an
/// unclaimed or claimed-elsewhere ball refuses (what `bl unclaim` would refuse).
pub fn move_enabled(state: JoinState) -> bool {
    matches!(state, JoinState::Bound)
}

#[cfg(test)]
mod tests;