travelagent 1.11.1

Agent-first TUI code review tool
//! Pre-event-loop helpers extracted from `main.rs` so the body of
//! `main()` reads as a sequence of named steps instead of an inline
//! 1,300-line waterfall. Each helper here covers a single concern of
//! the startup sequence: panic hook, config + theme load, second-stage
//! repo overrides, blind-tests filtering, sparring-mode entry,
//! config-driven `App` defaults, narrow-terminal default layout,
//! `--tour` plan seeding.
//!
//! All helpers are `pub(crate)` so future tests in
//! `crates/travelagent-tui/src/` can drive them directly without going
//! through `main()`.

use std::io;

use crossterm::{
    event::{DisableBracketedPaste, PopKeyboardEnhancementFlags},
    execute,
    terminal::{LeaveAlternateScreen, disable_raw_mode, supports_keyboard_enhancement},
};

use crate::app::{self, App, FocusedPanel};
use crate::cli::Cli;
use crate::theme::{Theme, resolve_theme_with_config};
use crate::{SparEntryOutcome, enter_spar_mode};
use travelagent_core::config::{AppConfig, ConfigLoadOutcome};

/// Install a panic hook that restores terminal state before the
/// default hook prints the panic message. Without this, a panic mid-
/// render leaves the user's terminal in raw mode with bracketed paste
/// and the alternate screen still active.
///
/// Idempotent in practice — calling it twice would chain the
/// previously-set hook, but `main()` only calls it once at startup.
pub(crate) fn install_panic_hook() {
    let original_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), DisableBracketedPaste, LeaveAlternateScreen);
        original_hook(panic_info);
    }));
}

/// `--path FOO` implies `--working-tree` unless an explicit `-r` was
/// also given. Mutates `cli` in place so the rest of `main()` sees the
/// inferred flag.
pub(crate) fn apply_path_filter_implies_working_tree(cli: &mut Cli) {
    if cli.path_filter.is_some() && !cli.working_tree && cli.revisions.is_none() {
        cli.working_tree = true;
    }
}

/// Result of resolving a `--resume` request: the saved session itself plus
/// the repo it belongs to and the identifier to echo in the exit hint. The
/// session is handed to [`crate::app::App::new_resumed`], which recomputes the
/// current diff for the session's diff-source and re-anchors the saved
/// comments + tour against it (tour index preserved — no `tour_start` reset).
pub(crate) struct ResumeTarget {
    /// Repo the session belongs to; `main()` chdirs here so `detect_vcs`
    /// finds the right repository even when `trv --resume` was run elsewhere.
    pub repo_path: std::path::PathBuf,
    /// The loaded session (comments, reviewed-state, tour, alias, …).
    pub session: travelagent_core::model::ReviewSession,
}

/// Resolve a `--resume [token]` request into a [`ResumeTarget`].
///
/// `token` semantics:
/// - empty (bare `--resume`) → most-recently-touched session overall;
/// - non-empty → alias match, else session-id prefix (see
///   [`travelagent_core::persistence::resolve_session_by_token`]).
///
/// Returns `Err(message)` when no session resolves, its repo is gone, or it's
/// a remote review (which has no local working tree to recompute), so
/// `main()` can print a clear error and exit rather than guessing.
pub(crate) fn resolve_resume_target(token: &str) -> Result<ResumeTarget, String> {
    use travelagent_core::model::SessionDiffSource;
    use travelagent_core::persistence::{load_most_recent_session, resolve_session_by_token};

    let resolved = if token.is_empty() {
        load_most_recent_session()
    } else {
        resolve_session_by_token(token)
    };

    let (_path, session) = match resolved {
        Ok(Some(found)) => found,
        Ok(None) => {
            return Err(if token.is_empty() {
                "No saved session to resume. Review some changes first, then `trv --resume`."
                    .to_string()
            } else {
                format!("No saved session matching '{token}' (tried alias, then session id).")
            });
        }
        Err(e) => return Err(format!("Couldn't read saved sessions: {e}")),
    };

    if !session.repo_path.is_dir() {
        return Err(format!(
            "Session's repo no longer exists at {}",
            session.repo_path.display()
        ));
    }

    if session.diff_source == SessionDiffSource::Remote {
        return Err(
            "That session is a remote PR/MR review; reopen it with `trv <pr-url>`.".to_string(),
        );
    }
    if matches!(
        session.diff_source,
        SessionDiffSource::CommitRange
            | SessionDiffSource::WorkingTreeAndCommits
            | SessionDiffSource::StagedUnstagedAndCommits
    ) && session.commit_range.as_ref().is_none_or(|r| r.is_empty())
    {
        return Err("Saved commit-range session has no commit range to replay.".to_string());
    }

    Ok(ResumeTarget {
        repo_path: session.repo_path.clone(),
        session,
    })
}

