aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 00:19:36 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 00:19:36 +0300
commit8b1d77acf6059e6688fb6a875c3176c95602b6c4 (patch)
treecb9bf5e138a4cfe098726cdaf3b06e0ef458288f /src
parent1db9695eecece3ddd2bcf43ac33c961daffd6004 (diff)
fix: keep powershell panes alive after agent ctrl-c
refs #860
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs89
-rw-r--r--src/pane.rs53
-rw-r--r--src/pane/terminal.rs174
-rw-r--r--src/terminal/state.rs49
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(