use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::Frame;
use crate::events::Command;
use crate::store::Recent;
use crate::ui::Theme;
use super::{center_rect, Modal, ModalData, ModalResult};
const MODAL_WIDTH: u16 = 74;
const MODAL_MIN_HEIGHT: u16 = 12;
const MODAL_MAX_HEIGHT: u16 = 40;
const MODAL_CHROME_ROWS: u16 = 6;
pub struct RecentsModal {
recents: Vec<Recent>,
filter: String,
selected: usize,
}
impl RecentsModal {
pub fn new(recents: Vec<Recent>) -> Self {
Self {
recents,
filter: String::new(),
selected: 0,
}
}
fn filtered_indices(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.recents.len()).collect();
}
let needle = self.filter.to_lowercase();
self.recents
.iter()
.enumerate()
.filter(|(_, r)| row_matches(r, &needle))
.map(|(i, _)| i)
.collect()
}
fn selected_recent(&self) -> Option<&Recent> {
let indices = self.filtered_indices();
indices
.get(self.selected)
.and_then(|i| self.recents.get(*i))
}
fn clamp_selection(&mut self) {
let len = self.filtered_indices().len();
if len == 0 {
self.selected = 0;
} else if self.selected >= len {
self.selected = len - 1;
}
}
fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
fn move_down(&mut self) {
let len = self.filtered_indices().len();
if len > 0 && self.selected + 1 < len {
self.selected += 1;
}
}
fn delete_highlighted(&mut self) -> ModalResult {
let indices = self.filtered_indices();
let Some(&recents_idx) = indices.get(self.selected) else {
return ModalResult::Consumed;
};
let removed = self.recents.remove(recents_idx);
self.clamp_selection();
ModalResult::EmitCommand(Command::DeleteRecent(removed.id))
}
}
impl Modal for RecentsModal {
fn id(&self) -> &'static str {
"recents"
}
fn handle(&mut self, key: KeyEvent) -> ModalResult {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ModalResult::Close(None);
}
match key.code {
KeyCode::Esc => ModalResult::Close(None),
KeyCode::Enter => match self.selected_recent() {
Some(r) => ModalResult::CloseWithData(ModalData::FillSessionSpec(r.to_spec())),
None => ModalResult::Consumed,
},
KeyCode::Up => {
self.move_up();
ModalResult::Consumed
}
KeyCode::Down => {
self.move_down();
ModalResult::Consumed
}
KeyCode::Backspace => {
self.filter.pop();
self.clamp_selection();
ModalResult::Consumed
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.delete_highlighted()
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
self.filter.push(c);
self.selected = 0;
ModalResult::Consumed
}
_ => ModalResult::Consumed,
}
}
fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
let desired = (self.recents.len() as u16).saturating_add(MODAL_CHROME_ROWS);
let height = desired
.clamp(MODAL_MIN_HEIGHT, MODAL_MAX_HEIGHT)
.min(area.height.saturating_sub(2).max(MODAL_MIN_HEIGHT));
let rect = center_rect(area, MODAL_WIDTH, height);
let body_bg = theme.panel_alt;
let buf = frame.buffer_mut();
if rect.x + rect.width < area.x + area.width && rect.y + rect.height < area.y + area.height
{
let shadow = Rect::new(rect.x + 1, rect.y + 1, rect.width, rect.height);
let style = Style::default().bg(theme.shadow);
for y in shadow.top()..shadow.bottom() {
for x in shadow.left()..shadow.right() {
buf[(x, y)].set_style(style);
}
}
}
let body_style = Style::default().bg(body_bg);
for y in rect.top()..rect.bottom() {
for x in rect.left()..rect.right() {
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(body_style);
}
}
let accent_style = Style::default().bg(theme.accent);
for y in rect.top()..rect.bottom() {
let cell = &mut buf[(rect.left(), y)];
cell.set_char(' ');
cell.set_style(accent_style);
}
let inner = Rect::new(
rect.x + 3,
rect.y + 1,
rect.width.saturating_sub(4),
rect.height.saturating_sub(2),
);
let filtered = self.filtered_indices();
let mut lines: Vec<Line<'static>> = Vec::with_capacity(16);
lines.push(Line::from(vec![
Span::styled(
"Recent sessions",
Style::default()
.fg(theme.text)
.bg(body_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" esc · ↑↓ select · ↵ pick · ^d delete",
Style::default().fg(theme.text_muted).bg(body_bg),
),
]));
lines.push(Line::from(""));
let filter_display = format!(" filter: {}▎", self.filter);
lines.push(Line::from(vec![Span::styled(
filter_display,
Style::default().fg(theme.text).bg(theme.bg),
)]));
lines.push(Line::from(""));
let max_rows = (inner.height as usize).saturating_sub(4);
if self.recents.is_empty() {
lines.push(Line::from(Span::styled(
" (no recent sessions yet — create some first)",
Style::default().fg(theme.text_muted).bg(body_bg),
)));
} else if filtered.is_empty() {
lines.push(Line::from(Span::styled(
" (no matches)",
Style::default().fg(theme.text_muted).bg(body_bg),
)));
} else {
for (vi, rec_idx) in filtered.iter().enumerate().take(max_rows) {
let r = &self.recents[*rec_idx];
lines.push(render_row(r, vi == self.selected, inner.width, theme));
}
}
Paragraph::new(lines)
.style(Style::default().bg(body_bg))
.render(inner, frame.buffer_mut());
}
}
fn row_matches(r: &Recent, needle: &str) -> bool {
r.name.to_lowercase().contains(needle)
|| r.agent.to_lowercase().contains(needle)
|| r.path.to_lowercase().contains(needle)
}
fn render_row(r: &Recent, selected: bool, width: u16, theme: &Theme) -> Line<'static> {
let marker = if selected { "▸" } else { " " };
let row_bg = if selected {
theme.selection_bg
} else {
theme.panel_alt
};
let marker_style = if selected {
Style::default().fg(theme.accent).bg(row_bg)
} else {
Style::default().fg(row_bg).bg(row_bg)
};
let name_style = if selected {
Style::default()
.fg(theme.text)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text).bg(row_bg)
};
let meta_style = Style::default().fg(theme.text_muted).bg(row_bg);
let path_short = shorten_path(&r.path);
let label = format!(" {} {} · {} · {}", marker, r.name, r.agent, path_short);
let padded = pad_to_width(&label, width);
Line::from(vec![
Span::styled(format!(" {} ", marker), marker_style),
Span::styled(r.name.clone(), name_style),
Span::styled(format!(" · {} · ", r.agent), meta_style),
Span::styled(path_short.clone(), meta_style),
Span::styled(
" ".repeat(padded.chars().count().saturating_sub(
5 + r.name.chars().count()
+ r.agent.chars().count()
+ path_short.chars().count()
+ 6,
)),
Style::default().bg(row_bg),
),
])
}
fn shorten_path(p: &str) -> String {
let home = std::env::var("HOME").unwrap_or_default();
if !home.is_empty() && p.starts_with(&home) {
format!("~{}", &p[home.len()..])
} else {
p.to_string()
}
}
fn pad_to_width(s: &str, width: u16) -> String {
let target = width as usize;
let current = s.chars().count();
if current < target {
let mut out = s.to_string();
for _ in current..target {
out.push(' ');
}
out
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{ClaudeOptions, CodexOptions, SpecOptions};
fn rec(name: &str, agent: &str, path: &str) -> Recent {
Recent {
id: 0,
name: name.into(),
path: path.into(),
agent: agent.into(),
args: String::new(),
claude: ClaudeOptions::default(),
codex: CodexOptions::default(),
last_used_at: 0,
use_count: 1,
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn empty_recents_still_works() {
let mut m = RecentsModal::new(vec![]);
assert!(m.selected_recent().is_none());
assert!(matches!(
m.handle(key(KeyCode::Enter)),
ModalResult::Consumed
));
}
#[test]
fn down_and_up_navigate_filtered_list() {
let mut m = RecentsModal::new(vec![
rec("alpha", "claude", "/tmp"),
rec("beta", "codex", "/tmp"),
rec("gamma", "claude", "/tmp"),
]);
assert_eq!(m.selected_recent().unwrap().name, "alpha");
m.handle(key(KeyCode::Down));
assert_eq!(m.selected_recent().unwrap().name, "beta");
m.handle(key(KeyCode::Down));
assert_eq!(m.selected_recent().unwrap().name, "gamma");
m.handle(key(KeyCode::Down));
assert_eq!(m.selected_recent().unwrap().name, "gamma");
m.handle(key(KeyCode::Up));
assert_eq!(m.selected_recent().unwrap().name, "beta");
}
#[test]
fn typing_filters_by_substring_across_name_agent_path() {
let mut m = RecentsModal::new(vec![
rec("work", "claude", "/tmp/user/proj"),
rec("play", "codex", "/tmp/user/play"),
rec("api", "claude", "/srv/api"),
]);
for c in "cod".chars() {
m.handle(key(KeyCode::Char(c)));
}
assert_eq!(m.filtered_indices().len(), 1);
assert_eq!(m.selected_recent().unwrap().name, "play");
m.handle(key(KeyCode::Backspace));
m.handle(key(KeyCode::Backspace));
m.handle(key(KeyCode::Backspace));
assert_eq!(m.filtered_indices().len(), 3);
}
#[test]
fn filter_narrows_to_zero_still_navigable() {
let mut m = RecentsModal::new(vec![rec("foo", "claude", "/tmp")]);
for c in "xyz".chars() {
m.handle(key(KeyCode::Char(c)));
}
assert_eq!(m.filtered_indices().len(), 0);
assert!(m.selected_recent().is_none());
assert!(matches!(
m.handle(key(KeyCode::Enter)),
ModalResult::Consumed
));
}
#[test]
fn enter_on_selected_closes_with_fill_data() {
let mut m = RecentsModal::new(vec![rec("work", "claude", "/tmp")]);
let r = m.handle(key(KeyCode::Enter));
match r {
ModalResult::CloseWithData(ModalData::FillSessionSpec(spec)) => {
assert_eq!(spec.name, "work");
assert_eq!(spec.path, "/tmp");
assert_eq!(spec.agent, "claude");
}
_ => panic!("expected CloseWithData(FillSessionSpec)"),
}
}
#[test]
fn esc_closes_without_data() {
let mut m = RecentsModal::new(vec![rec("work", "claude", "/tmp")]);
assert!(matches!(
m.handle(key(KeyCode::Esc)),
ModalResult::Close(None)
));
}
#[test]
fn shorten_path_collapses_home() {
let _ = shorten_path("/tmp");
let _ = shorten_path("/nonexistent/path");
}
fn _unused(_: SpecOptions) {}
}