From 17e4b492c6743041faddefd49bd56c44eb60dfdf Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Sat, 21 Mar 2026 06:42:55 +0000 Subject: [PATCH 01/46] livekit_client: Screensharing on Niri + NixOS (#52017) Release Notes: - Fixed a weird niche interaction between niri and nixos that broke screensharing --------- Co-authored-by: Jakub Konka --- Cargo.lock | 1 + Cargo.toml | 2 ++ crates/livekit_client/Cargo.toml | 1 + .../livekit_client/src/livekit_client/linux.rs | 18 ++++++++++++++++-- crates/zed/build.rs | 4 +++- nix/build.nix | 9 +++++---- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21893b57542098c6166cc4a822429eb4df902702..823c3d05463a8038f0a426b89b0ac3acaca354c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10004,6 +10004,7 @@ dependencies = [ "tokio", "ui", "util", + "webrtc-sys", "zed-scap", ] diff --git a/Cargo.toml b/Cargo.toml index bc1722718b8ed464b6c78c776699bce890ba223b..b31c088581a65070f348cf55195d0db948b33bb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -779,6 +779,7 @@ wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" web-time = "1.1.0" +webrtc-sys = "0.3.23" wgpu = { git = "https://github.com/zed-industries/wgpu.git", branch = "v29" } windows-core = "0.61" yawc = "0.2.5" @@ -849,6 +850,7 @@ windows-capture = { git = "https://github.com/zed-industries/windows-capture.git calloop = { git = "https://github.com/zed-industries/calloop" } livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c1209aa155cbf4543383774f884a46ae7e53ee2e" } [profile.dev] split-debuginfo = "unpacked" diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index d4a238fc15997d833df65ac1be459763be6ec782..42c13f094c1893260f474c98f650ba83be832ef0 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -49,6 +49,7 @@ livekit.workspace = true [target.'cfg(target_os = "linux")'.dependencies] tokio = { workspace = true, features = ["time"] } +webrtc-sys.workspace = true [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies] scap.workspace = true diff --git a/crates/livekit_client/src/livekit_client/linux.rs b/crates/livekit_client/src/livekit_client/linux.rs index e7bfa7b2ca631636233586cb902b36bac93c9be1..fe7189e901dc8586dbcbdfadbc7a8a0ef5fb1e5d 100644 --- a/crates/livekit_client/src/livekit_client/linux.rs +++ b/crates/livekit_client/src/livekit_client/linux.rs @@ -14,6 +14,7 @@ use std::sync::{ }; static NEXT_WAYLAND_SHARE_ID: AtomicU64 = AtomicU64::new(1); +const PIPEWIRE_TIMEOUT_S: u64 = 30; pub struct WaylandScreenCaptureStream { id: u64, @@ -64,6 +65,17 @@ pub(crate) async fn start_wayland_desktop_capture( }; use libwebrtc::native::yuv_helper::argb_to_nv12; use std::time::Duration; + use webrtc_sys::webrtc::ffi as webrtc_ffi; + + fn webrtc_log_callback(message: String, severity: webrtc_ffi::LoggingSeverity) { + match severity { + webrtc_ffi::LoggingSeverity::Error => log::error!("[webrtc] {}", message.trim()), + _ => log::debug!("[webrtc] {}", message.trim()), + } + } + + let _webrtc_log_sink = webrtc_ffi::new_log_sink(webrtc_log_callback); + log::debug!("Wayland desktop capture: WebRTC internal logging enabled"); let stop_flag = Arc::new(AtomicBool::new(false)); let (mut video_source_tx, mut video_source_rx) = mpsc::channel::(1); @@ -79,7 +91,6 @@ pub(crate) async fn start_wayland_desktop_capture( })?; let permanent_error = Arc::new(AtomicBool::new(false)); - let stop_cb = stop_flag.clone(); let permanent_error_cb = permanent_error.clone(); capturer.start_capture(None, { @@ -136,6 +147,8 @@ pub(crate) async fn start_wayland_desktop_capture( } }); + log::info!("Wayland desktop capture: starting capture loop"); + let stop = stop_flag.clone(); let tokio_task = gpui_tokio::Tokio::spawn(cx, async move { loop { @@ -162,10 +175,11 @@ pub(crate) async fn start_wayland_desktop_capture( let executor = cx.background_executor().clone(); let video_source = video_source_rx .next() - .with_timeout(Duration::from_secs(15), &executor) + .with_timeout(Duration::from_secs(PIPEWIRE_TIMEOUT_S), &executor) .await .map_err(|_| { stop_flag.store(true, Ordering::Relaxed); + log::error!("Wayland desktop capture timed out."); anyhow::anyhow!( "Screen sharing timed out waiting for the first frame. \ Check that xdg-desktop-portal and PipeWire are running, \ diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 9b9ed59bf4de65220f36c1fd53421fdf44c1e529..690444705c9ed52cf96901a7cda81e04eabeeb4e 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -7,12 +7,14 @@ fn main() { // Add rpaths for libraries that webrtc-sys dlopens at runtime. // This is mostly required for hosts with non-standard SO installation // locations such as NixOS. - let dlopened_libs = ["libva", "libva-drm"]; + let dlopened_libs = ["libva", "libva-drm", "egl"]; let mut rpath_dirs = std::collections::BTreeSet::new(); for lib in &dlopened_libs { if let Some(libdir) = pkg_config::get_variable(lib, "libdir").ok() { rpath_dirs.insert(libdir); + } else { + eprintln!("zed build.rs: {lib} not found in pkg-config's path"); } } diff --git a/nix/build.nix b/nix/build.nix index a5ced61bbbfd145c1e3f9fc9909ae69779ba133a..02ed6235e54daa27a9af9b86da79618a21e3cc7e 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -77,7 +77,6 @@ let builtins.elem firstComp topLevelIncludes; craneLib = crane.overrideToolchain rustToolchain; - gpu-lib = if withGLES then libglvnd else vulkan-loader; commonArgs = let zedCargoLock = builtins.fromTOML (builtins.readFile ../crates/zed/Cargo.toml); @@ -179,7 +178,8 @@ let libva libxkbcommon wayland - gpu-lib + libglvnd + vulkan-loader xorg.libX11 xorg.libxcb libdrm @@ -236,7 +236,8 @@ let # about them that's special is that they're manually dlopened at runtime NIX_LDFLAGS = lib.optionalString stdenv'.hostPlatform.isLinux "-rpath ${ lib.makeLibraryPath [ - gpu-lib + libglvnd + vulkan-loader wayland libva ] @@ -245,7 +246,7 @@ let NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds"; }; - # prevent nix from removing the "unused" wayland/gpu-lib rpaths + # prevent nix from removing the "unused" wayland rpaths dontPatchELF = stdenv'.hostPlatform.isLinux; # TODO: try craneLib.cargoNextest separate output From d663dbb6796a57410252bc0c4d4ccfdf38f5f96f Mon Sep 17 00:00:00 2001 From: Tree Xie Date: Sat, 21 Mar 2026 16:20:47 +0800 Subject: [PATCH 02/46] gpui_macos: Fix x86_64 build error in Submenu (#52059) Missed in #52028. Release Notes: - N/A --- crates/gpui_macos/src/platform.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index bce98b2ce996e05ef1be0520d28afa2eb29a7bfa..4d30f82bc0555d38e9bbfbc3d8887806049f8314 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -414,7 +414,7 @@ impl MacPlatform { submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap)); } item.setSubmenu_(submenu); - item.setEnabled_(!disabled); + item.setEnabled_(if *disabled { NO } else { YES }); item.setTitle_(ns_string(name)); item } From abec0efce8de9388506ea92341ded605c1e37e03 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 21 Mar 2026 04:44:34 -0400 Subject: [PATCH 03/46] ci: Run clippy for x86_64-apple-darwin target (#52036) Release Notes: - N/A --------- Co-authored-by: Finn Evers Co-authored-by: Jakub Konka --- .github/workflows/run_tests.yml | 36 +++++++++++++++++++ .../src/tasks/workflows/deploy_collab.rs | 2 +- tooling/xtask/src/tasks/workflows/release.rs | 6 ++-- .../src/tasks/workflows/release_nightly.rs | 2 +- .../xtask/src/tasks/workflows/run_tests.rs | 30 ++++++++++------ tooling/xtask/src/tasks/workflows/steps.rs | 11 ++++-- 6 files changed, 70 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index fd7fecb4eb0309b7cc53c6efe0d2f2ece5f2a228..1906acf9fab7bbaab81b0549328c2e85d732756d 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -263,6 +263,39 @@ jobs: - name: steps::show_sccache_stats run: sccache --show-stats || true timeout-minutes: 60 + clippy_mac_x86_64: + needs: + - orchestrate + if: needs.orchestrate.outputs.run_tests == 'true' + runs-on: namespace-profile-mac-large + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup + - name: steps::install_rustup_target + run: rustup target add x86_64-apple-darwin + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed + - name: steps::clippy + run: ./script/clippy --target x86_64-apple-darwin + - name: steps::show_sccache_stats + run: sccache --show-stats || true + timeout-minutes: 60 run_tests_windows: needs: - orchestrate @@ -731,6 +764,7 @@ jobs: - clippy_windows - clippy_linux - clippy_mac + - clippy_mac_x86_64 - run_tests_windows - run_tests_linux - run_tests_mac @@ -760,6 +794,7 @@ jobs: check_result "clippy_windows" "$RESULT_CLIPPY_WINDOWS" check_result "clippy_linux" "$RESULT_CLIPPY_LINUX" check_result "clippy_mac" "$RESULT_CLIPPY_MAC" + check_result "clippy_mac_x86_64" "$RESULT_CLIPPY_MAC_X86_64" check_result "run_tests_windows" "$RESULT_RUN_TESTS_WINDOWS" check_result "run_tests_linux" "$RESULT_RUN_TESTS_LINUX" check_result "run_tests_mac" "$RESULT_RUN_TESTS_MAC" @@ -779,6 +814,7 @@ jobs: RESULT_CLIPPY_WINDOWS: ${{ needs.clippy_windows.result }} RESULT_CLIPPY_LINUX: ${{ needs.clippy_linux.result }} RESULT_CLIPPY_MAC: ${{ needs.clippy_mac.result }} + RESULT_CLIPPY_MAC_X86_64: ${{ needs.clippy_mac_x86_64.result }} RESULT_RUN_TESTS_WINDOWS: ${{ needs.run_tests_windows.result }} RESULT_RUN_TESTS_LINUX: ${{ needs.run_tests_linux.result }} RESULT_RUN_TESTS_MAC: ${{ needs.run_tests_mac.result }} diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs index a13e5684f615e1c219e131f7308f6e021e89ac9f..c6b620bd5d54c18ddad3796b414e1ba04c90f530 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_collab.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -33,7 +33,7 @@ fn style() -> NamedJob { .add_step(steps::cache_rust_dependencies_namespace()) .map(steps::install_linux_dependencies) .add_step(steps::cargo_fmt()) - .add_step(steps::clippy(Platform::Linux)), + .add_step(steps::clippy(Platform::Linux, None)), )) } diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs index 2963bbec24301b85b345461a6ea532a9ac3421c5..2646005021e052681c0fa16a258a1d0dad725390 100644 --- a/tooling/xtask/src/tasks/workflows/release.rs +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -16,9 +16,9 @@ pub(crate) fn release() -> Workflow { let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac); let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux); let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows); - let macos_clippy = run_tests::clippy(Platform::Mac); - let linux_clippy = run_tests::clippy(Platform::Linux); - let windows_clippy = run_tests::clippy(Platform::Windows); + let macos_clippy = run_tests::clippy(Platform::Mac, None); + let linux_clippy = run_tests::clippy(Platform::Linux, None); + let windows_clippy = run_tests::clippy(Platform::Windows, None); let check_scripts = run_tests::check_scripts(); let create_draft_release = create_draft_release(); diff --git a/tooling/xtask/src/tasks/workflows/release_nightly.rs b/tooling/xtask/src/tasks/workflows/release_nightly.rs index bcae94d08d14a76bef82482c1afd707c5a8a4bda..277db38bee6ebe24482d6c91f6bb8966bed9d1d3 100644 --- a/tooling/xtask/src/tasks/workflows/release_nightly.rs +++ b/tooling/xtask/src/tasks/workflows/release_nightly.rs @@ -18,7 +18,7 @@ pub fn release_nightly() -> Workflow { let style = check_style(); // run only on windows as that's our fastest platform right now. let tests = run_platform_tests_no_filter(Platform::Windows); - let clippy_job = clippy(Platform::Windows); + let clippy_job = clippy(Platform::Windows, None); let nightly = Some(ReleaseChannel::Nightly); let bundle = ReleaseBundleJobs { diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 3ca8e456346dc5b1bbea89ca40993456e4f1354c..a43b36e975957daed86e5eff242eb645599eb185 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -15,7 +15,7 @@ use crate::tasks::workflows::{ }; use super::{ - runners::{self, Platform}, + runners::{self, Arch, Platform}, steps::{self, FluentBuilder, NamedJob, named, release_job}, }; @@ -48,9 +48,10 @@ pub(crate) fn run_tests() -> Workflow { let mut jobs = vec![ orchestrate, check_style(), - should_run_tests.guard(clippy(Platform::Windows)), - should_run_tests.guard(clippy(Platform::Linux)), - should_run_tests.guard(clippy(Platform::Mac)), + should_run_tests.guard(clippy(Platform::Windows, None)), + should_run_tests.guard(clippy(Platform::Linux, None)), + should_run_tests.guard(clippy(Platform::Mac, None)), + should_run_tests.guard(clippy(Platform::Mac, Some(Arch::X86_64))), should_run_tests.guard(run_platform_tests(Platform::Windows)), should_run_tests.guard(run_platform_tests(Platform::Linux)), should_run_tests.guard(run_platform_tests(Platform::Mac)), @@ -489,7 +490,12 @@ fn check_workspace_binaries() -> NamedJob { )) } -pub(crate) fn clippy(platform: Platform) -> NamedJob { +pub(crate) fn clippy(platform: Platform, arch: Option) -> NamedJob { + let target = arch.map(|arch| match (platform, arch) { + (Platform::Mac, Arch::X86_64) => "x86_64-apple-darwin", + (Platform::Mac, Arch::AARCH64) => "aarch64-apple-darwin", + _ => unimplemented!("cross-arch clippy not supported for {platform}/{arch}"), + }); let runner = match platform { Platform::Windows => runners::WINDOWS_DEFAULT, Platform::Linux => runners::LINUX_DEFAULT, @@ -507,16 +513,20 @@ pub(crate) fn clippy(platform: Platform) -> NamedJob { platform == Platform::Linux, steps::install_linux_dependencies, ) + .when_some(target, |this, target| { + this.add_step(steps::install_rustup_target(target)) + }) .add_step(steps::setup_sccache(platform)) - .add_step(steps::clippy(platform)) + .add_step(steps::clippy(platform, target)) .add_step(steps::show_sccache_stats(platform)); if platform == Platform::Linux { job = use_clang(job); } - NamedJob { - name: format!("clippy_{platform}"), - job, - } + let name = match arch { + Some(arch) => format!("clippy_{platform}_{arch}"), + None => format!("clippy_{platform}"), + }; + NamedJob { name, job } } pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 27d3819ec72d9117347284610742a0de96d005f3..2593d5dd0e8a2edc33f558de07af05a30f46ddbe 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -211,13 +211,20 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step { } } -pub fn clippy(platform: Platform) -> Step { +pub fn clippy(platform: Platform, target: Option<&str>) -> Step { match platform { Platform::Windows => named::pwsh("./script/clippy.ps1"), - _ => named::bash("./script/clippy"), + _ => match target { + Some(target) => named::bash(format!("./script/clippy --target {target}")), + None => named::bash("./script/clippy"), + }, } } +pub fn install_rustup_target(target: &str) -> Step { + named::bash(format!("rustup target add {target}")) +} + pub fn cache_rust_dependencies_namespace() -> Step { named::uses("namespacelabs", "nscloud-cache-action", "v1") .add_with(("cache", "rust")) From aabc967b1c3217380346c07e4c6b244664ac46e6 Mon Sep 17 00:00:00 2001 From: Gnome! Date: Sat, 21 Mar 2026 13:05:30 +0000 Subject: [PATCH 04/46] Swap arrayvec crate for heapless to use LenT optimization (#47101) Swaps the `arrayvec` dependency for `heapless`, as the `heapless` library allows changing the type used for the `len` field, which `arrayvec` hard-codes to `usize`. This means that, for all the `ArrayVec`s in Zed, we can save 7 bytes on 64 bit platforms by just storing the length as a `u8`. I have not benchmarked this change locally, as I don't know what benchmarking tools are in this project. As a small bit of context, I wrote the PR to `heapless` to add this `LenT` generic after seeing a PR on the `arrayvec` crate that seems to be dead now. Once I saw some of Zed's blog posts about the `rope` crate and noticed the usage of `arrayvec`, I thought this might be a welcome change. Release Notes: - N/A --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- Cargo.lock | 30 +++-- Cargo.toml | 2 +- crates/agent_ui/Cargo.toml | 2 +- crates/agent_ui/src/conversation_view.rs | 1 - .../src/conversation_view/thread_view.rs | 5 +- crates/edit_prediction/Cargo.toml | 2 +- crates/edit_prediction/src/edit_prediction.rs | 30 +++-- crates/rope/Cargo.toml | 2 +- crates/rope/src/chunk.rs | 18 +-- crates/rope/src/rope.rs | 6 +- crates/sum_tree/Cargo.toml | 2 +- crates/sum_tree/src/cursor.rs | 110 ++++++++++-------- crates/sum_tree/src/sum_tree.rs | 82 ++++++++----- 13 files changed, 176 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 823c3d05463a8038f0a426b89b0ac3acaca354c5..d76e9f1f40cfb1be27799ee3433957639872b324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,7 +334,6 @@ dependencies = [ "agent_settings", "ai_onboarding", "anyhow", - "arrayvec", "assistant_slash_command", "assistant_slash_commands", "assistant_text_thread", @@ -363,6 +362,7 @@ dependencies = [ "git", "gpui", "gpui_tokio", + "heapless", "html_to_markdown", "http_client", "image", @@ -733,9 +733,6 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] [[package]] name = "as-raw-xcb-connection" @@ -5238,7 +5235,6 @@ version = "0.1.0" dependencies = [ "ai_onboarding", "anyhow", - "arrayvec", "brotli", "buffer_diff", "client", @@ -5256,6 +5252,7 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", + "heapless", "indoc", "itertools 0.14.0", "language", @@ -8027,6 +8024,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -8111,6 +8117,16 @@ dependencies = [ "http 0.2.12", ] +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.3.3" @@ -14672,10 +14688,10 @@ dependencies = [ name = "rope" version = "0.1.0" dependencies = [ - "arrayvec", "criterion", "ctor", "gpui", + "heapless", "log", "rand 0.9.2", "rayon", @@ -16736,8 +16752,8 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" name = "sum_tree" version = "0.1.0" dependencies = [ - "arrayvec", "ctor", + "heapless", "log", "proptest", "rand 0.9.2", diff --git a/Cargo.toml b/Cargo.toml index b31c088581a65070f348cf55195d0db948b33bb0..5f736ef3e83625c89425985e179e973bff4ff67c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -480,7 +480,6 @@ aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" anyhow = "1.0.86" -arrayvec = { version = "0.7.4", features = ["serde"] } ashpd = { version = "0.13", default-features = false, features = [ "async-io", "notification", @@ -564,6 +563,7 @@ futures-lite = "1.13" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } globset = "0.4" +heapless = "0.9.2" handlebars = "4.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 8b06417d2f5812ef2e0fb265e6afa4cfeb26eb3f..b60f2a6b136c5e4dbb131603d95623a719ce7134 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -34,7 +34,7 @@ agent_servers.workspace = true agent_settings.workspace = true ai_onboarding.workspace = true anyhow.workspace = true -arrayvec.workspace = true +heapless.workspace = true assistant_text_thread.workspace = true assistant_slash_command.workspace = true assistant_slash_commands.workspace = true diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 2ba60eba96ed08bdc276164e01b7480731edf635..d0ccf2dd0116074cbcdfff3162d585e5b3222cbf 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -14,7 +14,6 @@ use agent_servers::AgentServerDelegate; use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID}; use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Result, anyhow}; -use arrayvec::ArrayVec; use audio::{Audio, Sound}; use buffer_diff::BufferDiff; use client::zed_urls; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index d086cb91f8204f51dbfa5eaaf2d011a2a9656309..ef8a46e5749966ac0a616ecb7fd2f5b7bc5e4f83 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8,6 +8,7 @@ use editor::actions::OpenExcerpts; use crate::StartThreadIn; use crate::message_editor::SharedSessionCapabilities; use gpui::{Corner, List}; +use heapless::Vec as ArrayVec; use language_model::{LanguageModelEffortLevel, Speed}; use settings::update_settings_file; use ui::{ButtonLike, SplitButton, SplitButtonStyle, Tab}; @@ -6367,7 +6368,7 @@ impl ThreadView { focus_handle: &FocusHandle, cx: &Context, ) -> Div { - let mut seen_kinds: ArrayVec = ArrayVec::new(); + let mut seen_kinds: ArrayVec = ArrayVec::new(); div() .p_1() @@ -6417,7 +6418,7 @@ impl ThreadView { return this; } - seen_kinds.push(option.kind); + seen_kinds.push(option.kind).unwrap(); this.key_binding( KeyBinding::for_action_in(action, focus_handle, cx) diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index d2a23b8b4ec3425072ffbe9d042ff89d26a56778..a6a7d8777cbf0d52575489e91a5ae03be2d031ea 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -17,7 +17,7 @@ cli-support = [] [dependencies] ai_onboarding.workspace = true anyhow.workspace = true -arrayvec.workspace = true +heapless.workspace = true brotli.workspace = true buffer_diff.workspace = true client.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index bd1bc7a3303a6f80094fd8261e90a2c5e113803d..421a51b055693617a915e622b617298f5f8a01c5 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use arrayvec::ArrayVec; use client::{Client, EditPredictionUsage, UserStore}; use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody}; use cloud_llm_client::predict_edits_v3::{ @@ -27,6 +26,7 @@ use gpui::{ http_client::{self, AsyncBody, Method}, prelude::*, }; +use heapless::Vec as ArrayVec; use language::language_settings::all_language_settings; use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; @@ -332,7 +332,7 @@ struct ProjectState { registered_buffers: HashMap, current_prediction: Option, next_pending_prediction_id: usize, - pending_predictions: ArrayVec, + pending_predictions: ArrayVec, debug_tx: Option>, last_edit_prediction_refresh: Option<(EntityId, Instant)>, last_jump_prediction_refresh: Option<(EntityId, Instant)>, @@ -2311,18 +2311,24 @@ impl EditPredictionStore { }); if project_state.pending_predictions.len() < max_pending_predictions { - project_state.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - task, - drop_on_cancel, - }); + project_state + .pending_predictions + .push(PendingPrediction { + id: pending_prediction_id, + task, + drop_on_cancel, + }) + .unwrap(); } else { let pending_prediction = project_state.pending_predictions.pop().unwrap(); - project_state.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - task, - drop_on_cancel, - }); + project_state + .pending_predictions + .push(PendingPrediction { + id: pending_prediction_id, + task, + drop_on_cancel, + }) + .unwrap(); project_state.cancel_pending_prediction(pending_prediction, cx); } } diff --git a/crates/rope/Cargo.toml b/crates/rope/Cargo.toml index 9f0fc2be8a021a4cd43679beefb18a3567452dde..a4273c8abff1a4a3bc9b08a72f0c405f3195c75e 100644 --- a/crates/rope/Cargo.toml +++ b/crates/rope/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/rope.rs" [dependencies] -arrayvec = "0.7.1" +heapless.workspace = true log.workspace = true rayon.workspace = true sum_tree.workspace = true diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index 594f8f5c67e2e151c1ba933b59344d8542f381e1..96fc743a33190da9c59c029ace9997b1f9407e63 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -1,5 +1,5 @@ use crate::{OffsetUtf16, Point, PointUtf16, TextSummary, Unclipped}; -use arrayvec::ArrayString; +use heapless::String as ArrayString; use std::{cmp, ops::Range}; use sum_tree::Bias; use unicode_segmentation::GraphemeCursor; @@ -29,7 +29,7 @@ pub struct Chunk { newlines: Bitmap, /// If bit[i] is set, then the character at index i is an ascii tab. tabs: Bitmap, - pub text: ArrayString, + pub text: ArrayString, } #[inline(always)] @@ -47,7 +47,11 @@ impl Chunk { #[inline(always)] pub fn new(text: &str) -> Self { - let text = ArrayString::from(text).unwrap(); + let text = { + let mut buf = ArrayString::new(); + buf.push_str(text).unwrap(); + buf + }; const CHUNK_SIZE: usize = 8; @@ -118,7 +122,7 @@ impl Chunk { self.chars_utf16 |= slice.chars_utf16 << base_ix; self.newlines |= slice.newlines << base_ix; self.tabs |= slice.tabs << base_ix; - self.text.push_str(slice.text); + self.text.push_str(slice.text).unwrap(); } #[inline(always)] @@ -137,9 +141,9 @@ impl Chunk { self.newlines = slice.newlines | (self.newlines << shift); self.tabs = slice.tabs | (self.tabs << shift); - let mut new_text = ArrayString::::new(); - new_text.push_str(slice.text); - new_text.push_str(&self.text); + let mut new_text = ArrayString::::new(); + new_text.push_str(slice.text).unwrap(); + new_text.push_str(&self.text).unwrap(); self.text = new_text; } diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index d7e27e6f11bce82b43cd37d6915bbc172c32d4f7..d6a4db3396c287e51dceddbc2f67fc0a40cf2c5b 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -4,7 +4,7 @@ mod point; mod point_utf16; mod unclipped; -use arrayvec::ArrayVec; +use heapless::Vec as ArrayVec; use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; use std::{ cmp, fmt, io, mem, @@ -184,7 +184,7 @@ impl Rope { return self.push_large(text); } // 16 is enough as otherwise we will hit the branch above - let mut new_chunks = ArrayVec::<_, NUM_CHUNKS>::new(); + let mut new_chunks = ArrayVec::<_, NUM_CHUNKS, u8>::new(); while !text.is_empty() { let mut split_ix = cmp::min(chunk::MAX_BASE, text.len()); @@ -192,7 +192,7 @@ impl Rope { split_ix -= 1; } let (chunk, remainder) = text.split_at(split_ix); - new_chunks.push(chunk); + new_chunks.push(chunk).unwrap(); text = remainder; } self.chunks diff --git a/crates/sum_tree/Cargo.toml b/crates/sum_tree/Cargo.toml index e4cf78181aa43cce4a6692cc3c6c92e03b7bf9ad..8392baa4678b1f635b1c6955fad50acd76576e86 100644 --- a/crates/sum_tree/Cargo.toml +++ b/crates/sum_tree/Cargo.toml @@ -14,7 +14,7 @@ path = "src/sum_tree.rs" doctest = false [dependencies] -arrayvec = "0.7.1" +heapless.workspace = true rayon.workspace = true log.workspace = true ztracing.workspace = true diff --git a/crates/sum_tree/src/cursor.rs b/crates/sum_tree/src/cursor.rs index 494ecbe049993e58357cf5d5606ea8d6624126c4..ec2ded5fcfcdc8400607c64b79ef8712e84e26fc 100644 --- a/crates/sum_tree/src/cursor.rs +++ b/crates/sum_tree/src/cursor.rs @@ -1,5 +1,5 @@ use super::*; -use arrayvec::ArrayVec; +use heapless::Vec as ArrayVec; use std::{cmp::Ordering, mem, sync::Arc}; use ztracing::instrument; @@ -29,7 +29,7 @@ impl fmt::Debug for StackEntry<'_, T, D> { #[derive(Clone)] pub struct Cursor<'a, 'b, T: Item, D> { tree: &'a SumTree, - stack: ArrayVec, 16>, + stack: ArrayVec, 16, u8>, pub position: D, did_seek: bool, at_end: bool, @@ -53,7 +53,7 @@ where pub struct Iter<'a, T: Item> { tree: &'a SumTree, - stack: ArrayVec, 16>, + stack: ArrayVec, 16, u8>, } impl<'a, 'b, T, D> Cursor<'a, 'b, T, D> @@ -231,11 +231,13 @@ where self.position = D::zero(self.cx); self.at_end = self.tree.is_empty(); if !self.tree.is_empty() { - self.stack.push(StackEntry { - tree: self.tree, - index: self.tree.0.child_summaries().len() as u32, - position: D::from_summary(self.tree.summary(), self.cx), - }); + self.stack + .push(StackEntry { + tree: self.tree, + index: self.tree.0.child_summaries().len() as u32, + position: D::from_summary(self.tree.summary(), self.cx), + }) + .unwrap_oob(); } } @@ -267,11 +269,13 @@ where Node::Internal { child_trees, .. } => { if descending { let tree = &child_trees[entry.index()]; - self.stack.push(StackEntry { - position: D::zero(self.cx), - tree, - index: tree.0.child_summaries().len() as u32 - 1, - }) + self.stack + .push(StackEntry { + position: D::zero(self.cx), + tree, + index: tree.0.child_summaries().len() as u32 - 1, + }) + .unwrap_oob(); } } Node::Leaf { .. } => { @@ -297,11 +301,13 @@ where if self.stack.is_empty() { if !self.at_end { - self.stack.push(StackEntry { - tree: self.tree, - index: 0, - position: D::zero(self.cx), - }); + self.stack + .push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }) + .unwrap_oob(); descend = true; } self.did_seek = true; @@ -361,11 +367,13 @@ where if let Some(subtree) = new_subtree { descend = true; - self.stack.push(StackEntry { - tree: subtree, - index: 0, - position: self.position.clone(), - }); + self.stack + .push(StackEntry { + tree: subtree, + index: 0, + position: self.position.clone(), + }) + .unwrap_oob(); } else { descend = false; self.stack.pop(); @@ -467,11 +475,13 @@ where if !self.did_seek { self.did_seek = true; - self.stack.push(StackEntry { - tree: self.tree, - index: 0, - position: D::zero(self.cx), - }); + self.stack + .push(StackEntry { + tree: self.tree, + index: 0, + position: D::zero(self.cx), + }) + .unwrap_oob(); } let mut ascending = false; @@ -503,11 +513,13 @@ where entry.index += 1; entry.position = self.position.clone(); } else { - self.stack.push(StackEntry { - tree: child_tree, - index: 0, - position: self.position.clone(), - }); + self.stack + .push(StackEntry { + tree: child_tree, + index: 0, + position: self.position.clone(), + }) + .unwrap_oob(); ascending = false; continue 'outer; } @@ -578,11 +590,13 @@ impl<'a, T: Item> Iterator for Iter<'a, T> { let mut descend = false; if self.stack.is_empty() { - self.stack.push(StackEntry { - tree: self.tree, - index: 0, - position: (), - }); + self.stack + .push(StackEntry { + tree: self.tree, + index: 0, + position: (), + }) + .unwrap_oob(); descend = true; } @@ -611,11 +625,13 @@ impl<'a, T: Item> Iterator for Iter<'a, T> { if let Some(subtree) = new_subtree { descend = true; - self.stack.push(StackEntry { - tree: subtree, - index: 0, - position: (), - }); + self.stack + .push(StackEntry { + tree: subtree, + index: 0, + position: (), + }) + .unwrap_oob(); } else { descend = false; self.stack.pop(); @@ -748,8 +764,8 @@ trait SeekAggregate<'a, T: Item> { struct SliceSeekAggregate { tree: SumTree, - leaf_items: ArrayVec, - leaf_item_summaries: ArrayVec, + leaf_items: ArrayVec, + leaf_item_summaries: ArrayVec, leaf_summary: T::Summary, } @@ -786,8 +802,8 @@ impl SeekAggregate<'_, T> for SliceSeekAggregate { summary: &T::Summary, cx: ::Context<'_>, ) { - self.leaf_items.push(item.clone()); - self.leaf_item_summaries.push(summary.clone()); + self.leaf_items.push(item.clone()).unwrap_oob(); + self.leaf_item_summaries.push(summary.clone()).unwrap_oob(); Summary::add_summary(&mut self.leaf_summary, summary, cx); } fn push_tree( diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 8ab9b5ccb1fdb3b28b3aa0dd93c7a732a21645cb..251a194d2c7c984a0caa4d0b478ece41332af6be 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -3,8 +3,8 @@ mod cursor; pub mod property_test; mod tree_map; -use arrayvec::ArrayVec; pub use cursor::{Cursor, FilterCursor, Iter}; +use heapless::Vec as ArrayVec; use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator as _}; use std::marker::PhantomData; use std::mem; @@ -17,6 +17,17 @@ pub const TREE_BASE: usize = 2; #[cfg(not(test))] pub const TREE_BASE: usize = 6; +// Helper for when we cannot use ArrayVec::::push().unwrap() as T doesn't impl Debug +trait CapacityResultExt { + fn unwrap_oob(self); +} + +impl CapacityResultExt for Result<(), T> { + fn unwrap_oob(self) { + self.unwrap_or_else(|_| panic!("item should fit into fixed size ArrayVec")) + } +} + /// An item that can be stored in a [`SumTree`] /// /// Must be summarized by a type that implements [`Summary`] @@ -243,8 +254,9 @@ impl SumTree { let mut iter = iter.into_iter().fuse().peekable(); while iter.peek().is_some() { - let items: ArrayVec = iter.by_ref().take(2 * TREE_BASE).collect(); - let item_summaries: ArrayVec = + let items: ArrayVec = + iter.by_ref().take(2 * TREE_BASE).collect(); + let item_summaries: ArrayVec = items.iter().map(|item| item.summary(cx)).collect(); let mut summary = item_summaries[0].clone(); @@ -284,8 +296,8 @@ impl SumTree { }; let child_summary = child_node.summary(); ::add_summary(summary, child_summary, cx); - child_summaries.push(child_summary.clone()); - child_trees.push(child_node); + child_summaries.push(child_summary.clone()).unwrap_oob(); + child_trees.push(child_node.clone()).unwrap_oob(); if child_trees.len() == 2 * TREE_BASE { parent_nodes.extend(current_parent_node.take()); @@ -315,8 +327,8 @@ impl SumTree { .into_par_iter() .chunks(2 * TREE_BASE) .map(|items| { - let items: ArrayVec = items.into_iter().collect(); - let item_summaries: ArrayVec = + let items: ArrayVec = items.into_iter().collect(); + let item_summaries: ArrayVec = items.iter().map(|item| item.summary(cx)).collect(); let mut summary = item_summaries[0].clone(); for item_summary in &item_summaries[1..] { @@ -337,9 +349,9 @@ impl SumTree { .into_par_iter() .chunks(2 * TREE_BASE) .map(|child_nodes| { - let child_trees: ArrayVec, { 2 * TREE_BASE }> = + let child_trees: ArrayVec, { 2 * TREE_BASE }, u8> = child_nodes.into_iter().collect(); - let child_summaries: ArrayVec = child_trees + let child_summaries: ArrayVec = child_trees .iter() .map(|child_tree| child_tree.summary().clone()) .collect(); @@ -798,14 +810,16 @@ impl SumTree { ::add_summary(summary, other_node.summary(), cx); let height_delta = *height - other_node.height(); - let mut summaries_to_append = ArrayVec::::new(); - let mut trees_to_append = ArrayVec::, { 2 * TREE_BASE }>::new(); + let mut summaries_to_append = ArrayVec::::new(); + let mut trees_to_append = ArrayVec::, { 2 * TREE_BASE }, u8>::new(); if height_delta == 0 { summaries_to_append.extend(other_node.child_summaries().iter().cloned()); trees_to_append.extend(other_node.child_trees().iter().cloned()); } else if height_delta == 1 && !other_node.is_underflowing() { - summaries_to_append.push(other_node.summary().clone()); - trees_to_append.push(other) + summaries_to_append + .push(other_node.summary().clone()) + .unwrap_oob(); + trees_to_append.push(other).unwrap_oob(); } else { let tree_to_append = child_trees .last_mut() @@ -815,15 +829,17 @@ impl SumTree { child_trees.last().unwrap().0.summary().clone(); if let Some(split_tree) = tree_to_append { - summaries_to_append.push(split_tree.0.summary().clone()); - trees_to_append.push(split_tree); + summaries_to_append + .push(split_tree.0.summary().clone()) + .unwrap_oob(); + trees_to_append.push(split_tree).unwrap_oob(); } } let child_count = child_trees.len() + trees_to_append.len(); if child_count > 2 * TREE_BASE { - let left_summaries: ArrayVec<_, { 2 * TREE_BASE }>; - let right_summaries: ArrayVec<_, { 2 * TREE_BASE }>; + let left_summaries: ArrayVec<_, { 2 * TREE_BASE }, u8>; + let right_summaries: ArrayVec<_, { 2 * TREE_BASE }, u8>; let left_trees; let right_trees; @@ -868,7 +884,7 @@ impl SumTree { let left_items; let right_items; let left_summaries; - let right_summaries: ArrayVec; + let right_summaries: ArrayVec; let midpoint = (child_count + child_count % 2) / 2; { @@ -933,8 +949,10 @@ impl SumTree { *child_summaries.first_mut().unwrap() = first.summary().clone(); if let Some(tree) = res { if child_trees.len() < 2 * TREE_BASE { - child_summaries.insert(0, tree.summary().clone()); - child_trees.insert(0, tree); + child_summaries + .insert(0, tree.summary().clone()) + .unwrap_oob(); + child_trees.insert(0, tree).unwrap_oob(); None } else { let new_child_summaries = { @@ -1016,7 +1034,7 @@ impl SumTree { .iter() .chain(child_summaries.iter()) .cloned(); - let left_summaries: ArrayVec<_, { 2 * TREE_BASE }> = + let left_summaries: ArrayVec<_, { 2 * TREE_BASE }, u8> = all_summaries.by_ref().take(midpoint).collect(); *child_summaries = all_summaries.collect(); @@ -1065,7 +1083,7 @@ impl SumTree { .iter() .chain(item_summaries.iter()) .cloned(); - let left_summaries: ArrayVec<_, { 2 * TREE_BASE }> = + let left_summaries: ArrayVec<_, { 2 * TREE_BASE }, u8> = all_summaries.by_ref().take(midpoint).collect(); *item_summaries = all_summaries.collect(); @@ -1088,11 +1106,11 @@ impl SumTree { ) -> Self { let height = left.0.height() + 1; let mut child_summaries = ArrayVec::new(); - child_summaries.push(left.0.summary().clone()); - child_summaries.push(right.0.summary().clone()); + child_summaries.push(left.0.summary().clone()).unwrap_oob(); + child_summaries.push(right.0.summary().clone()).unwrap_oob(); let mut child_trees = ArrayVec::new(); - child_trees.push(left); - child_trees.push(right); + child_trees.push(left).unwrap_oob(); + child_trees.push(right).unwrap_oob(); SumTree(Arc::new(Node::Internal { height, summary: sum(child_summaries.iter(), cx), @@ -1252,13 +1270,13 @@ pub enum Node { Internal { height: u8, summary: T::Summary, - child_summaries: ArrayVec, - child_trees: ArrayVec, { 2 * TREE_BASE }>, + child_summaries: ArrayVec, + child_trees: ArrayVec, { 2 * TREE_BASE }, u8>, }, Leaf { summary: T::Summary, - items: ArrayVec, - item_summaries: ArrayVec, + items: ArrayVec, + item_summaries: ArrayVec, }, } @@ -1323,14 +1341,14 @@ impl Node { } } - fn child_trees(&self) -> &ArrayVec, { 2 * TREE_BASE }> { + fn child_trees(&self) -> &ArrayVec, { 2 * TREE_BASE }, u8> { match self { Node::Internal { child_trees, .. } => child_trees, Node::Leaf { .. } => panic!("Leaf nodes have no child trees"), } } - fn items(&self) -> &ArrayVec { + fn items(&self) -> &ArrayVec { match self { Node::Leaf { items, .. } => items, Node::Internal { .. } => panic!("Internal nodes have no items"), From 0f1e8a6e3dd6f8922d5c9d4d0cd9cb9bd1969cfb Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Sat, 21 Mar 2026 19:07:13 +0100 Subject: [PATCH 05/46] agent: Fix summarization model being cleared by unrelated registry events (#52080) ## Context We were seeing lots of pending title generation, which should only happen if we don't have a summarization model. `handle_models_updated_event` unconditionally overwrote the thread's summarization model on every registry event, even with `None`. We should only setting if explicitly changed by the user or we haven't set it yet, and only if we actually have one. It is hard to reproduce this issue.. but I don't think this code was right in the first place anyway. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent/src/agent.rs | 65 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 62a26f5b10672e3d1367d0fb7b085602a049df47..37dee2d97f44f7290ad9a084fccb3fc226f6de52 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -727,7 +727,7 @@ impl NativeAgent { fn handle_models_updated_event( &mut self, _registry: Entity, - _event: &language_model::Event, + event: &language_model::Event, cx: &mut Context, ) { self.models.refresh_list(cx); @@ -744,7 +744,13 @@ impl NativeAgent { thread.set_model(model, cx); cx.notify(); } - thread.set_summarization_model(summarization_model.clone(), cx); + if let Some(model) = summarization_model.clone() { + if thread.summarization_model().is_none() + || matches!(event, language_model::Event::ThreadSummaryModelChanged) + { + thread.set_summarization_model(Some(model), cx); + } + } }); } } @@ -2456,6 +2462,61 @@ mod internal_tests { }); } + #[gpui::test] + async fn test_summarization_model_survives_transient_registry_clearing( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [], cx).await; + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + + let acp_thread = cx + .update(|cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + + thread.read_with(cx, |thread, _| { + assert!( + thread.summarization_model().is_some(), + "session should have a summarization model from the test registry" + ); + }); + + // Simulate what happens during a provider blip: + // update_active_language_model_from_settings calls set_default_model(None) + // when it can't resolve the model, clearing all fallbacks. + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.set_default_model(None, cx); + }); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert!( + thread.summarization_model().is_some(), + "summarization model should survive a transient default model clearing" + ); + }); + } + #[gpui::test] async fn test_loaded_thread_preserves_thinking_enabled(cx: &mut TestAppContext) { init_test(cx); From 4a965d18e6bf5217ebb41bacc0a87d08287e01ad Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Sat, 21 Mar 2026 13:37:34 -0500 Subject: [PATCH 06/46] Feat unbind (#52047) ## Context This PR adds an `Unbind` action, as well as syntax sugar in the keymaps for declaring it ``` { "unbind": { "tab: "editor::AcceptEditPrediction" } } ``` Is equivalent to ``` { "bindings": { "tab: ["zed::Unbind", "editor::AcceptEditPrediction"] } } ``` In the keymap, unbind is always parsed first, so that you can unbind and rebind something in the same block. The semantics of `Unbind` differ from `NoAction` in that `NoAction` is treated _as an action_, `Unbind` is treated as a filter. In practice this means that when resolving bindings, we stop searching when we hit a `NoAction` (because we found a matching binding), but we keep looking when we hit an `Unbind` and filter out keystroke:action pairs that match previous unbindings. In essence `Unbind` is only an action so that it fits cleanly in the existing logic. It is really just a storage of deleted bindings. The plan is to rework the edit predictions key bindings on top of this, as well as use `Unbind` rather than `NoAction` in the keymap UI. Both will be done in follow up PRs. Additionally, in this initial implementation unbound actions are matched by name only. The assumption is that actions with arguments are bound to different keys in general. However, the current syntax allows providing arguments to the unbound actions. Both so that copy-paste works, and so that in the future if this functionality is added, keymaps will not break. ## How to Review - The dispatch logic in GPUI - The parsing logic in `keymap_file.rs` ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added support for unbinding key bindings from the default keymaps. You can now remove default bindings you don't want, without having to re-declare default bindings that use the same keys. For instance, to unbind `tab` from `editor::AcceptEditPrediction`, you can put the following in your `keymap.json` ``` [ { "context": "Editor && edit_prediction", "unbind": { "tab": "editor::AcceptEditPrediction" } } ] ``` --- crates/gpui/src/action.rs | 35 +-- crates/gpui/src/key_dispatch.rs | 114 +++++---- crates/gpui/src/keymap.rs | 150 ++++++++++-- crates/settings/src/keymap_file.rs | 375 +++++++++++++++++++++++++++-- 4 files changed, 580 insertions(+), 94 deletions(-) diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 1ab619ff171dbeab8a0843393874e7184320e0db..a47ebe69f0d825c6e2c347ea2881180cb5b04573 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, Result}; use collections::HashMap; pub use gpui_macros::Action; -pub use no_action::{NoAction, is_no_action}; +pub use no_action::{NoAction, Unbind, is_no_action, is_unbind}; use serde_json::json; use std::{ any::{Any, TypeId}, @@ -290,19 +290,6 @@ impl ActionRegistry { } } - #[cfg(test)] - pub(crate) fn load_action(&mut self) { - self.insert_action(MacroActionData { - name: A::name_for_type(), - type_id: TypeId::of::(), - build: A::build, - json_schema: A::action_json_schema, - deprecated_aliases: A::deprecated_aliases(), - deprecation_message: A::deprecation_message(), - documentation: A::documentation(), - }); - } - fn insert_action(&mut self, action: MacroActionData) { let name = action.name; if self.by_name.contains_key(name) { @@ -432,7 +419,8 @@ pub fn generate_list_of_all_registered_actions() -> impl Iterator bool { - action.as_any().type_id() == (NoAction {}).type_id() + action.as_any().is::() + } + + /// Returns whether or not this action represents an unbind marker. + pub fn is_unbind(action: &dyn gpui::Action) -> bool { + action.as_any().is::() } } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 03c7eaaaae6e16f8a9c3f486b0a7b863e0c86416..fee75d5dad39df5cb6c2df2729811a1d942d2fe8 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -629,66 +629,99 @@ mod tests { use std::{cell::RefCell, ops::Range, rc::Rc}; use crate::{ - Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, - IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription, - TestAppContext, UTF16Selection, Window, + ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, IntoElement, + KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription, TestAppContext, + UTF16Selection, Unbind, Window, }; - #[derive(PartialEq, Eq)] - struct TestAction; + actions!(dispatch_test, [TestAction, SecondaryTestAction]); - impl Action for TestAction { - fn name(&self) -> &'static str { - "test::TestAction" - } - - fn name_for_type() -> &'static str - where - Self: ::std::marker::Sized, - { - "test::TestAction" - } - - fn partial_eq(&self, action: &dyn Action) -> bool { - action.as_any().downcast_ref::() == Some(self) - } - - fn boxed_clone(&self) -> std::boxed::Box { - Box::new(TestAction) - } + fn test_dispatch_tree(bindings: Vec) -> DispatchTree { + let registry = ActionRegistry::default(); - fn build(_value: serde_json::Value) -> anyhow::Result> - where - Self: Sized, - { - Ok(Box::new(TestAction)) - } + DispatchTree::new( + Rc::new(RefCell::new(Keymap::new(bindings))), + Rc::new(registry), + ) } #[test] fn test_keybinding_for_action_bounds() { - let keymap = Keymap::new(vec![KeyBinding::new( + let tree = test_dispatch_tree(vec![KeyBinding::new( "cmd-n", TestAction, Some("ProjectPanel"), )]); - let mut registry = ActionRegistry::default(); + let contexts = vec![ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("ProjectPanel").unwrap(), + ]; + + let keybinding = tree.bindings_for_action(&TestAction, &contexts); + + assert!(keybinding[0].action.partial_eq(&TestAction)) + } + + #[test] + fn test_bindings_for_action_hides_targeted_unbind_in_active_context() { + let tree = test_dispatch_tree(vec![ + KeyBinding::new("tab", TestAction, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("dispatch_test::TestAction".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new( + "tab", + SecondaryTestAction, + Some("Editor && showing_completions"), + ), + ]); + + let contexts = vec![ + KeyContext::parse("Workspace").unwrap(), + KeyContext::parse("Editor showing_completions edit_prediction").unwrap(), + ]; - registry.load_action::(); + let bindings = tree.bindings_for_action(&TestAction, &contexts); + assert!(bindings.is_empty()); - let keymap = Rc::new(RefCell::new(keymap)); + let highest = tree.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!(highest.is_none()); + + let fallback_bindings = tree.bindings_for_action(&SecondaryTestAction, &contexts); + assert_eq!(fallback_bindings.len(), 1); + assert!(fallback_bindings[0].action.partial_eq(&SecondaryTestAction)); + } - let tree = DispatchTree::new(keymap, Rc::new(registry)); + #[test] + fn test_bindings_for_action_keeps_targeted_binding_outside_unbind_context() { + let tree = test_dispatch_tree(vec![ + KeyBinding::new("tab", TestAction, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("dispatch_test::TestAction".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new( + "tab", + SecondaryTestAction, + Some("Editor && showing_completions"), + ), + ]); let contexts = vec![ KeyContext::parse("Workspace").unwrap(), - KeyContext::parse("ProjectPanel").unwrap(), + KeyContext::parse("Editor").unwrap(), ]; - let keybinding = tree.bindings_for_action(&TestAction, &contexts); + let bindings = tree.bindings_for_action(&TestAction, &contexts); + assert_eq!(bindings.len(), 1); + assert!(bindings[0].action.partial_eq(&TestAction)); - assert!(keybinding[0].action.partial_eq(&TestAction)) + let highest = tree.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!(highest.is_some_and(|binding| binding.action.partial_eq(&TestAction))); } #[test] @@ -698,10 +731,7 @@ mod tests { KeyBinding::new("space", TestAction, Some("ContextA")), KeyBinding::new("space f g", TestAction, Some("ContextB")), ]; - let keymap = Rc::new(RefCell::new(Keymap::new(bindings))); - let mut registry = ActionRegistry::default(); - registry.load_action::(); - let mut tree = DispatchTree::new(keymap, Rc::new(registry)); + let mut tree = test_dispatch_tree(bindings); type DispatchPath = SmallVec<[super::DispatchNodeId; 32]>; fn dispatch( diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index d5398ff0447849ca5bfcdbbb5a838af0cbc22836..eaf582a0074d4e8d21d46fdeadf44141182405a6 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -4,7 +4,7 @@ mod context; pub use binding::*; pub use context::*; -use crate::{Action, AsKeystroke, Keystroke, is_no_action}; +use crate::{Action, AsKeystroke, Keystroke, Unbind, is_no_action, is_unbind}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::any::TypeId; @@ -19,7 +19,7 @@ pub struct KeymapVersion(usize); pub struct Keymap { bindings: Vec, binding_indices_by_action_id: HashMap>, - no_action_binding_indices: Vec, + disabled_binding_indices: Vec, version: KeymapVersion, } @@ -27,6 +27,26 @@ pub struct Keymap { #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct BindingIndex(usize); +fn disabled_binding_matches_context(disabled_binding: &KeyBinding, binding: &KeyBinding) -> bool { + match ( + &disabled_binding.context_predicate, + &binding.context_predicate, + ) { + (None, _) => true, + (Some(_), None) => false, + (Some(disabled_predicate), Some(predicate)) => disabled_predicate.is_superset(predicate), + } +} + +fn binding_is_unbound(disabled_binding: &KeyBinding, binding: &KeyBinding) -> bool { + disabled_binding.keystrokes == binding.keystrokes + && disabled_binding + .action() + .as_any() + .downcast_ref::() + .is_some_and(|unbind| unbind.0.as_ref() == binding.action.name()) +} + impl Keymap { /// Create a new keymap with the given bindings. pub fn new(bindings: Vec) -> Self { @@ -44,8 +64,8 @@ impl Keymap { pub fn add_bindings>(&mut self, bindings: T) { for binding in bindings { let action_id = binding.action().as_any().type_id(); - if is_no_action(&*binding.action) { - self.no_action_binding_indices.push(self.bindings.len()); + if is_no_action(&*binding.action) || is_unbind(&*binding.action) { + self.disabled_binding_indices.push(self.bindings.len()); } else { self.binding_indices_by_action_id .entry(action_id) @@ -62,7 +82,7 @@ impl Keymap { pub fn clear(&mut self) { self.bindings.clear(); self.binding_indices_by_action_id.clear(); - self.no_action_binding_indices.clear(); + self.disabled_binding_indices.clear(); self.version.0 += 1; } @@ -90,21 +110,22 @@ impl Keymap { return None; } - for null_ix in &self.no_action_binding_indices { - if null_ix > ix { - let null_binding = &self.bindings[*null_ix]; - if null_binding.keystrokes == binding.keystrokes { - let null_binding_matches = - match (&null_binding.context_predicate, &binding.context_predicate) { - (None, _) => true, - (Some(_), None) => false, - (Some(null_predicate), Some(predicate)) => { - null_predicate.is_superset(predicate) - } - }; - if null_binding_matches { + for disabled_ix in &self.disabled_binding_indices { + if disabled_ix > ix { + let disabled_binding = &self.bindings[*disabled_ix]; + if disabled_binding.keystrokes != binding.keystrokes { + continue; + } + + if is_no_action(&*disabled_binding.action) { + if disabled_binding_matches_context(disabled_binding, binding) { return None; } + } else if is_unbind(&*disabled_binding.action) + && disabled_binding_matches_context(disabled_binding, binding) + && binding_is_unbound(disabled_binding, binding) + { + return None; } } } @@ -170,6 +191,7 @@ impl Keymap { let mut bindings: SmallVec<[_; 1]> = SmallVec::new(); let mut first_binding_index = None; + let mut unbound_bindings: Vec<&KeyBinding> = Vec::new(); for (_, ix, binding) in matched_bindings { if is_no_action(&*binding.action) { @@ -186,6 +208,19 @@ impl Keymap { // For non-user NoAction bindings, continue searching for user overrides continue; } + + if is_unbind(&*binding.action) { + unbound_bindings.push(binding); + continue; + } + + if unbound_bindings + .iter() + .any(|disabled_binding| binding_is_unbound(disabled_binding, binding)) + { + continue; + } + bindings.push(binding.clone()); first_binding_index.get_or_insert(ix); } @@ -197,7 +232,7 @@ impl Keymap { { continue; } - if is_no_action(&*binding.action) { + if is_no_action(&*binding.action) || is_unbind(&*binding.action) { pending.remove(&&binding.keystrokes); continue; } @@ -232,7 +267,10 @@ impl Keymap { match pending { None => None, Some(is_pending) => { - if !is_pending || is_no_action(&*binding.action) { + if !is_pending + || is_no_action(&*binding.action) + || is_unbind(&*binding.action) + { return None; } Some((depth, BindingIndex(ix), binding)) @@ -256,7 +294,7 @@ impl Keymap { mod tests { use super::*; use crate as gpui; - use gpui::NoAction; + use gpui::{NoAction, Unbind}; actions!( test_only, @@ -720,6 +758,76 @@ mod tests { } } + #[test] + fn test_targeted_unbind_ignores_target_context() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor")), + KeyBinding::new("tab", ActionBeta {}, Some("Editor && showing_completions")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor && edit_prediction"), + ), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + let (result, pending) = keymap.bindings_for_input( + &[Keystroke::parse("tab").unwrap()], + &[KeyContext::parse("Editor showing_completions edit_prediction").unwrap()], + ); + + assert!(!pending); + assert_eq!(result.len(), 1); + assert!(result[0].action.partial_eq(&ActionBeta {})); + } + + #[test] + fn test_bindings_for_action_keeps_binding_for_narrower_targeted_unbind() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor && edit_prediction"), + ), + KeyBinding::new("tab", ActionBeta {}, Some("Editor && showing_completions")), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + assert_bindings(&keymap, &ActionAlpha {}, &["tab"]); + assert_bindings(&keymap, &ActionBeta {}, &["tab"]); + + #[track_caller] + fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) { + let actual = keymap + .bindings_for_action(action) + .map(|binding| binding.keystrokes[0].inner().unparse()) + .collect::>(); + assert_eq!(actual, expected, "{:?}", action); + } + } + + #[test] + fn test_bindings_for_action_removes_binding_for_broader_targeted_unbind() { + let bindings = [ + KeyBinding::new("tab", ActionAlpha {}, Some("Editor && edit_prediction")), + KeyBinding::new( + "tab", + Unbind("test_only::ActionAlpha".into()), + Some("Editor"), + ), + ]; + + let mut keymap = Keymap::default(); + keymap.add_bindings(bindings); + + assert!(keymap.bindings_for_action(&ActionAlpha {}).next().is_none()); + } + #[test] fn test_source_precedence_sorting() { // KeybindSource precedence: User (0) > Vim (1) > Base (2) > Default (3) diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 0bc7c45afb6870c772c5963aebcf9807988ac359..79713bdb5a20250a7b98b81bf73408cd63f55c60 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -4,7 +4,7 @@ use fs::Fs; use gpui::{ Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE, KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, KeybindingKeystroke, Keystroke, - NoAction, SharedString, generate_list_of_all_registered_actions, register_action, + NoAction, SharedString, Unbind, generate_list_of_all_registered_actions, register_action, }; use schemars::{JsonSchema, json_schema}; use serde::Deserialize; @@ -73,6 +73,10 @@ pub struct KeymapSection { /// on macOS. See the documentation for more details. #[serde(default)] use_key_equivalents: bool, + /// This keymap section's unbindings, as a JSON object mapping keystrokes to actions. These are + /// parsed before `bindings`, so bindings later in the same section can still take precedence. + #[serde(default)] + unbind: Option>, /// This keymap section's bindings, as a JSON object mapping keystrokes to actions. The /// keystrokes key is a string representing a sequence of keystrokes to type, where the /// keystrokes are separated by whitespace. Each keystroke is a sequence of modifiers (`ctrl`, @@ -135,6 +139,20 @@ impl JsonSchema for KeymapAction { } } +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(transparent)] +pub struct UnbindTargetAction(Value); + +impl JsonSchema for UnbindTargetAction { + fn schema_name() -> Cow<'static, str> { + "UnbindTargetAction".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!(true) + } +} + #[derive(Debug)] #[must_use] pub enum KeymapFileLoadResult { @@ -231,6 +249,7 @@ impl KeymapFile { for KeymapSection { context, use_key_equivalents, + unbind, bindings, unrecognized_fields, } in keymap_file.0.iter() @@ -244,7 +263,7 @@ impl KeymapFile { // Leading space is to separate from the message indicating which section // the error occurred in. errors.push(( - context, + context.clone(), format!(" Parse error in section `context` field: {}", err), )); continue; @@ -263,6 +282,38 @@ impl KeymapFile { .unwrap(); } + if let Some(unbind) = unbind { + for (keystrokes, action) in unbind { + let result = Self::load_unbinding( + keystrokes, + action, + context_predicate.clone(), + *use_key_equivalents, + cx, + ); + match result { + Ok(key_binding) => { + key_bindings.push(key_binding); + } + Err(err) => { + let mut lines = err.lines(); + let mut indented_err = lines.next().unwrap().to_string(); + for line in lines { + indented_err.push_str(" "); + indented_err.push_str(line); + indented_err.push_str("\n"); + } + write!( + section_errors, + "\n\n- In unbind {}, {indented_err}", + MarkdownInlineCode(&format!("\"{}\"", keystrokes)) + ) + .unwrap(); + } + } + } + } + if let Some(bindings) = bindings { for (keystrokes, action) in bindings { let result = Self::load_keybinding( @@ -296,7 +347,7 @@ impl KeymapFile { } if !section_errors.is_empty() { - errors.push((context, section_errors)) + errors.push((context.clone(), section_errors)) } } @@ -332,7 +383,17 @@ impl KeymapFile { use_key_equivalents: bool, cx: &App, ) -> std::result::Result { - let (action, action_input_string) = Self::build_keymap_action(action, cx)?; + Self::load_keybinding_action_value(keystrokes, &action.0, context, use_key_equivalents, cx) + } + + fn load_keybinding_action_value( + keystrokes: &str, + action: &Value, + context: Option>, + use_key_equivalents: bool, + cx: &App, + ) -> std::result::Result { + let (action, action_input_string) = Self::build_keymap_action_value(action, cx)?; let key_binding = match KeyBinding::load( keystrokes, @@ -362,23 +423,70 @@ impl KeymapFile { } } + fn load_unbinding( + keystrokes: &str, + action: &UnbindTargetAction, + context: Option>, + use_key_equivalents: bool, + cx: &App, + ) -> std::result::Result { + let key_binding = Self::load_keybinding_action_value( + keystrokes, + &action.0, + context, + use_key_equivalents, + cx, + )?; + + if key_binding.action().partial_eq(&NoAction) { + return Err("expected action name string or [name, input] array.".to_string()); + } + + if key_binding.action().name() == Unbind::name_for_type() { + return Err(format!( + "can't use {} as an unbind target.", + MarkdownInlineCode(&format!("\"{}\"", Unbind::name_for_type())) + )); + } + + KeyBinding::load( + keystrokes, + Box::new(Unbind(key_binding.action().name().into())), + key_binding.predicate(), + use_key_equivalents, + key_binding.action_input(), + cx.keyboard_mapper().as_ref(), + ) + .map_err(|InvalidKeystrokeError { keystroke }| { + format!( + "invalid keystroke {}. {}", + MarkdownInlineCode(&format!("\"{}\"", &keystroke)), + KEYSTROKE_PARSE_EXPECTED_MESSAGE + ) + }) + } + pub fn parse_action( action: &KeymapAction, ) -> Result)>, String> { - let name_and_input = match &action.0 { + Self::parse_action_value(&action.0) + } + + fn parse_action_value(action: &Value) -> Result)>, String> { + let name_and_input = match action { Value::Array(items) => { if items.len() != 2 { return Err(format!( "expected two-element array of `[name, input]`. \ Instead found {}.", - MarkdownInlineCode(&action.0.to_string()) + MarkdownInlineCode(&action.to_string()) )); } let serde_json::Value::String(ref name) = items[0] else { return Err(format!( "expected two-element array of `[name, input]`, \ but the first element is not a string in {}.", - MarkdownInlineCode(&action.0.to_string()) + MarkdownInlineCode(&action.to_string()) )); }; Some((name, Some(&items[1]))) @@ -389,7 +497,7 @@ impl KeymapFile { return Err(format!( "expected two-element array of `[name, input]`. \ Instead found {}.", - MarkdownInlineCode(&action.0.to_string()) + MarkdownInlineCode(&action.to_string()) )); } }; @@ -400,7 +508,14 @@ impl KeymapFile { action: &KeymapAction, cx: &App, ) -> std::result::Result<(Box, Option), String> { - let (build_result, action_input_string) = match Self::parse_action(action)? { + Self::build_keymap_action_value(&action.0, cx) + } + + fn build_keymap_action_value( + action: &Value, + cx: &App, + ) -> std::result::Result<(Box, Option), String> { + let (build_result, action_input_string) = match Self::parse_action_value(action)? { Some((name, action_input)) if name.as_str() == ActionSequence::name_for_type() => { match action_input { Some(action_input) => ( @@ -583,9 +698,15 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - let mut keymap_action_alternatives = vec![empty_action_name, empty_action_name_with_input]; + let mut keymap_action_alternatives = vec![ + empty_action_name.clone(), + empty_action_name_with_input.clone(), + ]; + let mut unbind_target_action_alternatives = + vec![empty_action_name, empty_action_name_with_input]; let mut empty_schema_action_names = vec![]; + let mut empty_schema_unbind_target_action_names = vec![]; for (name, action_schema) in action_schemas.into_iter() { let deprecation = if name == NoAction.name() { Some("null") @@ -593,6 +714,9 @@ impl KeymapFile { deprecations.get(name).copied() }; + let include_in_unbind_target_schema = + name != NoAction.name() && name != Unbind::name_for_type(); + // Add an alternative for plain action names. let mut plain_action = json_schema!({ "type": "string", @@ -607,7 +731,10 @@ impl KeymapFile { if let Some(description) = &description { add_description(&mut plain_action, description); } - keymap_action_alternatives.push(plain_action); + keymap_action_alternatives.push(plain_action.clone()); + if include_in_unbind_target_schema { + unbind_target_action_alternatives.push(plain_action); + } // Add an alternative for actions with data specified as a [name, data] array. // @@ -633,9 +760,15 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - keymap_action_alternatives.push(action_with_input); + keymap_action_alternatives.push(action_with_input.clone()); + if include_in_unbind_target_schema { + unbind_target_action_alternatives.push(action_with_input); + } } else { empty_schema_action_names.push(name); + if include_in_unbind_target_schema { + empty_schema_unbind_target_action_names.push(name); + } } } @@ -659,20 +792,44 @@ impl KeymapFile { keymap_action_alternatives.push(actions_with_empty_input); } + if !empty_schema_unbind_target_action_names.is_empty() { + let action_names = json_schema!({ "enum": empty_schema_unbind_target_action_names }); + let no_properties_allowed = json_schema!({ + "type": "object", + "additionalProperties": false + }); + let mut actions_with_empty_input = json_schema!({ + "type": "array", + "items": [action_names, no_properties_allowed], + "minItems": 2, + "maxItems": 2 + }); + add_deprecation( + &mut actions_with_empty_input, + "This action does not take input - just the action name string should be used." + .to_string(), + ); + unbind_target_action_alternatives.push(actions_with_empty_input); + } + // Placing null first causes json-language-server to default assuming actions should be // null, so place it last. keymap_action_alternatives.push(json_schema!({ "type": "null" })); - // The `KeymapSection` schema will reference the `KeymapAction` schema by name, so setting - // the definition of `KeymapAction` results in the full action schema being used. generator.definitions_mut().insert( KeymapAction::schema_name().to_string(), json!({ "anyOf": keymap_action_alternatives }), ); + generator.definitions_mut().insert( + UnbindTargetAction::schema_name().to_string(), + json!({ + "anyOf": unbind_target_action_alternatives + }), + ); generator.root_schema_for::().to_value() } @@ -1260,7 +1417,8 @@ impl Action for ActionSequence { #[cfg(test)] mod tests { - use gpui::{DummyKeyboardMapper, KeybindingKeystroke, Keystroke}; + use gpui::{Action, App, DummyKeyboardMapper, KeybindingKeystroke, Keystroke, Unbind}; + use serde_json::Value; use unindent::Unindent; use crate::{ @@ -1268,6 +1426,8 @@ mod tests { keymap_file::{KeybindUpdateOperation, KeybindUpdateTarget}, }; + gpui::actions!(test_keymap_file, [StringAction, InputAction]); + #[test] fn can_deserialize_keymap_with_trailing_comma() { let json = indoc::indoc! {"[ @@ -1283,6 +1443,191 @@ mod tests { KeymapFile::parse(json).unwrap(); } + #[gpui::test] + fn keymap_section_unbinds_are_loaded_before_bindings(cx: &mut App) { + let key_bindings = match KeymapFile::load( + indoc::indoc! {r#" + [ + { + "unbind": { + "ctrl-a": "test_keymap_file::StringAction", + "ctrl-b": ["test_keymap_file::InputAction", {}] + }, + "bindings": { + "ctrl-c": "test_keymap_file::StringAction" + } + } + ] + "#}, + cx, + ) { + crate::keymap_file::KeymapFileLoadResult::Success { key_bindings } => key_bindings, + crate::keymap_file::KeymapFileLoadResult::SomeFailedToLoad { + error_message, .. + } => { + panic!("{error_message}"); + } + crate::keymap_file::KeymapFileLoadResult::JsonParseFailure { error } => { + panic!("JSON parse error: {error}"); + } + }; + + assert_eq!(key_bindings.len(), 3); + assert!( + key_bindings[0] + .action() + .partial_eq(&Unbind("test_keymap_file::StringAction".into())) + ); + assert_eq!(key_bindings[0].action_input(), None); + assert!( + key_bindings[1] + .action() + .partial_eq(&Unbind("test_keymap_file::InputAction".into())) + ); + assert_eq!( + key_bindings[1] + .action_input() + .as_ref() + .map(ToString::to_string), + Some("{}".to_string()) + ); + assert_eq!( + key_bindings[2].action().name(), + "test_keymap_file::StringAction" + ); + } + + #[gpui::test] + fn keymap_unbind_loads_valid_target_action_with_input(cx: &mut App) { + let key_bindings = match KeymapFile::load( + indoc::indoc! {r#" + [ + { + "unbind": { + "ctrl-a": ["test_keymap_file::InputAction", {}] + } + } + ] + "#}, + cx, + ) { + crate::keymap_file::KeymapFileLoadResult::Success { key_bindings } => key_bindings, + other => panic!("expected Success, got {other:?}"), + }; + + assert_eq!(key_bindings.len(), 1); + assert!( + key_bindings[0] + .action() + .partial_eq(&Unbind("test_keymap_file::InputAction".into())) + ); + assert_eq!( + key_bindings[0] + .action_input() + .as_ref() + .map(ToString::to_string), + Some("{}".to_string()) + ); + } + + #[gpui::test] + fn keymap_unbind_rejects_null(cx: &mut App) { + match KeymapFile::load( + indoc::indoc! {r#" + [ + { + "unbind": { + "ctrl-a": null + } + } + ] + "#}, + cx, + ) { + crate::keymap_file::KeymapFileLoadResult::SomeFailedToLoad { + key_bindings, + error_message, + } => { + assert!(key_bindings.is_empty()); + assert!( + error_message + .0 + .contains("expected action name string or [name, input] array.") + ); + } + other => panic!("expected SomeFailedToLoad, got {other:?}"), + } + } + + #[gpui::test] + fn keymap_unbind_rejects_unbind_action(cx: &mut App) { + match KeymapFile::load( + indoc::indoc! {r#" + [ + { + "unbind": { + "ctrl-a": ["zed::Unbind", "test_keymap_file::StringAction"] + } + } + ] + "#}, + cx, + ) { + crate::keymap_file::KeymapFileLoadResult::SomeFailedToLoad { + key_bindings, + error_message, + } => { + assert!(key_bindings.is_empty()); + assert!( + error_message + .0 + .contains("can't use `\"zed::Unbind\"` as an unbind target.") + ); + } + other => panic!("expected SomeFailedToLoad, got {other:?}"), + } + } + + #[test] + fn keymap_schema_for_unbind_excludes_null_and_unbind_action() { + fn schema_allows(schema: &Value, expected: &Value) -> bool { + match schema { + Value::Object(object) => { + if object.get("const") == Some(expected) { + return true; + } + if object.get("type") == Some(&Value::String("null".to_string())) + && expected == &Value::Null + { + return true; + } + object.values().any(|value| schema_allows(value, expected)) + } + Value::Array(items) => items.iter().any(|value| schema_allows(value, expected)), + _ => false, + } + } + + let schema = KeymapFile::generate_json_schema_from_inventory(); + let unbind_schema = schema + .pointer("/$defs/UnbindTargetAction") + .expect("missing UnbindTargetAction schema"); + + assert!(!schema_allows(unbind_schema, &Value::Null)); + assert!(!schema_allows( + unbind_schema, + &Value::String(Unbind::name_for_type().to_string()) + )); + assert!(schema_allows( + unbind_schema, + &Value::String("test_keymap_file::StringAction".to_string()) + )); + assert!(schema_allows( + unbind_schema, + &Value::String("test_keymap_file::InputAction".to_string()) + )); + } + #[track_caller] fn check_keymap_update( input: impl ToString, From e1268577321e7b48596166701b6f061b255b441a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:22:32 -0300 Subject: [PATCH 07/46] sidebar: Add another round of refinements (#52101) - Change the branch button's tooltip to be more accurate given it displays more stuff than only branches - Hide the worktree dropdown menu when in a non-Git repo project - Improve provisioned title truncation - Remove the plus icon from the "view more" item to improve sidebar's overall feel - Remove the always visible "new thread" button but make it visible only when you're in an empty thread state - Add worktree icon in the thread item and tooltip with full path - Space out the worktree name from the branch name in the git picker in the title bar - Swap order of views in the git picker to "worktree | branches | stash" - Improve the "creating worktree" loading indicator --- - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- assets/icons/git_worktree.svg | 7 + assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/default-windows.json | 4 +- crates/agent_ui/src/agent_panel.rs | 65 ++- .../src/conversation_view/thread_view.rs | 2 +- crates/agent_ui/src/threads_archive_view.rs | 3 +- crates/git_ui/src/branch_picker.rs | 26 +- crates/git_ui/src/git_picker.rs | 38 +- crates/icons/src/icons.rs | 1 + .../src/sidebar_recent_projects.rs | 9 +- crates/sidebar/src/sidebar.rs | 382 +++++++++--------- crates/title_bar/src/title_bar.rs | 50 ++- crates/ui/src/components/ai/thread_item.rs | 250 ++++++------ crates/ui/src/components/list/list_item.rs | 29 +- 15 files changed, 410 insertions(+), 464 deletions(-) create mode 100644 assets/icons/git_worktree.svg diff --git a/assets/icons/git_worktree.svg b/assets/icons/git_worktree.svg new file mode 100644 index 0000000000000000000000000000000000000000..25b49bc69f34d8a742451709d4d4a164f29248b6 --- /dev/null +++ b/assets/icons/git_worktree.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 26144db389ef553c73e099926bcf7ff0868ffc52..95c709f86197685cb9fc0b987b43832bd6a279e6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1451,8 +1451,8 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateBranchesTab", - "alt-2": "git_picker::ActivateWorktreesTab", + "alt-1": "git_picker::ActivateWorktreesTab", + "alt-2": "git_picker::ActivateBranchesTab", "alt-3": "git_picker::ActivateStashTab", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index aa455cfb70ca1c6bc627cdc587d8d4980bd71397..a3577422f76d15ca0f7984a6db1259add9d8ded3 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1526,8 +1526,8 @@ { "context": "GitPicker", "bindings": { - "cmd-1": "git_picker::ActivateBranchesTab", - "cmd-2": "git_picker::ActivateWorktreesTab", + "cmd-1": "git_picker::ActivateWorktreesTab", + "cmd-2": "git_picker::ActivateBranchesTab", "cmd-3": "git_picker::ActivateStashTab", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 0316bf08ffdf9df659707845038caf74072b75c4..58774f540b10f7de40b59738aaabb13c67aa553c 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1440,8 +1440,8 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateBranchesTab", - "alt-2": "git_picker::ActivateWorktreesTab", + "alt-1": "git_picker::ActivateWorktreesTab", + "alt-2": "git_picker::ActivateBranchesTab", "alt-3": "git_picker::ActivateStashTab", }, }, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d55bdb1a8af3c68c478227e040d40849fb47369a..ddee8e8d43839b4fea0aa35b9fcfedf3fc6f9673 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -75,8 +75,8 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, - PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, + Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, + KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; use workspace::{ @@ -2302,7 +2302,13 @@ impl AgentPanel { let default = AgentSettings::get_global(cx).new_thread_location; let start_thread_in = match default { NewThreadLocation::LocalProject => StartThreadIn::LocalProject, - NewThreadLocation::NewWorktree => StartThreadIn::NewWorktree, + NewThreadLocation::NewWorktree => { + if self.project_has_git_repository(cx) { + StartThreadIn::NewWorktree + } else { + StartThreadIn::LocalProject + } + } }; if self.start_thread_in != start_thread_in { self.start_thread_in = start_thread_in; @@ -4053,9 +4059,10 @@ impl AgentPanel { .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .child(agent_selector_menu) - .when(has_visible_worktrees, |this| { - this.child(self.render_start_thread_in_selector(cx)) - }), + .when( + has_visible_worktrees && self.project_has_git_repository(cx), + |this| this.child(self.render_start_thread_in_selector(cx)), + ), ) .child( h_flex() @@ -4134,41 +4141,31 @@ impl AgentPanel { match status { WorktreeCreationStatus::Creating => Some( h_flex() + .absolute() + .bottom_12() .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .py(DynamicSpacing::Base02.rems(cx)) - .gap_2() - .bg(cx.theme().colors().surface_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child(SpinnerLabel::new().size(LabelSize::Small)) + .p_2() + .gap_1() + .justify_center() + .bg(cx.theme().colors().editor_background) + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(3), + ) .child( - Label::new("Creating worktree…") + Label::new("Creating Worktree…") .color(Color::Muted) .size(LabelSize::Small), ) .into_any_element(), ), WorktreeCreationStatus::Error(message) => Some( - h_flex() - .w_full() - .px(DynamicSpacing::Base06.rems(cx)) - .py(DynamicSpacing::Base02.rems(cx)) - .gap_2() - .bg(cx.theme().colors().surface_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .child( - Label::new(message.clone()) - .color(Color::Warning) - .size(LabelSize::Small) - .truncate(), - ) + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(message.clone()) .into_any_element(), ), } @@ -4611,7 +4608,6 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) - .children(self.render_worktree_creation_status(cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| { @@ -4668,6 +4664,7 @@ impl Render for AgentPanel { ActiveView::Configuration => parent.children(self.configuration.clone()), } }) + .children(self.render_worktree_creation_status(cx)) .children(self.render_trial_end_upsell(window, cx)); match self.active_view.which_font_size_used() { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index ef8a46e5749966ac0a616ecb7fd2f5b7bc5e4f83..8c8157a834cee5481013246ae4c71f84ae77f04c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1066,7 +1066,7 @@ impl ThreadView { .join(" "); let text = text.lines().next().unwrap_or("").trim(); if !text.is_empty() { - let title: SharedString = util::truncate_and_trailoff(text, 20).into(); + let title: SharedString = util::truncate_and_trailoff(text, 200).into(); thread.update(cx, |thread, cx| { thread.set_provisional_title(title, cx); })?; diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index e95607a5966d072d085e91247dc1c3a9fd580628..ef4e3ab5393b1045b4de15b348c3e01e07c366bc 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -575,7 +575,7 @@ impl ThreadsArchiveView { .when(can_unarchive, |this| { this.child( Button::new("unarchive-thread", "Restore") - .style(ButtonStyle::OutlinedGhost) + .style(ButtonStyle::Filled) .label_size(LabelSize::Small) .when(is_focused, |this| { this.key_binding( @@ -606,6 +606,7 @@ impl ThreadsArchiveView { "delete-thread", IconName::Trash, ) + .style(ButtonStyle::Filled) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip({ diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 329f8e91e9e8a0994b2c3502b7c6c2013f28a936..cfb7b6bd2a2fb6c57f17244e0e57a4a637866418 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -16,10 +16,7 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use ui::{ - Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, - prelude::*, -}; +use ui::{Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; @@ -1084,21 +1081,6 @@ impl PickerDelegate for BranchListDelegate { ) } - fn render_header( - &self, - _window: &mut Window, - _cx: &mut Context>, - ) -> Option { - matches!(self.state, PickerState::List).then(|| { - let label = match self.branch_filter { - BranchFilter::All => "Branches", - BranchFilter::Remote => "Remotes", - }; - - ListHeader::new(label).inset(true).into_any_element() - }) - } - fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { if self.editor_position() == PickerEditorPosition::End { return None; @@ -1193,7 +1175,11 @@ impl PickerDelegate for BranchListDelegate { this.justify_between() .child({ let focus_handle = focus_handle.clone(); - Button::new("filter-remotes", "Filter Remotes") + let filter_label = match self.branch_filter { + BranchFilter::All => "Filter Remote", + BranchFilter::Remote => "Show All", + }; + Button::new("filter-remotes", filter_label) .toggle_state(matches!( self.branch_filter, BranchFilter::Remote diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 6cf82327b43abe6c3784e4ec8ca3d16161edfda7..bf9d122a7ec16b11c56fc45f59ff8c5f85f7fded 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -25,8 +25,8 @@ actions!( #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GitPickerTab { - Branches, Worktrees, + Branches, Stash, } @@ -190,9 +190,9 @@ impl GitPicker { fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Worktrees, - GitPickerTab::Worktrees => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Branches, + GitPickerTab::Worktrees => GitPickerTab::Branches, + GitPickerTab::Branches => GitPickerTab::Stash, + GitPickerTab::Stash => GitPickerTab::Worktrees, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -201,9 +201,9 @@ impl GitPicker { fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Worktrees => GitPickerTab::Branches, - GitPickerTab::Stash => GitPickerTab::Worktrees, + GitPickerTab::Worktrees => GitPickerTab::Stash, + GitPickerTab::Branches => GitPickerTab::Worktrees, + GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -241,9 +241,9 @@ impl GitPicker { "git-picker-tabs", [ ToggleButtonSimple::new( - GitPickerTab::Branches.to_string(), + GitPickerTab::Worktrees.to_string(), cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Branches; + this.tab = GitPickerTab::Worktrees; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -251,16 +251,16 @@ impl GitPicker { ) .tooltip(move |_, cx| { Tooltip::for_action_in( - "Toggle Branch Picker", - &ActivateBranchesTab, - &branches_focus_handle, + "Toggle Worktree Picker", + &ActivateWorktreesTab, + &worktrees_focus_handle, cx, ) }), ToggleButtonSimple::new( - GitPickerTab::Worktrees.to_string(), + GitPickerTab::Branches.to_string(), cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Worktrees; + this.tab = GitPickerTab::Branches; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -268,9 +268,9 @@ impl GitPicker { ) .tooltip(move |_, cx| { Tooltip::for_action_in( - "Toggle Worktree Picker", - &ActivateWorktreesTab, - &worktrees_focus_handle, + "Toggle Branch Picker", + &ActivateBranchesTab, + &branches_focus_handle, cx, ) }), @@ -297,8 +297,8 @@ impl GitPicker { .style(ToggleButtonGroupStyle::Outlined) .auto_width() .selected_index(match self.tab { - GitPickerTab::Branches => 0, - GitPickerTab::Worktrees => 1, + GitPickerTab::Worktrees => 0, + GitPickerTab::Branches => 1, GitPickerTab::Stash => 2, }), ) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d1450abdac49b34f240e375e9a4318d186c1f1da..3ca6b4f84d4f09fe2114d0bd86e1d30e6a30e1d1 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -151,6 +151,7 @@ pub enum IconName { GitCommit, GitGraph, GitMergeConflict, + GitWorktree, Github, Hash, HistoryRerun, diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 5ae8ee8bbf48c50c105251ea2ca08b3a88b05ec4..bef88557b12aa076658799ff0c08518c68b6e729 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -47,6 +47,7 @@ impl SidebarRecentProjects { workspaces: Vec::new(), filtered_workspaces: Vec::new(), selected_index: 0, + has_any_non_local_projects: false, focus_handle: cx.focus_handle(), }; @@ -122,6 +123,7 @@ pub struct SidebarRecentProjectsDelegate { )>, filtered_workspaces: Vec, selected_index: usize, + has_any_non_local_projects: bool, focus_handle: FocusHandle, } @@ -135,6 +137,9 @@ impl SidebarRecentProjectsDelegate { DateTime, )>, ) { + self.has_any_non_local_projects = workspaces + .iter() + .any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local)); self.workspaces = workspaces; } } @@ -383,7 +388,9 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { h_flex() .gap_3() .flex_grow() - .child(Icon::new(icon).color(Color::Muted)) + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) .child(highlighted_match.render(window, cx)), ) .tooltip(Tooltip::text(tooltip_path)) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6c4142df5c3f65919a701a29e9a2f7dbd3dd2216..4df6adaaa4d303402d622b393cb3899257d79f13 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -29,8 +29,7 @@ use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, - prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -110,6 +109,7 @@ struct ThreadEntry { is_title_generating: bool, highlight_positions: Vec, worktree_name: Option, + worktree_full_path: Option, worktree_highlight_positions: Vec, diff_stats: DiffStats, } @@ -127,7 +127,6 @@ enum ListEntry { Thread(ThreadEntry), ViewMore { path_list: PathList, - remaining_count: usize, is_fully_expanded: bool, }, NewThread { @@ -599,6 +598,19 @@ impl Sidebar { let query = self.filter_editor.read(cx).text(cx); + // Re-derive agent_panel_visible from the active workspace so it stays + // correct after workspace switches. + self.agent_panel_visible = active_workspace + .as_ref() + .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); + + // Derive active_thread_is_draft BEFORE focused_thread so we can + // use it as a guard below. + self.active_thread_is_draft = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); + // Derive focused_thread from the active workspace's agent panel. // Only update when the panel gives us a positive signal — if the // panel returns None (e.g. still loading after a thread activation), @@ -612,21 +624,10 @@ impl Sidebar { .active_conversation() .and_then(|cv| cv.read(cx).parent_id(cx)) }); - if panel_focused.is_some() { + if panel_focused.is_some() && !self.active_thread_is_draft { self.focused_thread = panel_focused; } - // Re-derive agent_panel_visible from the active workspace so it stays - // correct after workspace switches. - self.agent_panel_visible = active_workspace - .as_ref() - .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); - - self.active_thread_is_draft = active_workspace - .as_ref() - .and_then(|ws| ws.read(cx).panel::(cx)) - .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); - let previous = mem::take(&mut self.contents); let old_statuses: HashMap = previous @@ -756,6 +757,7 @@ impl Sidebar { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); @@ -842,6 +844,9 @@ impl Sidebar { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: Some(worktree_name.clone()), + worktree_full_path: Some( + worktree_path.display().to_string().into(), + ), worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }); @@ -886,9 +891,7 @@ impl Sidebar { ThreadEntryWorkspace::Closed(_) => false, }; - if thread.is_background && thread.status == AgentThreadStatus::Completed { - notified_threads.insert(session_id.clone()); - } else if thread.status == AgentThreadStatus::Completed + if thread.status == AgentThreadStatus::Completed && !is_thread_workspace_active && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) { @@ -965,6 +968,16 @@ impl Sidebar { entries.push(thread.into()); } } else { + let thread_count = threads.len(); + let is_draft_for_workspace = self.agent_panel_visible + && self.active_thread_is_draft + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace); + + let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace; + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), @@ -979,10 +992,12 @@ impl Sidebar { continue; } - entries.push(ListEntry::NewThread { - path_list: path_list.clone(), - workspace: workspace.clone(), - }); + if show_new_thread_entry { + entries.push(ListEntry::NewThread { + path_list: path_list.clone(), + workspace: workspace.clone(), + }); + } let total = threads.len(); @@ -1027,7 +1042,6 @@ impl Sidebar { if total > DEFAULT_THREADS_SHOWN { entries.push(ListEntry::ViewMore { path_list: path_list.clone(), - remaining_count: total.saturating_sub(visible), is_fully_expanded, }); } @@ -1126,16 +1140,8 @@ impl Sidebar { ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx), ListEntry::ViewMore { path_list, - remaining_count, is_fully_expanded, - } => self.render_view_more( - ix, - path_list, - *remaining_count, - *is_fully_expanded, - is_selected, - cx, - ), + } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx), ListEntry::NewThread { path_list, workspace, @@ -1178,6 +1184,13 @@ impl Sidebar { IconName::ChevronDown }; + let has_new_thread_entry = self + .contents + .entries + .get(ix + 1) + .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. })); + let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx); + let workspace_for_remove = workspace.clone(); let workspace_for_menu = workspace.clone(); @@ -1200,10 +1213,27 @@ impl Sidebar { .into_any_element() }; - ListItem::new(id) - .height(Tab::content_height(cx)) - .group_name(group_name) - .focused(is_selected) + let color = cx.theme().colors(); + let hover_color = color + .element_active + .blend(color.element_background.opacity(0.2)); + + h_flex() + .id(id) + .group(&group_name) + .h(Tab::content_height(cx)) + .w_full() + .px_1p5() + .border_1() + .map(|this| { + if is_selected { + this.border_color(color.border_focused) + } else { + this.border_color(gpui::transparent_black()) + } + }) + .justify_between() + .hover(|s| s.bg(hover_color)) .child( h_flex() .relative() @@ -1214,7 +1244,7 @@ impl Sidebar { h_flex().size_4().flex_none().justify_center().child( Icon::new(disclosure_icon) .size(IconSize::Small) - .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), + .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))), ), ) .child(label) @@ -1244,11 +1274,13 @@ impl Sidebar { ) }), ) - .end_hover_gradient_overlay(true) - .end_slot({ + .child({ + let workspace_for_new_thread = workspace.clone(); + let path_list_for_new_thread = path_list.clone(); + h_flex() .when(self.project_header_menu_ix != Some(ix), |this| { - this.visible_on_hover("list_item") + this.visible_on_hover(group_name) }) .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { cx.stop_propagation(); @@ -1300,6 +1332,30 @@ impl Sidebar { )), ) }) + .when(show_new_thread_button, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-new-thread-{ix}", + )), + IconName::Plus, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener({ + let workspace_for_new_thread = workspace_for_new_thread.clone(); + let path_list_for_new_thread = path_list_for_new_thread.clone(); + move |this, _, window, cx| { + // Uncollapse the group if collapsed so + // the new-thread entry becomes visible. + this.collapsed_groups.remove(&path_list_for_new_thread); + this.selection = None; + this.create_new_thread(&workspace_for_new_thread, window, cx); + } + })), + ) + }) }) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; @@ -1513,7 +1569,7 @@ impl Sidebar { let color = cx.theme().colors(); let background = color .title_bar_background - .blend(color.panel_background.opacity(0.8)); + .blend(color.panel_background.opacity(0.2)); let element = v_flex() .absolute() @@ -2348,17 +2404,21 @@ impl Sidebar { ThreadItem::new(id, title) .icon(thread.icon) + .status(thread.status) .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) .when_some(thread.worktree_name.clone(), |this, name| { - this.worktree(name) + let this = this.worktree(name); + match thread.worktree_full_path.clone() { + Some(path) => this.worktree_full_path(path), + None => this, + } }) .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) - .status(thread.status) - .generating_title(thread.is_title_generating) + .title_generating(thread.is_title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) @@ -2521,7 +2581,6 @@ impl Sidebar { &self, ix: usize, path_list: &PathList, - remaining_count: usize, is_fully_expanded: bool, is_selected: bool, cx: &mut Context, @@ -2529,23 +2588,15 @@ impl Sidebar { let path_list = path_list.clone(); let id = SharedString::from(format!("view-more-{}", ix)); - let icon = if is_fully_expanded { - IconName::ListCollapse - } else { - IconName::Plus - }; - let label: SharedString = if is_fully_expanded { "Collapse".into() - } else if remaining_count > 0 { - format!("View More ({})", remaining_count).into() } else { "View More".into() }; ThreadItem::new(id, label) - .icon(icon) .focused(is_selected) + .icon_visible(false) .title_label_color(Color::Muted) .on_click(cx.listener(move |this, _, _window, cx| { this.selection = None; @@ -2650,9 +2701,9 @@ impl Sidebar { let thread_item = ThreadItem::new(id, label) .icon(IconName::Plus) + .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8))) .selected(is_active) .focused(is_selected) - .title_label_color(Color::Custom(cx.theme().colors().text.opacity(0.85))) .when(!is_active, |this| { this.on_click(cx.listener(move |this, _, window, cx| { this.selection = None; @@ -2927,11 +2978,11 @@ impl Render for Sidebar { let _titlebar_height = ui::utils::platform_title_bar_height(window); let ui_font = theme::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); - let bg = cx - .theme() - .colors() + + let color = cx.theme().colors(); + let bg = color .title_bar_background - .blend(cx.theme().colors().panel_background.opacity(0.8)); + .blend(color.panel_background.opacity(0.32)); let no_open_projects = !self.contents.has_open_projects; let no_search_results = self.contents.entries.is_empty(); @@ -2965,7 +3016,7 @@ impl Render for Sidebar { .w(self.width) .bg(bg) .border_r_1() - .border_color(cx.theme().colors().border) + .border_color(color.border) .map(|this| match &self.view { SidebarView::ThreadList => this .child(self.render_sidebar_header(no_open_projects, window, cx)) @@ -3240,14 +3291,12 @@ mod tests { ) } ListEntry::ViewMore { - remaining_count, - is_fully_expanded, - .. + is_fully_expanded, .. } => { if *is_fully_expanded { format!(" - Collapse{}", selected) } else { - format!(" + View More ({}){}", remaining_count, selected) + format!(" + View More{}", selected) } } ListEntry::NewThread { .. } => { @@ -3345,7 +3394,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread]", " Fix crash in project panel", " Add inline diff view", ] @@ -3377,7 +3425,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1"] + vec!["v [project-a]", " Thread A1"] ); // Add a second workspace @@ -3388,7 +3436,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1",] + vec!["v [project-a]", " Thread A1",] ); // Remove the second workspace @@ -3399,7 +3447,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Thread A1"] + vec!["v [project-a]", " Thread A1"] ); } @@ -3420,13 +3468,12 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread]", " Thread 12", " Thread 11", " Thread 10", " Thread 9", " Thread 8", - " + View More (7)", + " + View More", ] ); } @@ -3445,23 +3492,23 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Initially shows NewThread + 5 threads + View More (12 remaining) + // Initially shows 5 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (12)"))); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Focus and navigate to View More, then confirm to expand by one batch open_and_focus_sidebar(&sidebar, cx); - for _ in 0..8 { + for _ in 0..7 { cx.dispatch_action(SelectNext); } cx.dispatch_action(Confirm); cx.run_until_parked(); - // Now shows NewThread + 10 threads + View More (7 remaining) + // Now shows 10 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 13); // header + NewThread + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (7)"))); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Expand again by one batch sidebar.update_in(cx, |s, _window, cx| { @@ -3471,10 +3518,10 @@ mod tests { }); cx.run_until_parked(); - // Now shows NewThread + 15 threads + View More (2 remaining) + // Now shows 15 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 18); // header + NewThread + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (2)"))); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); // Expand one more time - should show all 17 threads with Collapse button sidebar.update_in(cx, |s, _window, cx| { @@ -3486,7 +3533,7 @@ mod tests { // All 17 threads shown with Collapse button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 20); // header + NewThread + 17 threads + Collapse + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); @@ -3497,10 +3544,10 @@ mod tests { }); cx.run_until_parked(); - // Back to initial state: NewThread + 5 threads + View More (12 remaining) + // Back to initial state: 5 threads + View More let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); // header + NewThread + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More (12)"))); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); } #[gpui::test] @@ -3518,7 +3565,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Collapse @@ -3540,7 +3587,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); } @@ -3570,7 +3617,6 @@ mod tests { has_running_threads: false, waiting_thread_count: 0, }, - // Thread with default (Completed) status, not active ListEntry::Thread(ThreadEntry { agent: Agent::NativeAgent, session_info: acp_thread::AgentSessionInfo { @@ -3590,6 +3636,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3613,6 +3660,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3636,6 +3684,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3659,6 +3708,7 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), @@ -3682,13 +3732,13 @@ mod tests { is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, + worktree_full_path: None, worktree_highlight_positions: Vec::new(), diff_stats: DiffStats::default(), }), // View More entry ListEntry::ViewMore { path_list: expanded_path.clone(), - remaining_count: 42, is_fully_expanded: false, }, // Collapsed project header @@ -3701,6 +3751,7 @@ mod tests { waiting_thread_count: 0, }, ]; + // Select the Running thread (index 2) s.selection = Some(2); }); @@ -3714,7 +3765,7 @@ mod tests { " Error thread * (error)", " Waiting thread (waiting)", " Notified thread * (!)", - " + View More (42)", + " + View More", "> [collapsed-project]", ] ); @@ -3758,7 +3809,7 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Entries: [header, new_thread, thread3, thread2, thread1] + // Entries: [header, thread3, thread2, thread1] // Focusing the sidebar does not set a selection; select_next/select_previous // handle None gracefully by starting from the first or last entry. open_and_focus_sidebar(&sidebar, cx); @@ -3778,9 +3829,6 @@ mod tests { cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); - // At the end, wraps back to first entry cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); @@ -3792,13 +3840,8 @@ mod tests { assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); // Move back up - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - cx.dispatch_action(SelectPrevious); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); @@ -3829,7 +3872,7 @@ mod tests { // SelectLast jumps to the end cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(4)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); // SelectFirst jumps to the beginning cx.dispatch_action(SelectFirst); @@ -3882,7 +3925,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Focus the sidebar and select the header (index 0) @@ -3906,11 +3949,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project] <== selected", - " [+ New Thread]", - " Thread 1", - ] + vec!["v [my-project] <== selected", " Thread 1",] ); } @@ -3926,17 +3965,17 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Should show header + NewThread + 5 threads + "View More (3)" + // Should show header + 5 threads + "View More" let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 8); - assert!(entries.iter().any(|e| e.contains("View More (3)"))); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 7) + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) open_and_focus_sidebar(&sidebar, cx); - for _ in 0..8 { + for _ in 0..7 { cx.dispatch_action(SelectNext); } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(7)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); // Confirm on "View More" to expand cx.dispatch_action(Confirm); @@ -3944,7 +3983,7 @@ mod tests { // All 8 threads should now be visible with a "Collapse" button let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 11); // header + NewThread + 8 threads + Collapse button + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button assert!(!entries.iter().any(|e| e.contains("View More"))); assert!(entries.iter().any(|e| e.contains("Collapse"))); } @@ -3963,7 +4002,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Thread 1"] + vec!["v [my-project]", " Thread 1"] ); // Focus sidebar and manually select the header (index 0). Press left to collapse. @@ -3986,11 +4025,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project] <== selected", - " [+ New Thread]", - " Thread 1", - ] + vec!["v [my-project] <== selected", " Thread 1",] ); // Press right again on already-expanded header moves selection down @@ -4014,16 +4049,11 @@ mod tests { open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Thread 1 <== selected", - ] + vec!["v [my-project]", " Thread 1 <== selected",] ); // Pressing left on a child collapses the parent group and selects it @@ -4044,7 +4074,7 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let sidebar = setup_sidebar(&multi_workspace, cx); - // Even an empty project has the header and a new thread button + // An empty project has the header and a new thread button. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["v [empty-project]", " [+ New Thread]"] @@ -4083,12 +4113,11 @@ mod tests { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Focus sidebar (selection starts at None), navigate down to the thread (index 2) + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) open_and_focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); // Collapse the group, which removes the thread from the list cx.dispatch_action(SelectParent); @@ -4188,15 +4217,10 @@ mod tests { cx.run_until_parked(); let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[2..].sort(); + entries[1..].sort(); assert_eq!( entries, - vec![ - "v [my-project]", - " [+ New Thread]", - " Hello *", - " Hello * (running)", - ] + vec!["v [my-project]", " Hello *", " Hello * (running)",] ); } @@ -4237,7 +4261,7 @@ mod tests { // Thread A is still running; no notification yet. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello * (running)",] + vec!["v [project-a]", " Hello * (running)",] ); // Complete thread A's turn (transition Running → Completed). @@ -4247,7 +4271,7 @@ mod tests { // The completed background thread shows a notification indicator. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello * (!)",] + vec!["v [project-a]", " Hello * (!)",] ); } @@ -4290,7 +4314,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread]", " Fix crash in project panel", " Add inline diff view", " Refactor settings module", @@ -4381,12 +4404,7 @@ mod tests { // Confirm the full list is showing. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Alpha thread", - " Beta thread", - ] + vec!["v [my-project]", " Alpha thread", " Beta thread",] ); // User types a search query to filter down. @@ -4398,16 +4416,14 @@ mod tests { ); // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the NewThread entry that was - // re-inserted when the filter was removed. + // The selection index (1) now points at the first thread entry. cx.dispatch_action(Cancel); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ "v [my-project]", - " [+ New Thread] <== selected", - " Alpha thread", + " Alpha thread <== selected", " Beta thread", ] ); @@ -4463,7 +4479,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project-a]", - " [+ New Thread]", " Fix bug in sidebar", " Add tests for editor", ] @@ -4781,7 +4796,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Historical Thread",] + vec!["v [my-project]", " Historical Thread",] ); // Switch to workspace 1 so we can verify the confirm switches back. @@ -4843,22 +4858,17 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Thread A", - " Thread B", - ] + vec!["v [my-project]", " Thread A", " Thread B",] ); // Keyboard confirm preserves selection. sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(2); + sidebar.selection = Some(1); sidebar.confirm(&Confirm, window, cx); }); assert_eq!( sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(2) + Some(1) ); // Click handlers clear selection to None so no highlight lingers @@ -4901,7 +4911,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"] + vec!["v [my-project]", " Hello *"] ); // Simulate the agent generating a title. The notification chain is: @@ -4923,11 +4933,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " [+ New Thread]", - " Friendly Greeting with AI *" - ] + vec!["v [my-project]", " Friendly Greeting with AI *"] ); } @@ -5179,7 +5185,7 @@ mod tests { // Verify the thread appears in the sidebar. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " [+ New Thread]", " Hello *",] + vec!["v [project-a]", " Hello *",] ); // The "New Thread" button should NOT be in "active/draft" state @@ -5340,11 +5346,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Worktree Thread {rosewood}", - ] + vec!["v [project]", " Worktree Thread {rosewood}",] ); } @@ -5421,10 +5423,8 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [wt-feature-a]", - " [+ New Thread]", " Thread A", "v [wt-feature-b]", - " [+ New Thread]", " Thread B", ] ); @@ -5461,7 +5461,6 @@ mod tests { visible_entries_as_strings(&sidebar, cx), vec![ "v [project]", - " [+ New Thread]", " Thread A {wt-feature-a}", " Thread B {wt-feature-b}", ] @@ -5482,11 +5481,7 @@ mod tests { // under the main repo. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Thread A {wt-feature-a}", - ] + vec!["v [project]", " Thread A {wt-feature-a}",] ); } @@ -5603,11 +5598,7 @@ mod tests { let entries = visible_entries_as_strings(&sidebar, cx); assert_eq!( entries, - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); } @@ -5706,11 +5697,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (running)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (running)",] ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -5718,11 +5705,7 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} * (!)", - ] + vec!["v [project]", " Hello {wt-feature-a} * (!)",] ); } @@ -5790,11 +5773,7 @@ mod tests { // Thread should appear under the main repo with a worktree chip. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " WT Thread {wt-feature-a}" - ], + vec!["v [project]", " WT Thread {wt-feature-a}"], ); // Only 1 workspace should exist. @@ -5806,7 +5785,7 @@ mod tests { // Focus the sidebar and select the worktree thread. open_and_focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(2); // index 0 is header, 1 is NewThread, 2 is the thread + sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); // Confirm to open the worktree thread. @@ -5911,9 +5890,8 @@ mod tests { // The worktree workspace should be absorbed under the main repo. let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 4); + assert_eq!(entries.len(), 3); assert_eq!(entries[0], "v [project]"); - assert_eq!(entries[1], " [+ New Thread]"); assert!(entries.contains(&" Main Thread".to_string())); assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b5fdece055d2c7f80421d361a27a5a93d62e3420..9c12e0ca5a0042d7679f5807bab81efbe0ead1eb 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -908,14 +908,7 @@ impl TitleBar { }; let branch_name = branch_name?; - let button_text = if let Some(worktree_name) = linked_worktree_name { - format!("{}/{}", worktree_name, branch_name) - } else { - branch_name - }; - let settings = TitleBarSettings::get_global(cx); - let effective_repository = Some(repository); Some( @@ -931,21 +924,42 @@ impl TitleBar { )) }) .trigger_with_tooltip( - Button::new("project_branch_trigger", button_text) + ButtonLike::new("project_branch_trigger") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .label_size(LabelSize::Small) - .color(Color::Muted) - .when(settings.show_branch_icon, |branch_button| { - let (icon, icon_color) = icon_info; - branch_button.start_icon( - Icon::new(icon).size(IconSize::Indicator).color(icon_color), - ) - }), + .child( + h_flex() + .gap_0p5() + .when(settings.show_branch_icon, |this| { + let (icon, icon_color) = icon_info; + this.child( + Icon::new(icon).size(IconSize::XSmall).color(icon_color), + ) + }) + .when_some(linked_worktree_name.as_ref(), |this, worktree_name| { + this.child( + Label::new(worktree_name) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("/").size(LabelSize::Small).color( + Color::Custom( + cx.theme().colors().text_muted.opacity(0.4), + ), + ), + ) + }) + .child( + Label::new(branch_name) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), move |_window, cx| { Tooltip::with_meta( - "Recent Branches", + "Git Switcher", Some(&zed_actions::git::Branch), - "Local branches only", + "Worktrees, Branches, and Stashes", cx, ) }, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 02de8512963302ddeb1abce572894caf4dadd616..875f73ed892fcce6a152ca21f5a661d262c02ad8 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -22,25 +22,26 @@ pub enum AgentThreadStatus { pub struct ThreadItem { id: ElementId, icon: IconName, + icon_color: Option, + icon_visible: bool, custom_icon_from_external_svg: Option, title: SharedString, + title_label_color: Option, + title_generating: bool, + highlight_positions: Vec, timestamp: SharedString, notified: bool, status: AgentThreadStatus, - generating_title: bool, selected: bool, focused: bool, hovered: bool, - docked_right: bool, added: Option, removed: Option, worktree: Option, - highlight_positions: Vec, + worktree_full_path: Option, worktree_highlight_positions: Vec, on_click: Option>, on_hover: Box, - title_label_color: Option, - title_label_size: Option, action_slot: Option, tooltip: Option AnyView + 'static>>, } @@ -50,25 +51,26 @@ impl ThreadItem { Self { id: id.into(), icon: IconName::ZedAgent, + icon_color: None, + icon_visible: true, custom_icon_from_external_svg: None, title: title.into(), + title_label_color: None, + title_generating: false, + highlight_positions: Vec::new(), timestamp: "".into(), notified: false, status: AgentThreadStatus::default(), - generating_title: false, selected: false, focused: false, hovered: false, - docked_right: false, added: None, removed: None, worktree: None, - highlight_positions: Vec::new(), + worktree_full_path: None, worktree_highlight_positions: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), - title_label_color: None, - title_label_size: None, action_slot: None, tooltip: None, } @@ -84,6 +86,16 @@ impl ThreadItem { self } + pub fn icon_color(mut self, color: Color) -> Self { + self.icon_color = Some(color); + self + } + + pub fn icon_visible(mut self, visible: bool) -> Self { + self.icon_visible = visible; + self + } + pub fn custom_icon_from_external_svg(mut self, svg: impl Into) -> Self { self.custom_icon_from_external_svg = Some(svg.into()); self @@ -99,8 +111,18 @@ impl ThreadItem { self } - pub fn generating_title(mut self, generating: bool) -> Self { - self.generating_title = generating; + pub fn title_generating(mut self, generating: bool) -> Self { + self.title_generating = generating; + self + } + + pub fn title_label_color(mut self, color: Color) -> Self { + self.title_label_color = Some(color); + self + } + + pub fn highlight_positions(mut self, positions: Vec) -> Self { + self.highlight_positions = positions; self } @@ -124,18 +146,13 @@ impl ThreadItem { self } - pub fn docked_right(mut self, docked_right: bool) -> Self { - self.docked_right = docked_right; - self - } - pub fn worktree(mut self, worktree: impl Into) -> Self { self.worktree = Some(worktree.into()); self } - pub fn highlight_positions(mut self, positions: Vec) -> Self { - self.highlight_positions = positions; + pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { + self.worktree_full_path = Some(worktree_full_path.into()); self } @@ -162,16 +179,6 @@ impl ThreadItem { self } - pub fn title_label_color(mut self, color: Color) -> Self { - self.title_label_color = Some(color); - self - } - - pub fn title_label_size(mut self, size: LabelSize) -> Self { - self.title_label_size = Some(size); - self - } - pub fn action_slot(mut self, element: impl IntoElement) -> Self { self.action_slot = Some(element.into_any_element()); self @@ -186,6 +193,26 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); + let base_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.2)); + + let base_bg = if self.selected { + color.element_active + } else { + base_bg + }; + + let hover_color = color + .element_active + .blend(color.element_background.opacity(0.2)); + + let gradient_overlay = GradientFade::new(base_bg, hover_color, hover_color) + .width(px(64.0)) + .right(px(-10.0)) + .gradient_stop(0.75) + .group_name("thread-item"); + let dot_separator = || { Label::new("•") .size(LabelSize::Small) @@ -194,25 +221,26 @@ impl RenderOnce for ThreadItem { }; let icon_id = format!("icon-{}", self.id); + let icon_visible = self.icon_visible; let icon_container = || { h_flex() .id(icon_id.clone()) .size_4() .flex_none() .justify_center() + .when(!icon_visible, |this| this.invisible()) }; + let icon_color = self.icon_color.unwrap_or(Color::Muted); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { Icon::from_external_svg(custom_svg) - .color(Color::Muted) + .color(icon_color) .size(IconSize::Small) } else { - Icon::new(self.icon) - .color(Color::Muted) - .size(IconSize::Small) + Icon::new(self.icon).color(icon_color).size(IconSize::Small) }; let decoration = |icon: IconDecorationKind, color: Hsla| { - IconDecoration::new(icon, cx.theme().colors().surface_background, cx) + IconDecoration::new(icon, base_bg, cx) .color(color) .position(gpui::Point { x: px(-2.), @@ -264,10 +292,9 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label_size = self.title_label_size.unwrap_or(LabelSize::Default); - let title_label = if self.generating_title { + + let title_label = if self.title_generating { Label::new(title) - .size(title_label_size) .color(Color::Muted) .with_animation( "generating-title", @@ -278,66 +305,38 @@ impl RenderOnce for ThreadItem { ) .into_any_element() } else if highlight_positions.is_empty() { - let label = Label::new(title).size(title_label_size); - let label = if let Some(color) = self.title_label_color { - label.color(color) - } else { - label - }; - label.into_any_element() - } else { - let label = HighlightedLabel::new(title, highlight_positions).size(title_label_size); - let label = if let Some(color) = self.title_label_color { - label.color(color) - } else { - label - }; - label.into_any_element() - }; - - let b_bg = color - .title_bar_background - .blend(color.panel_background.opacity(0.8)); - - let base_bg = if self.selected { - color.element_active + Label::new(title) + .when_some(self.title_label_color, |label, color| label.color(color)) + .into_any_element() } else { - b_bg + HighlightedLabel::new(title, highlight_positions) + .when_some(self.title_label_color, |label, color| label.color(color)) + .into_any_element() }; - let gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(64.0)) - .right(px(-10.0)) - .gradient_stop(0.75) - .group_name("thread-item"); - let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let diff_stat_id = self.id.clone(); let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; v_flex() .id(self.id.clone()) + .cursor_pointer() .group("thread-item") .relative() .overflow_hidden() - .cursor_pointer() .w_full() .py_1() .px_1p5() .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.focused, |s| { - s.when(self.docked_right, |s| s.border_r_2()) - .border_color(color.border_focused) - }) - .hover(|s| s.bg(color.element_hover)) - .active(|s| s.bg(color.element_active)) + .when(self.focused, |s| s.border_color(color.border_focused)) + .hover(|s| s.bg(hover_color)) .on_hover(self.on_hover) .child( h_flex() @@ -358,15 +357,11 @@ impl RenderOnce for ThreadItem { .child(gradient_overlay) .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { - let overlay = GradientFade::new( - base_bg, - color.element_hover, - color.element_active, - ) - .width(px(64.0)) - .right(px(6.)) - .gradient_stop(0.75) - .group_name("thread-item"); + let overlay = GradientFade::new(base_bg, hover_color, hover_color) + .width(px(64.0)) + .right(px(6.)) + .gradient_stop(0.75) + .group_name("thread-item"); this.child( h_flex() @@ -380,57 +375,56 @@ impl RenderOnce for ThreadItem { }) }), ) - .when_some(self.worktree, |this, worktree| { - let worktree_highlight_positions = self.worktree_highlight_positions; - let worktree_label = if worktree_highlight_positions.is_empty() { - Label::new(worktree) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - } else { - HighlightedLabel::new(worktree, worktree_highlight_positions) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - }; + .when(has_worktree || has_diff_stats || has_timestamp, |this| { + let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); + let worktree_label = self.worktree.map(|worktree| { + let positions = self.worktree_highlight_positions; + if positions.is_empty() { + Label::new(worktree) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(worktree, positions) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } + }); this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .child(worktree_label) - .when(has_diff_stats || has_timestamp, |this| { - this.child(dot_separator()) - }) - .when(has_diff_stats, |this| { + .when_some(worktree_label, |this, label| { this.child( - DiffStat::new(diff_stat_id.clone(), added_count, removed_count) - .tooltip("Unreviewed changes"), + h_flex() + .id(format!("{}-worktree", self.id.clone())) + .gap_1() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(label) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Thread Running in a Local Git Worktree", + None, + worktree_full_path.clone(), + cx, + ) + }), ) }) - .when(has_diff_stats && has_timestamp, |this| { + .when(has_worktree && (has_diff_stats || has_timestamp), |this| { this.child(dot_separator()) }) - .when(has_timestamp, |this| { - this.child( - Label::new(timestamp.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - }) - .when(!has_worktree && (has_diff_stats || has_timestamp), |this| { - this.child( - h_flex() - .min_w_0() - .gap_1p5() - .child(icon_container()) // Icon Spacing .when(has_diff_stats, |this| { this.child( DiffStat::new(diff_stat_id, added_count, removed_count) - .tooltip("Unreviewed Changes"), + .tooltip("Unreviewed changes"), ) }) .when(has_diff_stats && has_timestamp, |this| { @@ -583,18 +577,6 @@ impl Component for ThreadItem { ) .into_any_element(), ), - single_example( - "Focused + Docked Right", - container() - .child( - ThreadItem::new("ti-7b", "Focused with right dock border") - .icon(IconName::AiClaude) - .timestamp("1w") - .focused(true) - .docked_right(true), - ) - .into_any_element(), - ), single_example( "Selected + Focused", container() diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 3eb21c3429d428675774d96a9969542536c31a26..693cf3d52e34369d04db445d1ddac765691fb429 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -4,7 +4,7 @@ use component::{Component, ComponentScope, example_group_with_title, single_exam use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px}; use smallvec::SmallVec; -use crate::{Disclosure, GradientFade, prelude::*}; +use crate::{Disclosure, prelude::*}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ListItemSpacing { @@ -31,9 +31,6 @@ pub struct ListItem { /// A slot for content that appears on hover after the children /// It will obscure the `end_slot` when visible. end_hover_slot: Option, - /// When true, renders a gradient fade overlay before the `end_hover_slot` - /// to smoothly truncate overflowing content. - end_hover_gradient_overlay: bool, toggle: Option, inset: bool, on_click: Option>, @@ -65,7 +62,6 @@ impl ListItem { start_slot: None, end_slot: None, end_hover_slot: None, - end_hover_gradient_overlay: false, toggle: None, inset: false, on_click: None, @@ -174,11 +170,6 @@ impl ListItem { self } - pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self { - self.end_hover_gradient_overlay = show; - self - } - pub fn outlined(mut self) -> Self { self.outlined = true; self @@ -232,21 +223,6 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let color = cx.theme().colors(); - - let base_bg = if self.selected { - color.element_active - } else { - color.panel_background - }; - - let end_hover_gradient_overlay = - GradientFade::new(base_bg, color.element_hover, color.element_active) - .width(px(96.0)) - .when_some(self.group_name.clone(), |fade, group| { - fade.group_name(group) - }); - h_flex() .id(self.id) .when_some(self.group_name, |this, group| this.group(group)) @@ -382,9 +358,6 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") - .when(self.end_hover_gradient_overlay, |this| { - this.child(end_hover_gradient_overlay) - }) .child(end_hover_slot), ) }), From e8d2627ef594347afd790b302b59130e0c72f076 Mon Sep 17 00:00:00 2001 From: Kai Kozlov <37962720+kaikozlov@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:32:33 -0500 Subject: [PATCH 08/46] Fix incorrect rainbow bracket matching in Markdown (#52107) ## Context Fixes #52022. Rainbow bracket matching could become incorrect when tree-sitter returned ambiguous bracket pairs for the same opening delimiter. The repair path rebuilt pairs using a shared stack across all bracket query patterns, which let excluded delimiters like Markdown single quotes interfere with parenthesis matching. This change scopes that repair logic to each bracket query pattern so ambiguous matches are rebuilt without mixing unrelated delimiter types. It also adds a regression test for the Markdown repro from the issue. image image ## How to Review Review `crates/language/src/buffer.rs` first, especially the fallback repair path for bogus tree-sitter bracket matches. Then review `crates/editor/src/bracket_colorization.rs`, which adds regression coverage for the issue repro. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed rainbow brackets in Markdown when quotes caused parentheses to match incorrectly --- crates/editor/src/bracket_colorization.rs | 14 ++++ crates/language/src/buffer.rs | 87 +++++++++++++++++------ 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 657f1e1b23d91ca421da6a38fbeaa382a65863db..ad2fc1bd8b9666dfa5e2c4b0367984c6398c98f8 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -392,6 +392,20 @@ where &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth, again" ); + + cx.set_state(indoc! {r#"ˇ('')('') + +((''))('') + +('')((''))"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + "«1('')1»«1('')1»\n\n«1(«2('')2»)1»«1('')1»\n\n«1('')1»«1(«2('')2»)1»\n1 hsla(207.80, 16.20%, 69.19%, 1.00)\n2 hsla(29.00, 54.00%, 65.88%, 1.00)\n", + &bracket_colors_markup(&mut cx), + "Markdown quote pairs should not interfere with parenthesis pairing" + ); } #[gpui::test] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 6724b5b1c2e6b666b7f0295685e40427279a0b30..8a3886a7832fabbd67340f7f6d19b36557aa24a8 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4610,7 +4610,7 @@ impl BufferSnapshot { continue; } - let mut all_brackets: Vec<(BracketMatch, bool)> = Vec::new(); + let mut all_brackets: Vec<(BracketMatch, usize, bool)> = Vec::new(); let mut opens = Vec::new(); let mut color_pairs = Vec::new(); @@ -4636,8 +4636,9 @@ impl BufferSnapshot { let mut open = None; let mut close = None; let syntax_layer_depth = mat.depth; + let pattern_index = mat.pattern_index; let config = configs[mat.grammar_index]; - let pattern = &config.patterns[mat.pattern_index]; + let pattern = &config.patterns[pattern_index]; for capture in mat.captures { if capture.index == config.open_capture_ix { open = Some(capture.node.byte_range()); @@ -4658,7 +4659,7 @@ impl BufferSnapshot { } open_to_close_ranges - .entry((open_range.start, open_range.end)) + .entry((open_range.start, open_range.end, pattern_index)) .or_insert_with(BTreeMap::new) .insert( (close_range.start, close_range.end), @@ -4679,6 +4680,7 @@ impl BufferSnapshot { newline_only: pattern.newline_only, color_index: None, }, + pattern_index, pattern.rainbow_exclude, )); } @@ -4692,22 +4694,43 @@ impl BufferSnapshot { // For each close, we know the expected open_len from tree-sitter matches. // Map each close to its expected open length (for inferring opens) - let close_to_open_len: HashMap<(usize, usize), usize> = all_brackets + let close_to_open_len: HashMap<(usize, usize, usize), usize> = all_brackets .iter() - .map(|(m, _)| ((m.close_range.start, m.close_range.end), m.open_range.len())) + .map(|(bracket_match, pattern_index, _)| { + ( + ( + bracket_match.close_range.start, + bracket_match.close_range.end, + *pattern_index, + ), + bracket_match.open_range.len(), + ) + }) .collect(); // Collect unique opens and closes within this chunk - let mut unique_opens: HashSet<(usize, usize)> = all_brackets + let mut unique_opens: HashSet<(usize, usize, usize)> = all_brackets .iter() - .map(|(m, _)| (m.open_range.start, m.open_range.end)) - .filter(|(start, _)| chunk_range.contains(start)) + .map(|(bracket_match, pattern_index, _)| { + ( + bracket_match.open_range.start, + bracket_match.open_range.end, + *pattern_index, + ) + }) + .filter(|(start, _, _)| chunk_range.contains(start)) .collect(); - let mut unique_closes: Vec<(usize, usize)> = all_brackets + let mut unique_closes: Vec<(usize, usize, usize)> = all_brackets .iter() - .map(|(m, _)| (m.close_range.start, m.close_range.end)) - .filter(|(start, _)| chunk_range.contains(start)) + .map(|(bracket_match, pattern_index, _)| { + ( + bracket_match.close_range.start, + bracket_match.close_range.end, + *pattern_index, + ) + }) + .filter(|(start, _, _)| chunk_range.contains(start)) .collect(); unique_closes.sort(); unique_closes.dedup(); @@ -4716,8 +4739,9 @@ impl BufferSnapshot { let mut unique_opens_vec: Vec<_> = unique_opens.iter().copied().collect(); unique_opens_vec.sort(); - let mut valid_pairs: HashSet<((usize, usize), (usize, usize))> = HashSet::default(); - let mut open_stack: Vec<(usize, usize)> = Vec::new(); + let mut valid_pairs: HashSet<((usize, usize, usize), (usize, usize, usize))> = + HashSet::default(); + let mut open_stacks: HashMap> = HashMap::default(); let mut open_idx = 0; for close in &unique_closes { @@ -4725,36 +4749,53 @@ impl BufferSnapshot { while open_idx < unique_opens_vec.len() && unique_opens_vec[open_idx].0 < close.0 { - open_stack.push(unique_opens_vec[open_idx]); + let (start, end, pattern_index) = unique_opens_vec[open_idx]; + open_stacks + .entry(pattern_index) + .or_default() + .push((start, end)); open_idx += 1; } // Try to match with most recent open - if let Some(open) = open_stack.pop() { - valid_pairs.insert((open, *close)); + let (close_start, close_end, pattern_index) = *close; + if let Some(open) = open_stacks + .get_mut(&pattern_index) + .and_then(|open_stack| open_stack.pop()) + { + valid_pairs.insert(((open.0, open.1, pattern_index), *close)); } else if let Some(&open_len) = close_to_open_len.get(close) { // No open on stack - infer one based on expected open_len - if close.0 >= open_len { - let inferred = (close.0 - open_len, close.0); + if close_start >= open_len { + let inferred = (close_start - open_len, close_start, pattern_index); unique_opens.insert(inferred); valid_pairs.insert((inferred, *close)); all_brackets.push(( BracketMatch { open_range: inferred.0..inferred.1, - close_range: close.0..close.1, + close_range: close_start..close_end, newline_only: false, syntax_layer_depth: 0, color_index: None, }, + pattern_index, false, )); } } } - all_brackets.retain(|(m, _)| { - let open = (m.open_range.start, m.open_range.end); - let close = (m.close_range.start, m.close_range.end); + all_brackets.retain(|(bracket_match, pattern_index, _)| { + let open = ( + bracket_match.open_range.start, + bracket_match.open_range.end, + *pattern_index, + ); + let close = ( + bracket_match.close_range.start, + bracket_match.close_range.end, + *pattern_index, + ); valid_pairs.contains(&(open, close)) }); } @@ -4762,7 +4803,7 @@ impl BufferSnapshot { let mut all_brackets = all_brackets .into_iter() .enumerate() - .map(|(index, (bracket_match, rainbow_exclude))| { + .map(|(index, (bracket_match, _, rainbow_exclude))| { // Certain languages have "brackets" that are not brackets, e.g. tags. and such // bracket will match the entire tag with all text inside. // For now, avoid highlighting any pair that has more than single char in each bracket. From 87cf32a6245f4a6b99805e28f0845d0e2e06b519 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Sun, 22 Mar 2026 04:15:35 +0000 Subject: [PATCH 09/46] agent: Set message editor language to markdown (#52113) --- crates/agent_ui/src/message_editor.rs | 37 +++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index f8329301493728a51a71bba4fe455168265a3a41..646058fe488dbdd14b78e466cf53734e81a7712c 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -14,7 +14,6 @@ use acp_thread::MentionUri; use agent::ThreadStore; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; -use collections::HashSet; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, @@ -25,7 +24,7 @@ use gpui::{ AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; -use language::{Buffer, Language, language_settings::InlayHintKind}; +use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; use project::AgentId; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; @@ -172,16 +171,18 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) -> Self { - let language = Language::new( - language::LanguageConfig { - completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), - ..Default::default() - }, - None, - ); + let language_registry = project + .upgrade() + .map(|project| project.read(cx).languages().clone()); let editor = cx.new(|cx| { - let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); + let buffer = cx.new(|cx| { + let buffer = Buffer::local("", cx); + if let Some(language_registry) = language_registry.as_ref() { + buffer.set_language_registry(language_registry.clone()); + } + buffer + }); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let mut editor = Editor::new(mode, buffer, None, window, cx); @@ -287,6 +288,22 @@ impl MessageEditor { } })); + if let Some(language_registry) = language_registry { + let editor = editor.clone(); + cx.spawn(async move |_, cx| { + let markdown = language_registry.language_for_name("Markdown").await?; + editor.update(cx, |editor, cx| { + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(markdown), cx); + }); + } + }); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + Self { editor, mention_set, From 42e7811d4536ea284078d5461bbd45fd0b589dd8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:02:24 -0300 Subject: [PATCH 10/46] sidebar: Fix highlighting "new thread" element after cmd-n (#52105) --- crates/sidebar/src/sidebar.rs | 227 ++++++++++++++++++++++++++++++++-- 1 file changed, 216 insertions(+), 11 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4df6adaaa4d303402d622b393cb3899257d79f13..9761c9f0ad835cf4cc103700c5f70b715f1b9427 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -132,6 +132,7 @@ enum ListEntry { NewThread { path_list: PathList, workspace: Entity, + is_active_draft: bool, }, } @@ -427,8 +428,15 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { AgentPanelEvent::ActiveViewChanged => { + let is_new_draft = agent_panel + .read(cx) + .active_conversation() + .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none()); + if is_new_draft { + this.focused_thread = None; + } this.observe_draft_editor(cx); this.update_entries(cx); } @@ -695,6 +703,10 @@ impl Sidebar { .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); + let active_ws_index = active_workspace + .as_ref() + .and_then(|active| workspaces.iter().position(|ws| ws == active)); + for (ws_index, workspace) in workspaces.iter().enumerate() { if absorbed.contains_key(&ws_index) { continue; @@ -972,9 +984,12 @@ impl Sidebar { let is_draft_for_workspace = self.agent_panel_visible && self.active_thread_is_draft && self.focused_thread.is_none() - && active_workspace - .as_ref() - .is_some_and(|active| active == workspace); + && active_ws_index.is_some_and(|active_idx| { + active_idx == ws_index + || absorbed + .get(&active_idx) + .is_some_and(|(main_idx, _)| *main_idx == ws_index) + }); let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace; @@ -996,6 +1011,7 @@ impl Sidebar { entries.push(ListEntry::NewThread { path_list: path_list.clone(), workspace: workspace.clone(), + is_active_draft: is_draft_for_workspace, }); } @@ -1145,7 +1161,10 @@ impl Sidebar { ListEntry::NewThread { path_list, workspace, - } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), + is_active_draft, + } => { + self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx) + } }; if is_group_header_after_first { @@ -2679,15 +2698,11 @@ impl Sidebar { ix: usize, _path_list: &PathList, workspace: &Entity, + is_active_draft: bool, is_selected: bool, cx: &mut Context, ) -> AnyElement { - let is_active = self.agent_panel_visible - && self.active_thread_is_draft - && self - .multi_workspace - .upgrade() - .map_or(false, |mw| mw.read(cx).workspace() == workspace); + let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft; let label: SharedString = if is_active { self.active_draft_text(cx) @@ -5248,6 +5263,196 @@ mod tests { }); } + #[gpui::test] + async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { + // When the user presses Cmd-N (NewThread action) while viewing a + // non-empty thread, the sidebar should show the "New Thread" entry. + // This exercises the same code path as the workspace action handler + // (which bypasses the sidebar's create_new_thread method). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create a non-empty thread (has messages). + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate cmd-n + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted New Thread entry" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); + } + + #[gpui::test] + async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { + // When the active workspace is an absorbed git worktree, cmd-n + // should still show the "New Thread" entry under the main repo's + // header and highlight it as active. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = + project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(main_project.clone(), window, cx) + }); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch to the worktree workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Create a non-empty thread in the worktree workspace. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&worktree_panel, connection, cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_path_list, cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} *"] + ); + + // Simulate Cmd-N in the worktree workspace. + worktree_panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + worktree_workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} *" + ], + "After Cmd-N in an absorbed worktree, the sidebar should show \ + a highlighted New Thread entry under the main repo header" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); + } + async fn init_test_project_with_git( worktree_path: &str, cx: &mut TestAppContext, From fb1a98cfeffd1413661416ac35f84ea5d3eca7c4 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 23 Mar 2026 01:54:26 +0000 Subject: [PATCH 11/46] multi_workspace: Add actions to cycle workspace (#52156) --- crates/workspace/src/multi_workspace.rs | 29 +++++++++++++++++++++++++ crates/workspace/src/workspace.rs | 3 ++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 2644ad4c620a57866a910f091954e24c8e4eedb8..c3ec2e1c61e1b038f91a57dddac0b7a7b89b337e 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -30,6 +30,10 @@ actions!( CloseWorkspaceSidebar, /// Moves focus to or from the workspace sidebar without closing it. FocusWorkspaceSidebar, + /// Switches to the next workspace. + NextWorkspace, + /// Switches to the previous workspace. + PreviousWorkspace, ] ); @@ -405,6 +409,29 @@ impl MultiWorkspace { cx.notify(); } + fn cycle_workspace(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { + let count = self.workspaces.len() as isize; + if count <= 1 { + return; + } + let current = self.active_workspace_index as isize; + let next = ((current + delta).rem_euclid(count)) as usize; + self.activate_index(next, window, cx); + } + + fn next_workspace(&mut self, _: &NextWorkspace, window: &mut Window, cx: &mut Context) { + self.cycle_workspace(1, window, cx); + } + + fn previous_workspace( + &mut self, + _: &PreviousWorkspace, + window: &mut Window, + cx: &mut Context, + ) { + self.cycle_workspace(-1, window, cx); + } + fn serialize(&mut self, cx: &mut App) { let window_id = self.window_id; let state = crate::persistence::model::MultiWorkspaceState { @@ -760,6 +787,8 @@ impl Render for MultiWorkspace { this.focus_sidebar(window, cx); }, )) + .on_action(cx.listener(Self::next_workspace)) + .on_action(cx.listener(Self::previous_workspace)) }) .when( self.sidebar_open() && self.multi_workspace_enabled(cx), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index bd79918eb435dd05b5e4be4459a0e2e6972182ab..0acc15697008d427efbe0371040a88945b8694c1 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -28,7 +28,8 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, - MultiWorkspaceEvent, Sidebar, SidebarHandle, ToggleWorkspaceSidebar, + MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarHandle, + ToggleWorkspaceSidebar, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; From 8b822f9e101c4abb99e4b141301112ae78a3b91f Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Sun, 22 Mar 2026 23:20:55 -0500 Subject: [PATCH 12/46] Fix regression preventing new predictions from being previewed in subtle mode (#51887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Fixes some issues with https://github.com/zed-industries/zed/pull/51842 Namely that the tests were scattered and not well organized (this PR also makes them more thorough), and a regression where holding the modifiers for the accept prediction keybind would not cause an incoming prediction to be immediately previewed. ## How to Review ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - (Preview v0.229.x only) Fixed a regression where holding the modifiers for the accept edit prediction keybind would not immediately preview predictions as they arrived --- assets/keymaps/default-linux.json | 7 +- assets/keymaps/default-macos.json | 7 +- assets/keymaps/default-windows.json | 8 +- assets/keymaps/vim.json | 2 +- crates/editor/src/edit_prediction_tests.rs | 888 ++++++++++++--------- crates/editor/src/editor.rs | 57 +- 6 files changed, 587 insertions(+), 382 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 95c709f86197685cb9fc0b987b43832bd6a279e6..e4183965fa0b798d526ad6d59d0ce936269cab51 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -785,11 +785,16 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "tab": "editor::AcceptEditPrediction", "alt-k": "editor::AcceptNextWordEditPrediction", "alt-j": "editor::AcceptNextLineEditPrediction", }, }, + { + "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "bindings": { + "tab": "editor::AcceptEditPrediction", + }, + }, { "context": "Editor && showing_code_actions", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a3577422f76d15ca0f7984a6db1259add9d8ded3..27901157e75813109e2b13fb44d6ffe71a04a0f5 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -847,11 +847,16 @@ "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "tab": "editor::AcceptEditPrediction", "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", }, }, + { + "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "bindings": { + "tab": "editor::AcceptEditPrediction", + }, + }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 58774f540b10f7de40b59738aaabb13c67aa553c..8a071c9043a88868d4b91bdde3791bdd118e7a84 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -779,11 +779,17 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "tab": "editor::AcceptEditPrediction", "alt-k": "editor::AcceptNextWordEditPrediction", "alt-j": "editor::AcceptNextLineEditPrediction", }, }, + { + "context": "Editor && edit_prediction && edit_prediction_mode == eager", + "use_key_equivalents": true, + "bindings": { + "tab": "editor::AcceptEditPrediction", + }, + }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 72fd7b253b49306563e8ab355b71e6b967f5bd28..6d1a0cf278d5eb7598ed92e91b7d4ffad90d9c05 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1060,7 +1060,7 @@ }, }, { - "context": "Editor && edit_prediction", + "context": "Editor && edit_prediction && edit_prediction_mode == eager", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 40d915b841c1cf4a53025f7dce654dd129cb8de5..684213e481762d7fb09a0bd6d8b7a0b9fc6d4a36 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -1,13 +1,17 @@ use edit_prediction_types::{ EditPredictionDelegate, EditPredictionIconSet, PredictedCursorPosition, }; -use gpui::{Entity, KeyBinding, Modifiers, prelude::*}; +use gpui::{ + Entity, KeyBinding, KeybindingKeystroke, Keystroke, Modifiers, NoAction, Task, prelude::*, +}; use indoc::indoc; -use language::Buffer; use language::EditPredictionsMode; -use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; +use language::{Buffer, CodeLabel}; +use multi_buffer::{Anchor, ExcerptId, MultiBufferSnapshot, ToPoint}; +use project::{Completion, CompletionResponse, CompletionSource}; use std::{ ops::Range, + rc::Rc, sync::{ Arc, atomic::{self, AtomicUsize}, @@ -17,8 +21,9 @@ use text::{Point, ToOffset}; use ui::prelude::*; use crate::{ - AcceptEditPrediction, EditPrediction, EditPredictionKeybindAction, - EditPredictionKeybindSurface, MenuEditPredictionsPolicy, + AcceptEditPrediction, CompletionContext, CompletionProvider, EditPrediction, + EditPredictionKeybindAction, EditPredictionKeybindSurface, MenuEditPredictionsPolicy, + ShowCompletions, editor_tests::{init_test, update_test_language_settings}, test::editor_test_context::EditorTestContext, }; @@ -482,57 +487,10 @@ async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestA }); } -fn load_default_keymap(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - cx.bind_keys( - settings::KeymapFile::load_asset_allow_partial_failure( - settings::DEFAULT_KEYMAP_PATH, - cx, - ) - .expect("failed to load default keymap"), - ); - }); -} - #[gpui::test] -async fn test_tab_is_preferred_accept_binding_over_alt_tab(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); - - propose_edits(&provider, vec![(8..8, "42")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::Inline, - window, - cx, - ); - let keystroke = keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); - assert!( - !keystroke.modifiers().modified(), - "preferred accept binding should be unmodified (tab), got modifiers: {:?}", - keystroke.modifiers() - ); - assert_eq!( - keystroke.key(), - "tab", - "preferred accept binding should be tab" - ); - }); -} - -#[gpui::test] -async fn test_subtle_in_code_indicator_prefers_preview_binding(cx: &mut gpui::TestAppContext) { +async fn test_edit_prediction_preview_activates_when_prediction_arrives_with_modifier_held( + cx: &mut gpui::TestAppContext, +) { init_test(cx, |_| {}); load_default_keymap(cx); update_test_language_settings(cx, &|settings| { @@ -544,227 +502,324 @@ async fn test_subtle_in_code_indicator_prefers_preview_binding(cx: &mut gpui::Te assign_editor_completion_provider(provider.clone(), &mut cx); cx.set_state("let x = ˇ;"); - propose_edits(&provider, vec![(8..8, "42")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - assert!( - editor.edit_prediction_requires_modifier(), - "subtle mode should require a modifier" - ); - - let inline_keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::Inline, - window, - cx, - ); - let compact_keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverCompact, - window, - cx, - ); - - let accept_keystroke = inline_keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); - let preview_keystroke = inline_keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); - let in_code_keystroke = inline_keybind_display - .displayed_keystroke - .as_ref() - .expect("should have an in-code binding"); - let compact_cursor_popover_keystroke = compact_keybind_display - .displayed_keystroke - .as_ref() - .expect("should have a compact cursor popover binding"); - - assert_eq!(accept_keystroke.key(), "tab"); - assert!( - !editor.has_visible_completions_menu(), - "compact cursor-popover branch should be used without a completions menu" - ); - assert!( - preview_keystroke.modifiers().modified(), - "preview binding should use modifiers in subtle mode" - ); - assert_eq!( - compact_cursor_popover_keystroke.key(), - preview_keystroke.key(), - "subtle compact cursor popover should prefer the preview binding" - ); - assert_eq!( - compact_cursor_popover_keystroke.modifiers(), - preview_keystroke.modifiers(), - "subtle compact cursor popover should use the preview binding modifiers" - ); - assert_eq!( - in_code_keystroke.key(), - preview_keystroke.key(), - "subtle in-code indicator should prefer the preview binding" - ); - assert_eq!( - in_code_keystroke.modifiers(), - preview_keystroke.modifiers(), - "subtle in-code indicator should use the preview binding modifiers" - ); + cx.editor(|editor, _, _| { + assert!(!editor.has_active_edit_prediction()); + assert!(!editor.edit_prediction_preview_is_active()); }); -} -#[gpui::test] -async fn test_tab_accepts_edit_prediction_over_completion(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); - - propose_edits(&provider, vec![(8..8, "42")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - - assert_editor_active_edit_completion(&mut cx, |_, edits| { - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].1.as_ref(), "42"); + let preview_modifiers = cx.update_editor(|editor, window, cx| { + *editor + .preview_edit_prediction_keystroke(window, cx) + .unwrap() + .modifiers() }); - cx.simulate_keystroke("tab"); + cx.simulate_modifiers_change(preview_modifiers); cx.run_until_parked(); - cx.assert_editor_state("let x = 42ˇ;"); -} - -#[gpui::test] -async fn test_single_line_prediction_uses_accept_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); + cx.editor(|editor, _, _| { + assert!(!editor.has_active_edit_prediction()); + assert!(editor.edit_prediction_preview_is_active()); + }); propose_edits(&provider, vec![(8..8, "42")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); + editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::ByProvider); + editor.update_visible_edit_prediction(window, cx) + }); - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, + cx.editor(|editor, _, _| { + assert!(editor.has_active_edit_prediction()); + assert!( + editor.edit_prediction_preview_is_active(), + "prediction preview should activate immediately when the prediction arrives while the preview modifier is still held", ); + }); +} - let accept_keystroke = keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); - let preview_keystroke = keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); - - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Accept, - "single-line prediction should show the accept action" +fn load_default_keymap(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + cx.bind_keys( + settings::KeymapFile::load_asset_allow_partial_failure( + settings::DEFAULT_KEYMAP_PATH, + cx, + ) + .expect("failed to load default keymap"), ); - assert_eq!(accept_keystroke.key(), "tab"); - assert!(preview_keystroke.modifiers().modified()); }); } #[gpui::test] -async fn test_multi_line_prediction_uses_preview_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); - - propose_edits(&provider, vec![(8..8, "42\n43")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); +async fn test_inline_edit_prediction_keybind_selection_cases(cx: &mut gpui::TestAppContext) { + enum InlineKeybindState { + Normal, + ShowingCompletions, + InLeadingWhitespace, + ShowingCompletionsAndLeadingWhitespace, + } - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, - ); - let preview_keystroke = keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); + enum ExpectedKeystroke { + DefaultAccept, + DefaultPreview, + Literal(&'static str), + } - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Preview, - "multi-line prediction should show the preview action" - ); - assert!(preview_keystroke.modifiers().modified()); - }); -} + struct InlineKeybindCase { + name: &'static str, + use_default_keymap: bool, + mode: EditPredictionsMode, + extra_bindings: Vec, + state: InlineKeybindState, + expected_accept_keystroke: ExpectedKeystroke, + expected_preview_keystroke: ExpectedKeystroke, + expected_displayed_keystroke: ExpectedKeystroke, + } -#[gpui::test] -async fn test_single_line_prediction_with_preview_uses_accept_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { init_test(cx, |_| {}); load_default_keymap(cx); + let mut default_cx = EditorTestContext::new(cx).await; + let provider = default_cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut default_cx); + default_cx.set_state("let x = ˇ;"); + propose_edits(&provider, vec![(8..8, "42")], &mut default_cx); + default_cx + .update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + + let (default_accept_keystroke, default_preview_keystroke) = + default_cx.update_editor(|editor, window, cx| { + let keybind_display = editor.edit_prediction_keybind_display( + EditPredictionKeybindSurface::Inline, + window, + cx, + ); + let accept_keystroke = keybind_display + .accept_keystroke + .as_ref() + .expect("default inline edit prediction should have an accept binding") + .clone(); + let preview_keystroke = keybind_display + .preview_keystroke + .as_ref() + .expect("default inline edit prediction should have a preview binding") + .clone(); + (accept_keystroke, preview_keystroke) + }); + + let cases = [ + InlineKeybindCase { + name: "default setup prefers tab over alt-tab for accept", + use_default_keymap: true, + mode: EditPredictionsMode::Eager, + extra_bindings: Vec::new(), + state: InlineKeybindState::Normal, + expected_accept_keystroke: ExpectedKeystroke::DefaultAccept, + expected_preview_keystroke: ExpectedKeystroke::DefaultPreview, + expected_displayed_keystroke: ExpectedKeystroke::DefaultAccept, + }, + InlineKeybindCase { + name: "subtle mode displays preview binding inline", + use_default_keymap: true, + mode: EditPredictionsMode::Subtle, + extra_bindings: Vec::new(), + state: InlineKeybindState::Normal, + expected_accept_keystroke: ExpectedKeystroke::DefaultPreview, + expected_preview_keystroke: ExpectedKeystroke::DefaultPreview, + expected_displayed_keystroke: ExpectedKeystroke::DefaultPreview, + }, + InlineKeybindCase { + name: "removing default tab binding still displays tab", + use_default_keymap: true, + mode: EditPredictionsMode::Eager, + extra_bindings: vec![KeyBinding::new( + "tab", + NoAction, + Some("Editor && edit_prediction && edit_prediction_mode == eager"), + )], + state: InlineKeybindState::Normal, + expected_accept_keystroke: ExpectedKeystroke::DefaultPreview, + expected_preview_keystroke: ExpectedKeystroke::DefaultPreview, + expected_displayed_keystroke: ExpectedKeystroke::DefaultPreview, + }, + InlineKeybindCase { + name: "custom-only rebound accept key uses replacement key", + use_default_keymap: true, + mode: EditPredictionsMode::Eager, + extra_bindings: vec![KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction"), + )], + state: InlineKeybindState::Normal, + expected_accept_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_preview_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_displayed_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + }, + InlineKeybindCase { + name: "showing completions restores conflict-context binding", + use_default_keymap: true, + mode: EditPredictionsMode::Eager, + extra_bindings: vec![KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction && showing_completions"), + )], + state: InlineKeybindState::ShowingCompletions, + expected_accept_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_preview_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_displayed_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + }, + InlineKeybindCase { + name: "leading whitespace restores conflict-context binding", + use_default_keymap: false, + mode: EditPredictionsMode::Eager, + extra_bindings: vec![KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction && in_leading_whitespace"), + )], + state: InlineKeybindState::InLeadingWhitespace, + expected_accept_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_preview_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_displayed_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + }, + InlineKeybindCase { + name: "showing completions and leading whitespace restore combined conflict binding", + use_default_keymap: false, + mode: EditPredictionsMode::Eager, + extra_bindings: vec![KeyBinding::new( + "ctrl-enter", + AcceptEditPrediction, + Some("Editor && edit_prediction && showing_completions && in_leading_whitespace"), + )], + state: InlineKeybindState::ShowingCompletionsAndLeadingWhitespace, + expected_accept_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_preview_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + expected_displayed_keystroke: ExpectedKeystroke::Literal("ctrl-enter"), + }, + ]; + + for case in cases { + init_test(cx, |_| {}); + if case.use_default_keymap { + load_default_keymap(cx); + } + update_test_language_settings(cx, &|settings| { + settings.edit_predictions.get_or_insert_default().mode = Some(case.mode); + }); - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); + if !case.extra_bindings.is_empty() { + cx.update(|cx| cx.bind_keys(case.extra_bindings.clone())); + } - propose_edits_with_preview(&provider, vec![(8..8, "42")], &mut cx).await; - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); + match case.state { + InlineKeybindState::Normal | InlineKeybindState::ShowingCompletions => { + cx.set_state("let x = ˇ;"); + } + InlineKeybindState::InLeadingWhitespace + | InlineKeybindState::ShowingCompletionsAndLeadingWhitespace => { + cx.set_state(indoc! {" + fn main() { + ˇ + } + "}); + } + } - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, - ); + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + + if matches!( + case.state, + InlineKeybindState::ShowingCompletions + | InlineKeybindState::ShowingCompletionsAndLeadingWhitespace + ) { + assign_editor_completion_menu_provider(&mut cx); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions, window, cx); + }); + cx.run_until_parked(); + } - let accept_keystroke = keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); - let preview_keystroke = keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); + cx.update_editor(|editor, window, cx| { + assert!( + editor.has_active_edit_prediction(), + "case '{}' should have an active edit prediction", + case.name + ); - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Accept, - "single-line prediction should show the accept action even with edit_preview" - ); - assert_eq!(accept_keystroke.key(), "tab"); - assert!(preview_keystroke.modifiers().modified()); - }); + let keybind_display = editor.edit_prediction_keybind_display( + EditPredictionKeybindSurface::Inline, + window, + cx, + ); + let accept_keystroke = keybind_display + .accept_keystroke + .as_ref() + .unwrap_or_else(|| panic!("case '{}' should have an accept binding", case.name)); + let preview_keystroke = keybind_display + .preview_keystroke + .as_ref() + .unwrap_or_else(|| panic!("case '{}' should have a preview binding", case.name)); + let displayed_keystroke = keybind_display + .displayed_keystroke + .as_ref() + .unwrap_or_else(|| panic!("case '{}' should have a displayed binding", case.name)); + + let expected_accept_keystroke = match case.expected_accept_keystroke { + ExpectedKeystroke::DefaultAccept => default_accept_keystroke.clone(), + ExpectedKeystroke::DefaultPreview => default_preview_keystroke.clone(), + ExpectedKeystroke::Literal(keystroke) => KeybindingKeystroke::from_keystroke( + Keystroke::parse(keystroke).expect("expected test keystroke to parse"), + ), + }; + let expected_preview_keystroke = match case.expected_preview_keystroke { + ExpectedKeystroke::DefaultAccept => default_accept_keystroke.clone(), + ExpectedKeystroke::DefaultPreview => default_preview_keystroke.clone(), + ExpectedKeystroke::Literal(keystroke) => KeybindingKeystroke::from_keystroke( + Keystroke::parse(keystroke).expect("expected test keystroke to parse"), + ), + }; + let expected_displayed_keystroke = match case.expected_displayed_keystroke { + ExpectedKeystroke::DefaultAccept => default_accept_keystroke.clone(), + ExpectedKeystroke::DefaultPreview => default_preview_keystroke.clone(), + ExpectedKeystroke::Literal(keystroke) => KeybindingKeystroke::from_keystroke( + Keystroke::parse(keystroke).expect("expected test keystroke to parse"), + ), + }; + + assert_eq!( + accept_keystroke, &expected_accept_keystroke, + "case '{}' selected the wrong accept binding", + case.name + ); + assert_eq!( + preview_keystroke, &expected_preview_keystroke, + "case '{}' selected the wrong preview binding", + case.name + ); + assert_eq!( + displayed_keystroke, &expected_displayed_keystroke, + "case '{}' selected the wrong displayed binding", + case.name + ); + + if matches!(case.mode, EditPredictionsMode::Subtle) { + assert!( + editor.edit_prediction_requires_modifier(), + "case '{}' should require a modifier", + case.name + ); + } + }); + } } #[gpui::test] -async fn test_multi_line_prediction_with_preview_uses_preview_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { +async fn test_tab_accepts_edit_prediction_over_completion(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); load_default_keymap(cx); @@ -773,131 +828,194 @@ async fn test_multi_line_prediction_with_preview_uses_preview_cursor_popover_act assign_editor_completion_provider(provider.clone(), &mut cx); cx.set_state("let x = ˇ;"); - propose_edits_with_preview(&provider, vec![(8..8, "42\n43")], &mut cx).await; + propose_edits(&provider, vec![(8..8, "42")], &mut cx); cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, - ); - let preview_keystroke = keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); - - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Preview, - "multi-line prediction should show the preview action with edit_preview" - ); - assert!(preview_keystroke.modifiers().modified()); + assert_editor_active_edit_completion(&mut cx, |_, edits| { + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].1.as_ref(), "42"); }); -} - -#[gpui::test] -async fn test_single_line_deletion_of_newline_uses_accept_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state(indoc! {" - fn main() { - let value = 1; - ˇprintln!(\"done\"); - } - "}); - - propose_edits( - &provider, - vec![(Point::new(1, 18)..Point::new(2, 17), "")], - &mut cx, - ); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, - ); - - let accept_keystroke = keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); - let preview_keystroke = keybind_display - .preview_keystroke - .as_ref() - .expect("should have a preview binding"); + cx.simulate_keystroke("tab"); + cx.run_until_parked(); - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Accept, - "deleting one newline plus adjacent text should show the accept action" - ); - assert_eq!(accept_keystroke.key(), "tab"); - assert!(preview_keystroke.modifiers().modified()); - }); + cx.assert_editor_state("let x = 42ˇ;"); } #[gpui::test] -async fn test_stale_single_line_prediction_does_not_force_preview_cursor_popover_action( - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - load_default_keymap(cx); - - let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionDelegate::default()); - assign_editor_completion_provider(provider.clone(), &mut cx); - cx.set_state("let x = ˇ;"); - - propose_edits(&provider, vec![(8..8, "42\n43")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); - cx.update_editor(|editor, _window, cx| { - assert!(editor.active_edit_prediction.is_some()); - assert!(editor.stale_edit_prediction_in_menu.is_none()); - editor.take_active_edit_prediction(cx); - assert!(editor.active_edit_prediction.is_none()); - assert!(editor.stale_edit_prediction_in_menu.is_some()); - }); +async fn test_cursor_popover_edit_prediction_keybind_cases(cx: &mut gpui::TestAppContext) { + enum CursorPopoverPredictionKind { + SingleLine, + MultiLine, + SingleLineWithPreview, + MultiLineWithPreview, + DeleteSingleNewline, + StaleSingleLineAfterMultiLine, + } - propose_edits(&provider, vec![(8..8, "42")], &mut cx); - cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx)); + struct CursorPopoverCase { + name: &'static str, + prediction_kind: CursorPopoverPredictionKind, + expected_action: EditPredictionKeybindAction, + } - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); + let cases = [ + CursorPopoverCase { + name: "single line prediction uses accept action", + prediction_kind: CursorPopoverPredictionKind::SingleLine, + expected_action: EditPredictionKeybindAction::Accept, + }, + CursorPopoverCase { + name: "multi line prediction uses preview action", + prediction_kind: CursorPopoverPredictionKind::MultiLine, + expected_action: EditPredictionKeybindAction::Preview, + }, + CursorPopoverCase { + name: "single line prediction with preview still uses accept action", + prediction_kind: CursorPopoverPredictionKind::SingleLineWithPreview, + expected_action: EditPredictionKeybindAction::Accept, + }, + CursorPopoverCase { + name: "multi line prediction with preview uses preview action", + prediction_kind: CursorPopoverPredictionKind::MultiLineWithPreview, + expected_action: EditPredictionKeybindAction::Preview, + }, + CursorPopoverCase { + name: "single line newline deletion uses accept action", + prediction_kind: CursorPopoverPredictionKind::DeleteSingleNewline, + expected_action: EditPredictionKeybindAction::Accept, + }, + CursorPopoverCase { + name: "stale multi line prediction does not force preview action", + prediction_kind: CursorPopoverPredictionKind::StaleSingleLineAfterMultiLine, + expected_action: EditPredictionKeybindAction::Accept, + }, + ]; + + for case in cases { + init_test(cx, |_| {}); + load_default_keymap(cx); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + + match case.prediction_kind { + CursorPopoverPredictionKind::SingleLine => { + cx.set_state("let x = ˇ;"); + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + CursorPopoverPredictionKind::MultiLine => { + cx.set_state("let x = ˇ;"); + propose_edits(&provider, vec![(8..8, "42\n43")], &mut cx); + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + CursorPopoverPredictionKind::SingleLineWithPreview => { + cx.set_state("let x = ˇ;"); + propose_edits_with_preview(&provider, vec![(8..8, "42")], &mut cx).await; + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + CursorPopoverPredictionKind::MultiLineWithPreview => { + cx.set_state("let x = ˇ;"); + propose_edits_with_preview(&provider, vec![(8..8, "42\n43")], &mut cx).await; + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + CursorPopoverPredictionKind::DeleteSingleNewline => { + cx.set_state(indoc! {" + fn main() { + let value = 1; + ˇprintln!(\"done\"); + } + "}); + propose_edits( + &provider, + vec![(Point::new(1, 18)..Point::new(2, 17), "")], + &mut cx, + ); + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + CursorPopoverPredictionKind::StaleSingleLineAfterMultiLine => { + cx.set_state("let x = ˇ;"); + propose_edits(&provider, vec![(8..8, "42\n43")], &mut cx); + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + cx.update_editor(|editor, _window, cx| { + assert!(editor.active_edit_prediction.is_some()); + assert!(editor.stale_edit_prediction_in_menu.is_none()); + editor.take_active_edit_prediction(cx); + assert!(editor.active_edit_prediction.is_none()); + assert!(editor.stale_edit_prediction_in_menu.is_some()); + }); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + cx.update_editor(|editor, window, cx| { + editor.update_visible_edit_prediction(window, cx) + }); + } + } - let keybind_display = editor.edit_prediction_keybind_display( - EditPredictionKeybindSurface::CursorPopoverExpanded, - window, - cx, - ); - let accept_keystroke = keybind_display - .accept_keystroke - .as_ref() - .expect("should have an accept binding"); + cx.update_editor(|editor, window, cx| { + assert!( + editor.has_active_edit_prediction(), + "case '{}' should have an active edit prediction", + case.name + ); - assert_eq!( - keybind_display.action, - EditPredictionKeybindAction::Accept, - "single-line active prediction should show the accept action" - ); - assert!( - editor.stale_edit_prediction_in_menu.is_none(), - "refreshing the visible prediction should clear stale menu state" - ); - assert_eq!(accept_keystroke.key(), "tab"); - }); + let keybind_display = editor.edit_prediction_keybind_display( + EditPredictionKeybindSurface::CursorPopoverExpanded, + window, + cx, + ); + let accept_keystroke = keybind_display + .accept_keystroke + .as_ref() + .unwrap_or_else(|| panic!("case '{}' should have an accept binding", case.name)); + let preview_keystroke = keybind_display + .preview_keystroke + .as_ref() + .unwrap_or_else(|| panic!("case '{}' should have a preview binding", case.name)); + + assert_eq!( + keybind_display.action, case.expected_action, + "case '{}' selected the wrong cursor popover action", + case.name + ); + assert_eq!( + accept_keystroke.key(), + "tab", + "case '{}' selected the wrong accept binding", + case.name + ); + assert!( + preview_keystroke.modifiers().modified(), + "case '{}' should use a modified preview binding", + case.name + ); + + if matches!( + case.prediction_kind, + CursorPopoverPredictionKind::StaleSingleLineAfterMultiLine + ) { + assert!( + editor.stale_edit_prediction_in_menu.is_none(), + "case '{}' should clear stale menu state", + case.name + ); + } + }); + } } fn assert_editor_active_edit_completion( @@ -1054,6 +1172,12 @@ fn assign_editor_completion_provider( }) } +fn assign_editor_completion_menu_provider(cx: &mut EditorTestContext) { + cx.update_editor(|editor, _, _| { + editor.set_completion_provider(Some(Rc::new(FakeCompletionMenuProvider))); + }); +} + fn propose_edits_non_zed( provider: &Entity, edits: Vec<(Range, &str)>, @@ -1086,6 +1210,54 @@ fn assign_editor_completion_provider_non_zed( }) } +struct FakeCompletionMenuProvider; + +impl CompletionProvider for FakeCompletionMenuProvider { + fn completions( + &self, + _excerpt_id: ExcerptId, + _buffer: &Entity, + _buffer_position: text::Anchor, + _trigger: CompletionContext, + _window: &mut Window, + _cx: &mut Context, + ) -> Task>> { + let completion = Completion { + replace_range: text::Anchor::MIN..text::Anchor::MAX, + new_text: "fake_completion".to_string(), + label: CodeLabel::plain("fake_completion".to_string(), None), + documentation: None, + source: CompletionSource::Custom, + icon_path: None, + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: None, + }; + + Task::ready(Ok(vec![CompletionResponse { + completions: vec![completion], + display_options: Default::default(), + is_incomplete: false, + }])) + } + + fn is_completion_trigger( + &self, + _buffer: &Entity, + _position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _cx: &mut Context, + ) -> bool { + false + } + + fn filter_completions(&self) -> bool { + false + } +} + #[derive(Default, Clone)] pub struct FakeEditPredictionDelegate { pub completion: Option, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ba7870efcb43a36442060654118bd228c0c0055c..a1135f7ad6a4b1153148da4013438190f7e765ab 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2885,6 +2885,11 @@ impl Editor { if self.in_leading_whitespace { key_context.add("in_leading_whitespace"); } + if self.edit_prediction_requires_modifier() { + key_context.set("edit_prediction_mode", "subtle") + } else { + key_context.set("edit_prediction_mode", "eager"); + } if self.selection_mark_mode { key_context.add("selection_mode"); @@ -2952,7 +2957,7 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> Option { - let key_context = self.key_context_internal(self.has_active_edit_prediction(), window, cx); + let key_context = self.key_context_internal(true, window, cx); let bindings = match granularity { @@ -2979,7 +2984,7 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> Option { - let key_context = self.key_context_internal(self.has_active_edit_prediction(), window, cx); + let key_context = self.key_context_internal(true, window, cx); let bindings = window.bindings_for_action_in_context(&AcceptEditPrediction, key_context); bindings .into_iter() @@ -2990,6 +2995,32 @@ impl Editor { }) } + fn edit_prediction_preview_modifiers_held( + &self, + modifiers: &Modifiers, + window: &mut Window, + cx: &mut App, + ) -> bool { + let key_context = self.key_context_internal(true, window, cx); + let actions: [&dyn Action; 3] = [ + &AcceptEditPrediction, + &AcceptNextWordEditPrediction, + &AcceptNextLineEditPrediction, + ]; + + actions.into_iter().any(|action| { + window + .bindings_for_action_in_context(action, key_context.clone()) + .into_iter() + .rev() + .any(|binding| { + binding.keystrokes().first().is_some_and(|keystroke| { + keystroke.modifiers().modified() && keystroke.modifiers() == modifiers + }) + }) + }) + } + fn edit_prediction_cursor_popover_prefers_preview( &self, completion: &EditPredictionState, @@ -8498,9 +8529,12 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + self.update_edit_prediction_settings(cx); + // Ensure that the edit prediction preview is updated, even when not // enabled, if there's an active edit prediction preview. if self.show_edit_predictions_in_menu() + || self.edit_prediction_requires_modifier() || matches!( self.edit_prediction_preview, EditPredictionPreview::Active { .. } @@ -8593,24 +8627,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let mut modifiers_held = false; - - let key_context = self.key_context_internal(self.has_active_edit_prediction(), window, cx); - let actions: [&dyn Action; 3] = [ - &AcceptEditPrediction, - &AcceptNextWordEditPrediction, - &AcceptNextLineEditPrediction, - ]; - - for action in actions { - let bindings = window.bindings_for_action_in_context(action, key_context.clone()); - for binding in bindings { - if let Some(keystroke) = binding.keystrokes().first() { - modifiers_held = modifiers_held - || (keystroke.modifiers() == modifiers && keystroke.modifiers().modified()); - } - } - } + let modifiers_held = self.edit_prediction_preview_modifiers_held(modifiers, window, cx); if modifiers_held { if matches!( From b4bd3fffdfe225a7b8bd5c1fb2202af9148b9b25 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 23 Mar 2026 10:39:25 +0200 Subject: [PATCH 13/46] ep: Add new prompt format (#52167) This PR adds `v0318`, which is just like `v0316` but with bigger blocks. It seems to perform best so far. It also adds heuristics to avoid placing the marker token on closing braces, which results in much nicer blocks. Finally, it fixes a bug with inserting `<|marker_N|>` mid-line in `v0316` and `v0317`. Release Notes: - N/A --- .../edit_prediction_cli/src/format_prompt.rs | 14 + crates/zeta_prompt/src/multi_region.rs | 1206 ++++++++++------- crates/zeta_prompt/src/zeta_prompt.rs | 76 ++ 3 files changed, 812 insertions(+), 484 deletions(-) diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index b0cfbd77ee543d4271cda0fb952f5ba48fc4a998..1da966ea9c5b2f3cf7b866bc82839de9d70e9fa6 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -150,6 +150,20 @@ pub fn zeta2_output_for_patch( ); } + if version == ZetaFormat::V0318SeedMultiRegions { + let cursor_in_new = cursor_offset.map(|cursor_offset| { + let hunk_start = first_hunk_offset.unwrap_or(0); + result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) + }); + return multi_region::encode_from_old_and_new_v0318( + &old_editable_region, + &result, + cursor_in_new, + zeta_prompt::CURSOR_MARKER, + multi_region::V0318_END_MARKER, + ); + } + if version == ZetaFormat::V0316SeedMultiRegions { let cursor_in_new = cursor_offset.map(|cursor_offset| { let hunk_start = first_hunk_offset.unwrap_or(0); diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs index a27a7245ae74824a086c9a39cc6d48d89f00d8b2..0514b8fd9c3e3fe4887ed57c27600e93f0df497a 100644 --- a/crates/zeta_prompt/src/multi_region.rs +++ b/crates/zeta_prompt/src/multi_region.rs @@ -3,10 +3,14 @@ use anyhow::{Context as _, Result, anyhow}; pub const MARKER_TAG_PREFIX: &str = "<|marker_"; pub const MARKER_TAG_SUFFIX: &str = "|>"; pub const RELATIVE_MARKER_TAG_PREFIX: &str = "<|marker"; -const MIN_BLOCK_LINES: usize = 3; -const MAX_BLOCK_LINES: usize = 8; +const V0316_MIN_BLOCK_LINES: usize = 3; +const V0316_MAX_BLOCK_LINES: usize = 8; +const V0318_MIN_BLOCK_LINES: usize = 6; +const V0318_MAX_BLOCK_LINES: usize = 16; +const MAX_NUDGE_LINES: usize = 5; pub const V0316_END_MARKER: &str = "<[end▁of▁sentence]>"; pub const V0317_END_MARKER: &str = "<[end▁of▁sentence]>"; +pub const V0318_END_MARKER: &str = "<[end▁of▁sentence]>"; pub fn marker_tag(number: usize) -> String { format!("{MARKER_TAG_PREFIX}{number}{MARKER_TAG_SUFFIX}") @@ -22,71 +26,104 @@ pub fn marker_tag_relative(delta: isize) -> String { } } +struct LineInfo { + start: usize, + is_blank: bool, + is_good_start: bool, +} + +fn collect_line_info(text: &str) -> Vec { + let mut lines = Vec::new(); + let mut offset = 0; + for line in text.split('\n') { + let trimmed = line.trim(); + let is_blank = trimmed.is_empty(); + let is_good_start = !is_blank && !is_structural_tail(trimmed); + lines.push(LineInfo { + start: offset, + is_blank, + is_good_start, + }); + offset += line.len() + 1; + } + // split('\n') on "abc\n" yields ["abc", ""] — drop the phantom trailing + // empty element when the text ends with '\n'. + if text.ends_with('\n') && lines.len() > 1 { + lines.pop(); + } + lines +} + +fn is_structural_tail(trimmed_line: &str) -> bool { + if trimmed_line.starts_with(&['}', ']', ')']) { + return true; + } + matches!( + trimmed_line.trim_end_matches(';'), + "break" | "continue" | "return" | "throw" | "end" + ) +} + +/// Starting from line `from`, scan up to `MAX_NUDGE_LINES` forward to find a +/// line with `is_good_start`. Returns `None` if no suitable line is found. +fn skip_to_good_start(lines: &[LineInfo], from: usize) -> Option { + (from..lines.len().min(from + MAX_NUDGE_LINES)).find(|&i| lines[i].is_good_start) +} + /// Compute byte offsets within `editable_text` where marker boundaries should /// be placed. /// /// Returns a sorted `Vec` that always starts with `0` and ends with /// `editable_text.len()`. Interior offsets are placed at line boundaries /// (right after a `\n`), preferring blank-line boundaries when available and -/// respecting `MIN_BLOCK_LINES` / `MAX_BLOCK_LINES` constraints. -pub fn compute_marker_offsets(editable_text: &str) -> Vec { +/// respecting `min_block_lines` / `max_block_lines` constraints. +fn compute_marker_offsets_with_limits( + editable_text: &str, + min_block_lines: usize, + max_block_lines: usize, +) -> Vec { if editable_text.is_empty() { return vec![0, 0]; } + let lines = collect_line_info(editable_text); let mut offsets = vec![0usize]; - let mut lines_since_last_marker = 0usize; - let mut byte_offset = 0usize; - - for line in editable_text.split('\n') { - let line_end = byte_offset + line.len() + 1; - let is_past_end = line_end > editable_text.len(); - let actual_line_end = line_end.min(editable_text.len()); - lines_since_last_marker += 1; - - let is_blank = line.trim().is_empty(); - - if !is_past_end && lines_since_last_marker >= MIN_BLOCK_LINES { - if is_blank { - // Blank-line boundary found. We'll place the marker when we - // find the next non-blank line (handled below). - } else if lines_since_last_marker >= MAX_BLOCK_LINES { - offsets.push(actual_line_end); - lines_since_last_marker = 0; - } - } + let mut last_boundary_line = 0; + let mut i = 0; + + while i < lines.len() { + let gap = i - last_boundary_line; - // Non-blank line immediately following blank line(s): split here so - // the new block starts with this line. - if !is_blank && byte_offset > 0 && lines_since_last_marker >= MIN_BLOCK_LINES { - let before = &editable_text[..byte_offset]; - let has_preceding_blank_line = before - .strip_suffix('\n') - .map(|stripped| { - let last_line = match stripped.rfind('\n') { - Some(pos) => &stripped[pos + 1..], - None => stripped, - }; - last_line.trim().is_empty() - }) - .unwrap_or(false); - - if has_preceding_blank_line { - offsets.push(byte_offset); - lines_since_last_marker = 1; + // Blank-line split: non-blank line following blank line(s) with enough + // accumulated lines. + if gap >= min_block_lines && !lines[i].is_blank && i > 0 && lines[i - 1].is_blank { + let target = if lines[i].is_good_start { + i + } else { + skip_to_good_start(&lines, i).unwrap_or(i) + }; + if lines.len() - target >= min_block_lines + && lines[target].start > *offsets.last().unwrap_or(&0) + { + offsets.push(lines[target].start); + last_boundary_line = target; + i = target + 1; + continue; } } - byte_offset = actual_line_end; - - // Re-check after blank-line logic since lines_since_last_marker may - // have been reset. - if !is_past_end && lines_since_last_marker >= MAX_BLOCK_LINES { - if *offsets.last().unwrap_or(&0) != actual_line_end { - offsets.push(actual_line_end); - lines_since_last_marker = 0; + // Hard cap: too many lines without a split. + if gap >= max_block_lines { + let target = skip_to_good_start(&lines, i).unwrap_or(i); + if lines[target].start > *offsets.last().unwrap_or(&0) { + offsets.push(lines[target].start); + last_boundary_line = target; + i = target + 1; + continue; } } + + i += 1; } let end = editable_text.len(); @@ -97,6 +134,15 @@ pub fn compute_marker_offsets(editable_text: &str) -> Vec { offsets } +/// Compute byte offsets within `editable_text` for the V0316/V0317 block sizing rules. +pub fn compute_marker_offsets(editable_text: &str) -> Vec { + compute_marker_offsets_with_limits(editable_text, V0316_MIN_BLOCK_LINES, V0316_MAX_BLOCK_LINES) +} + +pub fn compute_marker_offsets_v0318(editable_text: &str) -> Vec { + compute_marker_offsets_with_limits(editable_text, V0318_MIN_BLOCK_LINES, V0318_MAX_BLOCK_LINES) +} + /// Write the editable region content with marker tags, inserting the cursor /// marker at the given offset within the editable text. pub fn write_editable_with_markers( @@ -267,27 +313,8 @@ pub fn encode_from_old_and_new( } let marker_offsets = compute_marker_offsets(old_editable); - - let common_prefix = old_editable - .bytes() - .zip(new_editable.bytes()) - .take_while(|(a, b)| a == b) - .count(); - - let old_remaining = old_editable.len() - common_prefix; - let new_remaining = new_editable.len() - common_prefix; - let max_suffix = old_remaining.min(new_remaining); - let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..] - .iter() - .rev() - .zip( - new_editable.as_bytes()[new_editable.len() - max_suffix..] - .iter() - .rev(), - ) - .take_while(|(a, b)| a == b) - .count(); - + let (common_prefix, common_suffix) = + common_prefix_suffix(old_editable.as_bytes(), new_editable.as_bytes()); let change_end_in_old = old_editable.len() - common_suffix; let start_marker_idx = marker_offsets @@ -380,55 +407,24 @@ pub fn extract_editable_region_from_markers(text: &str) -> Option { Some(result) } -struct MarkerTag { - number: usize, - tag_start: usize, - tag_end: usize, -} - -struct RelativeMarkerTag { - delta: isize, +struct ParsedTag { + value: isize, tag_start: usize, tag_end: usize, } -fn collect_marker_tags(text: &str) -> Vec { - let mut markers = Vec::new(); - let mut search_from = 0; - while let Some(rel_pos) = text[search_from..].find(MARKER_TAG_PREFIX) { - let tag_start = search_from + rel_pos; - let num_start = tag_start + MARKER_TAG_PREFIX.len(); - if let Some(suffix_rel) = text[num_start..].find(MARKER_TAG_SUFFIX) { - let num_end = num_start + suffix_rel; - if let Ok(number) = text[num_start..num_end].parse::() { - let tag_end = num_end + MARKER_TAG_SUFFIX.len(); - markers.push(MarkerTag { - number, - tag_start, - tag_end, - }); - search_from = tag_end; - continue; - } - } - search_from = tag_start + MARKER_TAG_PREFIX.len(); - } - markers -} - -fn collect_relative_marker_tags(text: &str) -> Vec { - let mut markers = Vec::new(); +fn collect_tags(text: &str, prefix: &str, parse: fn(&str) -> Option) -> Vec { + let mut tags = Vec::new(); let mut search_from = 0; - while let Some(rel_pos) = text[search_from..].find(RELATIVE_MARKER_TAG_PREFIX) { + while let Some(rel_pos) = text[search_from..].find(prefix) { let tag_start = search_from + rel_pos; - let payload_start = tag_start + RELATIVE_MARKER_TAG_PREFIX.len(); + let payload_start = tag_start + prefix.len(); if let Some(suffix_rel) = text[payload_start..].find(MARKER_TAG_SUFFIX) { let payload_end = payload_start + suffix_rel; - let payload = &text[payload_start..payload_end]; - if let Ok(delta) = payload.parse::() { + if let Some(value) = parse(&text[payload_start..payload_end]) { let tag_end = payload_end + MARKER_TAG_SUFFIX.len(); - markers.push(RelativeMarkerTag { - delta, + tags.push(ParsedTag { + value, tag_start, tag_end, }); @@ -436,9 +432,21 @@ fn collect_relative_marker_tags(text: &str) -> Vec { continue; } } - search_from = tag_start + RELATIVE_MARKER_TAG_PREFIX.len(); + search_from = tag_start + prefix.len(); } - markers + tags +} + +fn collect_marker_tags(text: &str) -> Vec { + collect_tags(text, MARKER_TAG_PREFIX, |s| { + s.parse::().ok().map(|n| n as isize) + }) +} + +fn collect_relative_marker_tags(text: &str) -> Vec { + collect_tags(text, RELATIVE_MARKER_TAG_PREFIX, |s| { + s.parse::().ok() + }) } pub fn nearest_marker_number(cursor_offset: Option, marker_offsets: &[usize]) -> usize { @@ -459,21 +467,87 @@ fn cursor_block_index(cursor_offset: Option, marker_offsets: &[usize]) -> .unwrap_or_else(|| marker_offsets.len().saturating_sub(2)) } -/// Write the editable region content with V0317 byte-exact marker tags, where -/// marker numbers are relative to the cursor block. -pub fn write_editable_with_markers_v0317( +fn common_prefix_suffix(a: &[u8], b: &[u8]) -> (usize, usize) { + let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count(); + let remaining_a = a.len() - prefix; + let remaining_b = b.len() - prefix; + let max_suffix = remaining_a.min(remaining_b); + let suffix = a[a.len() - max_suffix..] + .iter() + .rev() + .zip(b[b.len() - max_suffix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + (prefix, suffix) +} + +/// Map a byte offset from old span coordinates to new span coordinates, +/// using common prefix/suffix within the span for accuracy. +fn map_boundary_offset( + old_rel: usize, + old_span_len: usize, + new_span_len: usize, + span_common_prefix: usize, + span_common_suffix: usize, +) -> usize { + if old_rel <= span_common_prefix { + old_rel + } else if old_rel >= old_span_len - span_common_suffix { + new_span_len - (old_span_len - old_rel) + } else { + let old_changed_start = span_common_prefix; + let old_changed_len = old_span_len + .saturating_sub(span_common_prefix) + .saturating_sub(span_common_suffix); + let new_changed_start = span_common_prefix; + let new_changed_len = new_span_len + .saturating_sub(span_common_prefix) + .saturating_sub(span_common_suffix); + + if old_changed_len == 0 { + new_changed_start + } else { + new_changed_start + ((old_rel - old_changed_start) * new_changed_len / old_changed_len) + } + } +} + +fn snap_to_line_start(text: &str, offset: usize) -> usize { + let bounded = offset.min(text.len()); + let bounded = text.floor_char_boundary(bounded); + + if bounded >= text.len() { + return text.len(); + } + + if bounded == 0 || text.as_bytes().get(bounded - 1) == Some(&b'\n') { + return bounded; + } + + if let Some(next_nl_rel) = text[bounded..].find('\n') { + let next = bounded + next_nl_rel + 1; + return text.floor_char_boundary(next.min(text.len())); + } + + let prev_start = text[..bounded].rfind('\n').map(|idx| idx + 1).unwrap_or(0); + text.floor_char_boundary(prev_start) +} + +/// Write the editable region content with byte-exact marker tags, inserting the +/// cursor marker at the given offset within the editable text. +/// +/// The `tag_for_index` closure maps a boundary index to the marker tag string. +fn write_editable_with_markers_impl( output: &mut String, editable_text: &str, cursor_offset_in_editable: usize, cursor_marker: &str, + marker_offsets: &[usize], + tag_for_index: impl Fn(usize) -> String, ) { - let marker_offsets = compute_marker_offsets(editable_text); - let anchor_idx = cursor_block_index(Some(cursor_offset_in_editable), &marker_offsets); let mut cursor_placed = false; - for (i, &offset) in marker_offsets.iter().enumerate() { - let marker_delta = i as isize - anchor_idx as isize; - output.push_str(&marker_tag_relative(marker_delta)); + output.push_str(&tag_for_index(i)); if let Some(&next_offset) = marker_offsets.get(i + 1) { let block = &editable_text[offset..next_offset]; @@ -493,11 +567,6 @@ pub fn write_editable_with_markers_v0317( } } -/// Write the editable region content with V0316 byte-exact marker tags. -/// -/// Unlike the V0306 version, markers are pure delimiters with no newline -/// padding. The content between markers is the exact bytes from the editable -/// text. pub fn write_editable_with_markers_v0316( output: &mut String, editable_text: &str, @@ -505,103 +574,93 @@ pub fn write_editable_with_markers_v0316( cursor_marker: &str, ) { let marker_offsets = compute_marker_offsets(editable_text); - let mut cursor_placed = false; - for (i, &offset) in marker_offsets.iter().enumerate() { - let marker_num = i + 1; - output.push_str(&marker_tag(marker_num)); + write_editable_with_markers_impl( + output, + editable_text, + cursor_offset_in_editable, + cursor_marker, + &marker_offsets, + |i| marker_tag(i + 1), + ); +} - if let Some(&next_offset) = marker_offsets.get(i + 1) { - let block = &editable_text[offset..next_offset]; - if !cursor_placed - && cursor_offset_in_editable >= offset - && cursor_offset_in_editable <= next_offset - { - cursor_placed = true; - let cursor_in_block = cursor_offset_in_editable - offset; - output.push_str(&block[..cursor_in_block]); - output.push_str(cursor_marker); - output.push_str(&block[cursor_in_block..]); - } else { - output.push_str(block); - } - } - } +pub fn write_editable_with_markers_v0317( + output: &mut String, + editable_text: &str, + cursor_offset_in_editable: usize, + cursor_marker: &str, +) { + let marker_offsets = compute_marker_offsets(editable_text); + let anchor_idx = cursor_block_index(Some(cursor_offset_in_editable), &marker_offsets); + write_editable_with_markers_impl( + output, + editable_text, + cursor_offset_in_editable, + cursor_marker, + &marker_offsets, + |i| marker_tag_relative(i as isize - anchor_idx as isize), + ); } -/// Parse V0316 model output and reconstruct the full new editable region. -/// -/// V0316 differences from V0306: -/// - No newline stripping or normalization (byte-exact content). -/// - The no-edit signal is `start_num == end_num` (any repeated marker). -/// - Intermediate marker tags are used for block-level extraction. -pub fn apply_marker_span_v0316(old_editable: &str, output: &str) -> Result { - let markers = collect_marker_tags(output); +pub fn write_editable_with_markers_v0318( + output: &mut String, + editable_text: &str, + cursor_offset_in_editable: usize, + cursor_marker: &str, +) { + let marker_offsets = compute_marker_offsets_v0318(editable_text); + write_editable_with_markers_impl( + output, + editable_text, + cursor_offset_in_editable, + cursor_marker, + &marker_offsets, + |i| marker_tag(i + 1), + ); +} - if markers.is_empty() { +/// Parse byte-exact model output and reconstruct the full new editable region. +/// +/// `resolve_boundary` maps a parsed tag value to an absolute byte offset in +/// old_editable, given the marker_offsets. Returns `(start_byte, end_byte)` or +/// an error. +fn apply_marker_span_impl( + old_editable: &str, + tags: &[ParsedTag], + output: &str, + resolve_boundaries: impl Fn(isize, isize) -> Result<(usize, usize)>, +) -> Result { + if tags.is_empty() { return Err(anyhow!("no marker tags found in output")); } - - if markers.len() == 1 { + if tags.len() == 1 { return Err(anyhow!( "only one marker tag found in output, expected at least two" )); } - let start_num = markers - .first() - .map(|marker| marker.number) - .context("missing first marker")?; - let end_num = markers - .last() - .map(|marker| marker.number) - .context("missing last marker")?; + let start_value = tags[0].value; + let end_value = tags[tags.len() - 1].value; - // No-edit signal: start_num == end_num - if start_num == end_num { + if start_value == end_value { return Ok(old_editable.to_string()); } - // Validate monotonically increasing with no gaps - let expected_nums: Vec = (start_num..=end_num).collect(); - let actual_nums: Vec = markers.iter().map(|m| m.number).collect(); - if actual_nums != expected_nums { - eprintln!( - "V0316 marker sequence validation failed: expected {:?}, got {:?}. Attempting best-effort parse.", - expected_nums, actual_nums - ); - } - - let marker_offsets = compute_marker_offsets(old_editable); - - let start_idx = start_num - .checked_sub(1) - .context("marker numbers are 1-indexed")?; - let end_idx = end_num - .checked_sub(1) - .context("marker numbers are 1-indexed")?; - - let start_byte = *marker_offsets - .get(start_idx) - .context("start marker number out of range")?; - let end_byte = *marker_offsets - .get(end_idx) - .context("end marker number out of range")?; + let (start_byte, end_byte) = resolve_boundaries(start_value, end_value)?; if start_byte > end_byte { return Err(anyhow!("start marker must come before end marker")); } - // Extract byte-exact content between consecutive markers let mut new_content = String::new(); - for i in 0..markers.len() - 1 { - let content_start = markers[i].tag_end; - let content_end = markers[i + 1].tag_start; + for i in 0..tags.len() - 1 { + let content_start = tags[i].tag_end; + let content_end = tags[i + 1].tag_start; if content_start <= content_end { new_content.push_str(&output[content_start..content_end]); } } - // Splice into old_editable let mut result = String::new(); result.push_str(&old_editable[..start_byte]); result.push_str(&new_content); @@ -610,123 +669,127 @@ pub fn apply_marker_span_v0316(old_editable: &str, output: &str) -> Result Result { + let tags = collect_marker_tags(output); + + // Validate monotonically increasing with no gaps (best-effort warning) + if tags.len() >= 2 { + let start_num = tags[0].value; + let end_num = tags[tags.len() - 1].value; + if start_num != end_num { + let expected: Vec = (start_num..=end_num).collect(); + let actual: Vec = tags.iter().map(|t| t.value).collect(); + if actual != expected { + eprintln!( + "V0316 marker sequence validation failed: expected {:?}, got {:?}. Attempting best-effort parse.", + expected, actual + ); + } + } + } + + let marker_offsets = compute_marker_offsets(old_editable); + apply_marker_span_impl(old_editable, &tags, output, |start_val, end_val| { + let start_idx = (start_val as usize) + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = (end_val as usize) + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + Ok((start_byte, end_byte)) + }) +} + pub fn apply_marker_span_v0317( old_editable: &str, output: &str, cursor_offset_in_old: Option, ) -> Result { - let markers = collect_relative_marker_tags(output); - - if markers.is_empty() { - return Err(anyhow!("no marker tags found in output")); - } - - if markers.len() == 1 { - return Err(anyhow!( - "only one marker tag found in output, expected at least two" - )); - } - + let tags = collect_relative_marker_tags(output); let marker_offsets = compute_marker_offsets(old_editable); let anchor_idx = cursor_block_index(cursor_offset_in_old, &marker_offsets); - let start_delta = markers - .first() - .map(|marker| marker.delta) - .context("missing first marker")?; - let end_delta = markers - .last() - .map(|marker| marker.delta) - .context("missing last marker")?; - - if start_delta == end_delta { - return Ok(old_editable.to_string()); - } - - let start_idx_isize = anchor_idx as isize + start_delta; - let end_idx_isize = anchor_idx as isize + end_delta; - if start_idx_isize < 0 || end_idx_isize < 0 { - return Err(anyhow!("relative marker maps before first marker")); - } - - let start_idx = usize::try_from(start_idx_isize).context("invalid start marker index")?; - let end_idx = usize::try_from(end_idx_isize).context("invalid end marker index")?; - - let start_byte = *marker_offsets - .get(start_idx) - .context("start marker number out of range")?; - let end_byte = *marker_offsets - .get(end_idx) - .context("end marker number out of range")?; - - if start_byte > end_byte { - return Err(anyhow!("start marker must come before end marker")); - } + apply_marker_span_impl(old_editable, &tags, output, |start_delta, end_delta| { + let start_idx_signed = anchor_idx as isize + start_delta; + let end_idx_signed = anchor_idx as isize + end_delta; + if start_idx_signed < 0 || end_idx_signed < 0 { + return Err(anyhow!("relative marker maps before first marker")); + } + let start_idx = usize::try_from(start_idx_signed).context("invalid start marker index")?; + let end_idx = usize::try_from(end_idx_signed).context("invalid end marker index")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + Ok((start_byte, end_byte)) + }) +} - let mut new_content = String::new(); - for i in 0..markers.len() - 1 { - let content_start = markers[i].tag_end; - let content_end = markers[i + 1].tag_start; - if content_start <= content_end { - new_content.push_str(&output[content_start..content_end]); +pub fn apply_marker_span_v0318(old_editable: &str, output: &str) -> Result { + let tags = collect_marker_tags(output); + + if tags.len() >= 2 { + let start_num = tags[0].value; + let end_num = tags[tags.len() - 1].value; + if start_num != end_num { + let expected: Vec = (start_num..=end_num).collect(); + let actual: Vec = tags.iter().map(|t| t.value).collect(); + if actual != expected { + eprintln!( + "V0318 marker sequence validation failed: expected {:?}, got {:?}. Attempting best-effort parse.", + expected, actual + ); + } } } - let mut result = String::new(); - result.push_str(&old_editable[..start_byte]); - result.push_str(&new_content); - result.push_str(&old_editable[end_byte..]); - - Ok(result) + let marker_offsets = compute_marker_offsets_v0318(old_editable); + apply_marker_span_impl(old_editable, &tags, output, |start_val, end_val| { + let start_idx = (start_val as usize) + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = (end_val as usize) + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + Ok((start_byte, end_byte)) + }) } -/// Encode the V0316 training target from old and new editable text. +/// Encode the training target from old and new editable text. /// -/// V0316 differences from V0306: -/// - No-edit signal: `<|marker_C|><|marker_C|>{end_marker}` where C is nearest -/// to cursor. -/// - All intermediate markers are emitted with byte-exact content. -/// - No newline padding around marker tags. -pub fn encode_from_old_and_new_v0316( +/// Shared implementation for V0316, V0317, and V0318. The `tag_for_block_idx` +/// closure maps a block index to the appropriate marker tag string. +/// `no_edit_tag` is the marker tag to repeat when there are no edits. +fn encode_from_old_and_new_impl( old_editable: &str, new_editable: &str, cursor_offset_in_new: Option, cursor_marker: &str, end_marker: &str, + no_edit_tag: &str, + marker_offsets: &[usize], + tag_for_block_idx: impl Fn(usize) -> String, ) -> Result { - let marker_offsets = compute_marker_offsets(old_editable); - if old_editable == new_editable { - let marker_num = nearest_marker_number(cursor_offset_in_new, &marker_offsets); - let tag = marker_tag(marker_num); - return Ok(format!("{tag}{tag}{end_marker}")); + return Ok(format!("{no_edit_tag}{no_edit_tag}{end_marker}")); } - let common_prefix = old_editable - .bytes() - .zip(new_editable.bytes()) - .take_while(|(a, b)| a == b) - .count(); - - let old_remaining = old_editable.len() - common_prefix; - let new_remaining = new_editable.len() - common_prefix; - let max_suffix = old_remaining.min(new_remaining); - let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..] - .iter() - .rev() - .zip( - new_editable.as_bytes()[new_editable.len() - max_suffix..] - .iter() - .rev(), - ) - .take_while(|(a, b)| a == b) - .count(); - + let (common_prefix, common_suffix) = + common_prefix_suffix(old_editable.as_bytes(), new_editable.as_bytes()); let change_end_in_old = old_editable.len() - common_suffix; let start_marker_idx = marker_offsets @@ -749,40 +812,19 @@ pub fn encode_from_old_and_new_v0316( let new_span = &new_editable[new_start..new_end]; let old_span = &old_editable[old_start..old_end]; - // Compute common prefix/suffix within the span for accurate boundary mapping - let span_common_prefix = old_span - .bytes() - .zip(new_span.bytes()) - .take_while(|(a, b)| a == b) - .count(); - - let span_old_remaining = old_span.len() - span_common_prefix; - let span_new_remaining = new_span.len() - span_common_prefix; - let span_max_suffix = span_old_remaining.min(span_new_remaining); - let span_common_suffix = old_span.as_bytes()[old_span.len() - span_max_suffix..] - .iter() - .rev() - .zip( - new_span.as_bytes()[new_span.len() - span_max_suffix..] - .iter() - .rev(), - ) - .take_while(|(a, b)| a == b) - .count(); + let (span_common_prefix, span_common_suffix) = + common_prefix_suffix(old_span.as_bytes(), new_span.as_bytes()); let mut result = String::new(); let mut prev_new_rel = 0usize; let mut cursor_placed = false; for block_idx in start_marker_idx..end_marker_idx { - let marker_num = block_idx + 1; - result.push_str(&marker_tag(marker_num)); + result.push_str(&tag_for_block_idx(block_idx)); let new_rel_end = if block_idx + 1 == end_marker_idx { - // Last block: extends to end of new span new_span.len() } else { - // Map the intermediate boundary from old to new coordinates let old_rel = marker_offsets[block_idx + 1] - old_start; let mapped = map_boundary_offset( old_rel, @@ -791,13 +833,10 @@ pub fn encode_from_old_and_new_v0316( span_common_prefix, span_common_suffix, ); - // Ensure char boundary safety and monotonicity - new_span.floor_char_boundary(mapped) + snap_to_line_start(new_span, mapped) }; - // Ensure monotonicity (each block gets at least zero content) let new_rel_end = new_rel_end.max(prev_new_rel); - let block_content = &new_span[prev_new_rel..new_rel_end]; if !cursor_placed { @@ -821,19 +860,33 @@ pub fn encode_from_old_and_new_v0316( prev_new_rel = new_rel_end; } - // Final closing marker - let end_marker_num = end_marker_idx + 1; - result.push_str(&marker_tag(end_marker_num)); + result.push_str(&tag_for_block_idx(end_marker_idx)); result.push_str(end_marker); Ok(result) } -/// Encode the V0317 training target from old and new editable text. -/// -/// V0317 differences from V0316: -/// - Marker ids are relative to cursor block (..., -2, -1, 0, +1, +2, ...). -/// - No-edit signal: repeated cursor-relative marker. +pub fn encode_from_old_and_new_v0316( + old_editable: &str, + new_editable: &str, + cursor_offset_in_new: Option, + cursor_marker: &str, + end_marker: &str, +) -> Result { + let marker_offsets = compute_marker_offsets(old_editable); + let no_edit_tag = marker_tag(nearest_marker_number(cursor_offset_in_new, &marker_offsets)); + encode_from_old_and_new_impl( + old_editable, + new_editable, + cursor_offset_in_new, + cursor_marker, + end_marker, + &no_edit_tag, + &marker_offsets, + |block_idx| marker_tag(block_idx + 1), + ) +} + pub fn encode_from_old_and_new_v0317( old_editable: &str, new_editable: &str, @@ -843,157 +896,38 @@ pub fn encode_from_old_and_new_v0317( ) -> Result { let marker_offsets = compute_marker_offsets(old_editable); let anchor_idx = cursor_block_index(cursor_offset_in_new, &marker_offsets); - - if old_editable == new_editable { - let tag = marker_tag_relative(0); - return Ok(format!("{tag}{tag}{end_marker}")); - } - - let common_prefix = old_editable - .bytes() - .zip(new_editable.bytes()) - .take_while(|(a, b)| a == b) - .count(); - - let old_remaining = old_editable.len() - common_prefix; - let new_remaining = new_editable.len() - common_prefix; - let max_suffix = old_remaining.min(new_remaining); - let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..] - .iter() - .rev() - .zip( - new_editable.as_bytes()[new_editable.len() - max_suffix..] - .iter() - .rev(), - ) - .take_while(|(a, b)| a == b) - .count(); - - let change_end_in_old = old_editable.len() - common_suffix; - - let start_marker_idx = marker_offsets - .iter() - .rposition(|&offset| offset <= common_prefix) - .unwrap_or(0); - let end_marker_idx = marker_offsets - .iter() - .position(|&offset| offset >= change_end_in_old) - .unwrap_or(marker_offsets.len() - 1); - - let old_start = marker_offsets[start_marker_idx]; - let old_end = marker_offsets[end_marker_idx]; - - let new_start = old_start; - let new_end = new_editable - .len() - .saturating_sub(old_editable.len().saturating_sub(old_end)); - - let new_span = &new_editable[new_start..new_end]; - let old_span = &old_editable[old_start..old_end]; - - let span_common_prefix = old_span - .bytes() - .zip(new_span.bytes()) - .take_while(|(a, b)| a == b) - .count(); - - let span_old_remaining = old_span.len() - span_common_prefix; - let span_new_remaining = new_span.len() - span_common_prefix; - let span_max_suffix = span_old_remaining.min(span_new_remaining); - let span_common_suffix = old_span.as_bytes()[old_span.len() - span_max_suffix..] - .iter() - .rev() - .zip( - new_span.as_bytes()[new_span.len() - span_max_suffix..] - .iter() - .rev(), - ) - .take_while(|(a, b)| a == b) - .count(); - - let mut result = String::new(); - let mut prev_new_rel = 0usize; - let mut cursor_placed = false; - - for block_idx in start_marker_idx..end_marker_idx { - let marker_delta = block_idx as isize - anchor_idx as isize; - result.push_str(&marker_tag_relative(marker_delta)); - - let new_rel_end = if block_idx + 1 == end_marker_idx { - new_span.len() - } else { - let old_rel = marker_offsets[block_idx + 1] - old_start; - let mapped = map_boundary_offset( - old_rel, - old_span.len(), - new_span.len(), - span_common_prefix, - span_common_suffix, - ); - new_span.floor_char_boundary(mapped) - }; - - let new_rel_end = new_rel_end.max(prev_new_rel); - let block_content = &new_span[prev_new_rel..new_rel_end]; - - if !cursor_placed { - if let Some(cursor_offset) = cursor_offset_in_new { - let abs_start = new_start + prev_new_rel; - let abs_end = new_start + new_rel_end; - if cursor_offset >= abs_start && cursor_offset <= abs_end { - cursor_placed = true; - let cursor_in_block = cursor_offset - abs_start; - let bounded = cursor_in_block.min(block_content.len()); - result.push_str(&block_content[..bounded]); - result.push_str(cursor_marker); - result.push_str(&block_content[bounded..]); - prev_new_rel = new_rel_end; - continue; - } - } - } - - result.push_str(block_content); - prev_new_rel = new_rel_end; - } - - let end_marker_delta = end_marker_idx as isize - anchor_idx as isize; - result.push_str(&marker_tag_relative(end_marker_delta)); - result.push_str(end_marker); - - Ok(result) + let no_edit_tag = marker_tag_relative(0); + encode_from_old_and_new_impl( + old_editable, + new_editable, + cursor_offset_in_new, + cursor_marker, + end_marker, + &no_edit_tag, + &marker_offsets, + |block_idx| marker_tag_relative(block_idx as isize - anchor_idx as isize), + ) } -/// Map a byte offset from old span coordinates to new span coordinates, -/// using common prefix/suffix within the span for accuracy. -fn map_boundary_offset( - old_rel: usize, - old_span_len: usize, - new_span_len: usize, - span_common_prefix: usize, - span_common_suffix: usize, -) -> usize { - if old_rel <= span_common_prefix { - old_rel - } else if old_rel >= old_span_len - span_common_suffix { - new_span_len - (old_span_len - old_rel) - } else { - // Within the changed region: proportional mapping - let old_changed_start = span_common_prefix; - let old_changed_len = old_span_len - .saturating_sub(span_common_prefix) - .saturating_sub(span_common_suffix); - let new_changed_start = span_common_prefix; - let new_changed_len = new_span_len - .saturating_sub(span_common_prefix) - .saturating_sub(span_common_suffix); - - if old_changed_len == 0 { - new_changed_start - } else { - new_changed_start + ((old_rel - old_changed_start) * new_changed_len / old_changed_len) - } - } +pub fn encode_from_old_and_new_v0318( + old_editable: &str, + new_editable: &str, + cursor_offset_in_new: Option, + cursor_marker: &str, + end_marker: &str, +) -> Result { + let marker_offsets = compute_marker_offsets_v0318(old_editable); + let no_edit_tag = marker_tag(nearest_marker_number(cursor_offset_in_new, &marker_offsets)); + encode_from_old_and_new_impl( + old_editable, + new_editable, + cursor_offset_in_new, + cursor_marker, + end_marker, + &no_edit_tag, + &marker_offsets, + |block_idx| marker_tag(block_idx + 1), + ) } #[cfg(test)] @@ -1016,6 +950,88 @@ mod tests { assert_eq!(*offsets.last().unwrap(), text.len()); } + #[test] + fn test_compute_marker_offsets_blank_line_split_overrides_pending_hard_cap_boundary() { + let text = "\ +class OCRDataframe(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + df: pl.DataFrame + + def page(self, page_number: int = 0) -> \"OCRDataframe\": + # Filter dataframe on specific page + df_page = self.df.filter(pl.col(\"page\") == page_number) + return OCRDataframe(df=df_page) + + def get_text_cell( + self, + cell: Cell, + margin: int = 0, + page_number: Optional[int] = None, + min_confidence: int = 50, + ) -> Optional[str]: + \"\"\" + Get text corresponding to cell +"; + let offsets = compute_marker_offsets(text); + + let def_start = text + .find(" def get_text_cell(") + .expect("def line exists"); + let self_start = text.find(" self,").expect("self line exists"); + + assert!( + offsets.contains(&def_start), + "expected boundary at def line start ({def_start}), got {offsets:?}" + ); + assert!( + !offsets.contains(&self_start), + "did not expect boundary at self line start ({self_start}), got {offsets:?}" + ); + } + + #[test] + fn test_compute_marker_offsets_blank_line_split_skips_closer_line() { + let text = "\ +impl Plugin for AhoySchedulePlugin { + fn build(&self, app: &mut App) { + app.configure_sets( + self.schedule, + ( + AhoySystems::MoveCharacters, + AhoySystems::ApplyForcesToDynamicRigidBodies, + ) + .chain() + .before(PhysicsSystems::First), + ); + + } +} + +/// System set used by all systems of `bevy_ahoy`. +#[derive(SystemSet, Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub enum AhoySystems { + MoveCharacters, + ApplyForcesToDynamicRigidBodies, +} +"; + let offsets = compute_marker_offsets(text); + + let closer_start = text.find(" }\n").expect("closer line exists"); + let doc_start = text + .find("/// System set used by all systems of `bevy_ahoy`.") + .expect("doc line exists"); + + assert!( + !offsets.contains(&closer_start), + "did not expect boundary at closer line start ({closer_start}), got {offsets:?}" + ); + assert!( + offsets.contains(&doc_start), + "expected boundary at doc line start ({doc_start}), got {offsets:?}" + ); + } + #[test] fn test_compute_marker_offsets_max_lines_split() { let text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"; @@ -1023,12 +1039,152 @@ mod tests { assert!(offsets.len() >= 3, "offsets: {:?}", offsets); } + #[test] + fn test_compute_marker_offsets_hard_cap_nudges_past_closer_to_case_line() { + let text = "a1\na2\na3\na4\na5\na6\na7\na8\n}\ncase 'x': {\nbody\n"; + let offsets = compute_marker_offsets(text); + + let expected = text.find("case 'x': {").expect("case line exists"); + assert!( + offsets.contains(&expected), + "expected nudged boundary at case line start ({expected}), got {offsets:?}" + ); + } + + #[test] + fn test_compute_marker_offsets_hard_cap_nudge_respects_max_forward_lines() { + let text = "a1\na2\na3\na4\na5\na6\na7\na8\n}\n}\n}\n}\n}\ncase 'x': {\nbody\n"; + let offsets = compute_marker_offsets(text); + + let case_start = text.find("case 'x': {").expect("case line exists"); + assert!( + !offsets.contains(&case_start), + "boundary should not nudge beyond max forward lines; offsets: {offsets:?}" + ); + } + + #[test] + fn test_compute_marker_offsets_stay_sorted_when_hard_cap_boundary_nudges_forward() { + let text = "\ +aaaaaaaaaa = 1; +bbbbbbbbbb = 2; +cccccccccc = 3; +dddddddddd = 4; +eeeeeeeeee = 5; +ffffffffff = 6; +gggggggggg = 7; +hhhhhhhhhh = 8; + }; + }; + + grafanaDashboards = { + cluster-overview.spec = { + inherit instanceSelector; + folderRef = \"infrastructure\"; + json = builtins.readFile ./grafana/dashboards/cluster-overview.json; + }; + }; +"; + let offsets = compute_marker_offsets(text); + + assert_eq!(offsets.first().copied(), Some(0), "offsets: {offsets:?}"); + assert_eq!( + offsets.last().copied(), + Some(text.len()), + "offsets: {offsets:?}" + ); + assert!( + offsets.windows(2).all(|window| window[0] <= window[1]), + "offsets must be sorted: {offsets:?}" + ); + } + #[test] fn test_compute_marker_offsets_empty() { let offsets = compute_marker_offsets(""); assert_eq!(offsets, vec![0, 0]); } + #[test] + fn test_compute_marker_offsets_avoid_short_markdown_blocks() { + let text = "\ +# Spree Posts + +This is a Posts extension for [Spree Commerce](https://spreecommerce.org), built with Ruby on Rails. + +## Installation + +1. Add this extension to your Gemfile with this line: + + ```ruby + bundle add spree_posts + ``` + +2. Run the install generator + + ```ruby + bundle exec rails g spree_posts:install + ``` + +3. Restart your server + + If your server was running, restart it so that it can find the assets properly. + +## Developing + +1. Create a dummy app + + ```bash + bundle update + bundle exec rake test_app + ``` + +2. Add your new code +3. Run tests + + ```bash + bundle exec rspec + ``` + +When testing your applications integration with this extension you may use it's factories. +Simply add this require statement to your spec_helper: + +```ruby +require 'spree_posts/factories' +``` + +## Releasing a new version + +```shell +bundle exec gem bump -p -t +bundle exec gem release +``` + +For more options please see [gem-release README](https://github.com/svenfuchs/gem-release) + +## Contributing + +If you'd like to contribute, please take a look at the contributing guide. +"; + let offsets = compute_marker_offsets(text); + + assert_eq!(offsets.first().copied(), Some(0), "offsets: {offsets:?}"); + assert_eq!( + offsets.last().copied(), + Some(text.len()), + "offsets: {offsets:?}" + ); + + for window in offsets.windows(2) { + let block = &text[window[0]..window[1]]; + let line_count = block.lines().count(); + assert!( + line_count >= V0316_MIN_BLOCK_LINES, + "block too short: {line_count} lines in block {block:?} with offsets {offsets:?}" + ); + } + } + #[test] fn test_extract_marker_span() { let text = "<|marker_2|>\n new content\n<|marker_3|>\n"; @@ -1189,10 +1345,8 @@ mod tests { let editable = "aaa\nbbb\nccc\n"; let mut output = String::new(); write_editable_with_markers_v0316(&mut output, editable, 4, "<|user_cursor|>"); - // Should have marker tags with no extra newlines assert!(output.starts_with("<|marker_1|>")); assert!(output.contains("<|user_cursor|>")); - // Content should be byte-exact - no extra newlines added by markers let stripped = output.replace("<|user_cursor|>", ""); let stripped = strip_marker_tags(&stripped); assert_eq!(stripped, editable); @@ -1232,11 +1386,9 @@ mod tests { marker_offsets ); - // Build output spanning all blocks with new content let new_content = "LINE1\nLINE2\nLINE3\n\nLINE5\nLINE6\nLINE7\nLINE8\n"; let mut output = String::new(); output.push_str("<|marker_1|>"); - // Split new_content at old block boundaries for i in 0..marker_offsets.len() - 1 { if i > 0 { output.push_str(&marker_tag(i + 1)); @@ -1244,7 +1396,6 @@ mod tests { let start = marker_offsets[i]; let end = marker_offsets[i + 1]; let block_len = end - start; - // Use same length blocks from new content (they happen to be same length) output.push_str(&new_content[start..start + block_len]); } let last_marker_num = marker_offsets.len(); @@ -1256,10 +1407,8 @@ mod tests { #[test] fn test_apply_marker_span_v0316_byte_exact_no_normalization() { let old = "aaa\nbbb\nccc\n"; - // Content doesn't end with \n - should NOT be normalized let output = "<|marker_1|>aaa\nBBB\nccc<|marker_2|>"; let result = apply_marker_span_v0316(old, output).unwrap(); - // V0316 is byte-exact: the missing trailing \n is NOT added assert_eq!(result, "aaa\nBBB\nccc"); } @@ -1268,9 +1417,7 @@ mod tests { let old = "aaa\nbbb\nccc\n"; let result = encode_from_old_and_new_v0316(old, old, Some(5), "<|user_cursor|>", "<|end|>").unwrap(); - // Should be <|marker_K|><|marker_K|><|end|> where K is nearest to cursor assert!(result.ends_with("<|end|>")); - // Parse it and verify it's a no-edit let stripped = result.strip_suffix("<|end|>").unwrap(); let result_parsed = apply_marker_span_v0316(old, stripped).unwrap(); assert_eq!(result_parsed, old); @@ -1412,4 +1559,95 @@ mod tests { assert!(result.contains("<|user_cursor|>"), "result: {result}"); assert!(result.contains("<|marker-0|>"), "result: {result}"); } + + #[test] + fn test_compute_marker_offsets_v0318_uses_larger_block_sizes() { + let text = "l1\nl2\nl3\n\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\n"; + let v0316_offsets = compute_marker_offsets(text); + let v0318_offsets = compute_marker_offsets_v0318(text); + + assert!(v0318_offsets.len() < v0316_offsets.len()); + assert_eq!(v0316_offsets.first().copied(), Some(0)); + assert_eq!(v0318_offsets.first().copied(), Some(0)); + assert_eq!(v0316_offsets.last().copied(), Some(text.len())); + assert_eq!(v0318_offsets.last().copied(), Some(text.len())); + } + + #[test] + fn test_roundtrip_v0318() { + let old = "line1\nline2\nline3\n\nline5\nline6\nline7\nline8\nline9\nline10\n"; + let new = "line1\nline2\nline3\n\nline5\nLINE6\nline7\nline8\nline9\nline10\n"; + let encoded = + encode_from_old_and_new_v0318(old, new, None, "<|user_cursor|>", "<|end|>").unwrap(); + let stripped = encoded + .strip_suffix("<|end|>") + .expect("should have end marker"); + let reconstructed = apply_marker_span_v0318(old, stripped).unwrap(); + assert_eq!(reconstructed, new); + } + + #[test] + fn test_encode_v0317_markers_stay_on_line_boundaries() { + let old = "\ +\t\t\t\tcontinue outer; +\t\t\t} +\t\t} +\t} + +\tconst intersectionObserver = new IntersectionObserver((entries) => { +\t\tfor (const entry of entries) { +\t\t\tif (entry.isIntersecting) { +\t\t\t\tintersectionObserver.unobserve(entry.target); +\t\t\t\tanchorPreload(/** @type {HTMLAnchorElement} */ (entry.target)); +\t\t\t} +\t\t} +\t}); + +\tconst observer = new MutationObserver(() => { +\t\tconst links = /** @type {NodeListOf} */ ( +\t\t\tdocument.querySelectorAll('a[data-preload]') +\t\t); + +\t\tfor (const link of links) { +\t\t\tif (linkSet.has(link)) continue; +\t\t\tlinkSet.add(link); + +\t\t\tswitch (link.dataset.preload) { +\t\t\t\tcase '': +\t\t\t\tcase 'true': +\t\t\t\tcase 'hover': { +\t\t\t\t\tlink.addEventListener('mouseenter', function callback() { +\t\t\t\t\t\tlink.removeEventListener('mouseenter', callback); +\t\t\t\t\t\tanchorPreload(link); +\t\t\t\t\t}); +"; + let new = old.replacen( + "\t\t\t\tcase 'true':\n", + "\t\t\t\tcase 'TRUE':<|user_cursor|>\n", + 1, + ); + + let cursor_offset = new.find("<|user_cursor|>").expect("cursor marker in new"); + let new_without_cursor = new.replace("<|user_cursor|>", ""); + + let encoded = encode_from_old_and_new_v0317( + old, + &new_without_cursor, + Some(cursor_offset), + "<|user_cursor|>", + "<|end|>", + ) + .unwrap(); + + let core = encoded.strip_suffix("<|end|>").unwrap_or(&encoded); + for marker in collect_relative_marker_tags(core) { + let tag_start = marker.tag_start; + assert!( + tag_start == 0 || core.as_bytes()[tag_start - 1] == b'\n', + "marker not at line boundary: {} in output:\n{}", + marker_tag_relative(marker.value), + core + ); + } + } } diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 2cc5322db0ce5b2e11002f06c036832357199d97..3ec90baf6e7d7781b5ddedb0af3dbdb0994cb3ad 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -91,6 +91,8 @@ pub enum ZetaFormat { V0306SeedMultiRegions, /// Byte-exact marker spans; all intermediate markers emitted; repeated marker means no-edit. V0316SeedMultiRegions, + /// V0316 with larger block sizes. + V0318SeedMultiRegions, /// V0316, but marker numbers are relative to the cursor block (e.g. -1, -0, +1). V0317SeedMultiRegions, } @@ -242,6 +244,18 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ]; TOKENS } + ZetaFormat::V0318SeedMultiRegions => { + static TOKENS: &[&str] = &[ + seed_coder::FIM_SUFFIX, + seed_coder::FIM_PREFIX, + seed_coder::FIM_MIDDLE, + seed_coder::FILE_MARKER, + multi_region::V0318_END_MARKER, + CURSOR_MARKER, + multi_region::MARKER_TAG_PREFIX, + ]; + TOKENS + } ZetaFormat::V0317SeedMultiRegions => { static TOKENS: &[&str] = &[ seed_coder::FIM_SUFFIX, @@ -283,6 +297,7 @@ pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) { | ZetaFormat::v0226Hashline | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions | ZetaFormat::V0304SeedNoEdits => (350, 150), ZetaFormat::V0304VariableEdit => (1024, 0), @@ -303,6 +318,7 @@ pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0304SeedNoEdits => &[], ZetaFormat::V0316SeedMultiRegions => &[multi_region::V0316_END_MARKER], + ZetaFormat::V0318SeedMultiRegions => &[multi_region::V0318_END_MARKER], ZetaFormat::V0317SeedMultiRegions => &[multi_region::V0317_END_MARKER], } } @@ -328,6 +344,7 @@ pub fn excerpt_ranges_for_format( | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions => ( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), @@ -419,6 +436,14 @@ pub fn write_cursor_excerpt_section_for_format( cursor_offset, )); } + ZetaFormat::V0318SeedMultiRegions => { + prompt.push_str(&build_v0318_cursor_prefix( + path, + context, + editable_range, + cursor_offset, + )); + } ZetaFormat::V0317SeedMultiRegions => { prompt.push_str(&build_v0317_cursor_prefix( path, @@ -486,6 +511,33 @@ fn build_v0316_cursor_prefix( section } +fn build_v0318_cursor_prefix( + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, +) -> String { + let mut section = String::new(); + let path_str = path.to_string_lossy(); + write!(section, "{}{}\n", seed_coder::FILE_MARKER, path_str).ok(); + + section.push_str(&context[..editable_range.start]); + + let editable_text = &context[editable_range.clone()]; + let cursor_in_editable = cursor_offset - editable_range.start; + multi_region::write_editable_with_markers_v0318( + &mut section, + editable_text, + cursor_in_editable, + CURSOR_MARKER, + ); + + if !section.ends_with('\n') { + section.push('\n'); + } + section +} + fn build_v0317_cursor_prefix( path: &Path, context: &str, @@ -551,6 +603,7 @@ pub fn format_prompt_with_budget_for_format( | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions => { let mut cursor_section = String::new(); write_cursor_excerpt_section_for_format( @@ -649,6 +702,7 @@ pub fn max_edit_event_count_for_format(format: &ZetaFormat) -> usize { | ZetaFormat::V0304VariableEdit | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions => 6, } } @@ -671,6 +725,7 @@ pub fn get_prefill_for_format( ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions | ZetaFormat::V0316SeedMultiRegions + | ZetaFormat::V0318SeedMultiRegions | ZetaFormat::V0317SeedMultiRegions => String::new(), } } @@ -684,6 +739,7 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> | ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => Some(seed_coder::END_MARKER), ZetaFormat::V0316SeedMultiRegions => Some(multi_region::V0316_END_MARKER), + ZetaFormat::V0318SeedMultiRegions => Some(multi_region::V0318_END_MARKER), ZetaFormat::V0317SeedMultiRegions => Some(multi_region::V0317_END_MARKER), ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered @@ -727,6 +783,22 @@ pub fn encode_patch_as_output_for_format( Ok(None) } } + ZetaFormat::V0318SeedMultiRegions => { + let empty_patch = patch.lines().count() <= 3; + if empty_patch { + let marker_offsets = + multi_region::compute_marker_offsets_v0318(old_editable_region); + let marker_num = + multi_region::nearest_marker_number(cursor_offset, &marker_offsets); + let tag = multi_region::marker_tag(marker_num); + Ok(Some(format!( + "{tag}{tag}{}", + multi_region::V0318_END_MARKER + ))) + } else { + Ok(None) + } + } ZetaFormat::V0317SeedMultiRegions => { let empty_patch = patch.lines().count() <= 3; if empty_patch { @@ -797,6 +869,10 @@ pub fn parse_zeta2_model_output( editable_range_in_context, multi_region::apply_marker_span_v0316(old_editable_region, output)?, ), + ZetaFormat::V0318SeedMultiRegions => ( + editable_range_in_context, + multi_region::apply_marker_span_v0318(old_editable_region, output)?, + ), ZetaFormat::V0317SeedMultiRegions => ( editable_range_in_context, multi_region::apply_marker_span_v0317( From b1e8473723c8bd30fc6eddfc015ca0f35be6b016 Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Mon, 23 Mar 2026 14:40:18 +0530 Subject: [PATCH 14/46] git_ui: Support side-by-side diff view in clipboard selection diff (#51966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Context Switches `TextDiffView` from using `Editor` directly to `SplittableEditor`, enabling side-by-side diff view support for "Diff Clipboard with Selection". The diff view now respects the user's `diff_view_style` setting. Split out from #51457. This PR contains only the `SplittableEditor` wiring. The multibuffer coordinate fix for non-singleton editors will follow in a separate PR. Closes #50912 (partially) #### How to Review Small PR — all changes are in `crates/git_ui/src/text_diff_view.rs`. Focus on: - `new()`: `SplittableEditor::new` replaces `Editor::for_multibuffer`, editor-specific setup goes through `rhs_editor()` - Item trait delegation: `act_as_type`, `for_each_project_item`, `set_nav_history` updated for `SplittableEditor` - Tests: pinned `DiffViewStyle::Unified` and assertions go through `rhs_editor()` #### Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable #### Video : [Screencast from 2026-03-19 23-11-36.webm](https://github.com/user-attachments/assets/c5a2381d-238d-43ef-ac6f-9994996c0c69) #### Release Notes: - Improved "Diff Clipboard with Selection" to support side-by-side diff view style. --- crates/git_ui/src/text_diff_view.rs | 61 ++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index ec2d6d7813da5883f0a146ff47af5027c3b7f643..965f41030817d3b7434a6fd02fb3a2de18046823 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -2,7 +2,10 @@ use anyhow::Result; use buffer_diff::BufferDiff; -use editor::{Editor, EditorEvent, MultiBuffer, ToPoint, actions::DiffClipboardWithSelectionData}; +use editor::{ + Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint, + actions::DiffClipboardWithSelectionData, +}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, @@ -10,6 +13,7 @@ use gpui::{ }; use language::{self, Buffer, Point}; use project::Project; +use settings::Settings; use std::{ any::{Any, TypeId}, cmp, @@ -22,13 +26,13 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString}; use util::paths::PathExt; use workspace::{ - Item, ItemHandle as _, ItemNavHistory, Workspace, + Item, ItemNavHistory, Workspace, item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; pub struct TextDiffView { - diff_editor: Entity, + diff_editor: Entity, title: SharedString, path: Option, buffer_changes_tx: watch::Sender<()>, @@ -125,11 +129,11 @@ impl TextDiffView { ); let task = window.spawn(cx, async move |cx| { - let project = workspace.update(cx, |workspace, _| workspace.project().clone())?; - update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?; workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + let workspace_entity = cx.entity(); let diff_view = cx.new(|cx| { TextDiffView::new( clipboard_buffer, @@ -138,6 +142,7 @@ impl TextDiffView { expanded_selection_range, diff_buffer, project, + workspace_entity, window, cx, ) @@ -162,6 +167,7 @@ impl TextDiffView { source_range: Range, diff_buffer: Entity, project: Entity, + workspace: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -174,15 +180,24 @@ impl TextDiffView { multibuffer }); let diff_editor = cx.new(|cx| { - let mut editor = Editor::for_multibuffer(multibuffer, Some(project), window, cx); - editor.start_temporary_diff_override(); - editor.disable_diagnostics(cx); - editor.set_expand_all_diff_hunks(cx); - editor.set_render_diff_hunk_controls( + let splittable = SplittableEditor::new( + EditorSettings::get_global(cx).diff_view_style, + multibuffer, + project, + workspace, + window, + cx, + ); + splittable.set_render_diff_hunk_controls( Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), cx, ); - editor + splittable.rhs_editor().update(cx, |editor, cx| { + editor.start_temporary_diff_override(); + editor.disable_diagnostics(cx); + editor.set_expand_all_diff_hunks(cx); + }); + splittable }); let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(()); @@ -352,12 +367,14 @@ impl Item for TextDiffView { &'a self, type_id: TypeId, self_handle: &'a Entity, - _: &'a App, + cx: &'a App, ) -> Option { if type_id == TypeId::of::() { Some(self_handle.clone().into()) - } else if type_id == TypeId::of::() { + } else if type_id == TypeId::of::() { Some(self.diff_editor.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.diff_editor.read(cx).rhs_editor().clone().into()) } else { None } @@ -372,7 +389,7 @@ impl Item for TextDiffView { cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { - self.diff_editor.for_each_project_item(cx, f) + self.diff_editor.read(cx).for_each_project_item(cx, f) } fn set_nav_history( @@ -381,7 +398,8 @@ impl Item for TextDiffView { _: &mut Window, cx: &mut Context, ) { - self.diff_editor.update(cx, |editor, _| { + let rhs = self.diff_editor.read(cx).rhs_editor().clone(); + rhs.update(cx, |editor, _| { editor.set_nav_history(Some(nav_history)); }); } @@ -463,11 +481,11 @@ impl Render for TextDiffView { mod tests { use super::*; use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff}; - use gpui::{TestAppContext, VisualContext}; + use gpui::{BorrowAppContext, TestAppContext, VisualContext}; use language::Point; use project::{FakeFs, Project}; use serde_json::json; - use settings::SettingsStore; + use settings::{DiffViewStyle, SettingsStore}; use unindent::unindent; use util::{path, test::marked_text_ranges}; use workspace::MultiWorkspace; @@ -476,6 +494,11 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Unified); + }); + }); theme::init(theme::LoadThemes::JustBase, cx); }); } @@ -918,7 +941,9 @@ mod tests { cx.executor().run_until_parked(); assert_state_with_diff( - &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()), + &diff_view.read_with(cx, |diff_view, cx| { + diff_view.diff_editor.read(cx).rhs_editor().clone() + }), cx, expected_diff, ); From 4b1a2f3ad80f7a9ebd49c6fb3bbaa07766efb08a Mon Sep 17 00:00:00 2001 From: Giorgi Merebashvili Date: Mon, 23 Mar 2026 13:12:00 +0400 Subject: [PATCH 15/46] search: Fix focus replacement field when opening replace (Ctrl+H) (#51061) Previously, focus stayed on the search bar because a pre-focus check `handle.is_focused(window)` was always false at deploy time. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed: When opening find-and-replace with `Ctrl+H`, the replacement input is now focused instead of the search bar. --- crates/search/src/buffer_search.rs | 45 +++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 2ad994244aee372dc829d199d6859a9234e2f56f..5381e47db092fb65ca3cdb844987c6714ca4cd76 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -976,7 +976,9 @@ impl BufferSearchBar { if deploy.focus { let mut handle = self.query_editor.focus_handle(cx); let mut select_query = true; - if deploy.replace_enabled && handle.is_focused(window) { + + let has_seed_text = self.query_suggestion(window, cx).is_some(); + if deploy.replace_enabled && has_seed_text { handle = self.replacement_editor.focus_handle(cx); select_query = false; }; @@ -3188,6 +3190,47 @@ mod tests { .await; } + #[gpui::test] + async fn test_deploy_replace_focuses_replacement_editor(cx: &mut TestAppContext) { + init_globals(cx); + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 8)..DisplayPoint::new(DisplayRow(0), 16) + ]) + }); + }); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.deploy( + &Deploy { + focus: true, + replace_enabled: true, + selection_search_enabled: false, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + search_bar.update_in(cx, |search_bar, window, cx| { + assert!( + search_bar + .replacement_editor + .focus_handle(cx) + .is_focused(window), + "replacement editor should be focused when deploying replace with a selection", + ); + assert!( + !search_bar.query_editor.focus_handle(cx).is_focused(window), + "search editor should not be focused when replacement editor is focused", + ); + }); + } + #[perf] #[gpui::test] async fn test_find_matches_in_selections_singleton_buffer_multiple_selections( From 6f2e4b02cb61241236e220fe750a90f636261dd5 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 23 Mar 2026 11:22:18 +0200 Subject: [PATCH 16/46] ep: Store cumulative and average log-probabilities of predictions (#52177) For now, the only source of logprobs is `./run.py infer`, which writes them to under the `.predictions` struct. `ep score` copies these values to `.score`. There is some duplication (same value stored in two places), which is unfortunate, but can't be fixed without reworking how scores are stored. Release Notes: - N/A --- crates/edit_prediction_cli/src/example.rs | 8 ++++++++ crates/edit_prediction_cli/src/predict.rs | 8 ++++++++ crates/edit_prediction_cli/src/repair.rs | 2 ++ crates/edit_prediction_cli/src/score.rs | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 495ca26f97af5f2c2c1dc50ea339881853d9ebbc..196f4f96d99b64aed2ff3ae2d7a9897295a60b29 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -82,6 +82,10 @@ pub struct ExamplePrediction { #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, pub provider: PredictionProvider, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cumulative_logprob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avg_logprob: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -166,6 +170,10 @@ pub struct ExampleScore { pub inserted_tokens: usize, #[serde(default)] pub deleted_tokens: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cumulative_logprob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avg_logprob: Option, } impl Example { diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 9f70861b5ef7298141441ec09606fa77e341cbfd..df797b0abaa4933e73e40b746797ffb5581d7f79 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -263,6 +263,8 @@ pub async fn run_prediction( actual_cursor: None, error: None, provider, + cumulative_logprob: None, + avg_logprob: None, }); step_progress.set_substatus("requesting prediction"); @@ -455,6 +457,8 @@ async fn predict_anthropic( _ => PredictionProvider::TeacherNonBatching(backend), } }, + cumulative_logprob: None, + avg_logprob: None, }; example.predictions.push(prediction); @@ -572,6 +576,8 @@ async fn predict_openai( _ => PredictionProvider::TeacherNonBatching(backend), } }, + cumulative_logprob: None, + avg_logprob: None, }; example.predictions.push(prediction); @@ -656,6 +662,8 @@ pub async fn predict_baseten( actual_cursor, error: None, provider: PredictionProvider::Baseten(format), + cumulative_logprob: None, + avg_logprob: None, }; example.predictions.push(prediction); diff --git a/crates/edit_prediction_cli/src/repair.rs b/crates/edit_prediction_cli/src/repair.rs index 9d891314bc62a44e730b584cea3423df665dc381..a0c4242748c9ad83c3b0fbe9e70a4b132ac75c4d 100644 --- a/crates/edit_prediction_cli/src/repair.rs +++ b/crates/edit_prediction_cli/src/repair.rs @@ -426,6 +426,8 @@ pub async fn run_repair( actual_cursor, error: err, provider: PredictionProvider::Repair, + cumulative_logprob: None, + avg_logprob: None, }); Ok(()) diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index b6f745114f6dd2a091b95b724ee53869a04a8c4e..d75cf55e85b198bc28469e83d8f9209a8a59a83f 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -78,6 +78,8 @@ pub async fn run_scoring( has_isolated_whitespace_changes: false, inserted_tokens: 0, deleted_tokens: 0, + cumulative_logprob: None, + avg_logprob: None, }; let cursor_path = example.spec.cursor_path.as_ref(); @@ -189,6 +191,8 @@ pub async fn run_scoring( has_isolated_whitespace_changes, inserted_tokens: token_changes.inserted_tokens, deleted_tokens: token_changes.deleted_tokens, + cumulative_logprob: prediction.cumulative_logprob, + avg_logprob: prediction.avg_logprob, }); } From 70d1940462127ebd02ce8ba7736a1c141e357b88 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 23 Mar 2026 10:34:42 +0100 Subject: [PATCH 17/46] agent_ui: Remove duplicated function from agent panel (#52179) Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 36 ++++++++++-------------------- crates/sidebar/src/sidebar.rs | 8 +++---- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ddee8e8d43839b4fea0aa35b9fcfedf3fc6f9673..3724f738827e747356ebd58e6834237d2ef48c50 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -222,7 +222,7 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAgentDiff, window, cx| { let thread = workspace .panel::(cx) - .and_then(|panel| panel.read(cx).active_conversation().cloned()) + .and_then(|panel| panel.read(cx).active_conversation_view().cloned()) .and_then(|conversation| { conversation .read(cx) @@ -1188,18 +1188,6 @@ impl AgentPanel { .unwrap_or(false) } - pub fn active_conversation(&self) -> Option<&Entity> { - match &self.active_view { - ActiveView::AgentThread { - conversation_view, .. - } => Some(conversation_view), - ActiveView::Uninitialized - | ActiveView::TextThread { .. } - | ActiveView::History { .. } - | ActiveView::Configuration => None, - } - } - pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { self.new_agent_thread(AgentType::NativeAgent, window, cx); } @@ -1411,7 +1399,7 @@ impl AgentPanel { } fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(conversation_view) = self.active_conversation() else { + let Some(conversation_view) = self.active_conversation_view() else { return; }; @@ -1737,7 +1725,7 @@ impl AgentPanel { cx: &mut Context, ) { if let Some(workspace) = self.workspace.upgrade() - && let Some(conversation_view) = self.active_conversation() + && let Some(conversation_view) = self.active_conversation_view() && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() { active_thread.update(cx, |thread, cx| { @@ -2542,7 +2530,7 @@ impl AgentPanel { } pub fn active_thread_is_draft(&self, cx: &App) -> bool { - self.active_conversation().is_some() && !self.active_thread_has_messages(cx) + self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx) } fn handle_first_send_requested( @@ -3936,7 +3924,7 @@ impl AgentPanel { }; let is_thread_loading = self - .active_conversation() + .active_conversation_view() .map(|thread| thread.read(cx).is_loading()) .unwrap_or(false); @@ -4601,7 +4589,7 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { - if let Some(conversation_view) = this.active_conversation() { + if let Some(conversation_view) = this.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { conversation_view.reauthenticate(window, cx) }) @@ -4797,7 +4785,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(conversation_view) = panel.active_conversation() { + if let Some(conversation_view) = panel.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { conversation_view.insert_selections(window, cx); }); @@ -4835,7 +4823,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(conversation_view) = panel.active_conversation() { + if let Some(conversation_view) = panel.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { conversation_view.insert_terminal_text(text, window, cx); }); @@ -4901,7 +4889,7 @@ impl AgentPanel { /// This is a test-only accessor that exposes the private `active_thread_view()` /// method for test assertions. Not compiled into production builds. pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { - self.active_conversation() + self.active_conversation_view() } /// Sets the start_thread_in value directly, bypassing validation. @@ -5091,7 +5079,7 @@ mod tests { "workspace A agent type should be restored" ); assert!( - panel.active_conversation().is_some(), + panel.active_conversation_view().is_some(), "workspace A should have its active thread restored" ); }); @@ -5111,7 +5099,7 @@ mod tests { "workspace B agent type should be restored" ); assert!( - panel.active_conversation().is_none(), + panel.active_conversation_view().is_none(), "workspace B should have no active thread" ); }); @@ -5563,7 +5551,7 @@ mod tests { send_message(&panel, &mut cx); let weak_view_a = panel.read_with(&cx, |panel, _cx| { - panel.active_conversation().unwrap().downgrade() + panel.active_conversation_view().unwrap().downgrade() }); let session_id_a = active_session_id(&panel, &cx); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 9761c9f0ad835cf4cc103700c5f70b715f1b9427..9d979ffde2a56b7bfaec3c89597eb6cfa2c95c9f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -432,7 +432,7 @@ impl Sidebar { AgentPanelEvent::ActiveViewChanged => { let is_new_draft = agent_panel .read(cx) - .active_conversation() + .active_conversation_view() .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none()); if is_new_draft { this.focused_thread = None; @@ -483,7 +483,7 @@ impl Sidebar { ws.read(cx).panel::(cx) }) .and_then(|panel| { - let cv = panel.read(cx).active_conversation()?; + let cv = panel.read(cx).active_conversation_view()?; let tv = cv.read(cx).active_thread()?; Some(tv.read(cx).message_editor.clone()) }) @@ -498,7 +498,7 @@ impl Sidebar { let mw = self.multi_workspace.upgrade()?; let workspace = mw.read(cx).workspace(); let panel = workspace.read(cx).panel::(cx)?; - let conversation_view = panel.read(cx).active_conversation()?; + let conversation_view = panel.read(cx).active_conversation_view()?; let thread_view = conversation_view.read(cx).active_thread()?; let raw = thread_view.read(cx).message_editor.read(cx).text(cx); let cleaned = Self::clean_mention_links(&raw); @@ -629,7 +629,7 @@ impl Sidebar { .and_then(|panel| { panel .read(cx) - .active_conversation() + .active_conversation_view() .and_then(|cv| cv.read(cx).parent_id(cx)) }); if panel_focused.is_some() && !self.active_thread_is_draft { From cf4848d74af75682bdbf05d515913ceea9a9919a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 23 Mar 2026 10:53:21 +0100 Subject: [PATCH 18/46] agent_ui: Focus prompt editor when clicking start in git worktree (#52181) Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 3724f738827e747356ebd58e6834237d2ef48c50..15dffbae160779508f7aa2a7c2bd79b7fa6a2226 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -404,17 +404,17 @@ pub fn init(cx: &mut App) { }); }, ) - .register_action(|workspace, action: &StartThreadIn, _window, cx| { + .register_action(|workspace, action: &StartThreadIn, window, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_start_thread_in(action, cx); + panel.set_start_thread_in(action, window, cx); }); } }) - .register_action(|workspace, _: &CycleStartThreadIn, _window, cx| { + .register_action(|workspace, _: &CycleStartThreadIn, window, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.cycle_start_thread_in(cx); + panel.cycle_start_thread_in(window, cx); }); } }); @@ -2251,7 +2251,12 @@ impl AgentPanel { &self.start_thread_in } - fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context) { + fn set_start_thread_in( + &mut self, + action: &StartThreadIn, + window: &mut Window, + cx: &mut Context, + ) { if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::() { return; } @@ -2273,16 +2278,19 @@ impl AgentPanel { } }; self.start_thread_in = new_target; + if let Some(thread) = self.active_thread_view(cx) { + thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx)); + } self.serialize(cx); cx.notify(); } - fn cycle_start_thread_in(&mut self, cx: &mut Context) { + fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context) { let next = match self.start_thread_in { StartThreadIn::LocalProject => StartThreadIn::NewWorktree, StartThreadIn::NewWorktree => StartThreadIn::LocalProject, }; - self.set_start_thread_in(&next, cx); + self.set_start_thread_in(&next, window, cx); } fn reset_start_thread_in_to_default(&mut self, cx: &mut Context) { @@ -5958,8 +5966,8 @@ mod tests { }); // Change thread target to NewWorktree. - panel.update(cx, |panel, cx| { - panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + panel.update_in(cx, |panel, window, cx| { + panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx); }); panel.read_with(cx, |panel, _cx| { @@ -6181,11 +6189,11 @@ mod tests { // Set the selected agent to Codex (a custom agent) and start_thread_in // to NewWorktree. We do this AFTER opening the thread because // open_external_thread_with_server overrides selected_agent_type. - panel.update(cx, |panel, cx| { + panel.update_in(cx, |panel, window, cx| { panel.selected_agent_type = AgentType::Custom { id: CODEX_ID.into(), }; - panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx); }); // Verify the panel has the Codex agent selected. From 4466d10df3a6a2546c9502e211c9612d58346970 Mon Sep 17 00:00:00 2001 From: Suphachai Phetthamrong <54477794+monkey-mode@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:58:32 +0700 Subject: [PATCH 19/46] agent_ui: Fix pasted image context showing Image instead of actual filename (#52082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fix image context mentions always showing the generic label `Image` instead of the actual filename when pasting from Finder or picking via the `+` → Image button in the Agent Panel. ## Why `insert_images_as_context` hardcoded the crease label to `MentionUri::PastedImage.name()` (`"Image"`) for every image, regardless of whether it originated from a named file. Both code paths that load images from file paths — `paste_images_as_context` and `add_images_from_picker` — discarded the filename before passing images to the shared insert function. ## Fix - `agent_ui/src/mention_set.rs`: Changed `insert_images_as_context` to accept `Vec<(gpui::Image, SharedString)>` instead of `Vec`, using the provided name as the crease label. In `paste_images_as_context`, extract `file_name()` from each path and pair it with the loaded image. Raw clipboard images (screenshots, copy from image editors) continue to use `"Image"` as there is no filename. - `agent_ui/src/message_editor.rs`: Same fix for `add_images_from_picker` — extract `file_name()` from each selected path and pass it alongside the image. Closes #52079 ## Test Plan - [x] `cargo build -p agent_ui` compiles clean - [x] `cargo fmt --all -- --check` format check - [x] Manual verification of: - [x] Copy an image file in Finder (`Cmd+C`), paste into Agent Panel — mention shows actual filename - [x] `+` → Image → pick a file — mention shows actual filename - [x] Screenshot paste (`Cmd+Shift+4`) still shows `Image` - [x] Regular text paste still works ## Screenshots image --- Release Notes: - Fixed image context mentions always showing `Image` instead of the actual filename when pasting from Finder or using the image picker in the Agent Panel --- crates/agent_ui/src/mention_set.rs | 45 ++++++++++++++++----------- crates/agent_ui/src/message_editor.rs | 7 ++++- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 877fb1eb6d9b5dea47393af63776e3eeca0668e5..c0aee0fc323977d9aa2822b592db0621c7061bba 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -739,7 +739,7 @@ mod tests { /// Inserts a list of images into the editor as context mentions. /// This is the shared implementation used by both paste and file picker operations. pub(crate) async fn insert_images_as_context( - images: Vec, + images: Vec<(gpui::Image, SharedString)>, editor: Entity, mention_set: Entity, workspace: WeakEntity, @@ -751,7 +751,7 @@ pub(crate) async fn insert_images_as_context( let replacement_text = MentionUri::PastedImage.as_link().to_string(); - for image in images { + for (image, name) in images { let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor .update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); @@ -785,7 +785,7 @@ pub(crate) async fn insert_images_as_context( excerpt_id, text_anchor, content_len, - MentionUri::PastedImage.name().into(), + name.clone(), IconName::Image.path().into(), None, None, @@ -856,10 +856,11 @@ pub(crate) fn paste_images_as_context( Some(window.spawn(cx, async move |mut cx| { use itertools::Itertools; - let (mut images, paths) = clipboard + let default_name: SharedString = MentionUri::PastedImage.name().into(); + let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard .into_entries() .filter_map(|entry| match entry { - ClipboardEntry::Image(image) => Some(Either::Left(image)), + ClipboardEntry::Image(image) => Some(Either::Left((image, default_name.clone()))), ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)), _ => None, }) @@ -870,24 +871,32 @@ pub(crate) fn paste_images_as_context( cx.background_spawn(async move { let mut images = vec![]; for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { - let Ok(content) = async_fs::read(path).await else { + let Ok(content) = async_fs::read(&path).await else { continue; }; let Ok(format) = image::guess_format(&content) else { continue; }; - images.push(gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, + let name: SharedString = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| SharedString::from(s.to_owned())) + .unwrap_or_else(|| default_name.clone()); + images.push(( + gpui::Image::from_bytes( + match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, + _ => continue, + }, + content, + ), + name, )); } images diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 646058fe488dbdd14b78e466cf53734e81a7712c..993d52640d1449b623dc47f2af4b3155d202448e 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -1366,7 +1366,12 @@ impl MessageEditor { continue; }; - images.push(gpui::Image::from_bytes(format, content)); + let name: gpui::SharedString = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| gpui::SharedString::from(s.to_owned())) + .unwrap_or_else(|| "Image".into()); + images.push((gpui::Image::from_bytes(format, content), name)); } crate::mention_set::insert_images_as_context( From 71667cfcb7a7fdf18ea9c80facb3e56566e1178a Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 23 Mar 2026 12:05:15 +0100 Subject: [PATCH 20/46] sidebar: Fix workspace and project leaking on window close (#52169) Release Notes: - n/a --- crates/sidebar/src/sidebar.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 9d979ffde2a56b7bfaec3c89597eb6cfa2c95c9f..62c032406cdd9d741c58e480f97dbfe3e445781d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -449,16 +449,19 @@ impl Sidebar { } fn observe_docks(&mut self, workspace: &Entity, cx: &mut Context) { - let workspace = workspace.clone(); let docks: Vec<_> = workspace .read(cx) .all_docks() .into_iter() .cloned() .collect(); + let workspace = workspace.downgrade(); for dock in docks { let workspace = workspace.clone(); cx.observe(&dock, move |this, _dock, cx| { + let Some(workspace) = workspace.upgrade() else { + return; + }; if !this.is_active_workspace(&workspace, cx) { return; } @@ -3361,6 +3364,27 @@ mod tests { assert_eq!(Sidebar::clean_mention_links(""), ""); } + #[gpui::test] + async fn test_entities_released_on_window_close(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); + let weak_sidebar = sidebar.downgrade(); + let weak_multi_workspace = multi_workspace.downgrade(); + + drop(sidebar); + drop(multi_workspace); + cx.update(|window, _cx| window.remove_window()); + cx.run_until_parked(); + + weak_multi_workspace.assert_released(); + weak_sidebar.assert_released(); + weak_workspace.assert_released(); + } + #[gpui::test] async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; From f5e56b5ccb3c1c0c538e16654e9c16ac1c1fabb2 Mon Sep 17 00:00:00 2001 From: Dino Date: Mon, 23 Mar 2026 11:09:54 +0000 Subject: [PATCH 21/46] helix: Add support for line length in reflow command (#52152) * Add `editor::RewrapOptions::line_length` to, optionally, override the line length used when rewrapping text. * Update `editor::Editor::rewrap_impl` to prefer `editor::RewrapOptions::line_length`, when set. * Add a `line_length` field to the `vim::rewrap::Rewrap` action. * Update the `:reflow` vim command with `vim::command::VimCommand::args` so as to be able to parse the provided argument as `usize`, ensuring that no effect is taken if the argument can't be parsed as such. Release Notes: - N/A --- crates/editor/src/editor.rs | 4 ++- crates/git_ui/src/git_panel.rs | 1 + crates/vim/src/command.rs | 61 +++++++++++++++++++++++++++++----- crates/vim/src/rewrap.rs | 20 ++++++----- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a1135f7ad6a4b1153148da4013438190f7e765ab..3fb832b3ac7b2ebb6aa3f47b2dcd590d7eb081df 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1869,6 +1869,7 @@ pub enum MultibufferSelectionMode { pub struct RewrapOptions { pub override_language_settings: bool, pub preserve_existing_whitespace: bool, + pub line_length: Option, } impl Editor { @@ -5150,6 +5151,7 @@ impl Editor { RewrapOptions { override_language_settings: true, preserve_existing_whitespace: true, + line_length: None, }, cx, ) @@ -13721,7 +13723,7 @@ impl Editor { continue; }; - let wrap_column = self.hard_wrap.unwrap_or_else(|| { + let wrap_column = options.line_length.or(self.hard_wrap).unwrap_or_else(|| { buffer .language_settings_at(Point::new(start_row, 0), cx) .preferred_line_length as usize diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3615a447d231ce741e38b677366da19ea1a93e84..1fc4813157f8e64a4e51cc570b906b3a2d456002 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2276,6 +2276,7 @@ impl GitPanel { RewrapOptions { override_language_settings: false, preserve_existing_whitespace: true, + line_length: None, }, cx, ); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index d185c1c0670212ffd683e79849415f479fa03b58..5e6337adda0325f6d7e5249da1a04c3317a3c051 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1726,7 +1726,15 @@ fn generate_commands(_: &App) -> Vec { ) .range(wrap_count), VimCommand::new(("j", "oin"), JoinLines).range(select_range), - VimCommand::new(("reflow", ""), Rewrap).range(select_range), + VimCommand::new(("reflow", ""), Rewrap { line_length: None }) + .range(select_range) + .args(|_action, args| { + args.parse::().map_or(None, |length| { + Some(Box::new(Rewrap { + line_length: Some(length), + })) + }) + }), VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range), VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines) .bang(editor::actions::UnfoldRecursive) @@ -3550,7 +3558,7 @@ mod test { cx.set_state( indoc! {" - ˇ0123456789 0123456789 0123456789 0123456789 + ˇ0123456789 0123456789 "}, Mode::Normal, ); @@ -3560,8 +3568,6 @@ mod test { cx.assert_state( indoc! {" - 0123456789 - 0123456789 0123456789 ˇ0123456789 "}, @@ -3570,22 +3576,59 @@ mod test { cx.set_state( indoc! {" - «0123456789 0123456789ˇ» - 0123456789 0123456789 + ˇ0123456789 0123456789 "}, Mode::VisualLine, ); - cx.simulate_keystrokes(": reflow"); + cx.simulate_keystrokes("shift-v : reflow"); cx.simulate_keystrokes("enter"); cx.assert_state( indoc! {" - ˇ0123456789 0123456789 - 0123456789 0123456789 + ˇ0123456789 "}, Mode::Normal, ); + + cx.set_state( + indoc! {" + ˇ0123 4567 0123 4567 + "}, + Mode::VisualLine, + ); + + cx.simulate_keystrokes(": reflow space 7"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇ0123 + 4567 + 0123 + 4567 + "}, + Mode::Normal, + ); + + // Assert that, if `:reflow` is invoked with an invalid argument, it + // does not actually have any effect in the buffer's contents. + cx.set_state( + indoc! {" + ˇ0123 4567 0123 4567 + "}, + Mode::VisualLine, + ); + + cx.simulate_keystrokes(": reflow space a"); + cx.simulate_keystrokes("enter"); + + cx.assert_state( + indoc! {" + ˇ0123 4567 0123 4567 + "}, + Mode::VisualLine, + ); } } diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index 3cb7d66116023d979d83e04a00b974fdd2a6d078..208bbfc7e6b37bb5b3ec2a8f53aaa191d79444bd 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -1,19 +1,20 @@ use crate::{Vim, motion::Motion, object::Object, state::Mode}; use collections::HashMap; use editor::{Bias, Editor, RewrapOptions, SelectionEffects, display_map::ToDisplayPoint}; -use gpui::{Context, Window, actions}; +use gpui::{Action, Context, Window}; use language::SelectionGoal; +use schemars::JsonSchema; +use serde::Deserialize; -actions!( - vim, - [ - /// Rewraps the selected text to fit within the line width. - Rewrap - ] -); +/// Rewraps the selected text to fit within the line width. +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)] +#[action(namespace = vim)] +pub(crate) struct Rewrap { + pub line_length: Option, +} pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { - Vim::action(editor, cx, |vim, _: &Rewrap, window, cx| { + Vim::action(editor, cx, |vim, action: &Rewrap, window, cx| { vim.record_current_action(cx); Vim::take_count(cx); Vim::take_forced_motion(cx); @@ -24,6 +25,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { editor.rewrap_impl( RewrapOptions { override_language_settings: true, + line_length: action.line_length, ..Default::default() }, cx, From 968a13d22b2420a836a926bed554bdb569ff653b Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 23 Mar 2026 13:19:20 +0200 Subject: [PATCH 22/46] ep: Fix prompt formatting bug (#52187) This bug was causing malformed `expected_output` for ~5% of v0316..v0318 examples Release Notes: - N/A --- crates/zeta_prompt/src/multi_region.rs | 42 ++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs index 0514b8fd9c3e3fe4887ed57c27600e93f0df497a..a2e50ca445998672a169f4220d13eb4c13a22e8b 100644 --- a/crates/zeta_prompt/src/multi_region.rs +++ b/crates/zeta_prompt/src/multi_region.rs @@ -792,15 +792,23 @@ fn encode_from_old_and_new_impl( common_prefix_suffix(old_editable.as_bytes(), new_editable.as_bytes()); let change_end_in_old = old_editable.len() - common_suffix; - let start_marker_idx = marker_offsets + let mut start_marker_idx = marker_offsets .iter() .rposition(|&offset| offset <= common_prefix) .unwrap_or(0); - let end_marker_idx = marker_offsets + let mut end_marker_idx = marker_offsets .iter() .position(|&offset| offset >= change_end_in_old) .unwrap_or(marker_offsets.len() - 1); + if start_marker_idx == end_marker_idx { + if end_marker_idx < marker_offsets.len().saturating_sub(1) { + end_marker_idx += 1; + } else if start_marker_idx > 0 { + start_marker_idx -= 1; + } + } + let old_start = marker_offsets[start_marker_idx]; let old_end = marker_offsets[end_marker_idx]; @@ -1586,6 +1594,36 @@ If you'd like to contribute, please take a look at the contributing guide. assert_eq!(reconstructed, new); } + #[test] + fn test_roundtrip_v0318_append_at_end_of_editable_region() { + let old = "line1\nline2\nline3\n"; + let new = "line1\nline2\nline3\nline4\n"; + let encoded = + encode_from_old_and_new_v0318(old, new, None, "<|user_cursor|>", "<|end|>").unwrap(); + + assert_ne!(encoded, "<|marker_2|><|end|>"); + + let stripped = encoded + .strip_suffix("<|end|>") + .expect("should have end marker"); + let reconstructed = apply_marker_span_v0318(old, stripped).unwrap(); + assert_eq!(reconstructed, new); + } + + #[test] + fn test_roundtrip_v0318_insert_at_internal_marker_boundary() { + let old = "alpha\nbeta\n\ngamma\ndelta\n"; + let new = "alpha\nbeta\n\ninserted\ngamma\ndelta\n"; + let encoded = + encode_from_old_and_new_v0318(old, new, None, "<|user_cursor|>", "<|end|>").unwrap(); + + let stripped = encoded + .strip_suffix("<|end|>") + .expect("should have end marker"); + let reconstructed = apply_marker_span_v0318(old, stripped).unwrap(); + assert_eq!(reconstructed, new); + } + #[test] fn test_encode_v0317_markers_stay_on_line_boundaries() { let old = "\ From 302aa859f7d239c5b8d50b3e00431a9edbbc4298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:54:08 +0100 Subject: [PATCH 23/46] MCP remote server OAuth authentication (#51768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #43162 Implements the OAuth 2.0 Authorization Code + PKCE authentication flow for remote MCP servers using Streamable HTTP transport, as specified by the [MCP auth specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). Previously, connecting to a remote MCP server that required OAuth would silently fail with a timeout — the server's 401 response was never handled. Now, Zed detects the 401, performs OAuth discovery, and guides the user through browser-based authentication. Step-up authentication and pre-registered clients are not in scope for this PR, but will be done as follow-ups. ## Overview - **401 detection** — When the HTTP transport receives a 401 during server startup, it surfaces a typed `TransportError::AuthRequired` with parsed `WWW-Authenticate` header info. - **OAuth discovery** — Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414) are fetched to locate the authorization and token endpoints. - **Client registration** — Zed first tries CIMD (Client ID Metadata Document) hosted at `zed.dev`. If the server doesn't support CIMD, falls back to Dynamic Client Registration (DCR). - **Browser flow** — A loopback HTTP callback server starts on a preferred fixed port (27523, listed in the CIMD), the user's browser opens to the authorization URL, and Zed waits for the callback with the authorization code. - **Token exchange & persistence** — The code is exchanged for access/refresh tokens using PKCE. The session is persisted in the system keychain so subsequent startups restore it without another browser flow. - **Automatic refresh** — The HTTP transport transparently refreshes expired tokens using the refresh token, and persists the updated session to the keychain. ## UI changes - Servers requiring auth show a warning indicator with an **"Authenticate"** button - During auth, a spinner and **"Waiting for authorization..."** message are shown - A **"Log Out"** option is available in the server settings menu for OAuth-authenticated servers - The configure server modal handles the auth flow inline when configuring a new server that needs authentication. Release Notes: - Added OAuth authentication support for remote MCP servers. Servers requiring OAuth now show an "Authenticate" button when they need you to log in. You will be redirected in your browser to the authorization server of the MCP server to go through the authorization flow. --------- Co-authored-by: Danilo Leal --- Cargo.lock | 5 + .../src/tools/context_server_registry.rs | 6 +- crates/agent_ui/src/agent_configuration.rs | 163 +- .../configure_context_server_modal.rs | 268 +- .../src/text_thread_store.rs | 6 +- crates/context_server/Cargo.toml | 4 + crates/context_server/src/client.rs | 32 +- crates/context_server/src/context_server.rs | 1 + crates/context_server/src/oauth.rs | 2800 +++++++++++++++++ crates/context_server/src/transport/http.rs | 517 ++- crates/project/Cargo.toml | 1 + crates/project/src/context_server_store.rs | 597 +++- crates/ui/src/components/modal.rs | 2 +- 13 files changed, 4240 insertions(+), 162 deletions(-) create mode 100644 crates/context_server/src/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index d76e9f1f40cfb1be27799ee3433957639872b324..434e74a46219a94296af742d7298889f03d7627f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3572,6 +3572,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "collections", "futures 0.3.31", "gpui", @@ -3580,14 +3581,17 @@ dependencies = [ "net", "parking_lot", "postage", + "rand 0.9.2", "schemars", "serde", "serde_json", "settings", + "sha2", "slotmap", "smol", "tempfile", "terminal", + "tiny_http", "url", "util", ] @@ -13189,6 +13193,7 @@ dependencies = [ "clock", "collections", "context_server", + "credentials_provider", "dap", "encoding_rs", "extension", diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 1c7590d8097a5de50b879d5b253c5dbabd3dcbab..df4cc313036b55e8842a9c46567256afb92ed944 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -253,12 +253,14 @@ impl ContextServerRegistry { let project::context_server_store::ServerStatusChangedEvent { server_id, status } = event; match status { - ContextServerStatus::Starting => {} + ContextServerStatus::Starting | ContextServerStatus::Authenticating => {} ContextServerStatus::Running => { self.reload_tools_for_server(server_id.clone(), cx); self.reload_prompts_for_server(server_id.clone(), cx); } - ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { + ContextServerStatus::Stopped + | ContextServerStatus::Error(_) + | ContextServerStatus::AuthRequired => { if let Some(registered_server) = self.registered_servers.remove(server_id) { if !registered_server.tools.is_empty() { cx.emit(ContextServerRegistryEvent::ToolsChanged); diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 7c2f23fcbce43bed271c58b750145d75655d16ba..fc5a78dfc936617f3782eae154b6a13531e5c425 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -517,11 +517,7 @@ impl AgentConfiguration { } } - fn render_context_servers_section( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { + fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { let context_server_ids = self.context_server_store.read(cx).server_ids(); let add_server_popover = PopoverMenu::new("add-server-popover") @@ -601,7 +597,7 @@ impl AgentConfiguration { } else { parent.children(itertools::intersperse_with( context_server_ids.iter().cloned().map(|context_server_id| { - self.render_context_server(context_server_id, window, cx) + self.render_context_server(context_server_id, cx) .into_any_element() }), || { @@ -618,7 +614,6 @@ impl AgentConfiguration { fn render_context_server( &self, context_server_id: ContextServerId, - window: &mut Window, cx: &Context, ) -> impl use<> + IntoElement { let server_status = self @@ -646,6 +641,9 @@ impl AgentConfiguration { } else { None }; + let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); + let authenticating = matches!(server_status, ContextServerStatus::Authenticating); + let context_server_store = self.context_server_store.clone(); let tool_count = self .context_server_registry @@ -689,11 +687,33 @@ impl AgentConfiguration { Indicator::dot().color(Color::Muted).into_any_element(), "Server is stopped.", ), + ContextServerStatus::AuthRequired => ( + Indicator::dot().color(Color::Warning).into_any_element(), + "Authentication required.", + ), + ContextServerStatus::Authenticating => ( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Accent) + .with_keyed_rotate_animation( + SharedString::from(format!("{}-authenticating", context_server_id.0)), + 3, + ) + .into_any_element(), + "Waiting for authorization...", + ), }; + let is_remote = server_configuration .as_ref() .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) .unwrap_or(false); + + let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| { + matches!(config.as_ref(), ContextServerConfiguration::Http { .. }) + && !config.has_static_auth_header() + }); + let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") .trigger_with_tooltip( IconButton::new("context-server-config-menu", IconName::Settings) @@ -708,6 +728,7 @@ impl AgentConfiguration { let language_registry = self.language_registry.clone(); let workspace = self.workspace.clone(); let context_server_registry = self.context_server_registry.clone(); + let context_server_store = context_server_store.clone(); move |window, cx| { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { @@ -754,6 +775,17 @@ impl AgentConfiguration { .ok(); } })) + .when(should_show_logout_button, |this| { + this.entry("Log Out", None, { + let context_server_store = context_server_store.clone(); + let context_server_id = context_server_id.clone(); + move |_window, cx| { + context_server_store.update(cx, |store, cx| { + store.logout_server(&context_server_id, cx).log_err(); + }); + } + }) + }) .separator() .entry("Uninstall", None, { let fs = fs.clone(); @@ -810,6 +842,9 @@ impl AgentConfiguration { } }); + let feedback_base_container = + || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); + v_flex() .min_w_0() .id(item_id.clone()) @@ -868,6 +903,7 @@ impl AgentConfiguration { .on_click({ let context_server_manager = self.context_server_store.clone(); let fs = self.fs.clone(); + let context_server_id = context_server_id.clone(); move |state, _window, cx| { let is_enabled = match state { @@ -915,30 +951,111 @@ impl AgentConfiguration { ) .map(|parent| { if let Some(error) = error { + return parent + .child( + feedback_base_container() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error), + ) + .child( + div().min_w_0().flex_1().child( + Label::new(error) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ) + .when(should_show_logout_button, |this| { + this.child( + Button::new("error-logout-server", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_store = + context_server_store.clone(); + let context_server_id = + context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update( + cx, + |store, cx| { + store + .logout_server( + &context_server_id, + cx, + ) + .log_err(); + }, + ); + } + }), + ) + }), + ); + } + if auth_required { return parent.child( - h_flex() - .gap_2() - .pr_4() - .items_start() + feedback_base_container() .child( h_flex() - .flex_none() - .h(window.line_height() / 1.6_f32) - .justify_center() + .pr_4() + .min_w_0() + .w_full() + .gap_2() .child( - Icon::new(IconName::XCircle) + Icon::new(IconName::Info) .size(IconSize::XSmall) - .color(Color::Error), + .color(Color::Muted), + ) + .child( + Label::new("Authenticate to connect this server") + .color(Color::Muted) + .size(LabelSize::Small), ), ) .child( - div().w_full().child( - Label::new(error) - .buffer_font(cx) - .color(Color::Muted) - .size(LabelSize::Small), - ), + Button::new("error-logout-server", "Authenticate") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_store = context_server_store.clone(); + let context_server_id = context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store + .authenticate_server(&context_server_id, cx) + .log_err(); + }); + } + }), + ), + ); + } + if authenticating { + return parent.child( + h_flex() + .mt_1() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + div().size_3().flex_shrink_0(), // Alignment Div + ) + .child( + Label::new("Authenticating…") + .color(Color::Muted) + .size(LabelSize::Small), ), + ); } parent @@ -1234,7 +1351,7 @@ impl Render for AgentConfiguration { .min_w_0() .overflow_y_scroll() .child(self.render_agent_servers_section(cx)) - .child(self.render_context_servers_section(window, cx)) + .child(self.render_context_servers_section(cx)) .child(self.render_provider_configuration_section(cx)), ) .vertical_scrollbar_for(&self.scroll_handle, window, cx), diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 857a084b720e732b218f0060f1fbee312f712540..e550d59c0ccb4deab40f6fcbc39dae124e3c08db 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,25 +1,27 @@ -use std::sync::{Arc, Mutex}; - use anyhow::{Context as _, Result}; use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; + use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, + Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use notifications::status_toast::{StatusToast, ToastIcon}; +use parking_lot::Mutex; use project::{ context_server_store::{ - ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry, + ContextServerStatus, ContextServerStore, ServerStatusChangedEvent, + registry::ContextServerDescriptorRegistry, }, project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; use serde::Deserialize; use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; use theme::ThemeSettings; use ui::{ CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, @@ -237,6 +239,8 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) format!( r#"{{ + /// Configure an MCP server that runs locally via stdin/stdout + /// /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server @@ -280,6 +284,8 @@ fn context_server_http_input( format!( r#"{{ + /// Configure an MCP server that you connect to over HTTP + /// /// The name of your remote MCP server "{name}": {{ /// The URL of the remote MCP server @@ -342,6 +348,8 @@ fn resolve_context_server_extension( enum State { Idle, Waiting, + AuthRequired { server_id: ContextServerId }, + Authenticating { _server_id: ContextServerId }, Error(SharedString), } @@ -352,6 +360,7 @@ pub struct ConfigureContextServerModal { state: State, original_server_id: Option, scroll_handle: ScrollHandle, + _auth_subscription: Option, } impl ConfigureContextServerModal { @@ -475,6 +484,7 @@ impl ConfigureContextServerModal { cx, ), scroll_handle: ScrollHandle::new(), + _auth_subscription: None, }) }) }) @@ -486,6 +496,13 @@ impl ConfigureContextServerModal { } fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context) { + if matches!( + self.state, + State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } + ) { + return; + } + self.state = State::Idle; let Some(workspace) = self.workspace.upgrade() else { return; @@ -515,14 +532,19 @@ impl ConfigureContextServerModal { async move |this, cx| { let result = wait_for_context_server_task.await; this.update(cx, |this, cx| match result { - Ok(_) => { + Ok(ContextServerStatus::Running) => { this.state = State::Idle; this.show_configured_context_server_toast(id, cx); cx.emit(DismissEvent); } + Ok(ContextServerStatus::AuthRequired) => { + this.state = State::AuthRequired { server_id: id }; + cx.notify(); + } Err(err) => { this.set_error(err, cx); } + Ok(_) => {} }) } }) @@ -558,6 +580,49 @@ impl ConfigureContextServerModal { cx.emit(DismissEvent); } + fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context) { + self.context_server_store.update(cx, |store, cx| { + store.authenticate_server(&server_id, cx).log_err(); + }); + + self.state = State::Authenticating { + _server_id: server_id.clone(), + }; + + self._auth_subscription = Some(cx.subscribe( + &self.context_server_store, + move |this, _, event: &ServerStatusChangedEvent, cx| { + if event.server_id != server_id { + return; + } + match &event.status { + ContextServerStatus::Running => { + this._auth_subscription = None; + this.state = State::Idle; + this.show_configured_context_server_toast(event.server_id.clone(), cx); + cx.emit(DismissEvent); + } + ContextServerStatus::AuthRequired => { + this._auth_subscription = None; + this.state = State::AuthRequired { + server_id: event.server_id.clone(), + }; + cx.notify(); + } + ContextServerStatus::Error(error) => { + this._auth_subscription = None; + this.set_error(error.clone(), cx); + } + ContextServerStatus::Authenticating + | ContextServerStatus::Starting + | ContextServerStatus::Stopped => {} + } + }, + )); + + cx.notify(); + } + fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) { self.workspace .update(cx, { @@ -615,7 +680,8 @@ impl ConfigureContextServerModal { } fn render_modal_description(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - const MODAL_DESCRIPTION: &str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables."; + const MODAL_DESCRIPTION: &str = + "Check the server docs for required arguments and environment variables."; if let ConfigurationSource::Extension { installation_instructions: Some(installation_instructions), @@ -637,6 +703,67 @@ impl ConfigureContextServerModal { } } + fn render_tab_bar(&self, cx: &mut Context) -> Option { + let is_http = match &self.source { + ConfigurationSource::New { is_http, .. } => *is_http, + _ => return None, + }; + + let tab = |label: &'static str, active: bool| { + div() + .id(label) + .cursor_pointer() + .p_1() + .text_sm() + .border_b_1() + .when(active, |this| { + this.border_color(cx.theme().colors().border_focused) + }) + .when(!active, |this| { + this.border_color(gpui::transparent_black()) + .text_color(cx.theme().colors().text_muted) + .hover(|s| s.text_color(cx.theme().colors().text)) + }) + .child(label) + }; + + Some( + h_flex() + .pt_1() + .mb_2p5() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .child( + tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| { + if let ConfigurationSource::New { editor, is_http } = &mut this.source { + if *is_http { + *is_http = false; + let new_text = context_server_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); + } + } + })), + ) + .child( + tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| { + if let ConfigurationSource::New { editor, is_http } = &mut this.source { + if !*is_http { + *is_http = true; + let new_text = context_server_http_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); + } + } + })), + ) + .into_any_element(), + ) + } + fn render_modal_content(&self, cx: &App) -> AnyElement { let editor = match &self.source { ConfigurationSource::New { editor, .. } => editor, @@ -682,7 +809,10 @@ impl ConfigureContextServerModal { fn render_modal_footer(&self, cx: &mut Context) -> ModalFooter { let focus_handle = self.focus_handle(cx); - let is_connecting = matches!(self.state, State::Waiting); + let is_busy = matches!( + self.state, + State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. } + ); ModalFooter::new() .start_slot::