yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The top bar (attention strip + workspace tab bar) and the conversation-list
//! side panel (§11 altitude 0). Coverage-excluded glue: the strip total, jump,
//! tab-bar build, conversation rows, and pin/collapse effects are all tested
//! `AppModel`/`nav` methods; this file only wires widgets.

use crate::AppModel;
use crate::cli_outbound::Cli;
use crate::nav::convs::{ConvRow, age_label};
use crate::nav::tabs::Tab;
use crate::nav::ws_key;
use crate::theme;

use super::ShellState;

/// The §11 top bar: left, the wordmark and attention strip (totals +
/// jump-to-next); right, the workspace tab bar — named tabs, the slim + mint,
/// and the foreign/replay overflow menu.
pub fn top_bar(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
) {
    ui.horizontal(|ui| {
        theme::wordmark(ui);
        ui.separator();
        let total = model.strip_total();
        if total > 0 {
            ui.colored_label(theme::BRAZEN, format!("⚑ {total} need attention"));
        } else {
            ui.weak("⚑ nothing stirs");
        }
        if ui.button("⏭ next").clicked() {
            model.jump_next_attention();
        }
        // The workspace tab bar, right-aligned under the top right (§11):
        // regime walls, almost invisible. Laid right-to-left, so paint the
        // overflow first, then +, then the tabs reversed (leftmost last).
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            let bar = model.tab_bar();
            overflow_menu(ui, model, &bar);
            if ui
                .button("+")
                .on_hover_text("new workspace (a fresh sphere wall)")
                .clicked()
            {
                // The deliberate mint (§3.4): always a fresh name, through the
                // one planner; the composer opens on success.
                let inputs = model.new_workspace_inputs();
                super::start_pane::run_prepare(model, &mut state.start, lernie, bl, inputs);
            }
            for tab in bar.tabs.iter().rev() {
                workspace_tab(ui, model, tab);
            }
        });
    });
}

/// One workspace tab: attention-badged name, selected = the focused workspace.
/// A pinned hoist offers unpin on middle-click via its context menu.
fn workspace_tab(ui: &mut egui::Ui, model: &mut AppModel, tab: &Tab) {
    let badge = if tab.attention > 0 {
        format!("⚑{} ", tab.attention)
    } else {
        String::new()
    };
    let suffix = if tab.replay { " ▶" } else { "" };
    let label = ui.selectable_label(tab.selected, format!("{badge}{}{suffix}", tab.name));
    if label.clicked() {
        model.focus_workspace(&tab.ws);
    }
    if tab.pinned {
        label.context_menu(|ui| {
            if ui.button("unpin").clicked() {
                model.toggle_pin(&ws_key(&tab.ws));
                ui.close_menu();
            }
        });
    }
}

/// The overflow menu (§11): foreign and replay workspaces — real but not
/// regimes. Selecting one focuses it; ★ pins it into the tab row. The button
/// carries the entries' aggregate attention and hides when the menu is empty.
fn overflow_menu(ui: &mut egui::Ui, model: &mut AppModel, bar: &crate::nav::tabs::TabBar) {
    if bar.overflow.is_empty() {
        return;
    }
    let attention = bar.overflow_attention();
    let title = if attention > 0 {
        format!("⋯ ⚑{attention}")
    } else {
        "⋯".to_owned()
    };
    ui.menu_button(title, |ui| {
        for entry in &bar.overflow {
            ui.horizontal(|ui| {
                let kind = if entry.replay { "replay" } else { "foreign" };
                let badge = if entry.attention > 0 {
                    format!(" ⚑{}", entry.attention)
                } else {
                    String::new()
                };
                if ui
                    .selectable_label(entry.selected, format!("{} · {kind}{badge}", entry.name))
                    .clicked()
                {
                    model.focus_workspace(&entry.ws);
                    ui.close_menu();
                }
                if ui.small_button("★").on_hover_text("pin as a tab").clicked() {
                    model.toggle_pin(&ws_key(&entry.ws));
                }
            });
        }
    });
}

/// The side panel (§11): the focused workspace's conversation list headed by
/// + conversation, then the collapsible balls section (start affordances +
/// bound balls), the Config entry, and the toolchain pane.
pub fn side_panel(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
    bz: &Cli,
) {
    conversations(ui, model, state);
    ui.separator();
    balls_section(ui, model, state, lernie, bl);
    // The Config entry (§11): toggles config mode, swapping the center to the
    // three editors (§9).
    if ui
        .selectable_label(state.config.active, "⚙ Config")
        .clicked()
    {
        state.config.active = !state.config.active;
    }
    ui.separator();
    // The phase-1 toolchain pane (§16.6 W5, §16.4): host-tool state + the
    // disclaimer (gates nothing), plus the §8.3 per-provider Login surface —
    // its streamed `bz --login` device flow rendered live.
    super::toolchain::pane(ui, model, &mut state.login, bz);
}

