aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/restore.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/restore.rs')
-rw-r--r--crates/dinggerd/src/restore.rs193
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"));