aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:04:11 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:25:32 +0300
commit48641e253f1aa3886b470cbebec60103247a7622 (patch)
treee4b76585fc8b928436d6379fe5caaf193ea88300 /src
parentbed8f1ebe9417d448837c107d92a8770f60b6d1c (diff)
fix: resume restored agents across background panes
Diffstat (limited to 'src')
-rw-r--r--src/app/agent_resume.rs286
1 files changed, 234 insertions, 52 deletions
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
index 3523da05..8ff6cc4d 100644
--- a/src/app/agent_resume.rs
+++ b/src/app/agent_resume.rs
@@ -1,6 +1,10 @@
use std::time::Instant;
use bytes::Bytes;
+use ratatui::{
+ layout::Rect,
+ widgets::{Block, Borders},
+};
use super::App;
@@ -75,55 +79,79 @@ impl App {
}
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 {
+ let terminal_area = self.state.view.terminal_area;
+ if terminal_area.width == 0 || terminal_area.height == 0 {
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;
+ for (ws_idx, ws) in self.state.workspaces.iter().enumerate() {
+ for (tab_idx, tab) in ws.tabs.iter().enumerate() {
+ for info in
+ self.pending_agent_resume_pane_infos(ws_idx, tab_idx, tab, terminal_area)
+ {
+ let Some(pane) = tab.panes.get(&info.id) else {
+ continue;
+ };
+ if self
+ .terminal_runtimes
+ .get(&pane.attached_terminal_id)
+ .is_some()
+ {
+ 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: info.id,
+ terminal_id: pane.attached_terminal_id.clone(),
+ cwd: terminal.cwd.clone(),
+ plan,
+ rows: info.inner_rect.height,
+ cols: info.inner_rect.width,
+ });
+ }
}
- 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
}
+ fn pending_agent_resume_pane_infos(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ tab: &crate::workspace::Tab,
+ terminal_area: Rect,
+ ) -> Vec<crate::layout::PaneInfo> {
+ let mut pane_infos = derived_pending_agent_resume_pane_infos(tab, terminal_area);
+
+ if self.state.active == Some(ws_idx)
+ && self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .is_some_and(|ws| tab_idx == ws.active_tab_index())
+ {
+ for visible_info in &self.state.view.pane_infos {
+ if let Some(info) = pane_infos
+ .iter_mut()
+ .find(|info| info.id == visible_info.id)
+ {
+ *info = visible_info.clone();
+ } else {
+ pane_infos.push(visible_info.clone());
+ }
+ }
+ }
+
+ pane_infos
+ }
+
pub(crate) fn start_pending_agent_resume_for_terminal(
&mut self,
terminal_id: &crate::terminal::TerminalId,
@@ -247,6 +275,39 @@ impl App {
}
}
+fn derived_pending_agent_resume_pane_infos(
+ tab: &crate::workspace::Tab,
+ terminal_area: Rect,
+) -> Vec<crate::layout::PaneInfo> {
+ let multi_pane = tab.layout.pane_count() > 1;
+ tab.layout
+ .panes(terminal_area)
+ .into_iter()
+ .map(|mut info| {
+ let pane_inner = if multi_pane {
+ Block::default().borders(Borders::ALL).inner(info.rect)
+ } else {
+ terminal_area
+ };
+ info.inner_rect = stable_terminal_inner_rect(pane_inner);
+ info
+ })
+ .collect()
+}
+
+fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect {
+ if pane_inner.width <= 4 {
+ return pane_inner;
+ }
+
+ Rect::new(
+ pane_inner.x,
+ pane_inner.y,
+ pane_inner.width.saturating_sub(1),
+ pane_inner.height,
+ )
+}
+
fn shell_command_from_argv(argv: &[String]) -> Option<String> {
let mut parts = argv.iter();
let first = shell_quote(parts.next()?);
@@ -301,6 +362,7 @@ mod tests {
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
app.state.view.pane_infos = pane_infos;
let terminal = app
.state
@@ -379,6 +441,7 @@ mod tests {
app.state.view.pane_infos = workspace.tabs[0]
.layout
.panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();
@@ -403,7 +466,7 @@ mod tests {
}
#[tokio::test]
- async fn pending_agent_resume_skips_hidden_panes_without_visible_geometry() {
+ async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() {
let mut app = test_app();
let active_workspace = crate::workspace::Workspace::test_new("active");
let active_pane = active_workspace.tabs[0].root_pane;
@@ -414,6 +477,7 @@ mod tests {
app.state.view.pane_infos = active_workspace.tabs[0]
.layout
.panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.view.terminal_area = 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();
@@ -445,19 +509,131 @@ mod tests {
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.terminal_runtimes.get(&hidden_terminal).is_some());
assert!(
app.pending_agent_resume_deadline.is_none(),
- "hidden-only pending resumes should not keep an expired wakeup deadline active"
+ "launched pending resumes should clear the wakeup deadline"
+ );
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() {
+ let mut app = test_app();
+ let mut workspace = crate::workspace::Workspace::test_new("tabs");
+ let active_pane = workspace.tabs[0].root_pane;
+ let inactive_tab = workspace.test_add_tab(Some("agents"));
+ let inactive_pane = workspace.tabs[inactive_tab].root_pane;
+ let inactive_terminal = workspace.tabs[inactive_tab]
+ .terminal_id(inactive_pane)
+ .cloned()
+ .unwrap();
+ app.state.view.pane_infos = workspace.tabs[0]
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ assert!(app
+ .state
+ .workspaces
+ .first()
+ .and_then(|ws| ws.tabs[0].terminal_id(active_pane))
+ .is_some());
+ 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(&inactive_terminal)
+ .expect("inactive tab 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\0inactive-tab-session".into(),
+ });
+
+ assert!(app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&inactive_terminal).is_some());
+ assert!(
+ app.state
+ .terminals
+ .get(&inactive_terminal)
+ .expect("inactive tab terminal should still exist")
+ .pending_agent_resume_plan
+ .is_none(),
+ "inactive tab restored panes should not wait for tab focus"
);
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
+ }
+
+ #[tokio::test]
+ async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() {
+ let mut app = test_app();
+ let mut workspace = crate::workspace::Workspace::test_new("zoomed");
+ let hidden_pane = workspace.tabs[0].root_pane;
+ let visible_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.tabs[0].zoomed = true;
+ let hidden_terminal = workspace.terminal_id(hidden_pane).cloned().unwrap();
+ app.state.view.pane_infos = vec![crate::layout::PaneInfo {
+ id: visible_pane,
+ 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.view.terminal_area = 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.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(&hidden_terminal)
+ .expect("hidden zoom pane 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\0zoom-hidden-session".into(),
+ });
+
+ assert!(app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&hidden_terminal).is_some());
assert!(
app.state
.terminals
.get(&hidden_terminal)
- .expect("hidden terminal should still exist")
+ .expect("hidden zoom pane terminal should still exist")
.pending_agent_resume_plan
- .is_some(),
- "hidden restored panes should wait until their tab has computed geometry"
+ .is_none(),
+ "zoom-hidden restored panes should not wait for pane focus"
);
for (_, runtime) in app.terminal_runtimes.drain() {
@@ -466,7 +642,7 @@ mod tests {
}
#[tokio::test]
- async fn pending_agent_resume_ignores_stale_geometry_from_previous_active_view() {
+ async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() {
let mut app = test_app();
let previous_workspace = crate::workspace::Workspace::test_new("previous");
let previous_pane = previous_workspace.tabs[0].root_pane;
@@ -478,6 +654,7 @@ mod tests {
app.state.view.pane_infos = previous_workspace.tabs[0]
.layout
.panes(ratatui::layout::Rect::new(0, 0, 100, 30));
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24);
app.state.workspaces = vec![previous_workspace, current_workspace];
app.state.active = Some(1);
app.state.ensure_test_terminals();
@@ -504,18 +681,22 @@ mod tests {
});
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.pending_agent_resume_deadline.is_some());
+ assert!(app.start_pending_agent_resumes(false));
+ assert!(app.terminal_runtimes.get(&previous_terminal).is_some());
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"
+ .is_none(),
+ "background restored panes should not wait for focus once terminal area is known"
);
+
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
}
#[tokio::test]
@@ -531,6 +712,7 @@ mod tests {
scrollbar_rect: None,
is_focused: true,
}];
+ app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();