cli: Add first-run prompt for default open behavior and abstract IPC transport (#53663)

Eric Holk and Nathan Sobo created

This PR adds the `cli_default_open_behavior` setting and a first-run TUI
prompt
that appears when `zed <path>` is invoked without flags while existing
windows are
open and the setting hasn't been configured yet.

## What it does

### Setting and prompt

- Adds a new `cli_default_open_behavior` workspace setting with two
values:
  `existing_window` (default) and `new_window`.
- When the user runs `zed <path>` for the first time with existing Zed
windows
open, a `dialoguer::Select` prompt in the CLI asks them to choose their
  preferred behavior. The choice is persisted to `settings.json`.
- The prompt is skipped when:
  - An explicit flag (`-n`, `-e`, `-a`) is given
  - No existing Zed windows are open
  - The setting is already configured in `settings.json`
- The paths being opened are already contained in an existing workspace

### IPC transport abstraction

- Introduces a `CliResponseSink` trait in the `cli` crate that abstracts
`IpcSender<CliResponse>`, with an implementation for the real IPC
sender.
- Replaces `IpcSender<CliResponse>` with `Box<dyn CliResponseSink>` /
  `&dyn CliResponseSink` across all signatures in `open_listener.rs`:
  `OpenRequestKind::CliConnection`, `handle_cli_connection`,
`maybe_prompt_open_behavior`, `open_workspaces`, `open_local_workspace`.
- Extracts the inline CLI response loop from `main.rs` into a testable
  `cli::run_cli_response_loop` function.
- Switches the request channel from bounded `mpsc::channel(16)` to
`mpsc::unbounded()`, eliminating `smol::block_on` in the bridge thread.

### End-to-end tests

Seven new tests exercise both the CLI-side response loop and the
Zed-side
handler connected through in-memory channels, using `allow_parking()` so
the
real `cli::run_cli_response_loop` runs on an OS thread while the GPUI
executor
drives the Zed handler:

- No flags, no windows → no prompt, opens new window
- No flags, existing windows, user picks "existing window" → prompt,
setting persisted
- No flags, existing windows, user picks "new window" → prompt, setting
persisted
- Setting already configured → no prompt
- Paths already in existing workspace → no prompt
- Explicit `-e` flag → no prompt
- Explicit `-n` flag → no prompt

Existing tests that previously used `ipc::channel()` now use a
`DiscardResponseSink`, removing OS-level IPC from all tests.

Release Notes:

- Added a first-run prompt when using `zed <path>` to choose between
opening
in an existing window or a new window. The choice is saved to settings
and
  can be changed later via the `cli_default_open_behavior` setting.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                                  |  54 +
Cargo.toml                                  |   2 
assets/settings/default.json                |   9 
crates/cli/Cargo.toml                       |   2 
crates/cli/src/cli.rs                       |  28 +
crates/cli/src/main.rs                      |  58 +
crates/gpui/src/elements/text.rs            |  56 ++
crates/settings/src/vscode_import.rs        |   1 
crates/settings_content/src/workspace.rs    |  30 +
crates/settings_ui/src/page_data.rs         |  23 
crates/settings_ui/src/settings_ui.rs       |   4 
crates/ui/src/components/label/label.rs     | 113 ++++
crates/workspace/src/workspace.rs           |  56 +
crates/workspace/src/workspace_settings.rs  |   2 
crates/zed/src/zed/open_listener.rs         | 613 +++++++++++++++++++++-
crates/zed/src/zed/windows_only_instance.rs |   6 
16 files changed, 972 insertions(+), 85 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2876,8 +2876,10 @@ dependencies = [
  "askpass",
  "clap",
  "collections",
+ "console",
  "core-foundation 0.10.0",
  "core-services",
+ "dialoguer",
  "exec",
  "fork",
  "ipc-channel",
@@ -3418,6 +3420,18 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "console"
+version = "0.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "unicode-width",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "console_error_panic_hook"
 version = "0.1.7"
@@ -4839,6 +4853,16 @@ dependencies = [
  "zlog",
 ]
 
+[[package]]
+name = "dialoguer"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
+dependencies = [
+ "console",
+ "shell-words",
+]
+
 [[package]]
 name = "diff"
 version = "0.1.13"
@@ -4925,7 +4949,7 @@ dependencies = [
  "libc",
  "option-ext",
  "redox_users 0.5.2",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5520,6 +5544,12 @@ dependencies = [
  "phf 0.11.3",
 ]
 
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
 [[package]]
 name = "encoding_rs"
 version = "0.8.35"
@@ -5691,7 +5721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -7116,7 +7146,7 @@ dependencies = [
  "gobject-sys",
  "libc",
  "system-deps 7.0.7",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -8445,7 +8475,7 @@ dependencies = [
  "js-sys",
  "log",
  "wasm-bindgen",
- "windows-core 0.57.0",
+ "windows-core 0.62.2",
 ]
 
 [[package]]
@@ -11089,7 +11119,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -13535,7 +13565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
 dependencies = [
  "bytes 1.11.1",
- "heck 0.4.1",
+ "heck 0.5.0",
  "itertools 0.12.1",
  "log",
  "multimap",
@@ -13841,7 +13871,7 @@ dependencies = [
  "once_cell",
  "socket2 0.6.1",
  "tracing",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
@@ -15033,7 +15063,7 @@ dependencies = [
  "errno 0.3.14",
  "libc",
  "linux-raw-sys 0.11.0",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -16286,7 +16316,7 @@ version = "0.8.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
 dependencies = [
- "heck 0.4.1",
+ "heck 0.5.0",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
@@ -17500,7 +17530,7 @@ dependencies = [
  "getrandom 0.3.4",
  "once_cell",
  "rustix 1.1.2",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -18419,7 +18449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f"
 dependencies = [
  "cc",
- "windows-targets 0.48.5",
+ "windows-targets 0.52.6",
 ]
 
 [[package]]
@@ -20531,7 +20561,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -295,6 +295,7 @@ command_palette_hooks = { path = "crates/command_palette_hooks" }
 compliance = { path = "tooling/compliance" }
 component = { path = "crates/component" }
 component_preview = { path = "crates/component_preview" }
+console = "0.16"
 context_server = { path = "crates/context_server" }
 copilot = { path = "crates/copilot" }
 copilot_chat = { path = "crates/copilot_chat" }
@@ -313,6 +314,7 @@ deepseek = { path = "crates/deepseek" }
 derive_refineable = { path = "crates/refineable/derive_refineable" }
 dev_container = { path = "crates/dev_container" }
 diagnostics = { path = "crates/diagnostics" }
+dialoguer = { version = "0.12", default-features = false }
 editor = { path = "crates/editor" }
 encoding_selector = { path = "crates/encoding_selector" }
 env_var = { path = "crates/env_var" }

assets/settings/default.json 🔗

@@ -134,6 +134,15 @@
   //  3. Do not restore previous workspaces
   //         "restore_on_startup": "none",
   "restore_on_startup": "last_session",
+  // The default behavior when opening paths from the CLI without
+  // an explicit `-e` (existing window) or `-n` (new window) flag.
+  //
+  // May take 2 values:
+  //  1. Add to the existing Zed window
+  //         "cli_default_open_behavior": "existing_window"
+  //  2. Open a new Zed window
+  //         "cli_default_open_behavior": "new_window"
+  "cli_default_open_behavior": "existing_window",
   // Whether to attempt to restore previous file's state when opening it again.
   // The state is stored per pane.
   // When disabled, defaults are applied instead of the state restoration.

crates/cli/Cargo.toml 🔗

@@ -25,6 +25,8 @@ anyhow.workspace = true
 askpass.workspace = true
 clap.workspace = true
 collections.workspace = true
+console.workspace = true
+dialoguer.workspace = true
 ipc-channel = "0.19"
 parking_lot.workspace = true
 paths.workspace = true

crates/cli/src/cli.rs 🔗

@@ -1,3 +1,4 @@
+use anyhow::Result;
 use collections::HashMap;
 pub use ipc_channel::ipc;
 use serde::{Deserialize, Serialize};
@@ -8,6 +9,13 @@ pub struct IpcHandshake {
     pub responses: ipc::IpcReceiver<CliResponse>,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CliOpenBehavior {
+    ExistingWindow,
+    NewWindow,
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub enum CliRequest {
     Open {
@@ -18,11 +26,16 @@ pub enum CliRequest {
         wsl: Option<String>,
         wait: bool,
         open_new_workspace: Option<bool>,
+        #[serde(default)]
+        force_existing_window: bool,
         reuse: bool,
         env: Option<HashMap<String, String>>,
         user_data_dir: Option<String>,
         dev_container: bool,
     },
+    SetOpenBehavior {
+        behavior: CliOpenBehavior,
+    },
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -31,6 +44,7 @@ pub enum CliResponse {
     Stdout { message: String },
     Stderr { message: String },
     Exit { status: i32 },
+    PromptOpenBehavior,
 }
 
 /// When Zed started not as an *.app but as a binary (e.g. local development),
@@ -39,3 +53,17 @@ pub enum CliResponse {
 /// Note that in the main zed binary, this variable is unset after it's read for the first time,
 /// therefore it should always be accessed through the `FORCE_CLI_MODE` static.
 pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE";
+
+/// Abstracts the transport for sending CLI responses (Zed → CLI).
+///
+/// Production code uses `IpcSender<CliResponse>`. Tests can provide in-memory
+/// implementations to avoid OS-level IPC.
+pub trait CliResponseSink: Send + 'static {
+    fn send(&self, response: CliResponse) -> Result<()>;
+}
+
+impl CliResponseSink for ipc::IpcSender<CliResponse> {
+    fn send(&self, response: CliResponse) -> Result<()> {
+        ipc::IpcSender::send(self, response).map_err(|error| anyhow::anyhow!("{error}"))
+    }
+}

crates/cli/src/main.rs 🔗

@@ -25,7 +25,6 @@ use tempfile::{NamedTempFile, TempDir};
 use util::paths::PathWithPosition;
 use walkdir::WalkDir;
 
-#[cfg(any(target_os = "linux", target_os = "freebsd"))]
 use std::io::IsTerminal;
 
 const URL_PREFIX: [&'static str; 5] = ["zed://", "http://", "https://", "file://", "ssh://"];
@@ -68,14 +67,17 @@ struct Args {
     #[arg(short, long)]
     wait: bool,
     /// Add files to the currently open workspace
-    #[arg(short, long, overrides_with_all = ["new", "reuse"])]
+    #[arg(short, long, overrides_with_all = ["new", "reuse", "existing"])]
     add: bool,
     /// Create a new workspace
-    #[arg(short, long, overrides_with_all = ["add", "reuse"])]
+    #[arg(short, long, overrides_with_all = ["add", "reuse", "existing"])]
     new: bool,
     /// Reuse an existing window, replacing its workspace
-    #[arg(short, long, overrides_with_all = ["add", "new"])]
+    #[arg(short, long, overrides_with_all = ["add", "new", "existing"])]
     reuse: bool,
+    /// Open in existing Zed window
+    #[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse"])]
+    existing: bool,
     /// Sets a custom directory for all user data (e.g., database, extensions, logs).
     /// This overrides the default platform-specific data directory location:
     #[cfg_attr(target_os = "macos", doc = "`~/Library/Application Support/Zed`.")]
@@ -544,6 +546,8 @@ fn main() -> Result<()> {
         None
     };
 
+    let force_existing_window = args.existing;
+
     let env = {
         #[cfg(any(target_os = "linux", target_os = "freebsd"))]
         {
@@ -665,7 +669,7 @@ fn main() -> Result<()> {
                 #[cfg(not(target_os = "windows"))]
                 let wsl = None;
 
-                tx.send(CliRequest::Open {
+                let open_request = CliRequest::Open {
                     paths,
                     urls,
                     diff_paths,
@@ -673,11 +677,14 @@ fn main() -> Result<()> {
                     wsl,
                     wait: args.wait,
                     open_new_workspace,
+                    force_existing_window,
                     reuse: args.reuse,
                     env,
                     user_data_dir: user_data_dir_for_thread,
                     dev_container: args.dev_container,
-                })?;
+                };
+
+                tx.send(open_request)?;
 
                 while let Ok(response) = rx.recv() {
                     match response {
@@ -688,6 +695,11 @@ fn main() -> Result<()> {
                             exit_status.lock().replace(status);
                             return Ok(());
                         }
+                        CliResponse::PromptOpenBehavior => {
+                            let behavior = prompt_open_behavior()
+                                .unwrap_or(cli::CliOpenBehavior::ExistingWindow);
+                            tx.send(CliRequest::SetOpenBehavior { behavior })?;
+                        }
                     }
                 }
 
@@ -781,6 +793,40 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
     }
 }
 
+/// Shows an interactive prompt asking the user to choose the default open
+/// behavior for `zed <path>`. Returns `None` if the prompt cannot be shown
+/// (e.g. stdin is not a terminal) or the user cancels.
+fn prompt_open_behavior() -> Option<cli::CliOpenBehavior> {
+    if !std::io::stdin().is_terminal() {
+        return None;
+    }
+
+    let blue = console::Style::new().blue();
+    let items = [
+        format!("Add to existing Zed window ({})", blue.apply_to("zed -e")),
+        format!("Open a new window ({})", blue.apply_to("zed -n")),
+    ];
+
+    let prompt = format!(
+        "Configure default behavior for {}\n{}",
+        blue.apply_to("zed <path>"),
+        console::style("You can change this later in Zed settings"),
+    );
+
+    let selection = dialoguer::Select::new()
+        .with_prompt(&prompt)
+        .items(&items)
+        .default(0)
+        .interact()
+        .ok()?;
+
+    Some(if selection == 0 {
+        cli::CliOpenBehavior::ExistingWindow
+    } else {
+        cli::CliOpenBehavior::NewWindow
+    })
+}
+
 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
 mod linux {
     use std::{

crates/gpui/src/elements/text.rs 🔗

@@ -159,6 +159,7 @@ pub struct StyledText {
     text: SharedString,
     runs: Option<Vec<TextRun>>,
     delayed_highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
+    delayed_font_family_overrides: Option<Vec<(Range<usize>, SharedString)>>,
     layout: TextLayout,
 }
 
@@ -169,6 +170,7 @@ impl StyledText {
             text: text.into(),
             runs: None,
             delayed_highlights: None,
+            delayed_font_family_overrides: None,
             layout: TextLayout::default(),
         }
     }
@@ -242,6 +244,51 @@ impl StyledText {
         runs
     }
 
+    /// Override the font family for specific byte ranges of the text.
+    ///
+    /// This is resolved lazily at layout time, so the overrides are applied
+    /// on top of the inherited text style from the parent element.
+    /// Can be combined with [`with_highlights`](Self::with_highlights).
+    ///
+    /// The overrides must be sorted by range start and non-overlapping.
+    /// Each override range must fall on character boundaries.
+    pub fn with_font_family_overrides(
+        mut self,
+        overrides: impl IntoIterator<Item = (Range<usize>, SharedString)>,
+    ) -> Self {
+        self.delayed_font_family_overrides = Some(
+            overrides
+                .into_iter()
+                .inspect(|(range, _)| {
+                    debug_assert!(self.text.is_char_boundary(range.start));
+                    debug_assert!(self.text.is_char_boundary(range.end));
+                })
+                .collect(),
+        );
+        self
+    }
+
+    fn apply_font_family_overrides(
+        runs: &mut [TextRun],
+        overrides: &[(Range<usize>, SharedString)],
+    ) {
+        let mut byte_offset = 0;
+        let mut override_idx = 0;
+        for run in runs.iter_mut() {
+            let run_end = byte_offset + run.len;
+            while override_idx < overrides.len() && overrides[override_idx].0.end <= byte_offset {
+                override_idx += 1;
+            }
+            if override_idx < overrides.len() {
+                let (ref range, ref family) = overrides[override_idx];
+                if byte_offset >= range.start && run_end <= range.end {
+                    run.font.family = family.clone();
+                }
+            }
+            byte_offset = run_end;
+        }
+    }
+
     /// Set the text runs for this piece of text.
     pub fn with_runs(mut self, runs: Vec<TextRun>) -> Self {
         let mut text = &**self.text;
@@ -278,12 +325,19 @@ impl Element for StyledText {
         window: &mut Window,
         cx: &mut App,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        let runs = self.runs.take().or_else(|| {
+        let font_family_overrides = self.delayed_font_family_overrides.take();
+        let mut runs = self.runs.take().or_else(|| {
             self.delayed_highlights.take().map(|delayed_highlights| {
                 Self::compute_runs(&self.text, &window.text_style(), delayed_highlights)
             })
         });
 
+        if let Some(ref overrides) = font_family_overrides {
+            let runs =
+                runs.get_or_insert_with(|| vec![window.text_style().to_run(self.text.len())]);
+            Self::apply_font_family_overrides(runs, overrides);
+        }
+
         let layout_id = self.layout.layout(self.text.clone(), runs, window, cx);
         (layout_id, ())
     }

crates/settings/src/vscode_import.rs 🔗

@@ -975,6 +975,7 @@ impl VsCodeSettings {
             }),
             bottom_dock_layout: None,
             centered_layout: None,
+            cli_default_open_behavior: None,
             close_on_file_delete: None,
             close_panel_on_toggle: None,
             command_aliases: Default::default(),

crates/settings_content/src/workspace.rs 🔗

@@ -49,6 +49,11 @@ pub struct WorkspaceSettingsContent {
     /// Values: empty_tab, last_workspace, last_session, launchpad
     /// Default: last_session
     pub restore_on_startup: Option<RestoreOnStartupBehavior>,
+    /// The default behavior when opening paths from the CLI without
+    /// an explicit `-e` or `-n` flag.
+    ///
+    /// Default: existing_window
+    pub cli_default_open_behavior: Option<CliDefaultOpenBehavior>,
     /// Whether to attempt to restore previous file's state when opening it again.
     /// The state is stored per pane.
     /// When disabled, defaults are applied instead of the state restoration.
@@ -379,6 +384,31 @@ impl CloseWindowWhenNoItems {
     }
 }
 
+#[derive(
+    Copy,
+    Clone,
+    PartialEq,
+    Eq,
+    Default,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    Debug,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum CliDefaultOpenBehavior {
+    /// Add to the existing Zed window as a new workspace.
+    #[default]
+    #[strum(serialize = "Add to Existing Window")]
+    ExistingWindow,
+    /// Open a new Zed window.
+    #[strum(serialize = "Open a New Window")]
+    NewWindow,
+}
+
 #[derive(
     Copy,
     Clone,

crates/settings_ui/src/page_data.rs 🔗

@@ -80,7 +80,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
 }
 
 fn general_page() -> SettingsPage {
-    fn general_settings_section() -> [SettingsPageItem; 8] {
+    fn general_settings_section() -> [SettingsPageItem; 9] {
         [
             SettingsPageItem::SectionHeader("General Settings"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -140,6 +140,27 @@ fn general_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "CLI Default Open Behavior",
+                description: "How `zed <path>` opens directories when no `-e` or `-n` flag is specified.",
+                field: Box::new(SettingField {
+                    json_path: Some("cli_default_open_behavior"),
+                    pick: |settings_content| {
+                        settings_content
+                            .workspace
+                            .cli_default_open_behavior
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content.workspace.cli_default_open_behavior = value;
+                    },
+                }),
+                metadata: Some(Box::new(SettingsFieldMetadata {
+                    should_do_titlecase: Some(false),
+                    ..Default::default()
+                })),
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Use System Path Prompts",
                 description: "Use native OS dialogs for 'Open' and 'Save As'.",

crates/settings_ui/src/settings_ui.rs 🔗

@@ -458,6 +458,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
         .add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
         .add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
+        .add_basic_renderer::<settings::CliDefaultOpenBehavior>(render_dropdown)
         .add_basic_renderer::<settings::CloseWindowWhenNoItems>(render_dropdown)
         .add_basic_renderer::<settings::TextRenderingMode>(render_dropdown)
         .add_basic_renderer::<settings::FontFamilyName>(render_font_picker)
@@ -1213,7 +1214,8 @@ fn render_settings_item(
                 .child(
                     Label::new(SharedString::new_static(setting_item.description))
                         .size(LabelSize::Small)
-                        .color(Color::Muted),
+                        .color(Color::Muted)
+                        .render_code_spans(),
                 ),
         )
         .child(control)

crates/ui/src/components/label/label.rs 🔗

@@ -1,5 +1,7 @@
+use std::ops::Range;
+
 use crate::{LabelLike, prelude::*};
-use gpui::StyleRefinement;
+use gpui::{HighlightStyle, StyleRefinement, StyledText};
 
 /// A struct representing a label element in the UI.
 ///
@@ -33,6 +35,7 @@ use gpui::StyleRefinement;
 pub struct Label {
     base: LabelLike,
     label: SharedString,
+    render_code_spans: bool,
 }
 
 impl Label {
@@ -49,9 +52,17 @@ impl Label {
         Self {
             base: LabelLike::new(),
             label: label.into(),
+            render_code_spans: false,
         }
     }
 
+    /// When enabled, text wrapped in backticks (e.g. `` `code` ``) will be
+    /// rendered in the buffer (monospace) font.
+    pub fn render_code_spans(mut self) -> Self {
+        self.render_code_spans = true;
+        self
+    }
+
     /// Sets the text of the [`Label`].
     pub fn set_text(&mut self, text: impl Into<SharedString>) {
         self.label = text.into();
@@ -233,11 +244,109 @@ impl LabelCommon for Label {
 }
 
 impl RenderOnce for Label {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        if self.render_code_spans {
+            if let Some((stripped, code_ranges)) = parse_backtick_spans(&self.label) {
+                let buffer_font_family = theme::theme_settings(cx).buffer_font(cx).family.clone();
+                let background_color = cx.theme().colors().element_background;
+
+                let highlights = code_ranges.iter().map(|range| {
+                    (
+                        range.clone(),
+                        HighlightStyle {
+                            background_color: Some(background_color),
+                            ..Default::default()
+                        },
+                    )
+                });
+
+                let font_overrides = code_ranges
+                    .iter()
+                    .map(|range| (range.clone(), buffer_font_family.clone()));
+
+                return self.base.child(
+                    StyledText::new(stripped)
+                        .with_highlights(highlights)
+                        .with_font_family_overrides(font_overrides),
+                );
+            }
+        }
         self.base.child(self.label)
     }
 }
 
+/// Parses backtick-delimited code spans from a string.
+///
+/// Returns `None` if there are no matched backtick pairs.
+/// Otherwise returns the text with backticks stripped and the byte ranges
+/// of the code spans in the stripped string.
+fn parse_backtick_spans(text: &str) -> Option<(SharedString, Vec<Range<usize>>)> {
+    if !text.contains('`') {
+        return None;
+    }
+
+    let mut stripped = String::with_capacity(text.len());
+    let mut code_ranges = Vec::new();
+    let mut in_code = false;
+    let mut code_start = 0;
+
+    for ch in text.chars() {
+        if ch == '`' {
+            if in_code {
+                code_ranges.push(code_start..stripped.len());
+            } else {
+                code_start = stripped.len();
+            }
+            in_code = !in_code;
+        } else {
+            stripped.push(ch);
+        }
+    }
+
+    if code_ranges.is_empty() {
+        return None;
+    }
+
+    Some((SharedString::from(stripped), code_ranges))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_backtick_spans_no_backticks() {
+        assert_eq!(parse_backtick_spans("plain text"), None);
+    }
+
+    #[test]
+    fn test_parse_backtick_spans_single_span() {
+        let (text, ranges) = parse_backtick_spans("use `zed` to open").unwrap();
+        assert_eq!(text.as_ref(), "use zed to open");
+        assert_eq!(ranges, vec![4..7]);
+    }
+
+    #[test]
+    fn test_parse_backtick_spans_multiple_spans() {
+        let (text, ranges) = parse_backtick_spans("flags `-e` or `-n`").unwrap();
+        assert_eq!(text.as_ref(), "flags -e or -n");
+        assert_eq!(ranges, vec![6..8, 12..14]);
+    }
+
+    #[test]
+    fn test_parse_backtick_spans_unmatched_backtick() {
+        // A trailing unmatched backtick should not produce a code range
+        assert_eq!(parse_backtick_spans("trailing `backtick"), None);
+    }
+
+    #[test]
+    fn test_parse_backtick_spans_empty_span() {
+        let (text, ranges) = parse_backtick_spans("empty `` span").unwrap();
+        assert_eq!(text.as_ref(), "empty  span");
+        assert_eq!(ranges, vec![6..6]);
+    }
+}
+
 impl Component for Label {
     fn scope() -> ComponentScope {
         ComponentScope::Typography

crates/workspace/src/workspace.rs 🔗

@@ -9317,6 +9317,7 @@ pub struct OpenOptions {
     pub visible: Option<OpenVisible>,
     pub focus: Option<bool>,
     pub open_new_workspace: Option<bool>,
+    pub force_existing_window: bool,
     pub wait: bool,
     pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
     pub open_mode: OpenMode,
@@ -9501,31 +9502,42 @@ pub fn open_paths(
         }
 
         // Fallback for directories: when no flag is specified and no existing
-        // workspace matched, add the directory as a new workspace in the
-        // active window's MultiWorkspace (instead of opening a new window).
+        // workspace matched, check the user's setting to decide whether to add
+        // the directory as a new workspace in the active window's MultiWorkspace
+        // or open a new window.
         if open_options.open_new_workspace.is_none() && existing.is_none() {
-            let target_window = cx.update(|cx| {
-                let windows = workspace_windows_for_location(
-                    &SerializedWorkspaceLocation::Local,
-                    cx,
-                );
-                let window = cx
-                    .active_window()
-                    .and_then(|window| window.downcast::<MultiWorkspace>())
-                    .filter(|window| windows.contains(window))
-                    .or_else(|| windows.into_iter().next());
-                window.filter(|window| {
-                    window.read(cx).is_ok_and(|mw| mw.multi_workspace_enabled(cx))
-                })
-            });
+            let use_existing_window = open_options.force_existing_window
+                || cx.update(|cx| {
+                    WorkspaceSettings::get_global(cx).cli_default_open_behavior
+                        == settings::CliDefaultOpenBehavior::ExistingWindow
+                });
 
-            if let Some(window) = target_window {
-                open_options.requesting_window = Some(window);
-                window
-                    .update(cx, |multi_workspace, _, cx| {
-                        multi_workspace.open_sidebar(cx);
+            if use_existing_window {
+                let target_window = cx.update(|cx| {
+                    let windows = workspace_windows_for_location(
+                        &SerializedWorkspaceLocation::Local,
+                        cx,
+                    );
+                    let window = cx
+                        .active_window()
+                        .and_then(|window| window.downcast::<MultiWorkspace>())
+                        .filter(|window| windows.contains(window))
+                        .or_else(|| windows.into_iter().next());
+                    window.filter(|window| {
+                        window
+                            .read(cx)
+                            .is_ok_and(|mw| mw.multi_workspace_enabled(cx))
                     })
-                    .log_err();
+                });
+
+                if let Some(window) = target_window {
+                    open_options.requesting_window = Some(window);
+                    window
+                        .update(cx, |multi_workspace, _, cx| {
+                            multi_workspace.open_sidebar(cx);
+                        })
+                        .log_err();
+                }
             }
         }
 

crates/workspace/src/workspace_settings.rs 🔗

@@ -20,6 +20,7 @@ pub struct WorkspaceSettings {
     pub show_call_status_icon: bool,
     pub autosave: AutosaveSetting,
     pub restore_on_startup: settings::RestoreOnStartupBehavior,
+    pub cli_default_open_behavior: settings::CliDefaultOpenBehavior,
     pub restore_on_file_reopen: bool,
     pub drop_target_size: f32,
     pub use_system_path_prompts: bool,
@@ -99,6 +100,7 @@ impl Settings for WorkspaceSettings {
             show_call_status_icon: workspace.show_call_status_icon.unwrap(),
             autosave: workspace.autosave.unwrap(),
             restore_on_startup: workspace.restore_on_startup.unwrap(),
+            cli_default_open_behavior: workspace.cli_default_open_behavior.unwrap(),
             restore_on_file_reopen: workspace.restore_on_file_reopen.unwrap(),
             drop_target_size: workspace.drop_target_size.unwrap(),
             use_system_path_prompts: workspace.use_system_path_prompts.unwrap(),

crates/zed/src/zed/open_listener.rs 🔗

@@ -2,7 +2,7 @@ use crate::handle_open_request;
 use crate::restore_or_create_workspace;
 use agent_ui::ExternalSourcePrompt;
 use anyhow::{Context as _, Result, anyhow};
-use cli::{CliRequest, CliResponse, ipc::IpcSender};
+use cli::{CliRequest, CliResponse, CliResponseSink};
 use cli::{IpcHandshake, ipc};
 use client::{ZedLink, parse_zed_link};
 use db::kvp::KeyValueStore;
@@ -12,7 +12,7 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
 use futures::channel::{mpsc, oneshot};
 use futures::future;
 
-use futures::{FutureExt, SinkExt, StreamExt};
+use futures::{FutureExt, StreamExt};
 use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView};
 use gpui::{App, AsyncApp, Global, WindowHandle};
 use onboarding::FIRST_OPEN;
@@ -26,6 +26,7 @@ use std::thread;
 use std::time::Duration;
 use ui::SharedString;
 use util::ResultExt;
+use util::debug_panic;
 use util::paths::PathWithPosition;
 use workspace::PathList;
 use workspace::item::ItemHandle;
@@ -43,9 +44,13 @@ pub struct OpenRequest {
     pub remote_connection: Option<RemoteConnectionOptions>,
 }
 
-#[derive(Debug)]
 pub enum OpenRequestKind {
-    CliConnection((mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)),
+    CliConnection(
+        (
+            mpsc::UnboundedReceiver<CliRequest>,
+            Box<dyn CliResponseSink>,
+        ),
+    ),
     Extension {
         extension_id: String,
     },
@@ -73,6 +78,45 @@ pub enum OpenRequestKind {
     },
 }
 
+impl std::fmt::Debug for OpenRequestKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::CliConnection(_) => write!(f, "CliConnection(..)"),
+            Self::Extension { extension_id } => f
+                .debug_struct("Extension")
+                .field("extension_id", extension_id)
+                .finish(),
+            Self::AgentPanel {
+                external_source_prompt,
+            } => f
+                .debug_struct("AgentPanel")
+                .field("external_source_prompt", external_source_prompt)
+                .finish(),
+            Self::SharedAgentThread { session_id } => f
+                .debug_struct("SharedAgentThread")
+                .field("session_id", session_id)
+                .finish(),
+            Self::DockMenuAction { index } => f
+                .debug_struct("DockMenuAction")
+                .field("index", index)
+                .finish(),
+            Self::BuiltinJsonSchema { schema_path } => f
+                .debug_struct("BuiltinJsonSchema")
+                .field("schema_path", schema_path)
+                .finish(),
+            Self::Setting { setting_path } => f
+                .debug_struct("Setting")
+                .field("setting_path", setting_path)
+                .finish(),
+            Self::GitClone { repo_url } => f
+                .debug_struct("GitClone")
+                .field("repo_url", repo_url)
+                .finish(),
+            Self::GitCommit { sha } => f.debug_struct("GitCommit").field("sha", sha).finish(),
+        }
+    }
+}
+
 impl OpenRequest {
     pub fn parse(request: RawOpenRequest, cx: &App) -> Result<Self> {
         let mut this = Self::default();
@@ -305,8 +349,11 @@ pub fn listen_for_cli_connections(opener: OpenListener) -> Result<()> {
 
 fn connect_to_cli(
     server_name: &str,
-) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
-    let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
+) -> Result<(
+    mpsc::UnboundedReceiver<CliRequest>,
+    Box<dyn CliResponseSink>,
+)> {
+    let handshake_tx = ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
         .context("error connecting to cli")?;
     let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
     let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
@@ -318,18 +365,17 @@ fn connect_to_cli(
         })
         .context("error sending ipc handshake")?;
 
-    let (mut async_request_tx, async_request_rx) =
-        futures::channel::mpsc::channel::<CliRequest>(16);
+    let (async_request_tx, async_request_rx) = futures::channel::mpsc::unbounded::<CliRequest>();
     thread::spawn(move || {
         while let Ok(cli_request) = request_rx.recv() {
-            if smol::block_on(async_request_tx.send(cli_request)).is_err() {
+            if async_request_tx.unbounded_send(cli_request).is_err() {
                 break;
             }
         }
         anyhow::Ok(())
     });
 
-    Ok((async_request_rx, response_tx))
+    Ok((async_request_rx, Box::new(response_tx)))
 }
 
 pub async fn open_paths_with_positions(
@@ -399,7 +445,10 @@ pub async fn open_paths_with_positions(
 }
 
 pub async fn handle_cli_connection(
-    (mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
+    (mut requests, responses): (
+        mpsc::UnboundedReceiver<CliRequest>,
+        Box<dyn CliResponseSink>,
+    ),
     app_state: Arc<AppState>,
     cx: &mut AsyncApp,
 ) {
@@ -412,7 +461,8 @@ pub async fn handle_cli_connection(
                 diff_all,
                 wait,
                 wsl,
-                open_new_workspace,
+                mut open_new_workspace,
+                mut force_existing_window,
                 reuse,
                 env,
                 user_data_dir: _,
@@ -447,13 +497,36 @@ pub async fn handle_cli_connection(
                     return;
                 }
 
+                if let Some(behavior) = maybe_prompt_open_behavior(
+                    open_new_workspace,
+                    force_existing_window,
+                    reuse,
+                    &paths,
+                    &app_state,
+                    responses.as_ref(),
+                    &mut requests,
+                    cx,
+                )
+                .await
+                {
+                    match behavior {
+                        settings::CliDefaultOpenBehavior::ExistingWindow => {
+                            force_existing_window = true;
+                        }
+                        settings::CliDefaultOpenBehavior::NewWindow => {
+                            open_new_workspace = Some(true);
+                        }
+                    }
+                }
+
                 let open_workspace_result = open_workspaces(
                     paths,
                     diff_paths,
                     diff_all,
                     open_new_workspace,
+                    force_existing_window,
                     reuse,
-                    &responses,
+                    responses.as_ref(),
                     wait,
                     dev_container,
                     app_state.clone(),
@@ -465,8 +538,117 @@ pub async fn handle_cli_connection(
                 let status = if open_workspace_result.is_err() { 1 } else { 0 };
                 responses.send(CliResponse::Exit { status }).log_err();
             }
+            CliRequest::SetOpenBehavior { .. } => {
+                // We handle this case in a situation-specific way in
+                // maybe_prompt_open_behavior
+                debug_panic!("unexpected SetOpenBehavior message");
+            }
+        }
+    }
+}
+
+/// Checks whether the CLI user should be prompted to configure their default
+/// open behavior. Sends `CliResponse::PromptOpenBehavior` and waits for the
+/// CLI's response if all of these are true:
+///   - No explicit flag was given (`-n`, `-e`, `-a`)
+///   - There is at least one existing Zed window
+///   - The user has not yet configured `cli_default_open_behavior` in settings
+///
+/// Returns the user's choice, or `None` if no prompt was needed or the CLI
+/// didn't respond.
+async fn maybe_prompt_open_behavior(
+    open_new_workspace: Option<bool>,
+    force_existing_window: bool,
+    reuse: bool,
+    paths: &[String],
+    app_state: &Arc<AppState>,
+    responses: &dyn CliResponseSink,
+    requests: &mut mpsc::UnboundedReceiver<CliRequest>,
+    cx: &mut AsyncApp,
+) -> Option<settings::CliDefaultOpenBehavior> {
+    if open_new_workspace.is_some() || force_existing_window || reuse {
+        return None;
+    }
+
+    let has_existing_windows = cx.update(|cx| {
+        cx.windows()
+            .iter()
+            .any(|window| window.downcast::<MultiWorkspace>().is_some())
+    });
+
+    if !has_existing_windows {
+        return None;
+    }
+
+    if !paths.is_empty() {
+        let paths_as_pathbufs: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
+        let paths_in_existing_workspace = cx.update(|cx| {
+            for window in cx.windows() {
+                if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+                    if let Ok(multi_workspace) = multi_workspace.read(cx) {
+                        for workspace in multi_workspace.workspaces() {
+                            let project = workspace.read(cx).project().read(cx);
+                            if project
+                                .visibility_for_paths(&paths_as_pathbufs, false, cx)
+                                .is_some()
+                            {
+                                return true;
+                            }
+                        }
+                    }
+                }
+            }
+            false
+        });
+
+        if paths_in_existing_workspace {
+            return None;
+        }
+    }
+
+    if !paths.is_empty() {
+        let has_directory =
+            futures::future::join_all(paths.iter().map(|p| app_state.fs.is_dir(Path::new(p))))
+                .await
+                .into_iter()
+                .any(|is_dir| is_dir);
+
+        if !has_directory {
+            return None;
         }
     }
+
+    let settings_text = app_state
+        .fs
+        .load(paths::settings_file())
+        .await
+        .unwrap_or_default();
+
+    if settings_text.contains("cli_default_open_behavior") {
+        return None;
+    }
+
+    responses.send(CliResponse::PromptOpenBehavior).log_err()?;
+
+    if let Some(CliRequest::SetOpenBehavior { behavior }) = requests.next().await {
+        let behavior = match behavior {
+            cli::CliOpenBehavior::ExistingWindow => {
+                settings::CliDefaultOpenBehavior::ExistingWindow
+            }
+            cli::CliOpenBehavior::NewWindow => settings::CliDefaultOpenBehavior::NewWindow,
+        };
+
+        let fs = app_state.fs.clone();
+        cx.update(|cx| {
+            settings::update_settings_file(fs, cx, move |content, _cx| {
+                content.workspace.cli_default_open_behavior = Some(behavior);
+            });
+        });
+
+        return Some(behavior);
+    }
+
+    None
 }
 
 async fn open_workspaces(
@@ -474,8 +656,9 @@ async fn open_workspaces(
     diff_paths: Vec<[String; 2]>,
     diff_all: bool,
     open_new_workspace: Option<bool>,
+    force_existing_window: bool,
     reuse: bool,
-    responses: &IpcSender<CliResponse>,
+    responses: &dyn CliResponseSink,
     wait: bool,
     dev_container: bool,
     app_state: Arc<AppState>,
@@ -536,6 +719,7 @@ async fn open_workspaces(
         };
         let open_options = workspace::OpenOptions {
             open_new_workspace,
+            force_existing_window,
             requesting_window: replace_window,
             wait,
             env: env.clone(),
@@ -600,7 +784,7 @@ async fn open_local_workspace(
     diff_paths: Vec<[String; 2]>,
     diff_all: bool,
     open_options: workspace::OpenOptions,
-    responses: &IpcSender<CliResponse>,
+    responses: &dyn CliResponseSink,
     app_state: &Arc<AppState>,
     cx: &mut AsyncApp,
 ) -> bool {
@@ -742,10 +926,7 @@ pub async fn derive_paths_with_position(
 mod tests {
     use super::*;
     use crate::zed::{open_listener::open_local_workspace, tests::init_test};
-    use cli::{
-        CliResponse,
-        ipc::{self},
-    };
+    use cli::CliResponse;
     use editor::Editor;
     use futures::poll;
     use gpui::{AppContext as _, TestAppContext};
@@ -757,6 +938,24 @@ mod tests {
     use util::path;
     use workspace::{AppState, MultiWorkspace};
 
+    struct DiscardResponseSink;
+
+    impl CliResponseSink for DiscardResponseSink {
+        fn send(&self, _response: CliResponse) -> anyhow::Result<()> {
+            Ok(())
+        }
+    }
+
+    struct SyncResponseSender(std::sync::mpsc::Sender<CliResponse>);
+
+    impl CliResponseSink for SyncResponseSender {
+        fn send(&self, response: CliResponse) -> anyhow::Result<()> {
+            self.0
+                .send(response)
+                .map_err(|error| anyhow::anyhow!("{error}"))
+        }
+    }
+
     #[gpui::test]
     fn test_parse_ssh_url(cx: &mut TestAppContext) {
         let _app_state = init_test(cx);
@@ -1072,7 +1271,7 @@ mod tests {
             )
             .await;
 
-        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let response_sink = DiscardResponseSink;
         let workspace_paths = vec![path!("/root/dir1").to_owned()];
 
         let (done_tx, mut done_rx) = futures::channel::oneshot::channel();
@@ -1087,7 +1286,7 @@ mod tests {
                         wait: true,
                         ..Default::default()
                     },
-                    &response_tx,
+                    &response_sink,
                     &app_state,
                     &mut cx,
                 )
@@ -1171,7 +1370,7 @@ mod tests {
         app_state: Arc<AppState>,
         cx: &TestAppContext,
     ) {
-        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let response_sink = DiscardResponseSink;
 
         let workspace_paths = vec![path.to_owned()];
 
@@ -1185,7 +1384,7 @@ mod tests {
                         open_new_workspace,
                         ..Default::default()
                     },
-                    &response_tx,
+                    &response_sink,
                     &app_state,
                     &mut cx,
                 )
@@ -1243,20 +1442,19 @@ mod tests {
             .unwrap();
 
         // First, open a workspace normally
-        let (response_tx, _response_rx) = ipc::channel::<CliResponse>().unwrap();
+        let response_sink = DiscardResponseSink;
         let workspace_paths = vec![file1_path.to_string()];
 
         let _errored = cx
             .spawn({
                 let app_state = app_state.clone();
-                let response_tx = response_tx.clone();
                 |mut cx| async move {
                     open_local_workspace(
                         workspace_paths,
                         vec![],
                         false,
                         workspace::OpenOptions::default(),
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1282,8 +1480,8 @@ mod tests {
         let errored_reuse = cx
             .spawn({
                 let app_state = app_state.clone();
-                let response_tx = response_tx.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         workspace_paths_reuse,
                         vec![],
@@ -1292,7 +1490,7 @@ mod tests {
                             requesting_window: Some(window_to_replace),
                             ..Default::default()
                         },
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1426,21 +1624,19 @@ mod tests {
             .await
             .unwrap();
 
-        let (response_tx, _response_rx) = ipc::channel::<CliResponse>().unwrap();
-
         // Open first workspace
         let workspace_paths_1 = vec![file1_path.to_string()];
         let _errored = cx
             .spawn({
                 let app_state = app_state.clone();
-                let response_tx = response_tx.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         workspace_paths_1,
                         Vec::new(),
                         false,
                         workspace::OpenOptions::default(),
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1457,8 +1653,8 @@ mod tests {
         let _errored = cx
             .spawn({
                 let app_state = app_state.clone();
-                let response_tx = response_tx.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         workspace_paths_2,
                         Vec::new(),
@@ -1467,7 +1663,7 @@ mod tests {
                             open_new_workspace: Some(true), // Force new window
                             ..Default::default()
                         },
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1503,8 +1699,8 @@ mod tests {
         let _errored = cx
             .spawn({
                 let app_state = app_state.clone();
-                let response_tx = response_tx.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         workspace_paths_add,
                         Vec::new(),
@@ -1513,7 +1709,7 @@ mod tests {
                             open_new_workspace: Some(false), // --add flag
                             ..Default::default()
                         },
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1564,11 +1760,11 @@ mod tests {
             )
             .await;
 
-        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
         let errored = cx
             .spawn({
                 let app_state = app_state.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         vec![path!("/project").to_owned()],
                         vec![],
@@ -1577,7 +1773,7 @@ mod tests {
                             open_in_dev_container: true,
                             ..Default::default()
                         },
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1618,11 +1814,11 @@ mod tests {
             )
             .await;
 
-        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
         let errored = cx
             .spawn({
                 let app_state = app_state.clone();
                 |mut cx| async move {
+                    let response_sink = DiscardResponseSink;
                     open_local_workspace(
                         vec![path!("/project").to_owned()],
                         vec![],
@@ -1631,7 +1827,7 @@ mod tests {
                             open_in_dev_container: true,
                             ..Default::default()
                         },
-                        &response_tx,
+                        &response_sink,
                         &app_state,
                         &mut cx,
                     )
@@ -1661,4 +1857,341 @@ mod tests {
             })
             .unwrap();
     }
+
+    fn make_cli_open_request(
+        paths: Vec<String>,
+        open_new_workspace: Option<bool>,
+        force_existing_window: bool,
+    ) -> CliRequest {
+        CliRequest::Open {
+            paths,
+            urls: vec![],
+            diff_paths: vec![],
+            diff_all: false,
+            wsl: None,
+            wait: false,
+            open_new_workspace,
+            force_existing_window,
+            reuse: false,
+            env: None,
+            user_data_dir: None,
+            dev_container: false,
+        }
+    }
+
+    /// Runs the real [`cli::run_cli_response_loop`] on an OS thread against
+    /// the Zed-side `handle_cli_connection` on the GPUI foreground executor,
+    /// using `allow_parking` so the test scheduler tolerates cross-thread
+    /// wakeups.
+    ///
+    /// Returns `(exit_status, prompt_was_shown)`.
+    fn run_cli_with_zed_handler(
+        cx: &mut TestAppContext,
+        app_state: Arc<AppState>,
+        open_request: CliRequest,
+        prompt_response: Option<cli::CliOpenBehavior>,
+    ) -> (i32, bool) {
+        cx.executor().allow_parking();
+
+        let (request_tx, request_rx) = mpsc::unbounded::<CliRequest>();
+        let (response_tx, response_rx) = std::sync::mpsc::channel::<CliResponse>();
+        let response_sink: Box<dyn CliResponseSink> = Box::new(SyncResponseSender(response_tx));
+
+        cx.spawn(|mut cx| async move {
+            handle_cli_connection((request_rx, response_sink), app_state, &mut cx).await;
+        })
+        .detach();
+
+        let prompt_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
+        let prompt_called_for_thread = prompt_called.clone();
+
+        let cli_thread = std::thread::spawn(move || -> anyhow::Result<i32> {
+            request_tx
+                .unbounded_send(open_request)
+                .map_err(|error| anyhow::anyhow!("{error}"))?;
+
+            while let Ok(response) = response_rx.recv() {
+                match response {
+                    CliResponse::Ping => {}
+                    CliResponse::Stdout { .. } | CliResponse::Stderr { .. } => {}
+                    CliResponse::Exit { status } => return Ok(status),
+                    CliResponse::PromptOpenBehavior => {
+                        prompt_called_for_thread.store(true, std::sync::atomic::Ordering::SeqCst);
+                        let behavior =
+                            prompt_response.unwrap_or(cli::CliOpenBehavior::ExistingWindow);
+                        request_tx
+                            .unbounded_send(CliRequest::SetOpenBehavior { behavior })
+                            .map_err(|error| anyhow::anyhow!("{error}"))?;
+                    }
+                }
+            }
+
+            anyhow::bail!("CLI response channel closed without Exit")
+        });
+
+        while !cli_thread.is_finished() {
+            cx.run_until_parked();
+            std::thread::sleep(std::time::Duration::from_millis(1));
+        }
+
+        let exit_status = cli_thread.join().unwrap().expect("CLI loop failed");
+        let prompt_shown = prompt_called.load(std::sync::atomic::Ordering::SeqCst);
+
+        // Flush any remaining async work (e.g. settings file writes).
+        cx.run_until_parked();
+
+        (exit_status, prompt_shown)
+    }
+
+    #[gpui::test]
+    async fn test_e2e_no_flags_no_windows_no_prompt(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), json!({ "file.txt": "content" }))
+            .await;
+
+        assert_eq!(cx.windows().len(), 0);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(vec![path!("/project/file.txt").to_string()], None, false),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when no windows exist"
+        );
+        assert_eq!(cx.windows().len(), 1);
+    }
+
+    #[gpui::test]
+    async fn test_e2e_prompt_user_picks_existing_window(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
+            .await;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
+            .await;
+
+        // Create an existing window so the prompt triggers
+        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state.clone(),
+            make_cli_open_request(vec![path!("/project_b").to_string()], None, false),
+            Some(cli::CliOpenBehavior::ExistingWindow),
+        );
+
+        assert_eq!(status, 0);
+        assert!(prompt_shown, "prompt should be shown");
+        assert_eq!(cx.windows().len(), 1);
+
+        let settings_text = app_state
+            .fs
+            .load(paths::settings_file())
+            .await
+            .unwrap_or_default();
+        assert!(
+            settings_text.contains("existing_window"),
+            "settings should contain 'existing_window', got: {settings_text}"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_e2e_prompt_user_picks_new_window(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
+            .await;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
+            .await;
+
+        // Create an existing window with project_a
+        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state.clone(),
+            make_cli_open_request(vec![path!("/project_b").to_string()], None, false),
+            Some(cli::CliOpenBehavior::NewWindow),
+        );
+
+        assert_eq!(status, 0);
+        assert!(prompt_shown, "prompt should be shown");
+        assert_eq!(cx.windows().len(), 2);
+
+        let settings_text = app_state
+            .fs
+            .load(paths::settings_file())
+            .await
+            .unwrap_or_default();
+        assert!(
+            settings_text.contains("new_window"),
+            "settings should contain 'new_window', got: {settings_text}"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_e2e_setting_already_configured_no_prompt(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), json!({ "file.txt": "content" }))
+            .await;
+
+        // Pre-configure the setting in settings.json
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                paths::config_dir(),
+                json!({
+                    "settings.json": r#"{"cli_default_open_behavior": "existing_window"}"#
+                }),
+            )
+            .await;
+
+        // Create an existing window
+        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(vec![path!("/project/file.txt").to_string()], None, false),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when setting already configured"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_e2e_explicit_existing_flag_no_prompt(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), json!({ "file.txt": "content" }))
+            .await;
+
+        // Create an existing window
+        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(
+                vec![path!("/project/file.txt").to_string()],
+                None,
+                true, // -e flag: force existing window
+            ),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(!prompt_shown, "no prompt should be shown with -e flag");
+        assert_eq!(cx.windows().len(), 1);
+    }
+
+    #[gpui::test]
+    async fn test_e2e_explicit_new_flag_no_prompt(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
+            .await;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
+            .await;
+
+        // Create an existing window
+        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(
+                vec![path!("/project_b/file.txt").to_string()],
+                Some(true), // -n flag: force new window
+                false,
+            ),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(!prompt_shown, "no prompt should be shown with -n flag");
+        assert_eq!(cx.windows().len(), 2);
+    }
+
+    #[gpui::test]
+    async fn test_e2e_paths_in_existing_workspace_no_prompt(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    "src": {
+                        "main.rs": "fn main() {}",
+                    }
+                }),
+            )
+            .await;
+
+        // Open the project directory as a workspace
+        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        assert_eq!(cx.windows().len(), 1);
+
+        // Opening a file inside the already-open workspace should not prompt
+        let (status, prompt_shown) = run_cli_with_zed_handler(
+            cx,
+            app_state,
+            make_cli_open_request(vec![path!("/project/src/main.rs").to_string()], None, false),
+            None,
+        );
+
+        assert_eq!(status, 0);
+        assert!(
+            !prompt_shown,
+            "no prompt should be shown when paths are in an existing workspace"
+        );
+        // File opened in existing window
+        assert_eq!(cx.windows().len(), 1);
+    }
 }

crates/zed/src/zed/windows_only_instance.rs 🔗

@@ -159,6 +159,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             wait: false,
             wsl: args.wsl.clone(),
             open_new_workspace: None,
+            force_existing_window: false,
             reuse: false,
             env: None,
             user_data_dir: args.user_data_dir.clone(),
@@ -186,6 +187,11 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
                             exit_status.lock().replace(status);
                             return Ok(());
                         }
+                        CliResponse::PromptOpenBehavior => {
+                            tx.send(CliRequest::SetOpenBehavior {
+                                behavior: cli::CliOpenBehavior::ExistingWindow,
+                            })?;
+                        }
                     }
                 }
                 Ok(())