/// Probe whether the terminal supports the keyboard-enhancement
/// protocol. Skipped (returns `false`) when the TUI is rendering to
/// `/dev/tty` because `--stdout` / `--mcp-alongside` need stdout free
/// of escape sequences from the probe itself.
pub(crate) fn keyboard_enhancement_supported_for(render_to_tty: bool) -> bool {
    if render_to_tty {
        false
    } else {
        matches!(supports_keyboard_enhancement(), Ok(true))
    }
}

/// Load the global `config.toml` and resolve the theme. Returns the
/// parsed config outcome (with its own warnings folded into
/// `startup_warnings`), the resolved [`Theme`], and any theme-resolver
/// warnings appended to `startup_warnings`.
///
/// The config-load failure path is non-fatal: a corrupt `config.toml`
/// surfaces as a startup warning and we fall back to defaults so the
/// user can still launch `trv` and fix their config from inside.
pub(crate) fn load_global_config_and_theme(
    cli: &Cli,
    startup_warnings: &mut Vec<String>,
) -> (ConfigLoadOutcome, Theme) {
    let config_outcome = match travelagent_core::config::load_config() {
        Ok(outcome) => outcome,
        Err(e) => {
            startup_warnings.push(format!("Failed to load config: {e}"));
            travelagent_core::config::ConfigLoadOutcome::default()
        }
    };
    startup_warnings.extend(config_outcome.warnings.clone());
    let (theme, theme_warnings) = resolve_theme_with_config(
        cli.theme,
        cli.appearance,
        config_outcome
            .config
            .as_ref()
            .and_then(|cfg| cfg.theme.as_deref()),
        config_outcome
            .config
            .as_ref()
            .and_then(|cfg| cfg.theme_dark.as_deref()),
        config_outcome
            .config
            .as_ref()
            .and_then(|cfg| cfg.theme_light.as_deref()),
        config_outcome
            .config
            .as_ref()
            .and_then(|cfg| cfg.appearance.as_deref()),
    );
    startup_warnings.extend(theme_warnings);
    (config_outcome, theme)
}

