From c821964c4d40aac0f3ce18462146eff43be5059c Mon Sep 17 00:00:00 2001 From: Evgeniy Kovrov <7254010+nyxene@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:07:10 +0200 Subject: [PATCH] fix: restarting a pane's program starts its agent again with clean terminal modes Restart now types the agent the pane ran when it has no session to resume, and the kept screen leaves the old program's mouse, paste, cursor, keypad and alternate-screen modes behind, so mouse moves no longer land in the shell. The same reset runs when a command leaves and the shell comes back. Closes #60 Co-Authored-By: Claude --- src/app/lifecycle.rs | 5 +++++ src/app/mail.rs | 16 ++++++++++++++++ src/app/mod.rs | 11 +++++++++++ src/app/model.rs | 9 ++++++--- src/term.rs | 8 ++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 8ed63ef..718ba88 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -1136,6 +1136,11 @@ pub(super) fn restored_command(argv: &[String], session: Option<(&str, &str)>, l (!live).then(|| m.resume_argv(id)).flatten().or(stored).or_else(|| Some(vec![m.program().to_string()])) } +/// What a restarted pane types: its session resumed, else the agent it ran started fresh; a plain shell types nothing. +pub(super) fn restart_command(argv: &[String], session: Option<(&str, &str)>, agent: Option<&str>) -> Option> { + restored_command(argv, session, false).or_else(|| agent.and_then(crate::agents::get).map(|m| vec![m.program().to_string()])) +} + /// The line typed into a restored pane's shell; None when an argument holds a control character. pub(super) fn restore_line(cmd: &[String]) -> Option { let words = cmd.iter().map(|a| shell_word(a)).collect::>>()?; diff --git a/src/app/mail.rs b/src/app/mail.rs index da568ac..da510c4 100644 --- a/src/app/mail.rs +++ b/src/app/mail.rs @@ -1383,6 +1383,22 @@ mod tests { assert_eq!(app.pane_by_name("planner"), Some(new)); } + #[test] + fn a_restarted_pane_starts_with_the_old_programs_modes_off() { + let mut app = app(); + let old = &mut app.pane_mut(1).unwrap().term; + old.feed(b"kept above\r\n\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?2004h\x1b[?1h\x1b[?25l"); + assert!(old.wants_mouse()); + let new = app.alloc_id(); + let pty = crate::app::snapshots::agent_pane(&app, new, crate::term::AgentState::Idle, 0).pty; + assert!(app.restarted(1, new, pty, None, "zsh".into())); + let term = &mut app.pane_mut(new).unwrap().term; + let s = term.screen(); + assert!(!term.wants_mouse(), "a mouse move would be typed into the shell"); + assert!(!s.alternate_screen() && !s.bracketed_paste() && !s.app_cursor() && !s.hide_cursor()); + assert!(term.all_lines().iter().any(|l| l == "kept above"), "the text stays"); + } + fn req(to: &str, text: &str) -> SendReq { SendReq { to: to.into(), text: text.into(), reply_to: None, auto: false, ask_route: false } } diff --git a/src/app/mod.rs b/src/app/mod.rs index d376313..6f4e296 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3686,6 +3686,17 @@ mod tests { assert_eq!(super::restored_command(&gem, None, false), Some(vec!["gemini".into()]), "its own flags stripped"); } + #[test] + fn a_restarted_agent_starts_again_without_a_session() { + let zsh = vec!["/bin/zsh".to_string(), "-l".to_string()]; + assert_eq!(super::restart_command(&zsh, None, Some("pi")), Some(vec!["pi".into()]), "the agent it ran, started fresh"); + assert_eq!(super::restart_command(&zsh, None, None), None, "a plain shell types nothing"); + assert_eq!(super::restart_command(&zsh, None, Some("gone")), None, "an agent with no manifest: the shell only"); + assert_eq!(super::restart_command(&zsh, Some(("pi", "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b")), Some("pi")), Some(vec!["pi".into(), "--session".into(), "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b".into()]), "its session resumed"); + let gem = vec!["gemini".to_string(), "--resume".to_string(), "x".to_string()]; + assert_eq!(super::restart_command(&gem, None, None), Some(vec!["gemini".into()]), "a pane started as the agent"); + } + #[test] fn an_option_letter_means_one_thing_everywhere() { // The tree answers `option` letters of its own; none of them may be a global one, or diff --git a/src/app/model.rs b/src/app/model.rs index 02d3b5f..ae98f4c 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -265,7 +265,8 @@ impl App { // The old pty's Drop asks the broker to kill this id, and the broker keys panes by id // alone: it has to be gone before the new shell exists, or the kill lands on the shell // we just started. That is how the first attempt at this closed the tab anyway. - let Pane { term, cwd, label, isolation, pty, .. } = old; + let Pane { mut term, cwd, label, isolation, pty, .. } = old; + term.modes_off(); let (cols, rows) = (pty.cols, pty.rows); drop(pty); let pty = match Pty::spawn(id, &shell_argv(), &cwd, cols, rows, bell, &isolation) { @@ -292,6 +293,7 @@ impl App { return false; } let ps = pane_snapshot(old); + let agent = old.agent.clone(); let new = self.alloc_id(); let mut pty = match Pty::spawn(new, &shell_argv(), &ps.cwd, ps.cols, ps.rows, self.bell.clone(), &ps.isolation) { Ok(pty) => pty, @@ -302,7 +304,7 @@ impl App { }; // Nobody else holds the session: nerd just stopped its holder. let session = ps.session().filter(|(a, _)| crate::agents::get(a).as_ref().is_some_and(crate::hook::resume_confirmed)); - let launch = session.as_ref().and_then(|(a, sid)| super::lifecycle::restored_command(&ps.argv, Some((a, sid)), false)); + let launch = super::lifecycle::restart_command(&ps.argv, session.as_ref().map(|(a, sid)| (a.as_str(), sid.as_str())), agent.as_deref()); if let Some(line) = launch.as_deref().and_then(super::lifecycle::restore_line) { pty.write(line.as_bytes()); } @@ -315,7 +317,8 @@ impl App { let Some((si, ti)) = self.locate(id) else { return false }; let Some(old) = self.spaces[si].tabs[ti].panes.remove(&id) else { return false }; // The old pty drops here: the broker kills its program. - let Pane { term, cwd, label, isolation, .. } = old; + let Pane { mut term, cwd, label, isolation, .. } = old; + term.modes_off(); let pane = Pane { label, isolation, command, agent_session, ..Pane::new(new, term, pty, cwd, shell_argv()) }; let tab = &mut self.spaces[si].tabs[ti]; tab.panes.insert(new, pane); diff --git a/src/term.rs b/src/term.rs index 208738e..d32d73a 100644 --- a/src/term.rs +++ b/src/term.rs @@ -494,6 +494,14 @@ impl Term { self.advance(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l"); } + /// Input modes and the alternate screen as a fresh terminal has them, text kept: for the next program in a pane. + pub fn modes_off(&mut self) { + self.mouse_off(); + self.advance(b"\x1b[?1049l\x1b[?1l\x1b>\x1b[?2004l\x1b[?1004l\x1b[=0u\x1b[?25h\x1b[?7h\x1b[m"); + self.theme_notify = false; + self.pointer = None; + } + /// Whether the program inside the pane is requesting mouse events. pub fn wants_mouse(&self) -> bool { self.screen().mouse_protocol_mode() != MouseProtocolMode::None