yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The roster, attention strip, and seen-acknowledgement surface of
//! [`AppModel`] (DESIGN §6, §11, §15 Y11).
//!
//! All read paths derive attention from the snapshot map plus the one `ui.json`
//! query the module needs — "is this evidence oid acknowledged?" — so the
//! seen-lookup closure is built here from [`UiState::is_seen`] and handed to the
//! pure [`attention`] functions. The write path is the **acknowledgement
//! gesture**: focusing an agent ([`focus_agent`](AppModel::focus_agent)) records
//! that agent's current evidence oids as seen (§6) — the durable, converging
//! ack both instances replicate (§13.1), never mirrored live focus.

use super::{AppModel, Focus};
use crate::attention;
use crate::git_tree::AgentState;
use crate::keymap::InspectorTab;
use crate::nav::{self, ws_key};
use crate::ui_state::SeenKind;
use std::path::{Path, PathBuf};

/// Which roster entry a keyboard/jump gesture lands on (§6, §11): the next
/// attention-bearing entry, or a plain ±`delta` step. Both share the roster
/// build in [`AppModel::pick_roster`]; only the final selection differs.
enum Pick {
    NextAttention,
    Step(isize),
}

impl AppModel {
    /// Per-workspace roster facts (§6 rollup): attention-bearing agent count,
    /// total agent count, and whether any agent is running (Live/InFlight). An
    /// unfetched workspace contributes zeros.
    pub fn workspace_stats(&self, ws: &Path) -> (usize, usize, bool) {
        let Some(tree) = self.trees.get(ws) else {
            return (0, 0, false);
        };
        let key = ws_key(ws);
        let seen = |k, w: &str, a: &str, o: &str| self.ui.is_seen(k, w, a, o);
        let mut attention = 0;
        let mut running = false;
        for a in &tree.agents {
            if attention::attention(a, &key, &seen).any() {
                attention += 1;
            }
            running |= matches!(a.state, AgentState::Live | AgentState::InFlight);
        }
        (attention, tree.agents.len(), running)
    }

    /// The §6 attention-strip total: attention-bearing agents across every
    /// workspace.
    pub fn strip_total(&self) -> usize {
        self.workspaces
            .iter()
            .map(|w| self.workspace_stats(&w.path).0)
            .sum()
    }

    /// The §11 workspace tab bar (pinned hoists + named tabs; foreign/replay in
    /// the overflow), built from the classification + attention rollups + the
    /// `ui.json` pin order.
    pub fn tab_bar(&self) -> nav::tabs::TabBar {
        let items: Vec<nav::tabs::Item> = self
            .workspaces
            .iter()
            .map(|w| nav::tabs::Item {
                ws: w.clone(),
                attention: self.workspace_stats(&w.path).0,
            })
            .collect();
        nav::tabs::build(&items, &self.ui.pinned(), self.focus.ws.as_deref())
    }

    /// The §11 conversation list for the focused workspace: one row per root
    /// agent, subtree-aggregated, sorted attention > running > recency.
    /// `now_unix` is the caller's wall clock (the shell boundary mints it, so
    /// the derivation stays clock-free here).
    pub fn conversations(&self, now_unix: i64) -> Vec<nav::convs::ConvRow> {
        let Some(ws) = self.focus.ws.as_deref() else {
            return Vec::new();
        };
        let Some(tree) = self.trees.get(ws) else {
            return Vec::new();
        };
        let key = ws_key(ws);
        let seen = |k, w: &str, a: &str, o: &str| self.ui.is_seen(k, w, a, o);
        let ball = |id: &str| self.resolve_conv_ball(id);
        nav::convs::build(&tree.agents, &key, &seen, now_unix, &ball)
    }

    /// The selected conversation's member rows (§11 center: the descent tree,
    /// rendered only when children exist): the subtree of the focused agent's
    /// conversation **root**, so selecting a child keeps the whole conversation
    /// on screen. Empty when nothing is focused.
    pub fn conversation_members(&self) -> Vec<crate::git_tree::DescentRow> {
        let Some(agent) = self.focus.agent.as_deref() else {
            return Vec::new();
        };
        let Some(tree) = self.focused_tree() else {
            return Vec::new();
        };
        match nav::convs::root_of(&tree.agents, agent) {
            Some(root) => nav::convs::members(&tree.agents, &root),
            None => Vec::new(),
        }
    }

