argonauts-app 0.1.0

Argonauts Code's app controller and run host: the conversation, routing, choosing Captain or crew, and the run host each run has.
//! What every handler shares: the environment, the settings, the services, the app mode, and
//! the way out to the TUI.

use std::path::PathBuf;
use std::sync::Arc;

use argonauts_core::NoteLevel;
use argonauts_core::api::{Approvals, ConductorApi, StartRun};
use argonauts_core::app::{AppEvent, AppMode, UiCommand};
use argonauts_core::clock::Clock;
use argonauts_core::limits::{HealthBoard, PlanUsageBoard};
use argonauts_core::names::ChoiceId;
use argonauts_core::registry::ModelRegistry;
use argonauts_core::session_runner::RunnerDirectory;
use argonauts_core::settings::{SettingScope, Settings};
use argonauts_core::steps::StepRunner;
use tokio::sync::mpsc;

use super::conversation::TurnUpdate;
use super::runs::RunUpdate;
use crate::app_settings::AppSettings;
use crate::env::AppEnv;
use crate::setup::SetupUpdate;
use crate::trace::Trace;

pub(crate) struct AppServices {
    pub conductor: Arc<dyn ConductorApi>,
    /// `None` without a terminal or inside an agent, so nothing can be approved from here.
    pub approvals: Option<Arc<dyn Approvals>>,
    /// For the app's own sessions: conversation, the estimate, and the narrator.
    pub workers: Arc<dyn RunnerDirectory>,
    pub steps: Arc<dyn StepRunner>,
    pub registry: Arc<dyn ModelRegistry>,
    pub plan_usage: Arc<dyn PlanUsageBoard>,
    pub health: Arc<dyn HealthBoard>,
    pub clock: Arc<dyn Clock>,
}

/// Every `AppEvent` goes out through here, so the trace sees each one.
#[derive(Clone)]
pub(crate) struct Outbox {
    events: mpsc::UnboundedSender<AppEvent>,
    trace: Arc<Trace>,
}

impl Outbox {
    pub(crate) fn new(events: mpsc::UnboundedSender<AppEvent>, trace: Arc<Trace>) -> Self {
        Self { events, trace }
    }

    /// Never blocks. After the screen closed, events have nowhere to go and are dropped.
    pub(crate) fn send(&self, event: AppEvent) {
        self.trace.event(&event);
        let _ = self.events.send(event);
    }

    pub(crate) fn trace(&self) -> &Trace {
        &self.trace
    }
}

/// Messages from the tasks the handlers start: conversation turns, run streams, setup probes, and
/// model list refreshes.
pub(crate) enum Internal {
    Conversation(TurnUpdate),
    Run(RunUpdate),
    Setup(SetupUpdate),
    /// A mod file changed, or a mod's command printed something new.
    Mods,
    /// A `!` command printed or ended.
    Shell(super::shell::ShellUpdate),
    /// The CLIs' model lists were read again.
    ModelsRefreshed,
    /// One CLI's or endpoint's model list was read again.
    ModelSourceListed {
        cli: String,
    },
    /// While `/model`'s picker is open: time to ask the local servers again.
    ModelsTick,
    /// What this machine gives models and what the local ones take, or nothing where it
    /// couldn't be read.
    MemoryRead(Option<Box<super::settings::memory_facts::Reading>>),
    /// The workflow file `/workflow edit` watches was saved.
    WorkflowSaved {
        name: String,
    },
    /// The check of the goal the main agent works toward alone came back.
    Goal(super::conversation::GoalChecked),
    /// A CLI's plan usage was read live, so the bottom line shows the new reading.
    PlanUsageRead,
    /// The update of a CLI a run waits on has ended.
    CliUpdated(Box<super::needed_update::Updated>),
    /// What GitHub says of the pull requests Argonauts Code follows.
    Followed(Box<crate::issue::follow::Looked>),
    /// The deeper Behaviors map's update ended, so the map on screen is sent again: the
    /// deeper one when the tab started it.
    DeepMapMade {
        deeper: bool,
    },
}

