travelagent 1.11.1

Agent-first TUI code review tool
use ratatui::{
    Frame,
    layout::{Constraint, Flex, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};

use crate::app::App;
use crate::ui::styles;

pub struct PaletteEntry {
    pub key: &'static str,
    pub name: &'static str,
    pub desc: &'static str,
}

pub const PALETTE_ENTRIES: &[PaletteEntry] = &[
    PaletteEntry {
        key: "q",
        name: "Quit",
        desc: "Exit travelagent",
    },
    PaletteEntry {
        key: "w",
        name: "Save",
        desc: "Save review session",
    },
    PaletteEntry {
        key: "wq",
        name: "Save & Quit",
        desc: "Save session, export, and quit",
    },
    PaletteEntry {
        key: "clip",
        name: "Export",
        desc: "Copy review to clipboard",
    },
    PaletteEntry {
        key: "clear",
        name: "Clear Comments",
        desc: "Remove all comments",
    },
    PaletteEntry {
        key: "e",
        name: "Reload",
        desc: "Reload diff from VCS",
    },
    PaletteEntry {
        key: "set wrap",
        name: "Toggle Wrap",
        desc: "Toggle line wrapping",
    },
    PaletteEntry {
        key: "diff",
        name: "Toggle Diff View",
        desc: "Switch unified/side-by-side",
    },
    PaletteEntry {
        key: "alias",
        name: "Session Alias",
        desc: "Name this session for `trv --resume <name>` (:alias <name>)",
    },
    PaletteEntry {
        key: "view",
        name: "Toggle Viewer",
        desc: "Show the whole file (not just the diff) \u{2014} also 't'",
    },
    PaletteEntry {
        key: "render",
        name: "Viewer: Rendered",
        desc: "Render markdown in the viewer (':raw' for source; also 'T')",
    },
    PaletteEntry {
        key: "review-all",
        name: "Review All",
        desc: "Mark every file in the current view/commit reviewed",
    },
    PaletteEntry {
        key: "unreview-all",
        name: "Unreview All",
        desc: "Mark every file in the current view unreviewed",
    },
    PaletteEntry {
        key: "review-restart",
        name: "Restart Review",
        desc: "Mark ALL files unreviewed across the session (confirms first)",
    },
    PaletteEntry {
        key: "version",
        name: "Version",
        desc: "Show trv version",
    },
    PaletteEntry {
        key: "update",
        name: "Check Update",
        desc: "Check for new versions",
    },
    PaletteEntry {
        key: "commits",
        name: "Commit Selector",
        desc: "Select commits or staged/unstaged changes",
    },
    PaletteEntry {
        key: "tour",
        name: "Request Agent Tour",
        desc: "Ask connected agent to plan a tour (requires MCP attach)",
    },
    PaletteEntry {
        key: "tour-rewind",
        name: "Rewind Tour",
        desc: "Jump back to the first tour stop",
    },
    PaletteEntry {
        key: "tour-goto",
        name: "Go to Tour Stop",
        desc: "Jump to stop N (1-based): :tour-goto <stop>",
    },
    PaletteEntry {
        key: "mcp-on",
        name: "MCP Listen",
        desc: "Start MCP socket listener so agents can attach",
    },
    PaletteEntry {
        key: "mcp-off",
        name: "MCP Stop",
        desc: "Drain & close the MCP listener (5s grace)",
    },
    PaletteEntry {
        key: "mcp-status",
        name: "MCP Status",
        desc: "Show MCP listener state (off / on / draining)",
    },
    // Keybinding entries
    PaletteEntry {
        key: "r",
        name: "Toggle Reviewed",
        desc: "Mark current file as reviewed",
    },
    PaletteEntry {
        key: "c",
        name: "Line Comment",
        desc: "Add comment on current line",
    },
    PaletteEntry {
        key: "C",
        name: "Review Comment",
        desc: "Add review-level comment",
    },
    PaletteEntry {
        key: "?",
        name: "Help",
        desc: "Show keybinding help",
    },
    PaletteEntry {
        key: "R",
        name: "Submit Review",
        desc: "Submit review to forge (remote)",
    },
    PaletteEntry {
        key: "M",
        name: "Merge PR",
        desc: "Merge pull request (remote)",
    },
    PaletteEntry {
        key: "o",
        name: "Open in Browser",
        desc: "Open PR in browser (remote)",
    },
    PaletteEntry {
        key: "/",
        name: "Search",
        desc: "Search in diff",
    },
    PaletteEntry {
        key: "v",
        name: "Visual Select",
        desc: "Select line range for comment",
    },
    PaletteEntry {
        key: "n/N",
        name: "Next/Prev Match",
        desc: "Navigate search matches",
    },
    PaletteEntry {
        key: "}/{",
        name: "Next/Prev File",
        desc: "Navigate between files",
    },
    PaletteEntry {
        key: "]/[",
        name: "Next/Prev Hunk",
        desc: "Navigate between hunks",
    },
];

/// Return indices of palette entries matching the given filter string.
pub fn filter_entries(filter: &str) -> Vec<usize> {
    if filter.is_empty() {
        return (0..PALETTE_ENTRIES.len()).collect();
    }
    let lower = filter.to_ascii_lowercase();
    PALETTE_ENTRIES
        .iter()
        .enumerate()
        .filter(|(_, e)| {
            e.key.to_ascii_lowercase().contains(&lower)
                || e.name.to_ascii_lowercase().contains(&lower)
                || e.desc.to_ascii_lowercase().contains(&lower)
        })
        .map(|(i, _)| i)
        .collect()
}

pub fn render_command_palette(frame: &mut Frame, app: &App) {
    let theme = &app.theme;
    let area = centered_rect(60, 70, frame.area());

    frame.render_widget(Clear, area);

    let block = Block::default()
        .title(" Command Palette ")
        .borders(Borders::ALL)
        .style(styles::popup_style(theme))
        .border_style(styles::border_style(theme, true));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Split inner area: filter input (1 line) + separator (1 line) + list + footer (1 line)
    let chunks = Layout::default()
        .direction(ratatui::layout::Direction::Vertical)
        .constraints([
            Constraint::Length(1), // Filter input
            Constraint::Length(1), // Separator
            Constraint::Min(1),    // List
            Constraint::Length(1), // Footer
        ])
        .split(inner);

    // Render filter input
    let filter_text = format!(":{}", app.palette.buffer());
    let filter_line = Paragraph::new(Line::from(Span::styled(
        filter_text,
        Style::default().fg(theme.fg_primary),
    )));
    frame.render_widget(filter_line, chunks[0]);

    // Separator
    let sep = Paragraph::new(Line::from(Span::styled(
        "\u{2500}".repeat(chunks[1].width as usize),
        Style::default().fg(theme.border_unfocused),
    )));
    frame.render_widget(sep, chunks[1]);

    // Filtered entries
    let filtered = filter_entries(app.palette.buffer());
    let list_height = chunks[2].height as usize;

    // Ensure cursor is in bounds
    let cursor = app.palette.cursor().min(filtered.len().saturating_sub(1));

    // Calculate scroll offset to keep cursor visible
    let scroll_offset = if cursor >= list_height {
        cursor - list_height + 1
    } else {
        0
    };

    let items: Vec<ListItem> = filtered
        .iter()
        .skip(scroll_offset)
        .take(list_height)
        .enumerate()
        .map(|(display_idx, &entry_idx)| {
            let entry = &PALETTE_ENTRIES[entry_idx];
            let is_selected = (display_idx + scroll_offset) == cursor;

            let key_span = Span::styled(
                format!("{:<12}", entry.key),
                Style::default()
                    .fg(theme.diff_hunk_header)
                    .add_modifier(Modifier::BOLD),
            );
            let name_span = Span::styled(
                format!("{:<20}", entry.name),
                Style::default().fg(theme.fg_primary),
            );
            let desc_span = Span::styled(entry.desc, Style::default().fg(theme.fg_secondary));

            let line = Line::from(vec![
                Span::raw(if is_selected { "> " } else { "  " }),
                key_span,
                name_span,
                desc_span,
            ]);

            if is_selected {
                ListItem::new(line).style(styles::selected_style(theme))
            } else {
                ListItem::new(line)
            }
        })
        .collect();

    let list = List::new(items);
    frame.render_widget(list, chunks[2]);

    // Footer
    let footer = Paragraph::new(Line::from(vec![
        Span::styled(
            " Enter",
            Style::default()
                .fg(theme.fg_primary)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            " \u{2192} execute  ",
            Style::default().fg(theme.fg_secondary),
        ),
        Span::styled(
            "Esc",
            Style::default()
                .fg(theme.fg_primary)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            " \u{2192} cancel  ",
            Style::default().fg(theme.fg_secondary),
        ),
        Span::styled(
            "\u{2191}\u{2193}",
            Style::default()
                .fg(theme.fg_primary)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            " \u{2192} navigate",
            Style::default().fg(theme.fg_secondary),
        ),
    ]));
    frame.render_widget(footer, chunks[3]);
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
    let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
    let [area] = vertical.areas(area);
    let [area] = horizontal.areas(area);
    area
}