From 4da3868797bc41d630fcc701651f958d62c77f69 Mon Sep 17 00:00:00 2001 From: webdev <86946125+biztex@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:52:35 +0700 Subject: [PATCH] feat(shells): let the new-tab menu carry entries the user wrote (#534) Closes #443 --- CHANGELOG.md | 14 ++ crates/tty7-core/src/core/config.rs | 45 +++++++ crates/tty7-core/src/core/shells.rs | 194 +++++++++++++++++++++++++++- crates/tty7-core/src/host/remote.rs | 1 + docs/reference/configuration.mdx | 1 + src/main.rs | 4 + src/ui/settings.rs | 13 +- src/ui/tab_strip.rs | 1 + src/ui/windows.rs | 20 +++ 9 files changed, 290 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d75bad..a3a39753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 automount off cannot see, and a `ZDOTDIR` aimed at nothing would start zsh with none of the user's own startup files at all. (#135) +- **Put your own entries in the new-tab menu** — `custom_shells` in + `config.json` takes a list of `{label, program, args}`, and each one becomes a + row in the menu behind the sidebar's **+**, after the shells tty7 detected. A + distro, a container, a REPL, the same shell against a different profile: they + are launched exactly as written, since tty7 chose none of the command and has + no defaults to add to it — including its shell integration, so a custom entry + opens without prompt marks or working-directory tracking even where the + detected row beside it has both. `shell` still names the one command that + stands in for the platform default; these are the rest. An entry with no + `program` is + skipped, one with no `label` is named after what it runs, and one that + borrows a name already in the menu is marked `(Custom)` so the row telling + you what a plain new tab opens with stays the only one wearing it. (#443) + ### Changed - **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index e097d5cb..4a81caa0 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -148,6 +148,12 @@ pub struct Config { #[serde(default = "default_prefix")] pub prefix: String, pub shell: Option, + /// Lenient on purpose: this is a hand-edited key with a nested shape, and a + /// typo in one entry must not fail the whole `Config` and hand the user + /// back defaults that the next settings write would then persist over what + /// they wrote. + #[serde(default, deserialize_with = "de_lenient")] + pub custom_shells: Vec, pub link_url: bool, pub link_file_command: Option, @@ -432,6 +438,21 @@ pub struct ShellConfig { pub args: Vec, } +/// A launcher the user put in the new-tab menu themselves. +/// +/// `shell` names the one command that stands in for the platform default; +/// these are the rest — a distro, a container, a REPL, the same shell against a +/// different profile. They are launched exactly as written, which is why `args` +/// crosses as user-authored: tty7 has no defaults to contribute to a command it +/// did not choose. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct CustomShell { + pub label: String, + pub program: String, + pub args: Vec, +} + pub fn default_font_fallbacks() -> Vec { let names: &[&str] = if cfg!(target_os = "macos") { &[ @@ -494,6 +515,7 @@ impl Default for Config { keybinding_preset: default_preset(), prefix: default_prefix(), shell: None, + custom_shells: Vec::new(), link_url: true, link_file_command: None, ssh_loopback_forward: false, @@ -1510,6 +1532,29 @@ mod tests { ); } + #[test] + fn a_mistyped_custom_shell_does_not_cost_the_user_the_rest_of_the_config() { + let cfg = Config::default(); + assert!(cfg.custom_shells.is_empty()); + + let cfg: Config = + serde_json::from_str(r#"{"font_size": 15.0, "custom_shells": {"Ubuntu": "wsl.exe"}}"#) + .expect("a bad custom_shells must not fail the whole config parse"); + assert!(cfg.custom_shells.is_empty()); + // The point of the leniency: `Config::load` hands back defaults for a + // config that fails to parse, and the next settings write persists them + // over the file. Everything the user wrote beside the typo survives. + assert_eq!(cfg.font_size, 15.0); + + let cfg: Config = serde_json::from_str( + r#"{"custom_shells":[{"label":"Ubuntu","program":"wsl.exe","args":["-d","Ubuntu"]}]}"#, + ) + .expect("a well-formed list parses"); + assert_eq!(cfg.custom_shells.len(), 1); + assert_eq!(cfg.custom_shells[0].program, "wsl.exe"); + assert_eq!(cfg.custom_shells[0].args, ["-d", "Ubuntu"]); + } + #[test] fn config_path_resolves_under_the_pinned_dir() { pin_config_dir(); diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index 32ffba12..220af7d9 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -16,6 +16,14 @@ pub struct DetectedShell { /// as tty7 defaults, so `true` preserves the previous protocol behavior. #[serde(default = "default_true")] pub args_are_tty7_defaults: bool, + /// Marks a row the user wrote into `custom_shells` rather than one tty7 + /// found. Detection is what Settings offers to stand in for the platform + /// default; an entry the user added is a menu extra, and a picker that + /// carries only a program would quietly drop the arguments that make it + /// what it is. Older peers omit this field and had no such rows, so `false` + /// is the honest reading of their inventory. + #[serde(default)] + pub user_authored: bool, } impl DetectedShell { @@ -25,6 +33,7 @@ impl DetectedShell { program: program.into(), args: Vec::new(), args_are_tty7_defaults: true, + user_authored: false, } } } @@ -40,8 +49,66 @@ pub struct ShellInventory { } pub fn inventory() -> ShellInventory { - let configured = crate::core::config::shell_command(); - inventory_from(detect_shells(), configured, &login_shell()) + // One read for both halves: `shell_command()` would open and parse + // `config.json` a second time, and this runs every time the menu is built. + let config = crate::core::config::Config::load(); + let configured = config + .shell + .as_ref() + .map(|s| (s.program.clone(), s.args.clone())); + let mut inventory = inventory_from(detect_shells(), configured, &login_shell()); + append_custom(&mut inventory, &config.custom_shells); + inventory +} + +/// Adds the user's own menu entries after everything that was detected. +/// +/// After, not among: the detected list is ordered so the shell a new tab +/// actually opens with sits at the top, and that ordering is the menu's answer +/// to "what do I get if I just click". A user-authored entry is an extra, not a +/// candidate for that position — so this runs once the default has already been +/// settled and cannot move it. +fn append_custom(inventory: &mut ShellInventory, custom: &[crate::core::config::CustomShell]) { + for (index, entry) in custom.iter().enumerate() { + let program = entry.program.trim(); + if program.is_empty() { + // Nothing to launch. The rest of the entry may be perfectly well + // formed, but a menu row that opens nothing is worse than no row. + // Say which one, though: a misspelled key deserializes to an entry + // that is simply empty, and a row that never appears is otherwise + // indistinguishable from the feature not working. + log::warn!("custom_shells[{index}] has no program; skipping it"); + continue; + } + let mut label = entry.label.trim().to_string(); + if label.is_empty() { + label = basename(program); + } + // The menu marks its default row by matching the label + // (`tab_strip::shell_menu`), so a custom entry that borrows a name + // already in the list would wear a mark meant for another row. Same + // answer the configured shell already gets when it collides — and it + // has to keep answering until the name is actually free, since the + // suffixed name can collide in its turn with a second entry that + // borrowed the same one, or with a label the user wrote suffix and + // all. `default_name` counts even when no row carries it: a hole + // `inventory_from` can leave, and the one name a custom row must never + // occupy. + while inventory.default_name == label + || inventory.shells.iter().any(|shell| shell.label == label) + { + label.push_str(" (Custom)"); + } + inventory.shells.push(DetectedShell { + label, + program: program.to_string(), + args: entry.args.clone(), + // tty7 chose none of this, so none of it is a tty7 default: the + // arguments have to survive into the pane exactly as written. + args_are_tty7_defaults: false, + user_authored: true, + }); + } } pub fn detect_shells() -> Vec { @@ -118,6 +185,7 @@ fn inventory_from( program, args, args_are_tty7_defaults: false, + user_authored: false, }, ); } @@ -427,6 +495,7 @@ fn detect_windows() -> Vec { program: bash.to_string_lossy().into_owned(), args: vec!["-i".into(), "-l".into()], args_are_tty7_defaults: true, + user_authored: false, }); } @@ -436,6 +505,7 @@ fn detect_windows() -> Vec { program: "wsl.exe".into(), args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()], args_are_tty7_defaults: true, + user_authored: false, }); } @@ -1181,4 +1251,124 @@ mod tests { assert!(!default_shell_name(None).is_empty()); assert!(!default_shell_name(Some(" ")).is_empty()); } + + fn custom(label: &str, program: &str, args: &[&str]) -> crate::core::config::CustomShell { + crate::core::config::CustomShell { + label: label.to_string(), + program: program.to_string(), + args: args.iter().map(|a| a.to_string()).collect(), + } + } + + fn menu(shells: &[&str]) -> ShellInventory { + ShellInventory { + shells: shells.iter().map(|s| DetectedShell::bare(*s, *s)).collect(), + default_name: shells.first().map(|s| s.to_string()).unwrap_or_default(), + } + } + + #[test] + fn a_custom_entry_joins_the_menu_with_its_arguments_intact() { + let mut inventory = menu(&["zsh"]); + append_custom( + &mut inventory, + &[custom("Ubuntu (dev)", "wsl.exe", &["-d", "Ubuntu"])], + ); + + let added = inventory.shells.last().expect("the entry"); + assert_eq!(added.label, "Ubuntu (dev)"); + assert_eq!(added.program, "wsl.exe"); + assert_eq!(added.args, vec!["-d".to_string(), "Ubuntu".to_string()]); + assert!( + !added.args_are_tty7_defaults, + "tty7 contributed none of this command, so none of it may be replaced as a default" + ); + assert_eq!( + inventory.default_name, "zsh", + "an extra entry does not change what a new tab opens with" + ); + } + + #[test] + fn a_custom_entry_with_nothing_to_launch_is_not_offered() { + let mut inventory = menu(&["zsh"]); + append_custom(&mut inventory, &[custom("Broken", " ", &[])]); + + assert_eq!(inventory.shells.len(), 1); + } + + #[test] + fn a_custom_entry_with_no_label_is_named_after_what_it_runs() { + let mut inventory = menu(&["zsh"]); + append_custom(&mut inventory, &[custom(" ", "/opt/homebrew/bin/nu", &[])]); + + assert_eq!(inventory.shells.last().expect("the entry").label, "nu"); + } + + #[test] + fn a_custom_entry_cannot_take_another_rows_name() { + let mut inventory = menu(&["zsh", "bash"]); + append_custom(&mut inventory, &[custom("zsh", "/usr/local/bin/zsh", &[])]); + + // The menu tells its default row apart by label alone, so two rows + // called "zsh" would put that mark on whichever came first. + assert_eq!( + inventory.shells.last().expect("the entry").label, + "zsh (Custom)" + ); + assert_eq!( + inventory + .shells + .iter() + .filter(|s| s.label == inventory.default_name) + .count(), + 1 + ); + } + + #[test] + fn custom_entries_that_all_want_the_same_name_still_get_one_each() { + let mut inventory = menu(&["zsh"]); + append_custom( + &mut inventory, + &[ + custom("zsh", "/a", &[]), + custom("zsh", "/b", &[]), + custom("zsh (Custom)", "/c", &[]), + ], + ); + + // Two rows with the same name launching different programs is the one + // outcome the menu cannot present: `conformance` fails a host whose + // inventory names a label twice, and the user cannot tell the rows + // apart to pick between them. + let labels: Vec<_> = inventory.shells.iter().map(|s| &s.label).collect(); + let unique: std::collections::HashSet<_> = labels.iter().collect(); + assert_eq!(labels.len(), unique.len(), "{labels:?}"); + } + + #[test] + fn a_custom_entry_cannot_claim_a_default_name_no_row_carries() { + // `inventory_from` can name a default that is in no row — a login shell + // recorded in passwd whose file is gone. The menu marks its default by + // label, so a custom entry landing on that name would wear the mark + // while a plain new tab opened something else entirely. + let mut inventory = menu(&["zsh"]); + inventory.default_name = "ksh".into(); + append_custom(&mut inventory, &[custom("ksh", "/usr/bin/ksh", &[])]); + + assert_eq!( + inventory.shells.last().expect("the entry").label, + "ksh (Custom)" + ); + } + + #[test] + fn no_custom_entries_leaves_the_menu_exactly_as_it_was() { + let before = menu(&["zsh", "bash"]); + let mut after = before.clone(); + append_custom(&mut after, &[]); + + assert_eq!(before, after); + } } diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 3f5f4fa1..71b70061 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -1037,6 +1037,7 @@ mod tests { program: "/usr/bin/zsh".into(), args: vec!["--no-rcs".into()], args_are_tty7_defaults: false, + user_authored: false, }], default_name: "zsh".into(), })), diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 81851282..2f0a29f9 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -85,6 +85,7 @@ their id from the file name. [More about themes →](/customization/themes) | Key | Type | Default | | |---|---|---|---| | `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. | +| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells — which also means tty7 adds no shell integration to one, so a custom entry has no prompt marks, working-directory tracking, or command-finished notifications even where the detected row beside it does. An entry with no `program` is skipped; with no `label` it is named after its program. | | `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. | | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | diff --git a/src/main.rs b/src/main.rs index 00b3e809..b445cfc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,6 +90,10 @@ fn spawn_config_watcher(cx: &mut App) { // in-app language picker does. crate::ui::theme::set_menus(cx); crate::ui::windows::WindowRegistry::refresh_locale(cx, None); + // `custom_shells` is only ever hand-edited, so this file is the + // one place it can change from — and the inventory that carries + // it to the new-tab menu is cached per window. + crate::ui::windows::WindowRegistry::refresh_shells(cx); cx.refresh_windows(); }); } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 86c8a941..d4f0e293 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4615,7 +4615,18 @@ impl Tty7App { // so the same choice was a menu in one place and a blind text field in // the other. The field stays: a shell tty7 did not find still has to be // reachable by path. - let shells = self.shells.shells.clone(); + // Detected shells only. A `custom_shells` row is a menu extra rather + // than a candidate for the platform default, and this picker hands its + // choice on as a program alone — so offering one here would set the + // default to a bare program and drop the arguments the user wrote it + // for, silently. + let shells: Vec<_> = self + .shells + .shells + .iter() + .filter(|shell| !shell.user_authored) + .cloned() + .collect(); let current_program = program_input.read(cx).value().trim().to_string(); let platform_default_item: SharedString = if cfg!(windows) { "PowerShell".into() diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 1f214029..7fbf6d04 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -2103,6 +2103,7 @@ mod tests { program: "custom-shell".into(), args: vec!["--login".into()], args_are_tty7_defaults: false, + user_authored: false, }; let spec = shell_spec(&shell); diff --git a/src/ui/windows.rs b/src/ui/windows.rs index 7b36585f..93b3aea3 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -132,6 +132,26 @@ impl WindowRegistry { } } + /// Rebuilds every window's shell inventory from disk. + /// + /// `custom_shells` is read while that inventory is assembled, and it lives + /// in `config.json` — which hot-reloads. The menu it feeds has to follow the + /// file there, or the one surface the feature has appears not to work until + /// the app is restarted, while every other key in the same save takes hold + /// at once. + pub fn refresh_shells(cx: &mut App) { + Self::sweep(cx); + let apps: Vec<_> = cx + .global::() + .windows + .iter() + .map(|entry| entry.app.clone()) + .collect(); + for app in apps { + let _ = app.update(cx, |app, cx| app.refresh_shells(cx)); + } + } + fn register( cx: &mut App, workspace: WorkspaceId,