diff options
Diffstat (limited to 'crates/dinggerd/src/restore.rs')
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 193 |
1 files changed, 176 insertions, 17 deletions
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs index 6ed19a7..2332865 100644 --- a/crates/dinggerd/src/restore.rs +++ b/crates/dinggerd/src/restore.rs @@ -8,10 +8,51 @@ //! Each session is rebuilt with its windows (index, name, automatic-rename), //! panes (directory, in order), exact layout, zoom, and active window and //! pane. Everything is addressed by tmux id, so names with `.` or `:` work. +//! +//! Then, once each new shell shows its prompt: agents with a session +//! reference are resumed (`pi --session <file>`, run), and every other +//! saved command line is typed but not run, for the user to press Enter. + +use std::time::{Duration, Instant}; -use crate::snapshot::{self, SessionSnap, Snapshot, WindowSnap}; +use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap}; use crate::tmux; +/// What restore types into a rebuilt pane. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Typed { + /// Resume the agent's session: typed and run. + Resume(String), + /// The saved command line: typed, not run. + Retype(String), +} + +/// The command that resumes an agent's session. +fn resume_command(agent: &str, session: &str) -> Option<String> { + match agent { + "pi" => Some(format!("pi --session {}", shell_quote(session))), + _ => None, + } +} + +pub fn typed_for(pane: &PaneSnap) -> Option<Typed> { + let typeable = |text: &String| !text.trim().is_empty() && !text.chars().any(char::is_control); + if let Some(agent) = &pane.agent { + let resume = agent.session.as_deref().and_then(|session| resume_command(&agent.agent, session)); + if let Some(command) = resume.filter(typeable) { + return Some(Typed::Resume(command)); + } + } + pane.command.clone().filter(typeable).map(Typed::Retype) +} + +/// A rebuilt pane and what it held. +struct Rebuilt { + pane_id: String, + label: String, + pane: PaneSnap, +} + /// What restore does with a saved session. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Plan { @@ -59,12 +100,7 @@ fn saved_bare(session: &SessionSnap) -> bool { matches!(&session.windows[..], [window] if matches!(&window.panes[..], [pane] if pane.agent.is_none())) } -fn is_shell(command: &str) -> bool { - matches!( - command.trim_start_matches('-'), - "zsh" | "bash" | "fish" | "sh" | "dash" | "ksh" | "tcsh" | "csh" | "nu" | "elvish" | "xonsh" - ) -} +use snapshot::is_shell; /// The running server's sessions; empty when tmux is not running. pub fn existing_sessions() -> Vec<Existing> { @@ -95,7 +131,13 @@ fn first_line(out: String) -> String { } /// Build one window's panes, layout, and options. `pane` is its first pane. -fn build_window(window_id: &str, first_pane: String, window: &WindowSnap) -> Result<(), String> { +fn build_window( + window_id: &str, + first_pane: String, + window: &WindowSnap, + session: &str, + rebuilt: &mut Vec<Rebuilt>, +) -> Result<(), String> { let width = window.width.max(10).to_string(); let height = window.height.max(5).to_string(); // The saved size, so the layout applies exactly; unset afterwards so the @@ -129,11 +171,19 @@ fn build_window(window_id: &str, first_pane: String, window: &WindowSnap) -> Res } } } + for (pane_id, pane) in panes.into_iter().zip(&window.panes) { + let label = if window.panes.len() > 1 { + format!("{session}:{}.{}", window.name, pane.index) + } else { + format!("{session}:{}", window.name) + }; + rebuilt.push(Rebuilt { pane_id, label, pane: pane.clone() }); + } Ok(()) } /// Create `session` under `name`; returns its session id. -fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> { +fn build_session(session: &SessionSnap, name: &str, rebuilt: &mut Vec<Rebuilt>) -> Result<String, String> { let Some(first) = session.windows.first() else { return Err(format!("{}: no windows saved", session.name)); }; @@ -151,7 +201,7 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> { if first_index != first.index.to_string() { tmux::run(&["move-window", "-s", first_window, "-t", &format!("{session_id}:{}", first.index)])?; } - build_window(first_window, first_pane.to_string(), first)?; + build_window(first_window, first_pane.to_string(), first, &session.name, rebuilt)?; for window in session.windows.iter().skip(1) { let cwd = window.panes.first().map(|pane| pane.cwd.as_str()).unwrap_or("~"); let created = first_line(tmux::run(&[ @@ -161,7 +211,7 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> { let Some((window_id, pane_id)) = created.split_once('\t') else { return Err(format!("unexpected new-window output: {created}")); }; - build_window(window_id, pane_id.to_string(), window)?; + build_window(window_id, pane_id.to_string(), window, &session.name, rebuilt)?; } if let Some(active) = session.windows.iter().find(|window| window.active) { tmux::run(&["select-window", "-t", &format!("{session_id}:{}", active.index)])?; @@ -171,9 +221,9 @@ fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> { /// Replace a bare session: build the saved one beside it, move its clients /// over, then remove it and take its name. -fn replace_session(session: &SessionSnap) -> Result<(), String> { +fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result<(), String> { let temporary = format!("{}-restoring-{}", session.name, std::process::id()); - let new_id = build_session(session, &temporary)?; + let new_id = build_session(session, &temporary, rebuilt)?; let clients = tmux::run(&["list-clients", "-F", "#{client_name}\t#{session_name}"]).unwrap_or_default(); for line in clients.lines() { if let Some((client, attached)) = line.split_once('\t') { @@ -186,6 +236,73 @@ fn replace_session(session: &SessionSnap) -> Result<(), String> { tmux::run(&["rename-session", "-t", &new_id, &session.name]).map(|_| ()) } +/// Wait for each new shell's prompt, then resume agents (spaced out) and +/// type the other command lines without running them; report which agents +/// did not start. +fn type_commands(rebuilt: &[Rebuilt]) { + let work: Vec<(&Rebuilt, Typed)> = + rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect(); + if work.is_empty() { + return; + } + // Text typed before the prompt is drawn is echoed twice; wait for it + // (the cursor leaves the top-left corner). + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let cursors = tmux::run(&["list-panes", "-a", "-F", "#{pane_id} #{cursor_x} #{cursor_y}"]).unwrap_or_default(); + let waiting = work.iter().any(|(r, _)| { + cursors.lines().any(|line| line == format!("{} 0 0", r.pane_id)) + }); + if !waiting || Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + std::thread::sleep(Duration::from_millis(200)); + + let mut resumed = Vec::new(); + for (r, typed) in &work { + match typed { + Typed::Resume(command) => { + let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]); + let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "Enter"]); + println!("resume {} {command}", r.label); + resumed.push(*r); + // Agents start one at a time. + std::thread::sleep(Duration::from_millis(500)); + } + Typed::Retype(command) => { + let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]); + println!("type {} {command} (press Enter to run)", r.label); + } + } + } + for (r, _) in &work { + if let Some(agent) = r.pane.agent.as_ref().filter(|agent| agent.state == "working") { + let name = if agent.name.is_empty() { &agent.agent } else { &agent.name }; + println!("interrupted {} {name}: it was working, its turn was cut short", r.label); + } + } + if resumed.is_empty() { + return; + } + // An agent that started has replaced the shell in the foreground. + let deadline = Instant::now() + Duration::from_secs(30); + let mut not_running: Vec<&Rebuilt> = resumed.clone(); + while !not_running.is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(500)); + not_running.retain(|r| { + tmux::run(&["display-message", "-p", "-t", &r.pane_id, "#{pane_current_command}"]) + .map(|command| is_shell(command.trim())) + .unwrap_or(true) + }); + } + println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len()); + for r in not_running { + println!("not running {}: check the pane for the error", r.label); + } +} + fn windows_and_panes(session: &SessionSnap) -> String { let panes: usize = session.windows.iter().map(|window| window.panes.len()).sum(); format!("{} windows, {panes} panes", session.windows.len()) @@ -236,6 +353,7 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str let snapshot = source(from)?; let existing = existing_sessions(); let mut failures = 0; + let mut rebuilt = Vec::new(); for (name, plan) in plan(&snapshot, &existing) { if !only.is_empty() && !only.contains(&name) { continue; @@ -254,11 +372,20 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str }; if dry_run { println!("{verb:<8} {name}: {what}"); + for window in &session.windows { + for pane in &window.panes { + match typed_for(pane) { + Some(Typed::Resume(command)) => println!(" resume {} {command}", window.name), + Some(Typed::Retype(command)) => println!(" type {} {command}", window.name), + None => {} + } + } + } continue; } let result = match plan { - Plan::Replace => replace_session(session), - _ => build_session(session, &name).map(|_| ()), + Plan::Replace => replace_session(session, &mut rebuilt), + _ => build_session(session, &name, &mut rebuilt).map(|_| ()), }; match result { Ok(()) => println!("{verb:<8} {name}: {what}"), @@ -268,6 +395,9 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str } } } + if !rebuilt.is_empty() { + type_commands(&rebuilt); + } if dry_run || from.is_some() { return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }; } @@ -280,7 +410,7 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str #[cfg(test)] mod tests { use super::*; - use crate::snapshot::{PaneSnap, ServerId, SessionSnap}; + use crate::snapshot::{AgentSnap, PaneSnap, ServerId, SessionSnap}; fn window(panes: usize) -> WindowSnap { WindowSnap { @@ -293,7 +423,14 @@ mod tests { zoomed: false, automatic_rename: false, panes: (0..panes) - .map(|i| PaneSnap { index: i as u32, cwd: "/tmp".into(), active: i == 0, agent: None }) + .map(|i| PaneSnap { + id: String::new(), + index: i as u32, + cwd: "/tmp".into(), + active: i == 0, + agent: None, + command: None, + }) .collect(), } } @@ -335,6 +472,28 @@ mod tests { } #[test] + fn resumes_agents_by_session_and_retypes_the_rest() { + let mut pane = window(1).panes.remove(0); + assert_eq!(typed_for(&pane), None, "a shell prompt types nothing"); + pane.command = Some("nvim 'my file.sh'".into()); + assert_eq!(typed_for(&pane), Some(Typed::Retype("nvim 'my file.sh'".into()))); + pane.command = Some("echo one\necho two".into()); + assert_eq!(typed_for(&pane), None, "a newline would run it"); + pane.agent = Some(AgentSnap { + agent: "pi".into(), + name: "x".into(), + state: "idle".into(), + session: Some("/s/a b.jsonl".into()), + }); + assert_eq!(typed_for(&pane), Some(Typed::Resume("pi --session '/s/a b.jsonl'".into()))); + if let Some(agent) = pane.agent.as_mut() { + agent.session = None; + } + pane.command = Some("pi --model x".into()); + assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session: retyped"); + } + + #[test] fn shells_are_recognized() { assert!(is_shell("zsh") && is_shell("-zsh") && is_shell("bash") && is_shell("fish")); assert!(!is_shell("nvim") && !is_shell("node") && !is_shell("tmux")); |
