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
540        let mut cells = vec![
541            cell(plabel.clone(), if is_cur { LABEL_CUR } else { LABEL_OTHER }),
542            cell(" ", PLAIN),
543        ];
544        // A restart in flight outranks the state, because during one the state is
545        // whatever the screen happened to show as the old session went away, and
546        // that is the least useful thing the column could say. The glyph goes
547        // here rather than into the summary because the summary strips a leading
548        // marker (see summary_of), so one put there would be silently eaten.
549        if input.restarting.contains(&it.id) {
550            cells.push(cell("↻", MARK_RESTART));
551            cells.push(cell(" ", PLAIN));
552        } else {
553            match it.state.as_str() {
554                "input" => {
555                    cells.push(cell("✳", MARK_INPUT));
556                    cells.push(cell(" ", PLAIN));
557                }
558                "run" => {
559                    cells.push(cell("◐", MARK_RUN));
560                    cells.push(cell(" ", PLAIN));
561                }
562                _ => cells.push(cell("  ", PLAIN)),
563            }
564        }
565        cells.push(cell(sum.clone(), PLAIN));
566
567        // A row that is here because of what its session SAID takes the snippet
568        // where its path would be. Two things at once: the row stops being a
569        // mystery, and the words typed are now ON it, which is what lets the
570        // matcher keep working in the ordinary way rather than being handed a
571        // blob it would match everything against.
572        let snip = input.snips.get(&it.id).filter(|_| {
573            !says_it(
574                &format!("{} {} {} {} {}", plabel, sum, it.path, it.agent, it.version),
575                &terms,
576                fold,
577            )
578        });
579        if let Some(s) = snip {
580            let stail = format!("⌕ {}", s);
581            let gap = gap_of(input.width, &plabel, &sum, vlen(&stail));
582            cells.push(cell(spaces(gap), PLAIN));
583            cells.push(cell(stail, PATH));
584        } else {
585            let gap = gap_of(input.width, &plabel, &sum, tailw);
586            cells.push(cell(spaces(gap), PLAIN));
587            cells.push(cell(pad(&it.path, pathw), PATH));
588            cells.push(cell(" ", PLAIN));
589            // Agent and version are right-aligned inside their columns, so the
590            // row stays flush with the right edge and the version numbers read
591            // down the list. A row missing either keeps the column: what it must
592            // not do is pull the ones beside it out of line.
593            if it.agent.is_empty() {
594                cells.push(cell(spaces(agw), PLAIN));
595            } else {
596                cells.push(cell(spaces(agw - vlen(&it.agent)), PLAIN));
597                cells.push(cell(it.agent.clone(), mode_paint(&it.mode)));
598            }
599            if verw > 0 {
600                if it.version.is_empty() {
601                    cells.push(cell(format!(" {}", spaces(verw)), PLAIN));
602                } else {
603                    cells.push(cell(
604                        format!(" {}", spaces(verw - vlen(&it.version))),
605                        PLAIN,
606                    ));
607                    cells.push(cell(
608                        it.version.clone(),
609                        version_paint(&it.host, &it.agent, &it.version, &it.state, input.newver),
610                    ));
611                }
612            }
613        }
614        trim_trailing(&mut cells);
615        cells.retain(|c| !c.text.is_empty());
616        out.push(Row {
617            cells,
618            pane_id: it.id.clone(),
619            target: it.target.clone(),
620            cwd: it.cwd.clone(),
621            host: it.host.clone(),
622        });
623    }
624    out
625}
626
627/// What is left between the summary and the right-hand block. Two columns
628/// minimum: a window with no room just trails the tail behind the summary
629/// instead of overlapping it.
630fn gap_of(width: usize, plabel: &str, sum: &str, tailw: usize) -> usize {
631    let used = vlen(plabel) + 1 + 2 + vlen(sum) + tailw;
632    width.saturating_sub(used).max(2)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    fn rows(lines: &str, cur: &str, width: usize) -> Vec<String> {
640        build(
641            lines,
642            &Input {
643                cur,
644                width,
645                home: "/home/p",
646                ..Default::default()
647            },
648        )
649        .iter()
650        .map(|r| r.to_ansi())
651        .collect()
652    }
653
654    const THREE: &str = "%10\twork:1.1\t/home/p/proj/web\tclaude\t2.1.229\trun\t-\t◐ Refactor auth\n\
655                         %11\tops:2.1\t/home/p\tgemini\t0.41.2\trun\t-\t⠂ tests\n\
656                         %12\tops:3.1\t/home/p/longdir/another-very-long-project-name-here\tcodex\t\trun\t-\t◐ X";
657
658    #[test]
659    fn the_pane_id_is_the_hidden_last_field() {
660        let r = rows(THREE, "%11", 100);
661        assert!(r[0].ends_with("\t%10"));
662        assert!(r[1].ends_with("\t%11"));
663    }
664
665    #[test]
666    fn only_the_current_pane_is_marked() {
667        let r = rows(THREE, "%11", 100);
668        assert!(!r[0].contains('●'));
669        assert!(r[1].contains('●'));
670    }
671
672    /// The glyph the title leads with is dropped and the column filled from the
673    /// state, so a title's own spinner frame never leaks into the summary.
674    #[test]
675    fn the_title_marker_is_replaced_by_the_state_marker() {
676        let r = rows(THREE, "%11", 100);
677        assert!(r[0].contains("Refactor auth"));
678        assert!(!r[0].contains("◐ Refactor")); // the title's own glyph is gone
679        assert!(r[0].contains("\x1b[2m◐\x1b[0m ")); // …and the state's is there
680    }
681
682    #[test]
683    fn summary_of_strips_only_a_leading_glyph_run() {
684        assert_eq!(summary_of("◐ Refactor auth"), "Refactor auth");
685        assert_eq!(summary_of("⠂ tests"), "tests");
686        assert_eq!(summary_of("plain title"), "plain title");
687        // a title that merely OPENS on a non-ASCII word keeps it: the space has
688        // to follow the run directly
689        assert_eq!(summary_of("étude du code"), "étude du code");
690        // a bare glyph with nothing after it leaves an empty summary
691        assert_eq!(summary_of("✳"), "");
692    }
693
694    #[test]
695    fn paths_fold_home_and_keep_the_last_two_components() {
696        assert_eq!(path_display("/home/p/proj/web", "/home/p"), "proj/web");
697        assert_eq!(path_display("/home/p", "/home/p"), "~");
698        assert_eq!(path_display("/var/log", "/home/p"), "var/log");
699    }
700
701    #[test]
702    fn a_long_path_is_elided_from_the_left_to_thirty() {
703        let d = path_display(
704            "/home/p/longdir/another-very-long-project-name-here",
705            "/home/p",
706        );
707        assert_eq!(d.chars().count(), 30);
708        assert!(d.starts_with('…'));
709        assert!(d.ends_with("name-here"));
710    }
711
712    /// Every trailing column is measured over the whole list, so the path column
713    /// starts in the same place on every row. Sizing them per row is what bent
714    /// the list: no two agent names are the same length.
715    /// Columns, not bytes: `●` and `◐` are three bytes each, so a byte offset
716    /// reports two rows as misaligned that are in fact flush.
717    fn col_of(line: &str, needle: &str) -> usize {
718        let s = strip(line);
719        let b = s.find(needle).expect("needle on the row");
720        vlen(&s[..b])
721    }
722
723    #[test]
724    fn the_trailing_columns_line_up_down_the_list() {
725        let r = rows(THREE, "%11", 100);
726        // all three paths start at the same column
727        let a = col_of(&r[0], "proj/web");
728        assert_eq!(col_of(&r[1], "~"), a);
729        assert_eq!(col_of(&r[2], "…"), a);
730    }
731
732    fn strip(s: &str) -> String {
733        let mut out = String::new();
734        let mut it = s.chars();
735        while let Some(c) = it.next() {
736            if c == '\x1b' {
737                for c in it.by_ref() {
738                    if c == 'm' {
739                        break;
740                    }
741                }
742            } else {
743                out.push(c);
744            }
745        }
746        out
747    }
748
749    /// A row with no version keeps the column rather than pulling the ones beside
750    /// it out of line, and the blank remainder that leaves is trimmed.
751    #[test]
752    fn a_missing_version_keeps_its_column_but_leaves_no_trailing_space() {
753        let r = rows(THREE, "%11", 100);
754        assert!(!strip(&r[2]).split('\t').next().unwrap().ends_with(' '));
755        assert!(strip(&r[2]).contains("codex"));
756    }
757
758    #[test]
759    fn abbreviates_to_the_shortest_prefix_that_still_tells_names_apart() {
760        let n: Vec<String> = ["main", "master", "ops"]
761            .iter()
762            .map(|s| s.to_string())
763            .collect();
764        let a = abbrev(&n, 1);
765        assert_eq!(a["ops"], "o");
766        assert_eq!(a["main"], "mai");
767        assert_eq!(a["master"], "mas");
768    }
769
770    /// A name that is a whole other name plus something can only be told apart in
771    /// full, which the awk gets by running its loop off the end.
772    #[test]
773    fn a_name_that_contains_another_is_kept_whole() {
774        let n: Vec<String> = ["main", "main2"].iter().map(|s| s.to_string()).collect();
775        let a = abbrev(&n, 1);
776        assert_eq!(a["main"], "main");
777        assert_eq!(a["main2"], "main2");
778    }
779
780    #[test]
781    fn the_host_floor_is_two_letters() {
782        let n: Vec<String> = ["laptop-two", "ha"].iter().map(|s| s.to_string()).collect();
783        let a = abbrev(&n, 2);
784        assert_eq!(a["laptop-two"], "la");
785        assert_eq!(a["ha"], "ha");
786    }
787
788    /// The floor holds up a column of pane labels and nothing else. A list of
789    /// ended sessions labels no pane, so it would spend twelve columns of summary
790    /// on nothing.
791    #[test]
792    fn the_label_floor_applies_to_pane_rows_and_the_cap_to_narrow_windows() {
793        let short = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
794        let wide = rows(short, "", 200);
795        let label_end = strip(&wide[0]).find("hi").unwrap();
796        assert_eq!(label_end, 15 + 1 + 2); // floored at 15, a space, the marker
797
798        // narrow caps rather than floors, so the summary gets the width back
799        let narrow = rows(short, "", 60);
800        assert!(strip(&narrow[0]).find("hi").unwrap() < 15);
801    }
802
803    #[test]
804    fn a_row_with_no_room_still_leaves_two_columns_of_gap() {
805        let r = rows(THREE, "%11", 0);
806        for line in &r {
807            assert!(strip(line).contains("  "));
808        }
809    }
810
811    #[test]
812    fn trims_only_a_trailing_run_of_unpainted_spaces() {
813        let mut c = vec![cell("a", PLAIN), cell("b  ", PLAIN)];
814        trim_trailing(&mut c);
815        assert_eq!(c, vec![cell("a", PLAIN), cell("b", PLAIN)]);
816
817        // the run crosses a cell boundary, exactly as it would in the awk's
818        // concatenated string
819        let mut c = vec![cell("a", PLAIN), cell("  ", PLAIN), cell("   ", PLAIN)];
820        trim_trailing(&mut c);
821        assert_eq!(c, vec![cell("a", PLAIN)]);
822
823        // a painted cell ends in a reset, so nothing is trimmed past it
824        let mut c = vec![cell("x  ", PATH), cell("", PLAIN)];
825        trim_trailing(&mut c);
826        assert_eq!(c[0].text, "x  ");
827    }
828
829    #[test]
830    fn the_permission_mode_rides_on_the_agent_name() {
831        let base = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t";
832        let ask = rows(&format!("{}default\thi", base), "", 100);
833        let edit = rows(&format!("{}acceptEdits\thi", base), "", 100);
834        let auto = rows(&format!("{}bypassPermissions\thi", base), "", 100);
835        assert!(ask[0].contains("\x1b[35mclaude"));
836        assert!(edit[0].contains("\x1b[95mclaude"));
837        assert!(auto[0].contains("\x1b[1;95mclaude"));
838    }
839
840    #[test]
841    fn a_stale_version_goes_yellow_only_where_ctrl_x_could_act() {
842        let line = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
843        let stale = build(
844            line,
845            &Input {
846                newver: "2.0",
847                width: 100,
848                ..Default::default()
849            },
850        );
851        assert!(stale[0].to_ansi().contains("\x1b[33m1.0"));
852
853        // same version installed: nothing to act on
854        let current = build(
855            line,
856            &Input {
857                newver: "1.0",
858                width: 100,
859                ..Default::default()
860            },
861        );
862        assert!(current[0].to_ansi().contains("\x1b[2;35m1.0"));
863
864        // another host: newver is what THIS box would start, so it says nothing
865        let remote = build(
866            "ha:%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi",
867            &Input {
868                newver: "2.0",
869                width: 100,
870                ..Default::default()
871            },
872        );
873        assert!(remote[0].to_ansi().contains("\x1b[2;35m1.0"));
874
875        // an ended session has no process to put back
876        let dead = build(
877            "%1\tw:1.1\t/home/p\tclaude\t1.0\tdead\t-\thi",
878            &Input {
879                newver: "2.0",
880                width: 100,
881                ..Default::default()
882            },
883        );
884        assert!(dead[0].to_ansi().contains("\x1b[2;35m1.0"));
885    }
886
887    #[test]
888    fn a_host_that_could_not_answer_keeps_its_name_whole() {
889        // the second field is the message, not a target, and the row is left out
890        // of the shortening
891        let r = build(
892            "laptop-two:unreachable\tlaptop-two: no answer\t\t\t\tnote\t\t",
893            &Input {
894                width: 60,
895                only: "note",
896                ..Default::default()
897            },
898        );
899        assert!(r[0].to_ansi().contains("laptop-two: no answer"));
900    }
901
902    /// The outdated list holds exactly the rows the version column paints
903    /// yellow, which is what makes it the list of rows ctrl-x and F8 act on.
904    /// Same predicate for both, so the two can never disagree.
905    #[test]
906    fn the_outdated_list_holds_exactly_the_rows_painted_yellow() {
907        let lines = "%1\ta:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tbehind\n\
908                     %2\tb:1.1\t/home/p\tclaude\t2.1.243\trun\t-\tcurrent\n\
909                     %3\tc:1.1\t/home/p\tgemini\t0.41.2\tinput\t-\tanother agent\n\
910                     ha:%4\td:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tover there";
911        let r = build(
912            lines,
913            &Input {
914                newver: "2.1.243",
915                width: 100,
916                outdated: true,
917                ..Default::default()
918            },
919        );
920        let ids: Vec<&str> = r.iter().map(|r| r.pane_id.as_str()).collect();
921        assert_eq!(ids, ["%1"]);
922        assert!(r[0].to_ansi().contains("\x1b[33m2.1.229"));
923
924        // …and the same list with nothing installed to compare against is
925        // empty rather than everything: the mode is skipped there.
926        let none = build(
927            lines,
928            &Input {
929                width: 100,
930                outdated: true,
931                ..Default::default()
932            },
933        );
934        assert!(none.is_empty());
935    }
936
937    /// Being behind is not a state, so the list crosses all four of them: a
938    /// session waiting for an answer is as behind as an idle one.
939    #[test]
940    fn the_outdated_list_is_not_one_state() {
941        let lines = "%1\ta:1.1\t/home/p\tclaude\t1.0\tinput\t-\tasking\n\
942                     %2\tb:1.1\t/home/p\tclaude\t1.0\trun\t-\tworking\n\
943                     %3\tc:1.1\t/home/p\tclaude\t1.0\tidle\t-\tidle";
944        let r = build(
945            lines,
946            &Input {
947                newver: "2.0",
948                width: 100,
949                outdated: true,
950                ..Default::default()
951            },
952        );
953        assert_eq!(r.len(), 3);
954    }
955
956    #[test]
957    fn one_state_only_when_asked() {
958        let r = build(
959            THREE,
960            &Input {
961                only: "run",
962                width: 100,
963                ..Default::default()
964            },
965        );
966        assert_eq!(r.len(), 3);
967        let r = build(
968            THREE,
969            &Input {
970                only: "input",
971                width: 100,
972                ..Default::default()
973            },
974        );
975        assert!(r.is_empty());
976    }
977
978    #[test]
979    fn a_blank_pane_title_borrows_the_one_its_conversation_recorded() {
980        let mut ptitles = HashMap::new();
981        ptitles.insert("%1".to_string(), "what it called itself".to_string());
982        let r = build(
983            "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\t",
984            &Input {
985                width: 100,
986                ptitles,
987                ..Default::default()
988            },
989        );
990        assert!(r[0].plain().contains("what it called itself"));
991    }
992
993    /// The snippet takes the path column, but only on a row that does not already
994    /// show what was typed: there is nothing to explain then, and the path is
995    /// worth more.
996    #[test]
997    fn a_search_snippet_replaces_the_tail_unless_the_row_already_says_it() {
998        let mut snips = HashMap::new();
999        snips.insert("%10".to_string(), "…the words it said…".to_string());
1000        snips.insert("%11".to_string(), "…other words…".to_string());
1001        let r = build(
1002            THREE,
1003            &Input {
1004                cur: "%11",
1005                width: 100,
1006                home: "/home/p",
1007                query: "refactor",
1008                snips,
1009                ..Default::default()
1010            },
1011        );
1012        // %10's summary is "Refactor auth", so it already says it
1013        assert!(r[0].plain().contains("proj/web"));
1014        assert!(!r[0].plain().contains('⌕'));
1015        // %11's does not
1016        assert!(r[1].plain().contains("⌕ …other words…"));
1017    }
1018
1019    #[test]
1020    fn says_it_is_smart_case_like_fzf() {
1021        let lower = vec!["refactor".to_string()];
1022        assert!(says_it("Refactor auth", &lower, true));
1023        let upper = vec!["Refactor".to_string()];
1024        assert!(!says_it("refactor auth", &upper, false));
1025    }
1026}