From 3231d315bfb041bd75924671d08da5d4613b964e Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 16:37:43 +0800 Subject: [PATCH 1/2] fix(windows): stop console windows flashing on every git status probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tty7 is a GUI process with no console, so launching a console-subsystem child makes Windows allocate one for it — a black window that pops up and vanishes. The git status probe is the worst offender: it shells out four times (rev-parse --show-toplevel, branch name, --git-dir / --git-common-dir, diff --numstat) and runs on every pane cwd change, command end, and agent-turn end. Opening a shell in a repo flashed four windows. Add core::proc::hide_console — CREATE_NO_WINDOW on Windows, a no-op on Unix so callers stay cfg-free — and route every non-PTY shell-out through it: the status probe, worktree's git, the diff-review git calls, the codex CLI, and shells.rs's WSL probe (which had its own copy of the constant, now one source of truth). PTY children are out of scope; daemon::spawn already passes its own flags for those. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/agent_hooks.rs | 7 +++---- src/core/mod.rs | 1 + src/core/proc.rs | 27 +++++++++++++++++++++++++++ src/core/shells.rs | 14 +++++--------- src/core/worktree.rs | 7 +++---- src/terminal/git_status.rs | 15 ++++++++------- src/ui/app.rs | 6 +++--- 7 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 src/core/proc.rs diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs index de31a4f9..ddce9d08 100644 --- a/src/core/agent_hooks.rs +++ b/src/core/agent_hooks.rs @@ -762,10 +762,9 @@ fn enable_codex_hooks_feature() -> Result<(), String> { .chain(home_dir().map(|h| h.join(".local/bin/codex"))) .find(|p| p.exists()); let program = candidates.unwrap_or_else(|| PathBuf::from("codex")); - match std::process::Command::new(&program) - .args(["features", "enable", "hooks"]) - .output() - { + let mut cmd = std::process::Command::new(&program); + cmd.args(["features", "enable", "hooks"]); + match crate::core::proc::hide_console(&mut cmd).output() { Ok(out) if out.status.success() => Ok(()), Ok(out) => Err(format!( "codex exited with {}: {}", diff --git a/src/core/mod.rs b/src/core/mod.rs index 073b26a7..aa190f83 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -18,6 +18,7 @@ pub mod config; #[allow(dead_code)] pub mod keychain; pub mod osc; +pub mod proc; pub mod session; pub mod shells; pub mod ssh_config; diff --git a/src/core/proc.rs b/src/core/proc.rs new file mode 100644 index 00000000..fc473426 --- /dev/null +++ b/src/core/proc.rs @@ -0,0 +1,27 @@ +//! One place for the Windows subprocess flag every helper shell-out needs. +//! +//! tty7 is a GUI process with no console of its own, so launching a console +//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh +//! console for it — a black window that pops up and vanishes. That is invisible +//! on a one-off invocation and very visible on the git-status probe, which runs +//! four `git` calls every time a pane's cwd changes or a command ends. +//! +//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are +//! unaffected, so output capture keeps working. Every non-PTY `Command` in the +//! app should go through [`hide_console`] before it runs. PTY children are not +//! in scope — the daemon owns those and passes its own flags (see +//! [`crate::daemon::spawn`]). + +use std::process::Command; + +/// Suppress the console window Windows would otherwise allocate for a console +/// subsystem child. No-op on Unix, so callers stay `cfg`-free. +pub fn hide_console(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/src/core/shells.rs b/src/core/shells.rs index 97e6bb21..e4249bcf 100644 --- a/src/core/shells.rs +++ b/src/core/shells.rs @@ -319,17 +319,13 @@ fn find_git_bash() -> Option { } /// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is -/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window -/// (we're a GUI process). +/// absent. [`hide_console`](crate::core::proc::hide_console) keeps the probe +/// from flashing a console window (we're a GUI process). #[cfg(windows)] fn list_wsl_distros() -> Vec { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let Ok(output) = std::process::Command::new("wsl.exe") - .args(["-l", "-q"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - else { + let mut cmd = std::process::Command::new("wsl.exe"); + cmd.args(["-l", "-q"]); + let Ok(output) = crate::core::proc::hide_console(&mut cmd).output() else { return Vec::new(); }; if !output.status.success() { diff --git a/src/core/worktree.rs b/src/core/worktree.rs index d88796d7..e908ff37 100644 --- a/src/core/worktree.rs +++ b/src/core/worktree.rs @@ -62,10 +62,9 @@ pub fn is_inside_repo(cwd: &Path) -> bool { /// Run `git -C `, returning trimmed stdout on success and trimmed /// stderr as the error otherwise. fn git(dir: &Path, args: &[&str]) -> Result { - let out = std::process::Command::new("git") - .arg("-C") - .arg(dir) - .args(args) + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = crate::core::proc::hide_console(&mut cmd) .output() .map_err(|e| format!("failed to run git: {e}"))?; if out.status.success() { diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index 098165d6..8bca6ed5 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -251,18 +251,19 @@ fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> { /// Run `git -C ` and return stdout on success, `None` on a /// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read /// truly read-only; stdin is nulled so a misconfigured git can't block on a -/// prompt. Shared with [`git_diff`](crate::terminal::git_diff) so every git -/// read in the app goes through the same lock-free, prompt-proof invocation. +/// prompt; `hide_console` keeps this GUI process from flashing a console window +/// on Windows for every probe. Shared with [`git_diff`](crate::terminal::git_diff) +/// so every git read in the app goes through the same lock-free, prompt-proof +/// invocation. pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option { - let out = Command::new("git") - .arg("-C") + let mut cmd = Command::new("git"); + cmd.arg("-C") .arg(cwd) .args(args) .env("GIT_OPTIONAL_LOCKS", "0") .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok()?; + .stderr(Stdio::null()); + let out = crate::core::proc::hide_console(&mut cmd).output().ok()?; if !out.status.success() { return None; } diff --git a/src/ui/app.rs b/src/ui/app.rs index a4fb4435..fdcab0dc 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3048,9 +3048,9 @@ impl Tty7App { // which is what a review pass wants. Both invocations are quick; the // prompt builder caps runaway diffs. let run = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(&cwd) + let mut cmd = std::process::Command::new("git"); + cmd.args(args).current_dir(&cwd); + crate::core::proc::hide_console(&mut cmd) .output() .ok() .filter(|o| o.status.success()) From 87f865d55a926683c36618cfe67ed956b92d2437 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 18:46:52 +0800 Subject: [PATCH 2/2] fix(ssh): hide the console for ProxyCommand children too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon is spawned detached with no console of its own, so a ProxyCommand launched from it (`ssh -W`, `connect.exe`, `cloudflared`) had Windows allocate one — not a flash but a black window that stayed up for the whole session. `hide_console` takes `std::process::Command`; this site builds a `tokio::process::Command`, which is a distinct type with its own `creation_flags`. Add `hide_console_tokio` alongside it so the module comment's claim that every non-PTY Command goes through this file holds again. Co-Authored-By: Claude Opus 4.8 --- src/core/proc.rs | 21 +++++++++++++++++---- src/daemon/ssh/connect.rs | 4 ++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/core/proc.rs b/src/core/proc.rs index fc473426..a23bdb0e 100644 --- a/src/core/proc.rs +++ b/src/core/proc.rs @@ -8,19 +8,32 @@ //! //! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are //! unaffected, so output capture keeps working. Every non-PTY `Command` in the -//! app should go through [`hide_console`] before it runs. PTY children are not -//! in scope — the daemon owns those and passes its own flags (see -//! [`crate::daemon::spawn`]). +//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the +//! async flavor) before it runs. PTY children are not in scope — the daemon +//! owns those and passes its own flags (see [`crate::daemon::spawn`]). use std::process::Command; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + /// Suppress the console window Windows would otherwise allocate for a console /// subsystem child. No-op on Unix, so callers stay `cfg`-free. pub fn hide_console(cmd: &mut Command) -> &mut Command { #[cfg(windows)] { use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's +/// builder is a distinct type with its own `creation_flags`, not a `Deref` to +/// the std one. +pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { + #[cfg(windows)] + { cmd.creation_flags(CREATE_NO_WINDOW); } cmd diff --git a/src/daemon/ssh/connect.rs b/src/daemon/ssh/connect.rs index bc10c484..319d989c 100644 --- a/src/daemon/ssh/connect.rs +++ b/src/daemon/ssh/connect.rs @@ -173,6 +173,10 @@ fn spawn_proxy_command( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); + // The daemon is detached and has no console to lend this child, so without + // the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a + // console of its own that stays up for the whole session. + crate::core::proc::hide_console_tokio(&mut cmd); let mut child = cmd .spawn() .map_err(|e| anyhow::anyhow!("spawn ProxyCommand failed: {e}"))?;