/// The conversation list (§11): + conversation, an organizing-view toggle, then
/// the rows — flat by recency (default) or grouped by ball — in the tested sort
/// (attention > running > recency). The toggle and the flat/grouped choice are
/// pure `AppModel`/`nav` derivations; this only paints them.
fn conversations(ui: &mut egui::Ui, model: &mut AppModel, state: &mut ShellState) {
    let Some(ws) = model.focused_workspace().map(std::path::Path::to_path_buf) else {
        ui.weak("no workspace yet — say the word");
        return;
    };
    if ui.button("+ conversation").clicked() {
        // Clear the agent selection (the composer then targets a new root,
        // §11) and hand the keyboard to the composer box.
        model.focus_workspace(&ws);
        state.focus_composer = true;
    }
    // The organizing-view toggle (§15 Z9): flat by recency | grouped by ball.
    ui.horizontal(|ui| {
        ui.selectable_value(&mut state.group_by_ball, false, "recent");
        ui.selectable_value(&mut state.group_by_ball, true, "by ball");
    });
    if model.conversations(now_unix()).is_empty() {
        ui.weak("no conversations yet");
        return;
    }
    let selected_root = selected_conversation_root(model);
    egui::ScrollArea::vertical().show(ui, |ui| {
        if state.group_by_ball {
            for group in model.conversation_groups(now_unix()) {
                super::conv_ball::group_header(ui, &group);
                for row in &group.convs {
                    conversation_row(ui, model, &ws, row, selected_root.as_deref());
                }
            }
        } else {
            for row in &model.conversations(now_unix()) {
                conversation_row(ui, model, &ws, row, selected_root.as_deref());
            }
        }
    });
}

/// The focused agent's conversation root — which row highlights (§11).
fn selected_conversation_root(model: &AppModel) -> Option<String> {
    let agent = model.focused_agent()?.agent_id.clone();
    let tree = model.focused_tree()?;
    crate::nav::convs::root_of(&tree.agents, &agent)
}

/// One conversation row: state badge (pulsing while streaming), first-line
/// preview, age, attention badge. Clicking focuses the root (the §6
/// acknowledgement gesture).
fn conversation_row(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    ws: &std::path::Path,
    row: &ConvRow,
    selected_root: Option<&str>,
) {
    ui.horizontal(|ui| {
        let (glyph, color) = theme::state_badge(row.state);
        let badge_color = if row.streaming {
            // The shared in-flight pulse (§11): every streaming row beats in step.
            let time = ui.ctx().input(|i| i.time);
            ui.ctx().request_repaint_after(theme::PULSE_REPAINT_DELAY);
            theme::pulse(time)
        } else {
            color
        };
        ui.colored_label(badge_color, glyph);
        if row.uncertain {
            ui.colored_label(theme::SIGIL, "?");
        }
        if row.attention > 0 {
            ui.colored_label(theme::BRAZEN, format!("⚑{}", row.attention));
        }
        let selected = selected_root == Some(row.root_id.as_str());
        let title = if row.preview.is_empty() {
            row.root_id.clone()
        } else {
            row.preview.clone()
        };
        if ui.selectable_label(selected, title).clicked() {
            model.focus_agent(ws, &row.root_id);
        }
        ui.weak(age_label(row.age_secs));
        if row.has_children() {
            ui.weak(format!("({})", row.members));
        }
        // The conversation's start-flow ball (§3.3), coloured by the §3.5 join.
        if let Some(ball) = &row.ball {
            super::conv_ball::row_badge(ui, ball);
        }
    });
}

/// The minimal collapsible balls section (§11): the start affordances and the
/// focused workspace's bound-ball rows; the full per-project views return in
/// the ball-views wave. The fold is the persisted §4.1 collapse override.
fn balls_section(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    state: &mut ShellState,
    lernie: &Cli,
    bl: &Cli,
) {
    let collapsed = model.is_collapsed("balls");
    let arrow = if collapsed { "▶" } else { "▼" };
    if ui
        .selectable_label(false, format!("{arrow} balls"))
        .clicked()
    {
        model.set_collapsed("balls", !collapsed);
    }
    if collapsed {
        return;
    }
    ui.indent("balls", |ui| {
        let mut show_internal = model.show_internal();
        if ui.checkbox(&mut show_internal, "internal clones").changed() {
            model.set_show_internal(show_internal);
        }
        super::start_pane::startable(ui, model, &mut state.start, lernie, bl);
        // The focused workspace's bound balls (§3.5 claimant join), id + badge.
        if let Some(ws) = model.focused_workspace().map(std::path::Path::to_path_buf) {
            for ball in model.ws_balls(&ws) {
                match &ball.badge {
                    Some(b) => ui.weak(format!("{} · {b}", ball.id)),
                    None => ui.weak(&ball.id),
                };
            }
        }
    });
}

/// Wall-clock unix seconds for the conversation ages (§11) — minted at the
/// shell boundary like [`super::now_ts`], so the view-models stay clock-free.
fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}