use crate::{config::Config, resume, search, session::Session};
use anyhow::Result;
use chrono::{DateTime, Local};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Margin},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, Paragraph},
};
use std::{
io::{self, Stdout, Write},
path::Path,
process::{Command, Stdio},
};
pub fn pick(cfg: &Config, list: &[Session], initial: &str) -> Result<Option<Session>> {
let mut term = Guard::enter()?;
run(&mut term.terminal, cfg, list, initial)
}
struct Guard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl Guard {
fn enter() -> Result<Self> {
enable_raw_mode()?;
let mut out = io::stdout();
if let Err(err) = execute!(out, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(err.into());
}
match Terminal::new(CrosstermBackend::new(out)) {
Ok(terminal) => Ok(Self { terminal }),
Err(err) => {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
Err(err.into())
}
}
}
}
impl Drop for Guard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
fn run(
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
cfg: &Config,
list: &[Session],
initial: &str,
) -> Result<Option<Session>> {
let mut state = State::new(list, initial, cfg.limit);
loop {
term.draw(|frame| {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(5),
Constraint::Length(1),
])
.split(area);
let body = chunks[1].inner(Margin {
vertical: 0,
horizontal: 1,
});
state.scroll(visible(cfg, body.height as usize));
frame.render_widget(header(&state, list.len(), area.width as usize), chunks[0]);
let items: Vec<_> = state
.items
.iter()
.skip(state.offset)
.take(visible(cfg, body.height as usize))
.enumerate()
.map(|(index, session)| {
let index = index + state.offset;
ListItem::new(lines(
cfg,
session,
body.width as usize,
index == state.selected,
))
})
.collect();
frame.render_widget(List::new(items), body);
frame.render_widget(footer(&state), chunks[2]);
})?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(None);
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.copy(cfg);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.clear(list, cfg.limit)
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => state.home(),
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => state.end(),
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.word(list, cfg.limit)
}
KeyCode::Esc if state.query.is_empty() => return Ok(None),
KeyCode::Esc => state.clear(list, cfg.limit),
KeyCode::Enter => return Ok(state.items.get(state.selected).cloned()),
KeyCode::Down => state.down(),
KeyCode::Up => state.up(),
KeyCode::Left => state.left(),
KeyCode::Right => state.right(),
KeyCode::Home => state.home(),
KeyCode::End => state.end(),
KeyCode::Delete => state.delete(list, cfg.limit),
KeyCode::Backspace => state.backspace(list, cfg.limit),
KeyCode::Char(ch) => state.insert(list, cfg.limit, ch),
_ => {}
}
}
}
}
struct State {
query: String,
items: Vec<Session>,
selected: usize,
offset: usize,
cursor: usize,
notice: Option<String>,
}
impl State {
fn new(list: &[Session], query: &str, limit: usize) -> Self {
Self {
query: query.to_string(),
items: search::filter(list, query, limit),
selected: 0,
offset: 0,
cursor: query.chars().count(),
notice: None,
}
}
fn insert(&mut self, list: &[Session], limit: usize, ch: char) {
self.notice = None;
let mut chars: Vec<_> = self.query.chars().collect();
chars.insert(self.cursor, ch);
self.query = chars.into_iter().collect();
self.cursor += 1;
self.refresh(list, limit);
}
fn backspace(&mut self, list: &[Session], limit: usize) {
self.notice = None;
if self.cursor == 0 {
return;
}
let mut chars: Vec<_> = self.query.chars().collect();
chars.remove(self.cursor - 1);
self.query = chars.into_iter().collect();
self.cursor -= 1;
self.refresh(list, limit);
}
fn delete(&mut self, list: &[Session], limit: usize) {
self.notice = None;
let mut chars: Vec<_> = self.query.chars().collect();
if self.cursor >= chars.len() {
return;
}
chars.remove(self.cursor);
self.query = chars.into_iter().collect();
self.refresh(list, limit);
}
fn clear(&mut self, list: &[Session], limit: usize) {
self.notice = None;
self.query.clear();
self.cursor = 0;
self.refresh(list, limit);
}
fn word(&mut self, list: &[Session], limit: usize) {
self.notice = None;
if self.cursor == 0 {
return;
}
let mut chars: Vec<_> = self.query.chars().collect();
while self.cursor > 0 && chars[self.cursor - 1].is_whitespace() {
chars.remove(self.cursor - 1);
self.cursor -= 1;
}
while self.cursor > 0 && !chars[self.cursor - 1].is_whitespace() {
chars.remove(self.cursor - 1);
self.cursor -= 1;
}
self.query = chars.into_iter().collect();
self.refresh(list, limit);
}
fn left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn right(&mut self) {
self.cursor = (self.cursor + 1).min(self.query.chars().count());
}
fn home(&mut self) {
self.cursor = 0;
}
fn end(&mut self) {
self.cursor = self.query.chars().count();
}
fn refresh(&mut self, list: &[Session], limit: usize) {
self.items = search::filter(list, &self.query, limit);
self.selected = self.selected.min(self.items.len().saturating_sub(1));
self.offset = 0;
}
fn down(&mut self) {
self.notice = None;
if !self.items.is_empty() {
self.selected = (self.selected + 1).min(self.items.len() - 1);
}
}
fn up(&mut self) {
self.notice = None;
self.selected = self.selected.saturating_sub(1);
}
fn scroll(&mut self, visible: usize) {
if self.selected < self.offset {
self.offset = self.selected;
return;
}
if self.selected >= self.offset + visible {
self.offset = self.selected.saturating_sub(visible.saturating_sub(1));
}
}
fn copy(&mut self, cfg: &Config) {
let Some(session) = self.items.get(self.selected) else {
return;
};
match copy_text(&resume::command(cfg, session)) {
Ok(()) => self.notice = Some("copied to clipboard".to_string()),
Err(_) => self.notice = Some("copy failed".to_string()),
}
}
}
pub fn copy_text(text: &str) -> Result<()> {
let (bin, args) = if cfg!(target_os = "macos") {
("pbcopy", Vec::<&str>::new())
} else if cfg!(target_os = "windows") {
("clip", Vec::<&str>::new())
} else if exists("wl-copy") {
("wl-copy", Vec::<&str>::new())
} else {
("xclip", vec!["-selection", "clipboard"])
};
let mut child = Command::new(bin).args(args).stdin(Stdio::piped()).spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(text.as_bytes())?;
}
let status = child.wait()?;
if !status.success() {
anyhow::bail!("clipboard command exited with {status}");
}
Ok(())
}
fn header(state: &State, total: usize, width: usize) -> Paragraph<'static> {
let title = "Sessions";
let right = state.notice.clone().unwrap_or_else(|| "esc".to_string());
let gap = width.saturating_sub(title.len() + right.len()).max(2);
let query = if state.query.is_empty() {
vec![Span::styled("Search", Style::default().fg(Color::DarkGray))]
} else {
input(&state.query, state.cursor)
};
Paragraph::new(vec![
Line::from(vec![
Span::styled(
title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(" ".repeat(gap)),
Span::styled(
right,
Style::default().fg(if state.notice.is_some() {
Color::LightGreen
} else {
Color::DarkGray
}),
),
]),
Line::from(""),
Line::from(query),
Line::from(Span::styled(
format!("{} matched · {} total", state.items.len(), total),
Style::default().fg(Color::DarkGray),
)),
])
}
fn input(query: &str, cursor: usize) -> Vec<Span<'static>> {
let chars: Vec<_> = query.chars().collect();
let left = chars[..cursor].iter().collect::<String>();
let current = chars.get(cursor).copied().unwrap_or(' ');
let right = if cursor < chars.len() {
chars[cursor + 1..].iter().collect::<String>()
} else {
String::new()
};
vec![
Span::styled(left, Style::default().fg(Color::White)),
Span::styled(
current.to_string(),
Style::default().fg(Color::Black).bg(Color::LightGreen),
),
Span::styled(right, Style::default().fg(Color::White)),
]
}
fn visible(cfg: &Config, height: usize) -> usize {
let rows = 1
+ usize::from(cfg.ui.show_directory)
+ usize::from(cfg.ui.show_agent || cfg.ui.show_model);
(height / rows.max(1)).max(1)
}
fn footer(state: &State) -> Paragraph<'static> {
let id = state
.items
.get(state.selected)
.map(|session| fit(&session.id, 22))
.unwrap_or_default();
Paragraph::new(Line::from(vec![
Span::styled(" enter resume", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("↑↓ move", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("esc clear/quit", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("ctrl+y copy", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(id, Style::default().fg(Color::DarkGray)),
]))
}
fn lines(cfg: &Config, session: &Session, width: usize, selected: bool) -> Vec<Line<'static>> {
let when = when(session.updated);
let prefix = if selected { "▌ " } else { " " };
let title = fit(
&session.title,
width.saturating_sub(when.len() + prefix.len() + 3),
);
let gap = width
.saturating_sub(prefix.chars().count() + title.chars().count() + when.chars().count())
.max(2);
let bg = selected.then_some(Color::Rgb(22, 72, 48));
let mut out = vec![Line::from(vec![
Span::styled(prefix, style(Color::LightGreen, bg, selected)),
Span::styled(title, style(Color::White, bg, selected)),
Span::styled(
" ".repeat(gap),
Style::default().bg(bg.unwrap_or(Color::Reset)),
),
Span::styled(when, style(Color::Gray, bg, selected)),
])];
if cfg.ui.show_directory {
out.push(Line::from(Span::styled(
fit(&pretty_path(&session.directory), width),
Style::default().fg(Color::DarkGray),
)));
}
if cfg.ui.show_agent || cfg.ui.show_model {
let mut bits = Vec::new();
if cfg.ui.show_agent {
bits.push(format!(
"agent: {}",
session.agent.as_deref().unwrap_or("-")
));
}
if cfg.ui.show_model {
bits.push(format!(
"model: {}",
session.model.as_deref().unwrap_or("-")
));
}
out.push(Line::from(Span::styled(
bits.join(" "),
Style::default().fg(Color::DarkGray),
)));
}
out
}
fn when(ms: i64) -> String {
let Ok(ms) = u64::try_from(ms) else {
return "unknown".to_string();
};
let Some(time) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms)) else {
return "unknown".to_string();
};
DateTime::<Local>::from(time)
.format("%a %b %-d %-I:%M %p")
.to_string()
}
fn exists(bin: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()))
}
fn pretty_path(path: &Path) -> String {
let text = path.display().to_string();
let Some(home) = dirs::home_dir() else {
return text;
};
let Ok(rest) = path.strip_prefix(home) else {
return text;
};
if rest.as_os_str().is_empty() {
return "~".to_string();
}
format!("~/{}", rest.display())
}
fn style(fg: Color, bg: Option<Color>, bold: bool) -> Style {
let style = Style::default().fg(fg).bg(bg.unwrap_or(Color::Reset));
if bold {
return style.add_modifier(Modifier::BOLD);
}
style
}
fn fit(text: &str, width: usize) -> String {
let chars: Vec<_> = text.chars().collect();
if chars.len() <= width {
return text.to_string();
}
if width <= 3 {
return ".".repeat(width);
}
let keep = width - 3;
let left = keep / 2;
let right = keep - left;
format!(
"{}...{}",
chars[..left].iter().collect::<String>(),
chars[chars.len() - right..].iter().collect::<String>()
)
}