taimux_cli/rows.rs
1//! The picker's rows, laid out here rather than in awk.
2//!
3//! Step 2 of removing fzf. The awk this replaces exists to hand fzf one TSV line
4//! per row with the colours already burned in as escape sequences; that format
5//! dies with fzf, so the layout is ported into structured cells and the ANSI is
6//! reduced to one renderer used for testing.
7//!
8//! **The layout itself is not being redesigned.** Every rule here was arrived at
9//! by looking at real lists and most of them fix a specific bent row, so they are
10//! ported as they stand and the reasoning is kept with them:
11//!
12//! - The summary follows the label directly, because it is what the list is read
13//! for. Everything that only says WHERE a session is (path, agent, version) is
14//! pinned to the right edge, in fixed columns, so it costs the summary no width
15//! and is what a too-long row truncates away.
16//! - Every column width is measured over the WHOLE list, never per row. Sizing
17//! the trailing block per row moved the path column by however long that row's
18//! agent happened to be, up to 9 columns apart on a mixed list.
19//! - The label column has a floor of 15 in a roomy window and a cap of 15 in a
20//! narrow one, and the floor only applies when the list holds real pane labels.
21//!
22//! The one deliberate improvement is `vlen`: the awk counts characters, this
23//! counts display columns, which is the same answer for everything on an ordinary
24//! list and the right one for a double-width character.
25
26use std::collections::{HashMap, HashSet};
27
28use ratatui::style::{Color, Modifier, Style};
29use unicode_width::UnicodeWidthStr;
30
31/// The paint a cell carries, stored as the SGR prefix the awk emits so the ANSI
32/// renderer is exact, with the ratatui mapping beside it so the TUI never parses
33/// its own escape sequences back.
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub struct Paint(pub &'static str);
36
37pub const PLAIN: Paint = Paint("");
38/// The label of the pane the picker was opened from.
39pub const LABEL_CUR: Paint = Paint("\x1b[33m");
40pub const LABEL_OTHER: Paint = Paint("\x1b[36m");
41/// A session asking for you: the one glyph here worth a colour of its own.
42pub const MARK_INPUT: Paint = Paint("\x1b[1;33m");
43/// Working, dimmed, because it wants nothing from you.
44pub const MARK_RUN: Paint = Paint("\x1b[2m");
45/// A restart in flight. Cyan rather than the waiting star's bold yellow: it
46/// wants nothing from you, it is just not finished, and it should not compete
47/// with the one glyph that means "this row is asking".
48pub const MARK_RESTART: Paint = Paint("\x1b[36m");
49pub const PATH: Paint = Paint("\x1b[90m");
50/// The permission mode rides on the agent name as brightness rather than taking
51/// a column: louder is less supervised.
52pub const MODE_ASK: Paint = Paint("\x1b[35m");
53pub const MODE_EDIT: Paint = Paint("\x1b[95m");
54pub const MODE_AUTO: Paint = Paint("\x1b[1;95m");
55/// A session running code a self-update has already replaced. Plain yellow, not
56/// the bold yellow of the star, since nothing is being asked of you.
57pub const VER_STALE: Paint = Paint("\x1b[33m");
58pub const VER_OK: Paint = Paint("\x1b[2;35m");
59
60impl Paint {
61 pub fn style(self) -> Style {
62 match self.0 {
63 "\x1b[33m" => Style::default().fg(Color::Yellow),
64 "\x1b[36m" => Style::default().fg(Color::Cyan),
65 "\x1b[1;33m" => Style::default()
66 .fg(Color::Yellow)
67 .add_modifier(Modifier::BOLD),
68 "\x1b[2m" => Style::default().add_modifier(Modifier::DIM),
69 "\x1b[90m" => Style::default().fg(Color::DarkGray),
70 "\x1b[35m" => Style::default().fg(Color::Magenta),
71 "\x1b[95m" => Style::default().fg(Color::LightMagenta),
72 "\x1b[1;95m" => Style::default()
73 .fg(Color::LightMagenta)
74 .add_modifier(Modifier::BOLD),
75 "\x1b[2;35m" => Style::default()
76 .fg(Color::Magenta)
77 .add_modifier(Modifier::DIM),
78 _ => Style::default(),
79 }
80 }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct Cell {
85 pub text: String,
86 pub paint: Paint,
87}
88
89fn cell(text: impl Into<String>, paint: Paint) -> Cell {
90 Cell {
91 text: text.into(),
92 paint,
93 }
94}
95
96#[derive(Clone, Debug)]
97pub struct Row {
98 pub cells: Vec<Cell>,
99 pub pane_id: String,
100 /// Kept unabbreviated and unshortened for the preview header, which has the
101 /// room the row does not and wants to say exactly where the session is.
102 pub target: String,
103 pub cwd: String,
104 /// Set for a session on another machine. capture-pane only works where the
105 /// pane is, so the preview has to ask over there rather than locally.
106 pub host: String,
107}
108
109impl Row {
110 /// The exact line the awk prints, for diffing one implementation against the
111 /// other. Nothing in the TUI calls this: it renders the cells directly.
112 pub fn to_ansi(&self) -> String {
113 let mut s = String::new();
114 for c in &self.cells {
115 if c.paint == PLAIN {
116 s.push_str(&c.text);
117 } else {
118 s.push_str(c.paint.0);
119 s.push_str(&c.text);
120 s.push_str("\x1b[0m");
121 }
122 }
123 s.push('\t');
124 s.push_str(&self.pane_id);
125 s
126 }
127
128 /// The row with every escape sequence gone, which is what a width assertion
129 /// and a fuzzy match both want. fzf has to be handed `--ansi` and parse our
130 /// colours back out to get at this; the TUI has it for free.
131 #[allow(dead_code)] // the in-memory filter is step 3
132 pub fn plain(&self) -> String {
133 self.cells.iter().map(|c| c.text.as_str()).collect()
134 }
135}
136
137/// Display width. The awk counts characters (gawk in a UTF-8 locale) and hand
138/// strips UTF-8 continuation bytes where it cannot; this counts columns, which
139/// agrees for everything an ordinary list holds and is right where the awk was
140/// quietly wrong.
141fn vlen(s: &str) -> usize {
142 UnicodeWidthStr::width(s)
143}
144
145fn spaces(n: usize) -> String {
146 " ".repeat(n)
147}
148
149/// Pad on the right to a column width, never truncating: a column measured over
150/// the whole list is already wide enough, and a row that overruns it is a bug
151/// worth seeing rather than hiding.
152fn pad(s: &str, w: usize) -> String {
153 let mut out = s.to_string();
154 out.push_str(&spaces(w.saturating_sub(vlen(s))));
155 out
156}
157
158/// Whatever marker the title leads with, taken off so the column can be filled
159/// from the STATE instead. The title shows the same star for all three states
160/// (and sometimes a spinner frame that means nothing in particular), so the glyph
161/// it carries is dropped rather than shown.
162///
163/// A marker is "a leading run of non-printable-ASCII, then a space or the end",
164/// not a list of the known frames: this only has to find where the summary
165/// starts, so a glyph nobody has seen yet is still stripped cleanly. A title that
166/// merely opens on a non-ASCII WORD is left whole, since the space has to follow
167/// the run directly.
168pub fn summary_of(title: &str) -> &str {
169 let run: usize = title
170 .chars()
171 .take_while(|c| !(' '..='~').contains(c))
172 .map(|c| c.len_utf8())
173 .sum();
174 if run == 0 {
175 return title;
176 }
177 let rest = &title[run..];
178 match rest.strip_prefix(' ') {
179 Some(r) => r,
180 None if rest.is_empty() => rest,
181 None => title,
182 }
183}
184
185/// The permission mode, in brightness. Ordinary magenta for a session that will
186/// stop and ask (default, plan, or nothing known), bright for one applying edits
187/// on its own, bold bright for one that asks nothing at all.
188fn mode_paint(mode: &str) -> Paint {
189 match mode {
190 "acceptEdits" => MODE_EDIT,
191 "bypassPermissions" | "auto" => MODE_AUTO,
192 _ => MODE_ASK,
193 }
194}
195
196/// Is this row running code a self-update has already replaced, i.e. one ctrl-x
197/// applies to?
198///
199/// Never on another host: `newver` is what THIS box would start, and a session on
200/// one host being behind another is not a fact about anything. Never on an ENDED
201/// session either, however far behind it last ran: there is no process to put
202/// back.
203///
204/// One predicate, two uses: the yellow version on a row, and the outdated list
205/// Tab stops on. They have to agree, or the list would hold rows whose colour
206/// says nothing is wrong with them, or leave out ones it paints yellow.
207fn outdated(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> bool {
208 state != "dead"
209 && host.is_empty()
210 && agent == "claude"
211 && !newver.is_empty()
212 && !v.is_empty()
213 && v != newver
214}
215
216/// Yellow means "ctrl-x applies to this row".
217fn version_paint(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> Paint {
218 if outdated(host, agent, v, state, newver) {
219 VER_STALE
220 } else {
221 VER_OK
222 }
223}
224
225/// Each name cut to the fewest letters that still tell it apart from every other
226/// name in the list: one where nothing else starts with it, more only against the
227/// names it actually collides with. A name that is a whole other name plus
228/// something (main, main2) can only be told apart in full.
229///
230/// `minlen` is a floor. Sessions take 1, since you picked those names and they
231/// are on screen constantly. Hosts take 2, because a host is the part of a row
232/// you are least likely to have in your head, and "l" for laptop-two saves nine
233/// columns by giving up the whole point of the column.
234fn abbrev(names: &[String], minlen: usize) -> HashMap<String, String> {
235 let mut out = HashMap::new();
236 for s in names {
237 let chars: Vec<char> = s.chars().collect();
238 let mut n = chars.len() + 1; // nothing distinguished it: keep it whole
239 for k in 1..=chars.len() {
240 let head: String = chars.iter().take(k).collect();
241 let clash = names
242 .iter()
243 .any(|t| t != s && t.chars().take(k).collect::<String>() == head);
244 if !clash {
245 n = k;
246 break;
247 }
248 }
249 let n = n.max(minlen);
250 out.insert(s.clone(), chars.iter().take(n).collect());
251 }
252 out
253}
254
255/// The last two components of the working directory, with $HOME folded to `~`
256/// and a cap of 30 characters, elided from the left because the tail is the part
257/// that says which project it is.
258fn path_display(cwd: &str, home: &str) -> String {
259 let cwd = if !home.is_empty() && cwd.starts_with(home) {
260 format!("~{}", &cwd[home.len()..])
261 } else {
262 cwd.to_string()
263 };
264 let parts: Vec<&str> = cwd.split('/').collect();
265 let disp = if parts.len() >= 2 {
266 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
267 } else {
268 cwd.clone()
269 };
270 let n = disp.chars().count();
271 if n > 30 {
272 // the awk takes from length-28 to the end, i.e. the last 29 characters
273 let tail: String = disp.chars().skip(n - 29).collect();
274 format!("…{}", tail)
275 } else {
276 disp
277 }
278}
279
280/// Does the row already show everything that was typed? If it does it needs no
281/// explaining, and keeping its path, agent and version is worth more than quoting
282/// the query back at it.
283fn says_it(s: &str, terms: &[String], fold: bool) -> bool {
284 if terms.is_empty() {
285 return false;
286 }
287 let hay = if fold {
288 s.to_lowercase()
289 } else {
290 s.to_string()
291 };
292 terms
293 .iter()
294 .all(|t| t.is_empty() || hay.contains(t.as_str()))
295}
296
297/// Trailing spaces align nothing, so the all-blank remainder a row with no agent
298/// or version leaves is trimmed off. Done over the cells rather than the rendered
299/// string, since a painted cell ends in a reset and would block the trim.
300fn trim_trailing(cells: &mut Vec<Cell>) {
301 while let Some(last) = cells.last_mut() {
302 if last.paint != PLAIN {
303 break;
304 }
305 let trimmed = last.text.trim_end_matches(' ');
306 if trimmed.len() == last.text.len() {
307 break; // ended in something other than a space: nothing to trim
308 }
309 last.text.truncate(trimmed.len());
310 if !last.text.is_empty() {
311 break; // the run of spaces ended inside this cell
312 }
313 cells.pop();
314 }
315}
316
317/// One input line, before anything is measured.
318struct Item {
319 id: String,
320 target: String,
321 agent: String,
322 version: String,
323 state: String,
324 mode: String,
325 title: String,
326 host: String,
327 /// The one row a host that could not answer keeps in the list. Left out of
328 /// the shortening and printed whole: the host name IS the message.
329 note: bool,
330 /// The last two components, capped, as the row shows it.
331 path: String,
332 /// The whole thing, as the preview header shows it.
333 cwd: String,
334 session: String,
335}
336
337#[derive(Default)]
338pub struct Input<'a> {
339 pub cur: &'a str,
340 /// 0 means "unknown", which reads as "do not right-align".
341 pub width: usize,
342 pub home: &'a str,
343 /// What a session started right now would run, so a pane left behind by a
344 /// self-update can be told apart from a current one.
345 pub newver: &'a str,
346 /// One state only, or empty for all.
347 pub only: &'a str,
348 /// Keep only the rows a restart would act on, i.e. the ones the version
349 /// column paints yellow.
350 ///
351 /// Separate from `only` because being behind is not a STATE: it is the row's
352 /// version against the one installed here, and a session waiting, working or
353 /// idle can each be behind. Which is also why this list crosses the four
354 /// state modes rather than sitting inside one of them.
355 pub outdated: bool,
356 pub query: &'a str,
357 /// pane id to the snippet of what that session said, when searching.
358 ///
359 /// The layout applies whatever it is given. **The "at least
360 /// TAIMUX_SEARCH_MIN characters" gate lives in the CALLER**, exactly as it
361 /// does in bash: under three characters a term is in every transcript and a
362 /// match would say nothing, so no snippets are looked up at all. Handing this
363 /// a snippet map for a one-letter query would quietly turn every row into a
364 /// search hit.
365 pub snips: HashMap<String, String>,
366 /// pane id to the title its conversation last recorded, for a pane that
367 /// publishes none of its own.
368 pub ptitles: HashMap<String, String>,
369 /// Panes with a restart in flight.
370 ///
371 /// This paints the marker column and nothing else. In particular it does NOT
372 /// touch the state field, which is what keeps such a row where it was: the
373 /// state drives both the Tab filter and the row's place in the list, so a
374 /// synthetic "restarting" state would drop the row out of whichever mode it
375 /// was being watched in, at the exact moment its owner is watching it.
376 pub restarting: HashSet<String>,
377}
378
379pub fn build(lines: &str, input: &Input) -> Vec<Row> {
380 let terms: Vec<String> = input
381 .query
382 .split([' ', '\t'])
383 .filter(|t| !t.is_empty())
384 .map(|t| t.to_string())
385 .collect();
386 // smart case, as fzf does it
387 let fold = input.query == input.query.to_lowercase();
388
389 let mut items: Vec<Item> = Vec::new();
390 for line in lines.lines() {
391 let f: Vec<&str> = line.split('\t').collect();
392 if f.len() < 7 {
393 continue;
394 }
395 let state = f[5];
396 if !input.only.is_empty() && state != input.only {
397 continue;
398 }
399 let (id, target, cwd) = (f[0], f[1], f[2]);
400
401 // A pane id that does not open on "%" names another host, and the label
402 // leads with it: "ha/main:1.7". What follows the colon says which kind of
403 // row it is, a pane id for a session over there, anything else for a
404 // host that could not answer.
405 let (mut host, mut note) = (String::new(), false);
406 if !id.starts_with('%') {
407 if let Some(c) = id.find(':') {
408 if c > 0 {
409 if id[c + 1..].starts_with('%') {
410 host = id[..c].to_string();
411 } else {
412 note = true;
413 }
414 }
415 }
416 }
417 // Applied here rather than beside `only` because it needs the host, and
418 // the host is what the id above has just been read for.
419 if input.outdated && !outdated(&host, f[3], f[4], state, input.newver) {
420 continue;
421 }
422 let session = match target.find(':') {
423 Some(c) if c > 0 => target[..c].to_string(),
424 _ => target.to_string(),
425 };
426 items.push(Item {
427 id: id.to_string(),
428 target: target.to_string(),
429 agent: f[3].to_string(),
430 version: f[4].to_string(),
431 state: state.to_string(),
432 mode: f[6].to_string(),
433 title: f.get(7).copied().unwrap_or("").to_string(),
434 host,
435 note,
436 path: path_display(cwd, input.home),
437 cwd: cwd.to_string(),
438 session,
439 });
440 }
441
442 // Narrow window: the session name is the first thing asked to give columns
443 // back. Of everything on the row it is the most recognisable from a few
444 // letters, and window.pane stays whole since two digits are no use truncated.
445 // The threshold is the one the tmux binding already uses to switch the popup
446 // to full width.
447 let compact = input.width > 0 && input.width < 100;
448 let mut names: Vec<String> = Vec::new();
449 let mut hnames: Vec<String> = Vec::new();
450 for it in &items {
451 if !it.note && !names.contains(&it.session) {
452 names.push(it.session.clone());
453 }
454 if !it.host.is_empty() && !hnames.contains(&it.host) {
455 hnames.push(it.host.clone());
456 }
457 }
458 let (short, shorth) = if compact {
459 (abbrev(&names, 1), abbrev(&hnames, 2))
460 } else {
461 (HashMap::new(), HashMap::new())
462 };
463
464 // The label column is as wide as the widest label actually in THIS list,
465 // never a guess. A flat 15 broke the moment a target needed more:
466 // "platform:14.11" is 14 plus the 2-column marker, so that one row started
467 // its summary a column right of every other and the whole list looked bent.
468 let mut labels: Vec<String> = Vec::new();
469 let mut labelw = 0;
470 for it in &items {
471 let lbl = if it.note {
472 it.target.clone()
473 } else {
474 let pfx = if it.host.is_empty() {
475 String::new()
476 } else if compact {
477 format!("{}/", shorth.get(&it.host).unwrap_or(&it.host))
478 } else {
479 format!("{}/", it.host)
480 };
481 let body = if compact {
482 let s = short.get(&it.session).cloned().unwrap_or_default();
483 format!("{}{}", s, &it.target[it.session.len()..])
484 } else {
485 it.target.clone()
486 };
487 format!("{}{}", pfx, body)
488 };
489 labelw = labelw.max(vlen(&lbl) + 2);
490 labels.push(lbl);
491 }
492 // A narrow window CAPS it: there the label is the column asked to give width
493 // back to the summary, and a shortened name that still overruns is worth a
494 // bent row. A roomy window gets a FLOOR instead, so the column stops
495 // jittering as sessions with longer names come and go. The old code applied
496 // 15 as a ceiling in BOTH, which is what bent the row.
497 //
498 // The floor only holds up a column of PANE labels, which is what it is for. A
499 // list of ended sessions labels no pane (the column holds an age, three
500 // characters of it), so the floor there would spend twelve columns of summary
501 // on nothing at all.
502 let panes = items.iter().filter(|i| !i.note).count();
503 if compact {
504 labelw = labelw.min(15);
505 } else if panes > 0 {
506 labelw = labelw.max(15);
507 }
508
509 // The trailing columns are a TABLE, so their widths come from the whole list
510 // too. Right-aligning "<path> <agent> <version>" as ONE string moves the path
511 // column by however long the agent and version on THAT row happen to be, and
512 // no two agents are the same length.
513 let mut agw = 0;
514 let mut verw = 0;
515 let mut pathw = 0;
516 for it in &items {
517 agw = agw.max(vlen(&it.agent));
518 verw = verw.max(vlen(&it.version));
519 pathw = pathw.max(vlen(&it.path));
520 }
521 pathw = pathw.min(30);
522 let tailw = pathw + 1 + agw + if verw > 0 { 1 + verw } else { 0 };
523
524 let mut out = Vec::new();
525 for (i, it) in items.iter().enumerate() {
526 let is_cur = it.id == input.cur;
527 let mark = if is_cur { "● " } else { " " };
528 let plabel = pad(&format!("{}{}", mark, labels[i]), labelw);
529
530 let mut sum = summary_of(&it.title).to_string();
531 // Nothing on the pane: fall back to what its conversation calls itself.
532 // claude sets a title at a turn boundary, so a session restored by
533 // tmux-resurrect and not prompted since has nothing there.
534 if sum.is_empty() {
535 if let Some(t) = input.ptitles.get(&it.id) {
536 sum = t.clone();
537 }
538 }
539 // A summary that will not fit gives way, rather than pushing the table
540 // off the right edge. Nothing needed this while every summary came from
541 // a pane title, which is a handful of words; a PAST session's summary is
542 // whatever it was asked to do, up to eighty characters of it, and those
543 // rows arrived shoving the directory, the agent and the version out of
544 // the window. The columns beside it are the ones you read down the list,
545 // so the summary is the one that can afford to end in an ellipsis.
546 sum = fit(&sum, summary_room(input.width, vlen(&plabel), tailw));
547
548 let mut cells = vec![
549 cell(plabel.clone(), if is_cur { LABEL_CUR } else { LABEL_OTHER }),
550 cell(" ", PLAIN),
551 ];
552 // A restart in flight outranks the state, because during one the state is
553 // whatever the screen happened to show as the old session went away, and
554 // that is the least useful thing the column could say. The glyph goes
555 // here rather than into the summary because the summary strips a leading
556 // marker (see summary_of), so one put there would be silently eaten.
557 if input.restarting.contains(&it.id) {
558 cells.push(cell("↻", MARK_RESTART));
559 cells.push(cell(" ", PLAIN));
560 } else {
561 match it.state.as_str() {
562 "input" => {
563 cells.push(cell("✳", MARK_INPUT));
564 cells.push(cell(" ", PLAIN));
565 }
566 "run" => {
567 cells.push(cell("◐", MARK_RUN));
568 cells.push(cell(" ", PLAIN));
569 }
570 _ => cells.push(cell(" ", PLAIN)),
571 }
572 }
573 cells.push(cell(sum.clone(), PLAIN));
574
575 // A row that is here because of what its session SAID takes the snippet
576 // where its path would be. Two things at once: the row stops being a
577 // mystery, and the words typed are now ON it, which is what lets the
578 // matcher keep working in the ordinary way rather than being handed a
579 // blob it would match everything against.
580 let snip = input.snips.get(&it.id).filter(|_| {
581 !says_it(
582 &format!("{} {} {} {} {}", plabel, sum, it.path, it.agent, it.version),
583 &terms,
584 fold,
585 )
586 });
587 if let Some(s) = snip {
588 let stail = format!("⌕ {}", s);
589 let gap = gap_of(input.width, &plabel, &sum, vlen(&stail));
590 cells.push(cell(spaces(gap), PLAIN));
591 cells.push(cell(stail, PATH));
592 } else {
593 let gap = gap_of(input.width, &plabel, &sum, tailw);
594 cells.push(cell(spaces(gap), PLAIN));
595 cells.push(cell(pad(&it.path, pathw), PATH));
596 cells.push(cell(" ", PLAIN));
597 // Agent and version are right-aligned inside their columns, so the
598 // row stays flush with the right edge and the version numbers read
599 // down the list. A row missing either keeps the column: what it must
600 // not do is pull the ones beside it out of line.
601 if it.agent.is_empty() {
602 cells.push(cell(spaces(agw), PLAIN));
603 } else {
604 cells.push(cell(spaces(agw - vlen(&it.agent)), PLAIN));
605 cells.push(cell(it.agent.clone(), mode_paint(&it.mode)));
606 }
607 if verw > 0 {
608 if it.version.is_empty() {
609 cells.push(cell(format!(" {}", spaces(verw)), PLAIN));
610 } else {
611 cells.push(cell(
612 format!(" {}", spaces(verw - vlen(&it.version))),
613 PLAIN,
614 ));
615 cells.push(cell(
616 it.version.clone(),
617 version_paint(&it.host, &it.agent, &it.version, &it.state, input.newver),
618 ));
619 }
620 }
621 }
622 trim_trailing(&mut cells);
623 cells.retain(|c| !c.text.is_empty());
624 out.push(Row {
625 cells,
626 pane_id: it.id.clone(),
627 target: it.target.clone(),
628 cwd: it.cwd.clone(),
629 host: it.host.clone(),
630 });
631 }
632 out
633}
634
635/// What is left between the summary and the right-hand block. Two columns
636/// minimum: a window with no room just trails the tail behind the summary
637/// instead of overlapping it.
638fn gap_of(width: usize, plabel: &str, sum: &str, tailw: usize) -> usize {
639 let used = vlen(plabel) + 1 + 2 + vlen(sum) + tailw;
640 width.saturating_sub(used).max(2)
641}
642
643/// How much width a summary may have before it starts costing the table.
644///
645/// `0` means "as much as it likes", which is what an unmeasured window (width 0,
646/// the machine-readable path) and one too narrow to hold a table both get: in
647/// the first nothing is being drawn, and in the second there is no arrangement
648/// that fits, so a long summary is more use than a stump.
649fn summary_room(width: usize, labelw: usize, tailw: usize) -> usize {
650 if width == 0 {
651 return 0;
652 }
653 let chrome = labelw + 1 + 2 + 2 + tailw; // label, space, marker, gap, table
654 let room = width.saturating_sub(chrome);
655 if room < MIN_SUMMARY {
656 0
657 } else {
658 room
659 }
660}
661
662/// Below this a summary says nothing, so the table gives way instead.
663const MIN_SUMMARY: usize = 20;
664
665/// Cut to a display width, with an ellipsis where it was cut.
666///
667/// `0` is no limit. Character-wise and width-aware, so a double-width character
668/// counts for two and none is ever cut in half.
669fn fit(s: &str, room: usize) -> String {
670 if room == 0 || vlen(s) <= room {
671 return s.to_string();
672 }
673 let mut out = String::new();
674 let mut w = 0;
675 for c in s.chars() {
676 let cw = UnicodeWidthStr::width(c.to_string().as_str());
677 if w + cw > room.saturating_sub(1) {
678 break;
679 }
680 out.push(c);
681 w += cw;
682 }
683 out.push('…');
684 out
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 fn rows(lines: &str, cur: &str, width: usize) -> Vec<String> {
692 build(
693 lines,
694 &Input {
695 cur,
696 width,
697 home: "/home/p",
698 ..Default::default()
699 },
700 )
701 .iter()
702 .map(|r| r.to_ansi())
703 .collect()
704 }
705
706 const THREE: &str = "%10\twork:1.1\t/home/p/proj/web\tclaude\t2.1.229\trun\t-\t◐ Refactor auth\n\
707 %11\tops:2.1\t/home/p\tgemini\t0.41.2\trun\t-\t⠂ tests\n\
708 %12\tops:3.1\t/home/p/longdir/another-very-long-project-name-here\tcodex\t\trun\t-\t◐ X";
709
710 #[test]
711 fn the_pane_id_is_the_hidden_last_field() {
712 let r = rows(THREE, "%11", 100);
713 assert!(r[0].ends_with("\t%10"));
714 assert!(r[1].ends_with("\t%11"));
715 }
716
717 #[test]
718 fn only_the_current_pane_is_marked() {
719 let r = rows(THREE, "%11", 100);
720 assert!(!r[0].contains('●'));
721 assert!(r[1].contains('●'));
722 }
723
724 /// The glyph the title leads with is dropped and the column filled from the
725 /// state, so a title's own spinner frame never leaks into the summary.
726 #[test]
727 fn the_title_marker_is_replaced_by_the_state_marker() {
728 let r = rows(THREE, "%11", 100);
729 assert!(r[0].contains("Refactor auth"));
730 assert!(!r[0].contains("◐ Refactor")); // the title's own glyph is gone
731 assert!(r[0].contains("\x1b[2m◐\x1b[0m ")); // …and the state's is there
732 }
733
734 #[test]
735 fn summary_of_strips_only_a_leading_glyph_run() {
736 assert_eq!(summary_of("◐ Refactor auth"), "Refactor auth");
737 assert_eq!(summary_of("⠂ tests"), "tests");
738 assert_eq!(summary_of("plain title"), "plain title");
739 // a title that merely OPENS on a non-ASCII word keeps it: the space has
740 // to follow the run directly
741 assert_eq!(summary_of("étude du code"), "étude du code");
742 // a bare glyph with nothing after it leaves an empty summary
743 assert_eq!(summary_of("✳"), "");
744 }
745
746 #[test]
747 fn paths_fold_home_and_keep_the_last_two_components() {
748 assert_eq!(path_display("/home/p/proj/web", "/home/p"), "proj/web");
749 assert_eq!(path_display("/home/p", "/home/p"), "~");
750 assert_eq!(path_display("/var/log", "/home/p"), "var/log");
751 }
752
753 #[test]
754 fn a_long_path_is_elided_from_the_left_to_thirty() {
755 let d = path_display(
756 "/home/p/longdir/another-very-long-project-name-here",
757 "/home/p",
758 );
759 assert_eq!(d.chars().count(), 30);
760 assert!(d.starts_with('…'));
761 assert!(d.ends_with("name-here"));
762 }
763
764 /// Every trailing column is measured over the whole list, so the path column
765 /// starts in the same place on every row. Sizing them per row is what bent
766 /// the list: no two agent names are the same length.
767 /// Columns, not bytes: `●` and `◐` are three bytes each, so a byte offset
768 /// reports two rows as misaligned that are in fact flush.
769 fn col_of(line: &str, needle: &str) -> usize {
770 let s = strip(line);
771 let b = s.find(needle).expect("needle on the row");
772 vlen(&s[..b])
773 }
774
775 #[test]
776 fn the_trailing_columns_line_up_down_the_list() {
777 let r = rows(THREE, "%11", 100);
778 // all three paths start at the same column
779 let a = col_of(&r[0], "proj/web");
780 assert_eq!(col_of(&r[1], "~"), a);
781 assert_eq!(col_of(&r[2], "…"), a);
782 }
783
784 fn strip(s: &str) -> String {
785 let mut out = String::new();
786 let mut it = s.chars();
787 while let Some(c) = it.next() {
788 if c == '\x1b' {
789 for c in it.by_ref() {
790 if c == 'm' {
791 break;
792 }
793 }
794 } else {
795 out.push(c);
796 }
797 }
798 out
799 }
800
801 /// A row with no version keeps the column rather than pulling the ones beside
802 /// it out of line, and the blank remainder that leaves is trimmed.
803 #[test]
804 fn a_missing_version_keeps_its_column_but_leaves_no_trailing_space() {
805 let r = rows(THREE, "%11", 100);
806 assert!(!strip(&r[2]).split('\t').next().unwrap().ends_with(' '));
807 assert!(strip(&r[2]).contains("codex"));
808 }
809
810 #[test]
811 fn abbreviates_to_the_shortest_prefix_that_still_tells_names_apart() {
812 let n: Vec<String> = ["main", "master", "ops"]
813 .iter()
814 .map(|s| s.to_string())
815 .collect();
816 let a = abbrev(&n, 1);
817 assert_eq!(a["ops"], "o");
818 assert_eq!(a["main"], "mai");
819 assert_eq!(a["master"], "mas");
820 }
821
822 /// A name that is a whole other name plus something can only be told apart in
823 /// full, which the awk gets by running its loop off the end.
824 #[test]
825 fn a_name_that_contains_another_is_kept_whole() {
826 let n: Vec<String> = ["main", "main2"].iter().map(|s| s.to_string()).collect();
827 let a = abbrev(&n, 1);
828 assert_eq!(a["main"], "main");
829 assert_eq!(a["main2"], "main2");
830 }
831
832 #[test]
833 fn the_host_floor_is_two_letters() {
834 let n: Vec<String> = ["laptop-two", "ha"].iter().map(|s| s.to_string()).collect();
835 let a = abbrev(&n, 2);
836 assert_eq!(a["laptop-two"], "la");
837 assert_eq!(a["ha"], "ha");
838 }
839
840 /// The floor holds up a column of pane labels and nothing else. A list of
841 /// ended sessions labels no pane, so it would spend twelve columns of summary
842 /// on nothing.
843 #[test]
844 fn the_label_floor_applies_to_pane_rows_and_the_cap_to_narrow_windows() {
845 let short = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
846 let wide = rows(short, "", 200);
847 let label_end = strip(&wide[0]).find("hi").unwrap();
848 assert_eq!(label_end, 15 + 1 + 2); // floored at 15, a space, the marker
849
850 // narrow caps rather than floors, so the summary gets the width back
851 let narrow = rows(short, "", 60);
852 assert!(strip(&narrow[0]).find("hi").unwrap() < 15);
853 }
854
855 #[test]
856 fn a_row_with_no_room_still_leaves_two_columns_of_gap() {
857 let r = rows(THREE, "%11", 0);
858 for line in &r {
859 assert!(strip(line).contains(" "));
860 }
861 }
862
863 #[test]
864 fn trims_only_a_trailing_run_of_unpainted_spaces() {
865 let mut c = vec![cell("a", PLAIN), cell("b ", PLAIN)];
866 trim_trailing(&mut c);
867 assert_eq!(c, vec![cell("a", PLAIN), cell("b", PLAIN)]);
868
869 // the run crosses a cell boundary, exactly as it would in the awk's
870 // concatenated string
871 let mut c = vec![cell("a", PLAIN), cell(" ", PLAIN), cell(" ", PLAIN)];
872 trim_trailing(&mut c);
873 assert_eq!(c, vec![cell("a", PLAIN)]);
874
875 // a painted cell ends in a reset, so nothing is trimmed past it
876 let mut c = vec![cell("x ", PATH), cell("", PLAIN)];
877 trim_trailing(&mut c);
878 assert_eq!(c[0].text, "x ");
879 }
880
881 #[test]
882 fn the_permission_mode_rides_on_the_agent_name() {
883 let base = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t";
884 let ask = rows(&format!("{}default\thi", base), "", 100);
885 let edit = rows(&format!("{}acceptEdits\thi", base), "", 100);
886 let auto = rows(&format!("{}bypassPermissions\thi", base), "", 100);
887 assert!(ask[0].contains("\x1b[35mclaude"));
888 assert!(edit[0].contains("\x1b[95mclaude"));
889 assert!(auto[0].contains("\x1b[1;95mclaude"));
890 }
891
892 #[test]
893 fn a_stale_version_goes_yellow_only_where_ctrl_x_could_act() {
894 let line = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
895 let stale = build(
896 line,
897 &Input {
898 newver: "2.0",
899 width: 100,
900 ..Default::default()
901 },
902 );
903 assert!(stale[0].to_ansi().contains("\x1b[33m1.0"));
904
905 // same version installed: nothing to act on
906 let current = build(
907 line,
908 &Input {
909 newver: "1.0",
910 width: 100,
911 ..Default::default()
912 },
913 );
914 assert!(current[0].to_ansi().contains("\x1b[2;35m1.0"));
915
916 // another host: newver is what THIS box would start, so it says nothing
917 let remote = build(
918 "ha:%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi",
919 &Input {
920 newver: "2.0",
921 width: 100,
922 ..Default::default()
923 },
924 );
925 assert!(remote[0].to_ansi().contains("\x1b[2;35m1.0"));
926
927 // an ended session has no process to put back
928 let dead = build(
929 "%1\tw:1.1\t/home/p\tclaude\t1.0\tdead\t-\thi",
930 &Input {
931 newver: "2.0",
932 width: 100,
933 ..Default::default()
934 },
935 );
936 assert!(dead[0].to_ansi().contains("\x1b[2;35m1.0"));
937 }
938
939 #[test]
940 fn a_host_that_could_not_answer_keeps_its_name_whole() {
941 // the second field is the message, not a target, and the row is left out
942 // of the shortening
943 let r = build(
944 "laptop-two:unreachable\tlaptop-two: no answer\t\t\t\tnote\t\t",
945 &Input {
946 width: 60,
947 only: "note",
948 ..Default::default()
949 },
950 );
951 assert!(r[0].to_ansi().contains("laptop-two: no answer"));
952 }
953
954 /// The outdated list holds exactly the rows the version column paints
955 /// yellow, which is what makes it the list of rows ctrl-x and F8 act on.
956 /// Same predicate for both, so the two can never disagree.
957 #[test]
958 fn the_outdated_list_holds_exactly_the_rows_painted_yellow() {
959 let lines = "%1\ta:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tbehind\n\
960 %2\tb:1.1\t/home/p\tclaude\t2.1.243\trun\t-\tcurrent\n\
961 %3\tc:1.1\t/home/p\tgemini\t0.41.2\tinput\t-\tanother agent\n\
962 ha:%4\td:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tover there";
963 let r = build(
964 lines,
965 &Input {
966 newver: "2.1.243",
967 width: 100,
968 outdated: true,
969 ..Default::default()
970 },
971 );
972 let ids: Vec<&str> = r.iter().map(|r| r.pane_id.as_str()).collect();
973 assert_eq!(ids, ["%1"]);
974 assert!(r[0].to_ansi().contains("\x1b[33m2.1.229"));
975
976 // …and the same list with nothing installed to compare against is
977 // empty rather than everything: the mode is skipped there.
978 let none = build(
979 lines,
980 &Input {
981 width: 100,
982 outdated: true,
983 ..Default::default()
984 },
985 );
986 assert!(none.is_empty());
987 }
988
989 /// Being behind is not a state, so the list crosses all four of them: a
990 /// session waiting for an answer is as behind as an idle one.
991 #[test]
992 fn the_outdated_list_is_not_one_state() {
993 let lines = "%1\ta:1.1\t/home/p\tclaude\t1.0\tinput\t-\tasking\n\
994 %2\tb:1.1\t/home/p\tclaude\t1.0\trun\t-\tworking\n\
995 %3\tc:1.1\t/home/p\tclaude\t1.0\tidle\t-\tidle";
996 let r = build(
997 lines,
998 &Input {
999 newver: "2.0",
1000 width: 100,
1001 outdated: true,
1002 ..Default::default()
1003 },
1004 );
1005 assert_eq!(r.len(), 3);
1006 }
1007
1008 #[test]
1009 fn one_state_only_when_asked() {
1010 let r = build(
1011 THREE,
1012 &Input {
1013 only: "run",
1014 width: 100,
1015 ..Default::default()
1016 },
1017 );
1018 assert_eq!(r.len(), 3);
1019 let r = build(
1020 THREE,
1021 &Input {
1022 only: "input",
1023 width: 100,
1024 ..Default::default()
1025 },
1026 );
1027 assert!(r.is_empty());
1028 }
1029
1030 #[test]
1031 fn a_blank_pane_title_borrows_the_one_its_conversation_recorded() {
1032 let mut ptitles = HashMap::new();
1033 ptitles.insert("%1".to_string(), "what it called itself".to_string());
1034 let r = build(
1035 "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\t",
1036 &Input {
1037 width: 100,
1038 ptitles,
1039 ..Default::default()
1040 },
1041 );
1042 assert!(r[0].plain().contains("what it called itself"));
1043 }
1044
1045 /// The snippet takes the path column, but only on a row that does not already
1046 /// show what was typed: there is nothing to explain then, and the path is
1047 /// worth more.
1048 #[test]
1049 fn a_search_snippet_replaces_the_tail_unless_the_row_already_says_it() {
1050 let mut snips = HashMap::new();
1051 snips.insert("%10".to_string(), "…the words it said…".to_string());
1052 snips.insert("%11".to_string(), "…other words…".to_string());
1053 let r = build(
1054 THREE,
1055 &Input {
1056 cur: "%11",
1057 width: 100,
1058 home: "/home/p",
1059 query: "refactor",
1060 snips,
1061 ..Default::default()
1062 },
1063 );
1064 // %10's summary is "Refactor auth", so it already says it
1065 assert!(r[0].plain().contains("proj/web"));
1066 assert!(!r[0].plain().contains('⌕'));
1067 // %11's does not
1068 assert!(r[1].plain().contains("⌕ …other words…"));
1069 }
1070
1071 #[test]
1072 fn says_it_is_smart_case_like_fzf() {
1073 let lower = vec!["refactor".to_string()];
1074 assert!(says_it("Refactor auth", &lower, true));
1075 let upper = vec!["Refactor".to_string()];
1076 assert!(!says_it("refactor auth", &upper, false));
1077 }
1078}