/// Phase C: merge per-repo `<repo>/.travelagent/config.toml` overrides
/// on top of the already-loaded global config. Theme is intentionally
/// global-only — already resolved above the caller — so this
/// second-stage merge only touches non-theme fields. Skipped for demo
/// mode (no repo) and for remote-only mode where `vcs_info.root_path`
/// isn't a real on-disk root.
///
/// A malformed per-repo file degrades to a warning so the global
/// config still drives the session.
pub(crate) fn apply_repo_config_overrides(
    app: &mut App,
    cli: &Cli,
    config_outcome: &mut ConfigLoadOutcome,
    startup_warnings: &mut Vec<String>,
) {
    if cli.demo || !app.vcs_info.root_path.is_absolute() {
        return;
    }
    match travelagent_core::config::load_repo_config(&app.vcs_info.root_path) {
        Ok(repo_outcome) => {
            startup_warnings.extend(repo_outcome.warnings.clone());
            if let Some(repo_cfg) = repo_outcome.config {
                let mut merge_warnings: Vec<String> = Vec::new();
                let global_cfg = config_outcome.config.clone().unwrap_or_default();
                let merged = travelagent_core::config::merge_overrides(
                    global_cfg,
                    repo_cfg,
                    &repo_outcome.sections_present,
                    &mut merge_warnings,
                );
                startup_warnings.extend(merge_warnings);
                config_outcome.config = Some(merged);
                // Quiet breadcrumb so the human (and an agent reading
                // the status bar) knows per-repo config was applied.
                app.set_message(format!(
                    "Loaded repo overrides from {}",
                    repo_outcome.path.display()
                ));
            }
        }
        Err(e) => {
            startup_warnings.push(format!(
                "Warning: Failed to load per-repo config at {}: {e}",
                travelagent_core::config::repo_config_path(&app.vcs_info.root_path).display()
            ));
        }
    }
}

/// Phase I3b: load `.travelagent/review.toml` for the reviewer's
/// blind-tests patterns and, if `--blind-tests` is on, filter the
/// initial diff file list. Deliberately runs after
/// [`apply_repo_config_overrides`] so `vcs_info.root_path` is
/// authoritative. Skipped for demo and remote-only modes.
pub(crate) fn apply_blind_tests_config(
    app: &mut App,
    cli: &Cli,
    startup_warnings: &mut Vec<String>,
) {
    if cli.demo || !app.vcs_info.root_path.is_absolute() {
        return;
    }
    match travelagent_core::review_config::load_review_config(&app.vcs_info.root_path) {
        Ok(outcome) => {
            startup_warnings.extend(outcome.warnings);
            app.blind_patterns = outcome.config.hidden_from_reviewer;
            if cli.blind_tests {
                if app.blind_patterns.is_empty() {
                    startup_warnings.push(
                        "Warning: --blind-tests set but hidden_from_reviewer is empty in .travelagent/review.toml; nothing to hide".to_string(),
                    );
                } else {
                    app.blind_mode = true;
                    let matcher = travelagent_core::trvignore::matcher_from_patterns(
                        &app.vcs_info.root_path,
                        &app.blind_patterns,
                    );
                    let before = app.diff_files.len();
                    let filtered = travelagent_core::trvignore::filter_diff_files_with_matcher(
                        matcher.as_ref(),
                        std::mem::take(&mut app.diff_files),
                    );
                    let after = filtered.len();
                    app.diff_files = filtered;
                    app.set_message(format!(
                        "Blind-tests on: hiding {} file(s)",
                        before.saturating_sub(after)
                    ));
                }
            }
        }
        Err(e) => {
            startup_warnings.push(format!(
                "Warning: Failed to load {}: {e}",
                travelagent_core::review_config::review_config_path(&app.vcs_info.root_path)
                    .display()
            ));
        }
    }
}

/// Phase I1b: enter Sparring Review mode if `--spar` was passed.
/// Drives the `enter_spar_mode` outcome into status-bar messages and
/// startup warnings, matching the behaviour of the mid-session
/// `:spar` command. No-op when `--spar` was not set.
pub(crate) fn try_enter_spar_mode(app: &mut App, cli: &Cli, startup_warnings: &mut Vec<String>) {
    if !cli.spar {
        return;
    }
    match enter_spar_mode(app) {
        Ok(SparEntryOutcome::Created(branch)) => {
            app.spar_mode = true;
            app.set_message(format!("Sparring mode on: created branch {branch}"));
        }
        Ok(SparEntryOutcome::Resumed(branch)) => {
            app.spar_mode = true;
            app.set_message(format!("Sparring mode on: resumed branch {branch}"));
        }
        Ok(SparEntryOutcome::FlagOnly(reason)) => {
            app.spar_mode = true;
            startup_warnings.push(format!(
                "Warning: Sparring mode active as flag only — {}",
                reason.user_message()
            ));
        }
        Err(err) => {
            startup_warnings.push(format!("Warning: Could not enter sparring mode: {err}"));
        }
    }
}

