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)",
},
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",
},
];
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);
let chunks = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
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]);
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_entries(app.palette.buffer());
let list_height = chunks[2].height as usize;
let cursor = app.palette.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 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]);
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
}