Skip to main content

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}