use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::process::Command;
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent,
MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::{execute, terminal};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Terminal;
use crate::{ansi, rows};
use taimux_core::{env, index};
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
All,
Input,
Run,
Idle,
Outdated,
Dead,
}
impl Mode {
fn filter(self) -> &'static str {
match self {
Mode::All => "",
Mode::Input => "input",
Mode::Run => "run",
Mode::Idle => "idle",
Mode::Outdated => "",
Mode::Dead => "dead",
}
}
fn label(self) -> &'static str {
match self {
Mode::All => "agent sessions",
Mode::Input => "waiting for an answer",
Mode::Run => "working",
Mode::Idle => "idle at the prompt",
Mode::Outdated => "running outdated code",
Mode::Dead => "past sessions",
}
}
pub fn key(self) -> &'static str {
match self {
Mode::All => "all",
Mode::Input => "input",
Mode::Run => "run",
Mode::Idle => "idle",
Mode::Outdated => "outdated",
Mode::Dead => "dead",
}
}
pub fn from_key(k: &str) -> Mode {
match k {
"input" => Mode::Input,
"run" => Mode::Run,
"idle" => Mode::Idle,
"outdated" => Mode::Outdated,
"dead" => Mode::Dead,
_ => Mode::All,
}
}
fn next(self, ended: bool, outdated: bool) -> Mode {
let cycle = [
(Mode::All, true),
(Mode::Input, true),
(Mode::Run, true),
(Mode::Idle, true),
(Mode::Outdated, outdated),
(Mode::Dead, ended),
];
let at = cycle.iter().position(|(m, _)| *m == self).unwrap_or(0);
cycle
.iter()
.cycle()
.skip(at + 1)
.take(cycle.len())
.find(|(_, on)| *on)
.map(|(m, _)| *m)
.unwrap_or(Mode::All)
}
}
pub struct Source {
pub fetch: Arc<dyn Fn() -> String + Send + Sync>,
pub ended: Option<Box<dyn Fn() -> String>>,
pub cur: String,
pub cur_cwd: String,
pub cur_target: String,
pub home: String,
pub newver: String,
pub script: Option<String>,
pub popup: bool,
pub state: State,
}
fn nearest(rows: &[&rows::Row], cwd: &str, target: &str) -> Option<usize> {
rows.iter()
.enumerate()
.min_by_key(|(_, r)| {
let (shared, apart) = cwd_near(cwd, &r.cwd);
(Reverse(shared), apart, tmux_near(target, r))
})
.map(|(i, _)| i)
}
fn comps(p: &str) -> Vec<&str> {
p.split('/').filter(|c| !c.is_empty()).collect()
}
fn cwd_near(cur: &str, row: &str) -> (usize, usize) {
let (a, b) = (comps(cur), comps(row));
let shared = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
if shared == 0 {
return (0, 0);
}
(shared, (a.len() - shared) + (b.len() - shared))
}
fn tmux_near(cur: &str, r: &rows::Row) -> (u8, usize, usize) {
const ELSEWHERE: (u8, usize, usize) = (1, 0, 0);
if !r.host.is_empty() {
return (2, 0, 0);
}
let (Some((sess, win, pane)), Some((rsess, rwin, rpane))) =
(target_parts(cur), target_parts(&r.target))
else {
return ELSEWHERE;
};
if sess != rsess {
return ELSEWHERE;
}
(0, win.abs_diff(rwin), pane.abs_diff(rpane))
}
fn target_parts(t: &str) -> Option<(&str, usize, usize)> {
let (sess, rest) = t.split_once(':')?;
let (win, pane) = rest.split_once('.')?;
Some((sess, win.parse().ok()?, pane.parse().ok()?))
}
fn repaint<B: ratatui::backend::Backend>(term: &mut Terminal<B>) {
if let Ok(size) = term.size() {
let _ = term.resize(size.into());
}
}
fn act_child(script: &str, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
let mut c = Command::new(script);
c.args(args);
if let Ok(tty) = OpenOptions::new().write(true).open("/dev/tty") {
if let Ok(err) = tty.try_clone() {
c.stdout(tty).stderr(err);
}
}
if let Ok(inp) = OpenOptions::new().read(true).open("/dev/tty") {
c.stdin(inp);
}
c.status()
}
struct Guard {
out: File,
kitty: bool,
mouse: bool,
}
impl Guard {
fn new(kitty: bool) -> std::io::Result<Guard> {
let mut out = OpenOptions::new().write(true).open("/dev/tty")?;
terminal::enable_raw_mode()?;
execute!(out, terminal::EnterAlternateScreen, EnableBracketedPaste)?;
let mouse = env::var("TAIMUX_MOUSE").is_none_or(|v| v != "0");
if mouse {
let _ = execute!(out, EnableMouseCapture);
}
if kitty {
let _ = execute!(
out,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
Ok(Guard { out, kitty, mouse })
}
fn suspend(&mut self) {
if self.mouse {
let _ = execute!(self.out, DisableMouseCapture);
}
let _ = execute!(
self.out,
DisableBracketedPaste,
terminal::LeaveAlternateScreen
);
let _ = terminal::disable_raw_mode();
}
fn resume(&mut self) {
let _ = terminal::enable_raw_mode();
let _ = execute!(
self.out,
terminal::Clear(terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
terminal::EnterAlternateScreen,
EnableBracketedPaste
);
if self.mouse {
let _ = execute!(self.out, EnableMouseCapture);
}
}
}
impl Drop for Guard {
fn drop(&mut self) {
if self.kitty {
let _ = execute!(self.out, PopKeyboardEnhancementFlags);
}
self.suspend();
}
}
fn row_width(area_width: u16) -> usize {
(area_width as usize).saturating_sub(4)
}
fn search_min() -> usize {
env::var("TAIMUX_SEARCH_MIN")
.and_then(|v| v.parse().ok())
.unwrap_or(3)
}
fn search_enabled() -> bool {
env::on("TAIMUX_SEARCH")
}
fn sessions_enabled() -> bool {
env::on("TAIMUX_SESSIONS")
}
fn resize_enabled() -> bool {
env::on("TAIMUX_RESIZE")
}
pub fn ended_source() -> Option<Box<dyn Fn() -> String>> {
sessions_enabled().then(|| Box::new(|| index::dead_rows(now())) as Box<dyn Fn() -> String>)
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn filter(list: &[rows::Row], query: &str, matcher: &SkimMatcherV2) -> Vec<usize> {
let terms: Vec<&str> = query.split_whitespace().collect();
if terms.is_empty() {
return (0..list.len()).collect();
}
let mut scored: Vec<(i64, usize)> = Vec::new();
for (i, r) in list.iter().enumerate() {
let hay = r.plain();
let mut total = 0i64;
let mut all = true;
for t in &terms {
match matcher.fuzzy_match(&hay, t) {
Some(s) => total += s,
None => {
all = false;
break;
}
}
}
if all {
scored.push((total, i));
}
}
scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
scored.into_iter().map(|(_, i)| i).collect()
}
fn header(script: bool, ended: bool, search_key: bool, search_on: bool) -> String {
let mut h = String::from("enter: switch");
if ended {
h.push_str("/resume");
}
h.push_str(" tab: filter ctrl-r: refresh ctrl-/: preview");
if search_key {
h.push_str(if search_on {
" ctrl-t: search text (on)"
} else {
" ctrl-t: search text"
});
}
if script {
h.push_str(" ctrl-x: restart ctrl-o: hand off f8: restart all outdated");
}
h
}
fn version_tag() -> String {
format!(" taimux {} ", env!("CARGO_PKG_VERSION"))
}
fn room_for_tag(width: u16, count: &str) -> bool {
width as usize >= count.chars().count() + version_tag().chars().count() + 2
}
fn empty_note(
mode: Mode,
query: &str,
scanning: bool,
nothing_scanned: bool,
ended: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<String> = Vec::new();
if scanning {
lines.push("Looking for agent sessions…".into());
lines.push(String::new());
lines.push("Esc closes this.".into());
return lines
.into_iter()
.map(|l| Line::from(format!(" {}", l)))
.collect();
}
if !query.is_empty() {
lines.push(format!("Nothing matches {}", query));
lines.push("ctrl-u clears it.".into());
} else if mode == Mode::Dead {
lines.push("No past conversations have been found here yet.".into());
lines.push("They are remembered as sessions come and go.".into());
} else if nothing_scanned {
lines.push("No agent sessions on this machine.".into());
lines.push(
if ended {
"Nothing is running one. Tab reaches the conversations that ended."
} else {
"Nothing is running one."
}
.into(),
);
} else {
lines.push(format!("Nothing is {} right now.", mode.label()));
lines.push("Tab moves on to the next list.".into());
}
lines.push(String::new());
lines.push("Esc closes this.".into());
lines
.into_iter()
.map(|l| Line::from(format!(" {}", l)))
.collect()
}
fn label(mode: Mode, live: bool, search: bool, refreshing: bool) -> String {
let mut s = format!(" {}", mode.label());
if live {
s.push_str(" · live");
}
if search {
s.push_str(" · ⌕");
}
if refreshing {
s.push_str(" · refreshing");
}
s.push(' ');
s
}
const PREVIEW_TTL: Duration = Duration::from_millis(750);
const REMOTE_TTL: Duration = Duration::from_secs(3);
struct App {
src: Source,
matcher: SkimMatcherV2,
mode: Mode,
search: bool,
preview: bool,
query: String,
width: usize,
tsv: String,
all: Vec<rows::Row>,
view: Vec<usize>,
sel: usize,
shot: Option<(String, Instant, String)>,
poff: i32,
poff_for: String,
pending: Option<Receiver<Refresh>>,
pending_since: Instant,
client: Option<(String, (u16, u16))>,
restarting: HashMap<String, (Instant, String, usize, bool)>,
}
const RESTART_HOLD: Duration = Duration::from_secs(40);
impl App {
fn preview(&mut self) -> (Vec<Line<'static>>, Vec<Line<'static>>, bool) {
let Some(r) = self.view.get(self.sel).map(|&i| &self.all[i]) else {
return (Vec::new(), Vec::new(), false);
};
let (id, target, cwd, host) = (
r.pane_id.clone(),
r.target.clone(),
r.cwd.clone(),
r.host.clone(),
);
if self.poff_for != id {
self.poff = 0;
self.poff_for = id.clone();
}
let mut out: Vec<Line<'static>> = Vec::new();
let mut body: Vec<Line<'static>> = Vec::new();
if self.search && self.query.chars().count() >= search_min() {
let hits = index::preview_match(
&id,
&index::Query::new(&self.query),
env::var("TAIMUX_SEARCH_PREVIEW")
.and_then(|v| v.parse().ok())
.unwrap_or(4),
);
for h in hits {
out.push(Line::from(vec![
Span::styled("⌕ ", Style::default().fg(Color::Yellow)),
Span::raw(h),
]));
}
if !out.is_empty() {
out.push(Line::from(""));
}
}
out.push(Line::from(vec![
Span::styled(
target,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(cwd, Style::default().add_modifier(Modifier::DIM)),
]));
out.push(Line::from(Span::styled(
"─".repeat(44),
Style::default().fg(Color::DarkGray),
)));
out.push(Line::from(""));
if id.starts_with("dead:") {
if id == "dead:!" {
body.push(Line::from(Span::styled(
"the list is still being built",
Style::default().fg(Color::DarkGray),
)));
return (out, body, false);
}
let Some((agent, key)) = taimux_core::index::split_past_id(&id) else {
body.push(Line::from(Span::styled(
"that row does not name a conversation",
Style::default().fg(Color::Red),
)));
return (out, body, false);
};
if key.starts_with('/') && !std::path::Path::new(key).is_file() {
body.push(Line::from(Span::styled(
"this conversation is no longer on disk",
Style::default().fg(Color::Red),
)));
return (out, body, false);
}
let want = env::var("TAIMUX_DEAD_TURNS")
.and_then(|v| v.parse().ok())
.unwrap_or(6);
let turns = taimux_core::agents::turns(agent, key, want);
if turns.is_empty() {
body.push(Line::from(Span::styled(
"(nothing was said in this one)",
Style::default().fg(Color::DarkGray),
)));
}
for t in turns {
let cap = self.width.max(20) * 2;
let what = if t.text.chars().count() > cap {
format!("{}…", t.text.chars().take(cap).collect::<String>())
} else {
t.text
};
let (mark, st) = if t.you {
(
"❯ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().add_modifier(Modifier::DIM))
};
body.push(Line::from(vec![
Span::styled(mark, st),
Span::styled(what, st),
]));
body.push(Line::from(""));
}
return (out, body, false);
}
if !host.is_empty() {
let Some(script) = self.src.script.clone() else {
body.push(Line::from(Span::styled(
format!("on {}: no taimux to ask", host),
Style::default().fg(Color::DarkGray),
)));
return (out, body, false);
};
let fresh = matches!(&self.shot, Some((k, at, _))
if *k == id && at.elapsed() < REMOTE_TTL);
if !fresh {
let text = Command::new(&script)
.args(["preview", &id])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
self.shot = Some((id.clone(), Instant::now(), text));
}
let text = self
.shot
.as_ref()
.map(|(_, _, s)| s.clone())
.unwrap_or_default();
body.extend(tail(&text, usize::MAX));
return (out, body, true);
}
let fresh = matches!(&self.shot, Some((k, at, _))
if *k == id && at.elapsed() < PREVIEW_TTL);
if !fresh {
self.shot = Some((
id.clone(),
Instant::now(),
taimux_core::tmux::capture_coloured(&id).unwrap_or_default(),
));
}
let screen = self
.shot
.as_ref()
.map(|(_, _, s)| s.clone())
.unwrap_or_default();
body.extend(tail(&screen, usize::MAX));
(out, body, true)
}
}
fn tail(screen: &str, room: usize) -> Vec<Line<'static>> {
let mut lines = ansi::to_lines(screen);
while lines
.last()
.is_some_and(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
{
lines.pop();
}
let over = lines.len().saturating_sub(room);
lines.drain(..over);
lines
}
struct Refresh {
tsv: String,
took: Duration,
spent: String,
client: Option<(String, (u16, u16))>,
}
fn own_session() -> Option<String> {
let tmux = std::env::var("TMUX").ok()?;
let id = tmux.split(',').nth(2)?.trim();
(!id.is_empty()).then(|| format!("${}", id))
}
fn own_client() -> Option<(String, (u16, u16))> {
let session = own_session()?;
let out = taimux_core::tmux::ask(&[
"list-clients",
"-t",
&session,
"-F",
"#{client_tty} #{client_width} #{client_height}",
])?;
let mut lines = out.lines().filter(|l| !l.trim().is_empty());
let only = lines.next()?;
if lines.next().is_some() {
return None; }
let mut f = only.split_whitespace();
let (tty, w, h) = (f.next()?, f.next()?.parse().ok()?, f.next()?.parse().ok()?);
(w > 0 && h > 0).then(|| (tty.to_string(), (w, h)))
}
fn outgrown(ours: (u16, u16), client: (u16, u16), slack: u16) -> bool {
let (pw, ph) = crate::install::popup_geometry(client.0 as usize);
let want_w = (client.0 as u32 * pw as u32 / 100).saturating_sub(2) as u16;
let want_h = (client.1 as u32 * ph as u32 / 100).saturating_sub(2) as u16;
want_w > ours.0.saturating_add(slack) || want_h > ours.1.saturating_add(slack)
}
#[derive(Debug, PartialEq, Eq)]
pub struct State {
pub query: String,
pub mode: &'static str,
pub search: bool,
pub preview: bool,
pub on: String,
pub client: String,
}
impl Default for State {
fn default() -> Self {
State {
query: String::new(),
mode: "all",
search: false,
preview: true,
on: String::new(),
client: String::new(),
}
}
}
pub enum Outcome {
Chosen(String),
Aborted,
Resize(State),
}
fn slow_after() -> Duration {
Duration::from_secs_f32(
env::var("TAIMUX_SLOW_REFRESH")
.and_then(|v| v.parse().ok())
.unwrap_or(2.0),
)
}
fn log_slow(r: &Refresh) {
let line = format!(
"--- {} picker refresh took {:.1}s{}{}\n",
taimux_core::log::stamp(),
r.took.as_secs_f32(),
if r.spent.is_empty() { "" } else { ": " },
r.spent
);
let path = taimux_core::paths::runtime_dir().join("restart.log");
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = f.write_all(line.as_bytes());
}
}
impl App {
fn fetch(&mut self) {
self.tsv = match self.mode {
Mode::Dead => self.src.ended.as_ref().map(|f| f()).unwrap_or_default(),
_ => (self.src.fetch)(),
};
self.hold_restarting();
}
fn start_refresh(&mut self) {
if self.mode == Mode::Dead {
self.fetch();
self.rebuild();
return;
}
if self.pending.is_some() {
return;
}
let f = self.src.fetch.clone();
let watch = self.src.popup;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
taimux_core::stat::reset();
let at = Instant::now();
let tsv = f();
let _ = tx.send(Refresh {
tsv,
took: at.elapsed(),
spent: taimux_core::stat::report(),
client: watch.then(own_client).flatten(),
});
});
self.pending = Some(rx);
self.pending_since = Instant::now();
}
fn take_refresh(&mut self) -> bool {
let Some(rx) = &self.pending else {
return false;
};
match rx.try_recv() {
Ok(r) => {
if r.took >= slow_after() {
log_slow(&r);
}
self.client = r.client;
self.tsv = r.tsv;
self.hold_restarting();
self.pending = None;
true
}
Err(TryRecvError::Empty) => false,
Err(TryRecvError::Disconnected) => {
self.pending = None;
false
}
}
}
fn refreshing(&self) -> bool {
self.pending.is_some() && self.pending_since.elapsed() > Duration::from_secs(1)
}
fn hold_restarting(&mut self) {
if self.restarting.is_empty() {
return;
}
let present: HashSet<String> = self
.tsv
.lines()
.filter_map(|l| l.split('\t').next())
.map(str::to_string)
.collect();
let now = Instant::now();
self.restarting.retain(|id, (at, _, _, seen_gone)| {
if now.duration_since(*at) >= RESTART_HOLD {
return false;
}
if present.contains(id) {
!*seen_gone
} else {
*seen_gone = true;
true
}
});
if self.restarting.is_empty() {
return;
}
let mut held: Vec<(usize, String)> = self
.restarting
.iter()
.filter(|(id, _)| !present.contains(*id))
.map(|(_, (_, line, idx, _))| (*idx, line.clone()))
.collect();
held.sort_by_key(|(idx, _)| *idx);
let mut lines: Vec<String> = self.tsv.lines().map(str::to_string).collect();
for (idx, line) in held {
let at = idx.min(lines.len());
lines.insert(at, line);
}
self.tsv = lines.join("\n");
self.tsv.push('\n');
}
fn hold(&mut self, id: &str) {
if let Some((idx, line)) = self
.tsv
.lines()
.enumerate()
.find(|(_, l)| l.split('\t').next() == Some(id))
{
self.restarting.insert(
id.to_string(),
(Instant::now(), line.to_string(), idx, false),
);
}
}
fn snippets(&self) -> HashMap<String, String> {
if !self.search || self.query.chars().count() < search_min() {
return HashMap::new();
}
index::snippets(&index::Query::new(&self.query))
}
fn rebuild(&mut self) {
let on = self.selected().map(|r| r.pane_id.clone());
let only = if self.mode == Mode::Dead {
""
} else {
self.mode.filter()
};
self.all = rows::build(
&self.tsv,
&rows::Input {
cur: &self.src.cur,
width: self.width,
home: &self.src.home,
newver: &self.src.newver,
only,
outdated: self.mode == Mode::Outdated,
query: &self.query,
snips: self.snippets(),
ptitles: index::pane_titles(),
restarting: self.restarting.keys().cloned().collect(),
},
);
self.view = filter(&self.all, &self.query, &self.matcher);
self.sel = on
.and_then(|id| self.view.iter().position(|&i| self.all[i].pane_id == id))
.unwrap_or(0);
self.clamp();
}
fn query_changed(&mut self) {
if self.search {
self.rebuild();
} else {
self.view = filter(&self.all, &self.query, &self.matcher);
self.clamp();
}
}
fn clamp(&mut self) {
if self.view.is_empty() {
self.sel = 0;
} else if self.sel >= self.view.len() {
self.sel = self.view.len() - 1;
}
}
fn kill_word(&mut self) {
while self.query.ends_with(char::is_whitespace) {
self.query.pop();
}
while !self.query.is_empty() && !self.query.ends_with(char::is_whitespace) {
self.query.pop();
}
self.query_changed();
}
fn focus(&mut self, id: &str) -> bool {
match self.view.iter().position(|&i| self.all[i].pane_id == id) {
Some(i) => {
self.sel = i;
true
}
None => false,
}
}
fn focus_nearest(&mut self) {
if self.src.cur_cwd.is_empty() && self.src.cur_target.is_empty() {
return;
}
let rows: Vec<&rows::Row> = self.view.iter().map(|&i| &self.all[i]).collect();
if let Some(i) = nearest(&rows, &self.src.cur_cwd, &self.src.cur_target) {
self.sel = i;
}
}
fn selected(&self) -> Option<&rows::Row> {
self.view.get(self.sel).map(|&i| &self.all[i])
}
fn move_by(&mut self, d: isize) {
if self.view.is_empty() {
return;
}
let n = self.view.len() as isize;
self.sel = (((self.sel as isize + d) % n + n) % n) as usize; }
fn move_page(&mut self, pages: isize, page: usize) {
if self.view.is_empty() {
return;
}
let step = page.max(1) as isize;
let last = self.view.len() as isize - 1;
self.sel = (self.sel as isize + pages * step).clamp(0, last) as usize;
}
}
pub fn run(src: Source) -> std::io::Result<Outcome> {
let kitty = env::var("TAIMUX_TUI_KITTY").is_some_and(|v| v == "1");
let mut guard = Guard::new(kitty)?;
let backend = CrosstermBackend::new(guard.out.try_clone()?);
let mut term = Terminal::new(backend)?;
let refresh: f32 = env::var("TAIMUX_REFRESH")
.and_then(|v| v.parse().ok())
.unwrap_or(1.0);
let live = refresh > 0.0;
let mut app = App {
matcher: SkimMatcherV2::default().smart_case(),
mode: Mode::All,
search: false,
preview: true,
query: String::new(),
width: row_width(term.size()?.width),
tsv: String::new(),
all: Vec::new(),
view: Vec::new(),
sel: 0,
shot: None,
poff: 0,
poff_for: String::new(),
pending: None,
pending_since: Instant::now(),
client: None,
restarting: HashMap::new(),
src,
};
app.query = std::mem::take(&mut app.src.state.query);
app.mode = Mode::from_key(app.src.state.mode);
app.search = app.src.state.search;
app.preview = app.src.state.preview;
app.start_refresh();
let opening_on = if app.src.state.on.is_empty() {
app.src.cur.clone()
} else {
app.src.state.on.clone()
};
let mut opened = false;
let mut state = ListState::default();
let mut chosen: Option<String> = None;
let mut outgrew = false;
let mut ticked = Instant::now();
let mut page: usize = 1;
let mut list_y: u16 = 0;
let mut clicked: Option<(u16, Instant)> = None;
loop {
state.select(if app.view.is_empty() {
None
} else {
Some(app.sel)
});
term.draw(|f| {
let count = format!(" {}/{} ", app.view.len(), app.all.len());
let mut block = Block::bordered()
.title(label(app.mode, live, app.search, app.refreshing()))
.title_bottom(Line::from(count.clone()));
if room_for_tag(f.area().width, &count) {
block = block.title_bottom(
Line::from(Span::styled(
version_tag(),
Style::default().fg(Color::DarkGray),
))
.right_aligned(),
);
}
let inner = block.inner(f.area());
f.render_widget(block, f.area());
let [prompt, head, body] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(inner);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("pick ❯ ", Style::default().fg(Color::Cyan)),
Span::raw(app.query.clone()),
])),
prompt,
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
header(
app.src.script.is_some(),
app.src.ended.is_some(),
search_enabled(),
app.search,
),
Style::default().fg(Color::DarkGray),
))),
head,
);
let (body, prev) = if app.preview && body.height >= 8 {
let [a, b] =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
.areas(body);
(a, Some(b))
} else {
(body, None)
};
page = body.height as usize;
list_y = body.y;
let items: Vec<ListItem> = app
.view
.iter()
.map(|&i| {
ListItem::new(Line::from(
app.all[i]
.cells
.iter()
.map(|c| Span::styled(c.text.clone(), c.paint.style()))
.collect::<Vec<_>>(),
))
})
.collect();
if app.view.is_empty() {
f.render_widget(
Paragraph::new(empty_note(
app.mode,
&app.query,
!opened,
app.tsv.trim().is_empty(),
app.src.ended.is_some(),
))
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: false }),
body,
);
} else {
f.render_stateful_widget(
List::new(items)
.highlight_symbol("▶ ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
body,
&mut state,
);
}
if let Some(area) = prev {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let (head, text, at_bottom) = app.preview();
let hh = (head.len() as u16).min(inner.height);
let [hrect, brect] =
Layout::vertical([Constraint::Length(hh), Constraint::Min(0)]).areas(inner);
f.render_widget(Paragraph::new(head).wrap(Wrap { trim: false }), hrect);
let most = text.len().saturating_sub(brect.height as usize) as i32;
let base = if at_bottom { most } else { 0 };
let start = (base + app.poff).clamp(0, most.max(0));
app.poff = start - base;
f.render_widget(Paragraph::new(text).scroll((start as u16, 0)), brect);
}
})?;
if app.take_refresh() {
app.rebuild();
if !opened {
opened = true;
if !app.focus(&opening_on) {
app.focus_nearest();
}
}
if let Some((_, size)) = app.client.clone() {
if resize_enabled() && outgrown(term.size().map(|s| (s.width, s.height))?, size, 2)
{
outgrew = true;
break;
}
}
}
if live && ticked.elapsed().as_secs_f32() >= refresh {
ticked = Instant::now();
app.start_refresh();
}
if !event::poll(Duration::from_millis(120))? {
continue;
}
match event::read()? {
Event::Paste(text) => {
let first = text.split(['\r', '\n']).next().unwrap_or_default();
app.query.push_str(first);
app.query_changed();
}
Event::Mouse(MouseEvent { kind, row: my, .. }) => match kind {
MouseEventKind::ScrollUp => app.move_by(-1),
MouseEventKind::ScrollDown => app.move_by(1),
MouseEventKind::Down(MouseButton::Left) if my >= list_y => {
let i = state.offset() + (my - list_y) as usize;
if i < app.view.len() {
app.sel = i;
let again =
clicked.is_some_and(|(r, t)| r == my && t.elapsed() < DOUBLE_CLICK);
clicked = Some((my, Instant::now()));
if again {
if let Some(r) = app.selected() {
chosen = Some(r.pane_id.clone());
}
break;
}
}
}
_ => {}
},
Event::Resize(w, _) => {
app.width = row_width(w);
app.rebuild();
}
Event::Key(k) => {
if k.kind != KeyEventKind::Press {
continue;
}
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let alt = k.modifiers.contains(KeyModifiers::ALT);
let shift = k.modifiers.contains(KeyModifiers::SHIFT);
match k.code {
KeyCode::Esc => break,
KeyCode::Char('c') | KeyCode::Char('g') | KeyCode::Char('q') if ctrl => break,
KeyCode::Enter => {
if let Some(r) = app.selected() {
chosen = Some(r.pane_id.clone());
}
break;
}
KeyCode::Up if shift => app.poff -= 1,
KeyCode::Down if shift => app.poff += 1,
KeyCode::Down => app.move_by(1),
KeyCode::Up => app.move_by(-1),
KeyCode::Char('n') | KeyCode::Char('j') if ctrl => app.move_by(1),
KeyCode::Char('p') | KeyCode::Char('k') if ctrl => app.move_by(-1),
KeyCode::Home => app.sel = 0,
KeyCode::End => app.sel = app.view.len().saturating_sub(1),
KeyCode::PageDown => app.move_page(1, page),
KeyCode::PageUp => app.move_page(-1, page),
KeyCode::Tab => {
app.mode = app
.mode
.next(app.src.ended.is_some(), !app.src.newver.is_empty());
app.rebuild();
app.start_refresh();
}
KeyCode::Char('r') if ctrl => app.start_refresh(),
KeyCode::Char('t') if ctrl && search_enabled() => {
app.search = !app.search;
app.rebuild();
}
KeyCode::Char('/') | KeyCode::Char('_') | KeyCode::Char('\u{1f}') if ctrl => {
app.preview = !app.preview;
}
KeyCode::Char('u') if ctrl => {
app.query.clear();
app.query_changed();
}
KeyCode::Char('w') if ctrl => app.kill_word(),
KeyCode::Backspace if alt => app.kill_word(),
KeyCode::Backspace => {
app.query.pop();
app.query_changed();
}
KeyCode::Char('h') if ctrl => {
app.query.pop();
app.query_changed();
}
KeyCode::Char('l') if ctrl => repaint(&mut term),
KeyCode::Char('x') if ctrl => {
if let (Some(s), Some(r)) = (app.src.script.clone(), app.selected()) {
let id = r.pane_id.clone();
app.hold(&id);
guard.suspend();
if let Err(e) = act_child(&s, &["_restart", &id]) {
crate::act::report_failed_child("the restart", &e);
}
guard.resume();
repaint(&mut term);
app.rebuild();
app.start_refresh();
app.focus(&id);
}
}
KeyCode::Char('o') if ctrl => {
if let (Some(s), Some(r)) = (app.src.script.clone(), app.selected()) {
let id = r.pane_id.clone();
guard.suspend();
if let Err(e) = act_child(&s, &["_handoff", &id]) {
crate::act::report_failed_child("the handoff", &e);
}
guard.resume();
repaint(&mut term);
app.focus(&id);
}
}
KeyCode::F(8) => {
if let Some(s) = app.src.script.clone() {
guard.suspend();
if let Err(e) = act_child(&s, &["_sweep"]) {
crate::act::report_failed_child("the sweep", &e);
}
guard.resume();
repaint(&mut term);
app.rebuild();
app.start_refresh();
}
}
KeyCode::Char(c) if !ctrl && !alt => {
app.query.push(c);
app.query_changed();
}
_ => {}
}
}
_ => {}
}
}
drop(term);
drop(guard);
Ok(match (chosen, outgrew) {
(Some(id), _) => Outcome::Chosen(id),
(None, true) => Outcome::Resize(State {
query: app.query.clone(),
mode: app.mode.key(),
search: app.search,
preview: app.preview,
on: app
.selected()
.map(|r| r.pane_id.clone())
.unwrap_or_default(),
client: app.client.clone().map(|(tty, _)| tty).unwrap_or_default(),
}),
(None, false) => Outcome::Aborted,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn src(tsv: &str) -> Source {
let t = tsv.to_string();
Source {
fetch: Arc::new(move || t.clone()),
ended: None,
cur: String::new(),
cur_cwd: String::new(),
cur_target: String::new(),
home: "/h".into(),
newver: String::new(),
script: None,
popup: false,
state: Default::default(),
}
}
fn app(tsv: &str) -> App {
let mut a = App {
src: src(tsv),
matcher: SkimMatcherV2::default().smart_case(),
mode: Mode::All,
search: false,
preview: true,
query: String::new(),
width: 100,
tsv: String::new(),
all: Vec::new(),
view: Vec::new(),
sel: 0,
shot: None,
poff: 0,
poff_for: String::new(),
pending: None,
pending_since: Instant::now(),
client: None,
restarting: HashMap::new(),
};
a.fetch();
a.rebuild();
a
}
fn app_live(cell: Arc<std::sync::Mutex<String>>) -> App {
let c = cell.clone();
let mut a = App {
src: Source {
fetch: Arc::new(move || c.lock().unwrap().clone()),
ended: None,
cur: String::new(),
cur_cwd: String::new(),
cur_target: String::new(),
home: "/h".into(),
newver: String::new(),
script: None,
popup: false,
state: Default::default(),
},
matcher: SkimMatcherV2::default().smart_case(),
mode: Mode::All,
search: false,
preview: true,
query: String::new(),
width: 100,
tsv: String::new(),
all: Vec::new(),
view: Vec::new(),
sel: 0,
shot: None,
poff: 0,
poff_for: String::new(),
pending: None,
pending_since: Instant::now(),
client: None,
restarting: HashMap::new(),
};
a.fetch();
a.rebuild();
a
}
fn ids(a: &App) -> Vec<String> {
a.view.iter().map(|&i| a.all[i].pane_id.clone()).collect()
}
#[test]
fn a_restarting_row_stays_in_the_list_where_it_was() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.sel = 1; assert_eq!(ids(&a), ["%1", "%2", "%3"]);
a.hold("%2");
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
%3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
.to_string();
a.fetch();
a.rebuild();
assert_eq!(ids(&a), ["%1", "%2", "%3"], "the row should still be there");
assert_eq!(
a.selected().map(|r| r.pane_id.as_str()),
Some("%2"),
"and the cursor should still be on it"
);
}
#[test]
fn a_row_is_still_held_through_the_refresh_before_the_session_goes() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.sel = 1;
a.hold("%2");
a.fetch();
a.rebuild();
assert_eq!(
ids(&a),
["%1", "%2", "%3"],
"no duplicate while it is present"
);
assert!(
a.restarting.contains_key("%2"),
"not yet gone, so still held"
);
assert_eq!(
a.selected().map(|r| r.pane_id.as_str()),
Some("%2"),
"cursor stays put"
);
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
%3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
.to_string();
a.fetch();
a.rebuild();
assert_eq!(
ids(&a),
["%1", "%2", "%3"],
"held in place while it is away"
);
assert_eq!(
a.selected().map(|r| r.pane_id.as_str()),
Some("%2"),
"and the cursor is STILL on the session being upgraded"
);
*cell.lock().unwrap() = THREE.to_string();
a.fetch();
a.rebuild();
assert!(a.restarting.is_empty(), "back for real, so no longer held");
assert_eq!(ids(&a), ["%1", "%2", "%3"]);
assert_eq!(a.selected().map(|r| r.pane_id.as_str()), Some("%2"));
}
#[test]
fn a_held_row_is_not_moved_to_the_end() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.hold("%1");
*cell.lock().unwrap() = "%2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
%3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
.to_string();
a.fetch();
a.rebuild();
assert_eq!(ids(&a), ["%1", "%2", "%3"], "%1 was first and stays first");
}
#[test]
fn the_hold_is_released_when_the_session_comes_back() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.hold("%2");
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
a.fetch();
assert!(a.restarting.contains_key("%2"), "still away, still held");
*cell.lock().unwrap() = THREE.to_string();
a.fetch();
a.rebuild();
assert!(a.restarting.is_empty(), "back, so no longer held");
assert_eq!(ids(&a), ["%1", "%2", "%3"]);
}
#[test]
fn the_hold_expires() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.hold("%2");
if let Some(e) = a.restarting.get_mut("%2") {
e.0 = Instant::now() - RESTART_HOLD - Duration::from_secs(1);
}
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
a.fetch();
a.rebuild();
assert!(a.restarting.is_empty());
assert_eq!(
ids(&a),
["%1"],
"the row is gone, because the restart failed"
);
}
#[test]
fn a_held_row_is_marked_as_restarting() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.hold("%2");
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
a.fetch();
a.rebuild();
let row = a.all.iter().find(|r| r.pane_id == "%2").unwrap();
let text = row.to_ansi();
assert!(
text.contains('↻'),
"expected the restart marker in {text:?}"
);
assert!(!text.contains('✳'), "must not read as asking: {text:?}");
}
#[test]
fn a_held_row_survives_the_mode_it_was_watched_in() {
let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
let mut a = app_live(cell.clone());
a.mode = Mode::Run; a.rebuild();
assert_eq!(ids(&a), ["%2"]);
a.hold("%2");
*cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
a.fetch();
a.rebuild();
assert_eq!(ids(&a), ["%2"], "still listed under the filter it was in");
}
#[test]
fn asking_for_a_refresh_does_not_wait_for_it() {
let mut a = app(THREE);
a.src.fetch = Arc::new(|| {
std::thread::sleep(Duration::from_millis(400));
"%9\tz:1.1\t/h\tclaude\t1\tidle\t-\tlate arrival".to_string()
});
let at = Instant::now();
a.start_refresh();
assert!(
at.elapsed() < Duration::from_millis(100),
"start_refresh blocked for {:?}",
at.elapsed()
);
assert!(!a.take_refresh(), "nothing has landed yet");
assert_eq!(a.all.len(), 3, "and the old rows are still there to draw");
let mut got = false;
for _ in 0..100 {
if a.take_refresh() {
got = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(got, "the refresh never arrived");
a.rebuild();
assert_eq!(ids(&a), ["%9"]);
}
#[test]
fn a_second_refresh_is_not_started_while_one_is_out() {
let mut a = app(THREE);
let runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let r = runs.clone();
a.src.fetch = Arc::new(move || {
r.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
String::new()
});
a.start_refresh();
a.start_refresh();
a.start_refresh();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(runs.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn a_refresh_that_never_answers_is_forgotten() {
let mut a = app(THREE);
a.src.fetch = Arc::new(|| panic!("the scan blew up"));
a.start_refresh();
for _ in 0..100 {
if a.pending.is_none() {
break;
}
let _ = a.take_refresh();
std::thread::sleep(Duration::from_millis(20));
}
assert!(a.pending.is_none(), "still waiting on a dead thread");
assert_eq!(a.all.len(), 3, "and the list it had is untouched");
}
#[test]
fn the_border_says_refreshing_only_when_it_is_worth_saying() {
let mut a = app(THREE);
a.src.fetch = Arc::new(|| {
std::thread::sleep(Duration::from_millis(1500));
String::new()
});
a.start_refresh();
assert!(!a.refreshing(), "not from the first millisecond");
a.pending_since = Instant::now() - Duration::from_secs(2);
assert!(a.refreshing());
assert_eq!(
label(Mode::All, true, false, true),
" agent sessions · live · refreshing "
);
}
#[test]
fn an_empty_list_says_which_kind_of_empty_it_is() {
let text = |ls: Vec<Line<'static>>| -> String {
ls.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let none = text(empty_note(Mode::All, "", false, true, false));
assert!(none.contains("No agent sessions on this machine"), "{none}");
assert!(none.contains("Esc closes this"), "{none}");
let none_ended = text(empty_note(Mode::All, "", false, true, true));
assert!(none_ended.contains("Tab reaches the conversations that ended"));
let filtered = text(empty_note(Mode::Input, "", false, false, true));
assert!(
filtered.contains("Nothing is waiting for an answer right now"),
"{filtered}"
);
assert!(!filtered.contains("No agent sessions"), "{filtered}");
let q = text(empty_note(Mode::All, "zzz", false, false, true));
assert!(q.contains("Nothing matches zzz"), "{q}");
assert!(q.contains("ctrl-u"), "{q}");
let dead = text(empty_note(Mode::Dead, "", false, false, true));
assert!(
dead.contains("No past conversations have been found here yet"),
"{dead}"
);
let scanning = text(empty_note(Mode::All, "", true, true, true));
assert!(
scanning.contains("Looking for agent sessions"),
"{scanning}"
);
assert!(!scanning.contains("No agent sessions"), "{scanning}");
let scanning_q = text(empty_note(Mode::Input, "zzz", true, false, true));
assert!(
scanning_q.contains("Looking for agent sessions"),
"{scanning_q}"
);
}
#[test]
fn the_default_state_is_an_ordinary_open() {
let d = State::default();
assert!(d.preview);
assert!(!d.search);
assert_eq!(Mode::from_key(d.mode), Mode::All);
assert!(d.query.is_empty() && d.on.is_empty());
}
#[test]
fn only_a_terminal_that_grew_past_the_popup_counts() {
assert!(
!outgrown((126, 38), (160, 50), 2),
"the size it was opened at is not a reason to reopen"
);
assert!(outgrown((126, 38), (200, 60), 2));
assert!(!outgrown((58, 18), (60, 20), 2));
}
#[test]
fn the_slack_stops_a_reopen_over_rounding() {
assert!(!outgrown((78, 19), (80, 24), 2));
assert!(!outgrown((78, 19), (81, 24), 2));
assert!(outgrown((78, 19), (140, 40), 2));
}
#[test]
fn a_resize_hands_over_what_the_picker_was_doing() {
let mut a = app(THREE);
a.query = "banana".into();
a.mode = Mode::Run;
a.search = true;
a.preview = false;
a.query_changed();
let state = State {
query: a.query.clone(),
mode: a.mode.key(),
search: a.search,
preview: a.preview,
on: a.selected().map(|r| r.pane_id.clone()).unwrap_or_default(),
client: "/dev/pts/7".into(),
};
assert_eq!(
state,
State {
query: "banana".into(),
mode: "run",
search: true,
preview: false,
on: "%2".into(),
client: "/dev/pts/7".into(),
}
);
assert_eq!(Mode::from_key(state.mode), Mode::Run);
assert_eq!(Mode::from_key("outdated"), Mode::Outdated);
assert_eq!(Mode::from_key(""), Mode::All);
}
#[test]
fn focus_puts_the_cursor_back_and_is_silent_when_it_cannot() {
let mut a = app(THREE);
a.sel = 0;
assert!(a.focus("%3"));
assert_eq!(a.selected().map(|r| r.pane_id.as_str()), Some("%3"));
assert!(!a.focus("%404"));
assert_eq!(
a.selected().map(|r| r.pane_id.as_str()),
Some("%3"),
"a pane that is not listed leaves the cursor alone"
);
}
fn app_at(tsv: &str, cwd: &str, target: &str) -> App {
let mut a = app(tsv);
a.src.cur_cwd = cwd.into();
a.src.cur_target = target.into();
a.focus_nearest();
a
}
fn on(a: &App) -> &str {
a.selected().map(|r| r.pane_id.as_str()).unwrap_or("")
}
const TREE: &str = "%1\tw:1.1\t/h/notes\tclaude\t1\tidle\t-\tnotes\n\
%2\tw:2.1\t/h/proj/web\tclaude\t1\tidle\t-\tweb\n\
%3\tw:3.1\t/h/proj/web/docs\tclaude\t1\tidle\t-\tdocs\n\
%4\tw:4.1\t/h/proj\tclaude\t1\tidle\t-\tproj";
#[test]
fn a_pane_with_no_agent_opens_on_the_session_in_its_own_directory() {
assert_eq!(on(&app_at(TREE, "/h/proj/web", "z:1.1")), "%2");
assert_eq!(on(&app_at(TREE, "/h/proj/web/", "z:1.1")), "%2");
assert_eq!(on(&app_at(TREE, "/h/notes", "z:1.1")), "%1");
}
#[test]
fn a_subdirectory_beats_the_parent_directory() {
let two = "%3\tw:3.1\t/h/proj/web/docs\tclaude\t1\tidle\t-\tdocs\n\
%4\tw:4.1\t/h/proj\tclaude\t1\tidle\t-\tproj";
assert_eq!(on(&app_at(two, "/h/proj/web", "z:1.1")), "%3");
assert_eq!(on(&app_at(TREE, "/h/proj/web/docs/api", "z:1.1")), "%3");
}
#[test]
fn the_directory_outranks_how_near_the_pane_is() {
let two = "%1\tw:1.1\t/h/proj\tclaude\t1\tidle\t-\tright tree\n\
%2\tw:4.1\t/h/other\tclaude\t1\tidle\t-\tnext door";
assert_eq!(on(&app_at(two, "/h/proj", "w:5.1")), "%1");
}
#[test]
fn equal_directories_are_separated_by_the_tmux_list() {
let same = "%1\tother:1.1\t/h/proj\tclaude\t1\tidle\t-\tanother session\n\
%2\tw:9.1\t/h/proj\tclaude\t1\tidle\t-\tfar window\n\
%3\tw:2.1\t/h/proj\tclaude\t1\tidle\t-\tnext window";
assert_eq!(on(&app_at(same, "/h/proj", "w:3.1")), "%3");
let remote = "ha:%9\tw:1.1\t/h/proj\tclaude\t1\tidle\t-\tover there\n\
%1\tother:1.1\t/h/proj\tclaude\t1\tidle\t-\there";
assert_eq!(on(&app_at(remote, "/h/proj", "w:3.1")), "%1");
}
#[test]
fn a_directory_that_shares_nothing_falls_through_to_the_list() {
let two = "%1\tw:1.1\t/h/a/b/c\tclaude\t1\tidle\t-\tdeep\n\
%2\tw:4.1\t/h/b\tclaude\t1\tidle\t-\tshallow";
assert_eq!(on(&app_at(two, "/tmp/scratch", "w:5.1")), "%2");
}
#[test]
fn an_unknown_position_leaves_the_cursor_at_the_top() {
assert_eq!(on(&app_at(TREE, "", "")), "%1");
}
const THREE: &str = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
%2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
%3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart";
#[test]
fn the_row_width_excludes_the_border_and_the_pointer() {
assert_eq!(row_width(130), 126);
assert_eq!(row_width(2), 0); assert_eq!(row_width(0), 0);
}
#[test]
fn tab_steps_round_the_cycle_and_starts_over() {
let mut m = Mode::All;
let seen: Vec<Mode> = (0..6)
.map(|_| {
m = m.next(true, true);
m
})
.collect();
assert_eq!(
seen,
vec![
Mode::Input,
Mode::Run,
Mode::Idle,
Mode::Outdated,
Mode::Dead,
Mode::All
]
);
}
#[test]
fn the_ended_mode_is_skipped_without_a_sessions_cache() {
assert_eq!(Mode::Idle.next(false, false), Mode::All);
assert_eq!(Mode::Idle.next(true, false), Mode::Dead);
}
#[test]
fn the_outdated_mode_is_skipped_when_no_version_is_installed() {
assert_eq!(Mode::Idle.next(false, true), Mode::Outdated);
assert_eq!(Mode::Outdated.next(false, true), Mode::All);
assert_eq!(Mode::Outdated.next(true, true), Mode::Dead);
assert_eq!(Mode::Idle.next(false, false), Mode::All);
}
#[test]
fn the_label_says_which_list_and_what_is_on() {
assert_eq!(label(Mode::All, false, false, false), " agent sessions ");
assert_eq!(
label(Mode::Input, true, false, false),
" waiting for an answer · live "
);
assert_eq!(
label(Mode::Outdated, false, false, false),
" running outdated code "
);
assert_eq!(
label(Mode::Dead, true, true, false),
" past sessions · live · ⌕ "
);
}
#[test]
fn the_outdated_mode_lists_the_rows_a_restart_would_act_on() {
let mut a = app(VERSIONS);
a.src.newver = "2.1.243".into();
a.mode = Mode::Outdated;
a.rebuild();
assert_eq!(ids(&a), ["%1", "%2"], "behind, whatever they are doing");
a.src.newver = String::new();
a.rebuild();
assert!(a.view.is_empty());
}
const VERSIONS: &str = "%1\ta:1.1\t/h\tclaude\t2.1.229\tidle\t-\tbehind\n\
%2\tb:1.1\t/h\tclaude\t2.1.229\tinput\t-\tbehind and asking\n\
%3\tc:1.1\t/h\tclaude\t2.1.243\trun\t-\tcurrent\n\
ha:%4\td:1.1\t/h\tclaude\t2.1.229\tidle\t-\tover there";
#[test]
fn the_stamp_names_the_tool_and_carries_the_crate_version() {
let tag = version_tag();
assert!(tag.contains("taimux"));
assert!(tag.contains(env!("CARGO_PKG_VERSION")));
assert!(tag.starts_with(' ') && tag.ends_with(' '));
}
#[test]
fn the_stamp_yields_to_the_count_on_a_narrow_border() {
let count = " 5/5 ";
let need = count.len() + version_tag().chars().count() + 2;
assert!(room_for_tag(need as u16, count));
assert!(!room_for_tag(need as u16 - 1, count));
assert!(!room_for_tag(need as u16, " 1000/1000 "));
assert!(!room_for_tag(16, count));
}
#[test]
fn the_header_advertises_only_bound_keys() {
let bare = header(false, false, false, false);
assert!(!bare.contains("ctrl-x"));
assert!(!bare.contains("resume"));
assert!(!bare.contains("ctrl-t"));
assert!(header(true, false, false, false).contains("ctrl-x"));
assert!(header(false, true, false, false).contains("enter: switch/resume"));
assert!(header(false, false, true, true).contains("(on)"));
assert!(!header(false, false, true, false).contains("(on)"));
}
#[test]
fn a_mode_shows_only_that_state() {
let mut a = app(THREE);
assert_eq!(a.view.len(), 3);
a.mode = Mode::Run;
a.rebuild();
assert_eq!(a.view.len(), 1);
assert!(a.selected().unwrap().plain().contains("banana"));
}
#[test]
fn the_query_filters_and_ranks() {
let mut a = app(THREE);
a.query = "banana".into();
a.view = filter(&a.all, &a.query, &a.matcher);
assert_eq!(a.view.len(), 1);
a.query = "apple tart".into();
a.view = filter(&a.all, &a.query, &a.matcher);
assert!(a.view.is_empty());
}
#[test]
fn no_query_keeps_the_lists_own_order() {
let a = app(THREE);
assert_eq!(a.view, vec![0, 1, 2]);
}
#[test]
fn a_rebuild_keeps_the_cursor_on_the_same_session() {
let mut a = app(THREE);
a.sel = 2;
let was = a.selected().unwrap().pane_id.clone();
a.src = src("%2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
%3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart");
a.fetch();
a.rebuild();
assert_eq!(a.selected().unwrap().pane_id, was);
assert_eq!(a.sel, 1);
}
#[test]
fn a_cursor_whose_row_is_gone_falls_back_to_the_top() {
let mut a = app(THREE);
a.sel = 2;
a.src = src("%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie");
a.fetch();
a.rebuild();
assert_eq!(a.sel, 0);
}
#[test]
fn the_cursor_wraps_both_ways() {
let mut a = app(THREE);
a.move_by(-1);
assert_eq!(a.sel, 2);
a.move_by(1);
assert_eq!(a.sel, 0);
}
#[test]
fn a_page_clamps_where_a_single_step_wraps() {
let mut a = app(THREE);
a.move_page(1, 2);
assert_eq!(a.sel, 2);
a.move_page(1, 2); assert_eq!(a.sel, 2);
a.move_page(-1, 2);
assert_eq!(a.sel, 0);
a.move_page(-1, 2);
assert_eq!(a.sel, 0);
}
#[test]
fn a_page_of_no_rows_still_moves_one() {
let mut a = app(THREE);
a.move_page(1, 0);
assert_eq!(a.sel, 1);
}
#[test]
fn an_empty_view_pages_without_panicking() {
let mut a = app(THREE);
a.query = "zzzzz".into();
a.query_changed();
assert!(a.view.is_empty());
a.move_page(1, 8);
a.move_page(-1, 8);
assert_eq!(a.sel, 0);
}
#[test]
fn an_empty_view_is_safe_to_navigate() {
let mut a = app(THREE);
a.query = "zzzzz".into();
a.view = filter(&a.all, &a.query, &a.matcher);
assert!(a.view.is_empty());
a.move_by(1);
a.move_by(-1);
a.clamp();
assert!(a.selected().is_none());
}
#[test]
fn the_preview_tail_ignores_the_padding_capture_pane_adds() {
let screen = "one\ntwo\nthree\n\n\n\n\n\n\n\n";
let t = tail(screen, 2);
let text: Vec<String> = t
.iter()
.map(|l| l.spans.iter().map(|s| s.content.to_string()).collect())
.collect();
assert_eq!(text, vec!["two", "three"]);
}
#[test]
fn a_screen_shorter_than_the_room_is_shown_whole() {
assert_eq!(tail("one\ntwo\n", 40).len(), 2);
assert!(tail("", 40).is_empty());
assert!(tail("\n\n\n", 40).is_empty());
}
#[test]
fn a_pasted_newline_stays_out_of_the_query() {
let text = "set -g @plugin foo\rdo not write below this line";
let first = text.split(['\r', '\n']).next().unwrap();
assert_eq!(first, "set -g @plugin foo");
}
}