travelagent 1.10.3

Agent-first TUI code review tool
//! Handler dispatch for the `:` command-palette and the comment-
//! template picker (Ctrl+T). Extracted from `handler/mod.rs` in v1.6
//! as part of the standing "peel mechanical slices out of the
//! 4,000-line handler file" policy.
//!
//! Dispatch for Command mode itself (the `:` prompt that submits a
//! single command on Enter) stays in `handler/mod.rs` because
//! `handle_command_action` is 400+ lines of ad-hoc `:command`
//! dispatch — worth its own extraction later, separately.
//!
//! Both handlers here are thin: the invariant-preserving mutations
//! live on `UiLayoutState` (comment-template picker) and
//! `PaletteState` (command palette), so this file is mostly
//! action→method plumbing. See those structs for the cursor-reset
//! invariants.

use crate::app::{self, App, InputMode};
use crate::input::Action;
use travelagent_core::forge::PrState;

use super::{handle_command_action, open_browser};

/// Handle actions in [`InputMode::CommentTemplatePicker`]. Filtering,
/// navigation, and selection all operate on `app.comment_templates`
/// (already sorted alphabetically at load time); the picker reuses
/// `filter_templates` from the UI module so both render and dispatch
/// agree on what's visible.
pub fn handle_comment_template_picker_action(app: &mut App, action: Action) {
    use crate::ui::comment_template_picker::filter_templates;

    // Invariant: every filter-mutating action resets the cursor to 0. This
    // keeps the dispatch-time cursor (read by `select_comment_template`)
    // in agreement with the render-time clamp (which additionally caps
    // cursor at `filtered.len() - 1` on overflow). Breaking this would
    // let a Select with a stale cursor land on the wrong entry — Claude
    // flagged the divergence risk as a NIT in the post-F crew review.
    match action {
        Action::InsertChar(c) => app.ui_layout.push_template_filter_char(c),
        Action::DeleteChar => app.ui_layout.pop_template_filter_char(),
        Action::DeleteWord => app.ui_layout.word_delete_template_filter(),
        Action::ClearLine => app.ui_layout.clear_template_filter(),
        Action::CursorDown(_) => {
            let filtered =
                filter_templates(&app.comment_templates, app.ui_layout.template_filter());
            app.ui_layout.advance_template_cursor(filtered.len());
        }
        Action::CursorUp(_) => app.ui_layout.retreat_template_cursor(),
        Action::CommentTemplatePickerSelect => app.select_comment_template(),
        Action::CommentTemplatePickerCancel => app.exit_comment_template_picker(),
        _ => {}
    }
}

/// Handle actions in CommandPalette mode (fuzzy filter + execute)
pub fn handle_command_palette_action(app: &mut App, action: Action) {
    use crate::ui::command_palette::{PALETTE_ENTRIES, filter_entries};

    match action {
        Action::InsertChar(c) => app.palette.push_char(c),
        Action::DeleteChar => {
            app.palette.pop_char();
            // Empty buffer exits palette mode — treat `DeleteChar` on
            // an empty prompt as "bail out", matching `:`-command UX.
            if app.palette.is_empty() {
                app.nav.input_mode = InputMode::Normal;
            }
        }
        Action::DeleteWord => app.palette.word_delete(),
        Action::ClearLine => app.palette.clear(),
        Action::CursorDown(_) => {
            let filtered = filter_entries(app.palette.buffer());
            app.palette.advance_cursor(filtered.len());
        }
        Action::CursorUp(_) => app.palette.retreat_cursor(),
        Action::SubmitInput => {
            let filtered = filter_entries(app.palette.buffer());
            if let Some(&entry_idx) = filtered.get(app.palette.cursor()) {
                let key = PALETTE_ENTRIES[entry_idx].key;
                // Exit palette first, then execute the selected entry.
                app.nav.input_mode = InputMode::Normal;
                app.palette.clear();
                execute_palette_entry(app, key);
            } else {
                // No match — treat the buffer as a raw `:command`. Take
                // the buffer out (clearing the palette) and replay it
                // through Command mode's submit path.
                let cmd = app.palette.take_buffer();
                app.nav.input_mode = InputMode::Command;
                app.palette.set_buffer(cmd);
                handle_command_action(app, Action::SubmitInput);
            }
        }
        Action::ExitMode => {
            app.nav.input_mode = InputMode::Normal;
            app.palette.clear();
        }
        _ => {}
    }
}

/// Execute a palette entry by its key string.
fn execute_palette_entry(app: &mut App, key: &str) {
    match key {
        // Single-key actions (simulate the keybinding)
        "r" => app.toggle_reviewed(),
        "c" => {
            let line = app.get_line_at_cursor();
            if line.is_some() {
                app.enter_comment_mode(false, line);
            } else {
                app.set_message("Move cursor to a diff line to add a line comment");
            }
        }
        "C" => app.enter_review_comment_mode(),
        "?" => app.toggle_help(),
        "R" => {
            if app.has_forge() {
                app.nav.input_mode = InputMode::ReviewSubmit;
                if let Some(r) = app.remote_mut() {
                    r.review_verdict_cursor = 0;
                    r.review_body.clear();
                    r.review_body_editing = false;
                }
            } else {
                app.set_message("Not in remote PR mode");
            }
        }
        "M" => {
            if app.has_forge() {
                let meta_snapshot = app
                    .remote()
                    .and_then(|r| r.pr_metadata.as_ref().map(|m| (m.state, m.is_draft)));
                if let Some((state, is_draft)) = meta_snapshot {
                    if state != PrState::Open {
                        app.set_warning(format!("PR is already {}", state.display()));
                    } else if is_draft {
                        app.set_warning("Cannot merge a draft PR".to_string());
                    } else {
                        app.enter_confirm_mode(app::ConfirmAction::Merge);
                    }
                } else {
                    app.set_warning("No PR metadata available".to_string());
                }
            } else {
                app.set_message("Not in remote PR mode");
            }
        }
        "o" => {
            if let Some(url) = app.get_browser_url()
                && let Err(e) = open_browser(&url)
            {
                app.set_error(format!("Failed to open browser: {e}"));
            }
        }
        "/" => app.enter_search_mode(),
        "v" => {
            if let Some((line, side)) = app.get_line_at_cursor() {
                app.enter_visual_mode(line, side);
            } else {
                app.set_message("Move cursor to a diff line to start visual selection");
            }
        }
        // Command-mode commands (execute via the command handler)
        _ => {
            app.nav.input_mode = InputMode::Command;
            app.palette.set_buffer(key.to_string());
            handle_command_action(app, Action::SubmitInput);
        }
    }
}