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/Cargo.lock b/Cargo.lock index 21893b57542098c6166cc4a822429eb4df902702..a3ddf3f4960224f4ebf46c4850f7214d3fc493d1 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" @@ -3575,6 +3572,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "collections", "futures 0.3.31", "gpui", @@ -3583,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", ] @@ -5238,7 +5239,6 @@ version = "0.1.0" dependencies = [ "ai_onboarding", "anyhow", - "arrayvec", "brotli", "buffer_diff", "client", @@ -5256,6 +5256,7 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", + "heapless", "indoc", "itertools 0.14.0", "language", @@ -8027,6 +8028,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 +8121,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" @@ -9494,6 +9514,7 @@ dependencies = [ "ollama", "open_ai", "open_router", + "opencode", "partial-json-fixer", "pretty_assertions", "release_channel", @@ -10004,6 +10025,7 @@ dependencies = [ "tokio", "ui", "util", + "webrtc-sys", "zed-scap", ] @@ -11644,6 +11666,20 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "opencode" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures 0.3.31", + "google_ai", + "http_client", + "schemars", + "serde", + "serde_json", + "strum 0.27.2", +] + [[package]] name = "opener" version = "0.7.2" @@ -13172,6 +13208,7 @@ dependencies = [ "clock", "collections", "context_server", + "credentials_provider", "dap", "encoding_rs", "extension", @@ -14671,10 +14708,10 @@ dependencies = [ name = "rope" version = "0.1.0" dependencies = [ - "arrayvec", "criterion", "ctor", "gpui", + "heapless", "log", "rand 0.9.2", "rayon", @@ -16735,8 +16772,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 bc1722718b8ed464b6c78c776699bce890ba223b..dd426748606407aad3fdce359bc4ba0abe64727d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,6 +134,7 @@ members = [ "crates/notifications", "crates/ollama", "crates/onboarding", + "crates/opencode", "crates/open_ai", "crates/open_path_prompt", "crates/open_router", @@ -381,6 +382,7 @@ node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } onboarding = { path = "crates/onboarding" } +opencode = { path = "crates/opencode" } open_ai = { path = "crates/open_ai" } open_path_prompt = { path = "crates/open_path_prompt" } open_router = { path = "crates/open_router", features = ["schemars"] } @@ -480,7 +482,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 +565,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"] } @@ -779,6 +781,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 +852,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/assets/icons/ai_open_code.svg b/assets/icons/ai_open_code.svg new file mode 100644 index 0000000000000000000000000000000000000000..304b155188c2286a4f8cab208872d0373d8099f1 --- /dev/null +++ b/assets/icons/ai_open_code.svg @@ -0,0 +1,3 @@ + + + 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..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": { @@ -1451,8 +1456,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..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, @@ -1526,8 +1531,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..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, @@ -1440,8 +1446,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/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/assets/settings/default.json b/assets/settings/default.json index be1244bd14dc98005e5ba6ecaf5392af2fff9b24..959af6a021b0312fda29ece92bc3d31b2bd3c7d7 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -460,6 +460,8 @@ "show_sign_in": true, // Whether to show the menus in the titlebar. "show_menus": false, + // The layout of window control buttons in the title bar (Linux only). + "button_layout": "platform_default", }, "audio": { // Opt into the new audio system. @@ -2245,6 +2247,9 @@ "api_url": "https://api.openai.com/v1", }, "openai_compatible": {}, + "opencode": { + "api_url": "https://opencode.ai/zen", + }, "open_router": { "api_url": "https://openrouter.ai/api/v1", }, diff --git a/assets/settings/default_semantic_token_rules.json b/assets/settings/default_semantic_token_rules.json index 65b20a7423aef3c3221f9f80e345fd503627d98d..c070a253d3065feff6647123b5f687e94f5e85d6 100644 --- a/assets/settings/default_semantic_token_rules.json +++ b/assets/settings/default_semantic_token_rules.json @@ -119,6 +119,16 @@ "style": ["type"], }, // References + { + "token_type": "parameter", + "token_modifiers": ["declaration"], + "style": ["variable.parameter"] + }, + { + "token_type": "parameter", + "token_modifiers": ["definition"], + "style": ["variable.parameter"] + }, { "token_type": "parameter", "token_modifiers": [], @@ -201,6 +211,11 @@ "token_modifiers": [], "style": ["comment"], }, + { + "token_type": "string", + "token_modifiers": ["documentation"], + "style": ["string.doc"], + }, { "token_type": "string", "token_modifiers": [], diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a16a4a7895b28b281d5a1d8d883206252b33c412..e11d86196ec6367ee6d2ded709c3ba9e100da514 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -502,13 +502,15 @@ pub enum SelectedPermissionParams { #[derive(Debug)] pub struct SelectedPermissionOutcome { pub option_id: acp::PermissionOptionId, + pub option_kind: acp::PermissionOptionKind, pub params: Option, } impl SelectedPermissionOutcome { - pub fn new(option_id: acp::PermissionOptionId) -> Self { + pub fn new(option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind) -> Self { Self { option_id, + option_kind, params: None, } } @@ -519,12 +521,6 @@ impl SelectedPermissionOutcome { } } -impl From for SelectedPermissionOutcome { - fn from(option_id: acp::PermissionOptionId) -> Self { - Self::new(option_id) - } -} - impl From for acp::SelectedPermissionOutcome { fn from(value: SelectedPermissionOutcome) -> Self { Self::new(value.option_id) @@ -924,6 +920,7 @@ impl Plan { } acp::PlanEntryStatus::InProgress => { stats.in_progress_entry = stats.in_progress_entry.or(Some(entry)); + stats.pending += 1; } acp::PlanEntryStatus::Completed => { stats.completed += 1; @@ -1013,7 +1010,7 @@ pub struct AcpThread { session_id: acp::SessionId, work_dirs: Option, parent_session_id: Option, - title: SharedString, + title: Option, provisional_title: Option, entries: Vec, plan: Plan, @@ -1176,7 +1173,7 @@ impl Error for LoadError {} impl AcpThread { pub fn new( parent_session_id: Option, - title: impl Into, + title: Option, work_dirs: Option, connection: Rc, project: Entity, @@ -1203,7 +1200,7 @@ impl AcpThread { shared_buffers: Default::default(), entries: Default::default(), plan: Default::default(), - title: title.into(), + title, provisional_title: None, project, running_turn: None, @@ -1259,10 +1256,10 @@ impl AcpThread { &self.project } - pub fn title(&self) -> SharedString { - self.provisional_title + pub fn title(&self) -> Option { + self.title .clone() - .unwrap_or_else(|| self.title.clone()) + .or_else(|| self.provisional_title.clone()) } pub fn has_provisional_title(&self) -> bool { @@ -1387,8 +1384,8 @@ impl AcpThread { if let acp::MaybeUndefined::Value(title) = info_update.title { let had_provisional = self.provisional_title.take().is_some(); let title: SharedString = title.into(); - if title != self.title { - self.title = title; + if self.title.as_ref() != Some(&title) { + self.title = Some(title); cx.emit(AcpThreadEvent::TitleUpdated); } else if had_provisional { cx.emit(AcpThreadEvent::TitleUpdated); @@ -1676,8 +1673,8 @@ impl AcpThread { pub fn set_title(&mut self, title: SharedString, cx: &mut Context) -> Task> { let had_provisional = self.provisional_title.take().is_some(); - if title != self.title { - self.title = title.clone(); + if self.title.as_ref() != Some(&title) { + self.title = Some(title.clone()); cx.emit(AcpThreadEvent::TitleUpdated); if let Some(set_title) = self.connection.set_title(&self.session_id, cx) { return set_title.run(title, cx); @@ -2012,14 +2009,13 @@ impl AcpThread { &mut self, id: acp::ToolCallId, outcome: SelectedPermissionOutcome, - option_kind: acp::PermissionOptionKind, cx: &mut Context, ) { let Some((ix, call)) = self.tool_call_mut(&id) else { return; }; - let new_status = match option_kind { + let new_status = match outcome.option_kind { acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => { ToolCallStatus::Rejected } @@ -4297,7 +4293,7 @@ mod tests { let thread = cx.new(|cx| { AcpThread::new( None, - "Test", + None, Some(work_dirs), self.clone(), project, @@ -4999,7 +4995,7 @@ mod tests { // Initial title is the default. thread.read_with(cx, |thread, _| { - assert_eq!(thread.title().as_ref(), "Test"); + assert_eq!(thread.title(), None); }); // Setting a provisional title updates the display title. @@ -5007,7 +5003,10 @@ mod tests { thread.set_provisional_title("Hello, can you help…".into(), cx); }); thread.read_with(cx, |thread, _| { - assert_eq!(thread.title().as_ref(), "Hello, can you help…"); + assert_eq!( + thread.title().as_ref().map(|s| s.as_str()), + Some("Hello, can you help…") + ); }); // The provisional title should NOT have propagated to the connection. @@ -5024,7 +5023,10 @@ mod tests { }); task.await.expect("set_title should succeed"); thread.read_with(cx, |thread, _| { - assert_eq!(thread.title().as_ref(), "Helping with Rust question"); + assert_eq!( + thread.title().as_ref().map(|s| s.as_str()), + Some("Helping with Rust question") + ); }); assert_eq!( set_title_calls.borrow().as_slice(), @@ -5088,7 +5090,10 @@ mod tests { result.expect("session info update should succeed"); thread.read_with(cx, |thread, _| { - assert_eq!(thread.title().as_ref(), "Helping with Rust question"); + assert_eq!( + thread.title().as_ref().map(|s| s.as_str()), + Some("Helping with Rust question") + ); assert!( !thread.has_provisional_title(), "session info title update should clear provisional title" diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index fd47c77e7d6ff7245dd4e98f1b87cce80e1cbba6..58a8aa33830f12ffb713490c87c47133cc2ad96f 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -477,6 +477,24 @@ impl PermissionOptionChoice { pub fn label(&self) -> SharedString { self.allow.name.clone().into() } + + /// Build a `SelectedPermissionOutcome` for this choice. + /// + /// If the choice carries `sub_patterns`, they are attached as + /// `SelectedPermissionParams::Terminal`. + pub fn build_outcome(&self, is_allow: bool) -> crate::SelectedPermissionOutcome { + let option = if is_allow { &self.allow } else { &self.deny }; + + let params = if !self.sub_patterns.is_empty() { + Some(crate::SelectedPermissionParams::Terminal { + patterns: self.sub_patterns.clone(), + }) + } else { + None + }; + + crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind).params(params) + } } /// Pairs a tool's permission pattern with its display name @@ -548,6 +566,57 @@ impl PermissionOptions { self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce) .map(|option| option.option_id.clone()) } + + /// Build a `SelectedPermissionOutcome` for the `DropdownWithPatterns` + /// variant when the user has checked specific pattern indices. + /// + /// Returns `Some` with the always-allow/deny outcome when at least one + /// pattern is checked. Returns `None` when zero patterns are checked, + /// signaling that the caller should degrade to allow-once / deny-once. + /// + /// Panics (debug) or returns `None` (release) if called on a non- + /// `DropdownWithPatterns` variant. + pub fn build_outcome_for_checked_patterns( + &self, + checked_indices: &[usize], + is_allow: bool, + ) -> Option { + let PermissionOptions::DropdownWithPatterns { + choices, patterns, .. + } = self + else { + debug_assert!( + false, + "build_outcome_for_checked_patterns called on non-DropdownWithPatterns" + ); + return None; + }; + + let checked_patterns: Vec = patterns + .iter() + .enumerate() + .filter(|(index, _)| checked_indices.contains(index)) + .map(|(_, cp)| cp.pattern.clone()) + .collect(); + + if checked_patterns.is_empty() { + return None; + } + + // Use the first choice (the "Always" choice) as the base for the outcome. + let always_choice = choices.first()?; + let option = if is_allow { + &always_choice.allow + } else { + &always_choice.deny + }; + + let outcome = crate::SelectedPermissionOutcome::new(option.option_id.clone(), option.kind) + .params(Some(crate::SelectedPermissionParams::Terminal { + patterns: checked_patterns, + })); + Some(outcome) + } } #[cfg(feature = "test-support")] @@ -665,11 +734,10 @@ mod test_support { cx: &mut gpui::App, ) -> Entity { let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread_title = title.unwrap_or_else(|| SharedString::new_static("Test")); let thread = cx.new(|cx| { AcpThread::new( None, - thread_title, + title, Some(work_dirs), self.clone(), project, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 62a26f5b10672e3d1367d0fb7b085602a049df47..6437fd1883c9ddbb256babbb88041b4c42293a95 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -82,7 +82,7 @@ struct Session { /// The ACP thread that handles protocol communication acp_thread: Entity, project_id: EntityId, - pending_save: Task<()>, + pending_save: Task>, _subscriptions: Vec, } @@ -387,7 +387,7 @@ impl NativeAgent { acp_thread: acp_thread.clone(), project_id, _subscriptions: subscriptions, - pending_save: Task::ready(()), + pending_save: Task::ready(Ok(())), }, ); @@ -662,14 +662,16 @@ impl NativeAgent { let Some(session) = self.sessions.get(session_id) else { return; }; - let thread = thread.downgrade(); - let acp_thread = session.acp_thread.downgrade(); - cx.spawn(async move |_, cx| { - let title = thread.read_with(cx, |thread, _| thread.title())?; - let task = acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; - task.await - }) - .detach_and_log_err(cx); + + if let Some(title) = thread.read(cx).title() { + let acp_thread = session.acp_thread.downgrade(); + cx.spawn(async move |_, cx| { + let task = + acp_thread.update(cx, |acp_thread, cx| acp_thread.set_title(title, cx))?; + task.await + }) + .detach_and_log_err(cx); + } } fn handle_thread_token_usage_updated( @@ -727,7 +729,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 +746,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); + } + } }); } } @@ -992,7 +1000,7 @@ impl NativeAgent { let thread_store = self.thread_store.clone(); session.pending_save = cx.spawn(async move |_, cx| { let Some(database) = database_future.await.map_err(|err| anyhow!(err)).log_err() else { - return; + return Ok(()); }; let db_thread = db_thread.await; database @@ -1000,6 +1008,7 @@ impl NativeAgent { .await .log_err(); thread_store.update(cx, |store, cx| store.reload(cx)); + Ok(()) }); } @@ -1436,18 +1445,23 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx: &mut App, ) -> Task> { self.0.update(cx, |agent, cx| { + let thread = agent.sessions.get(session_id).map(|s| s.thread.clone()); + if let Some(thread) = thread { + agent.save_thread(thread, cx); + } + let Some(session) = agent.sessions.remove(session_id) else { - return; + return Task::ready(Ok(())); }; let project_id = session.project_id; - agent.save_thread(session.thread, cx); let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { agent.projects.remove(&project_id); } - }); - Task::ready(Ok(())) + + session.pending_save + }) } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -2456,6 +2470,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); @@ -2767,7 +2836,9 @@ mod internal_tests { cx.run_until_parked(); - // Set a draft prompt with rich content blocks before saving. + // Set a draft prompt with rich content blocks and scroll position + // AFTER run_until_parked, so the only save that captures these + // changes is the one performed by close_session itself. let draft_blocks = vec![ acp::ContentBlock::Text(acp::TextContent::new("Check out ")), acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())), @@ -2782,8 +2853,6 @@ mod internal_tests { offset_in_item: gpui::px(12.5), })); }); - thread.update(cx, |_thread, cx| cx.notify()); - cx.run_until_parked(); // Close the session so it can be reloaded from disk. cx.update(|cx| connection.clone().close_session(&session_id, cx)) @@ -2849,6 +2918,87 @@ mod internal_tests { }); } + #[gpui::test] + async fn test_close_session_saves_thread(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "file.txt": "hello" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), 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("")]), 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() + }); + + let model = Arc::new(FakeLanguageModel::default()); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + }); + + // Send a message so the thread is non-empty (empty threads aren't saved). + let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("world"); + model.end_last_completion_stream(); + send.await.unwrap(); + cx.run_until_parked(); + + // Set a draft prompt WITHOUT calling run_until_parked afterwards. + // This means no observe-triggered save has run for this change. + // The only way this data gets persisted is if close_session + // itself performs the save. + let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new( + "unsaved draft", + ))]; + acp_thread.update(cx, |thread, _cx| { + thread.set_draft_prompt(Some(draft_blocks.clone())); + }); + + // Close the session immediately — no run_until_parked in between. + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Reopen and verify the draft prompt was saved. + let reloaded = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + reloaded.read_with(cx, |thread, _| { + assert_eq!( + thread.draft_prompt(), + Some(draft_blocks.as_slice()), + "close_session must save the thread; draft prompt was lost" + ); + }); + } + fn thread_entries( thread_store: &Entity, cx: &mut TestAppContext, diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index d486f4f667a91fd18e5f5cade1933f2527e8048f..8a291a89e2f2a18b6180d288179406a8ba527d25 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -841,14 +841,20 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first - send "allow" option_id (UI transforms "once" to "allow") tool_call_auth_1 .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); cx.run_until_parked(); // Reject the second - send "deny" option_id directly since Deny is now a button tool_call_auth_2 .response - .send(acp::PermissionOptionId::new("deny").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + )) .unwrap(); cx.run_until_parked(); @@ -892,7 +898,10 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(acp::PermissionOptionId::new("always_allow:tool_requiring_permission").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("always_allow:tool_requiring_permission"), + acp::PermissionOptionKind::AllowAlways, + )) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -3122,7 +3131,7 @@ async fn test_title_generation(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_text_chunk("Hey!"); fake_model.end_last_completion_stream(); cx.run_until_parked(); - thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "New Thread")); + thread.read_with(cx, |thread, _| assert_eq!(thread.title(), None)); // Ensure the summary model has been invoked to generate a title. summary_model.send_last_completion_stream_text_chunk("Hello "); @@ -3131,7 +3140,9 @@ async fn test_title_generation(cx: &mut TestAppContext) { summary_model.end_last_completion_stream(); send.collect::>().await; cx.run_until_parked(); - thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Hello world".into())) + }); // Send another message, ensuring no title is generated this time. let send = thread @@ -3145,7 +3156,9 @@ async fn test_title_generation(cx: &mut TestAppContext) { cx.run_until_parked(); assert_eq!(summary_model.pending_completions(), Vec::new()); send.collect::>().await; - thread.read_with(cx, |thread, _| assert_eq!(thread.title(), "Hello world")); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.title(), Some("Hello world".into())) + }); } #[gpui::test] diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 5e1de6783953a53a92196823e79b168ee9f08319..39f5a9df902744875a9faaa1651d65842c1dbf11 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1312,7 +1312,7 @@ impl Thread { pub fn to_db(&self, cx: &App) -> Task { let initial_project_snapshot = self.initial_project_snapshot.clone(); let mut thread = DbThread { - title: self.title(), + title: self.title().unwrap_or_default(), messages: self.messages.clone(), updated_at: self.updated_at, detailed_summary: self.summary.clone(), @@ -2491,8 +2491,8 @@ impl Thread { } } - pub fn title(&self) -> SharedString { - self.title.clone().unwrap_or("New Thread".into()) + pub fn title(&self) -> Option { + self.title.clone() } pub fn is_generating_summary(&self) -> bool { 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/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 7955a6cc0755514ba4341e43af980e9b93478134..95688f27dcd8ca04aef72358ce52144f95138e17 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -266,7 +266,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -372,7 +375,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 7052b5dfdc2c7d546f5e477430d6de1a0039b03d..d6c59bcce30ab26991edba0fa7181ec45d10e1b0 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -241,7 +241,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -359,7 +362,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 9b2c0a20b8a26b57ef77bb91004c079265fc80cf..7433975c7b782a145dd3e5a80ee59cd92945a989 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -301,7 +301,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -428,7 +431,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 3325a612a0143070a3fc157976be93276f98cb5f..0b4d7ce5eb94b79ed8f822e14b76c191788afcf9 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1374,7 +1374,10 @@ mod tests { event .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); authorize_task.await.unwrap(); } diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index 7769669222631f7a2a4bd9de1e0d81a68665a816..7abbe1ed4c488210b9079e59765dddc8d5208bed 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -848,7 +848,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index ab5637c26d250b5866ebdc015ab6fce294adc7e7..147947bb67ec646c38b51f37dd75779ed78ec85b 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -273,7 +273,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; @@ -379,7 +382,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index ef33d7d5b9d0f04783849ebd681badd04b7df052..093a8580892cfc4cec0a061bcc10717b28c608f2 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -896,7 +896,10 @@ mod test { ); authorization .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = read_task.await; @@ -1185,7 +1188,10 @@ mod test { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let result = task.await; diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs index ffe886b73c217e7afe4c5a8754b12d15be4b9b0d..9273ea5b8bb041e0ea53f3ea72b94b46e5a7e294 100644 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -523,7 +523,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let _result = task.await; @@ -651,7 +654,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs index 3f741d2eea7794111e039dcb981a5239d96b7b65..c6a1cd79db65127164fe66f966029b58a366da7f 100644 --- a/crates/agent/src/tools/save_file_tool.rs +++ b/crates/agent/src/tools/save_file_tool.rs @@ -518,7 +518,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); let _result = task.await; @@ -646,7 +649,10 @@ mod tests { ); auth.response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); assert!( @@ -727,7 +733,10 @@ mod tests { let auth = event_rx.expect_authorization().await; auth.response - .send(acp::PermissionOptionId::new("deny").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("deny"), + acp::PermissionOptionKind::RejectOnce, + )) .unwrap(); let output = task.await.unwrap(); diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 9e7a7bbf1f287a8791591f3ae80a8731802eda42..065ff49f8498025830a6491344a57c7ae6053f5e 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -2581,7 +2581,10 @@ mod tests { event .response - .send(acp::PermissionOptionId::new("allow").into()) + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + )) .unwrap(); authorize_task.await.unwrap(); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 14b4628c764b76e58a2d2be1637f760a46d7bfad..f7b6a59a63b02028a8b30c905c92b82805a52b33 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -42,7 +42,6 @@ pub struct UnsupportedVersion; pub struct AcpConnection { id: AgentId, - display_name: SharedString, telemetry_id: SharedString, connection: Rc, sessions: Rc>>, @@ -167,7 +166,6 @@ impl AgentSessionList for AcpSessionList { pub async fn connect( agent_id: AgentId, project: Entity, - display_name: SharedString, command: AgentServerCommand, default_mode: Option, default_model: Option, @@ -177,7 +175,6 @@ pub async fn connect( let conn = AcpConnection::stdio( agent_id, project, - display_name, command.clone(), default_mode, default_model, @@ -194,7 +191,6 @@ impl AcpConnection { pub async fn stdio( agent_id: AgentId, project: Entity, - display_name: SharedString, command: AgentServerCommand, default_mode: Option, default_model: Option, @@ -364,7 +360,6 @@ impl AcpConnection { auth_methods, command, connection, - display_name, telemetry_id, sessions, agent_capabilities: response.agent_capabilities, @@ -660,7 +655,7 @@ impl AgentConnection for AcpConnection { let thread: Entity = cx.new(|cx| { AcpThread::new( None, - self.display_name.clone(), + None, Some(work_dirs), self.clone(), project, @@ -718,7 +713,6 @@ impl AgentConnection for AcpConnection { let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let title = title.unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, @@ -801,7 +795,6 @@ impl AgentConnection for AcpConnection { let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let title = title.unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 2506608432ffa7a1eaf82bb3dfd15259a5dd53e5..0dcd2240d6ecf6dc052cdd55953cff8ec1442eae 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -296,11 +296,6 @@ impl AgentServer for CustomAgentServer { cx: &mut App, ) -> Task>> { let agent_id = self.agent_id(); - let display_name = delegate - .store - .read(cx) - .agent_display_name(&agent_id) - .unwrap_or_else(|| agent_id.0.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); let is_registry_agent = is_registry_agent(agent_id.clone(), cx); @@ -376,7 +371,6 @@ impl AgentServer for CustomAgentServer { let connection = crate::acp::connect( agent_id, project, - display_name, command, default_mode, default_model, diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 93c37b5e258c5342be2c02eb3762bd775e22d001..956d106df2a260bd2eb31c14f4f1f1705bf74cd6 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -208,8 +208,10 @@ pub async fn test_tool_call_with_permission( thread.update(cx, |thread, cx| { thread.authorize_tool_call( tool_call_id, - allow_option_id.into(), - acp::PermissionOptionKind::AllowOnce, + acp_thread::SelectedPermissionOutcome::new( + allow_option_id, + acp::PermissionOptionKind::AllowOnce, + ), cx, ); 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/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::