Skip to main content

taimux_cli/
tui.rs

1//! The picker, drawn here instead of by fzf.
2//!
3//! Step 1 answered the questions this depends on, inside a real `tmux
4//! display-popup -E`, and the answers are worth keeping written down:
5//!
6//! - **The alternate screen nests inside a popup** and unwinds cleanly. That was
7//!   the one genuine unknown, since fzf runs `--height=100%` here and so says
8//!   nothing about it.
9//! - **Bracketed paste arrives as `Event::Paste`**, one event carrying its own
10//!   text, embedded line break included. This is the structural fix for the bug
11//!   that put `~/.tmux.conf.local` into live agent sessions: fzf reads a pasted
12//!   line break as Enter, and every guard against that is a heuristic. Here there
13//!   is nothing left to defeat. A pasted line break arrives as CR, not LF.
14//! - **Resize is an event**, not a reload.
15//! - **The window is sized by the POPUP**, 126x34 inside an 80% popup of a 160x45
16//!   terminal, so the rows are fitted to what they are actually drawn in rather
17//!   than to `tput cols` less a guess at fzf's chrome.
18//!
19//! Drawing goes to `/dev/tty` and input comes from there too (crossterm's
20//! use-dev-tty), which leaves stdout carrying exactly one line, the chosen pane
21//! id. atuin swaps file descriptors in its shell widget to get the same effect.
22//!
23//! Owning the state is most of what this buys. fzf has no state store, so the
24//! bash picker keeps its mode in the BORDER LABEL and reads it back out by
25//! matching words in it, carries the mode and the search flag through every
26//! reload as quoted arguments because a child spawned by a reload cannot be
27//! relied on to see the new label yet, and needs `--track --id-nth=2` so a reload
28//! does not drop the cursor. All of that is a field here.
29
30use std::collections::{HashMap, HashSet};
31use std::fs::{File, OpenOptions};
32use std::io::Write;
33use std::process::Command;
34use std::sync::mpsc::{Receiver, TryRecvError};
35use std::sync::Arc;
36use std::time::{Duration, Instant};
37
38use crossterm::event::{
39    self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
40    Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent,
41    MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
42};
43use crossterm::{execute, terminal};
44use fuzzy_matcher::skim::SkimMatcherV2;
45use fuzzy_matcher::FuzzyMatcher;
46use ratatui::backend::CrosstermBackend;
47use ratatui::layout::{Constraint, Layout};
48use ratatui::style::{Color, Modifier, Style};
49use ratatui::text::{Line, Span};
50use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
51use ratatui::Terminal;
52
53use crate::{ansi, rows};
54use taimux_core::{env, index};
55
56/// How close two clicks on one row have to be to read as a double-click, which
57/// is what accepts it. Long enough to be reachable without hurrying, short
58/// enough that two deliberate single clicks on the same row do not switch panes
59/// by accident. Claude Code's own stray-click guard sits in the same range.
60const DOUBLE_CLICK: Duration = Duration::from_millis(400);
61
62/// Which list is on screen. Tab steps round the cycle.
63///
64/// The first four are the same question asked of the same list, and the last two
65/// are not states at all: Outdated asks a different question of it (what is this
66/// session RUNNING, rather than what is it doing), and Dead changes what the list
67/// IS. So they sit at the far end, in that order, rather than between two states
68/// of a running session.
69#[derive(Clone, Copy, PartialEq, Eq, Debug)]
70pub enum Mode {
71    All,
72    Input,
73    Run,
74    Idle,
75    Outdated,
76    Dead,
77}
78
79impl Mode {
80    /// The state filter this mode passes to the layout; empty means every row.
81    ///
82    /// Outdated is empty because being behind is not a state: a session waiting,
83    /// working or idle can each be running code a self-update has replaced, and
84    /// that filter rides on `Input::outdated` instead.
85    fn filter(self) -> &'static str {
86        match self {
87            Mode::All => "",
88            Mode::Input => "input",
89            Mode::Run => "run",
90            Mode::Idle => "idle",
91            Mode::Outdated => "",
92            Mode::Dead => "dead",
93        }
94    }
95
96    fn label(self) -> &'static str {
97        match self {
98            Mode::All => "agent sessions",
99            Mode::Input => "waiting for an answer",
100            Mode::Run => "working",
101            Mode::Idle => "idle at the prompt",
102            Mode::Outdated => "running outdated code",
103            Mode::Dead => "past sessions",
104        }
105    }
106
107    /// The name this mode is carried across a reopen by. Its own word rather
108    /// than the state filter, since Outdated and All share that.
109    pub fn key(self) -> &'static str {
110        match self {
111            Mode::All => "all",
112            Mode::Input => "input",
113            Mode::Run => "run",
114            Mode::Idle => "idle",
115            Mode::Outdated => "outdated",
116            Mode::Dead => "dead",
117        }
118    }
119
120    pub fn from_key(k: &str) -> Mode {
121        match k {
122            "input" => Mode::Input,
123            "run" => Mode::Run,
124            "idle" => Mode::Idle,
125            "outdated" => Mode::Outdated,
126            "dead" => Mode::Dead,
127            _ => Mode::All,
128        }
129    }
130
131    /// One step round: all, waiting, working, idle, outdated, ended, all.
132    ///
133    /// A stop with nothing that could ever be in it is left OUT of the cycle
134    /// rather than reached and found empty: no cache of past sessions, no past
135    /// stop, and nothing installed to compare a version against, no outdated
136    /// stop. An empty list you can still land on is one you have to press Tab
137    /// past every time round.
138    fn next(self, ended: bool, outdated: bool) -> Mode {
139        let cycle = [
140            (Mode::All, true),
141            (Mode::Input, true),
142            (Mode::Run, true),
143            (Mode::Idle, true),
144            (Mode::Outdated, outdated),
145            (Mode::Dead, ended),
146        ];
147        let at = cycle.iter().position(|(m, _)| *m == self).unwrap_or(0);
148        cycle
149            .iter()
150            .cycle()
151            .skip(at + 1)
152            .take(cycle.len())
153            .find(|(_, on)| *on)
154            .map(|(m, _)| *m)
155            .unwrap_or(Mode::All)
156    }
157}
158
159/// Where rows come from, and what does not change while the picker is open.
160///
161/// `fetch` is a closure rather than a string so ctrl-r and the refresh timer can
162/// ask again. `ended` is separate because the ended list is not the pane list
163/// filtered: it comes off the sessions cache and nothing in it has a pane at all.
164pub struct Source {
165    /// Shared and thread-safe because a refresh runs OFF the input loop: see
166    /// `start_refresh`. It was a plain closure until a sweep froze the picker
167    /// for the 85 seconds its restarts took, with no key accepted and nothing
168    /// on screen to say why.
169    pub fetch: Arc<dyn Fn() -> String + Send + Sync>,
170    /// The ended list stays synchronous: it is a read of one cache file, with no
171    /// fork in it, and it is what Tab's last stop shows the instant you land on
172    /// it. Nothing here has ever been slow, and making it async would mean
173    /// showing pane rows under the "past sessions" label while it arrived.
174    pub ended: Option<Box<dyn Fn() -> String>>,
175    pub cur: String,
176    pub home: String,
177    pub newver: String,
178    /// The taimux script, for the two keys that act rather than navigate. Unset
179    /// means they are not bound, and the header then does not advertise them:
180    /// the header only ever says what is really there.
181    pub script: Option<String>,
182    /// Set only when the binding said so with `-e TAIMUX_POPUP=1`. It is what
183    /// allows the picker to close and reopen itself at a new size, which would
184    /// be wrong for a picker running inline in a pane: tmux resizes a PANE with
185    /// the client already, so there is nothing to do there and everything to
186    /// lose by guessing.
187    pub popup: bool,
188    /// What a previous instance was doing when the terminal grew under it.
189    pub state: State,
190}
191
192/// Throw away what ratatui thinks is on the terminal, so the next draw repaints
193/// in full.
194///
195/// `resize` and NOT `Terminal::clear`, which is the obvious call and is a trap
196/// here: clear snapshots the cursor first, and the crossterm backend does that
197/// with `crossterm::cursor::position()`, which writes ESC[6n to the PROCESS's
198/// stdout rather than to the backend's writer. Stdout carries exactly one thing
199/// in this program, the chosen pane id, so anything using clear puts `[6n` where
200/// the caller reads the answer.
201///
202/// This was fixed once for ctrl-l and left in place for the two keys that hand
203/// the terminal to a child, which need it MORE: they always repaint, so they
204/// always leaked, and the list came back blank after every restart.
205fn repaint<B: ratatui::backend::Backend>(term: &mut Terminal<B>) {
206    if let Ok(size) = term.size() {
207        let _ = term.resize(size.into());
208    }
209}
210
211/// Run one of the two keys that act, with the terminal handed over.
212///
213/// Its output goes to **/dev/tty**, not to the picker's stdout. Inherited, the
214/// child's whole screen ends up in the one thing this program writes to stdout,
215/// the chosen pane id: measured, the caller of `taimux tui` got the sweep's
216/// plan, its prompt and its closing message, and then the pane id on the end.
217/// In a popup stdout happens to BE the tty, which is why it looked right there
218/// and was wrong everywhere else.
219/// Run a child that owns the terminal while it runs.
220///
221/// All THREE streams are pointed at the terminal, stdin included. The picker's
222/// own stdin is not the terminal (its stdout carries the chosen pane id, and it
223/// draws to /dev/tty for exactly that reason), so a child left to inherit it
224/// gets a stdin that is not where the person is typing, while its output goes
225/// somewhere else entirely. The child then reads its own /dev/tty to get around
226/// that, which works but means the parent hands over a terminal it has only
227/// half set up.
228fn act_child(script: &str, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
229    let mut c = Command::new(script);
230    c.args(args);
231    if let Ok(tty) = OpenOptions::new().write(true).open("/dev/tty") {
232        if let Ok(err) = tty.try_clone() {
233            c.stdout(tty).stderr(err);
234        }
235    }
236    if let Ok(inp) = OpenOptions::new().read(true).open("/dev/tty") {
237        c.stdin(inp);
238    }
239    c.status()
240}
241
242/// Raw mode, the alternate screen and bracketed paste, undone on the way out.
243///
244/// A guard rather than a pair of calls because every early return, `?` and panic
245/// has to restore the terminal: the failure mode is a shell left in raw mode with
246/// no echo, which is indistinguishable from a hung machine to whoever is looking
247/// at it. This is the part bash could never do properly, since a trap does not
248/// survive a kill.
249struct Guard {
250    out: File,
251    kitty: bool,
252    mouse: bool,
253}
254
255impl Guard {
256    fn new(kitty: bool) -> std::io::Result<Guard> {
257        let mut out = OpenOptions::new().write(true).open("/dev/tty")?;
258        terminal::enable_raw_mode()?;
259        // Mouse capture goes everywhere bracketed paste goes, including the
260        // suspend/resume pair below, or handing the terminal to a child would
261        // leave the picker with a dead wheel when it came back.
262        //
263        // fzf had this on by default and the port never asked for it, which is
264        // the same way Page Up and Page Down went missing: nothing referenced
265        // the behaviour, so nothing pointed at its absence. TAIMUX_MOUSE=0
266        // turns it off, for a terminal where capture costs more than it gives
267        // (it takes over drag-to-select, and tmux's own copy mode with it).
268        execute!(out, terminal::EnterAlternateScreen, EnableBracketedPaste)?;
269        let mouse = env::var("TAIMUX_MOUSE").is_none_or(|v| v != "0");
270        if mouse {
271            let _ = execute!(out, EnableMouseCapture);
272        }
273        if kitty {
274            // Makes a bare ESC arrive on its own rather than as the head of a
275            // possible chord. Only some terminals answer; the flags are harmless
276            // where they are ignored, and they also turn on key-release events,
277            // which is why the loop filters on KeyEventKind::Press.
278            let _ = execute!(
279                out,
280                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
281            );
282        }
283        Ok(Guard { out, kitty, mouse })
284    }
285
286    /// Hand the terminal back so a child can own it, as fzf's `execute()` does.
287    fn suspend(&mut self) {
288        if self.mouse {
289            let _ = execute!(self.out, DisableMouseCapture);
290        }
291        let _ = execute!(
292            self.out,
293            DisableBracketedPaste,
294            terminal::LeaveAlternateScreen
295        );
296        let _ = terminal::disable_raw_mode();
297    }
298
299    fn resume(&mut self) {
300        let _ = terminal::enable_raw_mode();
301        // Wipe what the child drew, BEFORE going back to the alternate screen.
302        //
303        // The child owned the NORMAL screen while it had the terminal, so its
304        // last frame is still sitting there under the picker. Leaving the
305        // alternate screen on the way out then reveals it, and what you get,
306        // seconds after picking a row, is the sweep's "restart every outdated
307        // session" screen back on your terminal as if it had run again.
308        // Reported that way, and it is only ever a leftover.
309        //
310        // Nothing of the caller's is lost: this runs only after a child that
311        // cleared the screen for itself.
312        let _ = execute!(
313            self.out,
314            terminal::Clear(terminal::ClearType::All),
315            crossterm::cursor::MoveTo(0, 0),
316            terminal::EnterAlternateScreen,
317            EnableBracketedPaste
318        );
319        if self.mouse {
320            let _ = execute!(self.out, EnableMouseCapture);
321        }
322    }
323}
324
325impl Drop for Guard {
326    fn drop(&mut self) {
327        if self.kitty {
328            let _ = execute!(self.out, PopKeyboardEnhancementFlags);
329        }
330        self.suspend();
331    }
332}
333
334/// The columns a row is laid out for: the drawn area less the border and less the
335/// two the pointer takes. fzf reserves the same two and reports the rest in
336/// FZF_COLUMNS, a figure that only exists once fzf is already up, which is why
337/// the bash picker has to guess a width for its first render.
338fn row_width(area_width: u16) -> usize {
339    (area_width as usize).saturating_sub(4)
340}
341
342/// Under this many characters a term is in every transcript and a match would
343/// say nothing, so a short query filters on the row alone.
344fn search_min() -> usize {
345    env::var("TAIMUX_SEARCH_MIN")
346        .and_then(|v| v.parse().ok())
347        .unwrap_or(3)
348}
349
350/// Text search is on unless it is turned off, the same knob the bash picker
351/// reads. Note that it still starts OFF at the ctrl-t toggle; this only says
352/// whether the key does anything.
353fn search_enabled() -> bool {
354    env::on("TAIMUX_SEARCH")
355}
356
357fn sessions_enabled() -> bool {
358    env::on("TAIMUX_SESSIONS")
359}
360
361/// Following the terminal is on unless it is turned off. `0` leaves a popup at
362/// whatever size it opened with, which is what every version before this did.
363fn resize_enabled() -> bool {
364    env::on("TAIMUX_RESIZE")
365}
366
367/// The ended-sessions list, or nothing where the sessions cache is turned off.
368/// `Source.ended` being None is what takes that mode out of the Tab cycle, so
369/// the decision is made once, here.
370pub fn ended_source() -> Option<Box<dyn Fn() -> String>> {
371    sessions_enabled().then(|| Box::new(|| index::dead_rows(now())) as Box<dyn Fn() -> String>)
372}
373
374fn now() -> i64 {
375    std::time::SystemTime::now()
376        .duration_since(std::time::UNIX_EPOCH)
377        .map(|d| d.as_secs() as i64)
378        .unwrap_or(0)
379}
380
381/// The rows the query keeps, best match first.
382///
383/// Terms are ANDed and their scores summed, which is fzf's extended-search
384/// default rather than one fuzzy match over the whole query. With no query the
385/// list keeps its own order; the sort is stable, so ties do too.
386///
387/// The haystack is the row's PLAIN text: fzf is handed `--ansi` and has to parse
388/// our own colours back out to match on them, which is work this does not do.
389fn filter(list: &[rows::Row], query: &str, matcher: &SkimMatcherV2) -> Vec<usize> {
390    let terms: Vec<&str> = query.split_whitespace().collect();
391    if terms.is_empty() {
392        return (0..list.len()).collect();
393    }
394    let mut scored: Vec<(i64, usize)> = Vec::new();
395    for (i, r) in list.iter().enumerate() {
396        let hay = r.plain();
397        let mut total = 0i64;
398        let mut all = true;
399        for t in &terms {
400            match matcher.fuzzy_match(&hay, t) {
401                Some(s) => total += s,
402                None => {
403                    all = false;
404                    break;
405                }
406            }
407        }
408        if all {
409            scored.push((total, i));
410        }
411    }
412    scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
413    scored.into_iter().map(|(_, i)| i).collect()
414}
415
416/// What the picker says it can do. Only what is really bound: a header promising
417/// a key that does nothing is worse than a shorter one.
418fn header(script: bool, ended: bool, search_key: bool, search_on: bool) -> String {
419    let mut h = String::from("enter: switch");
420    if ended {
421        h.push_str("/resume");
422    }
423    h.push_str("   tab: filter   ctrl-r: refresh   ctrl-/: preview");
424    if search_key {
425        h.push_str(if search_on {
426            "   ctrl-t: search text (on)"
427        } else {
428            "   ctrl-t: search text"
429        });
430    }
431    if script {
432        // "outdated" and not "stale", which it said until the list of those rows
433        // got a Tab stop of its own: two words for one thing on the same screen
434        // reads as two different things.
435        h.push_str("   ctrl-x: restart   ctrl-o: hand off   f8: restart all outdated");
436    }
437    h
438}
439
440/// The bottom-right stamp: which taimux drew this list.
441///
442/// Worth a permanent corner of the chrome because the answer is not obvious from
443/// anywhere else. The picker is a popup launched by a tmux binding, one binary
444/// per host, and a self-update swaps the launcher under a running tmux server
445/// without touching the panes: the same keypress can therefore draw a different
446/// version tomorrow, and until now nothing on screen said which. It is the crate
447/// version, the same string `taimux version` prints, so a row's `claude 2.1.229`
448/// and this cannot be confused for each other: this one is named.
449fn version_tag() -> String {
450    format!(" taimux {} ", env!("CARGO_PKG_VERSION"))
451}
452
453/// …but only where the bottom border can carry it AND the count.
454///
455/// ratatui gives a right-aligned title precedence over a left-aligned one, so
456/// without this the stamp eats the count on a narrow window: measured at 20
457/// columns it left ` 5/`, and at 16 the count was gone altogether. That is the
458/// priority backwards. The count is live and read constantly, the stamp is
459/// reference read once after an update, so the stamp is what gives way.
460///
461/// `+ 2` is the two corner characters the border spends whatever else happens.
462fn room_for_tag(width: u16, count: &str) -> bool {
463    width as usize >= count.chars().count() + version_tag().chars().count() + 2
464}
465
466/// What to say where the rows would be, when there are none.
467///
468/// Four different silences, and they mean different things: nothing running at
469/// all, nothing in the state you are filtering on, nothing matching what you
470/// typed, and no ended sessions recorded yet. Saying which is the whole point,
471/// since the picker used to say nothing and simply close.
472fn empty_note(
473    mode: Mode,
474    query: &str,
475    scanning: bool,
476    nothing_scanned: bool,
477    ended: bool,
478) -> Vec<Line<'static>> {
479    let mut lines: Vec<String> = Vec::new();
480    if scanning {
481        // The first scan is off the loop like every other, so this is what a
482        // popup shows for the ~90ms it usually takes, and what it keeps showing
483        // instead of going blank when something makes it slow.
484        lines.push("Looking for agent sessions…".into());
485        lines.push(String::new());
486        lines.push("Esc closes this.".into());
487        return lines
488            .into_iter()
489            .map(|l| Line::from(format!("  {}", l)))
490            .collect();
491    }
492    if !query.is_empty() {
493        lines.push(format!("Nothing matches {}", query));
494        lines.push("ctrl-u clears it.".into());
495    } else if mode == Mode::Dead {
496        lines.push("No past conversations have been found here yet.".into());
497        lines.push("They are remembered as sessions come and go.".into());
498    } else if nothing_scanned {
499        lines.push("No agent sessions on this machine.".into());
500        lines.push(
501            if ended {
502                "Nothing is running one. Tab reaches the conversations that ended."
503            } else {
504                "Nothing is running one."
505            }
506            .into(),
507        );
508    } else {
509        lines.push(format!("Nothing is {} right now.", mode.label()));
510        lines.push("Tab moves on to the next list.".into());
511    }
512    lines.push(String::new());
513    lines.push("Esc closes this.".into());
514    lines
515        .into_iter()
516        .map(|l| Line::from(format!("  {}", l)))
517        .collect()
518}
519
520/// The border label: which list, whether the timer and text search are on, and
521/// whether a refresh is taking long enough to be worth mentioning.
522fn label(mode: Mode, live: bool, search: bool, refreshing: bool) -> String {
523    let mut s = format!(" {}", mode.label());
524    if live {
525        s.push_str(" · live");
526    }
527    if search {
528        s.push_str(" · ⌕");
529    }
530    // Last, and only after a second: on a healthy machine the answer is back
531    // before the next draw, so a label that flashed on every tick would be noise
532    // about nothing. It is here for the case where the list is NOT arriving, so
533    // that a picker waiting on a slow scan reads as busy rather than as dead.
534    if refreshing {
535        s.push_str(" · refreshing");
536    }
537    s.push(' ');
538    s
539}
540
541/// A captured screen and when it was taken.
542///
543/// The preview is redrawn on every tick and every keypress, and capturing a pane
544/// per redraw would be a fork per keystroke. Cached by pane, so it costs one
545/// capture per row the cursor lands on, per TTL. Short on purpose: a preview is
546/// read to see what a session is doing NOW, and a stale screen is worse than a
547/// slow one.
548const PREVIEW_TTL: Duration = Duration::from_millis(750);
549
550/// …and longer for one that costs an ssh. The remote screen is no fresher for
551/// being asked more often, since the fetch itself is the slow part.
552const REMOTE_TTL: Duration = Duration::from_secs(3);
553
554struct App {
555    src: Source,
556    matcher: SkimMatcherV2,
557    mode: Mode,
558    /// Typing searches what sessions SAID, not only what their rows show. Off by
559    /// default, as in bash. The reason it HAD to be off is gone (a paste can no
560    /// longer be read as Enter, see the module comment), but the port does not
561    /// change behaviour; the rest of it lands in step 5.
562    search: bool,
563    preview: bool,
564    query: String,
565    width: usize,
566    tsv: String,
567    all: Vec<rows::Row>,
568    view: Vec<usize>,
569    sel: usize,
570    shot: Option<(String, Instant, String)>,
571    /// How far the preview is scrolled from its default view, in rows, negative
572    /// towards the start of the body.
573    poff: i32,
574    /// The row `poff` was measured against. Comparing it in `preview()` resets
575    /// the offset on every way the cursor can move (a key, the wheel, a click, a
576    /// rebuild, the refresh timer) from ONE place, rather than needing each of
577    /// those to remember to do it. An offset carried onto another row is a lie:
578    /// it was measured against a different session's screen.
579    poff_for: String,
580    /// A refresh running on a worker thread, and when it started.
581    ///
582    /// The picker used to call the row source straight from the input loop, so
583    /// for as long as that took there was no draw and no key: a refresh that
584    /// normally costs 90ms froze the whole picker for 85 SECONDS after an F8
585    /// sweep, showing the sweep's last screen the entire time, with Esc, ctrl-c
586    /// and even tmux's own F1 all apparently dead. Nothing about that told its
587    /// owner it was alive.
588    ///
589    /// One at a time: the timer must not stack refreshes on a machine where they
590    /// take longer than the interval, which is exactly the machine this matters
591    /// on.
592    pending: Option<Receiver<Refresh>>,
593    pending_since: Instant,
594    /// The client as of the last refresh, for the resize check.
595    client: Option<(String, (u16, u16))>,
596    /// Panes with a restart in flight: when it was fired, the row the pane had
597    /// at the time, and where that row sat in the list.
598    ///
599    /// A restart is detached and takes seconds: it asks the session to exit,
600    /// waits, and starts a new one. For that whole window the pane has no agent
601    /// in its foreground group, so the scan does not see it and the row simply
602    /// VANISHES from under the cursor, which then falls back to the top of the
603    /// list. You press ctrl-x on a session and lose both the row and your place.
604    /// So the row is held: reinserted where it was, with the marker column saying
605    /// what is happening, until the session comes back or the hold runs out.
606    /// …and whether the pane has been observed GONE yet, which is what makes
607    /// "it is in the scan again" mean the session came back rather than the
608    /// restart not having happened yet.
609    restarting: HashMap<String, (Instant, String, usize, bool)>,
610}
611
612/// How long a restarting row is held.
613///
614/// `restart` waits up to 12s for a session to exit and then polls up to 20s for
615/// it to come back, so anything shorter than that drops the row exactly when its
616/// owner is watching to see whether it worked. The hold is a backstop, not the
617/// normal path: a row stops being held the moment the pane is scanned again.
618const RESTART_HOLD: Duration = Duration::from_secs(40);
619
620impl App {
621    /// The preview for the row under the cursor, in two parts: a header saying
622    /// exactly where the session is, and the body to show under it.
623    ///
624    /// Split rather than concatenated so the header can be PINNED while the body
625    /// scrolls. The third value says where the body's default view sits: a live
626    /// pane is anchored at the BOTTOM, because what a session is doing is the
627    /// last thing on its screen, while an ended conversation reads from the top.
628    /// One offset then means the same thing for both, "rows towards the start",
629    /// and the clamp does the rest.
630    ///
631    /// The header comes off the row rather than out of a `tmux display-message`,
632    /// which is a fork the bash preview pays every time the cursor moves.
633    fn preview(&mut self) -> (Vec<Line<'static>>, Vec<Line<'static>>, bool) {
634        let Some(r) = self.view.get(self.sel).map(|&i| &self.all[i]) else {
635            return (Vec::new(), Vec::new(), false);
636        };
637        let (id, target, cwd, host) = (
638            r.pane_id.clone(),
639            r.target.clone(),
640            r.cwd.clone(),
641            r.host.clone(),
642        );
643        if self.poff_for != id {
644            self.poff = 0;
645            self.poff_for = id.clone();
646        }
647        // Where the words you typed turn up in what this session actually SAID.
648        // The row has room for one window of context; this has room for several,
649        // so the preview is where you find out whether the hit is the one you
650        // were after before jumping to it.
651        let mut out: Vec<Line<'static>> = Vec::new();
652        let mut body: Vec<Line<'static>> = Vec::new();
653        if self.search && self.query.chars().count() >= search_min() {
654            let hits = index::preview_match(
655                &id,
656                &index::Query::new(&self.query),
657                env::var("TAIMUX_SEARCH_PREVIEW")
658                    .and_then(|v| v.parse().ok())
659                    .unwrap_or(4),
660            );
661            for h in hits {
662                out.push(Line::from(vec![
663                    Span::styled("⌕ ", Style::default().fg(Color::Yellow)),
664                    Span::raw(h),
665                ]));
666            }
667            if !out.is_empty() {
668                out.push(Line::from(""));
669            }
670        }
671        out.push(Line::from(vec![
672            Span::styled(
673                target,
674                Style::default()
675                    .fg(Color::Cyan)
676                    .add_modifier(Modifier::BOLD),
677            ),
678            Span::raw("   "),
679            Span::styled(cwd, Style::default().add_modifier(Modifier::DIM)),
680        ]));
681        out.push(Line::from(Span::styled(
682            "─".repeat(44),
683            Style::default().fg(Color::DarkGray),
684        )));
685        out.push(Line::from(""));
686
687        // A past conversation has no screen to capture: what it has is the
688        // last things that were said in it.
689        if id.starts_with("dead:") {
690            if id == "dead:!" {
691                body.push(Line::from(Span::styled(
692                    "the list is still being built",
693                    Style::default().fg(Color::DarkGray),
694                )));
695                return (out, body, false);
696            }
697            let Some((agent, key)) = taimux_core::index::split_past_id(&id) else {
698                body.push(Line::from(Span::styled(
699                    "that row does not name a conversation",
700                    Style::default().fg(Color::Red),
701                )));
702                return (out, body, false);
703            };
704            // A conversation kept in a database has no file to be missing, and
705            // its store answered when the list was built.
706            if key.starts_with('/') && !std::path::Path::new(key).is_file() {
707                body.push(Line::from(Span::styled(
708                    "this conversation is no longer on disk",
709                    Style::default().fg(Color::Red),
710                )));
711                return (out, body, false);
712            }
713            let want = env::var("TAIMUX_DEAD_TURNS")
714                .and_then(|v| v.parse().ok())
715                .unwrap_or(6);
716            let turns = taimux_core::agents::turns(agent, key, want);
717            if turns.is_empty() {
718                body.push(Line::from(Span::styled(
719                    "(nothing was said in this one)",
720                    Style::default().fg(Color::DarkGray),
721                )));
722            }
723            for t in turns {
724                // Two lines a turn is enough to recognise one, and the preview
725                // pane is short.
726                let cap = self.width.max(20) * 2;
727                let what = if t.text.chars().count() > cap {
728                    format!("{}…", t.text.chars().take(cap).collect::<String>())
729                } else {
730                    t.text
731                };
732                let (mark, st) = if t.you {
733                    (
734                        "❯ ",
735                        Style::default()
736                            .fg(Color::Cyan)
737                            .add_modifier(Modifier::BOLD),
738                    )
739                } else {
740                    ("  ", Style::default().add_modifier(Modifier::DIM))
741                };
742                body.push(Line::from(vec![
743                    Span::styled(mark, st),
744                    Span::styled(what, st),
745                ]));
746                body.push(Line::from(""));
747            }
748            // From the top: a conversation reads forwards, and its opening is
749            // already on the row as the title, so what you want first is what
750            // came after it.
751            return (out, body, false);
752        }
753        // capture-pane only works where the pane IS, so a session on another host
754        // renders its own. That is an ssh, and the script already knows how to
755        // make it: which taimux is over there, the bound it runs under, and what
756        // to say when a host stops answering between the list and the cursor
757        // landing on its row. Shelling out to it is one fork per cursor landing,
758        // which is what fzf's preview cost anyway, and it beats keeping a second
759        // copy of that knowledge here.
760        if !host.is_empty() {
761            let Some(script) = self.src.script.clone() else {
762                body.push(Line::from(Span::styled(
763                    format!("on {}: no taimux to ask", host),
764                    Style::default().fg(Color::DarkGray),
765                )));
766                return (out, body, false);
767            };
768            let fresh = matches!(&self.shot, Some((k, at, _))
769                                 if *k == id && at.elapsed() < REMOTE_TTL);
770            if !fresh {
771                let text = Command::new(&script)
772                    .args(["preview", &id])
773                    .output()
774                    .ok()
775                    .filter(|o| o.status.success())
776                    .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
777                    .unwrap_or_default();
778                self.shot = Some((id.clone(), Instant::now(), text));
779            }
780            let text = self
781                .shot
782                .as_ref()
783                .map(|(_, _, s)| s.clone())
784                .unwrap_or_default();
785            body.extend(tail(&text, usize::MAX));
786            return (out, body, true);
787        }
788        let fresh = matches!(&self.shot, Some((k, at, _))
789                             if *k == id && at.elapsed() < PREVIEW_TTL);
790        if !fresh {
791            self.shot = Some((
792                id.clone(),
793                Instant::now(),
794                taimux_core::tmux::capture_coloured(&id).unwrap_or_default(),
795            ));
796        }
797        let screen = self
798            .shot
799            .as_ref()
800            .map(|(_, _, s)| s.clone())
801            .unwrap_or_default();
802        // The WHOLE screen, and the renderer takes the last screenful of it, so
803        // there is something above the default view to scroll into. The padding
804        // to the pane height is trimmed here either way, or the tail would be
805        // all padding: the same trap that made every waiting session read as
806        // idle when the state reader was ported.
807        body.extend(tail(&screen, usize::MAX));
808        // Anchored at the bottom: what a session is doing is the last thing on
809        // its screen.
810        (out, body, true)
811    }
812}
813
814/// The last `room` lines of a captured screen, which is where what a session is
815/// doing lives.
816///
817/// The trailing blanks come off first. `capture-pane` pads its output to the pane
818/// height, so a tail taken without trimming is all padding: that is the exact
819/// trap that made every session waiting for an answer read as idle when the state
820/// reader was ported, and it is the same capture being read here.
821fn tail(screen: &str, room: usize) -> Vec<Line<'static>> {
822    let mut lines = ansi::to_lines(screen);
823    while lines
824        .last()
825        .is_some_and(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
826    {
827        lines.pop();
828    }
829    let over = lines.len().saturating_sub(room);
830    lines.drain(..over);
831    lines
832}
833
834/// What a refresh brings back: the rows, how long they took, and what the time
835/// went on.
836struct Refresh {
837    tsv: String,
838    took: Duration,
839    /// Empty unless something forked; see `stat`.
840    spent: String,
841    /// The client this popup is on, asked for only when we are in a popup that
842    /// may reopen itself, and only when tmux can name it without guessing. It
843    /// rides along with the refresh because that already runs off the input
844    /// loop: a resize check of its own would be another fork on it.
845    client: Option<(String, (u16, u16))>,
846}
847
848/// Our own session, out of `$TMUX`: socket, server pid, session id.
849fn own_session() -> Option<String> {
850    let tmux = std::env::var("TMUX").ok()?;
851    let id = tmux.split(',').nth(2)?.trim();
852    (!id.is_empty()).then(|| format!("${}", id))
853}
854
855/// The client this popup is drawn on, and its size, or None when that cannot be
856/// answered without guessing.
857///
858/// **Asking tmux for `#{client_width}` with no target is the bug this exists to
859/// avoid.** An untargeted query answers for whichever client tmux considers
860/// current, and Patrick routinely has two attached to one session: a 213-column
861/// desktop and a 46-column phone. A picker opened on the PHONE then measured
862/// itself against the desktop, decided a 44-column popup had been outgrown,
863/// closed itself, and reopened on the desktop over whatever pane was there. That
864/// is what "stuck after selecting another session" turned out to be: a popup
865/// arriving unbidden on the other client.
866///
867/// So the client has to be unambiguous. One client on our session is our client.
868/// Two, and nothing here can tell which of them the popup belongs to (tmux
869/// exposes no format for it, and `display-popup -e` does not expand formats, so
870/// the binding cannot pass it either), which is exactly when this must do
871/// nothing at all.
872fn own_client() -> Option<(String, (u16, u16))> {
873    let session = own_session()?;
874    let out = taimux_core::tmux::ask(&[
875        "list-clients",
876        "-t",
877        &session,
878        "-F",
879        "#{client_tty} #{client_width} #{client_height}",
880    ])?;
881    let mut lines = out.lines().filter(|l| !l.trim().is_empty());
882    let only = lines.next()?;
883    if lines.next().is_some() {
884        return None; // more than one client: whose popup is this?
885    }
886    let mut f = only.split_whitespace();
887    let (tty, w, h) = (f.next()?, f.next()?.parse().ok()?, f.next()?.parse().ok()?);
888    (w > 0 && h > 0).then(|| (tty.to_string(), (w, h)))
889}
890
891/// Could this popup usefully be bigger than it is?
892///
893/// tmux SHRINKS a popup to fit a client that got smaller and grows it back up to
894/// the size it was asked for, so the only case left over is a terminal that grew
895/// PAST that: a popup opened on a phone in portrait stays portrait-width after
896/// the rotation, at 63% of a screen it was told to take 80% of. Measured, both
897/// directions, before any of this was written.
898///
899/// `slack` is what keeps it from firing on a rounding difference of a column or
900/// two, which would close and reopen the popup for nothing.
901fn outgrown(ours: (u16, u16), client: (u16, u16), slack: u16) -> bool {
902    let (pw, ph) = crate::install::popup_geometry(client.0 as usize);
903    // A popup's usable area is its geometry less the border it draws.
904    let want_w = (client.0 as u32 * pw as u32 / 100).saturating_sub(2) as u16;
905    let want_h = (client.1 as u32 * ph as u32 / 100).saturating_sub(2) as u16;
906    want_w > ours.0.saturating_add(slack) || want_h > ours.1.saturating_add(slack)
907}
908
909/// Everything the picker has to carry across a reopen, so a resize costs you
910/// your popup's geometry and nothing else.
911///
912/// Its Default is an ORDINARY open, not an empty struct: `preview` is on unless
913/// something turned it off, and deriving Default silently opened every picker
914/// with the preview hidden, which showed up as the list being twice as tall as
915/// the page keys expected.
916#[derive(Debug, PartialEq, Eq)]
917pub struct State {
918    pub query: String,
919    pub mode: &'static str,
920    pub search: bool,
921    pub preview: bool,
922    /// The row the cursor was on, by pane id.
923    pub on: String,
924    /// The client whose popup this was, so the reopen goes to THAT one rather
925    /// than to whichever tmux considers current a moment later.
926    pub client: String,
927}
928
929impl Default for State {
930    fn default() -> Self {
931        State {
932            query: String::new(),
933            mode: "all",
934            search: false,
935            preview: true,
936            on: String::new(),
937            client: String::new(),
938        }
939    }
940}
941
942/// How the picker finished.
943pub enum Outcome {
944    Chosen(String),
945    Aborted,
946    /// The terminal grew: reopen at the geometry the binding would use now,
947    /// with this state. Only ever returned from a popup that was told it is one.
948    Resize(State),
949}
950
951/// How slow a refresh has to be before it is written down.
952///
953/// Two seconds is well past anything healthy here (a full scan of 171 panes is
954/// 90ms) and well short of the freeze that prompted this, so the log stays empty
955/// on a normal day and names the culprit on a bad one.
956fn slow_after() -> Duration {
957    Duration::from_secs_f32(
958        env::var("TAIMUX_SLOW_REFRESH")
959            .and_then(|v| v.parse().ok())
960            .unwrap_or(2.0),
961    )
962}
963
964/// A refresh that took too long, written where the sweep already sends you.
965///
966/// Appended rather than printed: the picker owns the screen, and the whole point
967/// is that this happens while nobody can see anything.
968fn log_slow(r: &Refresh) {
969    let line = format!(
970        "--- {} picker refresh took {:.1}s{}{}\n",
971        taimux_core::log::stamp(),
972        r.took.as_secs_f32(),
973        if r.spent.is_empty() { "" } else { ": " },
974        r.spent
975    );
976    let path = taimux_core::paths::runtime_dir().join("restart.log");
977    if let Some(d) = path.parent() {
978        let _ = std::fs::create_dir_all(d);
979    }
980    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
981        let _ = f.write_all(line.as_bytes());
982    }
983}
984
985impl App {
986    /// Rows now, on this thread. Startup and the ended list only: everything the
987    /// loop does goes through `start_refresh` instead.
988    fn fetch(&mut self) {
989        self.tsv = match self.mode {
990            Mode::Dead => self.src.ended.as_ref().map(|f| f()).unwrap_or_default(),
991            _ => (self.src.fetch)(),
992        };
993        self.hold_restarting();
994    }
995
996    /// Ask for rows on a worker thread, leaving the loop free to draw and to
997    /// read keys while the answer is on its way.
998    ///
999    /// The ended list is fetched inline, since it is a cache read with no fork in
1000    /// it and swapping it in late would mean drawing pane rows under the "ended
1001    /// sessions" label.
1002    fn start_refresh(&mut self) {
1003        if self.mode == Mode::Dead {
1004            self.fetch();
1005            self.rebuild();
1006            return;
1007        }
1008        if self.pending.is_some() {
1009            return;
1010        }
1011        let f = self.src.fetch.clone();
1012        let watch = self.src.popup;
1013        let (tx, rx) = std::sync::mpsc::channel();
1014        std::thread::spawn(move || {
1015            taimux_core::stat::reset();
1016            let at = Instant::now();
1017            let tsv = f();
1018            let _ = tx.send(Refresh {
1019                tsv,
1020                took: at.elapsed(),
1021                spent: taimux_core::stat::report(),
1022                client: watch.then(own_client).flatten(),
1023            });
1024        });
1025        self.pending = Some(rx);
1026        self.pending_since = Instant::now();
1027    }
1028
1029    /// Take a refresh that has landed. True when the list changed, which is what
1030    /// tells the loop to rebuild.
1031    ///
1032    /// A thread that died without sending (a panic in the row source) drops the
1033    /// sender, and that arrives here as Disconnected: the refresh is simply
1034    /// forgotten and the next tick tries again, rather than the picker waiting
1035    /// on it forever.
1036    fn take_refresh(&mut self) -> bool {
1037        let Some(rx) = &self.pending else {
1038            return false;
1039        };
1040        match rx.try_recv() {
1041            Ok(r) => {
1042                if r.took >= slow_after() {
1043                    log_slow(&r);
1044                }
1045                self.client = r.client;
1046                self.tsv = r.tsv;
1047                self.hold_restarting();
1048                self.pending = None;
1049                true
1050            }
1051            Err(TryRecvError::Empty) => false,
1052            Err(TryRecvError::Disconnected) => {
1053                self.pending = None;
1054                false
1055            }
1056        }
1057    }
1058
1059    /// Has a refresh been out long enough to be worth saying so on the border?
1060    ///
1061    /// Not from the first millisecond: every tick would flicker the label on a
1062    /// healthy machine, where the answer is back before the next draw.
1063    fn refreshing(&self) -> bool {
1064        self.pending.is_some() && self.pending_since.elapsed() > Duration::from_secs(1)
1065    }
1066
1067    /// Put back the rows of panes whose restart is still in flight.
1068    ///
1069    /// Reinserted at the index each one had rather than appended, because the
1070    /// list is otherwise unchanged and appending would move the row to the bottom
1071    /// just as its owner is watching it. Holding stops as soon as the pane is
1072    /// scanned again, which is the session coming back, or after RESTART_HOLD,
1073    /// which is the restart having failed. Either way the row stops lying.
1074    fn hold_restarting(&mut self) {
1075        if self.restarting.is_empty() {
1076            return;
1077        }
1078        let present: HashSet<String> = self
1079            .tsv
1080            .lines()
1081            .filter_map(|l| l.split('\t').next())
1082            .map(str::to_string)
1083            .collect();
1084        let now = Instant::now();
1085        self.restarting.retain(|id, (at, _, _, seen_gone)| {
1086            // The timeout is the backstop either way: a restart that never
1087            // took effect must not hold a row for ever.
1088            if now.duration_since(*at) >= RESTART_HOLD {
1089                return false;
1090            }
1091            if present.contains(id) {
1092                // Being in the scan only means "the session came back" if it
1093                // was ever seen to LEAVE. Before that it means the restart has
1094                // simply not taken effect yet, and treating the two the same is
1095                // what dropped the hold on the very first refresh after ctrl-x:
1096                // the agent had not exited yet, so the row was released, and
1097                // when it did exit a moment later there was nothing holding it.
1098                // The row vanished from under the cursor, which fell to the top.
1099                !*seen_gone
1100            } else {
1101                *seen_gone = true;
1102                true
1103            }
1104        });
1105        if self.restarting.is_empty() {
1106            return;
1107        }
1108        // Ascending, so each index still means the position it meant when the
1109        // row was taken out.
1110        // Only the ones actually MISSING are put back. An entry still held
1111        // because its pane has not gone yet is already in the list, and
1112        // reinserting it would show the row twice.
1113        let mut held: Vec<(usize, String)> = self
1114            .restarting
1115            .iter()
1116            .filter(|(id, _)| !present.contains(*id))
1117            .map(|(_, (_, line, idx, _))| (*idx, line.clone()))
1118            .collect();
1119        held.sort_by_key(|(idx, _)| *idx);
1120        let mut lines: Vec<String> = self.tsv.lines().map(str::to_string).collect();
1121        for (idx, line) in held {
1122            let at = idx.min(lines.len());
1123            lines.insert(at, line);
1124        }
1125        self.tsv = lines.join("\n");
1126        self.tsv.push('\n');
1127    }
1128
1129    /// Start holding a pane's row, before the restart takes its session away.
1130    ///
1131    /// Called BEFORE the restart is fired, because afterwards the row it needs to
1132    /// remember may already be gone.
1133    fn hold(&mut self, id: &str) {
1134        if let Some((idx, line)) = self
1135            .tsv
1136            .lines()
1137            .enumerate()
1138            .find(|(_, l)| l.split('\t').next() == Some(id))
1139        {
1140            self.restarting.insert(
1141                id.to_string(),
1142                (Instant::now(), line.to_string(), idx, false),
1143            );
1144        }
1145    }
1146
1147    /// The snippets the query earns, or none.
1148    ///
1149    /// **The "at least TAIMUX_SEARCH_MIN characters" gate lives here, not in the
1150    /// layout**, exactly as it does in bash: under three characters a term is in
1151    /// every transcript and a match would say nothing. Handing the layout a
1152    /// snippet map for a one-letter query turns every row into a search hit.
1153    fn snippets(&self) -> HashMap<String, String> {
1154        if !self.search || self.query.chars().count() < search_min() {
1155            return HashMap::new();
1156        }
1157        index::snippets(&index::Query::new(&self.query))
1158    }
1159
1160    /// Re-lay the rows out and re-apply the query, putting the cursor back on the
1161    /// same SESSION rather than the same index. That is what `--track --id-nth=2`
1162    /// buys fzf, and owning the state makes it a lookup.
1163    fn rebuild(&mut self) {
1164        let on = self.selected().map(|r| r.pane_id.clone());
1165        // The ended list is a different list, not this one filtered, so its own
1166        // rows are already only ended ones and asking for the filter as well
1167        // would be asking twice.
1168        let only = if self.mode == Mode::Dead {
1169            ""
1170        } else {
1171            self.mode.filter()
1172        };
1173        self.all = rows::build(
1174            &self.tsv,
1175            &rows::Input {
1176                cur: &self.src.cur,
1177                width: self.width,
1178                home: &self.src.home,
1179                newver: &self.src.newver,
1180                only,
1181                // A row held through a restart keeps the version it had, so the
1182                // one you just pressed ctrl-x on stays in this list, marked ↻,
1183                // until it comes back on the installed one and drops out of it.
1184                outdated: self.mode == Mode::Outdated,
1185                query: &self.query,
1186                snips: self.snippets(),
1187                // A live pane can publish no title at all: claude sets one at a
1188                // turn boundary, so one restored by tmux-resurrect and not
1189                // prompted since has nothing there.
1190                ptitles: index::pane_titles(),
1191                restarting: self.restarting.keys().cloned().collect(),
1192            },
1193        );
1194        self.view = filter(&self.all, &self.query, &self.matcher);
1195        self.sel = on
1196            .and_then(|id| self.view.iter().position(|&i| self.all[i].pane_id == id))
1197            .unwrap_or(0);
1198        self.clamp();
1199    }
1200
1201    /// The query changed.
1202    ///
1203    /// With text search on this is a full rebuild, not just a re-filter: a row
1204    /// that is in the list because of what its session SAID carries the snippet
1205    /// where its path would be, which is what puts the typed words ON the row so
1206    /// the matcher can keep working in the ordinary way. Re-filtering alone
1207    /// leaves the old rows in place, nothing carries the words, and every row
1208    /// disappears the moment you type something only a transcript holds.
1209    ///
1210    /// With search off it is only a filter, which is what makes typing into a
1211    /// picker you merely opened to jump as cheap as it always was.
1212    fn query_changed(&mut self) {
1213        if self.search {
1214            self.rebuild();
1215        } else {
1216            self.view = filter(&self.all, &self.query, &self.matcher);
1217            self.clamp();
1218        }
1219    }
1220
1221    fn clamp(&mut self) {
1222        if self.view.is_empty() {
1223            self.sel = 0;
1224        } else if self.sel >= self.view.len() {
1225            self.sel = self.view.len() - 1;
1226        }
1227    }
1228
1229    /// Drop the last word of the query, which is fzf's `unix-word-rubout` and
1230    /// `backward-kill-word`. The trailing space goes with it, so a query ending
1231    /// in one loses a whole word rather than just the gap.
1232    fn kill_word(&mut self) {
1233        while self.query.ends_with(char::is_whitespace) {
1234            self.query.pop();
1235        }
1236        while !self.query.is_empty() && !self.query.ends_with(char::is_whitespace) {
1237            self.query.pop();
1238        }
1239        self.query_changed();
1240    }
1241
1242    /// Put the cursor on a pane, if it is in the list. Silent when it is not,
1243    /// which is the case where there is nothing to put it on.
1244    fn focus(&mut self, id: &str) {
1245        if let Some(i) = self.view.iter().position(|&i| self.all[i].pane_id == id) {
1246            self.sel = i;
1247        }
1248    }
1249
1250    fn selected(&self) -> Option<&rows::Row> {
1251        self.view.get(self.sel).map(|&i| &self.all[i])
1252    }
1253
1254    fn move_by(&mut self, d: isize) {
1255        if self.view.is_empty() {
1256            return;
1257        }
1258        let n = self.view.len() as isize;
1259        self.sel = (((self.sel as isize + d) % n + n) % n) as usize; // --cycle
1260    }
1261
1262    /// A screenful, and it CLAMPS where `move_by` cycles.
1263    ///
1264    /// fzf's page-up and page-down do not cycle even under `--cycle`, and that
1265    /// is the right behaviour rather than an inconsistency: a page that wrapped
1266    /// would be unusable for what paging is for. Holding Page Down to reach the
1267    /// bottom of a list would sail past the end and land back at the top, and
1268    /// nothing on the row tells you it happened.
1269    ///
1270    /// `page` is the list's drawn height, so it follows the popup's size and the
1271    /// preview being open. Zero is possible on a pane too short to draw a row,
1272    /// and would make the key do nothing.
1273    fn move_page(&mut self, pages: isize, page: usize) {
1274        if self.view.is_empty() {
1275            return;
1276        }
1277        let step = page.max(1) as isize;
1278        let last = self.view.len() as isize - 1;
1279        self.sel = (self.sel as isize + pages * step).clamp(0, last) as usize;
1280    }
1281}
1282
1283/// Returns the row that was chosen, an abort, or a request to be reopened at a
1284/// new size.
1285pub fn run(src: Source) -> std::io::Result<Outcome> {
1286    let kitty = env::var("TAIMUX_TUI_KITTY").is_some_and(|v| v == "1");
1287    let mut guard = Guard::new(kitty)?;
1288    let backend = CrosstermBackend::new(guard.out.try_clone()?);
1289    let mut term = Terminal::new(backend)?;
1290
1291    // 0 turns the timer off, as TAIMUX_REFRESH does for the fzf picker. There is
1292    // no idle gate here: fzf needs one because a reload blocks its input loop and
1293    // swallows keystrokes, and a tick in this loop is just a redraw.
1294    let refresh: f32 = env::var("TAIMUX_REFRESH")
1295        .and_then(|v| v.parse().ok())
1296        .unwrap_or(3.0);
1297    let live = refresh > 0.0;
1298
1299    let mut app = App {
1300        matcher: SkimMatcherV2::default().smart_case(),
1301        mode: Mode::All,
1302        search: false,
1303        preview: true,
1304        query: String::new(),
1305        width: row_width(term.size()?.width),
1306        tsv: String::new(),
1307        all: Vec::new(),
1308        view: Vec::new(),
1309        sel: 0,
1310        shot: None,
1311        poff: 0,
1312        poff_for: String::new(),
1313        pending: None,
1314        pending_since: Instant::now(),
1315        client: None,
1316        restarting: HashMap::new(),
1317        src,
1318    };
1319    // Whatever a previous instance was doing when the terminal grew under it.
1320    // Default-empty otherwise, which is an ordinary open.
1321    app.query = std::mem::take(&mut app.src.state.query);
1322    app.mode = Mode::from_key(app.src.state.mode);
1323    app.search = app.src.state.search;
1324    app.preview = app.src.state.preview;
1325    // Even the FIRST scan runs off the loop. It used to be synchronous, on the
1326    // reasoning that there is nothing to draw until it lands, and what that
1327    // produced was a POPUP WITH NOTHING IN IT for as long as the scan took:
1328    // reported as another stuck picker, an empty box over a session, no keys.
1329    // There is something to draw, and it is "looking for them".
1330    //
1331    // An empty answer used to close the picker too. It says one thing, and it is
1332    // the thing you need: F1 on a machine with no agent sessions was
1333    // indistinguishable from F1 not being bound, from taimux not being
1334    // installed, and from the popup failing to start.
1335    app.start_refresh();
1336    // The cursor opens on the pane the picker was opened from, which is the row
1337    // marked ●, and on the top row when that pane is not an agent session. After
1338    // a reopen it goes back where it was instead, since that is the row you were
1339    // looking at when the terminal changed shape under you. Applied when the
1340    // rows arrive, since there is nothing to put it on before that.
1341    let opening_on = if app.src.state.on.is_empty() {
1342        app.src.cur.clone()
1343    } else {
1344        app.src.state.on.clone()
1345    };
1346    let mut opened = false;
1347
1348    let mut state = ListState::default();
1349    let mut chosen: Option<String> = None;
1350    // Set when the terminal has grown past what this popup can use.
1351    let mut outgrew = false;
1352    let mut ticked = Instant::now();
1353    // How tall the list came out, written by the draw below and read by Page
1354    // Up / Page Down. Taken from the drawn area rather than recomputed from the
1355    // terminal size, because the layout it would have to reproduce (a border,
1356    // two fixed lines, and a preview that takes 60% only when there is room for
1357    // it) is exactly the sort of arithmetic that drifts from the real thing.
1358    let mut page: usize = 1;
1359    // Where the list starts on screen, for turning a click's row into a row of
1360    // the list. Same reasoning as `page`: measured, not recomputed.
1361    let mut list_y: u16 = 0;
1362    // The last left click, so a second one on the same row reads as a
1363    // double-click. crossterm reports presses, never double-clicks, so the only
1364    // way to have the gesture fzf had is to time it.
1365    let mut clicked: Option<(u16, Instant)> = None;
1366
1367    loop {
1368        state.select(if app.view.is_empty() {
1369            None
1370        } else {
1371            Some(app.sel)
1372        });
1373        term.draw(|f| {
1374            let count = format!(" {}/{} ", app.view.len(), app.all.len());
1375            let mut block = Block::bordered()
1376                .title(label(app.mode, live, app.search, app.refreshing()))
1377                .title_bottom(Line::from(count.clone()));
1378            // Dim, and in the corner furthest from the cursor: it is reference,
1379            // read once after an update and never again, so it must not compete
1380            // with the count beside it or the list above.
1381            if room_for_tag(f.area().width, &count) {
1382                block = block.title_bottom(
1383                    Line::from(Span::styled(
1384                        version_tag(),
1385                        Style::default().fg(Color::DarkGray),
1386                    ))
1387                    .right_aligned(),
1388                );
1389            }
1390            let inner = block.inner(f.area());
1391            f.render_widget(block, f.area());
1392
1393            let [prompt, head, body] = Layout::vertical([
1394                Constraint::Length(1),
1395                Constraint::Length(1),
1396                Constraint::Min(1),
1397            ])
1398            .areas(inner);
1399
1400            f.render_widget(
1401                Paragraph::new(Line::from(vec![
1402                    Span::styled("pick ❯ ", Style::default().fg(Color::Cyan)),
1403                    Span::raw(app.query.clone()),
1404                ])),
1405                prompt,
1406            );
1407            f.render_widget(
1408                Paragraph::new(Line::from(Span::styled(
1409                    header(
1410                        app.src.script.is_some(),
1411                        app.src.ended.is_some(),
1412                        search_enabled(),
1413                        app.search,
1414                    ),
1415                    Style::default().fg(Color::DarkGray),
1416                ))),
1417                head,
1418            );
1419
1420            // The preview takes the bottom 60%, as --preview-window=down,60% does.
1421            let (body, prev) = if app.preview && body.height >= 8 {
1422                let [a, b] =
1423                    Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
1424                        .areas(body);
1425                (a, Some(b))
1426            } else {
1427                (body, None)
1428            };
1429            page = body.height as usize;
1430            list_y = body.y;
1431
1432            let items: Vec<ListItem> = app
1433                .view
1434                .iter()
1435                .map(|&i| {
1436                    ListItem::new(Line::from(
1437                        app.all[i]
1438                            .cells
1439                            .iter()
1440                            .map(|c| Span::styled(c.text.clone(), c.paint.style()))
1441                            .collect::<Vec<_>>(),
1442                    ))
1443                })
1444                .collect();
1445            if app.view.is_empty() {
1446                // Where the rows would be, in the same place your eye already
1447                // is, rather than a line tucked under the header.
1448                f.render_widget(
1449                    Paragraph::new(empty_note(
1450                        app.mode,
1451                        &app.query,
1452                        // Nothing has come back yet, which is not the same as
1453                        // nothing being there.
1454                        !opened,
1455                        // The RAW scan, not the laid-out rows: those already
1456                        // have the mode filter applied, so one idle session
1457                        // viewed through the waiting list read as a machine
1458                        // with nothing running on it at all.
1459                        app.tsv.trim().is_empty(),
1460                        app.src.ended.is_some(),
1461                    ))
1462                    .style(Style::default().fg(Color::DarkGray))
1463                    .wrap(Wrap { trim: false }),
1464                    body,
1465                );
1466            } else {
1467                f.render_stateful_widget(
1468                    List::new(items)
1469                        .highlight_symbol("▶ ")
1470                        .highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
1471                    body,
1472                    &mut state,
1473                );
1474            }
1475
1476            if let Some(area) = prev {
1477                let block = Block::default()
1478                    .borders(Borders::TOP)
1479                    .border_style(Style::default().fg(Color::DarkGray));
1480                let inner = block.inner(area);
1481                f.render_widget(block, area);
1482                let (head, text, at_bottom) = app.preview();
1483                // The header stays put and the body scrolls under it. Pinning it
1484                // is the whole reason the two are built separately: it says
1485                // WHICH session this is, and scrolling that off the top would
1486                // leave a screenful of text belonging to nothing in particular.
1487                let hh = (head.len() as u16).min(inner.height);
1488                let [hrect, brect] =
1489                    Layout::vertical([Constraint::Length(hh), Constraint::Min(0)]).areas(inner);
1490                f.render_widget(Paragraph::new(head).wrap(Wrap { trim: false }), hrect);
1491
1492                // Where the window sits in the body. `poff` is rows away from
1493                // the default view, negative towards the start, and the clamp is
1494                // what lets one offset mean the same thing for a live pane
1495                // (anchored at the bottom) and an ended conversation (anchored
1496                // at the top): at either extreme it simply stops.
1497                //
1498                // The body is NOT wrapped, and that is what makes the arithmetic
1499                // exact. `Paragraph::scroll` counts WRAPPED rows while this
1500                // counts lines, so with wrapping on a capture padded to a wider
1501                // pane every line became two rows: each keypress moved half a
1502                // line and the clamp stopped a third of the way up. Unwrapped, a
1503                // line is a row. It also suits what this is, a viewport onto a
1504                // pane: a line too long for the preview reads better clipped
1505                // than re-flowed, since that is what the pane looks like. The
1506                // header keeps its wrap, being prose.
1507                let most = text.len().saturating_sub(brect.height as usize) as i32;
1508                let base = if at_bottom { most } else { 0 };
1509                let start = (base + app.poff).clamp(0, most.max(0));
1510                // Write the clamped offset BACK, or it accumulates past the end
1511                // of the body: hold shift-up at the top for a second and coming
1512                // back down takes as many presses as went in, with nothing on
1513                // screen moving for any of them. The limits are only known here,
1514                // where the body and the area both are, which is why the field
1515                // cannot clamp itself.
1516                app.poff = start - base;
1517                f.render_widget(Paragraph::new(text).scroll((start as u16, 0)), brect);
1518            }
1519        })?;
1520
1521        // A refresh that has landed is taken here, between two draws, so the
1522        // rebuild it costs is the only work the loop ever does off the input
1523        // path. The ASK for one is free: it hands the row source to a thread.
1524        if app.take_refresh() {
1525            app.rebuild();
1526            if !opened {
1527                opened = true;
1528                app.focus(&opening_on);
1529            }
1530            // The terminal has grown past what this popup was asked for, and
1531            // tmux will not grow a popup on its own. Leaving the loop is how the
1532            // picker asks to be reopened: the popup closes with it, and what it
1533            // was doing goes out in the Outcome.
1534            if let Some((_, size)) = app.client.clone() {
1535                if resize_enabled() && outgrown(term.size().map(|s| (s.width, s.height))?, size, 2)
1536                {
1537                    outgrew = true;
1538                    break;
1539                }
1540            }
1541        }
1542        if live && ticked.elapsed().as_secs_f32() >= refresh {
1543            ticked = Instant::now();
1544            app.start_refresh();
1545        }
1546        if !event::poll(Duration::from_millis(120))? {
1547            continue;
1548        }
1549        match event::read()? {
1550            // One event, carrying its own text, with no way to mistake it for
1551            // Enter. A pasted line break arrives as CR, so both are split on.
1552            Event::Paste(text) => {
1553                let first = text.split(['\r', '\n']).next().unwrap_or_default();
1554                app.query.push_str(first);
1555                app.query_changed();
1556            }
1557            // The wheel is the arrow keys, and a click is the cursor, which is
1558            // what fzf's default mouse handling did. Restored because the port
1559            // simply never asked the terminal for mouse events.
1560            Event::Mouse(MouseEvent { kind, row: my, .. }) => match kind {
1561                // Wrapping, because these ARE Up and Down: a wheel that stopped
1562                // where the arrow key it stands in for cycles would be the odd
1563                // one out. Over the preview too, since that pane has no scroll
1564                // of its own to offer instead.
1565                MouseEventKind::ScrollUp => app.move_by(-1),
1566                MouseEventKind::ScrollDown => app.move_by(1),
1567                // A click at or below where the list starts. Above it is the
1568                // prompt or the header, which are not rows.
1569                MouseEventKind::Down(MouseButton::Left) if my >= list_y => {
1570                    // Which row was under the pointer. `offset` is what the List
1571                    // widget has scrolled to, so this stays right on a list
1572                    // longer than the window, and a click past the last row
1573                    // lands on nothing rather than off the end.
1574                    let i = state.offset() + (my - list_y) as usize;
1575                    if i < app.view.len() {
1576                        app.sel = i;
1577                        // A second click on the row already under the cursor,
1578                        // soon enough, accepts it. fzf's double-click, with the
1579                        // clock this has to keep because crossterm reports
1580                        // presses and never the gesture.
1581                        let again =
1582                            clicked.is_some_and(|(r, t)| r == my && t.elapsed() < DOUBLE_CLICK);
1583                        clicked = Some((my, Instant::now()));
1584                        if again {
1585                            if let Some(r) = app.selected() {
1586                                chosen = Some(r.pane_id.clone());
1587                            }
1588                            break;
1589                        }
1590                    }
1591                }
1592                _ => {}
1593            },
1594            Event::Resize(w, _) => {
1595                app.width = row_width(w);
1596                app.rebuild();
1597            }
1598            Event::Key(k) => {
1599                // A terminal with the kitty flags pushed reports releases too, and
1600                // acting on both double-counts every key.
1601                if k.kind != KeyEventKind::Press {
1602                    continue;
1603                }
1604                let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
1605                let alt = k.modifiers.contains(KeyModifiers::ALT);
1606                let shift = k.modifiers.contains(KeyModifiers::SHIFT);
1607                match k.code {
1608                    // fzf aborts on all four of these, and abort is the one
1609                    // action worth having several ways to reach.
1610                    KeyCode::Esc => break,
1611                    KeyCode::Char('c') | KeyCode::Char('g') | KeyCode::Char('q') if ctrl => break,
1612                    KeyCode::Enter => {
1613                        if let Some(r) = app.selected() {
1614                            chosen = Some(r.pane_id.clone());
1615                        }
1616                        break;
1617                    }
1618                    // Scroll the PREVIEW, not the list, which is what fzf points
1619                    // these at. A live pane's preview opens on the bottom of its
1620                    // screen, so shift-up is how you see what came before it; an
1621                    // ended conversation opens at the top, so shift-down is how
1622                    // you read forwards through it. The offset is clamped at both
1623                    // ends of the body and reset whenever the cursor moves.
1624                    KeyCode::Up if shift => app.poff -= 1,
1625                    KeyCode::Down if shift => app.poff += 1,
1626                    KeyCode::Down => app.move_by(1),
1627                    KeyCode::Up => app.move_by(-1),
1628                    // Both pairs, as fzf binds both. ctrl-j is safe to take
1629                    // here: a terminal sends LF for it and CR for Enter, and
1630                    // crossterm keeps them apart, so this does not shadow
1631                    // accept. Checked rather than assumed.
1632                    KeyCode::Char('n') | KeyCode::Char('j') if ctrl => app.move_by(1),
1633                    KeyCode::Char('p') | KeyCode::Char('k') if ctrl => app.move_by(-1),
1634                    // The ends of the LIST. fzf points these at the ends of the
1635                    // QUERY by default, which in a picker you rarely type into is
1636                    // a key that visibly does nothing at all.
1637                    KeyCode::Home => app.sel = 0,
1638                    KeyCode::End => app.sel = app.view.len().saturating_sub(1),
1639                    // A screenful, by the height the list was actually drawn at,
1640                    // so it tracks the popup's size and whether the preview is
1641                    // open. fzf bound these itself and the port simply dropped
1642                    // them: Home and End were ported and these were not, which is
1643                    // why one pair kept working and the other went quiet.
1644                    KeyCode::PageDown => app.move_page(1, page),
1645                    KeyCode::PageUp => app.move_page(-1, page),
1646                    KeyCode::Tab => {
1647                        // Nothing installed to compare against and the outdated
1648                        // stop is not in the cycle at all: every row there would
1649                        // be judged against an empty version, so the list could
1650                        // only ever be empty.
1651                        app.mode = app
1652                            .mode
1653                            .next(app.src.ended.is_some(), !app.src.newver.is_empty());
1654                        // Re-filtered from the rows already in hand, so the new
1655                        // list is on screen at once; the scan behind it lands
1656                        // when it lands.
1657                        app.rebuild();
1658                        app.start_refresh();
1659                    }
1660                    KeyCode::Char('r') if ctrl => app.start_refresh(),
1661                    // Nothing is bound when search is turned off, and the
1662                    // picker then behaves exactly as it did before there was any.
1663                    KeyCode::Char('t') if ctrl && search_enabled() => {
1664                        app.search = !app.search;
1665                        app.rebuild();
1666                    }
1667                    // ctrl-/ reaches a terminal as several different bytes, so
1668                    // all of them are taken rather than one.
1669                    KeyCode::Char('/') | KeyCode::Char('_') | KeyCode::Char('\u{1f}') if ctrl => {
1670                        app.preview = !app.preview;
1671                    }
1672                    KeyCode::Char('u') if ctrl => {
1673                        app.query.clear();
1674                        app.query_changed();
1675                    }
1676                    // A word back, which fzf gives both of these. Worth having
1677                    // even though the query has no cursor: deleting the last
1678                    // word of "claude renovate" is a thing you want, and the
1679                    // alternative is holding backspace.
1680                    KeyCode::Char('w') if ctrl => app.kill_word(),
1681                    // Before the bare Backspace below, or alt-backspace would
1682                    // take a single character. It took one until now: the arm
1683                    // ignored modifiers, so fzf's backward-kill-word quietly
1684                    // behaved as plain backspace.
1685                    KeyCode::Backspace if alt => app.kill_word(),
1686                    // ctrl-h is backspace as far as fzf is concerned, and some
1687                    // terminals send it for the key. It needs naming separately
1688                    // because it arrives as a ctrl-char, not as Backspace.
1689                    KeyCode::Backspace => {
1690                        app.query.pop();
1691                        app.query_changed();
1692                    }
1693                    KeyCode::Char('h') if ctrl => {
1694                        app.query.pop();
1695                        app.query_changed();
1696                    }
1697                    // A full repaint, for a screen something else has written
1698                    // over. Every loop redraws already, so this only has to
1699                    // throw away what ratatui thinks is on the terminal.
1700                    //
1701                    // `resize` and NOT `Terminal::clear`, which would be the
1702                    // obvious call and is a trap here: it snapshots the cursor
1703                    // first, and the crossterm backend does that with
1704                    // `crossterm::cursor::position()`, which writes ESC[6n to
1705                    // the PROCESS's stdout rather than to the backend's writer.
1706                    // Stdout carries exactly one thing in this program, the
1707                    // chosen pane id, so ctrl-l put `[6n` where the caller reads
1708                    // the answer. Measured, not theorised.
1709                    //
1710                    // resize() on a fullscreen viewport takes the same path
1711                    // minus that snapshot: it clears through the backend's own
1712                    // writer and resets the back buffer, so the next draw
1713                    // repaints in full.
1714                    KeyCode::Char('l') if ctrl => repaint(&mut term),
1715                    // The two keys that act rather than navigate. They run the
1716                    // script the way fzf's execute() does: hand the terminal over,
1717                    // let the child own it, take it back.
1718                    KeyCode::Char('x') if ctrl => {
1719                        if let (Some(s), Some(r)) = (app.src.script.clone(), app.selected()) {
1720                            let id = r.pane_id.clone();
1721                            // Held BEFORE the restart is fired. By the time it
1722                            // returns the session may already be gone, and with
1723                            // it the row this needs to remember.
1724                            app.hold(&id);
1725                            guard.suspend();
1726                            if let Err(e) = act_child(&s, &["_restart", &id]) {
1727                                crate::act::report_failed_child("the restart", &e);
1728                            }
1729                            guard.resume();
1730                            repaint(&mut term);
1731                            // Rebuilt from the rows already in hand and drawn on
1732                            // the next pass, so the list is back on screen at
1733                            // once. Asking for fresh rows here and WAITING for
1734                            // them is what left the picker showing the child's
1735                            // last screen, unable to draw or read a key, for as
1736                            // long as the scan took.
1737                            app.rebuild();
1738                            app.start_refresh();
1739                            // …and the cursor goes back on it explicitly. The
1740                            // rebuild re-pins by pane id on its own, but only
1741                            // when the row is in the list: a restart that was
1742                            // REFUSED (working, holding a dialog, unresolvable)
1743                            // holds nothing, so without this the cursor would
1744                            // still fall to the top on exactly the presses that
1745                            // did nothing.
1746                            app.focus(&id);
1747                        }
1748                    }
1749                    // ctrl-o: carry this conversation into a different agent.
1750                    // Same shape as ctrl-x, and for the same reason: it draws a
1751                    // menu, waits on a key and then opens a window, none of
1752                    // which the picker's own loop can do while it is drawing.
1753                    KeyCode::Char('o') if ctrl => {
1754                        if let (Some(s), Some(r)) = (app.src.script.clone(), app.selected()) {
1755                            let id = r.pane_id.clone();
1756                            guard.suspend();
1757                            if let Err(e) = act_child(&s, &["_handoff", &id]) {
1758                                crate::act::report_failed_child("the handoff", &e);
1759                            }
1760                            guard.resume();
1761                            repaint(&mut term);
1762                            // Nothing in the list changed: a handoff opens a NEW
1763                            // window and leaves the conversation it came from
1764                            // exactly where it was. So the cursor goes straight
1765                            // back on the row rather than the list being rebuilt.
1766                            app.focus(&id);
1767                        }
1768                    }
1769                    KeyCode::F(8) => {
1770                        if let Some(s) = app.src.script.clone() {
1771                            guard.suspend();
1772                            if let Err(e) = act_child(&s, &["_sweep"]) {
1773                                crate::act::report_failed_child("the sweep", &e);
1774                            }
1775                            guard.resume();
1776                            repaint(&mut term);
1777                            // Same as ctrl-x, and this is the press it was
1778                            // REPORTED on: a sweep restarts every outdated
1779                            // session at once, so the scan that follows it is the
1780                            // slowest one the picker ever runs.
1781                            app.rebuild();
1782                            app.start_refresh();
1783                        }
1784                    }
1785                    // Anything else printable joins the query. `!alt` matters as
1786                    // much as `!ctrl` and was missing: ALT is not CTRL, so every
1787                    // alt-chord fell in here and TYPED ITS LETTER. Holding alt
1788                    // and pressing b put a "b" in the query, which is fzf's
1789                    // backward-word, and any stray chord the terminal passed
1790                    // through corrupted the search with no way to tell.
1791                    KeyCode::Char(c) if !ctrl && !alt => {
1792                        app.query.push(c);
1793                        app.query_changed();
1794                    }
1795                    _ => {}
1796                }
1797            }
1798            _ => {}
1799        }
1800    }
1801
1802    drop(term);
1803    drop(guard);
1804    Ok(match (chosen, outgrew) {
1805        (Some(id), _) => Outcome::Chosen(id),
1806        (None, true) => Outcome::Resize(State {
1807            query: app.query.clone(),
1808            mode: app.mode.key(),
1809            search: app.search,
1810            preview: app.preview,
1811            on: app
1812                .selected()
1813                .map(|r| r.pane_id.clone())
1814                .unwrap_or_default(),
1815            client: app.client.clone().map(|(tty, _)| tty).unwrap_or_default(),
1816        }),
1817        (None, false) => Outcome::Aborted,
1818    })
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823    use super::*;
1824
1825    fn src(tsv: &str) -> Source {
1826        let t = tsv.to_string();
1827        Source {
1828            fetch: Arc::new(move || t.clone()),
1829            ended: None,
1830            cur: String::new(),
1831            home: "/h".into(),
1832            newver: String::new(),
1833            script: None,
1834            popup: false,
1835            state: Default::default(),
1836        }
1837    }
1838
1839    fn app(tsv: &str) -> App {
1840        let mut a = App {
1841            src: src(tsv),
1842            matcher: SkimMatcherV2::default().smart_case(),
1843            mode: Mode::All,
1844            search: false,
1845            preview: true,
1846            query: String::new(),
1847            width: 100,
1848            tsv: String::new(),
1849            all: Vec::new(),
1850            view: Vec::new(),
1851            sel: 0,
1852            shot: None,
1853            poff: 0,
1854            poff_for: String::new(),
1855            pending: None,
1856            pending_since: Instant::now(),
1857            client: None,
1858            restarting: HashMap::new(),
1859        };
1860        a.fetch();
1861        a.rebuild();
1862        a
1863    }
1864
1865    /// An app whose scan can be changed under it, which is what a restart does:
1866    /// the pane is there, then it is not, then it is back.
1867    ///
1868    /// Arc/Mutex rather than Rc/RefCell because the row source is handed to a
1869    /// worker thread now, so it has to be Send and Sync like the real ones.
1870    fn app_live(cell: Arc<std::sync::Mutex<String>>) -> App {
1871        let c = cell.clone();
1872        let mut a = App {
1873            src: Source {
1874                fetch: Arc::new(move || c.lock().unwrap().clone()),
1875                ended: None,
1876                cur: String::new(),
1877                home: "/h".into(),
1878                newver: String::new(),
1879                script: None,
1880                popup: false,
1881                state: Default::default(),
1882            },
1883            matcher: SkimMatcherV2::default().smart_case(),
1884            mode: Mode::All,
1885            search: false,
1886            preview: true,
1887            query: String::new(),
1888            width: 100,
1889            tsv: String::new(),
1890            all: Vec::new(),
1891            view: Vec::new(),
1892            sel: 0,
1893            shot: None,
1894            poff: 0,
1895            poff_for: String::new(),
1896            pending: None,
1897            pending_since: Instant::now(),
1898            client: None,
1899            restarting: HashMap::new(),
1900        };
1901        a.fetch();
1902        a.rebuild();
1903        a
1904    }
1905
1906    fn ids(a: &App) -> Vec<String> {
1907        a.view.iter().map(|&i| a.all[i].pane_id.clone()).collect()
1908    }
1909
1910    /// The bug this is all for: a restart takes the session away for seconds, so
1911    /// the pane has no agent, the scan does not see it, and the row disappears
1912    /// from under the cursor.
1913    #[test]
1914    fn a_restarting_row_stays_in_the_list_where_it_was() {
1915        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
1916        let mut a = app_live(cell.clone());
1917        a.sel = 1; // the middle one, %2
1918        assert_eq!(ids(&a), ["%1", "%2", "%3"]);
1919
1920        a.hold("%2");
1921        // the restart has taken it away
1922        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
1923                              %3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
1924            .to_string();
1925        a.fetch();
1926        a.rebuild();
1927
1928        assert_eq!(ids(&a), ["%1", "%2", "%3"], "the row should still be there");
1929        assert_eq!(
1930            a.selected().map(|r| r.pane_id.as_str()),
1931            Some("%2"),
1932            "and the cursor should still be on it"
1933        );
1934    }
1935
1936    /// The same, but through the sequence ctrl-x actually produces.
1937    ///
1938    /// The test above jumps straight to "the restart has taken it away", and
1939    /// that is the step the bug was hiding behind. A restart is fired and
1940    /// returns AT ONCE, so the first refresh after ctrl-x still sees the agent:
1941    /// it has been asked to exit and has not done so yet. Releasing the hold on
1942    /// that refresh meant nothing was holding the row when the session did go a
1943    /// moment later, and the cursor fell to the top of the list while its owner
1944    /// was watching the session they had just asked to upgrade.
1945    #[test]
1946    fn a_row_is_still_held_through_the_refresh_before_the_session_goes() {
1947        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
1948        let mut a = app_live(cell.clone());
1949        a.sel = 1;
1950        a.hold("%2");
1951
1952        // Refresh ONE: the restart is in flight and the agent is still there.
1953        a.fetch();
1954        a.rebuild();
1955        assert_eq!(
1956            ids(&a),
1957            ["%1", "%2", "%3"],
1958            "no duplicate while it is present"
1959        );
1960        assert!(
1961            a.restarting.contains_key("%2"),
1962            "not yet gone, so still held"
1963        );
1964        assert_eq!(
1965            a.selected().map(|r| r.pane_id.as_str()),
1966            Some("%2"),
1967            "cursor stays put"
1968        );
1969
1970        // Refresh TWO: now the session has actually gone.
1971        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
1972                              %3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
1973            .to_string();
1974        a.fetch();
1975        a.rebuild();
1976        assert_eq!(
1977            ids(&a),
1978            ["%1", "%2", "%3"],
1979            "held in place while it is away"
1980        );
1981        assert_eq!(
1982            a.selected().map(|r| r.pane_id.as_str()),
1983            Some("%2"),
1984            "and the cursor is STILL on the session being upgraded"
1985        );
1986
1987        // Refresh THREE: it comes back, and only now is the hold spent.
1988        *cell.lock().unwrap() = THREE.to_string();
1989        a.fetch();
1990        a.rebuild();
1991        assert!(a.restarting.is_empty(), "back for real, so no longer held");
1992        assert_eq!(ids(&a), ["%1", "%2", "%3"]);
1993        assert_eq!(a.selected().map(|r| r.pane_id.as_str()), Some("%2"));
1994    }
1995
1996    /// Appending would have been easier and wrong: the row would jump to the
1997    /// bottom of the list at the moment its owner is watching it.
1998    #[test]
1999    fn a_held_row_is_not_moved_to_the_end() {
2000        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
2001        let mut a = app_live(cell.clone());
2002        a.hold("%1");
2003        *cell.lock().unwrap() = "%2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
2004                              %3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart"
2005            .to_string();
2006        a.fetch();
2007        a.rebuild();
2008        assert_eq!(ids(&a), ["%1", "%2", "%3"], "%1 was first and stays first");
2009    }
2010
2011    /// Holding stops the moment the session is back, or the row would go on
2012    /// claiming a restart is in flight for as long as the picker is open.
2013    #[test]
2014    fn the_hold_is_released_when_the_session_comes_back() {
2015        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
2016        let mut a = app_live(cell.clone());
2017        a.hold("%2");
2018        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
2019        a.fetch();
2020        assert!(a.restarting.contains_key("%2"), "still away, still held");
2021        // back, with a new title, which is what a fresh session looks like
2022        *cell.lock().unwrap() = THREE.to_string();
2023        a.fetch();
2024        a.rebuild();
2025        assert!(a.restarting.is_empty(), "back, so no longer held");
2026        assert_eq!(ids(&a), ["%1", "%2", "%3"]);
2027    }
2028
2029    /// A restart that never comes back must not leave a row lying about forever.
2030    #[test]
2031    fn the_hold_expires() {
2032        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
2033        let mut a = app_live(cell.clone());
2034        a.hold("%2");
2035        // fired longer ago than the hold allows
2036        if let Some(e) = a.restarting.get_mut("%2") {
2037            e.0 = Instant::now() - RESTART_HOLD - Duration::from_secs(1);
2038        }
2039        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
2040        a.fetch();
2041        a.rebuild();
2042        assert!(a.restarting.is_empty());
2043        assert_eq!(
2044            ids(&a),
2045            ["%1"],
2046            "the row is gone, because the restart failed"
2047        );
2048    }
2049
2050    /// The marker column says a restart is in flight. It goes there and not into
2051    /// the summary because the summary strips a leading marker glyph.
2052    #[test]
2053    fn a_held_row_is_marked_as_restarting() {
2054        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
2055        let mut a = app_live(cell.clone());
2056        a.hold("%2");
2057        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
2058        a.fetch();
2059        a.rebuild();
2060        let row = a.all.iter().find(|r| r.pane_id == "%2").unwrap();
2061        let text = row.to_ansi();
2062        assert!(
2063            text.contains('↻'),
2064            "expected the restart marker in {text:?}"
2065        );
2066        // …and it does not borrow the waiting star, which means something else
2067        assert!(!text.contains('✳'), "must not read as asking: {text:?}");
2068    }
2069
2070    /// A row held while a filter is on keeps its state, so it stays in whichever
2071    /// mode was being watched. A synthetic state would have dropped it out of the
2072    /// list at exactly the wrong moment.
2073    #[test]
2074    fn a_held_row_survives_the_mode_it_was_watched_in() {
2075        let cell = Arc::new(std::sync::Mutex::new(THREE.to_string()));
2076        let mut a = app_live(cell.clone());
2077        a.mode = Mode::Run; // %2 is the running one
2078        a.rebuild();
2079        assert_eq!(ids(&a), ["%2"]);
2080        a.hold("%2");
2081        *cell.lock().unwrap() = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie".to_string();
2082        a.fetch();
2083        a.rebuild();
2084        assert_eq!(ids(&a), ["%2"], "still listed under the filter it was in");
2085    }
2086
2087    /// The bug this is all for: the picker used to call the row source from its
2088    /// input loop, so a scan that took 85 seconds after an F8 sweep was 85
2089    /// seconds with no draw and no key. Asking must return AT ONCE.
2090    #[test]
2091    fn asking_for_a_refresh_does_not_wait_for_it() {
2092        let mut a = app(THREE);
2093        a.src.fetch = Arc::new(|| {
2094            std::thread::sleep(Duration::from_millis(400));
2095            "%9\tz:1.1\t/h\tclaude\t1\tidle\t-\tlate arrival".to_string()
2096        });
2097        let at = Instant::now();
2098        a.start_refresh();
2099        assert!(
2100            at.elapsed() < Duration::from_millis(100),
2101            "start_refresh blocked for {:?}",
2102            at.elapsed()
2103        );
2104        assert!(!a.take_refresh(), "nothing has landed yet");
2105        assert_eq!(a.all.len(), 3, "and the old rows are still there to draw");
2106
2107        // …and it lands later, without anything having waited on it.
2108        let mut got = false;
2109        for _ in 0..100 {
2110            if a.take_refresh() {
2111                got = true;
2112                break;
2113            }
2114            std::thread::sleep(Duration::from_millis(20));
2115        }
2116        assert!(got, "the refresh never arrived");
2117        a.rebuild();
2118        assert_eq!(ids(&a), ["%9"]);
2119    }
2120
2121    /// One at a time. The timer must not stack refreshes on a machine where they
2122    /// take longer than the interval, which is the machine this matters on.
2123    #[test]
2124    fn a_second_refresh_is_not_started_while_one_is_out() {
2125        let mut a = app(THREE);
2126        let runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2127        let r = runs.clone();
2128        a.src.fetch = Arc::new(move || {
2129            r.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2130            std::thread::sleep(Duration::from_millis(300));
2131            String::new()
2132        });
2133        a.start_refresh();
2134        a.start_refresh();
2135        a.start_refresh();
2136        std::thread::sleep(Duration::from_millis(500));
2137        assert_eq!(runs.load(std::sync::atomic::Ordering::SeqCst), 1);
2138    }
2139
2140    /// A row source that panics drops its sender rather than answering. The
2141    /// picker has to forget that refresh and carry on, not wait on it forever.
2142    #[test]
2143    fn a_refresh_that_never_answers_is_forgotten() {
2144        let mut a = app(THREE);
2145        a.src.fetch = Arc::new(|| panic!("the scan blew up"));
2146        a.start_refresh();
2147        for _ in 0..100 {
2148            if a.pending.is_none() {
2149                break;
2150            }
2151            let _ = a.take_refresh();
2152            std::thread::sleep(Duration::from_millis(20));
2153        }
2154        assert!(a.pending.is_none(), "still waiting on a dead thread");
2155        assert_eq!(a.all.len(), 3, "and the list it had is untouched");
2156    }
2157
2158    /// The border says so, but only once it has been a second: on a healthy
2159    /// machine the answer is back before the next draw, and a label that flashed
2160    /// every tick would be noise about nothing.
2161    #[test]
2162    fn the_border_says_refreshing_only_when_it_is_worth_saying() {
2163        let mut a = app(THREE);
2164        a.src.fetch = Arc::new(|| {
2165            std::thread::sleep(Duration::from_millis(1500));
2166            String::new()
2167        });
2168        a.start_refresh();
2169        assert!(!a.refreshing(), "not from the first millisecond");
2170        a.pending_since = Instant::now() - Duration::from_secs(2);
2171        assert!(a.refreshing());
2172        assert_eq!(
2173            label(Mode::All, true, false, true),
2174            " agent sessions · live · refreshing "
2175        );
2176    }
2177
2178    /// The picker used to CLOSE itself when the list came out empty, and that
2179    /// is what was reported as "F1 no longer works": on a machine with no agent
2180    /// sessions the popup opened and closed too fast to see, which looks exactly
2181    /// like an unbound key, a missing binary, or a popup that failed to start.
2182    /// The four silences mean different things and it now says which.
2183    #[test]
2184    fn an_empty_list_says_which_kind_of_empty_it_is() {
2185        let text = |ls: Vec<Line<'static>>| -> String {
2186            ls.iter()
2187                .map(|l| {
2188                    l.spans
2189                        .iter()
2190                        .map(|s| s.content.to_string())
2191                        .collect::<String>()
2192                })
2193                .collect::<Vec<_>>()
2194                .join("\n")
2195        };
2196
2197        // nothing running at all, which is the reported case
2198        let none = text(empty_note(Mode::All, "", false, true, false));
2199        assert!(none.contains("No agent sessions on this machine"), "{none}");
2200        assert!(none.contains("Esc closes this"), "{none}");
2201        // …and with an ended list to offer, it offers it
2202        let none_ended = text(empty_note(Mode::All, "", false, true, true));
2203        assert!(none_ended.contains("Tab reaches the conversations that ended"));
2204
2205        // something IS running, just not in this state
2206        let filtered = text(empty_note(Mode::Input, "", false, false, true));
2207        assert!(
2208            filtered.contains("Nothing is waiting for an answer right now"),
2209            "{filtered}"
2210        );
2211        assert!(!filtered.contains("No agent sessions"), "{filtered}");
2212
2213        // a query nobody matches, which says what to press to undo it
2214        let q = text(empty_note(Mode::All, "zzz", false, false, true));
2215        assert!(q.contains("Nothing matches zzz"), "{q}");
2216        assert!(q.contains("ctrl-u"), "{q}");
2217
2218        // the ended list, before anything has ended
2219        let dead = text(empty_note(Mode::Dead, "", false, false, true));
2220        assert!(
2221            dead.contains("No past conversations have been found here yet"),
2222            "{dead}"
2223        );
2224
2225        // …and before the first scan has come back at all, which is the state a
2226        // popup used to show as an empty box. It outranks every other case,
2227        // because none of them is known yet.
2228        let scanning = text(empty_note(Mode::All, "", true, true, true));
2229        assert!(
2230            scanning.contains("Looking for agent sessions"),
2231            "{scanning}"
2232        );
2233        assert!(!scanning.contains("No agent sessions"), "{scanning}");
2234        let scanning_q = text(empty_note(Mode::Input, "zzz", true, false, true));
2235        assert!(
2236            scanning_q.contains("Looking for agent sessions"),
2237            "{scanning_q}"
2238        );
2239    }
2240
2241    /// The default state is an ORDINARY open, which above all means the preview
2242    /// is ON. Deriving Default gave `preview: false` and every picker opened
2243    /// with it hidden; the tell was the page keys moving twice as far, since the
2244    /// list had the preview's half of the window too.
2245    #[test]
2246    fn the_default_state_is_an_ordinary_open() {
2247        let d = State::default();
2248        assert!(d.preview);
2249        assert!(!d.search);
2250        assert_eq!(Mode::from_key(d.mode), Mode::All);
2251        assert!(d.query.is_empty() && d.on.is_empty());
2252    }
2253
2254    /// tmux shrinks a popup to fit a client that got smaller and grows it back
2255    /// up to the size it was ASKED for, so the only case the picker has to act
2256    /// on is a terminal that grew past that. Both directions were measured
2257    /// before this was written; these are the numbers that came back.
2258    #[test]
2259    fn only_a_terminal_that_grew_past_the_popup_counts() {
2260        // opened at 160x50, so 80% is 128x40 and the usable area 126x38
2261        assert!(
2262            !outgrown((126, 38), (160, 50), 2),
2263            "the size it was opened at is not a reason to reopen"
2264        );
2265        // the client grew to 200x60: 80% of that is 160x48, well past 126x38
2266        assert!(outgrown((126, 38), (200, 60), 2));
2267        // …and the same popup on a client that SHRANK is tmux's business, not
2268        // ours: it has already clamped the popup to fit.
2269        assert!(!outgrown((58, 18), (60, 20), 2));
2270    }
2271
2272    /// A column or two of rounding must not close and reopen the popup, and the
2273    /// rule switches at 100 columns, so a phone rotating between portrait and
2274    /// landscape crosses it in both directions.
2275    #[test]
2276    fn the_slack_stops_a_reopen_over_rounding() {
2277        // 80 columns is "small", so the popup is 100% wide: 78 usable
2278        assert!(!outgrown((78, 19), (80, 24), 2));
2279        // one column of growth is not worth a flicker
2280        assert!(!outgrown((78, 19), (81, 24), 2));
2281        // portrait to landscape: 80 -> 140 crosses the rule, 80% of 140 is 112
2282        assert!(outgrown((78, 19), (140, 40), 2));
2283    }
2284
2285    /// What a resize carries over. Losing the query or the cursor to a rotation
2286    /// would make the reopen worse than the stuck popup it replaces.
2287    #[test]
2288    fn a_resize_hands_over_what_the_picker_was_doing() {
2289        let mut a = app(THREE);
2290        a.query = "banana".into();
2291        a.mode = Mode::Run;
2292        a.search = true;
2293        a.preview = false;
2294        a.query_changed();
2295        let state = State {
2296            query: a.query.clone(),
2297            mode: a.mode.key(),
2298            search: a.search,
2299            preview: a.preview,
2300            on: a.selected().map(|r| r.pane_id.clone()).unwrap_or_default(),
2301            // The client the popup was on, which the reopen must target rather
2302            // than asking tmux which one is "current".
2303            client: "/dev/pts/7".into(),
2304        };
2305        assert_eq!(
2306            state,
2307            State {
2308                query: "banana".into(),
2309                mode: "run",
2310                search: true,
2311                preview: false,
2312                on: "%2".into(),
2313                client: "/dev/pts/7".into(),
2314            }
2315        );
2316        // …and it comes back as the same picker on the other side
2317        assert_eq!(Mode::from_key(state.mode), Mode::Run);
2318        assert_eq!(Mode::from_key("outdated"), Mode::Outdated);
2319        assert_eq!(Mode::from_key(""), Mode::All);
2320    }
2321
2322    /// focus() is what covers a REFUSED restart: nothing is held, so the rebuild
2323    /// has nothing to re-pin to, and without it the cursor fell to the top on
2324    /// exactly the presses that did nothing.
2325    #[test]
2326    fn focus_puts_the_cursor_back_and_is_silent_when_it_cannot() {
2327        let mut a = app(THREE);
2328        a.sel = 0;
2329        a.focus("%3");
2330        assert_eq!(a.selected().map(|r| r.pane_id.as_str()), Some("%3"));
2331        a.focus("%404");
2332        assert_eq!(
2333            a.selected().map(|r| r.pane_id.as_str()),
2334            Some("%3"),
2335            "a pane that is not listed leaves the cursor alone"
2336        );
2337    }
2338
2339    const THREE: &str = "%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie\n\
2340                         %2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
2341                         %3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart";
2342
2343    /// The rows are fitted to the area they are drawn in, less the border and the
2344    /// pointer. Getting this wrong is invisible until a row is one column too
2345    /// long and the right-hand columns fall off.
2346    #[test]
2347    fn the_row_width_excludes_the_border_and_the_pointer() {
2348        assert_eq!(row_width(130), 126);
2349        assert_eq!(row_width(2), 0); // narrower than its own chrome
2350        assert_eq!(row_width(0), 0);
2351    }
2352
2353    #[test]
2354    fn tab_steps_round_the_cycle_and_starts_over() {
2355        let mut m = Mode::All;
2356        let seen: Vec<Mode> = (0..6)
2357            .map(|_| {
2358                m = m.next(true, true);
2359                m
2360            })
2361            .collect();
2362        assert_eq!(
2363            seen,
2364            vec![
2365                Mode::Input,
2366                Mode::Run,
2367                Mode::Idle,
2368                Mode::Outdated,
2369                Mode::Dead,
2370                Mode::All
2371            ]
2372        );
2373    }
2374
2375    /// Ended is skipped where there is nothing to show, rather than trapping the
2376    /// picker in a mode with no rows in it.
2377    #[test]
2378    fn the_ended_mode_is_skipped_without_a_sessions_cache() {
2379        assert_eq!(Mode::Idle.next(false, false), Mode::All);
2380        assert_eq!(Mode::Idle.next(true, false), Mode::Dead);
2381    }
2382
2383    /// …and so is outdated, where nothing is installed to judge a version
2384    /// against: every row would be measured against an empty version, so the
2385    /// list could only ever be empty.
2386    #[test]
2387    fn the_outdated_mode_is_skipped_when_no_version_is_installed() {
2388        assert_eq!(Mode::Idle.next(false, true), Mode::Outdated);
2389        assert_eq!(Mode::Outdated.next(false, true), Mode::All);
2390        assert_eq!(Mode::Outdated.next(true, true), Mode::Dead);
2391        // both gates off: idle is the last stop
2392        assert_eq!(Mode::Idle.next(false, false), Mode::All);
2393    }
2394
2395    #[test]
2396    fn the_label_says_which_list_and_what_is_on() {
2397        assert_eq!(label(Mode::All, false, false, false), " agent sessions ");
2398        assert_eq!(
2399            label(Mode::Input, true, false, false),
2400            " waiting for an answer · live "
2401        );
2402        assert_eq!(
2403            label(Mode::Outdated, false, false, false),
2404            " running outdated code "
2405        );
2406        assert_eq!(
2407            label(Mode::Dead, true, true, false),
2408            " past sessions · live · ⌕ "
2409        );
2410    }
2411
2412    /// The list ctrl-x and F8 act on, gathered in one place. It crosses the four
2413    /// state modes, because being behind is not a state.
2414    #[test]
2415    fn the_outdated_mode_lists_the_rows_a_restart_would_act_on() {
2416        let mut a = app(VERSIONS);
2417        a.src.newver = "2.1.243".into();
2418        a.mode = Mode::Outdated;
2419        a.rebuild();
2420        assert_eq!(ids(&a), ["%1", "%2"], "behind, whatever they are doing");
2421
2422        // …and with nothing installed to compare against, nothing is behind.
2423        a.src.newver = String::new();
2424        a.rebuild();
2425        assert!(a.view.is_empty());
2426    }
2427
2428    const VERSIONS: &str = "%1\ta:1.1\t/h\tclaude\t2.1.229\tidle\t-\tbehind\n\
2429                            %2\tb:1.1\t/h\tclaude\t2.1.229\tinput\t-\tbehind and asking\n\
2430                            %3\tc:1.1\t/h\tclaude\t2.1.243\trun\t-\tcurrent\n\
2431                            ha:%4\td:1.1\t/h\tclaude\t2.1.229\tidle\t-\tover there";
2432
2433    /// The stamp names the tool as well as the version, or a bare number in a
2434    /// corner would read as one more agent version like the ones down the right
2435    /// of every row. It is the crate version, which is what `taimux version`
2436    /// prints and what release-please bumps, so the three cannot drift.
2437    #[test]
2438    fn the_stamp_names_the_tool_and_carries_the_crate_version() {
2439        let tag = version_tag();
2440        assert!(tag.contains("taimux"));
2441        assert!(tag.contains(env!("CARGO_PKG_VERSION")));
2442        // padded both sides, so it does not touch the border corner
2443        assert!(tag.starts_with(' ') && tag.ends_with(' '));
2444    }
2445
2446    /// The stamp gives way to the count, never the other way round: ratatui
2447    /// gives a right-aligned title precedence, so without the check the count
2448    /// is what gets eaten, and the count is the live half.
2449    #[test]
2450    fn the_stamp_yields_to_the_count_on_a_narrow_border() {
2451        let count = " 5/5 ";
2452        let need = count.len() + version_tag().chars().count() + 2;
2453        assert!(room_for_tag(need as u16, count));
2454        assert!(!room_for_tag(need as u16 - 1, count));
2455        // a four-figure list needs more room for the same window
2456        assert!(!room_for_tag(need as u16, " 1000/1000 "));
2457        // and a window narrower than the stamp alone never gets it
2458        assert!(!room_for_tag(16, count));
2459    }
2460
2461    /// The header only ever advertises what is really bound: a key that does
2462    /// nothing is worse than a shorter header.
2463    #[test]
2464    fn the_header_advertises_only_bound_keys() {
2465        let bare = header(false, false, false, false);
2466        assert!(!bare.contains("ctrl-x"));
2467        assert!(!bare.contains("resume"));
2468        assert!(!bare.contains("ctrl-t"));
2469        assert!(header(true, false, false, false).contains("ctrl-x"));
2470        assert!(header(false, true, false, false).contains("enter: switch/resume"));
2471        assert!(header(false, false, true, true).contains("(on)"));
2472        assert!(!header(false, false, true, false).contains("(on)"));
2473    }
2474
2475    #[test]
2476    fn a_mode_shows_only_that_state() {
2477        let mut a = app(THREE);
2478        assert_eq!(a.view.len(), 3);
2479        a.mode = Mode::Run;
2480        a.rebuild();
2481        assert_eq!(a.view.len(), 1);
2482        assert!(a.selected().unwrap().plain().contains("banana"));
2483    }
2484
2485    #[test]
2486    fn the_query_filters_and_ranks() {
2487        let mut a = app(THREE);
2488        a.query = "banana".into();
2489        a.view = filter(&a.all, &a.query, &a.matcher);
2490        assert_eq!(a.view.len(), 1);
2491
2492        // an AND of terms, as fzf's extended search does, not one fuzzy match
2493        a.query = "apple tart".into();
2494        a.view = filter(&a.all, &a.query, &a.matcher);
2495        assert!(a.view.is_empty());
2496    }
2497
2498    #[test]
2499    fn no_query_keeps_the_lists_own_order() {
2500        let a = app(THREE);
2501        assert_eq!(a.view, vec![0, 1, 2]);
2502    }
2503
2504    /// A rebuild puts the cursor back on the same SESSION, not the same index.
2505    /// That is what --track --id-nth=2 buys fzf, and it matters because the
2506    /// refresh timer rebuilds under you while you are moving.
2507    #[test]
2508    fn a_rebuild_keeps_the_cursor_on_the_same_session() {
2509        let mut a = app(THREE);
2510        a.sel = 2;
2511        let was = a.selected().unwrap().pane_id.clone();
2512        // a session vanishes from the top of the list
2513        a.src = src("%2\tb:1.1\t/h\tclaude\t1\trun\t-\tbanana bread\n\
2514                     %3\tc:1.1\t/h\tclaude\t1\tinput\t-\tcherry tart");
2515        a.fetch();
2516        a.rebuild();
2517        assert_eq!(a.selected().unwrap().pane_id, was);
2518        assert_eq!(a.sel, 1);
2519    }
2520
2521    #[test]
2522    fn a_cursor_whose_row_is_gone_falls_back_to_the_top() {
2523        let mut a = app(THREE);
2524        a.sel = 2;
2525        a.src = src("%1\ta:1.1\t/h\tclaude\t1\tidle\t-\tapple pie");
2526        a.fetch();
2527        a.rebuild();
2528        assert_eq!(a.sel, 0);
2529    }
2530
2531    #[test]
2532    fn the_cursor_wraps_both_ways() {
2533        let mut a = app(THREE);
2534        a.move_by(-1);
2535        assert_eq!(a.sel, 2);
2536        a.move_by(1);
2537        assert_eq!(a.sel, 0);
2538    }
2539
2540    /// A page CLAMPS where a single step wraps, and the difference is the point:
2541    /// holding Page Down to reach the bottom of a long list must not sail past
2542    /// the end and land back at the top, with nothing on the row to say so.
2543    #[test]
2544    fn a_page_clamps_where_a_single_step_wraps() {
2545        let mut a = app(THREE);
2546        a.move_page(1, 2);
2547        assert_eq!(a.sel, 2);
2548        a.move_page(1, 2); // already at the end, and it stays there
2549        assert_eq!(a.sel, 2);
2550        a.move_page(-1, 2);
2551        assert_eq!(a.sel, 0);
2552        a.move_page(-1, 2);
2553        assert_eq!(a.sel, 0);
2554    }
2555
2556    /// A list drawn zero rows tall (a pane too short for one) would otherwise
2557    /// make the key do nothing at all, which reads as the key being unbound.
2558    #[test]
2559    fn a_page_of_no_rows_still_moves_one() {
2560        let mut a = app(THREE);
2561        a.move_page(1, 0);
2562        assert_eq!(a.sel, 1);
2563    }
2564
2565    /// Page Up and Page Down were the two keys the port dropped: fzf bound them
2566    /// itself, `Home` and `End` were ported by hand and these were not, so one
2567    /// pair kept working and the other went quiet. Nothing failed, which is why
2568    /// it took a report. The suite drives the real keys in a real terminal (see
2569    /// tests/run.sh); this is the arithmetic underneath.
2570    #[test]
2571    fn an_empty_view_pages_without_panicking() {
2572        let mut a = app(THREE);
2573        a.query = "zzzzz".into();
2574        a.query_changed();
2575        assert!(a.view.is_empty());
2576        a.move_page(1, 8);
2577        a.move_page(-1, 8);
2578        assert_eq!(a.sel, 0);
2579    }
2580
2581    /// An empty list must not be indexed into, and every key still has to work on
2582    /// one: a query that matches nothing is the ordinary way to get here.
2583    #[test]
2584    fn an_empty_view_is_safe_to_navigate() {
2585        let mut a = app(THREE);
2586        a.query = "zzzzz".into();
2587        a.view = filter(&a.all, &a.query, &a.matcher);
2588        assert!(a.view.is_empty());
2589        a.move_by(1);
2590        a.move_by(-1);
2591        a.clamp();
2592        assert!(a.selected().is_none());
2593    }
2594
2595    /// The padding `capture-pane` adds is what made every waiting session read as
2596    /// idle when the state reader was ported. Same capture, same trap, so the
2597    /// preview trims before it takes a tail.
2598    #[test]
2599    fn the_preview_tail_ignores_the_padding_capture_pane_adds() {
2600        let screen = "one\ntwo\nthree\n\n\n\n\n\n\n\n";
2601        let t = tail(screen, 2);
2602        let text: Vec<String> = t
2603            .iter()
2604            .map(|l| l.spans.iter().map(|s| s.content.to_string()).collect())
2605            .collect();
2606        assert_eq!(text, vec!["two", "three"]);
2607    }
2608
2609    #[test]
2610    fn a_screen_shorter_than_the_room_is_shown_whole() {
2611        assert_eq!(tail("one\ntwo\n", 40).len(), 2);
2612        assert!(tail("", 40).is_empty());
2613        assert!(tail("\n\n\n", 40).is_empty());
2614    }
2615
2616    /// The whole point of the exercise: a paste is text, never an Enter. Its
2617    /// first line joins the query and the rest is dropped, rather than being
2618    /// submitted into whatever is behind the picker.
2619    #[test]
2620    fn a_pasted_newline_stays_out_of_the_query() {
2621        let text = "set -g @plugin foo\rdo not write below this line";
2622        let first = text.split(['\r', '\n']).next().unwrap();
2623        assert_eq!(first, "set -g @plugin foo");
2624    }
2625}