aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:31:53 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:32:44 +0300
commit36a4608603a82dbf32ec8a013dd47047cf02c6a3 (patch)
tree8a9242974ad45dd6dc22b66f576dd9adcc36d996 /src/app
parent5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (diff)
fix: restore agents through pane shells
Diffstat (limited to 'src/app')
-rw-r--r--src/app/agent_resume.rs587
-rw-r--r--src/app/input/terminal.rs2
-rw-r--r--src/app/mod.rs13
-rw-r--r--src/app/runtime.rs98
4 files changed, 695 insertions, 5 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
new file mode 100644
index 00000000..3523da05
--- /dev/null
+++ b/src/app/agent_resume.rs
@@ -0,0 +1,587 @@
+use std::time::Instant;
+
+use bytes::Bytes;
+
+use super::App;
+
+struct PendingAgentResumeCandidate {
+ pane_id: crate::layout::PaneId,
+ terminal_id: crate::terminal::TerminalId,
+ cwd: std::path::PathBuf,
+ plan: crate::agent_resume::AgentResumePlan,
+ rows: u16,
+ cols: u16,
+}
+
+impl App {
+ pub(crate) fn has_pending_agent_resumes(&self) -> bool {
+ self.state
+ .terminals
+ .values()
+ .any(|terminal| terminal.pending_agent_resume_plan.is_some())
+ }
+
+ pub(crate) fn sync_pending_agent_resume_deadline(&mut self, now: Instant) {
+ if !self.has_pending_agent_resumes() {
+ self.pending_agent_resume_deadline = None;
+ return;
+ }
+ if self.pending_agent_resume_candidates().is_empty() {
+ self.pending_agent_resume_deadline = None;
+ return;
+ }
+ self.pending_agent_resume_deadline
+ .get_or_insert(now + super::PENDING_AGENT_RESUME_THEME_WAIT);
+ }
+
+ pub(crate) fn pending_agent_resume_due(&self, now: Instant) -> bool {
+ self.pending_agent_resume_deadline
+ .is_some_and(|deadline| now >= deadline)
+ }
+
+ pub(crate) fn start_pending_agent_resumes(&mut self, allow_empty_theme: bool) -> bool {
+ let pending = self.pending_agent_resume_candidates();
+ let mut changed = false;
+ for PendingAgentResumeCandidate {
+ pane_id,
+ terminal_id,
+ cwd,
+ plan,
+ rows,
+ cols,
+ } in pending
+ {
+ if self.terminal_runtimes.get(&terminal_id).is_some() {
+ continue;
+ }
+ changed |= self.start_pending_agent_resume(
+ pane_id,
+ terminal_id,
+ cwd,
+ plan,
+ rows,
+ cols,
+ allow_empty_theme,
+ );
+ }
+
+ if changed {
+ self.schedule_session_save();
+ }
+ if !self.has_pending_agent_resumes() || self.pending_agent_resume_candidates().is_empty() {
+ self.pending_agent_resume_deadline = None;
+ }
+ changed
+ }
+
+ fn pending_agent_resume_candidates(&self) -> Vec<PendingAgentResumeCandidate> {
+ let Some(ws_idx) = self.state.active else {
+ return Vec::new();
+ };
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ let Some(tab) = ws.tabs.get(ws.active_tab) else {
+ return Vec::new();
+ };
+
+ let mut pending = Vec::new();
+ for pane_id in tab.layout.pane_ids() {
+ let Some(pane) = tab.panes.get(&pane_id) else {
+ continue;
+ };
+ if self
+ .terminal_runtimes
+ .get(&pane.attached_terminal_id)
+ .is_some()
+ {
+ continue;
+ }
+ let Some(info) = self
+ .state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)
+ else {
+ continue;
+ };
+ let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id) else {
+ continue;
+ };
+ let Some(plan) = terminal.pending_agent_resume_plan.clone() else {
+ continue;
+ };
+ pending.push(PendingAgentResumeCandidate {
+ pane_id,
+ terminal_id: pane.attached_terminal_id.clone(),
+ cwd: terminal.cwd.clone(),
+ plan,
+ rows: info.inner_rect.height,
+ cols: info.inner_rect.width,
+ });
+ }
+ pending
+ }
+
+ pub(crate) fn start_pending_agent_resume_for_terminal(
+ &mut self,
+ terminal_id: &crate::terminal::TerminalId,
+ rows: u16,
+ cols: u16,
+ allow_empty_theme: bool,
+ ) -> bool {
+ if self.terminal_runtimes.get(terminal_id).is_some() {
+ return false;
+ }
+ let Some((pane_id, cwd, plan)) = self.state.workspaces.iter().find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ tab.layout.pane_ids().into_iter().find_map(|pane_id| {
+ let pane = tab.panes.get(&pane_id)?;
+ if &pane.attached_terminal_id != terminal_id {
+ return None;
+ }
+ let terminal = self.state.terminals.get(terminal_id)?;
+ Some((
+ pane_id,
+ terminal.cwd.clone(),
+ terminal.pending_agent_resume_plan.clone()?,
+ ))
+ })
+ })
+ }) else {
+ return false;
+ };
+
+ let changed = self.start_pending_agent_resume(
+ pane_id,
+ terminal_id.clone(),
+ cwd,
+ plan,
+ rows,
+ cols,
+ allow_empty_theme,
+ );
+ if changed {
+ self.schedule_session_save();
+ }
+ if !self.has_pending_agent_resumes() {
+ self.pending_agent_resume_deadline = None;
+ }
+ changed
+ }
+
+ fn start_pending_agent_resume(
+ &mut self,
+ pane_id: crate::layout::PaneId,
+ terminal_id: crate::terminal::TerminalId,
+ cwd: std::path::PathBuf,
+ plan: crate::agent_resume::AgentResumePlan,
+ rows: u16,
+ cols: u16,
+ allow_empty_theme: bool,
+ ) -> bool {
+ let host_terminal_theme = self.state.host_terminal_theme;
+ if host_terminal_theme.is_empty() && !allow_empty_theme {
+ return false;
+ }
+
+ let Some(resume_command) = shell_command_from_argv(&plan.argv) else {
+ tracing::warn!(
+ pane = pane_id.raw(),
+ terminal = %terminal_id,
+ agent = %plan.agent,
+ "failed to start deferred agent resume with empty argv"
+ );
+ return false;
+ };
+
+ let runtime = match crate::terminal::TerminalRuntime::spawn(
+ pane_id,
+ rows,
+ cols,
+ cwd,
+ self.state.pane_scrollback_limit_bytes,
+ host_terminal_theme,
+ crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ ) {
+ Ok(runtime) => runtime,
+ Err(err) => {
+ tracing::warn!(
+ pane = pane_id.raw(),
+ terminal = %terminal_id,
+ agent = %plan.agent,
+ err = %err,
+ "failed to start shell for deferred agent resume"
+ );
+ if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
+ terminal.clear_agent_runtime_identity_after_respawn();
+ }
+ return false;
+ }
+ };
+
+ let mut input = resume_command;
+ input.push('\r');
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(input)) {
+ tracing::warn!(
+ pane = pane_id.raw(),
+ terminal = %terminal_id,
+ agent = %plan.agent,
+ err = %err,
+ "failed to send deferred agent resume command to shell"
+ );
+ runtime.shutdown();
+ return false;
+ }
+
+ self.terminal_runtimes.insert(terminal_id.clone(), runtime);
+ if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
+ terminal.pending_agent_resume_plan = None;
+ terminal.respawn_shell_on_exit = false;
+ }
+ true
+ }
+}
+
+fn shell_command_from_argv(argv: &[String]) -> Option<String> {
+ let mut parts = argv.iter();
+ let first = shell_quote(parts.next()?);
+ let mut command = first;
+ for part in parts {
+ command.push(' ');
+ command.push_str(&shell_quote(part));
+ }
+ Some(command)
+}
+
+fn shell_quote(value: &str) -> String {
+ if value.is_empty() {
+ return "''".to_string();
+ }
+ if value.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric()
+ || matches!(
+ byte,
+ b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'='
+ )
+ }) {
+ return value.to_string();
+ }
+ format!("'{}'", value.replace('\'', "'\\''"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn test_app() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_waits_for_host_theme_before_launch() {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("restored");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ let pane_infos = workspace.tabs[0]
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state.view.pane_infos = pane_infos;
+ let terminal = app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist");
+ terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec![
+ "/bin/sh".into(),
+ "-c".into(),
+ "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
+ ],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ assert!(!app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&terminal_id).is_none());
+
+ app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 20,
+ g: 20,
+ b: 20,
+ }),
+ };
+
+ assert!(app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&terminal_id).is_some());
+ let terminal = app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("terminal should survive launch");
+ assert!(terminal.pending_agent_resume_plan.is_none());
+ assert!(!terminal.respawn_shell_on_exit);
+
+ let runtime = app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("pending resume should leave a shell runtime");
+ let marker = "restored agent: shell quoted | marker";
+ for _ in 0..20 {
+ if runtime
+ .snapshot_history()
+ .is_some_and(|text| text.contains(marker))
+ {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;
+ }
+ assert!(
+ runtime
+ .snapshot_history()
+ .expect("runtime should expose terminal history")
+ .contains(marker),
+ "deferred restore should inject the resume argv into the restored shell"
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_can_launch_after_theme_wait_expires() {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("restored");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ app.state.view.pane_infos = workspace.tabs[0]
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ app.sync_pending_agent_resume_deadline(std::time::Instant::now());
+ assert!(!app.start_pending_agent_resumes(false));
+ assert!(app.start_pending_agent_resumes(true));
+ assert!(app.terminal_runtimes.get(&terminal_id).is_some());
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() {
+ let mut app = test_app();
+ let active_workspace = crate::workspace::Workspace::test_new("active");
+ let active_pane = active_workspace.tabs[0].root_pane;
+ let active_terminal = active_workspace.terminal_id(active_pane).cloned().unwrap();
+ let hidden_workspace = crate::workspace::Workspace::test_new("hidden");
+ let hidden_pane = hidden_workspace.tabs[0].root_pane;
+ let hidden_terminal = hidden_workspace.terminal_id(hidden_pane).cloned().unwrap();
+ app.state.view.pane_infos = active_workspace.tabs[0]
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.workspaces = vec![active_workspace, hidden_workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 20,
+ g: 20,
+ b: 20,
+ }),
+ };
+ for terminal_id in [&active_terminal, &hidden_terminal] {
+ app.state
+ .terminals
+ .get_mut(terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"),
+ });
+ }
+ app.pending_agent_resume_deadline =
+ Some(std::time::Instant::now() - std::time::Duration::from_millis(1));
+
+ assert!(app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&active_terminal).is_some());
+ assert!(app.terminal_runtimes.get(&hidden_terminal).is_none());
+ assert!(
+ app.pending_agent_resume_deadline.is_none(),
+ "hidden-only pending resumes should not keep an expired wakeup deadline active"
+ );
+ assert!(
+ app.state
+ .terminals
+ .get(&hidden_terminal)
+ .expect("hidden terminal should still exist")
+ .pending_agent_resume_plan
+ .is_some(),
+ "hidden restored panes should wait until their tab has computed geometry"
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() {
+ let mut app = test_app();
+ let previous_workspace = crate::workspace::Workspace::test_new("previous");
+ let previous_pane = previous_workspace.tabs[0].root_pane;
+ let previous_terminal = previous_workspace
+ .terminal_id(previous_pane)
+ .cloned()
+ .unwrap();
+ let current_workspace = crate::workspace::Workspace::test_new("current");
+ app.state.view.pane_infos = previous_workspace.tabs[0]
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.workspaces = vec![previous_workspace, current_workspace];
+ app.state.active = Some(1);
+ app.state.ensure_test_terminals();
+ app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 20,
+ g: 20,
+ b: 20,
+ }),
+ };
+ app.state
+ .terminals
+ .get_mut(&previous_terminal)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ app.sync_pending_agent_resume_deadline(std::time::Instant::now());
+ assert!(app.pending_agent_resume_deadline.is_none());
+ assert!(!app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&previous_terminal).is_none());
+ assert!(
+ app.state
+ .terminals
+ .get(&previous_terminal)
+ .expect("previous terminal should still exist")
+ .pending_agent_resume_plan
+ .is_some(),
+ "a pane hidden by navigation should wait for a fresh visible geometry snapshot"
+ );
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_launches_with_inner_rect_size() {
+ let mut app = test_app();
+ let mut workspace = crate::workspace::Workspace::test_new("split");
+ let pane_id = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ app.state.view.pane_infos = vec![crate::layout::PaneInfo {
+ id: pane_id,
+ rect: ratatui::layout::Rect::new(0, 0, 100, 30),
+ inner_rect: ratatui::layout::Rect::new(1, 1, 98, 28),
+ scrollbar_rect: None,
+ is_focused: true,
+ }];
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 20,
+ g: 20,
+ b: 20,
+ }),
+ };
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ assert!(app.start_pending_agent_resumes(false));
+ assert_eq!(
+ app.terminal_runtimes
+ .get(&terminal_id)
+ .expect("pending resume should launch")
+ .current_size(),
+ (28, 98)
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[test]
+ fn shell_command_from_argv_quotes_resume_arguments() {
+ let argv = vec![
+ "claude".to_string(),
+ "--resume".to_string(),
+ "session with ' quote".to_string(),
+ ];
+
+ assert_eq!(
+ shell_command_from_argv(&argv).as_deref(),
+ Some("claude --resume 'session with '\\'' quote'")
+ );
+ assert_eq!(shell_command_from_argv(&[]), None);
+ }
+}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 401a981d..99ae3bf1 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -519,7 +519,7 @@ mod tests {
let deadline = app
.selection_highlight_clear_deadline
.expect("highlight clear deadline");
- assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1)));
+ assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false));
assert!(app.state.selection.is_none());
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0dcd0372..5a39b8e2 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -5,6 +5,7 @@
//! - `input.rs` — key/mouse → action translation
pub(crate) mod actions;
+mod agent_resume;
mod agents;
mod api;
mod api_helpers;
@@ -34,6 +35,7 @@ pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis
const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
+const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
@@ -105,6 +107,7 @@ pub struct App {
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) agent_metadata_deadline: Option<Instant>,
+ pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
@@ -576,6 +579,7 @@ impl App {
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
agent_metadata_deadline: None,
+ pending_agent_resume_deadline: None,
session_save_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
@@ -698,7 +702,7 @@ impl App {
self.sync_session_save_schedule();
let now = Instant::now();
- if self.handle_scheduled_tasks(now) {
+ if self.handle_scheduled_tasks(now, needs_render) {
needs_render = true;
}
@@ -813,6 +817,11 @@ impl App {
cell_size,
)?;
}
+ self.sync_pending_agent_resume_deadline(now);
+ if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) {
+ self.render_dirty.store(true, Ordering::Release);
+ self.render_notify.notify_one();
+ }
self.last_render_at = Some(now);
needs_render = false;
continue;
@@ -3000,7 +3009,7 @@ mod tests {
let mut app = test_app();
app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1));
- app.handle_scheduled_tasks(Instant::now());
+ app.handle_scheduled_tasks(Instant::now(), false);
assert!(app.session_save_deadline.is_none());
}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index ad90a267..4ddcde6c 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -165,13 +165,15 @@ impl App {
false
}
- pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant) -> bool {
+ pub(crate) fn handle_scheduled_tasks(&mut self, now: Instant, geometry_dirty: bool) -> bool {
let mut changed = false;
+ let mut resized = false;
self.sync_animation_timer(now);
if now >= self.next_resize_poll {
- changed |= self.handle_resize_poll();
+ resized = self.handle_resize_poll();
+ changed |= resized;
self.next_resize_poll = now + RESIZE_POLL_INTERVAL;
}
@@ -247,6 +249,12 @@ impl App {
changed = true;
}
+ if geometry_dirty || resized {
+ self.pending_agent_resume_deadline = None;
+ } else {
+ self.sync_pending_agent_resume_deadline(now);
+ changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now));
+ }
self.sync_animation_timer(now);
changed
}
@@ -496,6 +504,7 @@ impl App {
.flatten(),
self.next_auto_update_check,
self.agent_metadata_deadline,
+ self.pending_agent_resume_deadline,
self.session_save_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
@@ -914,4 +923,89 @@ mod tests {
assert!(app.state.selection_autoscroll.is_none());
assert!(app.selection_autoscroll_deadline.is_none());
}
+
+ #[tokio::test]
+ async fn raw_input_batch_does_not_start_pending_agent_resume_before_render() {
+ let (mut app, pane_id) = test_app_with_pane();
+ app.state.ensure_test_terminals();
+ let terminal_id = app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .cloned()
+ .expect("test pane should have a terminal");
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+
+ assert!(
+ app.handle_raw_input_batch(crate::raw_input::RawInputEvent::HostDefaultColor {
+ kind: crate::terminal_theme::DefaultColorKind::Foreground,
+ color: crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ },
+ })
+ .await
+ );
+ assert!(
+ app.terminal_runtimes.get(&terminal_id).is_none(),
+ "raw input can mutate active geometry; pending resumes must wait for render to refresh pane_infos"
+ );
+ assert!(app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("test terminal should still exist")
+ .pending_agent_resume_plan
+ .is_some());
+ }
+
+ #[tokio::test]
+ async fn scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() {
+ let (mut app, pane_id) = test_app_with_pane();
+ app.state.ensure_test_terminals();
+ app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 220,
+ g: 220,
+ b: 220,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 20,
+ g: 20,
+ b: 20,
+ }),
+ };
+ let terminal_id = app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .cloned()
+ .expect("test pane should have a terminal");
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("test terminal should exist")
+ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
+ agent: "codex".into(),
+ argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
+ });
+ app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
+
+ assert!(!app.handle_scheduled_tasks(Instant::now(), true));
+ assert!(app.terminal_runtimes.get(&terminal_id).is_none());
+ assert!(app
+ .state
+ .terminals
+ .get(&terminal_id)
+ .expect("test terminal should still exist")
+ .pending_agent_resume_plan
+ .is_some());
+ assert!(app.pending_agent_resume_deadline.is_none());
+ }
}