feat(shell): detect installed shells, pick one from the "+" dropdown

The "+" button now opens a shell picker listing every shell found on
this machine — the login shell plus /etc/shells on Unix; PowerShell 7,
Windows PowerShell, cmd, Git Bash and WSL distributions on Windows — so
opening a tab in a different shell no longer requires hand-editing
config.json. The default entry leads the menu (Cmd+T still opens a
default tab in one keystroke), and splits inherit the pane's shell.

The Windows default shell now prefers PowerShell 7 when installed,
probed the way Warp does it (Program Files x64/x86/ARM, the Microsoft
Store shim, scoop, dotnet tools, then PATH), falling back to the
Windows PowerShell that ships with the OS.

Spawn precedence in the daemon is now: explicit per-spawn pick >
config.json `shell` > platform default. Wire compat is preserved across
GUI/daemon version skew: a default spawn keeps the legacy SPAWN frame
byte-for-byte, so an old daemon still serves a new GUI; only an
explicit pick uses the new SPAWN_SHELL kind, locked by a round-trip +
legacy-frame test.

Claude-Session: https://claude.ai/code/session_01ABey161AUxhgmJC3PRoYtF
This commit is contained in:
l0ng-ai
2026-07-07 23:31:54 +08:00
parent 60db1cee58
commit cdb383d6ce
12 changed files with 725 additions and 95 deletions
+19
View File
@@ -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
+7 -5
View File
@@ -46,9 +46,9 @@ pub struct Config {
pub keybindings: HashMap<String, String>,
/// 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<ShellConfig>,
// ── 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<PathBuf> {
/// 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<String>)> {
Config::load().shell.map(|s| (s.program, s.args))
}
+1
View File
@@ -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;
+394
View File
@@ -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<String>,
}
impl DetectedShell {
fn bare(label: impl Into<String>, program: impl Into<String>) -> 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<DetectedShell> {
#[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<String> {
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<Item = String>,
exists: impl Fn(&str) -> bool,
) -> Vec<DetectedShell> {
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<DetectedShell> {
// 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<String> = 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<PathBuf> {
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<Item = PathBuf>) -> Option<PathBuf> {
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<PathBuf> {
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<DetectedShell> {
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<PathBuf> {
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<String> {
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<String> {
// UTF-16LE: pair up bytes, tolerate a stray trailing byte.
let units: Vec<u16> = 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<u8> = 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::<String>::new());
let text = "\u{feff}Arch\r\n";
let bytes: Vec<u8> = 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());
}
}
+51 -21
View File
@@ -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<ShellSpec>,
configured: Option<(String, Vec<String>)>,
) -> Option<(String, Vec<String>)> {
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<PathBuf>,
size: WinSize,
shell: Option<ShellSpec>,
on_dead: impl FnOnce() + Send + 'static,
) -> anyhow::Result<Arc<Self>> {
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<String> {
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]
+94 -3
View File
@@ -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<String>,
}
/// 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<PathBuf>, 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<PathBuf>,
size: WinSize,
shell: Option<ShellSpec>,
},
/// 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<W: Write>(&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<PathBuf>, 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]
+2 -2
View File
@@ -164,7 +164,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> 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<Registry>) -> 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.
+12 -4
View File
@@ -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<PathBuf>,
shell: Option<ShellSpec>,
) -> 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) => {
+34 -5
View File
@@ -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<ShellSpec>,
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<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) -> anyhow::Result<Self> {
// 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<ShellSpec> {
self.shell_spec.clone()
}
fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context<Self>) {
// Surface a child-exit/daemon-disconnect noticed by the reader thread into
// the field the view reads directly (`self.terminal.exited`).
+40 -6
View File
@@ -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<DetectedShell>,
}
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>) {
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<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// 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<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Entity<TerminalView> {
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);
+2 -1
View File
@@ -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<Self>) -> AnyElement {
let muted_fg = cx.theme().muted_foreground;
let (program_input, args_input, wd_path_input) = match self.active_settings() {
+69 -48
View File
@@ -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::<Config>()
.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<F>(
&self,
id: &'static str,
icon: IconName,
cx: &mut Context<Self>,
on_click: F,
) -> impl IntoElement + use<F>
where
F: Fn(&mut Self, &mut Window, &mut Context<Self>) + '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)]