/// What one part of the controller asks of another. The loop applies them in order.
#[derive(Debug)]
pub(crate) enum Effect {
    /// Start this run and attach to it. A CLI it would use that must be updated is asked about
    /// first, then the detected checks are offered, once.
    StartRun(Box<StartRun>),
    /// The main agent changed: its session carries on, or another CLI starts from what carries
    /// over.
    FreshConversation,
    /// A workflow of yours was written or removed, so the `/` menu lists the commands again.
    CommandsChanged,
    /// Settings in effect changed: the narrator hears of it, and a new header and bottom line
    /// go out.
    SettingsApplied { keys: Vec<String> },
    /// Ask where to save a setting, as an argument like `/effort high` does.
    AskScope(Box<ScopeRequest>),
    /// Save these `(key, TOML value)` settings where you picked, through the same confirming
    /// and trust steps as `/config`.
    SaveSettings {
        pairs: Vec<(String, String)>,
        scope: SettingScope,
    },
    /// Save these settings where you picked on a setup's question. The pick confirmed them, so a
    /// safety key among them asks nothing more.
    SaveConfirmedSettings {
        pairs: Vec<(String, String)>,
        scope: SettingScope,
    },
    /// Send a new header and bottom line.
    Status,
    /// Open the guided setup again (`/setup`).
    OpenSetup,
    /// The guided setup finished or was set aside.
    SetupDone,
    /// Input to handle as if you had just sent it.
    Input(Box<UiCommand>),
    /// Input typed while a conversation turn ran, handled now as if you had just sent it. It hears
    /// of the `!` commands up to `typed_after`, the last that had ended when you typed it.
    Queued {
        command: Box<UiCommand>,
        typed_after: u32,
    },
    /// Close the screen. Runs keep going.
    Exit,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopeRequest {
    pub key: String,
    /// TOML text.
    pub value: String,
    /// The settings command whose default scope applies, such as `fallback`.
    pub command: String,
}

pub(crate) struct Ctx {
    pub env: Arc<AppEnv>,
    pub settings: AppSettings,
    pub services: AppServices,
    pub mode: AppMode,
    pub out: Outbox,
    pub internal: mpsc::UnboundedSender<Internal>,
    next_choice: u32,
    /// Where `/cd` asked the app to open next, read once the screen has closed.
    reopen: Reopen,
    /// What the screen was last told to call each CLI and model.
    pub names: super::names::NamesSent,
    /// The runs' agents' sessions as their journals were last read, for `/resume`.
    pub agent_sessions: Arc<super::conversation::SessionCache>,
    /// Holds the computer's sleep off while anything works, and for `/awake`.
    pub awake: crate::keep_awake::KeepAwake,
    /// What the Status tab knows of the CLIs: Doctor's check, and who each is signed in as.
    pub cli_facts: Arc<super::status_tab::CliFacts>,
    /// The deeper Behaviors map's update, while one runs.
    pub deep: crate::behaviors::deep::tab::DeepWork,
}

/// Shared with whoever started the app, which outlives the controller.
pub(crate) type Reopen = Arc<std::sync::Mutex<Option<PathBuf>>>;

impl Ctx {
    pub(crate) fn new(
        env: Arc<AppEnv>,
        settings: AppSettings,
        services: AppServices,
        out: Outbox,
        internal: mpsc::UnboundedSender<Internal>,
    ) -> Self {
        Self {
            env,
            settings,
            services,
            mode: AppMode::Idle,
            out,
            internal,
            next_choice: 0,
            reopen: Reopen::default(),
            names: super::names::NamesSent::default(),
            agent_sessions: Arc::default(),
            // The app's own start gives it this computer's way to hold sleep off.
            awake: crate::keep_awake::KeepAwake::never(),
            cli_facts: Arc::default(),
            deep: Default::default(),
        }
    }

    /// The app opens again in `repo` once this screen closes.
    pub(crate) fn reopen_in(&self, repo: PathBuf) {
        *self
            .reopen
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(repo);
    }

    pub(crate) fn reopen(&self) -> Reopen {
        Arc::clone(&self.reopen)
    }

    pub(crate) fn settings(&self) -> &Settings {
        self.settings.settings()
    }

    pub(crate) fn send(&self, event: AppEvent) {
        self.out.send(event);
    }

    pub(crate) fn notice(&self, level: NoteLevel, text: impl Into<String>) {
        self.send(AppEvent::Notice {
            level,
            text: text.into(),
        });
    }

    /// The answer to a key that can't do anything now, for the notice line.
    pub(crate) fn hint(&self, text: impl Into<String>) {
        self.send(AppEvent::Hint { text: text.into() });
    }

    /// Every error a handler returns reaches you as a notice, naming files in the repo by
    /// their path from it.
    pub(crate) fn error(&self, err: &anyhow::Error) {
        self.notice(NoteLevel::Error, self.env.shown_in(&format!("{err:#}")));
    }

    /// Sends `Mode` only when it changed.
    pub(crate) fn set_mode(&mut self, mode: AppMode) {
        if self.mode != mode {
            // An answer or a run you watch is under way, so the computer stays awake.
            let working = matches!(
                mode,
                AppMode::Answering { .. } | AppMode::Working { .. } | AppMode::Pausing { .. }
            );
            let settings = self.settings().awake.clone();
            self.awake.working(working, &settings);
            self.mode = mode.clone();
            self.send(AppEvent::Mode { mode });
        }
    }

    pub(crate) fn next_choice_id(&mut self) -> ChoiceId {
        self.next_choice += 1;
        ChoiceId::numbered(self.next_choice)
    }
}