diff --git a/CHANGELOG.md b/CHANGELOG.md index b57fbe29..1ae99861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The "+" button now opens a shell picker: tty7 detects the shells installed + on this machine (the login shell plus `/etc/shells` on Unix; PowerShell 7, + Windows PowerShell, Command Prompt, Git Bash and WSL distributions on + Windows) and lists them in a dropdown, so opening a tab in a different shell + no longer requires editing `config.json`. The default entry leads the menu, + ⌘T / Ctrl+T still opens a default tab in one keystroke, and splitting a pane + inherits its shell — a fish tab splits into more fish, not back to the + default. Shells picked this way aren't remembered across restarts (restored + panes re-attach to their still-running shells anyway). + +### Changed + +- The Windows default shell now prefers PowerShell 7 (`pwsh.exe`) when + installed — probed across Program Files (x64/x86/ARM), the Microsoft Store, + scoop, dotnet tools and `PATH` — and falls back to Windows PowerShell as + before. Set `shell` in `config.json` to override, as ever. + ## [0.5.0] - 2026-07-07 ### Added diff --git a/src/core/config.rs b/src/core/config.rs index e6b0dc6b..aafee90e 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -46,9 +46,9 @@ pub struct Config { pub keybindings: HashMap, /// Optional shell override for the terminals tty7 spawns. When unset (the /// default), the platform's default shell is used: the user's login shell on - /// Unix (via `$SHELL`), and PowerShell on Windows. Set this to run a specific - /// shell instead — e.g. `pwsh` / `cmd` / WSL `bash` on Windows, or `fish` / - /// `bash` on Unix. + /// Unix (via `$SHELL`), and PowerShell on Windows (PowerShell 7 when + /// installed, else Windows PowerShell). Set this to run a specific shell + /// instead — e.g. `cmd` / WSL `bash` on Windows, or `fish` / `bash` on Unix. pub shell: Option, // ── Behavior ──────────────────────────────────────────────────────────── @@ -228,7 +228,8 @@ impl Default for Config { colors: Colors::default(), keybindings: HashMap::new(), // `None` → the platform default shell (login shell on Unix, - // PowerShell on Windows), chosen by the daemon at spawn time. + // PowerShell 7 / Windows PowerShell on Windows), chosen by the + // daemon at spawn time. shell: None, // Behavior defaults mirror the values previously hardcoded across the // app, so exposing them as config changes nothing until the user opts @@ -449,7 +450,8 @@ pub fn config_dir_path() -> Option { /// The user's configured shell override, if any, as `(program, args)`. Loaded /// straight from `config.json` so the **daemon** process (which has no GPUI /// `Config` global) can honor it when spawning a PTY. `None` → the daemon picks -/// the platform default (login shell on Unix, PowerShell on Windows). +/// the platform default (login shell on Unix, PowerShell 7 / Windows PowerShell +/// on Windows). pub fn shell_command() -> Option<(String, Vec)> { Config::load().shell.map(|s| (s.program, s.args)) } diff --git a/src/core/mod.rs b/src/core/mod.rs index 8fd243ac..9378bcb1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -11,5 +11,6 @@ pub mod actions; pub mod config; pub mod osc; pub mod session; +pub mod shells; pub mod threads; pub mod update; diff --git a/src/core/shells.rs b/src/core/shells.rs new file mode 100644 index 00000000..a29ba6e6 --- /dev/null +++ b/src/core/shells.rs @@ -0,0 +1,394 @@ +//! Shell discovery: enumerate the shells installed on this machine so the UI +//! can offer them in the new-tab dropdown, and resolve the platform default. +//! +//! Mirrors Warp's approach (`app/src/util/windows.rs` there): rather than +//! asking the user to type a program path into config, probe the well-known +//! install locations up front and present what actually exists. +//! +//! - **Unix**: `/etc/shells` is the system's own inventory — parse it, keep the +//! entries that exist, dedupe by basename (the same shell often appears as +//! both `/bin/zsh` and `/usr/local/bin/zsh`). The login shell (`$SHELL`) is +//! seeded first so it wins its dedupe slot and leads the list. +//! - **Windows**: there is no inventory file, so probe each shell's known +//! homes: PowerShell 7 across its six-ish install roots, Windows PowerShell +//! in System32, cmd via `%ComSpec%`, Git Bash under the Git install, and WSL +//! distributions via `wsl.exe -l -q`. +//! +//! Everything effectful (filesystem, env, spawning `wsl.exe`) stays in thin +//! wrappers; the parsing/selection logic is pure functions with unit tests. +//! Discovery can take a beat (WSL enumeration spawns a process), so callers +//! run [`detect_shells`] off the UI thread. + +use std::path::Path; +// The probe helpers below build candidate paths; they're Windows-only code. +#[cfg(windows)] +use std::path::PathBuf; + +/// One launchable shell surfaced in the new-tab dropdown. `program` + `args` +/// have the same shape as `config::ShellConfig` / `protocol::ShellSpec`: a +/// bare name resolved via `PATH` or an absolute path, plus launch arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedShell { + /// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`. + pub label: String, + pub program: String, + pub args: Vec, +} + +impl DetectedShell { + fn bare(label: impl Into, program: impl Into) -> Self { + Self { + label: label.into(), + program: program.into(), + args: Vec::new(), + } + } +} + +/// Enumerate the shells installed on this machine, best-effort. Order is +/// meaningful: the entry most likely to be the user's default comes first. +/// Runs filesystem probes (and `wsl.exe` on Windows) — call off the UI thread. +pub fn detect_shells() -> Vec { + #[cfg(unix)] + { + detect_unix() + } + #[cfg(windows)] + { + detect_windows() + } +} + +/// The short display name of the shell a *default* spawn resolves to: the +/// config override when set, otherwise the platform default (`$SHELL` on Unix, +/// the probed PowerShell on Windows). Drives the "Default (zsh)" menu label. +pub fn default_shell_name(configured: Option<&str>) -> String { + let program = match configured { + Some(p) if !p.trim().is_empty() => p.to_string(), + _ => { + #[cfg(unix)] + { + std::env::var("SHELL").unwrap_or_else(|_| "sh".into()) + } + #[cfg(windows)] + { + windows_default_shell().to_string() + } + } + }; + basename(&program) +} + +/// The last path component of `program`, lowercased on Windows and stripped of +/// a trailing `.exe` — `C:\...\pwsh.exe` and `/usr/local/bin/fish` both reduce +/// to their bare shell name for labels and dedupe keys. +fn basename(program: &str) -> String { + let base = Path::new(program) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| program.to_string()); + if cfg!(windows) { + let lower = base.to_ascii_lowercase(); + lower.strip_suffix(".exe").unwrap_or(&lower).to_string() + } else { + base + } +} + +// --------------------------------------------------------------------------- +// Unix +// --------------------------------------------------------------------------- + +/// Parse `/etc/shells` content: one absolute path per line, `#` comments and +/// blank lines skipped. Pure — the caller supplies the file content. +#[cfg_attr(windows, allow(dead_code))] +fn parse_etc_shells(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(str::to_string) + .collect() +} + +/// Order + dedupe the Unix candidate list: keep the first occurrence of each +/// basename that `exists` confirms, labelled by that basename. Pure — `exists` +/// is injected so tests need no real filesystem. +#[cfg_attr(windows, allow(dead_code))] +fn unix_shells_from( + candidates: impl IntoIterator, + exists: impl Fn(&str) -> bool, +) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for path in candidates { + if !exists(&path) { + continue; + } + let name = basename(&path); + if seen.insert(name.clone()) { + out.push(DetectedShell::bare(name, path)); + } + } + out +} + +#[cfg(unix)] +fn detect_unix() -> Vec { + // Seed the login shell first so it wins its basename's dedupe slot and + // leads the list — it also covers shells installed outside /etc/shells + // (nix/homebrew installs the user pointed $SHELL at without registering). + let login = std::env::var("SHELL").ok().filter(|s| !s.is_empty()); + let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default(); + let candidates = login.into_iter().chain(parse_etc_shells(&etc)); + unix_shells_from(candidates, |p| Path::new(p).is_file()) +} + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +/// The Windows shell a *default* spawn launches: PowerShell 7 (`pwsh.exe`) +/// when installed, else Windows PowerShell. Probed once and cached — the +/// daemon consults this on every pane spawn. +#[cfg(windows)] +pub fn windows_default_shell() -> &'static str { + use std::sync::OnceLock; + static DEFAULT: OnceLock = OnceLock::new(); + DEFAULT.get_or_init(|| { + find_pwsh7() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "powershell.exe".to_string()) + }) +} + +/// Locate PowerShell 7 the way Warp does: fixed install roots first (Program +/// Files x64/x86/ARM, dotnet tools, scoop, the Microsoft Store shim), then a +/// `PATH` search as the catch-all. +#[cfg(windows)] +fn find_pwsh7() -> Option { + let mut roots = Vec::new(); + for var in ["ProgramFiles", "ProgramFiles(x86)", "ProgramFiles(Arm)"] { + if let Some(pf) = std::env::var_os(var).filter(|v| !v.is_empty()) { + let pf = PathBuf::from(pf); + roots.push(pf.join("PowerShell").join("7")); + roots.push(pf.join("PowerShell").join("7-preview")); + } + } + if let Some(home) = std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()) { + let home = PathBuf::from(home); + roots.push(home.join(".dotnet").join("tools")); + roots.push(home.join("scoop").join("shims")); + } + if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { + roots.push(PathBuf::from(local).join("Microsoft").join("WindowsApps")); + } + pick_first_existing(roots.iter().map(|r| r.join("pwsh.exe"))) + .or_else(|| find_in_path("pwsh.exe")) +} + +/// First candidate that exists on disk. Shared by the per-shell probes. +#[cfg(windows)] +fn pick_first_existing(candidates: impl IntoIterator) -> Option { + candidates.into_iter().find(|p| p.is_file()) +} + +/// Minimal `PATH` search (no PATHEXT expansion — callers pass the full +/// `foo.exe` name). +#[cfg(windows)] +fn find_in_path(exe: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(exe)) + .find(|p| p.is_file()) +} + +#[cfg(windows)] +fn detect_windows() -> Vec { + let mut out = Vec::new(); + let system_root = + PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into())); + + if let Some(pwsh) = find_pwsh7() { + out.push(DetectedShell::bare( + "PowerShell 7", + pwsh.to_string_lossy().into_owned(), + )); + } + + let ps5 = system_root + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + if ps5.is_file() { + out.push(DetectedShell::bare( + "Windows PowerShell", + ps5.to_string_lossy().into_owned(), + )); + } + + let cmd = std::env::var_os("ComSpec") + .map(PathBuf::from) + .filter(|p| p.is_file()) + .unwrap_or_else(|| system_root.join("System32").join("cmd.exe")); + if cmd.is_file() { + out.push(DetectedShell::bare( + "Command Prompt", + cmd.to_string_lossy().into_owned(), + )); + } + + if let Some(bash) = find_git_bash() { + out.push(DetectedShell { + label: "Git Bash".into(), + program: bash.to_string_lossy().into_owned(), + // Interactive login shell — matches Git Bash's own launcher. + args: vec!["-i".into(), "-l".into()], + }); + } + + for distro in list_wsl_distros() { + out.push(DetectedShell { + label: format!("WSL · {distro}"), + program: "wsl.exe".into(), + // `--cd ~` lands in the distro's home rather than a translated + // Windows path the inner shell can't do much with. + args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()], + }); + } + + out +} + +/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64, +/// x86, and the per-user installer's home). +#[cfg(windows)] +fn find_git_bash() -> Option { + let mut candidates = Vec::new(); + for var in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(pf) = std::env::var_os(var).filter(|v| !v.is_empty()) { + candidates.push(PathBuf::from(pf).join("Git").join("bin").join("bash.exe")); + } + } + if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { + candidates.push( + PathBuf::from(local) + .join("Programs") + .join("Git") + .join("bin") + .join("bash.exe"), + ); + } + pick_first_existing(candidates) +} + +/// 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). +#[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 { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + parse_wsl_list(&output.stdout) +} + +/// Decode `wsl.exe -l -q` output — UTF-16LE, one distro per line — skipping +/// blanks and Docker Desktop's internal distros. Pure for testability. +#[cfg_attr(unix, allow(dead_code))] +fn parse_wsl_list(bytes: &[u8]) -> Vec { + // UTF-16LE: pair up bytes, tolerate a stray trailing byte. + let units: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16_lossy(&units); + text.lines() + .map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}' || c == '\0')) + .filter(|l| !l.is_empty() && !l.starts_with("docker-desktop")) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_etc_shells_skips_comments_and_blanks() { + let content = "# /etc/shells\n\n/bin/sh\n/bin/bash\n /bin/zsh \n# trailing\n"; + assert_eq!( + parse_etc_shells(content), + vec!["/bin/sh", "/bin/bash", "/bin/zsh"] + ); + } + + #[test] + fn unix_shells_dedupe_by_basename_keeping_first() { + // The login shell (seeded first) claims "zsh"; the /etc/shells copy of + // zsh under another prefix is dropped; missing files are dropped. + let candidates = [ + "/opt/homebrew/bin/zsh", + "/bin/zsh", + "/bin/bash", + "/usr/local/bin/fish", + ] + .map(String::from); + let exists = |p: &str| p != "/usr/local/bin/fish"; + let got = unix_shells_from(candidates, exists); + assert_eq!( + got, + vec![ + DetectedShell::bare("zsh", "/opt/homebrew/bin/zsh"), + DetectedShell::bare("bash", "/bin/bash"), + ] + ); + } + + #[test] + fn parse_wsl_list_decodes_utf16le_and_filters() { + // "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n" + let text = "Ubuntu\r\ndocker-desktop\r\ndocker-desktop-data\r\nDebian\r\n\r\n"; + let bytes: Vec = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert_eq!(parse_wsl_list(&bytes), vec!["Ubuntu", "Debian"]); + } + + #[test] + fn parse_wsl_list_tolerates_bom_and_empty_input() { + assert_eq!(parse_wsl_list(&[]), Vec::::new()); + let text = "\u{feff}Arch\r\n"; + let bytes: Vec = text.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert_eq!(parse_wsl_list(&bytes), vec!["Arch"]); + } + + #[test] + fn basename_reduces_paths_to_shell_names() { + assert_eq!(basename("/usr/local/bin/fish"), "fish"); + assert_eq!(basename("zsh"), "zsh"); + #[cfg(windows)] + { + assert_eq!(basename(r"C:\Program Files\PowerShell\7\pwsh.exe"), "pwsh"); + assert_eq!(basename("CMD.EXE"), "cmd"); + } + } + + #[test] + fn default_shell_name_prefers_the_configured_program() { + assert_eq!(default_shell_name(Some("/usr/bin/fish")), "fish"); + assert_eq!(default_shell_name(Some("pwsh")), "pwsh"); + // Blank config falls through to the platform default — just assert it + // yields *something* non-empty without pinning this host's $SHELL. + assert!(!default_shell_name(None).is_empty()); + assert!(!default_shell_name(Some(" ")).is_empty()); + } +} diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 9417139b..5ec3163d 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -35,23 +35,18 @@ use std::time::Duration; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; use crate::core::osc::OscTokenizer; -use crate::daemon::protocol::{DaemonMsg, PaneInfo, WinSize}; +use crate::daemon::protocol::{DaemonMsg, PaneInfo, ShellSpec, WinSize}; use crate::daemon::shell_integration; -/// The Windows default shell. `powershell.exe` (Windows PowerShell 5.1) ships -/// with every supported Windows, so it always resolves — users who prefer -/// `pwsh` 7+ or `cmd` can set `shell` in config. This is also the name shell -/// integration keys off (see [`default_shell_name`]). -#[cfg(windows)] -const WINDOWS_DEFAULT_SHELL: &str = "powershell.exe"; - /// The platform default shell command, used when the user hasn't set `shell` in /// `config.json`. On Windows `portable-pty`'s own default is `%COMSPEC%` -/// (i.e. `cmd.exe`); we override it to PowerShell so tty7's default matches what -/// the docs promise. +/// (i.e. `cmd.exe`); we override it to PowerShell — PowerShell 7 (`pwsh`) when +/// installed, probed once by `core::shells`, else the `powershell.exe` that +/// ships with every supported Windows. Mirrors Warp / Windows Terminal's +/// preference for the modern shell. #[cfg(windows)] fn default_prog() -> CommandBuilder { - CommandBuilder::new(WINDOWS_DEFAULT_SHELL) + CommandBuilder::new(crate::core::shells::windows_default_shell()) } /// On Unix, defer to `portable-pty`, which launches the user's login shell from @@ -66,10 +61,10 @@ fn default_prog() -> CommandBuilder { /// (`$SHELL` / passwd). On Windows we can't ask the builder: its `get_shell()` /// reports `%ComSpec%` (cmd.exe) regardless of what we actually spawn, so it /// would send integration detection chasing cmd.exe and never engage — return -/// our real default (`powershell.exe`) instead. +/// the same PowerShell `default_prog()` resolved instead. #[cfg(windows)] fn default_shell_name(_cmd: &CommandBuilder) -> String { - WINDOWS_DEFAULT_SHELL.to_string() + crate::core::shells::windows_default_shell().to_string() } #[cfg(not(windows))] @@ -77,6 +72,17 @@ fn default_shell_name(cmd: &CommandBuilder) -> String { cmd.get_shell() } +/// Which shell a spawn launches, by precedence: the explicit per-spawn override +/// (the new-tab dropdown) > the configured `shell` in `config.json` > `None`, +/// meaning the platform default (`default_prog()`). Kept as a function so the +/// contract is stated (and tested) in one place. +fn choose_shell( + spawn_override: Option, + configured: Option<(String, Vec)>, +) -> Option<(String, Vec)> { + spawn_override.map(|s| (s.program, s.args)).or(configured) +} + /// Default cap on the replay ring: 8 MiB. Enough to reconstruct a deep screen + /// scrollback for a fresh attach, while bounding daemon memory per pane. When the /// ring is full we drop the *oldest* bytes: a terminal stream is only meaningful @@ -242,24 +248,27 @@ pub struct DaemonPane { impl DaemonPane { /// Spawn the user's shell on a fresh PTY in `cwd`, sized to `size`, and start - /// its reader thread. `id` is the registry id the server assigns. `on_dead` - /// fires (from the reader thread) when the child exits while *nobody is - /// attached* — the case where no connection's detach would ever reclaim the - /// pane; the server uses it to drop the dead pane from its registry instead - /// of leaking the zombie child + replay ring for the daemon's lifetime. + /// its reader thread. `id` is the registry id the server assigns. `shell` is + /// an explicit per-spawn override (the new-tab dropdown) that outranks the + /// configured default — see [`choose_shell`]. `on_dead` fires (from the + /// reader thread) when the child exits while *nobody is attached* — the case + /// where no connection's detach would ever reclaim the pane; the server uses + /// it to drop the dead pane from its registry instead of leaking the zombie + /// child + replay ring for the daemon's lifetime. pub fn spawn( id: u64, cwd: Option, size: WinSize, + shell: Option, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result> { let pty_size = pty_size(size); let pair = native_pty_system().openpty(pty_size)?; - // Build the shell command. A configured `shell` wins; otherwise fall back - // to the platform default (the login shell on Unix, PowerShell on Windows). - let configured = crate::core::config::shell_command(); + // Build the shell command; `None` means the platform default (the login + // shell on Unix, PowerShell on Windows). + let configured = choose_shell(shell, crate::core::config::shell_command()); let mut cmd = match &configured { Some((program, args)) => { let mut c = CommandBuilder::new(program); @@ -1133,6 +1142,27 @@ fn proc_name(pid: i32) -> Option { mod tests { use super::*; + /// Spawn shell precedence: explicit override > configured > platform + /// default (`None`). Locks the contract stated on [`choose_shell`]. + #[test] + fn choose_shell_prefers_override_then_config_then_default() { + let over = ShellSpec { + program: "fish".into(), + args: vec!["-l".into()], + }; + let cfg = ("zsh".to_string(), vec!["-i".to_string()]); + + // Override wins even when a shell is configured. + assert_eq!( + choose_shell(Some(over.clone()), Some(cfg.clone())), + Some(("fish".to_string(), vec!["-l".to_string()])) + ); + // No override → the configured shell. + assert_eq!(choose_shell(None, Some(cfg.clone())), Some(cfg)); + // Neither → platform default. + assert_eq!(choose_shell(None, None), None); + } + /// A reader that finishes is joined and reported done — the common teardown /// path (group-kill closed the slave, the reader EOFed) returns cleanly. #[test] diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 07a24834..d777b2d0 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -43,6 +43,18 @@ pub struct WinSize { pub cell_h: u16, } +/// A shell program plus launch arguments, carried by `Spawn` when the user +/// picked a specific shell from the new-tab dropdown. Same shape as +/// `config::ShellConfig`, but defined here so the wire format doesn't depend +/// on the config module's evolution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellSpec { + /// Bare name resolved via `PATH` (`"pwsh"`) or an absolute path. + pub program: String, + #[serde(default)] + pub args: Vec, +} + /// Metadata for one live pane, returned by `List` for session restore / pickers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneInfo { @@ -61,7 +73,13 @@ pub struct PaneInfo { pub enum ClientMsg { /// Create a new pane (spawn a shell) in `cwd`, sized to `size`. The daemon /// replies `Spawned`, then this connection becomes that pane's stream. - Spawn { cwd: Option, size: WinSize }, + /// `shell` overrides the daemon's default shell resolution (config → + /// platform default) when the user picked one from the new-tab dropdown. + Spawn { + cwd: Option, + size: WinSize, + shell: Option, + }, /// Bind this connection to an existing pane and (re)size it. The daemon /// replies with a `Snapshot` then live `Output`. Attach { pane_id: u64, size: WinSize }, @@ -126,6 +144,12 @@ mod kind { pub const KILL: u8 = 6; pub const LIST: u8 = 7; pub const SHUTDOWN: u8 = 8; + /// `Spawn` with an explicit shell override. A separate kind (rather than a + /// new field under `SPAWN`) so a default spawn stays byte-identical on the + /// wire: the GUI and the long-lived daemon can be different versions, and + /// an old daemon must keep serving new-GUI default spawns. Only picking a + /// non-default shell sends this, and only a too-old daemon rejects it. + pub const SPAWN_SHELL: u8 = 9; // Daemon -> client pub const SPAWNED: u8 = 1; @@ -216,7 +240,19 @@ impl ClientMsg { /// Encode and write this message as one frame. pub fn encode(&self, w: &mut W) -> io::Result<()> { match self { - ClientMsg::Spawn { cwd, size } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), + // Default spawn keeps the legacy frame (kind + tuple payload) + // byte-for-byte so an older daemon still serves it; an explicit + // shell rides the newer SPAWN_SHELL frame. See `kind::SPAWN_SHELL`. + ClientMsg::Spawn { + cwd, + size, + shell: None, + } => write_frame(w, kind::SPAWN, &to_json(&(cwd, size))?), + ClientMsg::Spawn { + cwd, + size, + shell: shell @ Some(_), + } => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), ClientMsg::Attach { pane_id, size } => { write_frame(w, kind::ATTACH, &to_json(&(pane_id, size))?) } @@ -234,7 +270,15 @@ impl ClientMsg { Ok(match k { kind::SPAWN => { let (cwd, size) = from_json(&payload)?; - ClientMsg::Spawn { cwd, size } + ClientMsg::Spawn { + cwd, + size, + shell: None, + } + } + kind::SPAWN_SHELL => { + let (cwd, size, shell) = from_json(&payload)?; + ClientMsg::Spawn { cwd, size, shell } } kind::ATTACH => { let (pane_id, size) = from_json(&payload)?; @@ -359,6 +403,7 @@ mod tests { ClientMsg::Spawn { cwd: Some(PathBuf::from("/work")), size: SIZE, + shell: None, }, ClientMsg::Resize(SIZE), ClientMsg::Input(vec![b'l', b's', b'\r']), @@ -413,10 +458,20 @@ mod tests { ClientMsg::Spawn { cwd: Some(PathBuf::from("/tmp/x")), size: SIZE, + shell: None, }, ClientMsg::Spawn { cwd: None, size: SIZE, + shell: None, + }, + ClientMsg::Spawn { + cwd: Some(PathBuf::from("/tmp/x")), + size: SIZE, + shell: Some(ShellSpec { + program: "wsl.exe".into(), + args: vec!["--distribution".into(), "Ubuntu".into()], + }), }, ClientMsg::Attach { pane_id: 42, @@ -473,6 +528,42 @@ mod tests { } } + /// Wire compatibility across GUI/daemon version skew, both directions: + /// a default spawn (`shell: None`) must emit the *legacy* frame — kind + /// `SPAWN` with a `(cwd, size)` tuple an old daemon can decode — and a + /// hand-built legacy frame must decode with `shell: None`. Locks the + /// compat contract documented on `kind::SPAWN_SHELL`. + #[test] + fn default_spawn_stays_wire_compatible_with_old_daemons() { + // New client -> old daemon: encode and pick the frame apart. + let msg = ClientMsg::Spawn { + cwd: Some(PathBuf::from("/work")), + size: SIZE, + shell: None, + }; + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + let (k, payload) = read_frame(&mut std::io::Cursor::new(&buf)).unwrap(); + assert_eq!(k, kind::SPAWN, "default spawn must use the legacy kind"); + // An old daemon deserializes exactly a (cwd, size) tuple. + let (cwd, size): (Option, WinSize) = serde_json::from_slice(&payload).unwrap(); + assert_eq!(cwd, Some(PathBuf::from("/work"))); + assert_eq!(size, SIZE); + + // Old client -> new daemon: a hand-built legacy frame decodes to + // `shell: None`. + let legacy = serde_json::to_vec(&(Some(PathBuf::from("/old")), SIZE)).unwrap(); + let decoded = ClientMsg::from_frame(kind::SPAWN, legacy).unwrap(); + assert_eq!( + decoded, + ClientMsg::Spawn { + cwd: Some(PathBuf::from("/old")), + size: SIZE, + shell: None, + } + ); + } + /// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and /// an oversize length is rejected. #[test] diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 484736ef..d4368aef 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -164,7 +164,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { let first = ClientMsg::read(&mut read_stream)?; match first { - ClientMsg::Spawn { cwd, size } => { + ClientMsg::Spawn { cwd, size, shell } => { let id = registry.alloc_id(); // Reclaim a pane whose child exits while *detached* (nobody attached, // so no connection's detach path will ever drop it): remove it from @@ -184,7 +184,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { .ok(); } }; - let pane = match DaemonPane::spawn(id, cwd, size, on_dead) { + let pane = match DaemonPane::spawn(id, cwd, size, shell, on_dead) { Ok(p) => p, Err(e) => { // Report the failure to the client and close. diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index a13b2c8f..dbf9fdd7 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -34,7 +34,7 @@ use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi; use crate::core::osc::OscTokenizer; -use crate::daemon::protocol::{ClientMsg, DaemonMsg, WinSize}; +use crate::daemon::protocol::{ClientMsg, DaemonMsg, ShellSpec, WinSize}; use crate::daemon::transport::{self, Stream}; use super::size::TermSize; @@ -147,13 +147,16 @@ pub struct RemoteTerminal { impl RemoteTerminal { /// Connect to the daemon, spawn a fresh pane (shell) sized to `size`, and - /// start mirroring it. Returns the terminal plus the daemon-assigned - /// `pane_id` (the caller persists it for later session restore / `attach`). + /// start mirroring it. `shell` is the user's dropdown pick, overriding the + /// daemon's default shell resolution; `None` spawns the default. Returns + /// the terminal plus the daemon-assigned `pane_id` (the caller persists it + /// for later session restore / `attach`). pub fn spawn( size: TermSize, cell_w: u16, cell_h: u16, cwd: Option, + shell: Option, ) -> anyhow::Result<(Self, u64)> { let mut stream = connect()?; let win = win_size(size, cell_w, cell_h); @@ -161,7 +164,12 @@ impl RemoteTerminal { // Ask the daemon to create the pane, then read its assigned id back. The // very next frames on this connection are this pane's Snapshot + Output, // which the reader thread (started below) will consume. - ClientMsg::Spawn { cwd, size: win }.encode(&mut stream)?; + ClientMsg::Spawn { + cwd, + size: win, + shell, + } + .encode(&mut stream)?; let pane_id = match DaemonMsg::read(&mut stream)? { DaemonMsg::Spawned { pane_id } => pane_id, DaemonMsg::Error(msg) => { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3d1a144a..24c3eace 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -29,6 +29,7 @@ use crate::core::actions::{ CloseActiveTab, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{Config, NotifyMode}; +use crate::daemon::protocol::ShellSpec; // Terminal-scoped actions dispatched by the right-click context menu. They route // to this view via `.on_action` handlers on the terminal surface; tab/split @@ -59,6 +60,11 @@ pub struct TerminalView { /// so a restart can re-`attach` to the still-running pane (process + scrollback /// intact) instead of spawning a fresh shell. pub pane_id: u64, + /// The shell this pane was spawned with when the user picked one from the + /// new-tab dropdown; `None` for the default shell and for re-attached + /// panes. In-memory only (not persisted) — held so splits of this pane + /// inherit the same shell. + shell_spec: Option, pub focus_handle: FocusHandle, pub font: Font, /// Optional distinct base face for bold cells (from `font_family_bold`), with @@ -364,22 +370,38 @@ impl TerminalView { pub fn new( working_directory: Option, restore_pane: Option, + shell: Option, window: &mut Window, cx: &mut Context, ) -> anyhow::Result { // Provisional size; corrected on the first prepaint once we can measure. // The PTY lives in the daemon now. On session restore (`restore_pane`), // re-`attach` to the still-running pane so its process + scrollback come - // back intact; otherwise `spawn` a fresh pane. The caller only passes a - // `restore_pane` it has already confirmed alive, so we trust it here. - let (terminal, pane_id) = match restore_pane { + // back intact; otherwise `spawn` a fresh pane (with the caller's shell + // pick, if any). The caller only passes a `restore_pane` it has already + // confirmed alive, so we trust it here. + let (terminal, pane_id, shell_spec) = match restore_pane { Some(id) => ( RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?, id, + // An attached pane keeps whatever shell it already runs; the + // pick that spawned it (if any) isn't persisted. + None, ), - None => RemoteTerminal::spawn(TermSize::new(80, 24), 8, 17, working_directory)?, + None => { + let (terminal, id) = RemoteTerminal::spawn( + TermSize::new(80, 24), + 8, + 17, + working_directory, + shell.clone(), + )?; + (terminal, id, shell) + } }; - Ok(Self::with_terminal(terminal, pane_id, window, cx)) + let mut view = Self::with_terminal(terminal, pane_id, window, cx); + view.shell_spec = shell_spec; + Ok(view) } /// Build the view around an already-connected terminal. Split from [`new`] @@ -548,6 +570,7 @@ impl TerminalView { Self { terminal, pane_id, + shell_spec: None, focus_handle, font, font_bold, @@ -611,6 +634,12 @@ impl TerminalView { self.terminal.foreground_cwd() } + /// The shell this pane was explicitly spawned with (new-tab dropdown pick), + /// so splits can inherit it. `None` → the default shell. + pub fn shell_spec(&self) -> Option { + self.shell_spec.clone() + } + fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context) { // Surface a child-exit/daemon-disconnect noticed by the reader thread into // the field the view reads directly (`self.terminal.exited`). diff --git a/src/ui/app.rs b/src/ui/app.rs index 2f23f43c..f47f9c96 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -14,6 +14,8 @@ use gpui_component::{ActiveTheme as _, IndexPath, TitleBar}; use crate::core::actions::*; use crate::core::config::{Config, NewTabPosition, ShellConfig, color_or, hsla_to_hex6}; use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab}; +use crate::core::shells::DetectedShell; +use crate::daemon::protocol::ShellSpec; use crate::terminal::view::{ChildExited, TerminalView}; use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView}; use crate::ui::pane::{CloseOutcome, Pane}; @@ -134,6 +136,10 @@ pub struct Tty7App { /// Keeping something focused keeps keystrokes flowing through the window's /// dispatch path, so ⌘T & friends still reach the root action handlers. pub(crate) home_focus: gpui::FocusHandle, + /// Shells found on this machine (`core::shells::detect_shells`), listed in + /// the "+" dropdown. Probed once at startup off the UI thread — empty until + /// that lands, when the dropdown offers just the default entry. + pub(crate) detected_shells: Vec, } impl Tty7App { @@ -168,7 +174,7 @@ impl Tty7App { // predecessor to inherit from, so start in the app's current // directory (None → default behavior). None => { - let first = new_terminal(font_size, None, None, window, cx); + let first = new_terminal(font_size, None, None, None, window, cx); (vec![Tab::new(Pane::leaf(first))], 0) } // A saved session (with tabs, or an empty home-page state): rebuild it @@ -193,7 +199,18 @@ impl Tty7App { mod_hint_badges: false, mod_hint_gen: 0, home_focus: cx.focus_handle(), + detected_shells: Vec::new(), }; + // Discover this machine's shells for the "+" dropdown off the UI thread + // (the WSL probe on Windows spawns a process, and /etc/shells hits the + // filesystem). Until it lands the dropdown offers just the default entry. + cx.spawn(async move |this, cx| { + let shells = cx + .background_spawn(async { crate::core::shells::detect_shells() }) + .await; + let _ = this.update(cx, |app, _| app.detected_shells = shells); + }) + .detach(); // Persist the session one last time as the app quits. This captures the // latest state — including a plain `cd` that changed a pane's cwd but // triggered no structural change — so the next launch restores where the @@ -665,6 +682,17 @@ impl Tty7App { } pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context) { + self.new_tab_with_shell(None, window, cx); + } + + /// Open a new tab running `shell` — a pick from the "+" dropdown — or the + /// default shell when `None` (the plain "+" click / Cmd+T path). + pub(crate) fn new_tab_with_shell( + &mut self, + shell: Option, + window: &mut Window, + cx: &mut Context, + ) { // Inherit the cwd of the active tab's focused terminal so the new tab // opens in the same directory the user is currently working in. let cwd = self.tabs.get(self.active).and_then(|t| { @@ -672,7 +700,7 @@ impl Tty7App { .focused_or_first(window, cx) .and_then(|leaf| leaf.read(cx).cwd()) }); - let tab = new_terminal(self.font_size, cwd, None, window, cx); + let tab = new_terminal(self.font_size, cwd, None, shell, window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); self.tabs.insert(insert_at, Tab::new(Pane::leaf(tab))); @@ -694,9 +722,12 @@ impl Tty7App { else { return; }; - // The new pane inherits the cwd of the pane being split. + // The new pane inherits the cwd — and the shell, when the pane being + // split was opened with an explicit pick (a WSL/fish tab splits into + // more WSL/fish, not back to the default). let cwd = target.read(cx).cwd(); - let new = new_terminal(self.font_size, cwd, None, window, cx); + let shell = target.read(cx).shell_spec(); + let new = new_terminal(self.font_size, cwd, None, shell, window, cx); if let Some(tab) = self.tabs.get_mut(self.active) { if tab.pane.split_leaf(&target, axis, new.clone()) { self.maximized = None; @@ -1861,7 +1892,9 @@ fn session_to_pane( // Only restore the pane id when the daemon confirms it's still live; // a stale id (daemon restarted, pane killed) falls back to a spawn. let restore = (*pane_id).filter(|id| alive.contains(id)); - let view = new_terminal(font_size, cwd.clone(), restore, window, cx); + // A shell pick isn't persisted in the session, so a stale pane that + // must respawn comes back on the default shell. + let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx); Pane::leaf(view) } SessionPane::Split { axis, ratio, a, b } => { @@ -1880,11 +1913,12 @@ fn new_terminal( font_size: f32, working_directory: Option, restore_pane: Option, + shell: Option, window: &mut Window, cx: &mut Context, ) -> Entity { let view = cx.new(|cx| { - let mut view = TerminalView::new(working_directory, restore_pane, window, cx) + let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx) .expect("failed to start terminal"); // Inherit the current global font size so new panes match existing ones. view.font_size = px(font_size); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 8b5f2a4f..9036d50f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -728,7 +728,8 @@ impl Tty7App { /// Shell section: the program tty7 launches in each new terminal, plus its /// launch arguments. Both apply to *newly spawned* panes/tabs — existing /// shells keep running until closed. An empty program falls back to the - /// platform default (the login shell on Unix, PowerShell on Windows). + /// platform default (the login shell on Unix; PowerShell 7 when installed, + /// else Windows PowerShell, on Windows). fn render_settings_shell(&self, cx: &mut Context) -> AnyElement { let muted_fg = cx.theme().muted_foreground; let (program_input, args_input, wd_path_input) = match self.active_settings() { diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index ff09da3a..8442bd18 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -10,8 +10,11 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; -use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex}; +use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, Size, h_flex}; +use crate::core::config::Config; +use crate::daemon::protocol::ShellSpec; use crate::ui::app::{Tab, Tty7App}; use crate::ui::hints::tab_badge_label; @@ -329,56 +332,74 @@ impl Tty7App { strip = strip.child(chip); } - // "+" new-tab button — a title-bar tile in the tab row's rhythm. - strip = - strip.child( - self.title_bar_tile("tab-add", IconName::Plus, cx, |this, window, cx| { - this.new_tab(window, cx); - }), - ); + // "+" new-tab button — click opens the shell picker. The default shell + // leads the menu (so the common case is two quick clicks on the same + // spot; ⌘T still opens a default tab in one), followed by every shell + // discovered on this machine (`detected_shells`, probed at startup). + // Built on gpui-component's `DropdownMenu`, which is only implemented + // for `Button` — hence a ghost Button restyled to the title bar's 30px + // tile rhythm (30px box, 15px glyph, soft corners) rather than the + // hand-rolled tile the "+" used to be. + let shells = self.detected_shells.clone(); + let default_name = crate::core::shells::default_shell_name( + cx.global::() + .shell + .as_ref() + .map(|s| s.program.as_str()), + ); + let app = cx.entity().downgrade(); + strip = strip.child( + // Same Windows titlebar note as the chips above: `occlude()` gives + // the trigger a BlockMouse hitbox so the TitleBar's HTCAPTION drag + // area doesn't swallow the click. + div().occlude().flex_shrink_0().child( + Button::new("tab-add") + .icon(Icon::new(IconName::Plus).size(px(15.))) + .ghost() + .xsmall() + .w(px(30.)) + .h(px(30.)) + .rounded_lg() + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu.with_size(Size::Small).min_w(px(220.)); + // Default first — what a bare "new tab" means today, + // named so the fallback is legible ("New Tab (zsh)"). + let open_default = app.clone(); + menu = menu.item( + PopupMenuItem::new(format!("New Tab ({default_name})")).on_click( + move |_, window, cx| { + if let Some(app) = open_default.upgrade() { + app.update(cx, |this, cx| this.new_tab(window, cx)); + } + }, + ), + ); + if !shells.is_empty() { + menu = menu.separator(); + } + for shell in &shells { + let spec = ShellSpec { + program: shell.program.clone(), + args: shell.args.clone(), + }; + let open = app.clone(); + menu = menu.item(PopupMenuItem::new(shell.label.clone()).on_click( + move |_, window, cx| { + if let Some(app) = open.upgrade() { + app.update(cx, |this, cx| { + this.new_tab_with_shell(Some(spec.clone()), window, cx); + }); + } + }, + )); + } + menu + }), + ), + ); strip } - - /// A minimal clickable icon tile sized to sit in the title bar's rhythm: - /// chip-height (30px) box, chip-sized (15px) glyph, quiet hover. We hand-roll - /// it because gpui-component's Button locks its glyph to 0.75× the box, so it - /// can't hit a 30px target with a 15px glyph — Button's xsmall would float a - /// 20px square beside the 30px chips. `occlude()` makes the tile a `BlockMouse` - /// hitbox — like the chips — so on Windows the click isn't swallowed by the - /// TitleBar's `HTCAPTION` drag area; mouse-down + stop_propagation also keeps - /// the click off the TitleBar's zoom-on-double-click handler. Shared by the - /// "+" new-tab button and the split-pane buttons. - fn title_bar_tile( - &self, - id: &'static str, - icon: IconName, - cx: &mut Context, - on_click: F, - ) -> impl IntoElement + use - where - F: Fn(&mut Self, &mut Window, &mut Context) + 'static, - { - div() - .id(id) - .occlude() - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .size(px(30.)) - .rounded_lg() - .text_color(cx.theme().muted_foreground) - .hover(|s| s.bg(cx.theme().muted)) - .child(Icon::new(icon).size(px(15.))) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - on_click(this, window, cx); - }), - ) - } } #[cfg(test)]