diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 00:19:36 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 00:19:36 +0300 |
| commit | 8b1d77acf6059e6688fb6a875c3176c95602b6c4 (patch) | |
| tree | cb9bf5e138a4cfe098726cdaf3b06e0ef458288f /src | |
| parent | 1db9695eecece3ddd2bcf43ac33c961daffd6004 (diff) | |
fix: keep powershell panes alive after agent ctrl-c
refs #860
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 89 | ||||
| -rw-r--r-- | src/pane.rs | 53 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 174 | ||||
| -rw-r--r-- | src/terminal/state.rs | 49 |
4 files changed, 335 insertions, 30 deletions
diff --git a/src/app/api.rs b/src/app/api.rs index b823a739..fa6d1f6d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -17,6 +17,8 @@ use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKi use crate::events::AppEvent; const API_NOTIFICATION_RATE_LIMIT: Duration = Duration::from_secs(1); +#[cfg(windows)] +const WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE: Duration = Duration::from_secs(2); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RuntimeExitAction { @@ -235,6 +237,8 @@ impl App { self.sync_full_lifecycle_authority_detection_pauses(); if terminal_cwd_reported { self.mark_git_status_refresh_due(Instant::now()); + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); } for update in &pane_updates { self.refresh_new_herdr_toast_context_for_update(update, &previous_toast); @@ -425,13 +429,38 @@ impl App { return RuntimeExitAction::ClosePane; }; - if terminal.respawn_shell_on_exit { + if terminal.respawn_shell_on_exit || self.should_respawn_shell_after_agent_exit(terminal) { RuntimeExitAction::RespawnShell } else { RuntimeExitAction::ClosePane } } + fn should_respawn_shell_after_agent_exit( + &self, + terminal: &crate::terminal::TerminalState, + ) -> bool { + #[cfg(not(windows))] + { + let _ = terminal; + false + } + + #[cfg(windows)] + { + if !terminal.agent_process_exited_within( + Instant::now(), + WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE, + ) { + return false; + } + + crate::pane::uses_windows_powershell_prompt_cwd_reporting( + crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode), + ) + } + } + fn respawn_shell_for_launch_pane(&mut self, pane_id: crate::layout::PaneId) -> bool { let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { return false; @@ -1763,6 +1792,64 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn windows_powershell_exit_after_agent_process_exit_respawns_shell() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let workspace = crate::workspace::Workspace::test_new("powershell"); + let pane_id = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.default_shell = "powershell.exe".into(); + app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; + + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::OpenCode), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: std::time::Instant::now(), + }); + + assert_eq!( + app.runtime_exit_action(pane_id), + RuntimeExitAction::RespawnShell + ); + } + + #[cfg(windows)] + #[test] + fn windows_powershell_exit_without_recent_agent_process_exit_closes_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let workspace = crate::workspace::Workspace::test_new("powershell"); + let pane_id = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.default_shell = "powershell.exe".into(); + app.state.shell_mode = crate::config::ShellModeConfig::NonLogin; + + assert_eq!( + app.runtime_exit_action(pane_id), + RuntimeExitAction::ClosePane + ); + } + #[test] fn terminal_delivery_does_not_refresh_existing_targeted_toast() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/pane.rs b/src/pane.rs index d616e5a1..2ba0d639 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -121,6 +121,7 @@ struct PendingAgentRelease { struct SpawnInitialState<'a> { detected_agent: Option<Agent>, history_ansi: Option<&'a str>, + windows_powershell_prompt_cwd_reporting: bool, } fn active_pending_release( @@ -1289,9 +1290,7 @@ fn pane_shell_command_builder_for_target( cmd.env("SHELL", resolve_shell_for_login_mode(&shell)?); Ok(cmd) } else { - let mut cmd = CommandBuilder::new(&shell); - apply_windows_powershell_cwd_reporting(&mut cmd, &shell); - Ok(cmd) + Ok(CommandBuilder::new(&shell)) } } @@ -1300,18 +1299,20 @@ fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<C } #[cfg(windows)] -fn apply_windows_powershell_cwd_reporting(cmd: &mut CommandBuilder, shell: &str) { - if !is_windows_powershell_shell(shell) { - return; +pub(crate) fn uses_windows_powershell_prompt_cwd_reporting( + shell_config: PaneShellConfig<'_>, +) -> bool { + if shell_mode_uses_login_shell(shell_config.mode, cfg!(target_os = "macos")) { + return false; } - cmd.arg("-NoExit"); - cmd.arg("-Command"); - cmd.arg(windows_powershell_cwd_prompt_wrapper()); + is_windows_powershell_shell(&pane_shell(shell_config.default_shell)) } #[cfg(not(windows))] -fn apply_windows_powershell_cwd_reporting(cmd: &mut CommandBuilder, shell: &str) { - let _ = (cmd, shell); +pub(crate) fn uses_windows_powershell_prompt_cwd_reporting( + _shell_config: PaneShellConfig<'_>, +) -> bool { + false } #[cfg(windows)] @@ -1327,11 +1328,6 @@ fn is_windows_powershell_shell(shell: &str) -> bool { ) } -#[cfg(windows)] -fn windows_powershell_cwd_prompt_wrapper() -> &'static str { - r#"$global:__HERDR_ORIGINAL_PROMPT = if (Test-Path Function:\prompt) { (Get-Command prompt -CommandType Function).ScriptBlock } else { { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } }; function global:prompt { try { if ($PWD.Provider.Name -eq 'FileSystem') { $uri = ([System.Uri]$PWD.ProviderPath).AbsoluteUri; [Console]::Write("$([char]27)]7;$uri$([char]7)") } } catch {}; & $global:__HERDR_ORIGINAL_PROMPT }"# -} - fn usable_reported_cwd(cwd: std::path::PathBuf) -> Option<std::path::PathBuf> { (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) } @@ -1499,6 +1495,8 @@ impl PaneRuntime { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { + let windows_powershell_prompt_cwd_reporting = + uses_windows_powershell_prompt_cwd_reporting(shell_config); let mut cmd = pane_shell_command_builder(shell_config)?; cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); @@ -1517,6 +1515,7 @@ impl PaneRuntime { SpawnInitialState { detected_agent: None, history_ansi: initial_history_ansi, + windows_powershell_prompt_cwd_reporting, }, ) } @@ -1770,6 +1769,9 @@ impl PaneRuntime { } let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); + pane_terminal.set_windows_powershell_prompt_cwd_reporting( + initial_state.windows_powershell_prompt_cwd_reporting, + ); if let Some(ansi) = initial_state.history_ansi { pane_terminal.seed_history_ansi(ansi); } @@ -2553,6 +2555,7 @@ impl PaneRuntime { { return Some(cwd); } + let pid = self.child_pid.load(Ordering::Relaxed); crate::platform::process_cwd(pid) } @@ -2821,23 +2824,17 @@ mod tests { #[cfg(windows)] #[test] - fn windows_powershell_shell_builder_wraps_cwd_reporting_prompt() { + fn windows_powershell_shell_builder_launches_plain_shell() { let cmd = pane_shell_command_builder_for_target( PaneShellConfig::new("powershell.exe", crate::config::ShellModeConfig::NonLogin), false, ) .unwrap(); - let argv: Vec<_> = cmd - .get_argv() - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(); - - assert_eq!(argv[0], "powershell.exe"); - assert!(argv.iter().any(|arg| arg == "-NoExit")); - assert!(argv - .iter() - .any(|arg| arg.contains("]7;") && arg.contains("Function:\\prompt"))); + + assert_eq!( + cmd.get_argv(), + &[std::ffi::OsString::from("powershell.exe")] + ); } #[test] diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index a479e5ad..b36ae0ee 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -133,6 +133,7 @@ pub(crate) struct GhosttyPaneCore { pub xtgettcap_query_tracker: XtgettcapQueryTracker, decscusr_tracker: DecscusrTracker, cursor_settle_state: CursorPositionSettleState, + windows_powershell_prompt_cwd_reporting: bool, } pub(crate) struct PaneTerminal { @@ -391,12 +392,19 @@ impl GhosttyPaneTerminal { xtgettcap_query_tracker: XtgettcapQueryTracker::default(), decscusr_tracker: DecscusrTracker::default(), cursor_settle_state: CursorPositionSettleState::default(), + windows_powershell_prompt_cwd_reporting: false, }), key_encoder: Mutex::new(key_encoder), pending_pty_responses, }) } + pub(super) fn set_windows_powershell_prompt_cwd_reporting(&self, enabled: bool) { + if let Ok(mut core) = self.core.lock() { + core.windows_powershell_prompt_cwd_reporting = enabled; + } + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { if let Ok(mut core) = self.core.lock() { let foreground_unowned = !core.child_default_foreground_changed; @@ -585,6 +593,13 @@ impl GhosttyPaneTerminal { core.cursor_settle_state .observe(cursor_after_write, Instant::now()); } + #[cfg(windows)] + let reported_cwd = if core.windows_powershell_prompt_cwd_reporting { + reported_cwd.or_else(|| windows_powershell_current_prompt_cwd(&mut core)) + } else { + reported_cwd + }; + let request_render = !synchronized_output && !has_kitty_graphics_sequence; let render_delay = render_delay_after_pty_write( synchronized_output, @@ -1624,6 +1639,69 @@ fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghost ghostty_recent_text(core, lines) } +#[cfg(windows)] +fn windows_powershell_current_prompt_cwd(core: &mut GhosttyPaneCore) -> Option<std::path::PathBuf> { + if core.terminal.active_screen().ok()? != crate::ghostty::ActiveScreen::Primary { + return None; + } + let cursor = current_cursor_state(core)?; + let rows = core.terminal.rows().ok()?; + let cols = core.terminal.cols().ok()?; + if rows == 0 || cols == 0 || cursor.y >= rows { + return None; + } + let total_rows = core.terminal.total_rows().ok()?; + let viewport_start = total_rows.saturating_sub(usize::from(rows)); + let cursor_row = viewport_start + usize::from(cursor.y); + if core + .terminal + .read_text_screen( + (0, cursor_row as u32), + (cols.saturating_sub(1), cursor_row as u32), + false, + ) + .ok()? + .trim() + .is_empty() + { + return None; + } + let text = core + .terminal + .read_text_screen( + (0, viewport_start as u32), + (cols.saturating_sub(1), cursor_row as u32), + false, + ) + .ok()?; + windows_powershell_prompt_cwd(&text) +} + +#[cfg(windows)] +fn windows_powershell_prompt_cwd(text: &str) -> Option<std::path::PathBuf> { + text.lines() + .rev() + .find(|line| !line.trim().is_empty()) + .and_then(windows_powershell_prompt_line_cwd) +} + +#[cfg(windows)] +fn windows_powershell_prompt_line_cwd(line: &str) -> Option<std::path::PathBuf> { + let line = line.trim_end(); + let rest = line.strip_prefix("PS ")?; + let marker = rest.find('>')?; + let mut raw_cwd = rest[..marker].trim_end(); + let suffix = rest[marker..].trim_end(); + if !suffix.chars().all(|ch| ch == '>') { + return None; + } + if let Some((_, filesystem_path)) = raw_cwd.rsplit_once("::") { + raw_cwd = filesystem_path; + } + let cwd = std::path::PathBuf::from(raw_cwd); + (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) +} + fn ghostty_recent_text( core: &GhosttyPaneCore, lines: usize, @@ -2202,6 +2280,102 @@ mod tests { Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) } + #[cfg(windows)] + #[test] + fn windows_powershell_prompt_cwd_uses_latest_default_prompt_path() { + let cwd = std::env::current_dir().unwrap(); + let text = format!("PS C:\\old> cd {}\nPS {}>", cwd.display(), cwd.display()); + + assert_eq!(windows_powershell_prompt_cwd(&text).as_ref(), Some(&cwd)); + } + + #[cfg(windows)] + #[test] + fn windows_powershell_prompt_cwd_requires_current_prompt_line() { + let cwd = std::env::current_dir().unwrap(); + let text = format!("PS {}>\ncommand output", cwd.display()); + + assert_eq!(windows_powershell_prompt_cwd(&text), None); + } + + #[cfg(windows)] + #[test] + fn windows_powershell_prompt_cwd_ignores_command_echo() { + let cwd = std::env::current_dir().unwrap(); + let text = format!("PS {}> echo hi", cwd.display()); + + assert_eq!(windows_powershell_prompt_cwd(&text), None); + } + + #[cfg(windows)] + fn process_windows_powershell_prompt_bytes( + bytes: &[u8], + cols: u16, + rows: u16, + enabled: bool, + ) -> ProcessBytesResult { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(cols, rows, 100).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + pane.set_windows_powershell_prompt_cwd_reporting(enabled); + pane.process_pty_bytes(PaneId::from_raw(1), 0, bytes, &tx) + } + + #[cfg(windows)] + #[test] + fn process_pty_bytes_reports_windows_powershell_prompt_cwd() { + let cwd = std::env::current_dir().unwrap(); + let bytes = format!("PS C:\\old> cd {}\r\nPS {}>", cwd.display(), cwd.display()); + + let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true); + + assert_eq!(result.reported_cwd.as_ref(), Some(&cwd)); + } + + #[cfg(windows)] + #[test] + fn process_pty_bytes_reports_wrapped_windows_powershell_prompt_cwd() { + let cwd = std::env::current_dir().unwrap(); + let bytes = format!("PS {}>", cwd.display()); + + let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 12, 8, true); + + assert_eq!(result.reported_cwd.as_ref(), Some(&cwd)); + } + + #[cfg(windows)] + #[test] + fn process_pty_bytes_ignores_prompt_like_output_on_previous_line() { + let cwd = std::env::current_dir().unwrap(); + let bytes = format!("PS {}>\r\n", cwd.display()); + + let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true); + + assert_eq!(result.reported_cwd, None); + } + + #[cfg(windows)] + #[test] + fn process_pty_bytes_ignores_windows_powershell_prompt_cwd_on_alternate_screen() { + let cwd = std::env::current_dir().unwrap(); + let bytes = format!("\x1b[?1049hPS {}>", cwd.display()); + + let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, true); + + assert_eq!(result.reported_cwd, None); + } + + #[cfg(windows)] + #[test] + fn process_pty_bytes_skips_windows_powershell_prompt_cwd_when_disabled() { + let cwd = std::env::current_dir().unwrap(); + let bytes = format!("PS {}>", cwd.display()); + + let result = process_windows_powershell_prompt_bytes(bytes.as_bytes(), 80, 24, false); + + assert_eq!(result.reported_cwd, None); + } + fn expected_xtgettcap_response(cap_hex: &str, value: Option<&[u8]>) -> Bytes { let mut response = format!("\x1bP1+r{cap_hex}").into_bytes(); if let Some(value) = value { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index e7923719..8fea0542 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -#[cfg(test)] +#[cfg(any(windows, test))] use std::time::Duration; use std::time::Instant; @@ -91,6 +91,7 @@ pub struct TerminalState { pub revision: u64, pub launch_argv: Option<Vec<String>>, pub respawn_shell_on_exit: bool, + recent_agent_process_exit_at: Option<Instant>, pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>, } @@ -117,6 +118,7 @@ impl TerminalState { revision: 0, launch_argv: None, respawn_shell_on_exit: false, + recent_agent_process_exit_at: None, pending_agent_resume_plan: None, } } @@ -131,6 +133,12 @@ impl TerminalState { self } + #[cfg(any(windows, test))] + pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool { + self.recent_agent_process_exit_at + .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age) + } + pub fn with_pending_agent_resume_plan( mut self, plan: crate::agent_resume::AgentResumePlan, @@ -246,6 +254,11 @@ impl TerminalState { self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; self.fallback_observed_at = Some(now); + if process_exited && agent.is_some() { + self.recent_agent_process_exit_at = Some(now); + } else if agent.is_some() { + self.recent_agent_process_exit_at = None; + } if process_exited && self.hook_authority_not_newer_than(now) && self.hook_authority.as_ref().is_some_and(|authority| { @@ -1118,6 +1131,7 @@ impl TerminalState { self.last_agent_state_change_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; + self.recent_agent_process_exit_at = None; self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -3657,6 +3671,39 @@ mod tests { } #[test] + fn agent_process_exit_tracks_recent_respawn_window() { + let mut terminal = test_terminal(); + let now = std::time::Instant::now(); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + false, + false, + true, + now, + ); + + assert!(terminal.agent_process_exited_within(now, Duration::from_secs(2))); + assert!(!terminal + .agent_process_exited_within(now + Duration::from_secs(3), Duration::from_secs(2))); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Working, + false, + false, + true, + false, + now + Duration::from_secs(4), + ); + + assert!(!terminal + .agent_process_exited_within(now + Duration::from_secs(4), Duration::from_secs(2))); + } + + #[test] fn detected_conflict_clears_live_hook_but_preserves_session_ref() { let mut terminal = test_terminal(); terminal.set_hook_authority_with_session_ref( |
