travelagent 1.10.2

Agent-first TUI code review tool
//! Mental-model modal handlers (Phase I2, Sparring Review).
//!
//! Lifted out of `handler/mod.rs` so the modal's open/edit/commit
//! flow lives next to the tests that exercise it. The functions are
//! re-exported at `crate::handler::*` so the call sites in
//! `main.rs`, the MCP bridge, and the command dispatcher keep
//! working unchanged — the split is a source-organisation refactor,
//! not an API change.

use crate::app::{self, App, InputMode};
use crate::input::Action;
use travelagent_core::persistence::save_session;

/// Seed the mental-model modal's draft buffers from the session's
/// existing model (if any) and enter [`InputMode::MentalModelEdit`].
/// Triggered by the `m` chord in Normal mode.
pub fn open_mental_model_modal(app: &mut App) {
    // Seed from any existing model so the user can edit rather than
    // re-enter. A missing model means four empty drafts — the modal
    // still opens cleanly.
    let drafts = match app.engine.session().mental_model.as_ref() {
        Some(mm) => [
            mm.should_do.clone(),
            mm.shouldnt_do.clone(),
            mm.could_go_wrong.clone(),
            mm.assumptions.clone(),
        ],
        None => Default::default(),
    };
    app.mental_model_edit = app::MentalModelEditState { drafts, focused: 0 };
    app.nav.input_mode = InputMode::MentalModelEdit;
}

/// Handle actions dispatched while the mental-model modal is open
/// ([`InputMode::MentalModelEdit`]). Tab/Shift+Tab cycle focus;
/// printable keys append to the focused draft (bounded at
/// `app.mental_model_byte_limit`); Ctrl+S commits the draft into
/// `session.mental_model`; Esc discards the draft.
pub fn handle_mental_model_edit_action(app: &mut App, action: Action) {
    match action {
        Action::MentalModelCancel => {
            app.mental_model_edit = app::MentalModelEditState::default();
            app.nav.input_mode = InputMode::Normal;
        }
        Action::MentalModelSave => {
            use travelagent_core::model::MentalModel;
            let now = chrono::Utc::now();
            let drafts = app.mental_model_edit.drafts.clone();
            // `commit_mental_model` handles empty-collapse, created_at
            // preservation, and stamping `updated_at` — same contract
            // the MCP approve path uses.
            app.engine.session_mut().commit_mental_model(
                MentalModel {
                    should_do: drafts[0].clone(),
                    shouldnt_do: drafts[1].clone(),
                    could_go_wrong: drafts[2].clone(),
                    assumptions: drafts[3].clone(),
                    created_at: now,
                    updated_at: now,
                },
                now,
            );
            app.dirty = true;
            // Persist immediately so the user doesn't lose the
            // reflection if the TUI exits ungracefully before the next
            // comment save. On failure, keep the modal open so the user
            // can copy the text out or retry instead of losing their
            // reflection to a reset draft buffer.
            if let Err(err) = save_session(app.engine.session()) {
                app.set_error(format!("failed to persist mental model: {err}"));
                return;
            }
            app.mental_model_edit = app::MentalModelEditState::default();
            app.nav.input_mode = InputMode::Normal;
        }
        Action::MentalModelNextField => {
            app.mental_model_edit.focused =
                (app.mental_model_edit.focused + 1) % app.mental_model_edit.drafts.len();
        }
        Action::MentalModelPrevField => {
            let n = app.mental_model_edit.drafts.len();
            app.mental_model_edit.focused = (app.mental_model_edit.focused + n - 1) % n;
        }
        Action::MentalModelInsertChar(c) => {
            let focused = app.mental_model_edit.focused;
            let buf = &mut app.mental_model_edit.drafts[focused];
            // Cap at the configured byte_limit. Safe for UTF-8 since we
            // only reject once the next char would overflow.
            if buf.len() + c.len_utf8() <= app.mental_model_byte_limit {
                buf.push(c);
            }
        }
        Action::MentalModelBackspace => {
            let focused = app.mental_model_edit.focused;
            let _ = app.mental_model_edit.drafts[focused].pop();
        }
        _ => {}
    }
}