diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | ROADMAP.md | 6 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 25 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 13 | ||||
| -rw-r--r-- | crates/dinggerd/src/platform.rs | 19 | ||||
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 243 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 3 | ||||
| -rw-r--r-- | menubar/main.swift | 98 | ||||
| -rw-r--r-- | menubar/model.swift | 52 | ||||
| -rw-r--r-- | menubar/panel.swift | 128 | ||||
| -rw-r--r-- | packaging/tmux/dingger.tmux | 10 | ||||
| -rwxr-xr-x | tests/e2e.sh | 51 |
12 files changed, 610 insertions, 43 deletions
@@ -71,6 +71,11 @@ than a poll is still a completion. zsh`, then source the file it names from your `.zshrc`), otherwise the program's arguments. An agent or program that exits within a minute before tmux stops (a shutdown) is still restored. + When tmux stops, the panel (first row: "↺ Restore 9 sessions · 22 + agents", or "Not now"), the menu (all, or one session at a time), and the + status line (`↺9`, clickable) offer the restore; it runs through your + login shell, like a terminal starting tmux, and a notification + summarizes (full report: `~/.local/state/dingger/restore.log`). - All shortcuts live in `~/.config/dingger/config.toml` (menu bar, Settings…), reloaded on save. - **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after @@ -2,9 +2,9 @@ In priority order. -1. **Restore after a crash or shutdown.** Bring back tmux sessions, windows, - and panes (names, layout, directories) and resume each agent's own - session (`pi --session <file>`, `claude --resume <id>`, ...). +1. ~~**Restore after a crash or shutdown.**~~ Done for pi (see the README). + Next: other agents' session references (claude's session id through its + hooks, `claude --resume <id>`; codex, ...). 2. **Raise the right window.** Jumping to an agent activates the terminal app; with several windows (several Ghostty windows, say) it should raise the exact window that holds the tmux client, not whichever was last used. diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 7cf3513..fced7c2 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -72,6 +72,12 @@ enum Command { /// Restore only these sessions. #[arg(long = "session")] sessions: Vec<String>, + /// Write the report to restore.log and notify a summary. + #[arg(long)] + report: bool, + /// Stop offering the saved layout (it can still be restored). + #[arg(long)] + dismiss: bool, }, /// Install integrations: an agent's hook extension (state and session /// reports), or `zsh` (command lines for restore to retype). @@ -90,11 +96,16 @@ enum ManifestCommand { } fn main() { + platform::default_sigpipe(); + let cli = Cli::parse(); + // The daemon logs its work; commands print their own output and only + // log problems. + let default_level = if matches!(cli.command, Command::Watch { .. }) { "info" } else { "warn" }; tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default_level.into())) .init(); - match Cli::parse().command { + match cli.command { Command::Watch { interval_secs, no_sounds, @@ -134,8 +145,14 @@ fn main() { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); } - Command::Restore { dry_run, list, from, sessions } => { - let result = if list { restore::list() } else { restore::run(from.as_deref(), &sessions, dry_run) }; + Command::Restore { dry_run, list, from, sessions, report, dismiss } => { + let result = if list { + restore::list() + } else if dismiss { + restore::dismiss() + } else { + restore::run(from.as_deref(), &sessions, dry_run, report) + }; if let Err(err) = result { eprintln!("dingger: {err}"); std::process::exit(1); diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs index e619d6d..555299d 100644 --- a/crates/dinggerd/src/notify.rs +++ b/crates/dinggerd/src/notify.rs @@ -63,6 +63,19 @@ pub fn play_sound(kind: &NotificationKind) { } } +/// Deliver a notification and wait for it: for short-lived commands, whose +/// exit would cut a background delivery short. +pub fn send_message_blocking(title: &str, body: &str) { + info!(title, body, "notifying"); + if let Ok(command) = std::env::var("DINGGER_NOTIFIER") { + let _ = std::process::Command::new("sh").args(["-c", &command, title, body]).status(); + return; + } + if let Err(err) = mac_notification_sys::Notification::new().title(title).message(body).send() { + warn!(%err, "failed to deliver macOS notification"); + } +} + /// Deliver a macOS notification without blocking the caller: the delivery /// call waits on Notification Center for seconds, and the watcher must keep /// updating the UI meanwhile. diff --git a/crates/dinggerd/src/platform.rs b/crates/dinggerd/src/platform.rs index 8504a83..6c78817 100644 --- a/crates/dinggerd/src/platform.rs +++ b/crates/dinggerd/src/platform.rs @@ -1,4 +1,21 @@ -//! macOS process inspection. +//! macOS process inspection and process setup. + +/// Exit quietly when stdout closes early (`dingger restore --list | head`), +/// like other command-line tools, instead of panicking: Rust ignores +/// SIGPIPE by default. +#[cfg(unix)] +pub fn default_sigpipe() { + extern "C" { + fn signal(signum: std::os::raw::c_int, handler: usize) -> usize; + } + const SIGPIPE: std::os::raw::c_int = 13; + const SIG_DFL: usize = 0; + // SAFETY: resets one signal to its default disposition before any + // threads start. + unsafe { + signal(SIGPIPE, SIG_DFL); + } +} /// A process's exact argument vector, from the kernel (`KERN_PROCARGS2`): /// unlike `ps`, it keeps arguments that contain spaces intact. diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs index 2332865..623cdcd 100644 --- a/crates/dinggerd/src/restore.rs +++ b/crates/dinggerd/src/restore.rs @@ -18,6 +18,27 @@ use std::time::{Duration, Instant}; use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap}; use crate::tmux; +thread_local! { + static REPORT: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Print a report line, and keep it for `--report`. +fn say(line: String) { + println!("{line}"); + REPORT.with(|report| report.borrow_mut().push(line)); +} + +/// What a restore did, for the summary notification. +#[derive(Debug, Default)] +pub struct Summary { + pub sessions: usize, + pub failed: usize, + pub agents: usize, + pub resumed: usize, + pub interrupted: usize, + pub typed: usize, +} + /// What restore types into a rebuilt pane. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Typed { @@ -239,7 +260,7 @@ fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result< /// 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]) { +fn type_commands(rebuilt: &[Rebuilt], summary: &mut Summary) { let work: Vec<(&Rebuilt, Typed)> = rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect(); if work.is_empty() { @@ -266,21 +287,23 @@ fn type_commands(rebuilt: &[Rebuilt]) { 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); + say(format!("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); + say(format!("type {} {command} (press Enter to run)", r.label)); + summary.typed += 1; } } } 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); + say(format!("interrupted {} {name}: it was working, its turn was cut short", r.label)); + summary.interrupted += 1; } } if resumed.is_empty() { @@ -297,9 +320,11 @@ fn type_commands(rebuilt: &[Rebuilt]) { .unwrap_or(true) }); } - println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len()); + summary.agents = resumed.len(); + summary.resumed = resumed.len() - not_running.len(); + say(format!("resumed {} of {} agents", summary.resumed, summary.agents)); for r in not_running { - println!("not running {}: check the pane for the error", r.label); + say(format!("not running {}: check the pane for the error", r.label)); } } @@ -348,9 +373,59 @@ fn format_time(ms: u64) -> String { .unwrap_or_else(|_| ms.to_string()) } -/// Restore missing sessions (all, or only `only`). -pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), String> { +/// Restore missing sessions (all, or only `only`). With `report`, also +/// write the report to `restore.log` and notify a summary. +pub fn run(from: Option<&str>, only: &[String], dry_run: bool, report: bool) -> Result<(), String> { + let result = restore(from, only, dry_run); + if report && !dry_run { + let lines = REPORT.with(|report| report.borrow().join("\n")); + let _ = std::fs::write(snapshot::state_dir().join("restore.log"), format!("{lines}\n")); + let (title, body) = match &result { + Ok(summary) => summary_message(summary), + Err(err) => ("tmux restore failed".to_string(), err.clone()), + }; + crate::notify::send_message_blocking(&title, &body); + } + let _ = crate::hooks::poke(); + result.map(|_| ()) +} + +fn plural(n: usize, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} + +pub fn summary_message(summary: &Summary) -> (String, String) { + let title = format!("Restored {}", plural(summary.sessions, "tmux session", "tmux sessions")); + let mut parts = Vec::new(); + if summary.agents > 0 { + parts.push(format!("resumed {} of {}", summary.resumed, plural(summary.agents, "agent", "agents"))); + } + if summary.interrupted > 0 { + parts.push(format!("{} mid-turn", plural(summary.interrupted, "was", "were"))); + } + if summary.typed > 0 { + parts.push(format!("{} typed, not run", plural(summary.typed, "command", "commands"))); + } + if summary.failed > 0 { + parts.push(format!("{} failed", plural(summary.failed, "session", "sessions"))); + } + if parts.is_empty() { + parts.push("nothing else to resume".into()); + } + (title, parts.join(" · ")) +} + +/// Mark the restore candidate dismissed: no more offers for it. +pub fn dismiss() -> Result<(), String> { + snapshot::set_candidate_status("dismissed")?; + let _ = crate::hooks::poke(); + println!("dismissed; `dingger restore` still restores it"); + Ok(()) +} + +fn restore(from: Option<&str>, only: &[String], dry_run: bool) -> Result<Summary, String> { let snapshot = source(from)?; + let mut summary = Summary::default(); let existing = existing_sessions(); let mut failures = 0; let mut rebuilt = Vec::new(); @@ -364,19 +439,19 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str let what = windows_and_panes(session); let verb = match plan { Plan::Keep => { - println!("keep {name}: it exists"); + say(format!("keep {name}: it exists")); continue; } Plan::Create => "create", Plan::Replace => "replace", }; if dry_run { - println!("{verb:<8} {name}: {what}"); + say(format!("{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), + Some(Typed::Resume(command)) => say(format!(" resume {} {command}", window.name)), + Some(Typed::Retype(command)) => say(format!(" type {} {command}", window.name)), None => {} } } @@ -388,23 +463,124 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str _ => build_session(session, &name, &mut rebuilt).map(|_| ()), }; match result { - Ok(()) => println!("{verb:<8} {name}: {what}"), + Ok(()) => { + summary.sessions += 1; + say(format!("{verb:<8} {name}: {what}")); + } Err(err) => { failures += 1; - println!("FAILED {name}: {err}"); + say(format!("FAILED {name}: {err}")); } } } if !rebuilt.is_empty() { - type_commands(&rebuilt); + type_commands(&rebuilt, &mut summary); } - if dry_run || from.is_some() { - return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }; - } - if missing(&snapshot, &existing_sessions()).is_empty() { + summary.failed = failures; + if !dry_run && from.is_none() && missing(&snapshot, &existing_sessions()).is_empty() { let _ = snapshot::set_candidate_status("restored"); } - if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) } + if failures == 0 { Ok(summary) } else { Err(format!("{failures} sessions failed")) } +} + +/// One session restore would bring back, as offered to the user. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct OfferedSession { + pub name: String, + pub windows: usize, + pub panes: usize, + pub agents: usize, +} + +/// The restore offer the menu bar app shows (`offer.json`). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct Offer { + pub saved_at: u64, + pub sessions: Vec<OfferedSession>, + pub agents: usize, + /// Agents that were mid-turn when tmux stopped. + pub interrupted: usize, +} + +pub fn offer(snapshot: &Snapshot, existing: &[Existing]) -> Option<Offer> { + let missing = missing(snapshot, existing); + let sessions: Vec<&SessionSnap> = + snapshot.sessions.iter().filter(|session| missing.contains(&session.name)).collect(); + if sessions.is_empty() { + return None; + } + let panes = |session: &&SessionSnap| session.windows.iter().flat_map(|w| &w.panes).cloned().collect::<Vec<_>>(); + let agents = sessions.iter().flat_map(panes).filter(|pane| pane.agent.is_some()).count(); + let interrupted = sessions + .iter() + .flat_map(panes) + .filter(|pane| pane.agent.as_ref().is_some_and(|agent| agent.state == "working")) + .count(); + Some(Offer { + saved_at: snapshot.saved_at, + sessions: sessions + .iter() + .map(|session| OfferedSession { + name: session.name.clone(), + windows: session.windows.len(), + panes: session.windows.iter().map(|w| w.panes.len()).sum(), + agents: panes(session).iter().filter(|pane| pane.agent.is_some()).count(), + }) + .collect(), + agents, + interrupted, + }) +} + +/// Keeps `offer.json` and the `@dingger_restore` option current; called by +/// the watcher every loop. +#[derive(Default)] +pub struct OfferPublisher { + candidate: Option<(std::time::SystemTime, Option<snapshot::Candidate>)>, + published: Option<Option<Offer>>, +} + +impl OfferPublisher { + pub fn tick(&mut self) { + let path = snapshot::state_dir().join("restore.json"); + let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); + let stale = match (&self.candidate, modified) { + (Some((at, _)), Some(modified)) => *at != modified, + (None, _) => true, + (Some(_), None) => true, + }; + if stale { + self.candidate = Some((modified.unwrap_or(std::time::UNIX_EPOCH), snapshot::candidate())); + } + let offered = self + .candidate + .as_ref() + .and_then(|(_, candidate)| candidate.as_ref()) + .filter(|candidate| candidate.status == "offered"); + let offer = offered.and_then(|candidate| offer(&candidate.snapshot, &existing_sessions())); + if self.published.as_ref() == Some(&offer) { + return; + } + let file = snapshot::state_dir().join("offer.json"); + match &offer { + Some(offer) => { + if let Ok(json) = serde_json::to_string_pretty(offer) { + let tmp = file.with_extension("tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &file); + } + } + let set = tmux::run(&["set-option", "-g", "@dingger_restore", &offer.sessions.len().to_string()]); + tracing::info!(sessions = offer.sessions.len(), agents = offer.agents, ok = set.is_ok(), "restore offered"); + } + None => { + let _ = std::fs::remove_file(&file); + let unset = tmux::run(&["set-option", "-g", "-u", "@dingger_restore"]); + tracing::info!(?unset, "restore offer withdrawn"); + } + } + self.published = Some(offer); + } } #[cfg(test)] @@ -494,6 +670,33 @@ mod tests { } #[test] + fn offers_only_missing_sessions_with_their_agents() { + let mut saved = snapshot(&["0", "work"]); + saved.sessions[1].windows[0].panes[0].agent = Some(AgentSnap { + agent: "pi".into(), + name: "x".into(), + state: "working".into(), + session: None, + }); + let offer = offer(&saved, &[Existing { name: "0".into(), bare: false }]).expect("work is missing"); + assert_eq!(offer.sessions.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["work"]); + assert_eq!((offer.agents, offer.interrupted, offer.sessions[0].panes), (1, 1, 2)); + assert_eq!(super::offer(&saved, &[ + Existing { name: "0".into(), bare: false }, + Existing { name: "work".into(), bare: false }, + ]), None, "nothing missing, no offer"); + } + + #[test] + fn summarizes_a_restore() { + let summary = Summary { sessions: 9, agents: 22, resumed: 21, interrupted: 1, typed: 3, failed: 0 }; + assert_eq!( + summary_message(&summary), + ("Restored 9 tmux sessions".into(), "resumed 21 of 22 agents · 1 was mid-turn · 3 commands typed, not run".into()) + ); + } + + #[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")); diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 24a2ecd..a2d6dae 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -182,6 +182,7 @@ pub fn advance( pub struct Watcher { tracks: HashMap<String, PaneTrack>, snapshots: crate::snapshot::Recorder, + offer: crate::restore::OfferPublisher, /// Last published (queue, blocked, done, working); avoids rewriting the /// global options every tick. last_published: Option<(String, usize, usize, usize)>, @@ -195,6 +196,7 @@ impl Watcher { Self { tracks: HashMap::new(), snapshots: Default::default(), + offer: Default::default(), last_published: None, interval_secs: interval_secs.max(1), sounds, @@ -226,6 +228,7 @@ impl Watcher { } // After the tick, so snapshots carry the states it just wrote. self.snapshots.tick(unix_now()); + self.offer.tick(); debug!(ms = started.elapsed().as_millis() as u64, "tick"); hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } diff --git a/menubar/main.swift b/menubar/main.swift index a00288c..0d367b8 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -25,6 +25,10 @@ // --snapshot-search <png> <query> [<down>] // render agent search offscreen, then exit // --click <pane> perform a row click for <pane>, then exit +// --restore [<session>...] +// restore saved tmux sessions as the panel and menu do +// (through the login shell), then exit +// --dismiss-offer dismiss the restore offer as "Not now" does, then exit // --selftest check the panel and search rules, then exit import AppKit @@ -33,6 +37,17 @@ func log(_ message: String) { FileHandle.standardError.write(Data("\(Date()) \(message)\n".utf8)) } +/// Restore saved tmux sessions (all when `sessions` is empty) through the +/// login shell, and log the report. Blocks until done. +@discardableResult +func performRestore(sessions: [String]) -> Bool { + let args = ["restore", "--report"] + sessions.flatMap { ["--session", $0] } + let result = runDinggerInLoginShell(args) + let lines = result.output.split(separator: "\n").filter { !$0.contains("can't change option: zle") } + log("restore: \(result.ok ? "done" : "failed")\n" + lines.map { " \($0)" }.joined(separator: "\n")) + return result.ok +} + final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let menu = NSMenu() @@ -49,6 +64,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var searchHotkeyRegistered = false private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull") private var rows: [Row] = [] + private var restoreOffer: RestoreOffer? + private var restoring = false /// The panel settings; the first run after the on/off split migrates the /// old three-way mode. @@ -71,7 +88,9 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { item.menu = menu panel = PanelController( onClick: { [weak self] paneId in self?.jump(to: paneId) }, - onToggleFull: { [weak self] in self?.toggleFull() }) + onToggleFull: { [weak self] in self?.toggleFull() }, + onRestore: { [weak self] in self?.restore(sessions: []) }, + onDismissRestore: { [weak self] in self?.dismissRestore() }) searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) }) Settings.ensureFile() reloadSettings() @@ -124,6 +143,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { item.button?.setAccessibilityLabel("dingger, " + tipText) visibility.anyWaiting = rows.contains(where: \.waiting) + restoreOffer = fetchRestoreOffer() + visibility.restoreOffered = restoreOffer != nil applyPanel() searchWindow.update(rows: rows) } @@ -141,7 +162,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var panelContent: PanelContent { PanelContent(rows: rows, full: panelShowsFull(policy: visibility.policy, prefersFull: prefersFull), - canToggle: visibility.policy == .always) + canToggle: visibility.policy == .always, + offer: restoreOffer, restoring: restoring) } private func applyPanel() { @@ -163,6 +185,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private var panelState: String { "\(visibility.enabled ? "on" : "off"), \(visibility.policy.rawValue), " + (visibility.anyWaiting ? "agents waiting" : "nothing waiting") + + (visibility.restoreOffered ? ", restore offered" : "") } /// The shortcut and the menu item: turn the panel on or off, persistently. @@ -188,6 +211,33 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Restore saved tmux sessions (all when `sessions` is empty). + func restore(sessions: [String], completion: (() -> Void)? = nil) { + guard !restoring else { return } + restoring = true + applyPanel() + log("restore: \(sessions.isEmpty ? "all sessions" : sessions.joined(separator: ", "))") + DispatchQueue.global().async { + performRestore(sessions: sessions) + DispatchQueue.main.async { + self.restoring = false + self.refresh() + completion?() + } + } + } + + /// "Not now": stop offering this saved layout. + @objc private func dismissRestore() { + let result = runReporting(dinggerBinary, ["restore", "--dismiss"]) + log("restore: dismissed (\(result.ok ? "ok" : result.output))") + refresh() + } + + @objc private func restoreFromMenu(_ sender: NSMenuItem) { + restore(sessions: (sender.representedObject as? String).map { [$0] } ?? []) + } + @objc private func selectPolicy(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let policy = PanelPolicy(rawValue: raw) else { return } visibility.policy = policy @@ -213,6 +263,37 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() rows = fetchRows() + restoreOffer = fetchRestoreOffer() + if let offer = restoreOffer { + let item = NSMenuItem(title: restoring ? "Restoring tmux Sessions…" : "↺ \(offer.title)", + action: nil, keyEquivalent: "") + if !restoring { + let submenu = NSMenu() + let all = NSMenuItem(title: "Restore All", action: #selector(restoreFromMenu(_:)), keyEquivalent: "") + all.target = self + submenu.addItem(all) + submenu.addItem(.separator()) + for session in offer.sessions { + var detail = "\(session.windows) \(session.windows == 1 ? "window" : "windows")" + if session.agents > 0 { detail += ", \(session.agents) \(session.agents == 1 ? "agent" : "agents")" } + let one = NSMenuItem(title: session.name, action: #selector(restoreFromMenu(_:)), keyEquivalent: "") + one.subtitle = detail + one.target = self + one.representedObject = session.name + submenu.addItem(one) + } + submenu.addItem(.separator()) + let later = NSMenuItem(title: "Not Now", action: #selector(dismissRestore), keyEquivalent: "") + later.target = self + submenu.addItem(later) + item.submenu = submenu + } else { + item.isEnabled = false + } + item.subtitle = offer.detail + menu.addItem(item) + menu.addItem(.separator()) + } if rows.isEmpty { let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "") none.isEnabled = false @@ -321,9 +402,18 @@ if arguments.contains("--print") { } if arguments.contains("--print-panel") { let full = arguments.contains("--full") - print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle")).plainText) + print(PanelContent(rows: fetchRows(), full: full, canToggle: full || arguments.contains("--toggle"), + offer: fetchRestoreOffer()).plainText) exit(0) } +if let index = arguments.firstIndex(of: "--restore") { + exit(performRestore(sessions: Array(arguments[(index + 1)...])) ? 0 : 1) +} +if arguments.contains("--dismiss-offer") { + let result = runReporting(dinggerBinary, ["restore", "--dismiss"]) + print(result.output) + exit(result.ok ? 0 : 1) +} if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < arguments.count { _ = NSApplication.shared let controller = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {}) @@ -332,7 +422,7 @@ if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < argumen paneId: "%stale", agent: "pi", name: "STALE ROW FROM A PREVIOUS RENDER", status: .blocked, session: "old", window: "old", since: 0)])) let full = arguments.contains("--full") - controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full)) + controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full, offer: fetchRestoreOffer())) exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1) } if let index = arguments.firstIndex(of: "--print-search") { diff --git a/menubar/model.swift b/menubar/model.swift index e891697..da5ad15 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -165,6 +165,58 @@ func tmux(_ args: [String]) -> String? { let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger") +// MARK: restore offer + +/// tmux sessions the daemon offers to restore after tmux stopped +/// (`offer.json`, written by `dingger watch`). +struct RestoreOffer: Decodable, Equatable { + struct Session: Decodable, Equatable { + let name: String + let windows: Int + let panes: Int + let agents: Int + } + let saved_at: Int + let sessions: [Session] + let agents: Int + let interrupted: Int + + /// "Restore 9 sessions · 22 agents" + var title: String { + var text = "Restore \(sessions.count) \(sessions.count == 1 ? "session" : "sessions")" + if agents > 0 { text += " · \(agents) \(agents == 1 ? "agent" : "agents")" } + return text + } + + /// "saved 13:01 · 1 was mid-turn" + var detail: String { + let formatter = DateFormatter() + let saved = Date(timeIntervalSince1970: TimeInterval(saved_at) / 1000) + formatter.dateFormat = Calendar.current.isDateInToday(saved) ? "HH:mm" : "MMM d, HH:mm" + var parts = ["tmux stopped · saved \(formatter.string(from: saved))"] + if interrupted > 0 { parts.append("\(interrupted) \(interrupted == 1 ? "was" : "were") mid-turn") } + return parts.joined(separator: " · ") + } +} + +let stateDirectory = environment["DINGGER_STATE_DIR"] ?? (NSHomeDirectory() + "/.local/state/dingger") + +func fetchRestoreOffer() -> RestoreOffer? { + guard let data = FileManager.default.contents(atPath: stateDirectory + "/offer.json") else { return nil } + return try? JSONDecoder().decode(RestoreOffer.self, from: data) +} + +/// Run `dingger <args>` through the user's login shell, the way a terminal +/// starts tmux: restored panes inherit this environment (PATH, LANG), and +/// launchd's is bare. DINGGER_RESTORE_DIRECT runs it directly (tests). +func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { + if environment["DINGGER_RESTORE_DIRECT"] != nil { + return runReporting(dinggerBinary, args) + } + let shell = getpwuid(getuid()).flatMap { String(validatingUTF8: $0.pointee.pw_shell) } ?? "/bin/zsh" + return runReporting(shell, ["--login", "-i", "-c", "exec \"$0\" \"$@\"", dinggerBinary] + args) +} + private let home = NSHomeDirectory() /// Every agent pane, in herdr's priority order, oldest waiting first. diff --git a/menubar/panel.swift b/menubar/panel.swift index 003a061..6ab85f2 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -36,13 +36,15 @@ struct PanelVisibility: Equatable { var enabled = true var policy: PanelPolicy = .whenWaiting var anyWaiting = false + /// tmux stopped and its sessions can be restored. + var restoreOffered = false /// Turning the panel on with nothing to show shows it briefly, so the /// shortcut visibly did something. var peekUntil: Date? func visible(at now: Date) -> Bool { guard enabled else { return false } - if policy == .always || anyWaiting { return true } + if policy == .always || anyWaiting || restoreOffered { return true } return peekUntil.map { now < $0 } ?? false } @@ -84,7 +86,15 @@ struct PanelContent { /// Whether the panel offers the compact/full toggle (shown always). let canToggle: Bool - init(rows all: [Row], full: Bool = false, canToggle: Bool = false) { + /// Sessions to restore, shown first. + let offer: RestoreOffer? + /// A restore is running. + let restoring: Bool + + init(rows all: [Row], full: Bool = false, canToggle: Bool = false, + offer: RestoreOffer? = nil, restoring: Bool = false) { + self.offer = offer + self.restoring = restoring let waiting = all.filter(\.waiting) if full { rows = all @@ -127,14 +137,28 @@ struct PanelContent { var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } + /// The offer row's title: what restore will do, or that it runs. + var offerTitle: String? { + guard let offer else { return nil } + return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title + } + + /// The summary line is noise when tmux is gone and only the offer shows. + var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 } + var plainText: String { var lines: [String] = [] + if let offer, let offerTitle { + lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]")) + } for (index, row) in rows.enumerated() { if index == separatorBefore { lines.append("---") } lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") } if overflow > 0 { lines.append("+\(overflow) more waiting") } - lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) + if showsSummary { + lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) + } return lines.joined(separator: "\n") } } @@ -272,6 +296,43 @@ final class PanelRowView: ClickView { } } +/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was. +private final class OfferView: ClickView { + init(title: String, detail: String, onClick: @escaping () -> Void) { + super.init(frame: .zero) + self.onClick = onClick + let font = NSFont.menuFont(ofSize: 0) + let text = NSMutableAttributedString(string: "↺ ", attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue, + ]) + text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText])) + let titleLabel = singleLine(NSTextField(labelWithAttributedString: text)) + let detailLabel = NSTextField(labelWithString: detail) + detailLabel.font = .systemFont(ofSize: 11) + detailLabel.textColor = mochaSubtext + let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 1 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 5), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), + ]) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + override func draw(_ dirtyRect: NSRect) { + guard hovered else { return } + NSColor.white.withAlphaComponent(0.08).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() + } +} + /// "Show all ▾" / "Show less ▴". private final class ToggleView: ClickView { private let label: NSTextField @@ -302,11 +363,16 @@ final class PanelController { private let stack = NSStackView() private let onClick: (String) -> Void private let onToggleFull: () -> Void + private let onRestore: () -> Void + private let onDismissRestore: () -> Void private var rendered: String? - init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) { + init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void, + onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) { self.onClick = onClick self.onToggleFull = onToggleFull + self.onRestore = onRestore + self.onDismissRestore = onDismissRestore panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) @@ -356,6 +422,10 @@ final class PanelController { stack.removeArrangedSubview(view) view.removeFromSuperview() } + if let offer = content.offer, let title = content.offerTitle { + add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true) + if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) } + } for (index, row) in content.rows.enumerated() { if index == content.separatorBefore { add(separatorLine()) } add(PanelRowView(row: row, onClick: onClick), fullWidth: true) @@ -365,7 +435,7 @@ final class PanelController { string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } - add(summaryLine(content), fullWidth: true) + if content.showsSummary { add(summaryLine(content), fullWidth: true) } // Grow or shrink downward from the top edge. stack.layoutSubtreeIfNeeded() @@ -413,6 +483,34 @@ final class PanelController { return box } + /// The offer row, with "Not now" beside it (a row takes every click + /// inside it, so the link cannot be inside). + private func offerLine(title: String, detail: String, restoring: Bool) -> NSView { + let box = NSView() + let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore) + row.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(row) + var constraints = [ + row.leadingAnchor.constraint(equalTo: box.leadingAnchor), + row.topAnchor.constraint(equalTo: box.topAnchor), + row.bottomAnchor.constraint(equalTo: box.bottomAnchor), + ] + if restoring { + constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor)) + } else { + let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore) + dismiss.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(dismiss) + constraints += [ + dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), + dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor), + row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8), + ] + } + NSLayoutConstraint.activate(constraints) + return box + } + /// Summary on the left, the compact/full toggle on the right. private func summaryLine(_ content: PanelContent) -> NSView { let box = caption(content.summary) @@ -511,6 +609,12 @@ func panelSelftest() -> [String] { check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting), "migration: when waiting") + var offered = PanelVisibility(enabled: true, policy: .whenWaiting) + offered.restoreOffered = true + check(offered.visible(at: t0), "a restore offer shows the panel") + offered.toggle(at: t0) + check(!offered.visible(at: at(10)), "off: a restore offer does not show it") + check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting") check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen") check(!panelShowsFull(policy: .always, prefersFull: false), "layout: compact in always by default") @@ -529,5 +633,19 @@ func panelSelftest() -> [String] { "full: no separator without waiting agents") check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + + let offer = RestoreOffer(saved_at: 0, sessions: [ + .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0), + ], agents: 2, interrupted: 1) + let afterReboot = PanelContent(rows: [], offer: offer) + check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t") + && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"), + "offer: first, with what it brings back and a way to dismiss it") + check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone") + let running = PanelContent(rows: [], offer: offer, restoring: true) + check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"), + "offer: while restoring, no dismiss") + check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"), + "offer: the summary stays when there are agents") return failures } diff --git a/packaging/tmux/dingger.tmux b/packaging/tmux/dingger.tmux index 64c5bff..f1529f1 100644 --- a/packaging/tmux/dingger.tmux +++ b/packaging/tmux/dingger.tmux @@ -4,9 +4,15 @@ # of the menu bar: blocked first, then the longest-done (FIFO), skipping the # window you are already looking at. # +# After tmux restarts, `↺N` (blue) offers to restore N saved sessions: +# clicking it runs `dingger restore`; a notification summarizes, the full +# report is in ~/.local/state/dingger/restore.log. +# # Source this from your tmux config: source-file /path/to/dingger.tmux -set -g status-right '%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}' -bind -n MouseDown1StatusRight run-shell "$HOME/.local/bin/dingger focus --client '#{client_name}'" +set -g status-right '#{?@dingger_restore,#[range=user|restore]#[fg=#89b4fa#,bold]↺#{@dingger_restore}#[norange default] ,}%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}' +bind -n MouseDown1StatusRight if -F '#{==:#{mouse_status_range},restore}' \ + 'run-shell -b "$HOME/.local/bin/dingger restore --report"' \ + "run-shell \"$HOME/.local/bin/dingger focus --client '#{client_name}'\"" # Tell dingger the moment you look somewhere else (it marks what you see as # attended). Index 77 keeps your own hooks and makes reloading idempotent. set-hook -g 'client-session-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"' diff --git a/tests/e2e.sh b/tests/e2e.sh index 29d900b..b79c748 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -37,7 +37,7 @@ cleanup() { [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null [ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null t kill-server 2>/dev/null - tmux -L "$SOCK-restore" kill-server 2>/dev/null + tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null rm -rf "$DIR" } trap cleanup EXIT @@ -226,6 +226,11 @@ t source-file "$ROOT/packaging/tmux/dingger.tmux" segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 +expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1 +t set -g @dingger_restore 3 +segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') +case "$segment" in "↺3 "*"×1"*) pass "segment offers to restore 3 sessions ($segment)";; *) fail "restore segment: [$segment]";; esac +t set -gu @dingger_restore # same run-shell + #{client_name} expansion as the mouse binding, via a key t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'" t send-keys -t viewer: F12 @@ -351,7 +356,7 @@ fi # A second server and daemon. "viewer" hosts a client the way a terminal # does: Ghostty runs `tmux new-session -As 0`. echo "16. restore" -R="$SOCK-restore" +R="$SOCK-restore-$$" STATE2="$DIR/state2" # tmux gives new windows the environment of the client that creates them: # both the test and restore run with the fake pi first on PATH. @@ -410,7 +415,10 @@ fingerprint() { | grep -E '^(0|work)\|' } BEFORE=$(fingerprint) -renv "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 & +# Started directly, not through a function: $! must be the daemon itself. +env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \ + DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null \ + "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 & DAEMON2_PID=$! latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; } expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80 @@ -452,7 +460,16 @@ expect "the candidate survives the new server's snapshots" \ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80 expect "dry run plans: replace 0, keep keep and viewer, create work" \ "renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;" -renv "$DINGGER" restore >"$DIR/restore.out" 2>&1 +mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \ + DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; } +expect "the daemon offers to restore 0 and work" \ + "python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \ + "['0', 'work'] 2 1" 80 +expect "and tells the status line" "r show -gv @dingger_restore" 2 +expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]" +expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn" +mbr --restore +cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0 expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl --session $W/sessions/gone.jsonl" @@ -463,6 +480,8 @@ expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $D typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; } expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'" expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat" +expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run" +expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0" expect "neither runs: the shell waits for Enter" \ "r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash bash" @@ -480,6 +499,30 @@ expect "restoring again changes nothing" "fingerprint" "$BEFORE" expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0 [ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; } +# Again, now with no tmux running at all (after a reboot, before a terminal +# opens): restoring starts the server. Then "Not now" stays dismissed. +names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; } +WORK_BEFORE=$(names work) +r kill-server +expect "a new offer: all four sessions, tmux is not running" \ + "python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80 +mbr --restore work +expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE" +expect "the offer shrinks to what is still missing" \ + "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \ + "['0', 'keep', 'viewer']" 80 +mbr --dismiss-offer >/dev/null +expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 +expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" "" +start_viewer +sleep 2 +expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 +expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1 + +# Every daemon this run started must stop with it. +kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null +expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 + echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; } |
