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.
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}