travelagent 1.10.3

Agent-first TUI code review tool
//! Popup picker for reusable comment templates (Phase F).
//!
//! Opened from comment mode via `Ctrl+T` when `[comment_templates]` is
//! configured in `~/.config/travelagent/config.toml`. Presents the
//! template names as a filterable list — typing refines the filter, `Enter`
//! inserts the selected template's body at the cursor, and `Esc` cancels
//! back to comment mode without touching the buffer.
//!
//! Modeled on [`crate::ui::command_palette`] but intentionally a separate
//! renderer because the palette has load-bearing coupling to the
//! static `PALETTE_ENTRIES` slice, and shoehorning user-defined templates
//! through that codepath would risk regressions in the existing command
//! workflow for no real gain.

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;

/// Return indices into `templates` matching the filter string.
/// Empty filter returns every entry in the original (caller-sorted) order.
pub fn filter_templates(templates: &[(String, String)], filter: &str) -> Vec<usize> {
    if filter.is_empty() {
        return (0..templates.len()).collect();
    }
    let lower = filter.to_ascii_lowercase();
    templates
        .iter()
        .enumerate()
        .filter(|(_, (name, body))| {
            name.to_ascii_lowercase().contains(&lower) || body.to_ascii_lowercase().contains(&lower)
        })
        .map(|(i, _)| i)
        .collect()
}

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

    frame.render_widget(Clear, area);

    let block = Block::default()
        .title(" Comment Templates ")
        .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);

    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);

    // Filter input
    let filter_text = format!("/{}", app.ui_layout.template_filter());
    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]);

    let filtered = filter_templates(&app.comment_templates, app.ui_layout.template_filter());
    let list_height = chunks[2].height as usize;

    let cursor = app
        .ui_layout
        .template_cursor()
        .min(filtered.len().saturating_sub(1));

    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 (name, body) = &app.comment_templates[entry_idx];
            let is_selected = (display_idx + scroll_offset) == cursor;

            // Preview the body with newlines collapsed to visible escape
            // sequences so a multi-line template stays on one row.
            let preview: String = body
                .chars()
                .map(|c| match c {
                    '\n' => '\u{21b5}',
                    _ => c,
                })
                .collect();

            let name_span = Span::styled(
                format!("{name:<16}"),
                Style::default()
                    .fg(theme.diff_hunk_header)
                    .add_modifier(Modifier::BOLD),
            );
            let body_span = Span::styled(preview, Style::default().fg(theme.fg_secondary));

            let line = Line::from(vec![
                Span::raw(if is_selected { "> " } else { "  " }),
                name_span,
                body_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]);

    let footer = Paragraph::new(Line::from(vec![
        Span::styled(
            " Enter",
            Style::default()
                .fg(theme.fg_primary)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            " \u{2192} insert  ",
            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
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_templates(entries: &[(&str, &str)]) -> Vec<(String, String)> {
        entries
            .iter()
            .map(|(n, b)| ((*n).to_string(), (*b).to_string()))
            .collect()
    }

    #[test]
    fn empty_filter_returns_every_entry() {
        let templates = make_templates(&[("nit", "nit: "), ("q", "Q: "), ("style", "Style: ")]);
        let filtered = filter_templates(&templates, "");
        assert_eq!(filtered, vec![0, 1, 2]);
    }

    #[test]
    fn filter_matches_name_case_insensitive() {
        let templates = make_templates(&[("nit", "nit: "), ("Q", "Question: ")]);
        let filtered = filter_templates(&templates, "ni");
        assert_eq!(filtered, vec![0]);
        let filtered = filter_templates(&templates, "q");
        assert_eq!(filtered, vec![1]);
    }

    #[test]
    fn filter_matches_body_contents() {
        let templates = make_templates(&[("nit", "nit: "), ("q", "Question: ")]);
        let filtered = filter_templates(&templates, "question");
        assert_eq!(filtered, vec![1]);
    }
}