use std::collections::{HashMap, HashSet};
use ratatui::style::{Color, Modifier, Style};
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Paint(pub &'static str);
pub const PLAIN: Paint = Paint("");
pub const LABEL_CUR: Paint = Paint("\x1b[33m");
pub const LABEL_OTHER: Paint = Paint("\x1b[36m");
pub const MARK_INPUT: Paint = Paint("\x1b[1;33m");
pub const MARK_RUN: Paint = Paint("\x1b[2m");
pub const MARK_RESTART: Paint = Paint("\x1b[36m");
pub const PATH: Paint = Paint("\x1b[90m");
pub const MODE_ASK: Paint = Paint("\x1b[35m");
pub const MODE_EDIT: Paint = Paint("\x1b[95m");
pub const MODE_AUTO: Paint = Paint("\x1b[1;95m");
pub const VER_STALE: Paint = Paint("\x1b[33m");
pub const VER_OK: Paint = Paint("\x1b[2;35m");
impl Paint {
pub fn style(self) -> Style {
match self.0 {
"\x1b[33m" => Style::default().fg(Color::Yellow),
"\x1b[36m" => Style::default().fg(Color::Cyan),
"\x1b[1;33m" => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
"\x1b[2m" => Style::default().add_modifier(Modifier::DIM),
"\x1b[90m" => Style::default().fg(Color::DarkGray),
"\x1b[35m" => Style::default().fg(Color::Magenta),
"\x1b[95m" => Style::default().fg(Color::LightMagenta),
"\x1b[1;95m" => Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
"\x1b[2;35m" => Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::DIM),
_ => Style::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cell {
pub text: String,
pub paint: Paint,
}
fn cell(text: impl Into<String>, paint: Paint) -> Cell {
Cell {
text: text.into(),
paint,
}
}
#[derive(Clone, Debug)]
pub struct Row {
pub cells: Vec<Cell>,
pub pane_id: String,
pub target: String,
pub cwd: String,
pub host: String,
}
impl Row {
pub fn to_ansi(&self) -> String {
let mut s = String::new();
for c in &self.cells {
if c.paint == PLAIN {
s.push_str(&c.text);
} else {
s.push_str(c.paint.0);
s.push_str(&c.text);
s.push_str("\x1b[0m");
}
}
s.push('\t');
s.push_str(&self.pane_id);
s
}
#[allow(dead_code)] pub fn plain(&self) -> String {
self.cells.iter().map(|c| c.text.as_str()).collect()
}
}
fn vlen(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
fn spaces(n: usize) -> String {
" ".repeat(n)
}
fn pad(s: &str, w: usize) -> String {
let mut out = s.to_string();
out.push_str(&spaces(w.saturating_sub(vlen(s))));
out
}
pub fn summary_of(title: &str) -> &str {
let run: usize = title
.chars()
.take_while(|c| !(' '..='~').contains(c))
.map(|c| c.len_utf8())
.sum();
if run == 0 {
return title;
}
let rest = &title[run..];
match rest.strip_prefix(' ') {
Some(r) => r,
None if rest.is_empty() => rest,
None => title,
}
}
fn mode_paint(mode: &str) -> Paint {
match mode {
"acceptEdits" => MODE_EDIT,
"bypassPermissions" | "auto" => MODE_AUTO,
_ => MODE_ASK,
}
}
fn outdated(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> bool {
state != "dead"
&& host.is_empty()
&& agent == "claude"
&& !newver.is_empty()
&& !v.is_empty()
&& v != newver
}
fn version_paint(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> Paint {
if outdated(host, agent, v, state, newver) {
VER_STALE
} else {
VER_OK
}
}
fn abbrev(names: &[String], minlen: usize) -> HashMap<String, String> {
let mut out = HashMap::new();
for s in names {
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len() + 1; for k in 1..=chars.len() {
let head: String = chars.iter().take(k).collect();
let clash = names
.iter()
.any(|t| t != s && t.chars().take(k).collect::<String>() == head);
if !clash {
n = k;
break;
}
}
let n = n.max(minlen);
out.insert(s.clone(), chars.iter().take(n).collect());
}
out
}
fn path_display(cwd: &str, home: &str) -> String {
let cwd = if !home.is_empty() && cwd.starts_with(home) {
format!("~{}", &cwd[home.len()..])
} else {
cwd.to_string()
};
let parts: Vec<&str> = cwd.split('/').collect();
let disp = if parts.len() >= 2 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
cwd.clone()
};
let n = disp.chars().count();
if n > 30 {
let tail: String = disp.chars().skip(n - 29).collect();
format!("…{}", tail)
} else {
disp
}
}
fn says_it(s: &str, terms: &[String], fold: bool) -> bool {
if terms.is_empty() {
return false;
}
let hay = if fold {
s.to_lowercase()
} else {
s.to_string()
};
terms
.iter()
.all(|t| t.is_empty() || hay.contains(t.as_str()))
}
fn trim_trailing(cells: &mut Vec<Cell>) {
while let Some(last) = cells.last_mut() {
if last.paint != PLAIN {
break;
}
let trimmed = last.text.trim_end_matches(' ');
if trimmed.len() == last.text.len() {
break; }
last.text.truncate(trimmed.len());
if !last.text.is_empty() {
break; }
cells.pop();
}
}
struct Item {
id: String,
target: String,
agent: String,
version: String,
state: String,
mode: String,
title: String,
host: String,
note: bool,
path: String,
cwd: String,
session: String,
}
#[derive(Default)]
pub struct Input<'a> {
pub cur: &'a str,
pub width: usize,
pub home: &'a str,
pub newver: &'a str,
pub only: &'a str,
pub outdated: bool,
pub query: &'a str,
pub snips: HashMap<String, String>,
pub ptitles: HashMap<String, String>,
pub restarting: HashSet<String>,
}
pub fn build(lines: &str, input: &Input) -> Vec<Row> {
let terms: Vec<String> = input
.query
.split([' ', '\t'])
.filter(|t| !t.is_empty())
.map(|t| t.to_string())
.collect();
let fold = input.query == input.query.to_lowercase();
let mut items: Vec<Item> = Vec::new();
for line in lines.lines() {
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 7 {
continue;
}
let state = f[5];
if !input.only.is_empty() && state != input.only {
continue;
}
let (id, target, cwd) = (f[0], f[1], f[2]);
let (mut host, mut note) = (String::new(), false);
if !id.starts_with('%') {
if let Some(c) = id.find(':') {
if c > 0 {
if id[c + 1..].starts_with('%') {
host = id[..c].to_string();
} else {
note = true;
}
}
}
}
if input.outdated && !outdated(&host, f[3], f[4], state, input.newver) {
continue;
}
let session = match target.find(':') {
Some(c) if c > 0 => target[..c].to_string(),
_ => target.to_string(),
};
items.push(Item {
id: id.to_string(),
target: target.to_string(),
agent: f[3].to_string(),
version: f[4].to_string(),
state: state.to_string(),
mode: f[6].to_string(),
title: f.get(7).copied().unwrap_or("").to_string(),
host,
note,
path: path_display(cwd, input.home),
cwd: cwd.to_string(),
session,
});
}
let compact = input.width > 0 && input.width < 100;
let mut names: Vec<String> = Vec::new();
let mut hnames: Vec<String> = Vec::new();
for it in &items {
if !it.note && !names.contains(&it.session) {
names.push(it.session.clone());
}
if !it.host.is_empty() && !hnames.contains(&it.host) {
hnames.push(it.host.clone());
}
}
let (short, shorth) = if compact {
(abbrev(&names, 1), abbrev(&hnames, 2))
} else {
(HashMap::new(), HashMap::new())
};
let mut labels: Vec<String> = Vec::new();
let mut labelw = 0;
for it in &items {
let lbl = if it.note {
it.target.clone()
} else {
let pfx = if it.host.is_empty() {
String::new()
} else if compact {
format!("{}/", shorth.get(&it.host).unwrap_or(&it.host))
} else {
format!("{}/", it.host)
};
let body = if compact {
let s = short.get(&it.session).cloned().unwrap_or_default();
format!("{}{}", s, &it.target[it.session.len()..])
} else {
it.target.clone()
};
format!("{}{}", pfx, body)
};
labelw = labelw.max(vlen(&lbl) + 2);
labels.push(lbl);
}
let panes = items.iter().filter(|i| !i.note).count();
if compact {
labelw = labelw.min(15);
} else if panes > 0 {
labelw = labelw.max(15);
}
let mut agw = 0;
let mut verw = 0;
let mut pathw = 0;
for it in &items {
agw = agw.max(vlen(&it.agent));
verw = verw.max(vlen(&it.version));
pathw = pathw.max(vlen(&it.path));
}
pathw = pathw.min(30);
let tailw = pathw + 1 + agw + if verw > 0 { 1 + verw } else { 0 };
let mut out = Vec::new();
for (i, it) in items.iter().enumerate() {
let is_cur = it.id == input.cur;
let mark = if is_cur { "● " } else { " " };
let plabel = pad(&format!("{}{}", mark, labels[i]), labelw);
let mut sum = summary_of(&it.title).to_string();
if sum.is_empty() {
if let Some(t) = input.ptitles.get(&it.id) {
sum = t.clone();
}
}
sum = fit(&sum, summary_room(input.width, vlen(&plabel), tailw));
let mut cells = vec![
cell(plabel.clone(), if is_cur { LABEL_CUR } else { LABEL_OTHER }),
cell(" ", PLAIN),
];
if input.restarting.contains(&it.id) {
cells.push(cell("↻", MARK_RESTART));
cells.push(cell(" ", PLAIN));
} else {
match it.state.as_str() {
"input" => {
cells.push(cell("✳", MARK_INPUT));
cells.push(cell(" ", PLAIN));
}
"run" => {
cells.push(cell("◐", MARK_RUN));
cells.push(cell(" ", PLAIN));
}
_ => cells.push(cell(" ", PLAIN)),
}
}
cells.push(cell(sum.clone(), PLAIN));
let snip = input.snips.get(&it.id).filter(|_| {
!says_it(
&format!("{} {} {} {} {}", plabel, sum, it.path, it.agent, it.version),
&terms,
fold,
)
});
if let Some(s) = snip {
let stail = format!("⌕ {}", s);
let gap = gap_of(input.width, &plabel, &sum, vlen(&stail));
cells.push(cell(spaces(gap), PLAIN));
cells.push(cell(stail, PATH));
} else {
let gap = gap_of(input.width, &plabel, &sum, tailw);
cells.push(cell(spaces(gap), PLAIN));
cells.push(cell(pad(&it.path, pathw), PATH));
cells.push(cell(" ", PLAIN));
if it.agent.is_empty() {
cells.push(cell(spaces(agw), PLAIN));
} else {
cells.push(cell(spaces(agw - vlen(&it.agent)), PLAIN));
cells.push(cell(it.agent.clone(), mode_paint(&it.mode)));
}
if verw > 0 {
if it.version.is_empty() {
cells.push(cell(format!(" {}", spaces(verw)), PLAIN));
} else {
cells.push(cell(
format!(" {}", spaces(verw - vlen(&it.version))),
PLAIN,
));
cells.push(cell(
it.version.clone(),
version_paint(&it.host, &it.agent, &it.version, &it.state, input.newver),
));
}
}
}
trim_trailing(&mut cells);
cells.retain(|c| !c.text.is_empty());
out.push(Row {
cells,
pane_id: it.id.clone(),
target: it.target.clone(),
cwd: it.cwd.clone(),
host: it.host.clone(),
});
}
out
}
fn gap_of(width: usize, plabel: &str, sum: &str, tailw: usize) -> usize {
let used = vlen(plabel) + 1 + 2 + vlen(sum) + tailw;
width.saturating_sub(used).max(2)
}
fn summary_room(width: usize, labelw: usize, tailw: usize) -> usize {
if width == 0 {
return 0;
}
let chrome = labelw + 1 + 2 + 2 + tailw; let room = width.saturating_sub(chrome);
if room < MIN_SUMMARY {
0
} else {
room
}
}
const MIN_SUMMARY: usize = 20;
fn fit(s: &str, room: usize) -> String {
if room == 0 || vlen(s) <= room {
return s.to_string();
}
let mut out = String::new();
let mut w = 0;
for c in s.chars() {
let cw = UnicodeWidthStr::width(c.to_string().as_str());
if w + cw > room.saturating_sub(1) {
break;
}
out.push(c);
w += cw;
}
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(lines: &str, cur: &str, width: usize) -> Vec<String> {
build(
lines,
&Input {
cur,
width,
home: "/home/p",
..Default::default()
},
)
.iter()
.map(|r| r.to_ansi())
.collect()
}
const THREE: &str = "%10\twork:1.1\t/home/p/proj/web\tclaude\t2.1.229\trun\t-\t◐ Refactor auth\n\
%11\tops:2.1\t/home/p\tgemini\t0.41.2\trun\t-\t⠂ tests\n\
%12\tops:3.1\t/home/p/longdir/another-very-long-project-name-here\tcodex\t\trun\t-\t◐ X";
#[test]
fn the_pane_id_is_the_hidden_last_field() {
let r = rows(THREE, "%11", 100);
assert!(r[0].ends_with("\t%10"));
assert!(r[1].ends_with("\t%11"));
}
#[test]
fn only_the_current_pane_is_marked() {
let r = rows(THREE, "%11", 100);
assert!(!r[0].contains('●'));
assert!(r[1].contains('●'));
}
#[test]
fn the_title_marker_is_replaced_by_the_state_marker() {
let r = rows(THREE, "%11", 100);
assert!(r[0].contains("Refactor auth"));
assert!(!r[0].contains("◐ Refactor")); assert!(r[0].contains("\x1b[2m◐\x1b[0m ")); }
#[test]
fn summary_of_strips_only_a_leading_glyph_run() {
assert_eq!(summary_of("◐ Refactor auth"), "Refactor auth");
assert_eq!(summary_of("⠂ tests"), "tests");
assert_eq!(summary_of("plain title"), "plain title");
assert_eq!(summary_of("étude du code"), "étude du code");
assert_eq!(summary_of("✳"), "");
}
#[test]
fn paths_fold_home_and_keep_the_last_two_components() {
assert_eq!(path_display("/home/p/proj/web", "/home/p"), "proj/web");
assert_eq!(path_display("/home/p", "/home/p"), "~");
assert_eq!(path_display("/var/log", "/home/p"), "var/log");
}
#[test]
fn a_long_path_is_elided_from_the_left_to_thirty() {
let d = path_display(
"/home/p/longdir/another-very-long-project-name-here",
"/home/p",
);
assert_eq!(d.chars().count(), 30);
assert!(d.starts_with('…'));
assert!(d.ends_with("name-here"));
}
fn col_of(line: &str, needle: &str) -> usize {
let s = strip(line);
let b = s.find(needle).expect("needle on the row");
vlen(&s[..b])
}
#[test]
fn the_trailing_columns_line_up_down_the_list() {
let r = rows(THREE, "%11", 100);
let a = col_of(&r[0], "proj/web");
assert_eq!(col_of(&r[1], "~"), a);
assert_eq!(col_of(&r[2], "…"), a);
}
fn strip(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars();
while let Some(c) = it.next() {
if c == '\x1b' {
for c in it.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn a_missing_version_keeps_its_column_but_leaves_no_trailing_space() {
let r = rows(THREE, "%11", 100);
assert!(!strip(&r[2]).split('\t').next().unwrap().ends_with(' '));
assert!(strip(&r[2]).contains("codex"));
}
#[test]
fn abbreviates_to_the_shortest_prefix_that_still_tells_names_apart() {
let n: Vec<String> = ["main", "master", "ops"]
.iter()
.map(|s| s.to_string())
.collect();
let a = abbrev(&n, 1);
assert_eq!(a["ops"], "o");
assert_eq!(a["main"], "mai");
assert_eq!(a["master"], "mas");
}
#[test]
fn a_name_that_contains_another_is_kept_whole() {
let n: Vec<String> = ["main", "main2"].iter().map(|s| s.to_string()).collect();
let a = abbrev(&n, 1);
assert_eq!(a["main"], "main");
assert_eq!(a["main2"], "main2");
}
#[test]
fn the_host_floor_is_two_letters() {
let n: Vec<String> = ["laptop-two", "ha"].iter().map(|s| s.to_string()).collect();
let a = abbrev(&n, 2);
assert_eq!(a["laptop-two"], "la");
assert_eq!(a["ha"], "ha");
}
#[test]
fn the_label_floor_applies_to_pane_rows_and_the_cap_to_narrow_windows() {
let short = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
let wide = rows(short, "", 200);
let label_end = strip(&wide[0]).find("hi").unwrap();
assert_eq!(label_end, 15 + 1 + 2);
let narrow = rows(short, "", 60);
assert!(strip(&narrow[0]).find("hi").unwrap() < 15);
}
#[test]
fn a_row_with_no_room_still_leaves_two_columns_of_gap() {
let r = rows(THREE, "%11", 0);
for line in &r {
assert!(strip(line).contains(" "));
}
}
#[test]
fn trims_only_a_trailing_run_of_unpainted_spaces() {
let mut c = vec![cell("a", PLAIN), cell("b ", PLAIN)];
trim_trailing(&mut c);
assert_eq!(c, vec![cell("a", PLAIN), cell("b", PLAIN)]);
let mut c = vec![cell("a", PLAIN), cell(" ", PLAIN), cell(" ", PLAIN)];
trim_trailing(&mut c);
assert_eq!(c, vec![cell("a", PLAIN)]);
let mut c = vec![cell("x ", PATH), cell("", PLAIN)];
trim_trailing(&mut c);
assert_eq!(c[0].text, "x ");
}
#[test]
fn the_permission_mode_rides_on_the_agent_name() {
let base = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t";
let ask = rows(&format!("{}default\thi", base), "", 100);
let edit = rows(&format!("{}acceptEdits\thi", base), "", 100);
let auto = rows(&format!("{}bypassPermissions\thi", base), "", 100);
assert!(ask[0].contains("\x1b[35mclaude"));
assert!(edit[0].contains("\x1b[95mclaude"));
assert!(auto[0].contains("\x1b[1;95mclaude"));
}
#[test]
fn a_stale_version_goes_yellow_only_where_ctrl_x_could_act() {
let line = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
let stale = build(
line,
&Input {
newver: "2.0",
width: 100,
..Default::default()
},
);
assert!(stale[0].to_ansi().contains("\x1b[33m1.0"));
let current = build(
line,
&Input {
newver: "1.0",
width: 100,
..Default::default()
},
);
assert!(current[0].to_ansi().contains("\x1b[2;35m1.0"));
let remote = build(
"ha:%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi",
&Input {
newver: "2.0",
width: 100,
..Default::default()
},
);
assert!(remote[0].to_ansi().contains("\x1b[2;35m1.0"));
let dead = build(
"%1\tw:1.1\t/home/p\tclaude\t1.0\tdead\t-\thi",
&Input {
newver: "2.0",
width: 100,
..Default::default()
},
);
assert!(dead[0].to_ansi().contains("\x1b[2;35m1.0"));
}
#[test]
fn a_host_that_could_not_answer_keeps_its_name_whole() {
let r = build(
"laptop-two:unreachable\tlaptop-two: no answer\t\t\t\tnote\t\t",
&Input {
width: 60,
only: "note",
..Default::default()
},
);
assert!(r[0].to_ansi().contains("laptop-two: no answer"));
}
#[test]
fn the_outdated_list_holds_exactly_the_rows_painted_yellow() {
let lines = "%1\ta:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tbehind\n\
%2\tb:1.1\t/home/p\tclaude\t2.1.243\trun\t-\tcurrent\n\
%3\tc:1.1\t/home/p\tgemini\t0.41.2\tinput\t-\tanother agent\n\
ha:%4\td:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tover there";
let r = build(
lines,
&Input {
newver: "2.1.243",
width: 100,
outdated: true,
..Default::default()
},
);
let ids: Vec<&str> = r.iter().map(|r| r.pane_id.as_str()).collect();
assert_eq!(ids, ["%1"]);
assert!(r[0].to_ansi().contains("\x1b[33m2.1.229"));
let none = build(
lines,
&Input {
width: 100,
outdated: true,
..Default::default()
},
);
assert!(none.is_empty());
}
#[test]
fn the_outdated_list_is_not_one_state() {
let lines = "%1\ta:1.1\t/home/p\tclaude\t1.0\tinput\t-\tasking\n\
%2\tb:1.1\t/home/p\tclaude\t1.0\trun\t-\tworking\n\
%3\tc:1.1\t/home/p\tclaude\t1.0\tidle\t-\tidle";
let r = build(
lines,
&Input {
newver: "2.0",
width: 100,
outdated: true,
..Default::default()
},
);
assert_eq!(r.len(), 3);
}
#[test]
fn one_state_only_when_asked() {
let r = build(
THREE,
&Input {
only: "run",
width: 100,
..Default::default()
},
);
assert_eq!(r.len(), 3);
let r = build(
THREE,
&Input {
only: "input",
width: 100,
..Default::default()
},
);
assert!(r.is_empty());
}
#[test]
fn a_blank_pane_title_borrows_the_one_its_conversation_recorded() {
let mut ptitles = HashMap::new();
ptitles.insert("%1".to_string(), "what it called itself".to_string());
let r = build(
"%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\t",
&Input {
width: 100,
ptitles,
..Default::default()
},
);
assert!(r[0].plain().contains("what it called itself"));
}
#[test]
fn a_search_snippet_replaces_the_tail_unless_the_row_already_says_it() {
let mut snips = HashMap::new();
snips.insert("%10".to_string(), "…the words it said…".to_string());
snips.insert("%11".to_string(), "…other words…".to_string());
let r = build(
THREE,
&Input {
cur: "%11",
width: 100,
home: "/home/p",
query: "refactor",
snips,
..Default::default()
},
);
assert!(r[0].plain().contains("proj/web"));
assert!(!r[0].plain().contains('⌕'));
assert!(r[1].plain().contains("⌕ …other words…"));
}
#[test]
fn says_it_is_smart_case_like_fzf() {
let lower = vec!["refactor".to_string()];
assert!(says_it("Refactor auth", &lower, true));
let upper = vec!["Refactor".to_string()];
assert!(!says_it("refactor auth", &upper, false));
}
}