travelagent 1.10.3

Agent-first TUI code review tool
//! Handler dispatch for the Sparring panel (Phase I5-2b). Reinterprets
//! a handful of Normal-mode Actions so `r`/`d`/`a` keep their keystroke
//! shape inside the Sparring panel without invalidating them elsewhere.
//!
//! Extracted from `handler/mod.rs` in v1.7 as another slice of the
//! standing "peel mechanical slices out of the 4,000-line handler
//! file" policy (ROADMAP Phase G item 785). The call site in
//! `handle_remote_panel_action` still lives in `mod.rs`; this file
//! owns the panel-specific dispatch plus its two helpers
//! (`mark_spec_resolved`, `delete_spec_comment`).
//!
//! **Companion module:** `handler/review_modes.rs` owns the
//! command-mode toggles (`:spar` / `:unspar` / `:spec` / `:specs`)
//! that enter / leave Sparring Review mode. This module owns the
//! panel-level actions once you're inside. Together they cover the
//! Sparring Review surface.

use crate::app::App;
use crate::input::Action;

use super::handle_shared_normal_action;

/// Sparring panel key handling. Reinterprets a handful of Normal-mode
/// Actions (ToggleReviewed → reshape, PendingDCommand → drop,
/// SparringAccept → accept) so `r`/`d`/`a` keep their keystroke shape
/// in this panel without invalidating them elsewhere. Everything else
/// falls through to `handle_shared_normal_action`.
pub fn handle_sparring_panel_action(app: &mut App, action: Action) {
    use crate::ui::sparring_panel::build_spec_rows;

    // Snapshot the current list so cursor math and action targeting
    // agree on what's on screen. Rebuilt after any mutation.
    let rows = build_spec_rows(app);
    let total = rows.len();

    match action {
        Action::CursorDown(n) => {
            if total > 0 {
                let max = total - 1;
                app.sparring_cursor = (app.sparring_cursor + n).min(max);
            }
        }
        Action::CursorUp(n) => {
            app.sparring_cursor = app.sparring_cursor.saturating_sub(n);
        }
        Action::GoToTop => {
            app.sparring_cursor = 0;
        }
        Action::GoToBottom => {
            if total > 0 {
                app.sparring_cursor = total - 1;
            }
        }
        // `a` — accept the cursored spec. Marks `resolved = true` so
        // the spec drops out of active counts while staying on the
        // session for audit.
        Action::SparringAccept => {
            let Some(row) = rows.get(app.sparring_cursor) else {
                return;
            };
            if mark_spec_resolved(app, &row.spec_id) {
                app.dirty = true;
                app.set_message(format!("Spec accepted (resolved): {}", row.preview));
                app.refresh_spec_statuses();
                // Clamp cursor after list shrinks.
                let new_total = app.spec_statuses.len();
                if new_total == 0 {
                    app.sparring_cursor = 0;
                } else if app.sparring_cursor >= new_total {
                    app.sparring_cursor = new_total - 1;
                }
            }
        }
        // `d` — drop the spec (delete the comment entirely). This is
        // destructive; `PendingDCommand` is normally the first half
        // of a `dd` chord elsewhere, but the Sparring panel rebinds
        // it as a single-stroke drop.
        Action::PendingDCommand => {
            let Some(row) = rows.get(app.sparring_cursor) else {
                return;
            };
            if delete_spec_comment(app, &row.spec_id) {
                app.dirty = true;
                app.set_message(format!("Spec dropped: {}", row.preview));
                app.refresh_spec_statuses();
                let new_total = app.spec_statuses.len();
                if new_total == 0 {
                    app.sparring_cursor = 0;
                } else if app.sparring_cursor >= new_total {
                    app.sparring_cursor = new_total - 1;
                }
            }
        }
        // `r` — reshape request. Doesn't mutate anything on trv's
        // side; leaves breadcrumbs for the agent to regenerate the
        // test. v1.4.1 will formalise this with an MCP notification.
        Action::ToggleReviewed => {
            let Some(row) = rows.get(app.sparring_cursor) else {
                return;
            };
            app.set_message(format!(
                "Reshape requested for spec {} — ask your agent to regenerate",
                &row.spec_id[..8.min(row.spec_id.len())]
            ));
        }
        _ => handle_shared_normal_action(app, action),
    }
}

/// Find the spec comment with `spec_id` across every scope and flip
/// its `resolved` flag to true. Returns `true` when a comment was
/// found and updated.
fn mark_spec_resolved(app: &mut App, spec_id: &str) -> bool {
    use travelagent_core::model::CommentType;
    let session = app.engine.session_mut();
    let mut updated = false;
    let mut mark = |c: &mut travelagent_core::model::Comment| {
        if matches!(c.comment_type, CommentType::Spec) && c.id == spec_id && !c.resolved {
            c.resolved = true;
            updated = true;
        }
    };
    for c in session.review_comments.iter_mut() {
        mark(c);
    }
    for fr in session.files.values_mut() {
        for c in fr.file_comments.iter_mut() {
            mark(c);
        }
        for cs in fr.line_comments.values_mut() {
            for c in cs.iter_mut() {
                mark(c);
            }
        }
        for c in fr.orphaned_comments.iter_mut() {
            mark(c);
        }
    }
    updated
}

/// Delete the spec comment matching `spec_id`. Returns true when
/// found and removed.
fn delete_spec_comment(app: &mut App, spec_id: &str) -> bool {
    use travelagent_core::model::CommentType;
    let session = app.engine.session_mut();
    let match_spec = |c: &travelagent_core::model::Comment| {
        matches!(c.comment_type, CommentType::Spec) && c.id == spec_id
    };
    let before = session.review_comments.len();
    session.review_comments.retain(|c| !match_spec(c));
    if session.review_comments.len() < before {
        return true;
    }
    for fr in session.files.values_mut() {
        let before = fr.file_comments.len();
        fr.file_comments.retain(|c| !match_spec(c));
        if fr.file_comments.len() < before {
            return true;
        }
        for cs in fr.line_comments.values_mut() {
            let before = cs.len();
            cs.retain(|c| !match_spec(c));
            if cs.len() < before {
                return true;
            }
        }
        let before = fr.orphaned_comments.len();
        fr.orphaned_comments.retain(|c| !match_spec(c));
        if fr.orphaned_comments.len() < before {
            return true;
        }
    }
    false
}