    /// The §11 activity-accessory summary over the cached ops tail — the
    /// collapsed chip's counts, its ⚠ being the **live** failures only (§6's
    /// retirement rule); the expansion renders [`ops_rows`](Self::ops_rows).
    pub fn activity(&self) -> crate::opslog::Activity {
        crate::opslog::activity(&self.ops)
    }

    /// Whether a left-panel section carries a persisted collapse override
    /// (§4.1 `collapsed` — the balls section's key).
    pub fn is_collapsed(&self, key: &str) -> bool {
        self.ui.is_collapsed(key)
    }

    /// Focus a workspace (center-panel target) without selecting an agent — no
    /// acknowledgement (§6: focus records seen only for a *selected agent*). The
    /// selected inspector tab is sticky across the move (§11).
    pub fn focus_workspace(&mut self, ws: &Path) {
        self.focus = Focus {
            ws: Some(ws.to_path_buf()),
            agent: None,
            tab: self.focus.tab,
        };
    }

    /// Focus an agent — the **acknowledgement gesture** (§6): select it in the
    /// inspector and record its current evidence oids as seen, converging the
    /// ack across instances (§13.1). The selected inspector tab is sticky (§11).
    pub fn focus_agent(&mut self, ws: &Path, agent_id: &str) {
        self.focus = Focus {
            ws: Some(ws.to_path_buf()),
            agent: Some(agent_id.to_string()),
            tab: self.focus.tab,
        };
        self.record_seen(ws, agent_id);
    }

    /// The selected §11 Altitude-2 inspector tab (RAM, §5.3) — the digit-key
    /// nav target and the shell's tab-strip highlight.
    pub fn inspector_tab(&self) -> InspectorTab {
        self.focus.tab
    }

    /// Select an inspector tab (§11 digit keys / tab-strip click). Viewport
    /// ephemera: no `ui.json` write, no acknowledgement.
    pub fn select_tab(&mut self, tab: InspectorTab) {
        self.focus.tab = tab;
    }

    /// Step the focus ±`delta` entries through the roster's flattened
    /// (workspace, agent) order (§11 ↑/↓), landing on and acknowledging the
    /// target agent via the [`focus_agent`](Self::focus_agent) path (§6). An
    /// empty roster is a no-op.
    pub fn roster_step(&mut self, delta: isize) {
        if let Some((ws, agent)) = self.pick_roster(Pick::Step(delta)) {
            self.focus_agent(&ws, &agent);
        }
    }

    /// The agent with `id` in `ws`'s snapshot, if both are present.
    fn agent_in(&self, ws: &Path, id: &str) -> Option<&crate::git_tree::Agent> {
        self.trees.get(ws)?.agents.iter().find(|a| a.agent_id == id)
    }

    /// Record every present evidence oid for `(ws, agent)` as seen (§6): notify,
    /// stop (the branch tip, unless abandoned), budget, conflicted. Recording
    /// the *current* oid acknowledges it; a later moved ref re-arms (§4.1). A
    /// phantom agent contributes no evidence, so this is a no-op for it.
    fn record_seen(&mut self, ws: &Path, agent_id: &str) {
        let key = ws_key(ws);
        for (kind, oid) in self.evidence_oids(ws, agent_id) {
            self.ui.record_seen(kind, &key, agent_id, &oid);
        }
    }

