use std::fmt;
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, FixedOffset, Local};
use dialoguer::console::{Key, Term, truncate_str};
use dialoguer::theme::{ColorfulTheme, Theme};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use unicode_width::UnicodeWidthStr;
use crate::session_index::{self, Summary};
pub fn in_dir<'a>(sessions: &'a [Summary], cwd: &str) -> Vec<&'a Summary> {
sessions.iter().filter(|s| s.cwd.as_deref().is_none_or(|c| c == cwd)).collect()
}
pub fn last(dir: &Path, cwd: &str, exclude: Option<&str>) -> Result<String> {
let sessions = session_index::list(dir)?;
match sessions.iter().find(|s| s.cwd.as_deref() == Some(cwd) && Some(s.id.as_str()) != exclude) {
Some(session) => Ok(session.id.clone()),
None => bail!("no saved session for {}; run with --resume to pick one", crate::sanitize_terminal_text(cwd)),
}
}
pub fn ago(then: DateTime<FixedOffset>, now: DateTime<FixedOffset>) -> String {
let minutes = (now - then).num_minutes();
match minutes {
..1 => "just now".into(),
1..60 => format!("{minutes}m ago"),
60..1440 => format!("{}h ago", minutes / 60),
1440..2880 => "yesterday".into(),
2880..10080 => format!("{}d ago", minutes / 1440),
_ => then.with_timezone(&Local).format("%b %-d").to_string(),
}
}
pub fn project(summary: &Summary) -> String {
summary
.cwd
.as_deref()
.map(|cwd| crate::sanitize_terminal_text(Path::new(cwd).file_name().map_or(cwd, |n| n.to_str().unwrap_or(cwd))))
.unwrap_or_else(|| "?".into())
}
fn one_line(text: &str) -> String {
crate::sanitize_terminal_text(&text.split_whitespace().collect::<Vec<_>>().join(" "))
}
pub fn topic(summary: &Summary) -> String {
let last = one_line(summary.last_prompt.as_deref().unwrap_or_default());
if let Some(title) = &summary.title {
return format!("{} · {last}", one_line(title));
}
match &summary.context_prompt {
Some(context) => format!("{last} ← {}", one_line(context)),
None => last,
}
}
fn cells(text: &str) -> usize {
UnicodeWidthStr::width(text)
}
fn truncate(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if cells(text) <= width {
return text.to_string();
}
let budget = width.saturating_sub(1);
let mut out = String::new();
for c in text.chars() {
out.push(c);
if cells(&out) > budget {
out.pop();
break;
}
}
out.push('…');
out
}
struct FitTheme {
inner: ColorfulTheme,
width: usize,
}
impl Theme for FitTheme {
fn format_fuzzy_select_prompt_item(
&self,
f: &mut dyn fmt::Write,
text: &str,
active: bool,
highlight_matches: bool,
matcher: &SkimMatcherV2,
search_term: &str,
) -> fmt::Result {
self.inner.format_fuzzy_select_prompt_item(
f,
&truncate(text, self.width),
active,
highlight_matches,
matcher,
search_term,
)
}
}
fn rank(matcher: &SkimMatcherV2, items: &[String], search: &str) -> Vec<usize> {
let mut scored: Vec<(usize, i64)> =
items.iter().enumerate().filter_map(|(i, t)| matcher.fuzzy_match(t, search).map(|s| (i, s))).collect();
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
scored.into_iter().map(|(i, _)| i).collect()
}
fn scroll_top(top: usize, sel: usize, max_visible: usize) -> usize {
if sel < top {
sel
} else if max_visible > 0 && sel >= top + max_visible {
sel + 1 - max_visible
} else {
top
}
}
fn physical_rows(line: &str, cols: usize) -> usize {
if cols == 0 {
return 1;
}
dialoguer::console::measure_text_width(line).div_ceil(cols).max(1)
}
fn item_window(rows: usize, prompt_rows: usize, max_visible: usize) -> usize {
max_visible.min(rows.saturating_sub(prompt_rows))
}
struct CursorGuard<'a> {
term: &'a Term,
}
impl Drop for CursorGuard<'_> {
fn drop(&mut self) {
let _ = self.term.show_cursor();
}
}
fn interactive_pick(
prompt: &str,
theme: &FitTheme,
items: &[String],
rows: usize,
cols: usize,
max_visible: usize,
) -> Result<Option<usize>> {
let term = Term::stderr();
let matcher = SkimMatcherV2::default();
let mut search = String::new();
let mut sel = 0usize; let mut top = 0usize; let mut drawn = 0usize;
term.hide_cursor()?;
let _cursor = CursorGuard { term: &term };
let selected = loop {
if drawn > 0 {
term.clear_last_lines(drawn)?;
}
let filtered = rank(&matcher, items, &search);
let mut prompt_line = String::new();
Theme::format_fuzzy_select_prompt(theme, &mut prompt_line, prompt, &search, search.len())?;
let prompt_budget = rows.saturating_mul(cols);
if prompt_budget > 0 && physical_rows(&prompt_line, cols) > rows {
prompt_line = truncate_str(&prompt_line, prompt_budget, "…").into_owned();
}
let prompt_rows = physical_rows(&prompt_line, cols);
let window = item_window(rows, prompt_rows, max_visible);
if filtered.is_empty() {
sel = 0;
top = 0;
} else {
sel = sel.min(filtered.len() - 1);
top = scroll_top(top, sel, window);
}
let mut lines: Vec<String> = Vec::with_capacity(window + 1);
lines.push(prompt_line);
for (pos, &item) in filtered.iter().enumerate().skip(top).take(window) {
let mut rendered = String::new();
Theme::format_fuzzy_select_prompt_item(
theme,
&mut rendered,
&items[item],
pos == sel,
true,
&matcher,
&search,
)?;
lines.push(rendered);
}
drawn = 0;
for line in &lines {
term.write_line(line)?;
drawn += physical_rows(line, cols);
}
term.flush()?;
match term.read_key()? {
Key::Escape | Key::CtrlC => break None,
Key::Enter if !filtered.is_empty() => break Some(filtered[sel]),
Key::ArrowUp | Key::BackTab if !filtered.is_empty() => {
sel = (sel + filtered.len() - 1) % filtered.len();
}
Key::ArrowDown | Key::Tab if !filtered.is_empty() => {
sel = (sel + 1) % filtered.len();
}
Key::Backspace => {
if search.pop().is_some() {
sel = 0;
top = 0;
}
}
Key::Char(c) if !c.is_ascii_control() => {
search.push(c);
sel = 0;
top = 0;
}
_ => {}
}
};
if drawn > 0 {
term.clear_last_lines(drawn)?;
}
Ok(selected)
}
pub fn row(summary: &Summary, now: DateTime<FixedOffset>, width: usize) -> String {
let prompts = if summary.prompts == 1 { "1 prompt".to_string() } else { format!("{} prompts", summary.prompts) };
let line = format!("{:<10} {} {} {}", ago(summary.last_used, now), project(summary), prompts, topic(summary));
truncate(&line, width)
}
fn id_row(summary: &Summary, now: DateTime<FixedOffset>) -> String {
format!("{} {}", crate::sanitize_terminal_text(&summary.id), row(summary, now, 100))
}
pub fn list_rows(dir: &Path, cwd: &str, exclude: Option<&str>) -> Result<Vec<String>> {
let sessions = session_index::list(dir)?;
let now = crate::session::now();
Ok(in_dir(&sessions, cwd).into_iter().filter(|s| Some(s.id.as_str()) != exclude).map(|s| id_row(s, now)).collect())
}
pub fn pick(dir: &Path, cwd: &str, exclude: Option<&str>) -> Result<Option<String>> {
Ok(match pick_outcome(dir, cwd, exclude)? {
Pick::Selected(id) => Some(id),
Pick::Cancelled | Pick::Empty => None,
})
}
pub enum Pick {
Selected(String),
Cancelled,
Empty,
}
pub fn pick_outcome(dir: &Path, cwd: &str, exclude: Option<&str>) -> Result<Pick> {
let mut sessions = session_index::list(dir)?;
sessions.retain(|s| Some(s.id.as_str()) != exclude);
if sessions.is_empty() {
eprintln!("No saved sessions to resume.");
return Ok(Pick::Empty);
}
let now = crate::session::now();
let (rows, cols) = crate::status::stderr_terminal_size().map_or((24, 100), |(r, c)| (r as usize, c as usize));
let width = cols.saturating_sub(4);
let max_visible = rows.saturating_sub(2).clamp(1, 15);
let theme = FitTheme { inner: ColorfulTheme::default(), width };
let mut show_all = false;
loop {
let shown: Vec<&Summary> = if show_all { sessions.iter().collect() } else { in_dir(&sessions, cwd) };
let mut items: Vec<String> = shown.iter().map(|s| row(s, now, usize::MAX)).collect();
let hidden = sessions.len() - shown.len();
if hidden > 0 {
items.push(format!("Show all sessions ({hidden} more in other directories)"));
}
let prompt = if show_all {
"Resume which session? (type to filter, Esc to cancel)".to_string()
} else {
format!("Resume which session in {}? (type to filter, Esc to cancel)", crate::sanitize_terminal_text(cwd))
};
match interactive_pick(&prompt, &theme, &items, rows, cols, max_visible)? {
None => return Ok(Pick::Cancelled),
Some(i) if i == shown.len() => show_all = true,
Some(i) => return Ok(Pick::Selected(shown[i].id.clone())),
}
}
}
pub fn print_list(dir: &Path, cwd: &str, all: bool, json: bool) -> Result<()> {
let sessions = session_index::list(dir)?;
let shown: Vec<&Summary> = if all { sessions.iter().collect() } else { in_dir(&sessions, cwd) };
let mut out = std::io::stdout().lock();
if json {
return match writeln!(out, "{}", serde_json::to_string_pretty(&shown)?) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e).context("write session list"),
};
}
if shown.is_empty() {
let scope = if all {
String::new()
} else {
format!(" for {} (--all lists every directory)", crate::sanitize_terminal_text(cwd))
};
eprintln!("No saved sessions{scope}.");
return Ok(());
}
let now = crate::session::now();
for summary in shown {
let line = writeln!(out, "{}", id_row(summary, now));
match line {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => break,
Err(e) => return Err(e).context("write session list"),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{Record, SessionLog};
use chrono::Duration;
fn summary(id: &str, cwd: Option<&str>, last: &str, context: Option<&str>) -> Summary {
Summary {
id: id.into(),
cwd: cwd.map(Into::into),
model: None,
created_at: None,
last_used: crate::session::now(),
prompts: 3,
first_prompt: None,
first_telling_prompt: None,
last_prompt: Some(last.into()),
context_prompt: context.map(Into::into),
log_bytes: 0,
title: None,
}
}
#[test]
fn title_leads_the_topic() {
let mut s = summary("s", None, "do it", Some("Fix the flaky deploy test"));
s.title = Some("Flaky deploy test".into());
assert_eq!(topic(&s), "Flaky deploy test · do it");
}
#[test]
fn title_drops_control_characters() {
let mut s = summary("s", None, "do it", None);
s.title = Some("evil\u{1b}[2J\ntitle".into());
assert_eq!(topic(&s), "evil[2J title · do it");
}
#[test]
fn ago_is_brief() {
let now = crate::session::now();
assert_eq!(ago(now, now), "just now");
assert_eq!(ago(now - Duration::minutes(5), now), "5m ago");
assert_eq!(ago(now - Duration::hours(3), now), "3h ago");
assert_eq!(ago(now - Duration::hours(30), now), "yesterday");
assert_eq!(ago(now - Duration::days(4), now), "4d ago");
let old = now - Duration::days(40);
assert_eq!(ago(old, now), old.with_timezone(&Local).format("%b %-d").to_string());
}
#[test]
fn row_shows_project_count_and_topic() {
let s = summary("s", Some("/work/rusty-harness"), "do it\nnow", Some("Fix the flaky\n deploy test"));
let row = row(&s, crate::session::now(), 200);
assert!(row.starts_with("just now rusty-harness 3 prompts"), "{row}");
assert!(row.contains("3 prompts do it now ← Fix the flaky deploy test"), "{row}");
let narrow = super::row(&s, crate::session::now(), 70);
assert!(narrow.ends_with('…') && super::cells(&narrow) <= 70, "{narrow}");
assert_eq!(project(&summary("s", None, "x", None)), "?");
}
#[test]
fn row_fits_narrow_and_wide_text() {
let s = summary("s", Some("/w"), "Fix the flaky deploy test in CI", None);
let narrow = row(&s, crate::session::now(), 30);
assert!(super::cells(&narrow) <= 30, "{narrow}");
let wide = summary("s", Some("/work/プロジェクト"), "修正テストを直す", None);
let row = row(&wide, crate::session::now(), 40);
assert!(super::cells(&row) <= 40, "{row}");
}
#[test]
fn truncate_never_exceeds_width_even_zero() {
assert_eq!(super::truncate("hello", 0), "");
assert_eq!(super::truncate("", 0), "");
assert_eq!(super::cells(&super::truncate("hello", 1)), 1);
let two = super::truncate("hello", 2);
assert!(super::cells(&two) <= 2 && two.ends_with('…'), "{two}");
assert_eq!(super::truncate("hi", 5), "hi");
}
#[test]
fn truncate_counts_variation_selector_width_as_a_string() {
let emoji = "#\u{fe0f}x";
assert_eq!(super::cells(emoji), 3);
let fitted = super::truncate(emoji, 2);
assert!(super::cells(&fitted) <= 2, "{fitted:?} exceeds width 2");
}
#[test]
fn rank_keeps_recency_order_when_search_is_empty() {
let matcher = SkimMatcherV2::default();
let items: Vec<String> = ["aaa", "bbb", "ccc"].iter().map(|s| s.to_string()).collect();
assert_eq!(rank(&matcher, &items, ""), vec![0, 1, 2]);
}
#[test]
fn rank_matches_full_row_text_and_drops_non_matches() {
let matcher = SkimMatcherV2::default();
let items: Vec<String> =
["morning acme 2 prompts fix the parser bug", "evening other 1 prompt write docs"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(rank(&matcher, &items, "parser"), vec![0]);
assert!(rank(&matcher, &items, "zzzzz").is_empty());
}
#[test]
fn rank_returns_distinct_indices_for_identical_rows() {
let matcher = SkimMatcherV2::default();
let items: Vec<String> = ["same row", "same row"].iter().map(|s| s.to_string()).collect();
assert_eq!(rank(&matcher, &items, "same"), vec![0, 1]);
}
#[test]
fn scroll_top_keeps_selection_in_the_window() {
assert_eq!(scroll_top(3, 1, 4), 1);
assert_eq!(scroll_top(0, 6, 4), 3);
assert_eq!(scroll_top(2, 3, 4), 2);
}
#[test]
fn physical_rows_counts_terminal_wrapping() {
assert_eq!(physical_rows("short", 80), 1);
assert_eq!(physical_rows(&"x".repeat(80), 80), 1);
assert_eq!(physical_rows(&"x".repeat(81), 80), 2);
assert_eq!(physical_rows("", 80), 1);
}
#[test]
fn item_window_never_pushes_the_frame_past_the_terminal() {
assert_eq!(item_window(24, 1, 15), 15);
assert_eq!(item_window(5, 2, 15), 3);
assert_eq!(item_window(3, 3, 15), 0);
assert_eq!(item_window(3, 7, 15), 0);
}
#[test]
fn project_and_topic_drop_control_characters() {
let s = summary("s", Some("/work/evil\u{1b}[2J"), "hi\u{1b}]8;;x\u{7}", None);
assert_eq!(project(&s), "evil[2J");
assert_eq!(topic(&s), "hi]8;;x");
}
#[test]
fn errors_and_plain_ids_drop_control_characters() {
let dir = tempfile::tempdir().unwrap();
let err = last(dir.path(), "/work/evil\u{1b}[2J", None).unwrap_err().to_string();
assert!(err.contains("no saved session for /work/evil[2J"), "{err}");
assert!(!err.contains('\u{1b}'), "{err}");
assert_eq!(crate::sanitize_terminal_text("s\u{1b}[2J"), "s[2J");
}
#[test]
fn directory_view_includes_unknown_cwd() {
let sessions = [
summary("here", Some("/a"), "x", None),
summary("there", Some("/b"), "x", None),
summary("old", None, "x", None),
];
let ids: Vec<&str> = in_dir(&sessions, "/a").iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, ["here", "old"]);
}
#[test]
fn last_requires_a_session_from_this_directory() {
let dir = tempfile::tempdir().unwrap();
let mut log = SessionLog::create_with(dir.path(), "s", Some("/a".into()), None).unwrap();
let input = Record::Input { id: "i".into(), text: "hello there".into(), recorded_at: crate::session::now() };
log.append(&input).unwrap();
assert_eq!(last(dir.path(), "/a", None).unwrap(), "s");
assert!(last(dir.path(), "/a", Some("s")).is_err(), "the session in use is skipped");
assert!(last(dir.path(), "/b", None).unwrap_err().to_string().contains("no saved session for /b"));
}
#[test]
fn list_rows_skips_the_session_in_use() {
let dir = tempfile::tempdir().unwrap();
for id in ["s", "t"] {
let mut log = SessionLog::create_with(dir.path(), id, Some("/a".into()), None).unwrap();
let input =
Record::Input { id: "i".into(), text: "hello there".into(), recorded_at: crate::session::now() };
log.append(&input).unwrap();
}
let rows = list_rows(dir.path(), "/a", None).unwrap();
assert_eq!(rows.len(), 2, "{rows:?}");
let rows = list_rows(dir.path(), "/a", Some("s")).unwrap();
assert_eq!(rows.len(), 1, "the session in use is left out: {rows:?}");
assert!(rows[0].starts_with("t "), "{rows:?}");
}
#[test]
fn pick_outcome_reports_empty_apart_from_cancelled() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(pick_outcome(dir.path(), "/a", None).unwrap(), Pick::Empty));
let mut log = SessionLog::create_with(dir.path(), "s", Some("/a".into()), None).unwrap();
let input = Record::Input { id: "i".into(), text: "hello there".into(), recorded_at: crate::session::now() };
log.append(&input).unwrap();
assert!(matches!(pick_outcome(dir.path(), "/a", Some("s")).unwrap(), Pick::Empty));
assert_eq!(pick(dir.path(), "/a", Some("s")).unwrap(), None);
}
}