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.
283///
284/// Case-insensitive, as the content search now is: `terms` arrive folded. The
285/// two have to agree, or a row kept because of a capital the transcript spells
286/// differently would still be told it says nothing.
287fn says_it(s: &str, terms: &[String]) -> bool {
288 if terms.is_empty() {
289 return false;
290 }
291 let hay = s.to_lowercase();
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 // Folded, because the search behind them is: see `says_it`.
381 let terms: Vec<String> = input
382 .query
383 .split([' ', '\t'])
384 .filter(|t| !t.is_empty())
385 .map(|t| t.to_lowercase())
386 .collect();
387
388 let mut items: Vec<Item> = Vec::new();
389 for line in lines.lines() {
390 let f: Vec<&str> = line.split('\t').collect();
391 if f.len() < 7 {
392 continue;
393 }
394 let state = f[5];
395 if !input.only.is_empty() && state != input.only {
396 continue;
397 }
398 let (id, target, cwd) = (f[0], f[1], f[2]);
399
400 // A pane id that does not open on "%" names another host, and the label
401 // leads with it: "ha/main:1.7". What follows the colon says which kind of
402 // row it is, a pane id for a session over there, anything else for a
403 // host that could not answer.
404 let (mut host, mut note) = (String::new(), false);
405 if !id.starts_with('%') {
406 if let Some(c) = id.find(':') {
407 if c > 0 {
408 if id[c + 1..].starts_with('%') {
409 host = id[..c].to_string();
410 } else {
411 note = true;
412 }
413 }
414 }
415 }
416 // Applied here rather than beside `only` because it needs the host, and
417 // the host is what the id above has just been read for.
418 if input.outdated && !outdated(&host, f[3], f[4], state, input.newver) {
419 continue;
420 }
421 let session = match target.find(':') {
422 Some(c) if c > 0 => target[..c].to_string(),
423 _ => target.to_string(),
424 };
425 items.push(Item {
426 id: id.to_string(),
427 target: target.to_string(),
428 agent: f[3].to_string(),
429 version: f[4].to_string(),
430 state: state.to_string(),
431 mode: f[6].to_string(),
432 title: f.get(7).copied().unwrap_or("").to_string(),
433 host,
434 note,
435 path: path_display(cwd, input.home),
436 cwd: cwd.to_string(),
437 session,
438 });
439 }
440
441 // Narrow window: the session name is the first thing asked to give columns
442 // back. Of everything on the row it is the most recognisable from a few
443 // letters, and window.pane stays whole since two digits are no use truncated.
444 // The threshold is the one the tmux binding already uses to switch the popup
445 // to full width.
446 let compact = input.width > 0 && input.width < 100;
447 let mut names: Vec<String> = Vec::new();
448 let mut hnames: Vec<String> = Vec::new();
449 for it in &items {
450 if !it.note && !names.contains(&it.session) {
451 names.push(it.session.clone());
452 }
453 if !it.host.is_empty() && !hnames.contains(&it.host) {
454 hnames.push(it.host.clone());
455 }
456 }
457 let (short, shorth) = if compact {
458 (abbrev(&names, 1), abbrev(&hnames, 2))
459 } else {
460 (HashMap::new(), HashMap::new())
461 };
462
463 // The label column is as wide as the widest label actually in THIS list,
464 // never a guess. A flat 15 broke the moment a target needed more:
465 // "platform:14.11" is 14 plus the 2-column marker, so that one row started
466 // its summary a column right of every other and the whole list looked bent.
467 let mut labels: Vec<String> = Vec::new();
468 let mut labelw = 0;
469 for it in &items {
470 let lbl = if it.note {
471 it.target.clone()
472 } else {
473 let pfx = if it.host.is_empty() {
474 String::new()
475 } else if compact {
476 format!("{}/", shorth.get(&it.host).unwrap_or(&it.host))
477 } else {
478 format!("{}/", it.host)
479 };
480 let body = if compact {
481 let s = short.get(&it.session).cloned().unwrap_or_default();
482 format!("{}{}", s, &it.target[it.session.len()..])
483 } else {
484 it.target.clone()
485 };
486 format!("{}{}", pfx, body)
487 };
488 labelw = labelw.max(vlen(&lbl) + 2);
489 labels.push(lbl);
490 }
491 // A narrow window CAPS it: there the label is the column asked to give width
492 // back to the summary, and a shortened name that still overruns is worth a
493 // bent row. A roomy window gets a FLOOR instead, so the column stops
494 // jittering as sessions with longer names come and go. The old code applied
495 // 15 as a ceiling in BOTH, which is what bent the row.
496 //
497 // The floor only holds up a column of PANE labels, which is what it is for. A
498 // list of ended sessions labels no pane (the column holds an age, three
499 // characters of it), so the floor there would spend twelve columns of summary
500 // on nothing at all.
501 let panes = items.iter().filter(|i| !i.note).count();
502 if compact {
503 labelw = labelw.min(15);
504 } else if panes > 0 {
505 labelw = labelw.max(15);
506 }
507
508 // The trailing columns are a TABLE, so their widths come from the whole list
509 // too. Right-aligning "<path> <agent> <version>" as ONE string moves the path
510 // column by however long the agent and version on THAT row happen to be, and
511 // no two agents are the same length.
512 let mut agw = 0;
513 let mut verw = 0;
514 let mut pathw = 0;
515 for it in &items {
516 agw = agw.max(vlen(&it.agent));
517 verw = verw.max(vlen(&it.version));
518 pathw = pathw.max(vlen(&it.path));
519 }
520 pathw = pathw.min(30);
521 let tailw = pathw + 1 + agw + if verw > 0 { 1 + verw } else { 0 };
522
523 let mut out = Vec::new();
524 for (i, it) in items.iter().enumerate() {
525 let is_cur = it.id == input.cur;
526 let mark = if is_cur { "● " } else { " " };
527 let plabel = pad(&format!("{}{}", mark, labels[i]), labelw);
528
529 let mut sum = summary_of(&it.title).to_string();
530 // Nothing on the pane: fall back to what its conversation calls itself.
531 // claude sets a title at a turn boundary, so a session restored by
532 // tmux-resurrect and not prompted since has nothing there.
533 if sum.is_empty() {
534 if let Some(t) = input.ptitles.get(&it.id) {
535 sum = t.clone();
536 }
537 }
538 // A summary that will not fit gives way, rather than pushing the table
539 // off the right edge. Nothing needed this while every summary came from
540 // a pane title, which is a handful of words; a PAST session's summary is
541 // whatever it was asked to do, up to eighty characters of it, and those
542 // rows arrived shoving the directory, the agent and the version out of
543 // the window. The columns beside it are the ones you read down the list,
544 // so the summary is the one that can afford to end in an ellipsis.
545 sum = fit(&sum, summary_room(input.width, vlen(&plabel), tailw));
546
547 let mut cells = vec![
548 cell(plabel.clone(), if is_cur { LABEL_CUR } else { LABEL_OTHER }),
549 cell(" ", PLAIN),
550 ];
551 // A restart in flight outranks the state, because during one the state is
552 // whatever the screen happened to show as the old session went away, and
553 // that is the least useful thing the column could say. The glyph goes
554 // here rather than into the summary because the summary strips a leading
555 // marker (see summary_of), so one put there would be silently eaten.
556 if input.restarting.contains(&it.id) {
557 cells.push(cell("↻", MARK_RESTART));
558 cells.push(cell(" ", PLAIN));
559 } else {
560 match it.state.as_str() {
561 "input" => {
562 cells.push(cell("✳", MARK_INPUT));
563 cells.push(cell(" ", PLAIN));
564 }
565 "run" => {
566 cells.push(cell("◐", MARK_RUN));
567 cells.push(cell(" ", PLAIN));
568 }
569 _ => cells.push(cell(" ", PLAIN)),
570 }
571 }
572 cells.push(cell(sum.clone(), PLAIN));
573
574 // A row that is here because of what its session SAID takes the snippet
575 // where its path would be. Two things at once: the row stops being a
576 // mystery, and the words typed are now ON it, which is what lets the
577 // matcher keep working in the ordinary way rather than being handed a
578 // blob it would match everything against.
579 let snip = input.snips.get(&it.id).filter(|_| {
580 !says_it(
581 &format!("{} {} {} {} {}", plabel, sum, it.path, it.agent, it.version),
582 &terms,
583 )
584 });
585 if let Some(s) = snip {
586 let stail = format!("⌕ {}", s);
587 let gap = gap_of(input.width, &plabel, &sum, vlen(&stail));
588 cells.push(cell(spaces(gap), PLAIN));
589 cells.push(cell(stail, PATH));
590 } else {
591 let gap = gap_of(input.width, &plabel, &sum, tailw);
592 cells.push(cell(spaces(gap), PLAIN));
593 cells.push(cell(pad(&it.path, pathw), PATH));
594 cells.push(cell(" ", PLAIN));
595 // Agent and version are right-aligned inside their columns, so the
596 // row stays flush with the right edge and the version numbers read
597 // down the list. A row missing either keeps the column: what it must
598 // not do is pull the ones beside it out of line.
599 if it.agent.is_empty() {
600 cells.push(cell(spaces(agw), PLAIN));
601 } else {
602 cells.push(cell(spaces(agw - vlen(&it.agent)), PLAIN));
603 cells.push(cell(it.agent.clone(), mode_paint(&it.mode)));
604 }
605 if verw > 0 {
606 if it.version.is_empty() {
607 cells.push(cell(format!(" {}", spaces(verw)), PLAIN));
608 } else {
609 cells.push(cell(
610 format!(" {}", spaces(verw - vlen(&it.version))),
611 PLAIN,
612 ));
613 cells.push(cell(
614 it.version.clone(),
615 version_paint(&it.host, &it.agent, &it.version, &it.state, input.newver),
616 ));
617 }
618 }
619 }
620 trim_trailing(&mut cells);
621 cells.retain(|c| !c.text.is_empty());
622 out.push(Row {
623 cells,
624 pane_id: it.id.clone(),
625 target: it.target.clone(),
626 cwd: it.cwd.clone(),
627 host: it.host.clone(),
628 });
629 }
630 out
631}
632
633/// What is left between the summary and the right-hand block. Two columns
634/// minimum: a window with no room just trails the tail behind the summary
635/// instead of overlapping it.
636fn gap_of(width: usize, plabel: &str, sum: &str, tailw: usize) -> usize {
637 let used = vlen(plabel) + 1 + 2 + vlen(sum) + tailw;
638 width.saturating_sub(used).max(2)
639}
640
641/// How much width a summary may have before it starts costing the table.
642///
643/// `0` means "as much as it likes", which is what an unmeasured window (width 0,
644/// the machine-readable path) and one too narrow to hold a table both get: in
645/// the first nothing is being drawn, and in the second there is no arrangement
646/// that fits, so a long summary is more use than a stump.
647fn summary_room(width: usize, labelw: usize, tailw: usize) -> usize {
648 if width == 0 {
649 return 0;
650 }
651 let chrome = labelw + 1 + 2 + 2 + tailw; // label, space, marker, gap, table
652 let room = width.saturating_sub(chrome);
653 if room < MIN_SUMMARY {
654 0
655 } else {
656 room
657 }
658}
659
660/// Below this a summary says nothing, so the table gives way instead.
661const MIN_SUMMARY: usize = 20;
662
663/// Cut to a display width, with an ellipsis where it was cut.
664///
665/// `0` is no limit. Character-wise and width-aware, so a double-width character
666/// counts for two and none is ever cut in half.
667fn fit(s: &str, room: usize) -> String {
668 if room == 0 || vlen(s) <= room {
669 return s.to_string();
670 }
671 let mut out = String::new();
672 let mut w = 0;
673 for c in s.chars() {
674 let cw = UnicodeWidthStr::width(c.to_string().as_str());
675 if w + cw > room.saturating_sub(1) {
676 break;
677 }
678 out.push(c);
679 w += cw;
680 }
681 out.push('…');
682 out
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 fn rows(lines: &str, cur: &str, width: usize) -> Vec<String> {
690 build(
691 lines,
692 &Input {
693 cur,
694 width,
695 home: "/home/p",
696 ..Default::default()
697 },
698 )
699 .iter()
700 .map(|r| r.to_ansi())
701 .collect()
702 }
703
704 const THREE: &str = "%10\twork:1.1\t/home/p/proj/web\tclaude\t2.1.229\trun\t-\t◐ Refactor auth\n\
705 %11\tops:2.1\t/home/p\tgemini\t0.41.2\trun\t-\t⠂ tests\n\
706 %12\tops:3.1\t/home/p/longdir/another-very-long-project-name-here\tcodex\t\trun\t-\t◐ X";
707
708 #[test]
709 fn the_pane_id_is_the_hidden_last_field() {
710 let r = rows(THREE, "%11", 100);
711 assert!(r[0].ends_with("\t%10"));
712 assert!(r[1].ends_with("\t%11"));
713 }
714
715 #[test]
716 fn only_the_current_pane_is_marked() {
717 let r = rows(THREE, "%11", 100);
718 assert!(!r[0].contains('●'));
719 assert!(r[1].contains('●'));
720 }
721
722 /// The glyph the title leads with is dropped and the column filled from the
723 /// state, so a title's own spinner frame never leaks into the summary.
724 #[test]
725 fn the_title_marker_is_replaced_by_the_state_marker() {
726 let r = rows(THREE, "%11", 100);
727 assert!(r[0].contains("Refactor auth"));
728 assert!(!r[0].contains("◐ Refactor")); // the title's own glyph is gone
729 assert!(r[0].contains("\x1b[2m◐\x1b[0m ")); // …and the state's is there
730 }
731
732 #[test]
733 fn summary_of_strips_only_a_leading_glyph_run() {
734 assert_eq!(summary_of("◐ Refactor auth"), "Refactor auth");
735 assert_eq!(summary_of("⠂ tests"), "tests");
736 assert_eq!(summary_of("plain title"), "plain title");
737 // a title that merely OPENS on a non-ASCII word keeps it: the space has
738 // to follow the run directly
739 assert_eq!(summary_of("étude du code"), "étude du code");
740 // a bare glyph with nothing after it leaves an empty summary
741 assert_eq!(summary_of("✳"), "");
742 }
743
744 #[test]
745 fn paths_fold_home_and_keep_the_last_two_components() {
746 assert_eq!(path_display("/home/p/proj/web", "/home/p"), "proj/web");
747 assert_eq!(path_display("/home/p", "/home/p"), "~");
748 assert_eq!(path_display("/var/log", "/home/p"), "var/log");
749 }
750
751 #[test]
752 fn a_long_path_is_elided_from_the_left_to_thirty() {
753 let d = path_display(
754 "/home/p/longdir/another-very-long-project-name-here",
755 "/home/p",
756 );
757 assert_eq!(d.chars().count(), 30);
758 assert!(d.starts_with('…'));
759 assert!(d.ends_with("name-here"));
760 }
761
762 /// Every trailing column is measured over the whole list, so the path column
763 /// starts in the same place on every row. Sizing them per row is what bent
764 /// the list: no two agent names are the same length.
765 /// Columns, not bytes: `●` and `◐` are three bytes each, so a byte offset
766 /// reports two rows as misaligned that are in fact flush.
767 fn col_of(line: &str, needle: &str) -> usize {
768 let s = strip(line);
769 let b = s.find(needle).expect("needle on the row");
770 vlen(&s[..b])
771 }
772
773 #[test]
774 fn the_trailing_columns_line_up_down_the_list() {
775 let r = rows(THREE, "%11", 100);
776 // all three paths start at the same column
777 let a = col_of(&r[0], "proj/web");
778 assert_eq!(col_of(&r[1], "~"), a);
779 assert_eq!(col_of(&r[2], "…"), a);
780 }
781
782 fn strip(s: &str) -> String {
783 let mut out = String::new();
784 let mut it = s.chars();
785 while let Some(c) = it.next() {
786 if c == '\x1b' {
787 for c in it.by_ref() {
788 if c == 'm' {
789 break;
790 }
791 }
792 } else {
793 out.push(c);
794 }
795 }
796 out
797 }
798
799 /// A row with no version keeps the column rather than pulling the ones beside
800 /// it out of line, and the blank remainder that leaves is trimmed.
801 #[test]
802 fn a_missing_version_keeps_its_column_but_leaves_no_trailing_space() {
803 let r = rows(THREE, "%11", 100);
804 assert!(!strip(&r[2]).split('\t').next().unwrap().ends_with(' '));
805 assert!(strip(&r[2]).contains("codex"));
806 }
807
808 #[test]
809 fn abbreviates_to_the_shortest_prefix_that_still_tells_names_apart() {
810 let n: Vec<String> = ["main", "master", "ops"]
811 .iter()
812 .map(|s| s.to_string())
813 .collect();
814 let a = abbrev(&n, 1);
815 assert_eq!(a["ops"], "o");
816 assert_eq!(a["main"], "mai");
817 assert_eq!(a["master"], "mas");
818 }
819
820 /// A name that is a whole other name plus something can only be told apart in
821 /// full, which the awk gets by running its loop off the end.
822 #[test]
823 fn a_name_that_contains_another_is_kept_whole() {
824 let n: Vec<String> = ["main", "main2"].iter().map(|s| s.to_string()).collect();
825 let a = abbrev(&n, 1);
826 assert_eq!(a["main"], "main");
827 assert_eq!(a["main2"], "main2");
828 }
829
830 #[test]
831 fn the_host_floor_is_two_letters() {
832 let n: Vec<String> = ["laptop-two", "ha"].iter().map(|s| s.to_string()).collect();
833 let a = abbrev(&n, 2);
834 assert_eq!(a["laptop-two"], "la");
835 assert_eq!(a["ha"], "ha");
836 }
837
838 /// The floor holds up a column of pane labels and nothing else. A list of
839 /// ended sessions labels no pane, so it would spend twelve columns of summary
840 /// on nothing.
841 #[test]
842 fn the_label_floor_applies_to_pane_rows_and_the_cap_to_narrow_windows() {
843 let short = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
844 let wide = rows(short, "", 200);
845 let label_end = strip(&wide[0]).find("hi").unwrap();
846 assert_eq!(label_end, 15 + 1 + 2); // floored at 15, a space, the marker
847
848 // narrow caps rather than floors, so the summary gets the width back
849 let narrow = rows(short, "", 60);
850 assert!(strip(&narrow[0]).find("hi").unwrap() < 15);
851 }
852
853 #[test]
854 fn a_row_with_no_room_still_leaves_two_columns_of_gap() {
855 let r = rows(THREE, "%11", 0);
856 for line in &r {
857 assert!(strip(line).contains(" "));
858 }
859 }
860
861 #[test]
862 fn trims_only_a_trailing_run_of_unpainted_spaces() {
863 let mut c = vec![cell("a", PLAIN), cell("b ", PLAIN)];
864 trim_trailing(&mut c);
865 assert_eq!(c, vec![cell("a", PLAIN), cell("b", PLAIN)]);
866
867 // the run crosses a cell boundary, exactly as it would in the awk's
868 // concatenated string
869 let mut c = vec![cell("a", PLAIN), cell(" ", PLAIN), cell(" ", PLAIN)];
870 trim_trailing(&mut c);
871 assert_eq!(c, vec![cell("a", PLAIN)]);
872
873 // a painted cell ends in a reset, so nothing is trimmed past it
874 let mut c = vec![cell("x ", PATH), cell("", PLAIN)];
875 trim_trailing(&mut c);
876 assert_eq!(c[0].text, "x ");
877 }
878
879 #[test]
880 fn the_permission_mode_rides_on_the_agent_name() {
881 let base = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t";
882 let ask = rows(&format!("{}default\thi", base), "", 100);
883 let edit = rows(&format!("{}acceptEdits\thi", base), "", 100);
884 let auto = rows(&format!("{}bypassPermissions\thi", base), "", 100);
885 assert!(ask[0].contains("\x1b[35mclaude"));
886 assert!(edit[0].contains("\x1b[95mclaude"));
887 assert!(auto[0].contains("\x1b[1;95mclaude"));
888 }
889
890 #[test]
891 fn a_stale_version_goes_yellow_only_where_ctrl_x_could_act() {
892 let line = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
893 let stale = build(
894 line,
895 &Input {
896 newver: "2.0",
897 width: 100,
898 ..Default::default()
899 },
900 );
901 assert!(stale[0].to_ansi().contains("\x1b[33m1.0"));
902
903 // same version installed: nothing to act on
904 let current = build(
905 line,
906 &Input {
907 newver: "1.0",
908 width: 100,
909 ..Default::default()
910 },
911 );
912 assert!(current[0].to_ansi().contains("\x1b[2;35m1.0"));
913
914 // another host: newver is what THIS box would start, so it says nothing
915 let remote = build(
916 "ha:%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi",
917 &Input {
918 newver: "2.0",
919 width: 100,
920 ..Default::default()
921 },
922 );
923 assert!(remote[0].to_ansi().contains("\x1b[2;35m1.0"));
924
925 // an ended session has no process to put back
926 let dead = build(
927 "%1\tw:1.1\t/home/p\tclaude\t1.0\tdead\t-\thi",
928 &Input {
929 newver: "2.0",
930 width: 100,
931 ..Default::default()
932 },
933 );
934 assert!(dead[0].to_ansi().contains("\x1b[2;35m1.0"));
935 }
936
937 #[test]
938 fn a_host_that_could_not_answer_keeps_its_name_whole() {
939 // the second field is the message, not a target, and the row is left out
940 // of the shortening
941 let r = build(
942 "laptop-two:unreachable\tlaptop-two: no answer\t\t\t\tnote\t\t",
943 &Input {
944 width: 60,
945 only: "note",
946 ..Default::default()
947 },
948 );
949 assert!(r[0].to_ansi().contains("laptop-two: no answer"));
950 }
951
952 /// The outdated list holds exactly the rows the version column paints
953 /// yellow, which is what makes it the list of rows ctrl-x and F8 act on.
954 /// Same predicate for both, so the two can never disagree.
955 #[test]
956 fn the_outdated_list_holds_exactly_the_rows_painted_yellow() {
957 let lines = "%1\ta:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tbehind\n\
958 %2\tb:1.1\t/home/p\tclaude\t2.1.243\trun\t-\tcurrent\n\
959 %3\tc:1.1\t/home/p\tgemini\t0.41.2\tinput\t-\tanother agent\n\
960 ha:%4\td:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tover there";
961 let r = build(
962 lines,
963 &Input {
964 newver: "2.1.243",
965 width: 100,
966 outdated: true,
967 ..Default::default()
968 },
969 );
970 let ids: Vec<&str> = r.iter().map(|r| r.pane_id.as_str()).collect();
971 assert_eq!(ids, ["%1"]);
972 assert!(r[0].to_ansi().contains("\x1b[33m2.1.229"));
973
974 // …and the same list with nothing installed to compare against is
975 // empty rather than everything: the mode is skipped there.
976 let none = build(
977 lines,
978 &Input {
979 width: 100,
980 outdated: true,
981 ..Default::default()
982 },
983 );
984 assert!(none.is_empty());
985 }
986
987 /// Being behind is not a state, so the list crosses all four of them: a
988 /// session waiting for an answer is as behind as an idle one.
989 #[test]
990 fn the_outdated_list_is_not_one_state() {
991 let lines = "%1\ta:1.1\t/home/p\tclaude\t1.0\tinput\t-\tasking\n\
992 %2\tb:1.1\t/home/p\tclaude\t1.0\trun\t-\tworking\n\
993 %3\tc:1.1\t/home/p\tclaude\t1.0\tidle\t-\tidle";
994 let r = build(
995 lines,
996 &Input {
997 newver: "2.0",
998 width: 100,
999 outdated: true,
1000 ..Default::default()
1001 },
1002 );
1003 assert_eq!(r.len(), 3);
1004 }
1005
1006 #[test]
1007 fn one_state_only_when_asked() {
1008 let r = build(
1009 THREE,
1010 &Input {
1011 only: "run",
1012 width: 100,
1013 ..Default::default()
1014 },
1015 );
1016 assert_eq!(r.len(), 3);
1017 let r = build(
1018 THREE,
1019 &Input {
1020 only: "input",
1021 width: 100,
1022 ..Default::default()
1023 },
1024 );
1025 assert!(r.is_empty());
1026 }
1027
1028 #[test]
1029 fn a_blank_pane_title_borrows_the_one_its_conversation_recorded() {
1030 let mut ptitles = HashMap::new();
1031 ptitles.insert("%1".to_string(), "what it called itself".to_string());
1032 let r = build(
1033 "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\t",
1034 &Input {
1035 width: 100,
1036 ptitles,
1037 ..Default::default()
1038 },
1039 );
1040 assert!(r[0].plain().contains("what it called itself"));
1041 }
1042
1043 /// The snippet takes the path column, but only on a row that does not already
1044 /// show what was typed: there is nothing to explain then, and the path is
1045 /// worth more.
1046 #[test]
1047 fn a_search_snippet_replaces_the_tail_unless_the_row_already_says_it() {
1048 let mut snips = HashMap::new();
1049 snips.insert("%10".to_string(), "…the words it said…".to_string());
1050 snips.insert("%11".to_string(), "…other words…".to_string());
1051 let r = build(
1052 THREE,
1053 &Input {
1054 cur: "%11",
1055 width: 100,
1056 home: "/home/p",
1057 query: "refactor",
1058 snips,
1059 ..Default::default()
1060 },
1061 );
1062 // %10's summary is "Refactor auth", so it already says it
1063 assert!(r[0].plain().contains("proj/web"));
1064 assert!(!r[0].plain().contains('⌕'));
1065 // %11's does not
1066 assert!(r[1].plain().contains("⌕ …other words…"));
1067 }
1068
1069 /// Whichever side carries the capital. `build` folds the terms, and a row
1070 /// showing `Refactor` answers a query for `REFACTOR` as well as one for
1071 /// `refactor`.
1072 #[test]
1073 fn says_it_ignores_case_both_ways() {
1074 assert!(says_it("Refactor auth", &["refactor".to_string()]));
1075 assert!(says_it("refactor auth", &["refactor".to_string()]));
1076 assert!(!says_it("refactor auth", &["rewrite".to_string()]));
1077 }
1078
1079 /// …and the row built from a shouted query keeps its path rather than being
1080 /// handed a snippet to explain a match it visibly already shows.
1081 #[test]
1082 fn a_shouted_query_still_counts_as_said_on_the_row() {
1083 let mut snips = HashMap::new();
1084 snips.insert("%10".to_string(), "…the words it said…".to_string());
1085 let r = build(
1086 THREE,
1087 &Input {
1088 width: 100,
1089 home: "/home/p",
1090 query: "REFACTOR",
1091 snips,
1092 ..Default::default()
1093 },
1094 );
1095 assert!(r[0].plain().contains("proj/web"));
1096 assert!(!r[0].plain().contains('⌕'));
1097 }
1098}