    /// The present `(kind, oid)` acknowledgement evidence for `(ws, agent)` — an
    /// owned list so [`record_seen`](Self::record_seen)'s mutable `ui` write does
    /// not overlap the snapshot borrow. Empty when the agent is absent.
    fn evidence_oids(&self, ws: &Path, agent_id: &str) -> Vec<(SeenKind, String)> {
        let mut out = Vec::new();
        if let Some(agent) = self.agent_in(ws, agent_id) {
            let stopped = (agent.state == AgentState::Stopped && agent.abandoned_oid.is_none())
                .then(|| agent.tip_oid.clone());
            let evidence = [
                (SeenKind::Notify, agent.notify_oid.clone()),
                (SeenKind::Stopped, stopped),
                (SeenKind::Budget, agent.budget_oid.clone()),
                (SeenKind::Conflicted, agent.conflicted_oid.clone()),
            ];
            for (kind, oid) in evidence {
                if let Some(oid) = oid {
                    out.push((kind, oid));
                }
            }
        }
        out
    }

    /// Jump-to-next-attention (§6): advance focus to the next attention-bearing
    /// agent after the current focus (wrapping), and acknowledge it — the strip
    /// control that walks the operator through everything that needs them.
    pub fn jump_next_attention(&mut self) {
        if let Some((ws, agent)) = self.pick_roster(Pick::NextAttention) {
            self.focus_agent(&ws, &agent);
        }
    }

    /// Build the §6 flattened roster and select one entry from it, returning
    /// owned ids — an inner scope so the roster's borrows of the snapshot map
    /// release before the mutating [`focus_agent`]. The [`Pick`] chooses the
    /// selector (next-attention vs a ±step), the only difference between the
    /// jump control and keyboard ↑/↓.
    fn pick_roster(&self, pick: Pick) -> Option<(PathBuf, String)> {
        let mut keyed: Vec<(PathBuf, String)> = Vec::new();
        for w in &self.workspaces {
            if self.trees.contains_key(&w.path) {
                keyed.push((w.path.clone(), ws_key(&w.path)));
            }
        }
        keyed.sort_by(|a, b| a.1.cmp(&b.1));
        let wss: Vec<(&str, &[crate::git_tree::Agent])> = keyed
            .iter()
            .filter_map(|(p, k)| self.trees.get(p).map(|t| (k.as_str(), t.agents.as_slice())))
            .collect();
        let seen = |k, w: &str, a: &str, o: &str| self.ui.is_seen(k, w, a, o);
        let roster = attention::roster_order(&wss, &seen);
        let key = match pick {
            Pick::NextAttention => attention::next_attention(&roster, self.focus_pos()),
            Pick::Step(delta) => attention::step(&roster, self.focus_pos(), delta),
        };
        key.map(|k| (PathBuf::from(k.ws), k.agent_id))
    }

    /// The current focus as a `(ws, agent)` position (both a workspace and an
    /// agent selected), else `None` — the jump then starts from the front.
    fn focus_pos(&self) -> Option<(&str, &str)> {
        let ws = self.focus.ws.as_deref()?.to_str()?;
        let agent = self.focus.agent.as_deref()?;
        Some((ws, agent))
    }

    /// Toggle `key`'s pin (§4.1 `pinned`, user order): appended when unpinned,
    /// removed when pinned. Durable, converging via `ui.json`.
    pub fn toggle_pin(&mut self, key: &str) {
        let mut pins = self.ui.pinned();
        match pins.iter().position(|k| k == key) {
            Some(i) => {
                pins.remove(i);
            }
            None => pins.push(key.to_string()),
        }
        self.ui.set_pinned(pins);
    }

    /// Set a collapse override for a roster section `key` (§4.1 `collapsed`).
    pub fn set_collapsed(&mut self, key: &str, collapsed: bool) {
        self.ui.set_collapsed(key, collapsed);
    }

    /// Force any pending `ui.json` change to disk (on a dispatched gesture or
    /// window close) — the write the other instance adopts.
    pub fn flush_ui(&mut self) -> std::io::Result<bool> {
        self.ui.flush()
    }

    /// Whether `(kind, ws, agent, oid)` is acknowledged in `ui.json` (§6) — the
    /// seen-watermark query, exposed for the convergence proof.
    pub fn is_seen(&self, kind: SeenKind, ws: &str, agent: &str, oid: &str) -> bool {
        self.ui.is_seen(kind, ws, agent, oid)
    }
}