claux 20260908.0.0

Terminal AI coding assistant with tool execution
//! TUI module with screen-based architecture.
//!
//! Each screen (home, chat) is self-contained with its own state, drawing,
//! and key handling. The top-level loop dispatches between screens based
//! on the Action returned by each.

pub mod chat;
pub mod completion;
pub mod home;
mod input;
pub mod markdown;
mod screen;
mod terminal;
mod tool_display;
mod ui;

use anyhow::Result;
use std::sync::Arc;

use crate::config::{Config, HookTrigger, ModelBinding, ProviderKind, ResolvedModel};
use crate::context;
use crate::db::Db;
use crate::plugin::PluginRegistry;
use crate::query::Engine;
use crate::theme::Theme;

use screen::Action;
use terminal::TerminalGuard;

/// Run the TUI application. Provider construction is deferred until a
/// session is opened so Home remains available without startup credentials.
pub async fn run(
    config: &Config,
    plugins: Arc<PluginRegistry>,
    models: Vec<ResolvedModel>,
) -> Result<()> {
    if models.is_empty() {
        anyhow::bail!("no models configured; set `model` in ~/.config/claux/config.toml");
    }

    // Open database
    let db_path = crate::session::db_path()?;
    let db = Db::open(&db_path)?;

    let shutdown = crate::shutdown::TuiShutdown::listen()?;
    let tui_logs = crate::logging::TuiLogs::defer();
    let mut terminal_guard = TerminalGuard::enter()?;

    let theme = Theme::dark();
    let mut engine: Option<Engine> = None;
    let mut engine_binding: Option<ModelBinding> = None;
    let mut home_notice = None;
    let mut active_session = None;
    let mut forced_shutdown = false;

    let app_result: Result<()> = {
        let run = async {
            // Screen loop: home -> chat -> home -> ...
            let mut next_action = Action::Home;
            loop {
                if shutdown.token.is_cancelled() {
                    return Ok(());
                }
                // Only the currently running chat owns unsaved engine state.
                // Provider setup for a new session must not overwrite an old one.
                active_session = None;
                tracing::debug!("TUI action: {next_action:?}");
                match next_action {
                    Action::Home => {
                        let mut home_screen =
                            home::HomeScreen::new(Db::open(&db_path)?, theme, models.clone());
                        if let Some(notice) = home_notice.take() {
                            home_screen.set_notice(notice);
                        }
                        next_action =
                            home_screen.run(terminal_guard.terminal_mut(), &shutdown.token)?;
                    }
                    Action::Chat { session_id } => {
                        let session = db.get_session(&session_id)?.ok_or_else(|| {
                            anyhow::anyhow!("session {session_id} no longer exists")
                        })?;
                        let resolved = match session.model_binding.as_ref() {
                            Some(binding) => config.resolve_binding(binding),
                            None => config.resolve_model(&session.model).map_err(|error| {
                                anyhow::anyhow!(
                                    "Legacy session '{}' uses model '{}': {error}",
                                    session.id,
                                    session.model
                                )
                            }),
                        };
                        let resolved = match resolved {
                            Ok(resolved) => resolved,
                            Err(error) => {
                                home_notice = Some(format!(
                                "Cannot open '{}': {error}. Restore its profile or create a new chat.",
                                session.name.as_deref().unwrap_or(&session.id)
                            ));
                                next_action = Action::Home;
                                continue;
                            }
                        };
                        if engine_binding.as_ref() != Some(&resolved.binding) {
                            match crate::build_engine(config, &resolved, plugins.clone()).await {
                                Ok(new_engine) => {
                                    engine = Some(new_engine);
                                    engine_binding = Some(resolved.binding.clone());
                                }
                                Err(error) => {
                                    home_notice = Some(format!(
                                        "Cannot open '{}': {error}",
                                        session.name.as_deref().unwrap_or(&session.id)
                                    ));
                                    next_action = Action::Home;
                                    continue;
                                }
                            }
                        }
                        let engine = engine.as_mut().expect("engine initialized");
                        let system_prompt = context::build_system_prompt_for_model(
                            &resolved.binding.model,
                            Some(&plugins),
                            &HookTrigger::OnContextBuild,
                            resolved.binding.provider_kind == ProviderKind::Anthropic,
                            config.is_project_trusted(),
                        )
                        .await?;
                        engine.set_system_prompt(system_prompt);
                        active_session = Some(session_id.clone());
                        next_action = chat::run(
                            engine,
                            &session_id,
                            &db,
                            terminal_guard.terminal_mut(),
                            theme,
                            &models,
                            &shutdown.token,
                        )
                        .await?;
                        engine_binding = engine.model_binding().cloned();
                    }
                    Action::SwitchModel {
                        session_id,
                        selector,
                    } => {
                        let session = db.get_session(&session_id)?.ok_or_else(|| {
                            anyhow::anyhow!("session {session_id} no longer exists")
                        })?;
                        match config.resolve_model(&selector) {
                            Ok(resolved) => {
                                match crate::build_engine(config, &resolved, plugins.clone()).await
                                {
                                    Ok(new_engine) => {
                                        // Only persist the selection after its
                                        // provider and credentials validate. A
                                        // typo or unavailable key must not strand
                                        // an otherwise usable saved session.
                                        db.update_session_binding(&session_id, &resolved.binding)?;
                                        engine = Some(new_engine);
                                        engine_binding = Some(resolved.binding);
                                        next_action = Action::Chat { session_id };
                                    }
                                    Err(error) => {
                                        home_notice = Some(format!(
                                            "Cannot switch '{}': {error}",
                                            session.name.as_deref().unwrap_or(&session.id)
                                        ));
                                        next_action = Action::Home;
                                    }
                                }
                            }
                            Err(error) => {
                                home_notice = Some(format!(
                                    "Cannot switch '{}': {error}",
                                    session.name.as_deref().unwrap_or(&session.id)
                                ));
                                next_action = Action::Home;
                            }
                        }
                    }
                    Action::Quit => return Ok(()),
                }
            }
        };
        tokio::pin!(run);
        tokio::select! {
            result = &mut run => result,
            _ = shutdown.token.cancelled() => {
                match tokio::time::timeout(std::time::Duration::from_secs(5), &mut run).await {
                    Ok(result) => result,
                    Err(_) => {
                        forced_shutdown = true;
                        Err(anyhow::anyhow!("Shutdown did not complete within five seconds."))
                    }
                }
            }
        }
    };

    let restore_result = terminal_guard.restore();
    // Drop the guard before flushing diagnostics, including on restore errors.
    drop(terminal_guard);
    drop(tui_logs);
    if forced_shutdown {
        if let Some(engine) = &engine {
            engine.jobs().shutdown().await;
        }
    }

    if forced_shutdown || app_result.is_err() {
        if let (Some(session_id), Some(engine)) = (&active_session, &engine) {
            let messages = crate::session::repair_history(engine.messages().to_vec());
            if let Err(error) = db.save_snapshot(session_id, &messages, engine.model_binding()) {
                match crate::session::write_recovery(session_id, &messages, engine.model_binding()) {
                    Ok(path) => eprintln!("Session save failed: {error}. Recovery JSON saved to {}. Copy it somewhere permanent; temporary files may be cleaned up.", path.display()),
                    Err(recovery_error) => eprintln!("Session save failed: {error}. Recovery export also failed: {recovery_error}. Unsaved messages could not be preserved."),
                }
            }
        }
    }

    app_result?;
    restore_result?;

    if let Some(engine) = engine {
        println!("{}", engine.cost.format_summary());
    }
    Ok(())
}