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