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