Skip to main content

taimux_cli/
act.rs

1//! The two keys that act rather than navigate: ctrl-x on a row, f8 on the list.
2//!
3//! Both own the whole screen for as long as they are up, because the picker has
4//! just handed it over, and both END in a detached restart: a restart waits up to
5//! twelve seconds for a session to exit and then polls for it to come back, so
6//! run inline it would freeze the popup for half a minute.
7//!
8//! The shape of each is the same and it is deliberate: **say what will happen,
9//! ask, and never offer something that would be refused anyway.** A restart that
10//! declines is not a failure to explain away, it is the guard working.
11
12use std::io::{Read, Write};
13
14use crate::{remote, restart};
15
16/// Own the screen the picker just handed over.
17fn clear(out: &mut impl Write) {
18    let _ = write!(out, "\x1b[H\x1b[2J");
19}
20
21/// What the terminal answered when a key was asked for.
22///
23/// The two cases used to be one `None`, and conflating them is why a screen
24/// could vanish before it was read. A key that cannot be waited for is not a
25/// key that was pressed: with no readable `/dev/tty` the read returns at once,
26/// `any_key` returns at once, and the caller's `resume()` wipes the screen in
27/// the same breath. Nothing anywhere said the pause had not happened.
28enum Pressed {
29    Key(char),
30    /// No `/dev/tty` to open, or it answered EOF or an error. **Nothing waited.**
31    Unreadable,
32}
33
34/// Wait for any single key, on the terminal rather than on stdin: the picker's
35/// stdout is where the answer goes, and a popup has no stdin worth reading.
36fn any_key() {
37    print!("  Press any key…");
38    let _ = std::io::stdout().flush();
39    if let Pressed::Unreadable = read_one_key() {
40        // Say so rather than racing past. This screen is about to be wiped by
41        // whoever handed the terminal over, so an unread message is the same as
42        // no message, and "it flashed and went" is exactly how this was
43        // reported.
44        println!("\r\n  (no readable terminal to wait on, so this was not held)");
45        let _ = std::io::stdout().flush();
46    }
47}
48
49fn read_one_key() -> Pressed {
50    let Ok(mut tty) = std::fs::File::open("/dev/tty") else {
51        return Pressed::Unreadable;
52    };
53    // Raw mode, or the read waits for a newline and "any key" becomes "Enter".
54    let raw = crossterm::terminal::enable_raw_mode().is_ok();
55    let mut b = [0u8; 1];
56    let got = match tty.read(&mut b) {
57        Ok(1) => Pressed::Key(b[0] as char),
58        // 0 is EOF and an Err is a terminal we cannot read; neither is a press.
59        _ => Pressed::Unreadable,
60    };
61    if raw {
62        let _ = crossterm::terminal::disable_raw_mode();
63    }
64    got
65}
66
67/// Report a child that could not be started, and hold the screen while it is
68/// read.
69///
70/// The caller has already left the alternate screen, so this lands where the
71/// child would have drawn, and the pause is what stops `resume()` wiping it.
72/// Without this the whole failure was invisible: the picker suspended, the
73/// child never ran, the picker resumed, and the only evidence was a flicker.
74///
75/// **Written to `/dev/tty`, never to stdout.** Everything else in this file is
76/// printed by a CHILD, whose stdout `act_child` has already pointed at the
77/// terminal. This one runs in the PICKER, whose stdout carries exactly one
78/// thing, the chosen pane id. Printing it cost the first attempt at this fix:
79/// the report went down the pipe to whoever called the picker, the screen
80/// stayed blank, and the test caught it.
81pub fn report_failed_child(what: &str, e: &std::io::Error) {
82    let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") else {
83        return;
84    };
85    // \r\n throughout: the picker may still have the terminal in raw mode when
86    // this runs, where a bare \n moves down without returning to column 0 and
87    // the message walks off to the right.
88    let _ = write!(
89        tty,
90        "\x1b[H\x1b[2J\x1b[1mtaimux: could not run {}\x1b[0m\r\n\r\n  {}\r\n\r\n\
91         \x20 The picker re-runs its OWN binary for this, so the usual cause is\r\n\
92         \x20 that binary moving or being rebuilt underneath a running picker.\r\n\
93         \x20 Closing and reopening the picker picks up the new one.\r\n\r\n\
94         \x20 Press any key…",
95        what, e
96    );
97    let _ = tty.flush();
98    let _ = read_one_key();
99}
100
101/// The pane lines of a plan, without its detail lines or its skip list.
102///
103/// The detail lines are indented further and the skip list sits past the
104/// `skipped (` heading, so this is a state machine over two headings rather than
105/// a guess at indentation.
106fn plan_panes(plan: &str) -> Vec<&str> {
107    let mut on = false;
108    let mut out = Vec::new();
109    for l in plan.lines() {
110        if l.starts_with("to restart (") {
111            on = true;
112            continue;
113        }
114        if l.starts_with("skipped (") {
115            on = false;
116        }
117        if on && l.starts_with("  %") {
118            out.push(l);
119        }
120    }
121    out
122}
123
124fn count_in(plan: &str, heading: &str) -> Option<usize> {
125    plan.lines()
126        .find(|l| l.starts_with(heading))
127        .and_then(|l| l.split(['(', ')']).nth(1))
128        .and_then(|n| n.parse().ok())
129}
130
131/// The one line of a plan that is about this pane.
132fn why_for(plan: &str, pane: &str) -> Option<String> {
133    let needle = format!("  {} ", pane);
134    plan.lines()
135        .find(|l| l.contains(&needle))
136        .map(|l| l.trim_start().to_string())
137}
138
139/// Fire a restart and return AT ONCE, appending to the log beside everything
140/// else this keeps in the runtime directory.
141///
142/// Detached, so closing the popup (or picking a pane and leaving) cannot SIGHUP a
143/// restart mid-flight and leave a pane with a dead session and nothing typed
144/// into it. Nothing is printed: the picker owns the screen, and a detached job
145/// has nowhere to print anyway.
146pub fn note(line: &str) {
147    let log = taimux_core::paths::runtime_dir().join("restart.log");
148    if let Some(d) = log.parent() {
149        let _ = std::fs::create_dir_all(d);
150    }
151    if let Ok(mut f) = std::fs::OpenOptions::new()
152        .create(true)
153        .append(true)
154        .open(&log)
155    {
156        let _ = writeln!(f, "--- {} {}", taimux_core::log::stamp(), line);
157    }
158}
159
160/// The stdio a DETACHED job must have, and the reason it is a function.
161///
162/// stdout and stderr go to the log, which was always so. stdin is the one that
163/// was missing, and Rust inherits it: `Command::spawn` defaults every stream to
164/// the parent's, so a restart fired from the picker kept the POPUP'S pty open
165/// on fd 0 long after the picker had exited. tmux then had a popup whose
166/// command was gone but whose terminal still had a holder, which is a popup
167/// that sits there blank, echoing whatever is typed at it, until the restart
168/// finally ends and lets go. Reported exactly that way: blank after F8,
169/// confirm, then Enter, and gone the moment every agent had come back.
170///
171/// It is also the definition of detaching. A job that outlives the thing that
172/// started it must not hold that thing's terminal.
173fn detached(
174    cmd: &mut std::process::Command,
175    out: std::fs::File,
176    err: std::fs::File,
177) -> &mut std::process::Command {
178    cmd.stdin(std::process::Stdio::null())
179        .stdout(out)
180        .stderr(err)
181}
182
183pub fn restart_detached(exe: &str, pane: &str, force: bool) {
184    let log = taimux_core::paths::runtime_dir().join("restart.log");
185    note(if pane.is_empty() {
186        "all outdated panes"
187    } else {
188        pane
189    });
190    let mut args: Vec<String> = vec!["restart".into(), "-y".into()];
191    if pane.starts_with('%') {
192        args.push("--pane".into());
193        args.push(pane.into());
194    }
195    if force {
196        args.push("--include-busy".into());
197    }
198    let Ok(out) = std::fs::OpenOptions::new()
199        .create(true)
200        .append(true)
201        .open(&log)
202    else {
203        return;
204    };
205    let Ok(err) = out.try_clone() else { return };
206    let Ok(out2) = out.try_clone() else { return };
207    // setsid so it survives the popup closing. `Command` cannot setsid without
208    // libc, so this goes through the program, and falls back to a plain spawn
209    // where there is none: a restart that dies with the popup is still better
210    // than no restart, and the log says which happened.
211    let spawned = detached(
212        std::process::Command::new("setsid").arg(exe).args(&args),
213        out2,
214        err,
215    )
216    .spawn();
217    if spawned.is_err() {
218        let Ok(out3) = std::fs::OpenOptions::new()
219            .create(true)
220            .append(true)
221            .open(&log)
222        else {
223            return;
224        };
225        let Ok(err2) = out3.try_clone() else { return };
226        let _ = detached(std::process::Command::new(exe).args(&args), out3, err2).spawn();
227    }
228}
229
230/// ctrl-x: restart the highlighted session.
231pub fn restart_one(exe: &str, pane: &str) {
232    let mut out = std::io::stdout();
233
234    // An ended conversation has nothing to restart: there is no process behind
235    // it and no pane to type into. Enter is the key that acts on those rows, and
236    // saying so beats a keypress that looks like it did nothing.
237    if pane.starts_with("dead:") {
238        clear(&mut out);
239        println!("\x1b[1mtaimux: that session has already ended\x1b[0m\n");
240        println!("  There is nothing running to restart. Press Enter on it instead:");
241        println!("  it opens again in a new window, in its own directory.\n");
242        any_key();
243        return;
244    }
245
246    // Another host's session. A restart reads /proc, resolves a transcript under
247    // ~/.claude and sends keys to a tmux pane, all of which have to happen where
248    // the session is, and that host's own taimux does all three. Rather than
249    // half a feature, the key says so and hands over the line that would do it.
250    if let Some(host) = remote::pane_host(pane) {
251        clear(&mut out);
252        println!(
253            "\x1b[1mtaimux: {} is on {}\x1b[0m\n",
254            remote::pane_local(pane),
255            host
256        );
257        println!("  Restarting is local-only. From that host, or from here:\n");
258        println!(
259            "    ssh {} taimux restart --pane {}\n",
260            host,
261            remote::pane_local(pane)
262        );
263        any_key();
264        return;
265    }
266    if !pane.starts_with('%') {
267        return; // not a pane id: nothing to act on
268    }
269
270    let plan = plan_of(exe, &["-n", "--pane", pane]);
271    if plan.contains("\nto restart (") || plan.starts_with("to restart (") {
272        restart_detached(exe, pane, false);
273        return;
274    }
275
276    clear(&mut out);
277    println!("\x1b[1mtaimux: {} will not restart cleanly\x1b[0m\n", pane);
278    let Some(why) = why_for(&plan, pane) else {
279        println!("  Nothing to do: it is already on the installed version, or it is");
280        println!("  not a claude pane.\n");
281        any_key();
282        return;
283    };
284    println!("  {}", why);
285
286    // Never offer a force that would be refused anyway: an unresolved
287    // conversation, this very pane, and a transcript already claimed by another
288    // pane are all untouched by --include-busy.
289    let forced = plan_of(exe, &["-n", "--pane", pane, "--include-busy"]);
290    if !(forced.contains("\nto restart (") || forced.starts_with("to restart (")) {
291        println!(
292            "\n  Forcing would not help:\n  {}",
293            why_for(&forced, pane).unwrap_or_else(|| "same refusal".into())
294        );
295        println!();
296        any_key();
297        return;
298    }
299
300    println!("\n  Forcing accepts losing an in-flight turn. A pane holding a");
301    println!("  permission dialog is still refused, so nothing gets answered for you.");
302    print!("\n\x1b[1mForce the restart?\x1b[0m [Y/n] ");
303    let _ = out.flush();
304    // An unreadable terminal declines, which is the safe default, but it SAYS
305    // so: a silent "no" here is indistinguishable from the user answering n,
306    // and the two want very different things done about them.
307    let go = match read_one_key() {
308        Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
309        Pressed::Unreadable => {
310            print!("\r\n  (no readable terminal to answer on, so nothing was restarted)");
311            false
312        }
313    };
314    println!();
315    if go {
316        restart_detached(exe, pane, true);
317        println!(
318            "\n  Forced, detached. Watch the version, or read\n  {}",
319            taimux_core::paths::runtime_dir()
320                .join("restart.log")
321                .display()
322        );
323        std::thread::sleep(std::time::Duration::from_millis(1200));
324    } else {
325        println!("\n  Left alone.");
326        std::thread::sleep(std::time::Duration::from_millis(600));
327    }
328}
329
330/// f8: restart every outdated session, after showing the plan and asking.
331///
332/// "outdated" throughout, which is the word the picker's own list of those rows
333/// uses: the border label, the header hint and this screen are all reached in
334/// one keypress of each other, and two words for one thing there read as two
335/// different things.
336pub fn sweep(exe: &str) {
337    let mut out = std::io::stdout();
338    // Timed, and written down when it fires. A sweep is the one press that can
339    // leave the picker with real work to do afterwards, and when it was reported
340    // as a freeze there was no record of how long any phase took, so every
341    // theory about it stayed a theory. The picker writes its own slow refreshes
342    // to this same log, which is what lets the two halves be read as one
343    // timeline.
344    let at = std::time::Instant::now();
345    let plan = plan_of(exe, &["-n"]);
346    let planned = at.elapsed();
347    let n = count_in(&plan, "to restart (");
348    let skipped = count_in(&plan, "skipped (");
349
350    clear(&mut out);
351    println!("\x1b[1mtaimux: restart every outdated session\x1b[0m\n");
352
353    let Some(n) = n.filter(|n| *n >= 1) else {
354        println!("Nothing to restart. Either every session is already on the");
355        println!("installed version, or the ones behind it are busy.");
356        if let Some(s) = skipped {
357            println!("\n  {} left alone.", s);
358        }
359        println!();
360        any_key();
361        return;
362    };
363
364    for l in plan_panes(&plan) {
365        println!("{}", l);
366    }
367    if let Some(s) = skipped {
368        println!("\n  {} left alone (working, waiting, or unidentified).", s);
369    }
370    print!("\n\x1b[1mRestart {} session(s)?\x1b[0m [Y/n] ", n);
371    let _ = out.flush();
372    // An unreadable terminal declines, which is the safe default, but it SAYS
373    // so: a silent "no" here is indistinguishable from the user answering n,
374    // and the two want very different things done about them.
375    let go = match read_one_key() {
376        Pressed::Key(c) => restart::confirm_yes(&c.to_string()),
377        Pressed::Unreadable => {
378            print!("\r\n  (no readable terminal to answer on, so nothing was restarted)");
379            false
380        }
381    };
382    println!();
383    if go {
384        note(&format!(
385            "sweep: {} to restart, plan took {:.1}s, {:.1}s from keypress to firing",
386            n,
387            planned.as_secs_f32(),
388            at.elapsed().as_secs_f32()
389        ));
390        restart_detached(exe, "", false);
391        println!(
392            "\nStarted, detached. Watch the version column, or read\n{}",
393            taimux_core::paths::runtime_dir()
394                .join("restart.log")
395                .display()
396        );
397        std::thread::sleep(std::time::Duration::from_millis(1200));
398    } else {
399        println!("\nNothing restarted.");
400        std::thread::sleep(std::time::Duration::from_millis(700));
401    }
402}
403
404/// A plan, as text, by asking ourselves for one.
405///
406/// A subprocess rather than a direct call, and that is not laziness: the plan has
407/// to be the SAME text the reader would get from `taimux restart -n`, and the
408/// only way to be sure of that is to run it.
409fn plan_of(exe: &str, args: &[&str]) -> String {
410    std::process::Command::new(exe)
411        .arg("restart")
412        .args(args)
413        .output()
414        .map(|o| {
415            let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
416            s.push_str(&String::from_utf8_lossy(&o.stderr));
417            s
418        })
419        .unwrap_or_default()
420}
421
422// ── ctrl-o: carry a conversation into a different agent ─────────────────────
423
424/// Which conversation a row is about, and which tool wrote it.
425///
426/// Two shapes answer: a **past** row says so in its own id, and a **live claude
427/// pane** can be resolved to the transcript it is writing (the pane map, then
428/// the argv, which is the same ladder `restart` walks). A live pane running any
429/// other agent cannot be, and that is not a gap this key can close: which
430/// conversation a pane is on is something the agent has to publish, and claude
431/// is the only one that does.
432pub fn conversation_of(pane: &str) -> Result<(String, String), String> {
433    if let Some((agent, key)) = taimux_core::index::split_past_id(pane) {
434        return Ok((agent.to_string(), key.to_string()));
435    }
436    if pane == "dead:!" {
437        return Err("that row is a note, not a conversation".into());
438    }
439    if remote::pane_host(pane).is_some() {
440        return Err(format!(
441            "{} is on another host, and a handoff reads its transcript and starts \
442             an agent in its directory, both of which have to happen over there",
443            remote::pane_local(pane)
444        ));
445    }
446    let rows = taimux_core::panes::agent_rows();
447    let row = rows
448        .lines()
449        .map(|l| l.split('\t').collect::<Vec<_>>())
450        .find(|f| f.len() >= 5 && f[0] == pane)
451        // Every row in that scan is an agent pane, so a miss is either a pane
452        // that has closed or one running an ordinary shell. Neither has a
453        // conversation, and saying "gone" about the second would send you
454        // looking for a pane that is right there.
455        .ok_or_else(|| format!("{} is not running an agent, or is not there any more", pane))?;
456    if row[3] != "claude" {
457        return Err(format!(
458            "taimux cannot tell which conversation a {} pane is on, so there is \
459             nothing to hand over. Only claude publishes that",
460            row[3]
461        ));
462    }
463    let pid: i32 = row[4].parse().unwrap_or(0);
464    let cwd = std::fs::read_link(format!("/proc/{}/cwd", pid))
465        .map(|p| p.to_string_lossy().into_owned())
466        .unwrap_or_else(|_| row[2].to_string());
467    match taimux_core::conv::resolve_from_pane(pane, &cwd, pid) {
468        Some(r) => Ok(("claude".into(), r.transcript.to_string_lossy().into_owned())),
469        None => Err("that pane's conversation could not be identified".into()),
470    }
471}
472
473/// ctrl-o: pick a target agent, then open the conversation in it.
474pub fn handoff_one(pane: &str) {
475    let mut out = std::io::stdout();
476    clear(&mut out);
477
478    let (agent, key) = match conversation_of(pane) {
479        Ok(v) => v,
480        Err(why) => {
481            println!("\x1b[1mtaimux: nothing to hand off here\x1b[0m\n");
482            println!("  {}.\n", why);
483            any_key();
484            return;
485        }
486    };
487
488    // The source is not offered as a target: continuing a conversation in the
489    // tool that already has it is Enter, which resumes it rather than starting a
490    // fresh one carrying a summary of itself.
491    let targets: Vec<&str> = taimux_core::handoff::installed()
492        .into_iter()
493        .filter(|t| *t != agent)
494        .collect();
495    if targets.is_empty() {
496        println!("\x1b[1mtaimux: no other agent is installed\x1b[0m\n");
497        println!("  A handoff starts a DIFFERENT tool on this conversation, and");
498        println!("  {} is the only one on your PATH.\n", agent);
499        any_key();
500        return;
501    }
502
503    let meta = taimux_core::agents::meta(&agent, &key);
504    let title = if meta.title.is_empty() {
505        "(no title)"
506    } else {
507        &meta.title
508    };
509    println!(
510        "\x1b[1mtaimux: continue this {} conversation elsewhere\x1b[0m\n",
511        taimux_core::handoff::display_name(&agent)
512    );
513    println!("  \x1b[1;36m{}\x1b[0m", title);
514    println!(
515        "  \x1b[90m{}\x1b[0m\n",
516        if meta.cwd.is_empty() { "?" } else { &meta.cwd }
517    );
518    println!("  The prompt carries the task, the repository's state and the last");
519    println!("  few turns, and points at the transcript for the rest.\n");
520    for (i, t) in targets.iter().enumerate() {
521        println!(
522            "    \x1b[1m{}\x1b[0m  {}",
523            i + 1,
524            taimux_core::handoff::display_name(t)
525        );
526    }
527    println!("\n  Anything else cancels.");
528    let _ = out.flush();
529
530    let Pressed::Key(c) = read_one_key() else {
531        println!("\r\n  (no readable terminal to ask on)");
532        return;
533    };
534    let Some(target) = c
535        .to_digit(10)
536        .and_then(|n| targets.get(n as usize - 1).copied())
537    else {
538        return; // anything that is not one of the offered numbers cancels
539    };
540
541    // Built AFTER the choice, not before: it forks git twice and reads the tail
542    // of a transcript, which is work nobody asked for while a menu is on screen.
543    let turns = taimux_core::env::var("TAIMUX_HANDOFF_TURNS")
544        .and_then(|v| v.parse().ok())
545        .unwrap_or(taimux_core::handoff::DEFAULT_TURNS);
546    let prompt = taimux_core::handoff::build(&agent, &key, turns);
547    let cmd = match taimux_core::handoff::launch(target, &prompt) {
548        Ok(c) => c,
549        Err(why) => {
550            println!("\r\n\r\n  {}.\r\n", why);
551            any_key();
552            return;
553        }
554    };
555    // The same refusal Enter makes, and for the same reason: an agent started in
556    // the wrong directory works on a different project, silently.
557    let cwd = if std::path::Path::new(&meta.cwd).is_dir() {
558        meta.cwd.clone()
559    } else {
560        clear(&mut out);
561        println!(
562            "\x1b[1mtaimux: {} is gone\x1b[0m\n",
563            if meta.cwd.is_empty() {
564                "its directory"
565            } else {
566                &meta.cwd
567            }
568        );
569        println!("  A handoff starts an agent in the directory the conversation ran");
570        println!("  in, and that one is no longer there.\n");
571        any_key();
572        return;
573    };
574
575    if taimux_core::tmux::run(&["new-window", "-c", &cwd, &cmd]) {
576        return; // the new window is the feedback
577    }
578    clear(&mut out);
579    println!("\x1b[1mtaimux: tmux would not open a window\x1b[0m\n");
580    any_key();
581}
582
583#[cfg(test)]
584mod tests {
585
586    /// A detached job must not hold the terminal it was detached from.
587    ///
588    /// This is the F8 blank-popup bug, and it is invisible by inspection:
589    /// `Command::spawn` inherits every stream it is not told about, so the
590    /// omission looks like nothing at all. Asserted through /proc rather than
591    /// by reading the source, because the whole defect was that the source
592    /// looked fine.
593    #[test]
594    fn a_detached_job_does_not_hold_the_terminal() {
595        let dir = std::env::temp_dir().join(format!("taimux-detached-{}", std::process::id()));
596        std::fs::create_dir_all(&dir).unwrap();
597        let logp = dir.join("log");
598        let out = std::fs::File::create(&logp).unwrap();
599        let err = out.try_clone().unwrap();
600
601        let mut cmd = std::process::Command::new("sleep");
602        cmd.arg("30");
603        // Hand it a terminal FIRST, so `detached` has something hostile to
604        // override. Without this the test is vacuous wherever the harness's own
605        // stdin is already /dev/null, which is most of CI: inheriting would give
606        // /dev/null too and the assertion would pass on the broken code.
607        let had_tty = match std::fs::File::open("/dev/tty") {
608            Ok(tty) => {
609                cmd.stdin(std::process::Stdio::from(tty));
610                true
611            }
612            Err(_) => false,
613        };
614        let mut child = detached(&mut cmd, out, err).spawn().expect("spawn sleep");
615
616        let fd0 = std::fs::read_link(format!("/proc/{}/fd/0", child.id()));
617        let _ = child.kill();
618        let _ = child.wait();
619        let _ = std::fs::remove_dir_all(&dir);
620
621        // /proc is Linux-only, and so is everything else here that reads it.
622        let Ok(fd0) = fd0 else { return };
623        if !had_tty {
624            // No controlling terminal to be wrongly kept, so there is nothing
625            // here to prove. Said out loud rather than passing quietly.
626            eprintln!("no /dev/tty in this environment, so this proves nothing");
627            return;
628        }
629        let fd0 = fd0.to_string_lossy().into_owned();
630        assert!(
631            !fd0.contains("/pts/") && !fd0.contains("/dev/tty"),
632            "a detached job kept a terminal on stdin: {fd0}"
633        );
634        assert!(
635            fd0.contains("null"),
636            "expected /dev/null on stdin, got {fd0}"
637        );
638    }
639
640    use super::*;
641
642    const PLAN: &str = "claude: 2.1.258 installed at /l/claude\n\
643                        \n\
644                        to restart (2):\n\
645                        \x20 %19   platform:4.1   a title\n\
646                        \x20       2.1.100 -> 2.1.258, pane map, idle\n\
647                        \x20       command claude --resume /t.jsonl\n\
648                        \x20 %23   platform:4.5   another\n\
649                        \x20       2.1.100 -> 2.1.258, pane map, idle\n\
650                        \x20       command claude\n\
651                        \n\
652                        skipped (3):\n\
653                        \x20 %77 main:1.1  2.1.100, run: rerun when idle\n";
654
655    /// The pane lines only: the detail lines are indented further and the skip
656    /// list sits past its own heading, so a guess at indentation would take
657    /// either of them.
658    #[test]
659    fn only_the_pane_lines_of_the_plan_are_shown() {
660        assert_eq!(
661            plan_panes(PLAN),
662            vec![
663                "  %19   platform:4.1   a title",
664                "  %23   platform:4.5   another"
665            ]
666        );
667    }
668
669    #[test]
670    fn the_counts_come_off_the_headings() {
671        assert_eq!(count_in(PLAN, "to restart ("), Some(2));
672        assert_eq!(count_in(PLAN, "skipped ("), Some(3));
673        assert_eq!(count_in("nothing to restart.\n", "to restart ("), None);
674    }
675
676    #[test]
677    fn a_panes_own_reason_is_picked_out_of_the_skip_list() {
678        assert_eq!(
679            why_for(PLAN, "%77").as_deref(),
680            Some("%77 main:1.1  2.1.100, run: rerun when idle")
681        );
682        assert_eq!(why_for(PLAN, "%99"), None);
683    }
684
685    /// A plan with nothing in it must not be read as having something: that is
686    /// the difference between firing a restart and explaining a refusal.
687    #[test]
688    fn an_empty_plan_is_not_mistaken_for_a_full_one() {
689        let empty = "claude: 2.1.258 installed at /l\n\nnothing to restart.\n";
690        assert!(plan_panes(empty).is_empty());
691        assert_eq!(count_in(empty, "to restart ("), None);
692    }
693}