/// Apply the parsed `[risk]`, `[auto_collapse]`, `[comment_templates]`
/// and assorted scalar config keys to the freshly-built `App`. Run
/// after the repo-config overrides so per-repo `[ui]`/`[diff_view]`
/// settings make it into the app. No-op when `config_outcome.config`
/// is `None`.
pub(crate) fn apply_config_defaults_to_app(app: &mut App, config: &AppConfig) {
    // The risk section drives tour-guide batching, the `:set tour=<preset>`
    // command, and the MCP commit-risk tools. Clone so the App owns an
    // authoritative copy independent of the caller's outcome.
    app.risk_config = config.risk.clone();
    app.invalidate_tour_score_cache();
    // [auto_collapse] drives the initial collapsed state of lockfile-like
    // and oversized files, and is consulted by the `z` keybinding.
    app.auto_collapse_cfg = config.auto_collapse.clone();
    // [comment_templates] drives the `Ctrl+T` template picker in comment mode.
    app.set_comment_templates(config.comment_templates.clone());
    if config.show_file_list == Some(false) {
        app.ui_layout.show_file_list = false;
        app.nav.focused_panel = FocusedPanel::Diff;
    }
    if let Some(width) = config.file_list_width {
        app.ui_layout.set_file_list_width_pct(width);
    }
    if config.diff_view.as_deref() == Some("side-by-side") {
        app.nav.diff_view_mode = app::DiffViewMode::SideBySide;
    }
    if config.wrap == Some(true) {
        app.set_diff_wrap(true);
    }
    if config.export_legend == Some(false) {
        app.export_legend = false;
    }
    if config.auto_stage == Some(true) {
        app.auto_stage = true;
    }
    if config.confirm_on_quit == Some(false) {
        app.confirm_on_quit = false;
    }
    if config.command_palette == Some(false) {
        app.ui_layout.command_palette = false;
    }
    if let Some(word_diff) = config.word_diff {
        app.word_diff_enabled = word_diff;
    }
    if let Some(md) = config.markdown_rendering {
        app.markdown_rendering_enabled = md;
    }
    app.mental_model_byte_limit = config.mental_model.byte_limit;
}

/// On narrow terminals, start with only the diff panel visible so the
/// review surface isn't an eyestrain-y postage stamp. Threshold lives
/// in [`crate::MIN_WIDTH_FOR_FILE_LIST`]. No-op when the terminal
/// width can't be probed (e.g. headless test harness).
pub(crate) fn apply_narrow_terminal_default(app: &mut App) {
    if let Ok((width, _)) = crossterm::terminal::size()
        && width < crate::MIN_WIDTH_FOR_FILE_LIST
    {
        app.ui_layout.show_file_list = false;
        app.nav.focused_panel = FocusedPanel::Diff;
    }
}

/// `--tour REVSET`: seed a default plan with one stop per commit and
/// an empty summary. Agents connected via `--mcp-alongside` can refine
/// the plan via `trv_tour_set_plan`. No-op unless `--tour` was passed
/// and the resolved diff source is a commit range.
pub(crate) fn seed_tour_plan_if_requested(app: &mut App, cli: &Cli) {
    if cli.tour.is_none() {
        return;
    }
    let app::DiffSource::CommitRange(ref commit_ids) = app.diff_source else {
        return;
    };
    let stops: Vec<travelagent_core::model::TourStop> = commit_ids
        .iter()
        .map(|sha| travelagent_core::model::TourStop {
            commit_ids: vec![sha.clone()],
            summary: String::new(),
            risk: travelagent_core::risk::RiskScore::MIN,
        })
        .collect();
    if !stops.is_empty() {
        let _ = app.tour_start(stops);
    }
}