Merge remote-tracking branch 'origin/main' into AI-112/git-worktree-operations

Richard Feldman created

Change summary

Cargo.lock                                                        |  42 
Cargo.toml                                                        |   4 
assets/icons/diff_split.svg                                       |   5 
assets/icons/diff_split_auto.svg                                  |   7 
assets/icons/diff_unified.svg                                     |   4 
assets/keymaps/default-linux.json                                 |  24 
assets/keymaps/default-macos.json                                 |  24 
assets/keymaps/default-windows.json                               |  24 
crates/agent/src/edit_agent/evals.rs                              |   5 
crates/agent/src/tests/mod.rs                                     |   8 
crates/agent/src/tools/evals/streaming_edit_file.rs               |   5 
crates/agent_servers/Cargo.toml                                   |   2 
crates/agent_servers/src/custom.rs                                |   3 
crates/agent_servers/src/e2e_tests.rs                             |   4 
crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs |   2 
crates/agent_ui/src/agent_diff.rs                                 |   4 
crates/agent_ui/src/agent_ui.rs                                   |  16 
crates/agent_ui/src/conversation_view.rs                          |  64 
crates/agent_ui/src/conversation_view/thread_view.rs              | 131 
crates/agent_ui/src/inline_assistant.rs                           |   5 
crates/cli/src/cli.rs                                             |   1 
crates/cli/src/main.rs                                            |   7 
crates/client/Cargo.toml                                          |   3 
crates/client/src/client.rs                                       |  72 
crates/client/src/llm_token.rs                                    | 116 
crates/codestral/Cargo.toml                                       |   1 
crates/codestral/src/codestral.rs                                 |   3 
crates/collab_ui/src/collab_panel.rs                              | 113 
crates/credentials_provider/Cargo.toml                            |   4 
crates/credentials_provider/src/credentials_provider.rs           | 167 
crates/edit_prediction/Cargo.toml                                 |   2 
crates/edit_prediction/src/capture_example.rs                     |   4 
crates/edit_prediction/src/edit_prediction.rs                     |  38 
crates/edit_prediction/src/edit_prediction_tests.rs               |   9 
crates/edit_prediction/src/mercury.rs                             |   7 
crates/edit_prediction/src/open_ai_compatible.rs                  |   3 
crates/edit_prediction_cli/src/headless.rs                        |   5 
crates/env_var/Cargo.toml                                         |  15 
crates/env_var/LICENSE-GPL                                        |   1 
crates/env_var/src/env_var.rs                                     |  40 
crates/eval_cli/src/headless.rs                                   |   5 
crates/feature_flags/src/flags.rs                                 |   6 
crates/fs/src/fake_git_repo.rs                                    |  84 
crates/fs/tests/integration/fake_git_repo.rs                      |  23 
crates/git_graph/Cargo.toml                                       |   1 
crates/git_graph/src/git_graph.rs                                 | 406 
crates/git_ui/Cargo.toml                                          |   1 
crates/git_ui/src/commit_view.rs                                  |  29 
crates/git_ui/src/git_panel.rs                                    |  24 
crates/gpui/src/elements/list.rs                                  |   7 
crates/grammars/src/javascript/highlights.scm                     |  18 
crates/grammars/src/tsx/highlights.scm                            |  18 
crates/icons/src/icons.rs                                         |   1 
crates/language_model/Cargo.toml                                  |   3 
crates/language_model/src/api_key.rs                              |  22 
crates/language_model/src/language_model.rs                       |  13 
crates/language_model/src/model/cloud_model.rs                    | 158 
crates/language_models/src/language_models.rs                     |  78 
crates/language_models/src/provider/anthropic.rs                  |  37 
crates/language_models/src/provider/bedrock.rs                    |  14 
crates/language_models/src/provider/cloud.rs                      |  29 
crates/language_models/src/provider/deepseek.rs                   |  37 
crates/language_models/src/provider/google.rs                     |  37 
crates/language_models/src/provider/lmstudio.rs                   |  45 
crates/language_models/src/provider/mistral.rs                    |  37 
crates/language_models/src/provider/ollama.rs                     |  38 
crates/language_models/src/provider/open_ai.rs                    |  37 
crates/language_models/src/provider/open_ai_compatible.rs         |  31 
crates/language_models/src/provider/open_router.rs                |  29 
crates/language_models/src/provider/opencode.rs                   |  37 
crates/language_models/src/provider/vercel.rs                     |  37 
crates/language_models/src/provider/vercel_ai_gateway.rs          |  29 
crates/language_models/src/provider/x_ai.rs                       |  37 
crates/project/Cargo.toml                                         |   1 
crates/project/src/context_server_store.rs                        |  11 
crates/project/src/git_store.rs                                   | 144 
crates/proto/proto/git.proto                                      |  37 
crates/proto/proto/zed.proto                                      |   9 
crates/proto/src/proto.rs                                         |  15 
crates/recent_projects/src/dev_container_suggest.rs               |  35 
crates/recent_projects/src/recent_projects.rs                     |   3 
crates/remote_server/src/remote_editing_tests.rs                  | 147 
crates/search/src/buffer_search.rs                                | 144 
crates/settings_ui/Cargo.toml                                     |   1 
crates/settings_ui/src/page_data.rs                               |  30 
crates/settings_ui/src/pages/edit_prediction_provider_setup.rs    |  17 
crates/settings_ui/src/settings_ui.rs                             |  59 
crates/sidebar/src/sidebar.rs                                     |  40 
crates/ui/src/components.rs                                       |   2 
crates/ui/src/components/ai/thread_item.rs                        |  62 
crates/ui/src/components/data_table.rs                            | 532 
crates/ui/src/components/data_table/tests.rs                      |   3 
crates/ui/src/components/redistributable_columns.rs               | 485 
crates/vim/src/test/vim_test_context.rs                           |  12 
crates/web_search_providers/src/cloud.rs                          |  14 
crates/workspace/src/workspace.rs                                 |  32 
crates/zed/src/main.rs                                            |  23 
crates/zed/src/visual_test_runner.rs                              |   7 
crates/zed/src/zed.rs                                             |   8 
crates/zed/src/zed/edit_prediction_registry.rs                    |   7 
crates/zed/src/zed/open_listener.rs                               | 127 
crates/zed/src/zed/windows_only_instance.rs                       |   1 
crates/zed_credentials_provider/Cargo.toml                        |  22 
crates/zed_credentials_provider/LICENSE-GPL                       |   1 
crates/zed_credentials_provider/src/zed_credentials_provider.rs   | 181 
crates/zed_env_vars/Cargo.toml                                    |   2 
crates/zed_env_vars/src/zed_env_vars.rs                           |  41 
docs/src/ai/agent-panel.md                                        |   4 
docs/src/performance.md                                           |  32 
docs/theme/css/general.css                                        |  33 
docs/theme/css/variables.css                                      |  13 
docs/theme/fonts/Lora.var.woff2                                   |   0 
docs/theme/fonts/fonts.css                                        |  32 
docs/theme/fonts/iAWriterQuattroS-Regular.woff2                   |   0 
docs/theme/page-toc.css                                           |   2 
docs/theme/plugins.css                                            |   6 
script/docs-suggest-publish                                       |  16 
117 files changed, 3,265 insertions(+), 1,530 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -260,7 +260,6 @@ dependencies = [
  "chrono",
  "client",
  "collections",
- "credentials_provider",
  "env_logger 0.11.8",
  "feature_flags",
  "fs",
@@ -289,6 +288,7 @@ dependencies = [
  "util",
  "uuid",
  "watch",
+ "zed_credentials_provider",
 ]
 
 [[package]]
@@ -2856,6 +2856,7 @@ dependencies = [
  "chrono",
  "clock",
  "cloud_api_client",
+ "cloud_api_types",
  "cloud_llm_client",
  "collections",
  "credentials_provider",
@@ -2869,6 +2870,7 @@ dependencies = [
  "http_client",
  "http_client_tls",
  "httparse",
+ "language_model",
  "log",
  "objc2-foundation",
  "parking_lot",
@@ -2900,6 +2902,7 @@ dependencies = [
  "util",
  "windows 0.61.3",
  "worktree",
+ "zed_credentials_provider",
 ]
 
 [[package]]
@@ -3059,6 +3062,7 @@ dependencies = [
  "serde",
  "serde_json",
  "text",
+ "zed_credentials_provider",
  "zeta_prompt",
 ]
 
@@ -4035,12 +4039,8 @@ name = "credentials_provider"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "futures 0.3.31",
  "gpui",
- "paths",
- "release_channel",
  "serde",
- "serde_json",
 ]
 
 [[package]]
@@ -5115,6 +5115,7 @@ dependencies = [
  "collections",
  "copilot",
  "copilot_ui",
+ "credentials_provider",
  "ctor",
  "db",
  "edit_prediction_context",
@@ -5157,6 +5158,7 @@ dependencies = [
  "workspace",
  "worktree",
  "zed_actions",
+ "zed_credentials_provider",
  "zeta_prompt",
  "zlog",
  "zstd",
@@ -5583,6 +5585,13 @@ dependencies = [
  "log",
 ]
 
+[[package]]
+name = "env_var"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+]
+
 [[package]]
 name = "envy"
 version = "0.4.2"
@@ -7132,7 +7141,6 @@ dependencies = [
  "collections",
  "db",
  "editor",
- "feature_flags",
  "fs",
  "git",
  "git_ui",
@@ -7190,7 +7198,6 @@ dependencies = [
  "ctor",
  "db",
  "editor",
- "feature_flags",
  "file_icons",
  "futures 0.3.31",
  "fuzzy",
@@ -9317,12 +9324,12 @@ dependencies = [
  "anthropic",
  "anyhow",
  "base64 0.22.1",
- "client",
  "cloud_api_client",
  "cloud_api_types",
  "cloud_llm_client",
  "collections",
  "credentials_provider",
+ "env_var",
  "futures 0.3.31",
  "gpui",
  "http_client",
@@ -9338,7 +9345,6 @@ dependencies = [
  "smol",
  "thiserror 2.0.17",
  "util",
- "zed_env_vars",
 ]
 
 [[package]]
@@ -13139,6 +13145,7 @@ dependencies = [
  "wax",
  "which 6.0.3",
  "worktree",
+ "zed_credentials_provider",
  "zeroize",
  "zlog",
  "ztracing",
@@ -15748,6 +15755,7 @@ dependencies = [
  "util",
  "workspace",
  "zed_actions",
+ "zed_credentials_provider",
 ]
 
 [[package]]
@@ -22182,10 +22190,24 @@ dependencies = [
 ]
 
 [[package]]
-name = "zed_env_vars"
+name = "zed_credentials_provider"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
+ "credentials_provider",
+ "futures 0.3.31",
  "gpui",
+ "paths",
+ "release_channel",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "zed_env_vars"
+version = "0.1.0"
+dependencies = [
+ "env_var",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -61,6 +61,7 @@ members = [
     "crates/edit_prediction_ui",
     "crates/editor",
     "crates/encoding_selector",
+    "crates/env_var",
     "crates/etw_tracing",
     "crates/eval_cli",
     "crates/eval_utils",
@@ -220,6 +221,7 @@ members = [
     "crates/x_ai",
     "crates/zed",
     "crates/zed_actions",
+    "crates/zed_credentials_provider",
     "crates/zed_env_vars",
     "crates/zeta_prompt",
     "crates/zlog",
@@ -309,6 +311,7 @@ dev_container = { path = "crates/dev_container" }
 diagnostics = { path = "crates/diagnostics" }
 editor = { path = "crates/editor" }
 encoding_selector = { path = "crates/encoding_selector" }
+env_var = { path = "crates/env_var" }
 etw_tracing = { path = "crates/etw_tracing" }
 eval_utils = { path = "crates/eval_utils" }
 extension = { path = "crates/extension" }
@@ -465,6 +468,7 @@ worktree = { path = "crates/worktree" }
 x_ai = { path = "crates/x_ai" }
 zed = { path = "crates/zed" }
 zed_actions = { path = "crates/zed_actions" }
+zed_credentials_provider = { path = "crates/zed_credentials_provider" }
 zed_env_vars = { path = "crates/zed_env_vars" }
 edit_prediction = { path = "crates/edit_prediction" }
 zeta_prompt = { path = "crates/zeta_prompt" }

assets/icons/diff_split.svg 🔗

@@ -1,5 +1,4 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M5.25 3H3.75C3.33579 3 3 3.33579 3 3.75V12.25C3 12.6642 3.33579 13 3.75 13H5.25C5.66421 13 6 12.6642 6 12.25V3.75C6 3.33579 5.66421 3 5.25 3Z" fill="#C6CAD0"/>
-<path opacity="0.7" d="M8.5 2.5C8.5 2.22386 8.27614 2 8 2C7.72386 2 7.5 2.22386 7.5 2.5V13.5C7.5 13.7761 7.72386 14 8 14C8.27614 14 8.5 13.7761 8.5 13.5V2.5Z" fill="#C6CAD0"/>
-<path d="M12.25 3H10.75C10.3358 3 10 3.33579 10 3.75V12.25C10 12.6642 10.3358 13 10.75 13H12.25C12.6642 13 13 12.6642 13 12.25V3.75C13 3.33579 12.6642 3 12.25 3Z" fill="#C6CAD0"/>
+<rect x="6.4" y="2.6" width="10.8" height="3.8" rx="0.9" transform="rotate(90 6.4 2.6)" fill="#C6CAD0" fill-opacity="0.5" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="13.4" y="2.6" width="10.8" height="3.8" rx="0.9" transform="rotate(90 13.4 2.6)" fill="#C6CAD0" stroke="#C6CAD0" stroke-width="1.2"/>
 </svg>

assets/icons/diff_split_auto.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.5" x="14" y="2" width="12" height="5" rx="1.5" transform="rotate(90 14 2)" fill="#C6CAD0" fill-opacity="0.2"/>
+<rect opacity="0.5" x="7" y="2" width="12" height="5" rx="1.5" transform="rotate(90 7 2)" fill="#C6CAD0" fill-opacity="0.2"/>
+<path d="M10.2002 12.5C10.2002 12.6657 10.3343 12.7998 10.5 12.7998H12.3125L13.4951 13.4824C13.5254 13.4999 13.557 13.5142 13.5879 13.5293C13.3145 13.8182 12.9291 14 12.5 14H10.5L10.3467 13.9922C9.59028 13.9154 9 13.2767 9 12.5V10.8867L10.2002 11.5791V12.5ZM10.2002 6.95996L9 6.26758V3.5C9 2.72334 9.59028 2.08461 10.3467 2.00781L10.5 2H12.5C13.3284 2 14 2.67157 14 3.5V9.1543L12.7998 8.46094V3.5C12.7998 3.33431 12.6657 3.2002 12.5 3.2002H10.5C10.3343 3.2002 10.2002 3.33431 10.2002 3.5V6.95996Z" fill="#C6CAD0"/>
+<path d="M7 9.73242V12.5C7 13.3284 6.32843 14 5.5 14H3.5L3.34668 13.9922C2.59028 13.9154 2 13.2767 2 12.5V6.84473L3.2002 7.53809V12.5C3.2002 12.6657 3.33431 12.7998 3.5 12.7998H5.5C5.66569 12.7998 5.7998 12.6657 5.7998 12.5V9.03906L7 9.73242ZM5.5 2C6.32843 2 7 2.67157 7 3.5V5.1123L5.7998 4.41992V3.5C5.7998 3.33431 5.66569 3.2002 5.5 3.2002H3.6875L2.50488 2.51758C2.47399 2.49975 2.44173 2.48513 2.41016 2.46973C2.65063 2.21547 2.97893 2.04515 3.34668 2.00781L3.5 2H5.5Z" fill="#C6CAD0"/>
+<rect x="0.427673" y="4.78281" width="2" height="16.3329" rx="0.5" transform="rotate(-60 0.427673 4.78281)" fill="#C6CAD0"/>
+</svg>

assets/icons/diff_unified.svg 🔗

@@ -1,4 +1,4 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M12 3H4C3.44772 3 3 3.44772 3 4V6C3 6.55228 3.44772 7 4 7H12C12.5523 7 13 6.55228 13 6V4C13 3.44772 12.5523 3 12 3Z" fill="#C6CAD0"/>
-<path d="M12 9.5H4C3.72386 9.5 3.5 9.72386 3.5 10V12C3.5 12.2761 3.72386 12.5 4 12.5H12C12.2761 12.5 12.5 12.2761 12.5 12V10C12.5 9.72386 12.2761 9.5 12 9.5Z" fill="#C6CAD0" fill-opacity="0.12" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2.6" y="9.6" width="10.8" height="3.8" rx="0.9" fill="#C6CAD0" fill-opacity="0.5" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="5" rx="1.5" fill="#C6CAD0"/>
 </svg>

assets/keymaps/default-linux.json 🔗

@@ -284,12 +284,36 @@
     "context": "AcpThread",
     "bindings": {
       "ctrl--": "pane::GoBack",
+      "pageup": "agent::ScrollOutputPageUp",
+      "pagedown": "agent::ScrollOutputPageDown",
+      "home": "agent::ScrollOutputToTop",
+      "end": "agent::ScrollOutputToBottom",
+      "up": "agent::ScrollOutputLineUp",
+      "down": "agent::ScrollOutputLineDown",
+      "shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "shift-pagedown": "agent::ScrollOutputToNextMessage",
+      "ctrl-alt-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-alt-home": "agent::ScrollOutputToTop",
+      "ctrl-alt-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
     },
   },
   {
     "context": "AcpThread > Editor",
     "use_key_equivalents": true,
     "bindings": {
+      "ctrl-alt-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-alt-home": "agent::ScrollOutputToTop",
+      "ctrl-alt-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
       "ctrl-shift-r": "agent::OpenAgentDiff",
       "ctrl-shift-d": "git::Diff",
       "shift-alt-y": "agent::KeepAll",

assets/keymaps/default-macos.json 🔗

@@ -327,12 +327,36 @@
     "context": "AcpThread",
     "bindings": {
       "ctrl--": "pane::GoBack",
+      "pageup": "agent::ScrollOutputPageUp",
+      "pagedown": "agent::ScrollOutputPageDown",
+      "home": "agent::ScrollOutputToTop",
+      "end": "agent::ScrollOutputToBottom",
+      "up": "agent::ScrollOutputLineUp",
+      "down": "agent::ScrollOutputLineDown",
+      "shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "shift-pagedown": "agent::ScrollOutputToNextMessage",
+      "ctrl-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-home": "agent::ScrollOutputToTop",
+      "ctrl-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage",
     },
   },
   {
     "context": "AcpThread > Editor",
     "use_key_equivalents": true,
     "bindings": {
+      "ctrl-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-home": "agent::ScrollOutputToTop",
+      "ctrl-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage",
       "shift-ctrl-r": "agent::OpenAgentDiff",
       "shift-ctrl-d": "git::Diff",
       "shift-alt-y": "agent::KeepAll",

assets/keymaps/default-windows.json 🔗

@@ -285,12 +285,36 @@
     "context": "AcpThread",
     "bindings": {
       "ctrl--": "pane::GoBack",
+      "pageup": "agent::ScrollOutputPageUp",
+      "pagedown": "agent::ScrollOutputPageDown",
+      "home": "agent::ScrollOutputToTop",
+      "end": "agent::ScrollOutputToBottom",
+      "up": "agent::ScrollOutputLineUp",
+      "down": "agent::ScrollOutputLineDown",
+      "shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "shift-pagedown": "agent::ScrollOutputToNextMessage",
+      "ctrl-alt-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-alt-home": "agent::ScrollOutputToTop",
+      "ctrl-alt-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
     },
   },
   {
     "context": "AcpThread > Editor",
     "use_key_equivalents": true,
     "bindings": {
+      "ctrl-alt-pageup": "agent::ScrollOutputPageUp",
+      "ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
+      "ctrl-alt-home": "agent::ScrollOutputToTop",
+      "ctrl-alt-end": "agent::ScrollOutputToBottom",
+      "ctrl-alt-up": "agent::ScrollOutputLineUp",
+      "ctrl-alt-down": "agent::ScrollOutputLineDown",
+      "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage",
+      "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
       "ctrl-shift-r": "agent::OpenAgentDiff",
       "ctrl-shift-d": "git::Diff",
       "shift-alt-y": "agent::KeepAll",

crates/agent/src/edit_agent/evals.rs 🔗

@@ -4,7 +4,7 @@ use crate::{
     ListDirectoryTool, ListDirectoryToolInput, ReadFileTool, ReadFileToolInput,
 };
 use Role::*;
-use client::{Client, UserStore};
+use client::{Client, RefreshLlmTokenListener, UserStore};
 use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind};
 use fs::FakeFs;
 use futures::{FutureExt, future::LocalBoxFuture};
@@ -1423,7 +1423,8 @@ impl EditAgentTest {
             let client = Client::production(cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
             settings::init(cx);
-            language_model::init(user_store.clone(), client.clone(), cx);
+            language_model::init(cx);
+            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
             language_models::init(user_store, client.clone(), cx);
         });
 

crates/agent/src/tests/mod.rs 🔗

@@ -6,7 +6,7 @@ use acp_thread::{
 use agent_client_protocol::{self as acp};
 use agent_settings::AgentProfileId;
 use anyhow::Result;
-use client::{Client, UserStore};
+use client::{Client, RefreshLlmTokenListener, UserStore};
 use collections::IndexMap;
 use context_server::{ContextServer, ContextServerCommand, ContextServerId};
 use feature_flags::FeatureFlagAppExt as _;
@@ -3253,7 +3253,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
         let clock = Arc::new(clock::FakeSystemClock::new());
         let client = Client::new(clock, http_client, cx);
         let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-        language_model::init(user_store.clone(), client.clone(), cx);
+        language_model::init(cx);
+        RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
         language_models::init(user_store, client.clone(), cx);
         LanguageModelRegistry::test(cx);
     });
@@ -3982,7 +3983,8 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
                 cx.set_http_client(Arc::new(http_client));
                 let client = Client::production(cx);
                 let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-                language_model::init(user_store.clone(), client.clone(), cx);
+                language_model::init(cx);
+                RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
                 language_models::init(user_store, client.clone(), cx);
             }
         };

crates/agent/src/tools/evals/streaming_edit_file.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
 };
 use Role::*;
 use anyhow::{Context as _, Result};
-use client::{Client, UserStore};
+use client::{Client, RefreshLlmTokenListener, UserStore};
 use fs::FakeFs;
 use futures::{FutureExt, StreamExt, future::LocalBoxFuture};
 use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _};
@@ -274,7 +274,8 @@ impl StreamingEditToolTest {
             cx.set_http_client(http_client);
             let client = Client::production(cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-            language_model::init(user_store.clone(), client.clone(), cx);
+            language_model::init(cx);
+            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
             language_models::init(user_store, client, cx);
         });
 

crates/agent_servers/Cargo.toml 🔗

@@ -32,7 +32,6 @@ futures.workspace = true
 gpui.workspace = true
 feature_flags.workspace = true
 gpui_tokio = { workspace = true, optional = true }
-credentials_provider.workspace = true
 google_ai.workspace = true
 http_client.workspace = true
 indoc.workspace = true
@@ -53,6 +52,7 @@ terminal.workspace = true
 uuid.workspace = true
 util.workspace = true
 watch.workspace = true
+zed_credentials_provider.workspace = true
 
 [target.'cfg(unix)'.dependencies]
 libc.workspace = true

crates/agent_servers/src/custom.rs 🔗

@@ -3,7 +3,6 @@ use acp_thread::AgentConnection;
 use agent_client_protocol as acp;
 use anyhow::{Context as _, Result};
 use collections::HashSet;
-use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use gpui::{App, AppContext as _, Entity, Task};
 use language_model::{ApiKey, EnvVar};
@@ -392,7 +391,7 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task<Result<String>> {
     if let Some(key) = env_var.value {
         return Task::ready(Ok(key));
     }
-    let credentials_provider = <dyn CredentialsProvider>::global(cx);
+    let credentials_provider = zed_credentials_provider::global(cx);
     let api_url = google_ai::API_URL.to_string();
     cx.spawn(async move |cx| {
         Ok(

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{AgentServer, AgentServerDelegate};
 use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
 use agent_client_protocol as acp;
+use client::RefreshLlmTokenListener;
 use futures::{FutureExt, StreamExt, channel::mpsc, select};
 use gpui::AppContext;
 use gpui::{Entity, TestAppContext};
@@ -413,7 +414,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
         cx.set_http_client(Arc::new(http_client));
         let client = client::Client::production(cx);
         let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
-        language_model::init(user_store, client, cx);
+        language_model::init(cx);
+        RefreshLlmTokenListener::register(client.clone(), user_store, cx);
 
         #[cfg(test)]
         project::agent_server_store::AllAgentServersSettings::override_global(

crates/agent_ui/src/agent_diff.rs 🔗

@@ -1809,7 +1809,7 @@ mod tests {
             cx.set_global(settings_store);
             prompt_store::init(cx);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
-            language_model::init_settings(cx);
+            language_model::init(cx);
         });
 
         let fs = FakeFs::new(cx.executor());
@@ -1966,7 +1966,7 @@ mod tests {
             cx.set_global(settings_store);
             prompt_store::init(cx);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
-            language_model::init_settings(cx);
+            language_model::init(cx);
             workspace::register_project_item::<Editor>(cx);
         });
 

crates/agent_ui/src/agent_ui.rs 🔗

@@ -173,6 +173,22 @@ actions!(
         ToggleThinkingEffortMenu,
         /// Toggles fast mode for models that support it.
         ToggleFastMode,
+        /// Scroll the output by one page up.
+        ScrollOutputPageUp,
+        /// Scroll the output by one page down.
+        ScrollOutputPageDown,
+        /// Scroll the output up by three lines.
+        ScrollOutputLineUp,
+        /// Scroll the output down by three lines.
+        ScrollOutputLineDown,
+        /// Scroll the output to the top.
+        ScrollOutputToTop,
+        /// Scroll the output to the bottom.
+        ScrollOutputToBottom,
+        /// Scroll the output to the previous user message.
+        ScrollOutputToPreviousMessage,
+        /// Scroll the output to the next user message.
+        ScrollOutputToNextMessage,
     ]
 );
 

crates/agent_ui/src/conversation_view.rs 🔗

@@ -85,8 +85,11 @@ use crate::{
     AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector,
     CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread,
     OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
-    RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
-    ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
+    RemoveFirstQueuedMessage, ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown,
+    ScrollOutputPageUp, ScrollOutputToBottom, ScrollOutputToNextMessage,
+    ScrollOutputToPreviousMessage, ScrollOutputToTop, SendImmediately, SendNextQueuedMessage,
+    ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode,
+    UndoLastReject,
 };
 
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
@@ -1240,15 +1243,15 @@ impl ConversationView {
                 if let Some(active) = self.thread_view(&thread_id) {
                     let entry_view_state = active.read(cx).entry_view_state.clone();
                     let list_state = active.read(cx).list_state.clone();
-                    entry_view_state.update(cx, |view_state, cx| {
-                        view_state.sync_entry(index, thread, window, cx);
-                        list_state.splice_focusable(
-                            index..index,
-                            [view_state
-                                .entry(index)
-                                .and_then(|entry| entry.focus_handle(cx))],
-                        );
-                    });
+                    notify_entry_changed(
+                        &entry_view_state,
+                        &list_state,
+                        index..index,
+                        index,
+                        thread,
+                        window,
+                        cx,
+                    );
                     active.update(cx, |active, cx| {
                         active.sync_editor_mode_for_empty_state(cx);
                     });
@@ -1257,9 +1260,16 @@ impl ConversationView {
             AcpThreadEvent::EntryUpdated(index) => {
                 if let Some(active) = self.thread_view(&thread_id) {
                     let entry_view_state = active.read(cx).entry_view_state.clone();
-                    entry_view_state.update(cx, |view_state, cx| {
-                        view_state.sync_entry(*index, thread, window, cx)
-                    });
+                    let list_state = active.read(cx).list_state.clone();
+                    notify_entry_changed(
+                        &entry_view_state,
+                        &list_state,
+                        *index..*index + 1,
+                        *index,
+                        thread,
+                        window,
+                        cx,
+                    );
                     active.update(cx, |active, cx| {
                         active.auto_expand_streaming_thought(cx);
                     });
@@ -2598,6 +2608,32 @@ impl ConversationView {
     }
 }
 
+/// Syncs an entry's view state with the latest thread data and splices
+/// the list item so the list knows to re-measure it on the next paint.
+///
+/// Used by both `NewEntry` (splice range `index..index` to insert) and
+/// `EntryUpdated` (splice range `index..index+1` to replace), which is
+/// why the caller provides the splice range.
+fn notify_entry_changed(
+    entry_view_state: &Entity<EntryViewState>,
+    list_state: &ListState,
+    splice_range: std::ops::Range<usize>,
+    index: usize,
+    thread: &Entity<AcpThread>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    entry_view_state.update(cx, |view_state, cx| {
+        view_state.sync_entry(index, thread, window, cx);
+        list_state.splice_focusable(
+            splice_range,
+            [view_state
+                .entry(index)
+                .and_then(|entry| entry.focus_handle(cx))],
+        );
+    });
+}
+
 fn loading_contents_spinner(size: IconSize) -> AnyElement {
     Icon::new(IconName::LoadCircle)
         .size(size)

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -552,17 +552,10 @@ impl ThreadView {
                     let scroll_top = list_state.logical_scroll_top();
                     let _ = thread_view.update(cx, |this, cx| {
                         if !is_following_tail {
-                            let is_at_bottom = {
-                                let current_offset =
-                                    list_state.scroll_px_offset_for_scrollbar().y.abs();
-                                let max_offset = list_state.max_offset_for_scrollbar().y;
-                                current_offset >= max_offset - px(1.0)
-                            };
-
                             let is_generating =
                                 matches!(this.thread.read(cx).status(), ThreadStatus::Generating);
 
-                            if is_at_bottom && is_generating {
+                            if list_state.is_at_bottom() && is_generating {
                                 list_state.set_follow_tail(true);
                             }
                         }
@@ -4952,7 +4945,7 @@ impl ThreadView {
     }
 
     pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
-        self.list_state.scroll_to_end();
+        self.list_state.set_follow_tail(true);
         cx.notify();
     }
 
@@ -4974,10 +4967,122 @@ impl ThreadView {
     }
 
     pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
+        self.list_state.set_follow_tail(false);
         self.list_state.scroll_to(ListOffset::default());
         cx.notify();
     }
 
+    fn scroll_output_page_up(
+        &mut self,
+        _: &ScrollOutputPageUp,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let page_height = self.list_state.viewport_bounds().size.height;
+        self.list_state.set_follow_tail(false);
+        self.list_state.scroll_by(-page_height * 0.9);
+        cx.notify();
+    }
+
+    fn scroll_output_page_down(
+        &mut self,
+        _: &ScrollOutputPageDown,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let page_height = self.list_state.viewport_bounds().size.height;
+        self.list_state.set_follow_tail(false);
+        self.list_state.scroll_by(page_height * 0.9);
+        if self.list_state.is_at_bottom() {
+            self.list_state.set_follow_tail(true);
+        }
+        cx.notify();
+    }
+
+    fn scroll_output_line_up(
+        &mut self,
+        _: &ScrollOutputLineUp,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.list_state.set_follow_tail(false);
+        self.list_state.scroll_by(-window.line_height() * 3.);
+        cx.notify();
+    }
+
+    fn scroll_output_line_down(
+        &mut self,
+        _: &ScrollOutputLineDown,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.list_state.set_follow_tail(false);
+        self.list_state.scroll_by(window.line_height() * 3.);
+        if self.list_state.is_at_bottom() {
+            self.list_state.set_follow_tail(true);
+        }
+        cx.notify();
+    }
+
+    fn scroll_output_to_top(
+        &mut self,
+        _: &ScrollOutputToTop,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.scroll_to_top(cx);
+    }
+
+    fn scroll_output_to_bottom(
+        &mut self,
+        _: &ScrollOutputToBottom,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.scroll_to_end(cx);
+    }
+
+    fn scroll_output_to_previous_message(
+        &mut self,
+        _: &ScrollOutputToPreviousMessage,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let entries = self.thread.read(cx).entries();
+        let current_ix = self.list_state.logical_scroll_top().item_ix;
+        if let Some(target_ix) = (0..current_ix)
+            .rev()
+            .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
+        {
+            self.list_state.set_follow_tail(false);
+            self.list_state.scroll_to(ListOffset {
+                item_ix: target_ix,
+                offset_in_item: px(0.),
+            });
+            cx.notify();
+        }
+    }
+
+    fn scroll_output_to_next_message(
+        &mut self,
+        _: &ScrollOutputToNextMessage,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let entries = self.thread.read(cx).entries();
+        let current_ix = self.list_state.logical_scroll_top().item_ix;
+        if let Some(target_ix) = (current_ix + 1..entries.len())
+            .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
+        {
+            self.list_state.set_follow_tail(false);
+            self.list_state.scroll_to(ListOffset {
+                item_ix: target_ix,
+                offset_in_item: px(0.),
+            });
+            cx.notify();
+        }
+    }
+
     pub fn open_thread_as_markdown(
         &self,
         workspace: Entity<Workspace>,
@@ -8541,6 +8646,14 @@ impl Render for ThreadView {
             .on_action(cx.listener(Self::handle_toggle_command_pattern))
             .on_action(cx.listener(Self::open_permission_dropdown))
             .on_action(cx.listener(Self::open_add_context_menu))
+            .on_action(cx.listener(Self::scroll_output_page_up))
+            .on_action(cx.listener(Self::scroll_output_page_down))
+            .on_action(cx.listener(Self::scroll_output_line_up))
+            .on_action(cx.listener(Self::scroll_output_line_down))
+            .on_action(cx.listener(Self::scroll_output_to_top))
+            .on_action(cx.listener(Self::scroll_output_to_bottom))
+            .on_action(cx.listener(Self::scroll_output_to_previous_message))
+            .on_action(cx.listener(Self::scroll_output_to_next_message))
             .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| {
                 this.toggle_fast_mode(cx);
             }))

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -2025,7 +2025,7 @@ fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
 pub mod evals {
     use crate::InlineAssistant;
     use agent::ThreadStore;
-    use client::{Client, UserStore};
+    use client::{Client, RefreshLlmTokenListener, UserStore};
     use editor::{Editor, MultiBuffer, MultiBufferOffset};
     use eval_utils::{EvalOutput, NoProcessor};
     use fs::FakeFs;
@@ -2091,7 +2091,8 @@ pub mod evals {
             client::init(&client, cx);
             workspace::init(app_state.clone(), cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-            language_model::init(user_store.clone(), client.clone(), cx);
+            language_model::init(cx);
+            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
             language_models::init(user_store, client.clone(), cx);
 
             cx.set_global(inline_assistant);

crates/cli/src/cli.rs 🔗

@@ -21,6 +21,7 @@ pub enum CliRequest {
         reuse: bool,
         env: Option<HashMap<String, String>>,
         user_data_dir: Option<String>,
+        dev_container: bool,
     },
 }
 

crates/cli/src/main.rs 🔗

@@ -118,6 +118,12 @@ struct Args {
     /// Will attempt to give the correct command to run
     #[arg(long)]
     system_specs: bool,
+    /// Open the project in a dev container.
+    ///
+    /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/`
+    /// configuration is found in the project directory.
+    #[arg(long)]
+    dev_container: bool,
     /// Pairs of file paths to diff. Can be specified multiple times.
     /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view.
     #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])]
@@ -670,6 +676,7 @@ fn main() -> Result<()> {
                     reuse: args.reuse,
                     env,
                     user_data_dir: user_data_dir_for_thread,
+                    dev_container: args.dev_container,
                 })?;
 
                 while let Ok(response) = rx.recv() {

crates/client/Cargo.toml 🔗

@@ -22,6 +22,7 @@ base64.workspace = true
 chrono = { workspace = true, features = ["serde"] }
 clock.workspace = true
 cloud_api_client.workspace = true
+cloud_api_types.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true
 credentials_provider.workspace = true
@@ -35,6 +36,7 @@ gpui_tokio.workspace = true
 http_client.workspace = true
 http_client_tls.workspace = true
 httparse = "1.10"
+language_model.workspace = true
 log.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
@@ -60,6 +62,7 @@ tokio.workspace = true
 url.workspace = true
 util.workspace = true
 worktree.workspace = true
+zed_credentials_provider.workspace = true
 
 [dev-dependencies]
 clock = { workspace = true, features = ["test-support"] }

crates/client/src/client.rs 🔗

@@ -1,6 +1,7 @@
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+mod llm_token;
 mod proxy;
 pub mod telemetry;
 pub mod user;
@@ -13,8 +14,9 @@ use async_tungstenite::tungstenite::{
     http::{HeaderValue, Request, StatusCode},
 };
 use clock::SystemClock;
-use cloud_api_client::CloudApiClient;
 use cloud_api_client::websocket_protocol::MessageToClient;
+use cloud_api_client::{ClientApiError, CloudApiClient};
+use cloud_api_types::OrganizationId;
 use credentials_provider::CredentialsProvider;
 use feature_flags::FeatureFlagAppExt as _;
 use futures::{
@@ -24,6 +26,7 @@ use futures::{
 };
 use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
 use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env};
+use language_model::LlmApiToken;
 use parking_lot::{Mutex, RwLock};
 use postage::watch;
 use proxy::connect_proxy_stream;
@@ -51,6 +54,7 @@ use tokio::net::TcpStream;
 use url::Url;
 use util::{ConnectionResult, ResultExt};
 
+pub use llm_token::*;
 pub use rpc::*;
 pub use telemetry_events::Event;
 pub use user::*;
@@ -339,7 +343,7 @@ pub struct ClientCredentialsProvider {
 impl ClientCredentialsProvider {
     pub fn new(cx: &App) -> Self {
         Self {
-            provider: <dyn CredentialsProvider>::global(cx),
+            provider: zed_credentials_provider::global(cx),
         }
     }
 
@@ -568,6 +572,10 @@ impl Client {
         self.http.clone()
     }
 
+    pub fn credentials_provider(&self) -> Arc<dyn CredentialsProvider> {
+        self.credentials_provider.provider.clone()
+    }
+
     pub fn cloud_client(&self) -> Arc<CloudApiClient> {
         self.cloud_client.clone()
     }
@@ -1513,6 +1521,66 @@ impl Client {
         })
     }
 
+    pub async fn acquire_llm_token(
+        &self,
+        llm_token: &LlmApiToken,
+        organization_id: Option<OrganizationId>,
+    ) -> Result<String> {
+        let system_id = self.telemetry().system_id().map(|x| x.to_string());
+        let cloud_client = self.cloud_client();
+        match llm_token
+            .acquire(&cloud_client, system_id, organization_id)
+            .await
+        {
+            Ok(token) => Ok(token),
+            Err(ClientApiError::Unauthorized) => {
+                self.request_sign_out();
+                Err(ClientApiError::Unauthorized).context("Failed to create LLM token")
+            }
+            Err(err) => Err(anyhow::Error::from(err)),
+        }
+    }
+
+    pub async fn refresh_llm_token(
+        &self,
+        llm_token: &LlmApiToken,
+        organization_id: Option<OrganizationId>,
+    ) -> Result<String> {
+        let system_id = self.telemetry().system_id().map(|x| x.to_string());
+        let cloud_client = self.cloud_client();
+        match llm_token
+            .refresh(&cloud_client, system_id, organization_id)
+            .await
+        {
+            Ok(token) => Ok(token),
+            Err(ClientApiError::Unauthorized) => {
+                self.request_sign_out();
+                return Err(ClientApiError::Unauthorized).context("Failed to create LLM token");
+            }
+            Err(err) => return Err(anyhow::Error::from(err)),
+        }
+    }
+
+    pub async fn clear_and_refresh_llm_token(
+        &self,
+        llm_token: &LlmApiToken,
+        organization_id: Option<OrganizationId>,
+    ) -> Result<String> {
+        let system_id = self.telemetry().system_id().map(|x| x.to_string());
+        let cloud_client = self.cloud_client();
+        match llm_token
+            .clear_and_refresh(&cloud_client, system_id, organization_id)
+            .await
+        {
+            Ok(token) => Ok(token),
+            Err(ClientApiError::Unauthorized) => {
+                self.request_sign_out();
+                return Err(ClientApiError::Unauthorized).context("Failed to create LLM token");
+            }
+            Err(err) => return Err(anyhow::Error::from(err)),
+        }
+    }
+
     pub async fn sign_out(self: &Arc<Self>, cx: &AsyncApp) {
         self.state.write().credentials = None;
         self.cloud_client.clear_credentials();

crates/client/src/llm_token.rs 🔗

@@ -0,0 +1,116 @@
+use super::{Client, UserStore};
+use cloud_api_types::websocket_protocol::MessageToClient;
+use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
+use gpui::{
+    App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
+};
+use language_model::LlmApiToken;
+use std::sync::Arc;
+
+pub trait NeedsLlmTokenRefresh {
+    /// Returns whether the LLM token needs to be refreshed.
+    fn needs_llm_token_refresh(&self) -> bool;
+}
+
+impl NeedsLlmTokenRefresh for http_client::Response<http_client::AsyncBody> {
+    fn needs_llm_token_refresh(&self) -> bool {
+        self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some()
+            || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some()
+    }
+}
+
+enum TokenRefreshMode {
+    Refresh,
+    ClearAndRefresh,
+}
+
+pub fn global_llm_token(cx: &App) -> LlmApiToken {
+    RefreshLlmTokenListener::global(cx)
+        .read(cx)
+        .llm_api_token
+        .clone()
+}
+
+struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
+
+impl Global for GlobalRefreshLlmTokenListener {}
+
+pub struct LlmTokenRefreshedEvent;
+
+pub struct RefreshLlmTokenListener {
+    client: Arc<Client>,
+    user_store: Entity<UserStore>,
+    llm_api_token: LlmApiToken,
+    _subscription: Subscription,
+}
+
+impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
+
+impl RefreshLlmTokenListener {
+    pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
+        let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx));
+        cx.set_global(GlobalRefreshLlmTokenListener(listener));
+    }
+
+    pub fn global(cx: &App) -> Entity<Self> {
+        GlobalRefreshLlmTokenListener::global(cx).0.clone()
+    }
+
+    fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
+        client.add_message_to_client_handler({
+            let this = cx.weak_entity();
+            move |message, cx| {
+                if let Some(this) = this.upgrade() {
+                    Self::handle_refresh_llm_token(this, message, cx);
+                }
+            }
+        });
+
+        let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
+            if matches!(event, super::user::Event::OrganizationChanged) {
+                this.refresh(TokenRefreshMode::ClearAndRefresh, cx);
+            }
+        });
+
+        Self {
+            client,
+            user_store,
+            llm_api_token: LlmApiToken::default(),
+            _subscription: subscription,
+        }
+    }
+
+    fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context<Self>) {
+        let client = self.client.clone();
+        let llm_api_token = self.llm_api_token.clone();
+        let organization_id = self
+            .user_store
+            .read(cx)
+            .current_organization()
+            .map(|organization| organization.id.clone());
+        cx.spawn(async move |this, cx| {
+            match mode {
+                TokenRefreshMode::Refresh => {
+                    client
+                        .refresh_llm_token(&llm_api_token, organization_id)
+                        .await?;
+                }
+                TokenRefreshMode::ClearAndRefresh => {
+                    client
+                        .clear_and_refresh_llm_token(&llm_api_token, organization_id)
+                        .await?;
+                }
+            }
+            this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
+        match message {
+            MessageToClient::UserUpdated => {
+                this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
+            }
+        }
+    }
+}

crates/codestral/Cargo.toml 🔗

@@ -22,6 +22,7 @@ log.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 text.workspace = true
+zed_credentials_provider.workspace = true
 zeta_prompt.workspace = true
 
 [dev-dependencies]

crates/codestral/src/codestral.rs 🔗

@@ -48,9 +48,10 @@ pub fn codestral_api_key(cx: &App) -> Option<Arc<str>> {
 }
 
 pub fn load_codestral_api_key(cx: &mut App) -> Task<Result<(), AuthenticateError>> {
+    let credentials_provider = zed_credentials_provider::global(cx);
     let api_url = codestral_api_url(cx);
     codestral_api_key_state(cx).update(cx, |key_state, cx| {
-        key_state.load_if_needed(api_url, |s| s, cx)
+        key_state.load_if_needed(api_url, |s| s, credentials_provider, cx)
     })
 }
 

crates/collab_ui/src/collab_panel.rs 🔗

@@ -13,12 +13,13 @@ use db::kvp::KeyValueStore;
 use editor::{Editor, EditorElement, EditorStyle};
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
-    AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, Context, DismissEvent,
-    Div, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, InteractiveElement, IntoElement,
-    KeyContext, ListOffset, ListState, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
-    Render, SharedString, Styled, Subscription, Task, TextStyle, WeakEntity, Window, actions,
-    anchored, canvas, deferred, div, fill, list, point, prelude::*, px,
+    AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div,
+    Empty, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, KeyContext, ListOffset,
+    ListState, MouseDownEvent, Pixels, Point, PromptLevel, SharedString, Subscription, Task,
+    TextStyle, WeakEntity, Window, actions, anchored, canvas, deferred, div, fill, list, point,
+    prelude::*, px,
 };
+
 use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
 use project::{Fs, Project};
 use rpc::{
@@ -1091,27 +1092,30 @@ impl CollabPanel {
             room.read(cx).local_participant().role == proto::ChannelRole::Admin
         });
 
+        let end_slot = if is_pending {
+            Label::new("Calling").color(Color::Muted).into_any_element()
+        } else if is_current_user {
+            IconButton::new("leave-call", IconName::Exit)
+                .icon_size(IconSize::Small)
+                .tooltip(Tooltip::text("Leave Call"))
+                .on_click(move |_, window, cx| Self::leave_call(window, cx))
+                .into_any_element()
+        } else if role == proto::ChannelRole::Guest {
+            Label::new("Guest").color(Color::Muted).into_any_element()
+        } else if role == proto::ChannelRole::Talker {
+            Label::new("Mic only")
+                .color(Color::Muted)
+                .into_any_element()
+        } else {
+            Empty.into_any_element()
+        };
+
         ListItem::new(user.github_login.clone())
             .start_slot(Avatar::new(user.avatar_uri.clone()))
             .child(render_participant_name_and_handle(user))
             .toggle_state(is_selected)
-            .end_slot(if is_pending {
-                Label::new("Calling").color(Color::Muted).into_any_element()
-            } else if is_current_user {
-                IconButton::new("leave-call", IconName::Exit)
-                    .style(ButtonStyle::Subtle)
-                    .on_click(move |_, window, cx| Self::leave_call(window, cx))
-                    .tooltip(Tooltip::text("Leave Call"))
-                    .into_any_element()
-            } else if role == proto::ChannelRole::Guest {
-                Label::new("Guest").color(Color::Muted).into_any_element()
-            } else if role == proto::ChannelRole::Talker {
-                Label::new("Mic only")
-                    .color(Color::Muted)
-                    .into_any_element()
-            } else {
-                div().into_any_element()
-            })
+            .end_slot(end_slot)
+            .tooltip(Tooltip::text("Click to Follow"))
             .when_some(peer_id, |el, peer_id| {
                 if role == proto::ChannelRole::Guest {
                     return el;
@@ -1156,6 +1160,7 @@ impl CollabPanel {
         .into();
 
         ListItem::new(project_id as usize)
+            .height(px(24.))
             .toggle_state(is_selected)
             .on_click(cx.listener(move |this, _, window, cx| {
                 this.workspace
@@ -1173,9 +1178,13 @@ impl CollabPanel {
             }))
             .start_slot(
                 h_flex()
-                    .gap_1()
+                    .gap_1p5()
                     .child(render_tree_branch(is_last, false, window, cx))
-                    .child(IconButton::new(0, IconName::Folder)),
+                    .child(
+                        Icon::new(IconName::Folder)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
             )
             .child(Label::new(project_name.clone()))
             .tooltip(Tooltip::text(format!("Open {}", project_name)))
@@ -1192,12 +1201,17 @@ impl CollabPanel {
         let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
 
         ListItem::new(("screen", id))
+            .height(px(24.))
             .toggle_state(is_selected)
             .start_slot(
                 h_flex()
-                    .gap_1()
+                    .gap_1p5()
                     .child(render_tree_branch(is_last, false, window, cx))
-                    .child(IconButton::new(0, IconName::Screen)),
+                    .child(
+                        Icon::new(IconName::Screen)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
             )
             .child(Label::new("Screen"))
             .when_some(peer_id, |this, _| {
@@ -1208,7 +1222,7 @@ impl CollabPanel {
                         })
                         .ok();
                 }))
-                .tooltip(Tooltip::text("Open shared screen"))
+                .tooltip(Tooltip::text("Open Shared Screen"))
             })
     }
 
@@ -1232,7 +1246,9 @@ impl CollabPanel {
     ) -> impl IntoElement {
         let channel_store = self.channel_store.read(cx);
         let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
+
         ListItem::new("channel-notes")
+            .height(px(24.))
             .toggle_state(is_selected)
             .on_click(cx.listener(move |this, _, window, cx| {
                 this.open_channel_notes(channel_id, window, cx);
@@ -1240,17 +1256,25 @@ impl CollabPanel {
             .start_slot(
                 h_flex()
                     .relative()
-                    .gap_1()
+                    .gap_1p5()
                     .child(render_tree_branch(false, true, window, cx))
-                    .child(IconButton::new(0, IconName::File))
-                    .children(has_channel_buffer_changed.then(|| {
-                        div()
-                            .w_1p5()
-                            .absolute()
-                            .right(px(2.))
-                            .top(px(2.))
-                            .child(Indicator::dot().color(Color::Info))
-                    })),
+                    .child(
+                        h_flex()
+                            .child(
+                                Icon::new(IconName::Reader)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .when(has_channel_buffer_changed, |this| {
+                                this.child(
+                                    div()
+                                        .absolute()
+                                        .top_neg_0p5()
+                                        .right_0()
+                                        .child(Indicator::dot().color(Color::Info)),
+                                )
+                            }),
+                    ),
             )
             .child(Label::new("notes"))
             .tooltip(Tooltip::text("Open Channel Notes"))
@@ -3144,10 +3168,14 @@ impl CollabPanel {
             (IconName::Star, Color::Default, "Add to Favorites")
         };
 
+        let height = px(24.);
+
         h_flex()
             .id(ix)
             .group("")
+            .h(height)
             .w_full()
+            .overflow_hidden()
             .when(!channel.is_root_channel(), |el| {
                 el.on_drag(channel.clone(), move |channel, _, _, cx| {
                     cx.new(|_| DraggedChannelView {
@@ -3175,6 +3203,7 @@ impl CollabPanel {
             )
             .child(
                 ListItem::new(ix)
+                    .height(height)
                     // Add one level of depth for the disclosure arrow.
                     .indent_level(depth + 1)
                     .indent_step_size(px(20.))
@@ -3256,12 +3285,13 @@ impl CollabPanel {
             .child(
                 h_flex()
                     .visible_on_hover("")
+                    .h_full()
                     .absolute()
                     .right_0()
                     .px_1()
                     .gap_px()
-                    .bg(cx.theme().colors().background)
                     .rounded_l_md()
+                    .bg(cx.theme().colors().background)
                     .child({
                         let focus_handle = self.focus_handle.clone();
                         IconButton::new("channel_favorite", favorite_icon)
@@ -3335,9 +3365,8 @@ fn render_tree_branch(
 ) -> impl IntoElement {
     let rem_size = window.rem_size();
     let line_height = window.text_style().line_height_in_pixels(rem_size);
-    let width = rem_size * 1.5;
     let thickness = px(1.);
-    let color = cx.theme().colors().text;
+    let color = cx.theme().colors().icon_disabled;
 
     canvas(
         |_, _, _| {},
@@ -3367,8 +3396,8 @@ fn render_tree_branch(
             ));
         },
     )
-    .w(width)
-    .h(line_height)
+    .w(rem_size)
+    .h(line_height - px(2.))
 }
 
 fn render_participant_name_and_handle(user: &User) -> impl IntoElement {

crates/credentials_provider/Cargo.toml 🔗

@@ -13,9 +13,5 @@ path = "src/credentials_provider.rs"
 
 [dependencies]
 anyhow.workspace = true
-futures.workspace = true
 gpui.workspace = true
-paths.workspace = true
-release_channel.workspace = true
 serde.workspace = true
-serde_json.workspace = true

crates/credentials_provider/src/credentials_provider.rs 🔗

@@ -1,26 +1,8 @@
-use std::collections::HashMap;
 use std::future::Future;
-use std::path::PathBuf;
 use std::pin::Pin;
-use std::sync::{Arc, LazyLock};
 
 use anyhow::Result;
-use futures::FutureExt as _;
-use gpui::{App, AsyncApp};
-use release_channel::ReleaseChannel;
-
-/// An environment variable whose presence indicates that the system keychain
-/// should be used in development.
-///
-/// By default, running Zed in development uses the development credentials
-/// provider. Setting this environment variable allows you to interact with the
-/// system keychain (for instance, if you need to test something).
-///
-/// Only works in development. Setting this environment variable in other
-/// release channels is a no-op.
-static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock<bool> = LazyLock::new(|| {
-    std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty())
-});
+use gpui::AsyncApp;
 
 /// A provider for credentials.
 ///
@@ -50,150 +32,3 @@ pub trait CredentialsProvider: Send + Sync {
         cx: &'a AsyncApp,
     ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
 }
-
-impl dyn CredentialsProvider {
-    /// Returns the global [`CredentialsProvider`].
-    pub fn global(cx: &App) -> Arc<Self> {
-        // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it
-        // seems like this is a false positive from Clippy.
-        #[allow(clippy::arc_with_non_send_sync)]
-        Self::new(cx)
-    }
-
-    fn new(cx: &App) -> Arc<Self> {
-        let use_development_provider = match ReleaseChannel::try_global(cx) {
-            Some(ReleaseChannel::Dev) => {
-                // In development we default to using the development
-                // credentials provider to avoid getting spammed by relentless
-                // keychain access prompts.
-                //
-                // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment
-                // variable is set, we will use the actual keychain.
-                !*ZED_DEVELOPMENT_USE_KEYCHAIN
-            }
-            Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable)
-            | None => false,
-        };
-
-        if use_development_provider {
-            Arc::new(DevelopmentCredentialsProvider::new())
-        } else {
-            Arc::new(KeychainCredentialsProvider)
-        }
-    }
-}
-
-/// A credentials provider that stores credentials in the system keychain.
-struct KeychainCredentialsProvider;
-
-impl CredentialsProvider for KeychainCredentialsProvider {
-    fn read_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
-        async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local()
-    }
-
-    fn write_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        username: &'a str,
-        password: &'a [u8],
-        cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
-        async move {
-            cx.update(move |cx| cx.write_credentials(url, username, password))
-                .await
-        }
-        .boxed_local()
-    }
-
-    fn delete_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
-        async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local()
-    }
-}
-
-/// A credentials provider that stores credentials in a local file.
-///
-/// This MUST only be used in development, as this is not a secure way of storing
-/// credentials on user machines.
-///
-/// Its existence is purely to work around the annoyance of having to constantly
-/// re-allow access to the system keychain when developing Zed.
-struct DevelopmentCredentialsProvider {
-    path: PathBuf,
-}
-
-impl DevelopmentCredentialsProvider {
-    fn new() -> Self {
-        let path = paths::config_dir().join("development_credentials");
-
-        Self { path }
-    }
-
-    fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
-        let json = std::fs::read(&self.path)?;
-        let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
-
-        Ok(credentials)
-    }
-
-    fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
-        let json = serde_json::to_string(credentials)?;
-        std::fs::write(&self.path, json)?;
-
-        Ok(())
-    }
-}
-
-impl CredentialsProvider for DevelopmentCredentialsProvider {
-    fn read_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        _cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
-        async move {
-            Ok(self
-                .load_credentials()
-                .unwrap_or_default()
-                .get(url)
-                .cloned())
-        }
-        .boxed_local()
-    }
-
-    fn write_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        username: &'a str,
-        password: &'a [u8],
-        _cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
-        async move {
-            let mut credentials = self.load_credentials().unwrap_or_default();
-            credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
-
-            self.save_credentials(&credentials)
-        }
-        .boxed_local()
-    }
-
-    fn delete_credentials<'a>(
-        &'a self,
-        url: &'a str,
-        _cx: &'a AsyncApp,
-    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
-        async move {
-            let mut credentials = self.load_credentials()?;
-            credentials.remove(url);
-
-            self.save_credentials(&credentials)
-        }
-        .boxed_local()
-    }
-}

crates/edit_prediction/Cargo.toml 🔗

@@ -26,6 +26,7 @@ cloud_llm_client.workspace = true
 collections.workspace = true
 copilot.workspace = true
 copilot_ui.workspace = true
+credentials_provider.workspace = true
 db.workspace = true
 edit_prediction_types.workspace = true
 edit_prediction_context.workspace = true
@@ -65,6 +66,7 @@ uuid.workspace = true
 workspace.workspace = true
 worktree.workspace = true
 zed_actions.workspace = true
+zed_credentials_provider.workspace = true
 zeta_prompt.workspace = true
 zstd.workspace = true
 

crates/edit_prediction/src/capture_example.rs 🔗

@@ -258,6 +258,7 @@ fn generate_timestamp_name() -> String {
 mod tests {
     use super::*;
     use crate::EditPredictionStore;
+    use client::RefreshLlmTokenListener;
     use client::{Client, UserStore};
     use clock::FakeSystemClock;
     use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient};
@@ -548,7 +549,8 @@ mod tests {
             let http_client = FakeHttpClient::with_404_response();
             let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx);
             let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-            language_model::init(user_store.clone(), client.clone(), cx);
+            language_model::init(cx);
+            RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
             EditPredictionStore::global(&client, &user_store, cx);
         })
     }

crates/edit_prediction/src/edit_prediction.rs 🔗

@@ -1,5 +1,5 @@
 use anyhow::Result;
-use client::{Client, EditPredictionUsage, UserStore};
+use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, global_llm_token};
 use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody};
 use cloud_llm_client::predict_edits_v3::{
     PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
@@ -11,6 +11,7 @@ use cloud_llm_client::{
 };
 use collections::{HashMap, HashSet};
 use copilot::{Copilot, Reinstall, SignIn, SignOut};
+use credentials_provider::CredentialsProvider;
 use db::kvp::{Dismissable, KeyValueStore};
 use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile};
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
@@ -30,7 +31,7 @@ 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};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
+use language_model::LlmApiToken;
 use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
 use release_channel::AppVersion;
 use semver::Version;
@@ -150,6 +151,7 @@ pub struct EditPredictionStore {
     rated_predictions: HashSet<EditPredictionId>,
     #[cfg(test)]
     settled_event_callback: Option<Box<dyn Fn(EditPredictionId, String)>>,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 pub(crate) struct EditPredictionRejectionPayload {
@@ -746,7 +748,7 @@ impl EditPredictionStore {
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
         let data_collection_choice = Self::load_data_collection_choice(cx);
 
-        let llm_token = LlmApiToken::global(cx);
+        let llm_token = global_llm_token(cx);
 
         let (reject_tx, reject_rx) = mpsc::unbounded();
         cx.background_spawn({
@@ -787,6 +789,8 @@ impl EditPredictionStore {
             .log_err();
         });
 
+        let credentials_provider = zed_credentials_provider::global(cx);
+
         let this = Self {
             projects: HashMap::default(),
             client,
@@ -807,6 +811,8 @@ impl EditPredictionStore {
             shown_predictions: Default::default(),
             #[cfg(test)]
             settled_event_callback: None,
+
+            credentials_provider,
         };
 
         this
@@ -871,7 +877,9 @@ impl EditPredictionStore {
             let experiments = cx
                 .background_spawn(async move {
                     let http_client = client.http_client();
-                    let token = llm_token.acquire(&client, organization_id).await?;
+                    let token = client
+                        .acquire_llm_token(&llm_token, organization_id.clone())
+                        .await?;
                     let url = http_client.build_zed_llm_url("/edit_prediction_experiments", &[])?;
                     let request = http_client::Request::builder()
                         .method(Method::GET)
@@ -2315,7 +2323,10 @@ impl EditPredictionStore {
                 zeta::request_prediction_with_zeta(self, inputs, capture_data, cx)
             }
             EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx),
-            EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx),
+            EditPredictionModel::Mercury => {
+                self.mercury
+                    .request_prediction(inputs, self.credentials_provider.clone(), cx)
+            }
         };
 
         cx.spawn(async move |this, cx| {
@@ -2536,12 +2547,15 @@ impl EditPredictionStore {
         Res: DeserializeOwned,
     {
         let http_client = client.http_client();
-
         let mut token = if require_auth {
-            Some(llm_token.acquire(&client, organization_id.clone()).await?)
+            Some(
+                client
+                    .acquire_llm_token(&llm_token, organization_id.clone())
+                    .await?,
+            )
         } else {
-            llm_token
-                .acquire(&client, organization_id.clone())
+            client
+                .acquire_llm_token(&llm_token, organization_id.clone())
                 .await
                 .ok()
         };
@@ -2585,7 +2599,11 @@ impl EditPredictionStore {
                 return Ok((serde_json::from_slice(&body)?, usage));
             } else if !did_retry && token.is_some() && response.needs_llm_token_refresh() {
                 did_retry = true;
-                token = Some(llm_token.refresh(&client, organization_id.clone()).await?);
+                token = Some(
+                    client
+                        .refresh_llm_token(&llm_token, organization_id.clone())
+                        .await?,
+                );
             } else {
                 let mut body = String::new();
                 response.body_mut().read_to_string(&mut body).await?;

crates/edit_prediction/src/edit_prediction_tests.rs 🔗

@@ -1,6 +1,6 @@
 use super::*;
 use crate::udiff::apply_diff_to_string;
-use client::{UserStore, test::FakeServer};
+use client::{RefreshLlmTokenListener, UserStore, test::FakeServer};
 use clock::FakeSystemClock;
 use clock::ReplicaId;
 use cloud_api_types::{CreateLlmTokenResponse, LlmToken};
@@ -23,7 +23,7 @@ use language::{
     Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet,
     DiagnosticSeverity, Operation, Point, Selection, SelectionGoal,
 };
-use language_model::RefreshLlmTokenListener;
+
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
 use pretty_assertions::{assert_eq, assert_matches};
@@ -2439,7 +2439,8 @@ fn init_test_with_fake_client(
         client.cloud_client().set_credentials(1, "test".into());
 
         let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
-        language_model::init(user_store.clone(), client.clone(), cx);
+        language_model::init(cx);
+        RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
         let ep_store = EditPredictionStore::global(&client, &user_store, cx);
 
         (
@@ -2891,7 +2892,7 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut
         cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx));
     let user_store = cx.update(|cx| cx.new(|cx| client::UserStore::new(client.clone(), cx)));
     cx.update(|cx| {
-        language_model::RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
+        RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
     });
 
     let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx));

crates/edit_prediction/src/mercury.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
 };
 use anyhow::{Context as _, Result};
 use cloud_llm_client::EditPredictionRejectReason;
+use credentials_provider::CredentialsProvider;
 use futures::AsyncReadExt as _;
 use gpui::{
     App, AppContext as _, Context, Entity, Global, SharedString, Task,
@@ -51,10 +52,11 @@ impl Mercury {
             debug_tx,
             ..
         }: EditPredictionModelInput,
+        credentials_provider: Arc<dyn CredentialsProvider>,
         cx: &mut Context<EditPredictionStore>,
     ) -> Task<Result<Option<EditPredictionResult>>> {
         self.api_token.update(cx, |key_state, cx| {
-            _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx);
+            _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx);
         });
         let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else {
             return Task::ready(Ok(None));
@@ -387,8 +389,9 @@ pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
 }
 
 pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
+    let credentials_provider = zed_credentials_provider::global(cx);
     mercury_api_token(cx).update(cx, |key_state, cx| {
-        key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx)
+        key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, credentials_provider, cx)
     })
 }
 

crates/edit_prediction/src/open_ai_compatible.rs 🔗

@@ -42,9 +42,10 @@ pub fn open_ai_compatible_api_token(cx: &mut App) -> Entity<ApiKeyState> {
 pub fn load_open_ai_compatible_api_token(
     cx: &mut App,
 ) -> Task<Result<(), language_model::AuthenticateError>> {
+    let credentials_provider = zed_credentials_provider::global(cx);
     let api_url = open_ai_compatible_api_url(cx);
     open_ai_compatible_api_token(cx).update(cx, |key_state, cx| {
-        key_state.load_if_needed(api_url, |s| s, cx)
+        key_state.load_if_needed(api_url, |s| s, credentials_provider, cx)
     })
 }
 

crates/edit_prediction_cli/src/headless.rs 🔗

@@ -1,4 +1,4 @@
-use client::{Client, ProxySettings, UserStore};
+use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore};
 use db::AppDatabase;
 use extension::ExtensionHostProxy;
 use fs::RealFs;
@@ -109,7 +109,8 @@ pub fn init(cx: &mut App) -> EpAppState {
 
     debug_adapter_extension::init(extension_host_proxy.clone(), cx);
     language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
-    language_model::init(user_store.clone(), client.clone(), cx);
+    language_model::init(cx);
+    RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
     language_models::init(user_store.clone(), client.clone(), cx);
     languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
     prompt_store::init(cx);

crates/env_var/Cargo.toml 🔗

@@ -0,0 +1,15 @@
+[package]
+name = "env_var"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/env_var.rs"
+
+[dependencies]
+gpui.workspace = true

crates/env_var/src/env_var.rs 🔗

@@ -0,0 +1,40 @@
+use gpui::SharedString;
+
+#[derive(Clone)]
+pub struct EnvVar {
+    pub name: SharedString,
+    /// Value of the environment variable. Also `None` when set to an empty string.
+    pub value: Option<String>,
+}
+
+impl EnvVar {
+    pub fn new(name: SharedString) -> Self {
+        let value = std::env::var(name.as_str()).ok();
+        if value.as_ref().is_some_and(|v| v.is_empty()) {
+            Self { name, value: None }
+        } else {
+            Self { name, value }
+        }
+    }
+
+    pub fn or(self, other: EnvVar) -> EnvVar {
+        if self.value.is_some() { self } else { other }
+    }
+}
+
+/// Creates a `LazyLock<EnvVar>` expression for use in a `static` declaration.
+#[macro_export]
+macro_rules! env_var {
+    ($name:expr) => {
+        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()))
+    };
+}
+
+/// Generates a `LazyLock<bool>` expression for use in a `static` declaration. Checks if the
+/// environment variable exists and is non-empty.
+#[macro_export]
+macro_rules! bool_env_var {
+    ($name:expr) => {
+        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
+    };
+}

crates/eval_cli/src/headless.rs 🔗

@@ -1,7 +1,7 @@
 use std::path::PathBuf;
 use std::sync::Arc;
 
-use client::{Client, ProxySettings, UserStore};
+use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore};
 use db::AppDatabase;
 use extension::ExtensionHostProxy;
 use fs::RealFs;
@@ -108,7 +108,8 @@ pub fn init(cx: &mut App) -> Arc<AgentCliAppState> {
     let extension_host_proxy = ExtensionHostProxy::global(cx);
     debug_adapter_extension::init(extension_host_proxy.clone(), cx);
     language_extension::init(LspAccess::Noop, extension_host_proxy, languages.clone());
-    language_model::init(user_store.clone(), client.clone(), cx);
+    language_model::init(cx);
+    RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
     language_models::init(user_store.clone(), client.clone(), cx);
     languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
     prompt_store::init(cx);

crates/feature_flags/src/flags.rs 🔗

@@ -47,12 +47,6 @@ impl FeatureFlag for DiffReviewFeatureFlag {
     }
 }
 
-pub struct GitGraphFeatureFlag;
-
-impl FeatureFlag for GitGraphFeatureFlag {
-    const NAME: &'static str = "git-graph";
-}
-
 pub struct StreamingEditFileToolFeatureFlag;
 
 impl FeatureFlag for StreamingEditFileToolFeatureFlag {

crates/fs/src/fake_git_repo.rs 🔗

@@ -1135,10 +1135,88 @@ impl GitRepository for FakeGitRepository {
 
     fn diff_checkpoints(
         &self,
-        _base_checkpoint: GitRepositoryCheckpoint,
-        _target_checkpoint: GitRepositoryCheckpoint,
+        base_checkpoint: GitRepositoryCheckpoint,
+        target_checkpoint: GitRepositoryCheckpoint,
     ) -> BoxFuture<'_, Result<String>> {
-        unimplemented!()
+        let executor = self.executor.clone();
+        let checkpoints = self.checkpoints.clone();
+        async move {
+            executor.simulate_random_delay().await;
+            let checkpoints = checkpoints.lock();
+            let base = checkpoints
+                .get(&base_checkpoint.commit_sha)
+                .context(format!(
+                    "invalid base checkpoint: {}",
+                    base_checkpoint.commit_sha
+                ))?;
+            let target = checkpoints
+                .get(&target_checkpoint.commit_sha)
+                .context(format!(
+                    "invalid target checkpoint: {}",
+                    target_checkpoint.commit_sha
+                ))?;
+
+            fn collect_files(
+                entry: &FakeFsEntry,
+                prefix: String,
+                out: &mut std::collections::BTreeMap<String, String>,
+            ) {
+                match entry {
+                    FakeFsEntry::File { content, .. } => {
+                        out.insert(prefix, String::from_utf8_lossy(content).into_owned());
+                    }
+                    FakeFsEntry::Dir { entries, .. } => {
+                        for (name, child) in entries {
+                            let path = if prefix.is_empty() {
+                                name.clone()
+                            } else {
+                                format!("{prefix}/{name}")
+                            };
+                            collect_files(child, path, out);
+                        }
+                    }
+                    FakeFsEntry::Symlink { .. } => {}
+                }
+            }
+
+            let mut base_files = std::collections::BTreeMap::new();
+            let mut target_files = std::collections::BTreeMap::new();
+            collect_files(base, String::new(), &mut base_files);
+            collect_files(target, String::new(), &mut target_files);
+
+            let all_paths: std::collections::BTreeSet<&String> =
+                base_files.keys().chain(target_files.keys()).collect();
+
+            let mut diff = String::new();
+            for path in all_paths {
+                match (base_files.get(path), target_files.get(path)) {
+                    (Some(base_content), Some(target_content))
+                        if base_content != target_content =>
+                    {
+                        diff.push_str(&format!("diff --git a/{path} b/{path}\n"));
+                        diff.push_str(&format!("--- a/{path}\n"));
+                        diff.push_str(&format!("+++ b/{path}\n"));
+                        for line in base_content.lines() {
+                            diff.push_str(&format!("-{line}\n"));
+                        }
+                        for line in target_content.lines() {
+                            diff.push_str(&format!("+{line}\n"));
+                        }
+                    }
+                    (Some(_), None) => {
+                        diff.push_str(&format!("diff --git a/{path} /dev/null\n"));
+                        diff.push_str("deleted file\n");
+                    }
+                    (None, Some(_)) => {
+                        diff.push_str(&format!("diff --git /dev/null b/{path}\n"));
+                        diff.push_str("new file\n");
+                    }
+                    _ => {}
+                }
+            }
+            Ok(diff)
+        }
+        .boxed()
     }
 
     fn default_branch(

crates/fs/tests/integration/fake_git_repo.rs 🔗

@@ -159,7 +159,10 @@ async fn test_checkpoints(executor: BackgroundExecutor) {
             .unwrap()
     );
 
-    repository.restore_checkpoint(checkpoint_1).await.unwrap();
+    repository
+        .restore_checkpoint(checkpoint_1.clone())
+        .await
+        .unwrap();
     assert_eq!(
         fs.files_with_contents(Path::new("")),
         [
@@ -168,4 +171,22 @@ async fn test_checkpoints(executor: BackgroundExecutor) {
             (Path::new(path!("/foo/b")).into(), b"ipsum".into())
         ]
     );
+
+    // diff_checkpoints: identical checkpoints produce empty diff
+    let diff = repository
+        .diff_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
+        .await
+        .unwrap();
+    assert!(
+        diff.is_empty(),
+        "identical checkpoints should produce empty diff"
+    );
+
+    // diff_checkpoints: different checkpoints produce non-empty diff
+    let diff = repository
+        .diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
+        .await
+        .unwrap();
+    assert!(diff.contains("b"), "diff should mention changed file 'b'");
+    assert!(diff.contains("c"), "diff should mention added file 'c'");
 }

crates/git_graph/Cargo.toml 🔗

@@ -24,7 +24,6 @@ anyhow.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
-feature_flags.workspace = true
 git.workspace = true
 git_ui.workspace = true
 gpui.workspace = true

crates/git_graph/src/git_graph.rs 🔗

@@ -1,6 +1,5 @@
 use collections::{BTreeMap, HashMap, IndexSet};
 use editor::Editor;
-use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
 use git::{
     BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
     parse_git_remote_url,
@@ -42,8 +41,10 @@ use theme_settings::ThemeSettings;
 use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
 use ui::{
     ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
-    HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
-    TableResizeBehavior, Tooltip, WithScrollbar, prelude::*,
+    HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table,
+    TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar,
+    bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles,
+    render_table_header, table_row::TableRow,
 };
 use workspace::{
     Workspace,
@@ -730,8 +731,7 @@ pub fn init(cx: &mut App) {
     cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
         workspace.register_action_renderer(|div, workspace, _, cx| {
             div.when(
-                workspace.project().read(cx).active_repository(cx).is_some()
-                    && cx.has_flag::<GitGraphFeatureFlag>(),
+                workspace.project().read(cx).active_repository(cx).is_some(),
                 |div| {
                     let workspace = workspace.weak_handle();
 
@@ -901,9 +901,8 @@ pub struct GitGraph {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     row_height: Pixels,
     table_interaction_state: Entity<TableInteractionState>,
-    table_column_widths: Entity<RedistributableColumnsState>,
+    column_widths: Entity<RedistributableColumnsState>,
     horizontal_scroll_offset: Pixels,
-    graph_viewport_width: Pixels,
     selected_entry_idx: Option<usize>,
     hovered_entry_idx: Option<usize>,
     graph_canvas_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
@@ -933,8 +932,60 @@ impl GitGraph {
         font_size + px(12.0)
     }
 
-    fn graph_content_width(&self) -> Pixels {
-        (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
+    fn graph_canvas_content_width(&self) -> Pixels {
+        (LANE_WIDTH * self.graph_data.max_lanes.max(6) as f32) + LEFT_PADDING * 2.0
+    }
+
+    fn preview_column_fractions(&self, window: &Window, cx: &App) -> [f32; 5] {
+        let fractions = self
+            .column_widths
+            .read(cx)
+            .preview_fractions(window.rem_size());
+        [
+            fractions[0],
+            fractions[1],
+            fractions[2],
+            fractions[3],
+            fractions[4],
+        ]
+    }
+
+    fn table_column_width_config(&self, window: &Window, cx: &App) -> ColumnWidthConfig {
+        let [_, description, date, author, commit] = self.preview_column_fractions(window, cx);
+        let table_total = description + date + author + commit;
+
+        let widths = if table_total > 0.0 {
+            vec![
+                DefiniteLength::Fraction(description / table_total),
+                DefiniteLength::Fraction(date / table_total),
+                DefiniteLength::Fraction(author / table_total),
+                DefiniteLength::Fraction(commit / table_total),
+            ]
+        } else {
+            vec![
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+                DefiniteLength::Fraction(0.25),
+            ]
+        };
+
+        ColumnWidthConfig::explicit(widths)
+    }
+
+    fn graph_viewport_width(&self, window: &Window, cx: &App) -> Pixels {
+        self.column_widths
+            .read(cx)
+            .preview_column_width(0, window)
+            .unwrap_or_else(|| self.graph_canvas_content_width())
+    }
+
+    fn clamp_horizontal_scroll_offset(&mut self, graph_viewport_width: Pixels) {
+        let max_horizontal_scroll =
+            (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
+        self.horizontal_scroll_offset = self
+            .horizontal_scroll_offset
+            .clamp(px(0.), max_horizontal_scroll);
     }
 
     pub fn new(
@@ -972,20 +1023,22 @@ impl GitGraph {
         });
 
         let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
-        let table_column_widths = cx.new(|_cx| {
+        let column_widths = cx.new(|_cx| {
             RedistributableColumnsState::new(
-                4,
+                5,
                 vec![
-                    DefiniteLength::Fraction(0.72),
-                    DefiniteLength::Fraction(0.12),
-                    DefiniteLength::Fraction(0.10),
-                    DefiniteLength::Fraction(0.06),
+                    DefiniteLength::Fraction(0.14),
+                    DefiniteLength::Fraction(0.6192),
+                    DefiniteLength::Fraction(0.1032),
+                    DefiniteLength::Fraction(0.086),
+                    DefiniteLength::Fraction(0.0516),
                 ],
                 vec![
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
                     TableResizeBehavior::Resizable,
+                    TableResizeBehavior::Resizable,
                 ],
             )
         });
@@ -1020,9 +1073,8 @@ impl GitGraph {
             context_menu: None,
             row_height,
             table_interaction_state,
-            table_column_widths,
+            column_widths,
             horizontal_scroll_offset: px(0.),
-            graph_viewport_width: px(88.),
             selected_entry_idx: None,
             hovered_entry_idx: None,
             graph_canvas_bounds: Rc::new(Cell::new(None)),
@@ -2089,8 +2141,12 @@ impl GitGraph {
         let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
         let horizontal_scroll_offset = self.horizontal_scroll_offset;
 
-        let max_lanes = self.graph_data.max_lanes.max(6);
-        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
+        let graph_viewport_width = self.graph_viewport_width(window, cx);
+        let graph_width = if self.graph_canvas_content_width() > graph_viewport_width {
+            self.graph_canvas_content_width()
+        } else {
+            graph_viewport_width
+        };
         let last_visible_row =
             first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
 
@@ -2414,9 +2470,9 @@ impl GitGraph {
         let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
         let new_offset = Point::new(current_offset.x, new_y);
 
-        let max_lanes = self.graph_data.max_lanes.max(1);
-        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
-        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
+        let graph_viewport_width = self.graph_viewport_width(window, cx);
+        let max_horizontal_scroll =
+            (self.graph_canvas_content_width() - graph_viewport_width).max(px(0.));
 
         let new_horizontal_offset =
             (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
@@ -2497,6 +2553,8 @@ impl Render for GitGraph {
                             cx,
                         );
                         self.graph_data.add_commits(&commits);
+                        let graph_viewport_width = self.graph_viewport_width(window, cx);
+                        self.clamp_horizontal_scroll_offset(graph_viewport_width);
                         (commits.len(), is_loading)
                     })
                 } else {
@@ -2527,118 +2585,202 @@ impl Render for GitGraph {
                     this.child(self.render_loading_spinner(cx))
                 })
         } else {
-            div()
+            let header_resize_info = HeaderResizeInfo::from_state(&self.column_widths, cx);
+            let header_context = TableRenderContext::for_column_widths(
+                Some(self.column_widths.read(cx).widths_to_render()),
+                true,
+            );
+            let [
+                graph_fraction,
+                description_fraction,
+                date_fraction,
+                author_fraction,
+                commit_fraction,
+            ] = self.preview_column_fractions(window, cx);
+            let table_fraction =
+                description_fraction + date_fraction + author_fraction + commit_fraction;
+            let table_width_config = self.table_column_width_config(window, cx);
+            let graph_viewport_width = self.graph_viewport_width(window, cx);
+            self.clamp_horizontal_scroll_offset(graph_viewport_width);
+
+            h_flex()
                 .size_full()
-                .flex()
-                .flex_row()
                 .child(
                     div()
-                        .w(self.graph_content_width())
-                        .h_full()
+                        .flex_1()
+                        .min_w_0()
+                        .size_full()
                         .flex()
                         .flex_col()
-                        .child(
-                            div()
-                                .flex()
-                                .items_center()
-                                .px_1()
-                                .py_0p5()
-                                .border_b_1()
-                                .whitespace_nowrap()
-                                .border_color(cx.theme().colors().border)
-                                .child(Label::new("Graph").color(Color::Muted)),
-                        )
-                        .child(
-                            div()
-                                .id("graph-canvas")
-                                .flex_1()
-                                .overflow_hidden()
-                                .child(self.render_graph(window, cx))
-                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll))
-                                .on_mouse_move(cx.listener(Self::handle_graph_mouse_move))
-                                .on_click(cx.listener(Self::handle_graph_click))
-                                .on_hover(cx.listener(|this, &is_hovered: &bool, _, cx| {
-                                    if !is_hovered && this.hovered_entry_idx.is_some() {
-                                        this.hovered_entry_idx = None;
-                                        cx.notify();
-                                    }
-                                })),
-                        ),
-                )
-                .child({
-                    let row_height = self.row_height;
-                    let selected_entry_idx = self.selected_entry_idx;
-                    let hovered_entry_idx = self.hovered_entry_idx;
-                    let weak_self = cx.weak_entity();
-                    let focus_handle = self.focus_handle.clone();
-                    div().flex_1().size_full().child(
-                        Table::new(4)
-                            .interactable(&self.table_interaction_state)
-                            .hide_row_borders()
-                            .hide_row_hover()
-                            .header(vec![
-                                Label::new("Description")
-                                    .color(Color::Muted)
-                                    .into_any_element(),
-                                Label::new("Date").color(Color::Muted).into_any_element(),
-                                Label::new("Author").color(Color::Muted).into_any_element(),
-                                Label::new("Commit").color(Color::Muted).into_any_element(),
-                            ])
-                            .width_config(ColumnWidthConfig::redistributable(
-                                self.table_column_widths.clone(),
-                            ))
-                            .map_row(move |(index, row), window, cx| {
-                                let is_selected = selected_entry_idx == Some(index);
-                                let is_hovered = hovered_entry_idx == Some(index);
-                                let is_focused = focus_handle.is_focused(window);
-                                let weak = weak_self.clone();
-                                let weak_for_hover = weak.clone();
-
-                                let hover_bg = cx.theme().colors().element_hover.opacity(0.6);
-                                let selected_bg = if is_focused {
-                                    cx.theme().colors().element_selected
-                                } else {
-                                    cx.theme().colors().element_hover
-                                };
-
-                                row.h(row_height)
-                                    .when(is_selected, |row| row.bg(selected_bg))
-                                    .when(is_hovered && !is_selected, |row| row.bg(hover_bg))
-                                    .on_hover(move |&is_hovered, _, cx| {
-                                        weak_for_hover
-                                            .update(cx, |this, cx| {
-                                                if is_hovered {
-                                                    if this.hovered_entry_idx != Some(index) {
-                                                        this.hovered_entry_idx = Some(index);
-                                                        cx.notify();
-                                                    }
-                                                } else if this.hovered_entry_idx == Some(index) {
-                                                    // Only clear if this row was the hovered one
-                                                    this.hovered_entry_idx = None;
-                                                    cx.notify();
-                                                }
-                                            })
-                                            .ok();
-                                    })
-                                    .on_click(move |event, window, cx| {
-                                        let click_count = event.click_count();
-                                        weak.update(cx, |this, cx| {
-                                            this.select_entry(index, ScrollStrategy::Center, cx);
-                                            if click_count >= 2 {
-                                                this.open_commit_view(index, window, cx);
-                                            }
-                                        })
-                                        .ok();
-                                    })
-                                    .into_any_element()
-                            })
-                            .uniform_list(
-                                "git-graph-commits",
-                                commit_count,
-                                cx.processor(Self::render_table_rows),
+                        .child(render_table_header(
+                            TableRow::from_vec(
+                                vec![
+                                    Label::new("Graph")
+                                        .color(Color::Muted)
+                                        .truncate()
+                                        .into_any_element(),
+                                    Label::new("Description")
+                                        .color(Color::Muted)
+                                        .into_any_element(),
+                                    Label::new("Date").color(Color::Muted).into_any_element(),
+                                    Label::new("Author").color(Color::Muted).into_any_element(),
+                                    Label::new("Commit").color(Color::Muted).into_any_element(),
+                                ],
+                                5,
                             ),
-                    )
-                })
+                            header_context,
+                            Some(header_resize_info),
+                            Some(self.column_widths.entity_id()),
+                            cx,
+                        ))
+                        .child({
+                            let row_height = self.row_height;
+                            let selected_entry_idx = self.selected_entry_idx;
+                            let hovered_entry_idx = self.hovered_entry_idx;
+                            let weak_self = cx.weak_entity();
+                            let focus_handle = self.focus_handle.clone();
+
+                            bind_redistributable_columns(
+                                div()
+                                    .relative()
+                                    .flex_1()
+                                    .w_full()
+                                    .overflow_hidden()
+                                    .child(
+                                        h_flex()
+                                            .size_full()
+                                            .child(
+                                                div()
+                                                    .w(DefiniteLength::Fraction(graph_fraction))
+                                                    .h_full()
+                                                    .min_w_0()
+                                                    .overflow_hidden()
+                                                    .child(
+                                                        div()
+                                                            .id("graph-canvas")
+                                                            .size_full()
+                                                            .overflow_hidden()
+                                                            .child(
+                                                                div()
+                                                                    .size_full()
+                                                                    .child(self.render_graph(window, cx)),
+                                                            )
+                                                            .on_scroll_wheel(
+                                                                cx.listener(Self::handle_graph_scroll),
+                                                            )
+                                                            .on_mouse_move(
+                                                                cx.listener(Self::handle_graph_mouse_move),
+                                                            )
+                                                            .on_click(cx.listener(Self::handle_graph_click))
+                                                            .on_hover(cx.listener(
+                                                                |this, &is_hovered: &bool, _, cx| {
+                                                                    if !is_hovered
+                                                                        && this.hovered_entry_idx.is_some()
+                                                                    {
+                                                                        this.hovered_entry_idx = None;
+                                                                        cx.notify();
+                                                                    }
+                                                                },
+                                                            )),
+                                                    ),
+                                            )
+                                            .child(
+                                                div()
+                                                    .w(DefiniteLength::Fraction(table_fraction))
+                                                    .h_full()
+                                                    .min_w_0()
+                                                    .child(
+                                                        Table::new(4)
+                                                            .interactable(&self.table_interaction_state)
+                                                            .hide_row_borders()
+                                                            .hide_row_hover()
+                                                            .width_config(table_width_config)
+                                                            .map_row(move |(index, row), window, cx| {
+                                                                let is_selected =
+                                                                    selected_entry_idx == Some(index);
+                                                                let is_hovered =
+                                                                    hovered_entry_idx == Some(index);
+                                                                let is_focused =
+                                                                    focus_handle.is_focused(window);
+                                                                let weak = weak_self.clone();
+                                                                let weak_for_hover = weak.clone();
+
+                                                                let hover_bg = cx
+                                                                    .theme()
+                                                                    .colors()
+                                                                    .element_hover
+                                                                    .opacity(0.6);
+                                                                let selected_bg = if is_focused {
+                                                                    cx.theme().colors().element_selected
+                                                                } else {
+                                                                    cx.theme().colors().element_hover
+                                                                };
+
+                                                                row.h(row_height)
+                                                                    .when(is_selected, |row| row.bg(selected_bg))
+                                                                    .when(
+                                                                        is_hovered && !is_selected,
+                                                                        |row| row.bg(hover_bg),
+                                                                    )
+                                                                    .on_hover(move |&is_hovered, _, cx| {
+                                                                        weak_for_hover
+                                                                            .update(cx, |this, cx| {
+                                                                                if is_hovered {
+                                                                                    if this.hovered_entry_idx
+                                                                                        != Some(index)
+                                                                                    {
+                                                                                        this.hovered_entry_idx =
+                                                                                            Some(index);
+                                                                                        cx.notify();
+                                                                                    }
+                                                                                } else if this
+                                                                                    .hovered_entry_idx
+                                                                                    == Some(index)
+                                                                                {
+                                                                                    this.hovered_entry_idx =
+                                                                                        None;
+                                                                                    cx.notify();
+                                                                                }
+                                                                            })
+                                                                            .ok();
+                                                                    })
+                                                                    .on_click(move |event, window, cx| {
+                                                                        let click_count = event.click_count();
+                                                                        weak.update(cx, |this, cx| {
+                                                                            this.select_entry(
+                                                                                index,
+                                                                                ScrollStrategy::Center,
+                                                                                cx,
+                                                                            );
+                                                                            if click_count >= 2 {
+                                                                                this.open_commit_view(
+                                                                                    index,
+                                                                                    window,
+                                                                                    cx,
+                                                                                );
+                                                                            }
+                                                                        })
+                                                                        .ok();
+                                                                    })
+                                                                    .into_any_element()
+                                                            })
+                                                            .uniform_list(
+                                                                "git-graph-commits",
+                                                                commit_count,
+                                                                cx.processor(Self::render_table_rows),
+                                                            ),
+                                                    ),
+                                            ),
+                                    )
+                                    .child(render_redistributable_columns_resize_handles(
+                                        &self.column_widths,
+                                        window,
+                                        cx,
+                                    )),
+                                self.column_widths.clone(),
+                            )
+                        }),
+                )
                 .on_drag_move::<DraggedSplitHandle>(cx.listener(|this, event, window, cx| {
                     this.commit_details_split_state.update(cx, |state, cx| {
                         state.on_drag_move(event, window, cx);
@@ -3734,9 +3876,11 @@ mod tests {
         });
         cx.run_until_parked();
 
-        git_graph.update_in(&mut *cx, |this, window, cx| {
-            this.render(window, cx);
-        });
+        cx.draw(
+            point(px(0.), px(0.)),
+            gpui::size(px(1200.), px(800.)),
+            |_, _| git_graph.clone().into_any_element(),
+        );
         cx.run_until_parked();
 
         let commit_count_after_switch_back =

crates/git_ui/Cargo.toml 🔗

@@ -27,7 +27,6 @@ db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 futures.workspace = true
-feature_flags.workspace = true
 fuzzy.workspace = true
 git.workspace = true
 gpui.workspace = true

crates/git_ui/src/commit_view.rs 🔗

@@ -3,7 +3,6 @@ use buffer_diff::BufferDiff;
 use collections::HashMap;
 use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
 use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
-use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
 use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
 use git::status::{FileStatus, StatusCode, TrackedStatus};
 use git::{
@@ -1045,21 +1044,19 @@ impl Render for CommitViewToolbar {
                     }),
             )
             .when(!is_stash, |this| {
-                this.when(cx.has_flag::<GitGraphFeatureFlag>(), |this| {
-                    this.child(
-                        IconButton::new("show-in-git-graph", IconName::GitGraph)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("Show in Git Graph"))
-                            .on_click(move |_, window, cx| {
-                                window.dispatch_action(
-                                    Box::new(crate::git_panel::OpenAtCommit {
-                                        sha: sha_for_graph.clone(),
-                                    }),
-                                    cx,
-                                );
-                            }),
-                    )
-                })
+                this.child(
+                    IconButton::new("show-in-git-graph", IconName::GitGraph)
+                        .icon_size(IconSize::Small)
+                        .tooltip(Tooltip::text("Show in Git Graph"))
+                        .on_click(move |_, window, cx| {
+                            window.dispatch_action(
+                                Box::new(crate::git_panel::OpenAtCommit {
+                                    sha: sha_for_graph.clone(),
+                                }),
+                                cx,
+                            );
+                        }),
+                )
                 .children(remote_info.map(|(provider_name, url)| {
                     let icon = match provider_name.as_str() {
                         "GitHub" => IconName::Github,

crates/git_ui/src/git_panel.rs 🔗

@@ -20,7 +20,6 @@ use editor::{
     actions::ExpandAllDiffHunks,
 };
 use editor::{EditorStyle, RewrapOptions};
-use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
 use file_icons::FileIcons;
 use futures::StreamExt as _;
 use git::commit::ParsedCommitMessage;
@@ -4535,7 +4534,6 @@ impl GitPanel {
         let commit = branch.most_recent_commit.as_ref()?.clone();
         let workspace = self.workspace.clone();
         let this = cx.entity();
-        let can_open_git_graph = cx.has_flag::<GitGraphFeatureFlag>();
 
         Some(
             h_flex()
@@ -4613,18 +4611,16 @@ impl GitPanel {
                                     ),
                             )
                         })
-                        .when(can_open_git_graph, |this| {
-                            this.child(
-                                panel_icon_button("git-graph-button", IconName::GitGraph)
-                                    .icon_size(IconSize::Small)
-                                    .tooltip(|_window, cx| {
-                                        Tooltip::for_action("Open Git Graph", &Open, cx)
-                                    })
-                                    .on_click(|_, window, cx| {
-                                        window.dispatch_action(Open.boxed_clone(), cx)
-                                    }),
-                            )
-                        }),
+                        .child(
+                            panel_icon_button("git-graph-button", IconName::GitGraph)
+                                .icon_size(IconSize::Small)
+                                .tooltip(|_window, cx| {
+                                    Tooltip::for_action("Open Git Graph", &Open, cx)
+                                })
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(Open.boxed_clone(), cx)
+                                }),
+                        ),
                 ),
         )
     }

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

@@ -427,6 +427,13 @@ impl ListState {
         self.0.borrow().follow_tail
     }
 
+    /// Returns whether the list is scrolled to the bottom (within 1px).
+    pub fn is_at_bottom(&self) -> bool {
+        let current_offset = self.scroll_px_offset_for_scrollbar().y.abs();
+        let max_offset = self.max_offset_for_scrollbar().y;
+        current_offset >= max_offset - px(1.0)
+    }
+
     /// Scroll the list to the given offset
     pub fn scroll_to(&self, mut scroll_top: ListOffset) {
         let state = &mut *self.0.borrow_mut();

crates/grammars/src/javascript/highlights.scm 🔗

@@ -328,26 +328,26 @@
 ; JSX elements
 (jsx_opening_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_closing_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_self_closing_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_opening_element

crates/grammars/src/tsx/highlights.scm 🔗

@@ -389,26 +389,26 @@
 
 (jsx_opening_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_closing_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_self_closing_element
   [
-    (identifier) @type
+    (identifier) @type @tag.component.jsx
     (member_expression
-      object: (identifier) @type
-      property: (property_identifier) @type)
+      object: (identifier) @type @tag.component.jsx
+      property: (property_identifier) @type @tag.component.jsx)
   ])
 
 (jsx_opening_element

crates/icons/src/icons.rs 🔗

@@ -95,6 +95,7 @@ pub enum IconName {
     DebugStepOver,
     Diff,
     DiffSplit,
+    DiffSplitAuto,
     DiffUnified,
     Disconnected,
     Download,

crates/language_model/Cargo.toml 🔗

@@ -20,11 +20,11 @@ anthropic = { workspace = true, features = ["schemars"] }
 anyhow.workspace = true
 credentials_provider.workspace = true
 base64.workspace = true
-client.workspace = true
 cloud_api_client.workspace = true
 cloud_api_types.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true
+env_var.workspace = true
 futures.workspace = true
 gpui.workspace = true
 http_client.workspace = true
@@ -40,7 +40,6 @@ serde_json.workspace = true
 smol.workspace = true
 thiserror.workspace = true
 util.workspace = true
-zed_env_vars.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }

crates/language_model/src/api_key.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Result, anyhow};
 use credentials_provider::CredentialsProvider;
+use env_var::EnvVar;
 use futures::{FutureExt, future};
 use gpui::{AsyncApp, Context, SharedString, Task};
 use std::{
@@ -7,7 +8,6 @@ use std::{
     sync::Arc,
 };
 use util::ResultExt as _;
-use zed_env_vars::EnvVar;
 
 use crate::AuthenticateError;
 
@@ -101,6 +101,7 @@ impl ApiKeyState {
         url: SharedString,
         key: Option<String>,
         get_this: impl Fn(&mut Ent) -> &mut Self + 'static,
+        provider: Arc<dyn CredentialsProvider>,
         cx: &Context<Ent>,
     ) -> Task<Result<()>> {
         if self.is_from_env_var() {
@@ -108,18 +109,14 @@ impl ApiKeyState {
                 "bug: attempted to store API key in system keychain when API key is from env var",
             )));
         }
-        let credentials_provider = <dyn CredentialsProvider>::global(cx);
         cx.spawn(async move |ent, cx| {
             if let Some(key) = &key {
-                credentials_provider
+                provider
                     .write_credentials(&url, "Bearer", key.as_bytes(), cx)
                     .await
                     .log_err();
             } else {
-                credentials_provider
-                    .delete_credentials(&url, cx)
-                    .await
-                    .log_err();
+                provider.delete_credentials(&url, cx).await.log_err();
             }
             ent.update(cx, |ent, cx| {
                 let this = get_this(ent);
@@ -144,12 +141,13 @@ impl ApiKeyState {
         &mut self,
         url: SharedString,
         get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
+        provider: Arc<dyn CredentialsProvider>,
         cx: &mut Context<Ent>,
     ) {
         if url != self.url {
             if !self.is_from_env_var() {
                 // loading will continue even though this result task is dropped
-                let _task = self.load_if_needed(url, get_this, cx);
+                let _task = self.load_if_needed(url, get_this, provider, cx);
             }
         }
     }
@@ -163,6 +161,7 @@ impl ApiKeyState {
         &mut self,
         url: SharedString,
         get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
+        provider: Arc<dyn CredentialsProvider>,
         cx: &mut Context<Ent>,
     ) -> Task<Result<(), AuthenticateError>> {
         if let LoadStatus::Loaded { .. } = &self.load_status
@@ -185,7 +184,7 @@ impl ApiKeyState {
         let task = if let Some(load_task) = &self.load_task {
             load_task.clone()
         } else {
-            let load_task = Self::load(url.clone(), get_this.clone(), cx).shared();
+            let load_task = Self::load(url.clone(), get_this.clone(), provider, cx).shared();
             self.url = url;
             self.load_status = LoadStatus::NotPresent;
             self.load_task = Some(load_task.clone());
@@ -206,14 +205,13 @@ impl ApiKeyState {
     fn load<Ent: 'static>(
         url: SharedString,
         get_this: impl Fn(&mut Ent) -> &mut Self + 'static,
+        provider: Arc<dyn CredentialsProvider>,
         cx: &Context<Ent>,
     ) -> Task<()> {
-        let credentials_provider = <dyn CredentialsProvider>::global(cx);
         cx.spawn({
             async move |ent, cx| {
                 let load_status =
-                    ApiKey::load_from_system_keychain_impl(&url, credentials_provider.as_ref(), cx)
-                        .await;
+                    ApiKey::load_from_system_keychain_impl(&url, provider.as_ref(), cx).await;
                 ent.update(cx, |ent, cx| {
                     let this = get_this(ent);
                     this.url = url;

crates/language_model/src/language_model.rs 🔗

@@ -11,12 +11,10 @@ pub mod tool_schema;
 pub mod fake_provider;
 
 use anyhow::{Result, anyhow};
-use client::Client;
-use client::UserStore;
 use cloud_llm_client::CompletionRequestStatus;
 use futures::FutureExt;
 use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Entity, SharedString, Task, Window};
+use gpui::{AnyView, App, AsyncApp, SharedString, Task, Window};
 use http_client::{StatusCode, http};
 use icons::IconName;
 use parking_lot::Mutex;
@@ -36,15 +34,10 @@ pub use crate::registry::*;
 pub use crate::request::*;
 pub use crate::role::*;
 pub use crate::tool_schema::LanguageModelToolSchemaFormat;
+pub use env_var::{EnvVar, env_var};
 pub use provider::*;
-pub use zed_env_vars::{EnvVar, env_var};
 
-pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
-    init_settings(cx);
-    RefreshLlmTokenListener::register(client, user_store, cx);
-}
-
-pub fn init_settings(cx: &mut App) {
+pub fn init(cx: &mut App) {
     registry::init(cx);
 }
 

crates/language_model/src/model/cloud_model.rs 🔗

@@ -1,16 +1,9 @@
 use std::fmt;
 use std::sync::Arc;
 
-use anyhow::{Context as _, Result};
-use client::Client;
-use client::UserStore;
 use cloud_api_client::ClientApiError;
+use cloud_api_client::CloudApiClient;
 use cloud_api_types::OrganizationId;
-use cloud_api_types::websocket_protocol::MessageToClient;
-use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
-use gpui::{
-    App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
-};
 use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
 use thiserror::Error;
 
@@ -30,18 +23,12 @@ impl fmt::Display for PaymentRequiredError {
 pub struct LlmApiToken(Arc<RwLock<Option<String>>>);
 
 impl LlmApiToken {
-    pub fn global(cx: &App) -> Self {
-        RefreshLlmTokenListener::global(cx)
-            .read(cx)
-            .llm_api_token
-            .clone()
-    }
-
     pub async fn acquire(
         &self,
-        client: &Arc<Client>,
+        client: &CloudApiClient,
+        system_id: Option<String>,
         organization_id: Option<OrganizationId>,
-    ) -> Result<String> {
+    ) -> Result<String, ClientApiError> {
         let lock = self.0.upgradable_read().await;
         if let Some(token) = lock.as_ref() {
             Ok(token.to_string())
@@ -49,6 +36,7 @@ impl LlmApiToken {
             Self::fetch(
                 RwLockUpgradableReadGuard::upgrade(lock).await,
                 client,
+                system_id,
                 organization_id,
             )
             .await
@@ -57,10 +45,11 @@ impl LlmApiToken {
 
     pub async fn refresh(
         &self,
-        client: &Arc<Client>,
+        client: &CloudApiClient,
+        system_id: Option<String>,
         organization_id: Option<OrganizationId>,
-    ) -> Result<String> {
-        Self::fetch(self.0.write().await, client, organization_id).await
+    ) -> Result<String, ClientApiError> {
+        Self::fetch(self.0.write().await, client, system_id, organization_id).await
     }
 
     /// Clears the existing token before attempting to fetch a new one.
@@ -69,28 +58,22 @@ impl LlmApiToken {
     /// leave a token for the wrong organization.
     pub async fn clear_and_refresh(
         &self,
-        client: &Arc<Client>,
+        client: &CloudApiClient,
+        system_id: Option<String>,
         organization_id: Option<OrganizationId>,
-    ) -> Result<String> {
+    ) -> Result<String, ClientApiError> {
         let mut lock = self.0.write().await;
         *lock = None;
-        Self::fetch(lock, client, organization_id).await
+        Self::fetch(lock, client, system_id, organization_id).await
     }
 
     async fn fetch(
         mut lock: RwLockWriteGuard<'_, Option<String>>,
-        client: &Arc<Client>,
+        client: &CloudApiClient,
+        system_id: Option<String>,
         organization_id: Option<OrganizationId>,
-    ) -> Result<String> {
-        let system_id = client
-            .telemetry()
-            .system_id()
-            .map(|system_id| system_id.to_string());
-
-        let result = client
-            .cloud_client()
-            .create_llm_token(system_id, organization_id)
-            .await;
+    ) -> Result<String, ClientApiError> {
+        let result = client.create_llm_token(system_id, organization_id).await;
         match result {
             Ok(response) => {
                 *lock = Some(response.token.0.clone());
@@ -98,112 +81,7 @@ impl LlmApiToken {
             }
             Err(err) => {
                 *lock = None;
-                match err {
-                    ClientApiError::Unauthorized => {
-                        client.request_sign_out();
-                        Err(err).context("Failed to create LLM token")
-                    }
-                    ClientApiError::Other(err) => Err(err),
-                }
-            }
-        }
-    }
-}
-
-pub trait NeedsLlmTokenRefresh {
-    /// Returns whether the LLM token needs to be refreshed.
-    fn needs_llm_token_refresh(&self) -> bool;
-}
-
-impl NeedsLlmTokenRefresh for http_client::Response<http_client::AsyncBody> {
-    fn needs_llm_token_refresh(&self) -> bool {
-        self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some()
-            || self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some()
-    }
-}
-
-enum TokenRefreshMode {
-    Refresh,
-    ClearAndRefresh,
-}
-
-struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
-
-impl Global for GlobalRefreshLlmTokenListener {}
-
-pub struct LlmTokenRefreshedEvent;
-
-pub struct RefreshLlmTokenListener {
-    client: Arc<Client>,
-    user_store: Entity<UserStore>,
-    llm_api_token: LlmApiToken,
-    _subscription: Subscription,
-}
-
-impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
-
-impl RefreshLlmTokenListener {
-    pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
-        let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx));
-        cx.set_global(GlobalRefreshLlmTokenListener(listener));
-    }
-
-    pub fn global(cx: &App) -> Entity<Self> {
-        GlobalRefreshLlmTokenListener::global(cx).0.clone()
-    }
-
-    fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
-        client.add_message_to_client_handler({
-            let this = cx.weak_entity();
-            move |message, cx| {
-                if let Some(this) = this.upgrade() {
-                    Self::handle_refresh_llm_token(this, message, cx);
-                }
-            }
-        });
-
-        let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
-            if matches!(event, client::user::Event::OrganizationChanged) {
-                this.refresh(TokenRefreshMode::ClearAndRefresh, cx);
-            }
-        });
-
-        Self {
-            client,
-            user_store,
-            llm_api_token: LlmApiToken::default(),
-            _subscription: subscription,
-        }
-    }
-
-    fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context<Self>) {
-        let client = self.client.clone();
-        let llm_api_token = self.llm_api_token.clone();
-        let organization_id = self
-            .user_store
-            .read(cx)
-            .current_organization()
-            .map(|organization| organization.id.clone());
-        cx.spawn(async move |this, cx| {
-            match mode {
-                TokenRefreshMode::Refresh => {
-                    llm_api_token.refresh(&client, organization_id).await?;
-                }
-                TokenRefreshMode::ClearAndRefresh => {
-                    llm_api_token
-                        .clear_and_refresh(&client, organization_id)
-                        .await?;
-                }
-            }
-            this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
-        match message {
-            MessageToClient::UserUpdated => {
-                this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
+                Err(err)
             }
         }
     }

crates/language_models/src/language_models.rs 🔗

@@ -3,6 +3,7 @@ use std::sync::Arc;
 use ::settings::{Settings, SettingsStore};
 use client::{Client, UserStore};
 use collections::HashSet;
+use credentials_provider::CredentialsProvider;
 use gpui::{App, Context, Entity};
 use language_model::{LanguageModelProviderId, LanguageModelRegistry};
 use provider::deepseek::DeepSeekLanguageModelProvider;
@@ -31,9 +32,16 @@ use crate::provider::x_ai::XAiLanguageModelProvider;
 pub use crate::settings::*;
 
 pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
+    let credentials_provider = client.credentials_provider();
     let registry = LanguageModelRegistry::global(cx);
     registry.update(cx, |registry, cx| {
-        register_language_model_providers(registry, user_store, client.clone(), cx);
+        register_language_model_providers(
+            registry,
+            user_store,
+            client.clone(),
+            credentials_provider.clone(),
+            cx,
+        );
     });
 
     // Subscribe to extension store events to track LLM extension installations
@@ -104,6 +112,7 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
             &HashSet::default(),
             &openai_compatible_providers,
             client.clone(),
+            credentials_provider.clone(),
             cx,
         );
     });
@@ -124,6 +133,7 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
                     &openai_compatible_providers,
                     &openai_compatible_providers_new,
                     client.clone(),
+                    credentials_provider.clone(),
                     cx,
                 );
             });
@@ -138,6 +148,7 @@ fn register_openai_compatible_providers(
     old: &HashSet<Arc<str>>,
     new: &HashSet<Arc<str>>,
     client: Arc<Client>,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     cx: &mut Context<LanguageModelRegistry>,
 ) {
     for provider_id in old {
@@ -152,6 +163,7 @@ fn register_openai_compatible_providers(
                 Arc::new(OpenAiCompatibleLanguageModelProvider::new(
                     provider_id.clone(),
                     client.http_client(),
+                    credentials_provider.clone(),
                     cx,
                 )),
                 cx,
@@ -164,6 +176,7 @@ fn register_language_model_providers(
     registry: &mut LanguageModelRegistry,
     user_store: Entity<UserStore>,
     client: Arc<Client>,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     cx: &mut Context<LanguageModelRegistry>,
 ) {
     registry.register_provider(
@@ -177,62 +190,105 @@ fn register_language_model_providers(
     registry.register_provider(
         Arc::new(AnthropicLanguageModelProvider::new(
             client.http_client(),
+            credentials_provider.clone(),
             cx,
         )),
         cx,
     );
     registry.register_provider(
-        Arc::new(OpenAiLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(OpenAiLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        Arc::new(OllamaLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(OllamaLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        Arc::new(LmStudioLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(LmStudioLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        Arc::new(DeepSeekLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(DeepSeekLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        Arc::new(GoogleLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(GoogleLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        MistralLanguageModelProvider::global(client.http_client(), cx),
+        MistralLanguageModelProvider::global(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        ),
         cx,
     );
     registry.register_provider(
-        Arc::new(BedrockLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(BedrockLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
         Arc::new(OpenRouterLanguageModelProvider::new(
             client.http_client(),
+            credentials_provider.clone(),
             cx,
         )),
         cx,
     );
     registry.register_provider(
-        Arc::new(VercelLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(VercelLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
         Arc::new(VercelAiGatewayLanguageModelProvider::new(
             client.http_client(),
+            credentials_provider.clone(),
             cx,
         )),
         cx,
     );
     registry.register_provider(
-        Arc::new(XAiLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(XAiLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider.clone(),
+            cx,
+        )),
         cx,
     );
     registry.register_provider(
-        Arc::new(OpenCodeLanguageModelProvider::new(client.http_client(), cx)),
+        Arc::new(OpenCodeLanguageModelProvider::new(
+            client.http_client(),
+            credentials_provider,
+            cx,
+        )),
         cx,
     );
     registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx);

crates/language_models/src/provider/anthropic.rs 🔗

@@ -6,6 +6,7 @@ use anthropic::{
 };
 use anyhow::Result;
 use collections::{BTreeMap, HashMap};
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
 use http_client::HttpClient;
@@ -51,6 +52,7 @@ static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -59,30 +61,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = AnthropicLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = AnthropicLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl AnthropicLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/bedrock.rs 🔗

@@ -195,12 +195,13 @@ pub struct State {
     settings: Option<AmazonBedrockSettings>,
     /// Whether credentials came from environment variables (only relevant for static credentials)
     credentials_from_env: bool,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     _subscription: Subscription,
 }
 
 impl State {
     fn reset_auth(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
-        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let credentials_provider = self.credentials_provider.clone();
         cx.spawn(async move |this, cx| {
             credentials_provider
                 .delete_credentials(AMAZON_AWS_URL, cx)
@@ -220,7 +221,7 @@ impl State {
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         let auth = credentials.clone().into_auth();
-        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let credentials_provider = self.credentials_provider.clone();
         cx.spawn(async move |this, cx| {
             credentials_provider
                 .write_credentials(
@@ -287,7 +288,7 @@ impl State {
         &self,
         cx: &mut Context<Self>,
     ) -> Task<Result<(), AuthenticateError>> {
-        let credentials_provider = <dyn CredentialsProvider>::global(cx);
+        let credentials_provider = self.credentials_provider.clone();
         cx.spawn(async move |this, cx| {
             // Try environment variables first
             let (auth, from_env) = if let Some(bearer_token) = &ZED_BEDROCK_BEARER_TOKEN_VAR.value {
@@ -400,11 +401,16 @@ pub struct BedrockLanguageModelProvider {
 }
 
 impl BedrockLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| State {
             auth: None,
             settings: Some(AllLanguageModelSettings::get_global(cx).bedrock.clone()),
             credentials_from_env: false,
+            credentials_provider,
             _subscription: cx.observe_global::<SettingsStore>(|_, cx| {
                 cx.notify();
             }),

crates/language_models/src/provider/cloud.rs 🔗

@@ -1,7 +1,9 @@
 use ai_onboarding::YoungAccountBanner;
 use anthropic::AnthropicModelMode;
 use anyhow::{Context as _, Result, anyhow};
-use client::{Client, UserStore, zed_urls};
+use client::{
+    Client, NeedsLlmTokenRefresh, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls,
+};
 use cloud_api_types::{OrganizationId, Plan};
 use cloud_llm_client::{
     CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME,
@@ -24,10 +26,9 @@ use language_model::{
     LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel,
     LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
     LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, NeedsLlmTokenRefresh,
-    OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter,
-    RefreshLlmTokenListener, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID,
-    ZED_CLOUD_PROVIDER_NAME,
+    LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, OPEN_AI_PROVIDER_ID,
+    OPEN_AI_PROVIDER_NAME, PaymentRequiredError, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME,
+    ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME,
 };
 use release_channel::AppVersion;
 use schemars::JsonSchema;
@@ -111,7 +112,7 @@ impl State {
         cx: &mut Context<Self>,
     ) -> Self {
         let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
-        let llm_api_token = LlmApiToken::global(cx);
+        let llm_api_token = global_llm_token(cx);
         Self {
             client: client.clone(),
             llm_api_token,
@@ -226,7 +227,9 @@ impl State {
         organization_id: Option<OrganizationId>,
     ) -> Result<ListModelsResponse> {
         let http_client = &client.http_client();
-        let token = llm_api_token.acquire(&client, organization_id).await?;
+        let token = client
+            .acquire_llm_token(&llm_api_token, organization_id)
+            .await?;
 
         let request = http_client::Request::builder()
             .method(Method::GET)
@@ -414,8 +417,8 @@ impl CloudLanguageModel {
     ) -> Result<PerformLlmCompletionResponse> {
         let http_client = &client.http_client();
 
-        let mut token = llm_api_token
-            .acquire(&client, organization_id.clone())
+        let mut token = client
+            .acquire_llm_token(&llm_api_token, organization_id.clone())
             .await?;
         let mut refreshed_token = false;
 
@@ -447,8 +450,8 @@ impl CloudLanguageModel {
             }
 
             if !refreshed_token && response.needs_llm_token_refresh() {
-                token = llm_api_token
-                    .refresh(&client, organization_id.clone())
+                token = client
+                    .refresh_llm_token(&llm_api_token, organization_id.clone())
                     .await?;
                 refreshed_token = true;
                 continue;
@@ -713,7 +716,9 @@ impl LanguageModel for CloudLanguageModel {
                     into_google(request, model_id.clone(), GoogleModelMode::Default);
                 async move {
                     let http_client = &client.http_client();
-                    let token = llm_api_token.acquire(&client, organization_id).await?;
+                    let token = client
+                        .acquire_llm_token(&llm_api_token, organization_id)
+                        .await?;
 
                     let request_body = CountTokensBody {
                         provider: cloud_llm_client::LanguageModelProvider::Google,

crates/language_models/src/provider/deepseek.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Result, anyhow};
 use collections::{BTreeMap, HashMap};
+use credentials_provider::CredentialsProvider;
 use deepseek::DEEPSEEK_API_URL;
 
 use futures::Stream;
@@ -49,6 +50,7 @@ pub struct DeepSeekLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -57,30 +59,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = DeepSeekLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = DeepSeekLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl DeepSeekLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/google.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Context as _, Result};
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
 use google_ai::{
     FunctionDeclaration, GenerateContentResponse, GoogleModelMode, Part, SystemInstruction,
@@ -60,6 +61,7 @@ pub struct GoogleLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 const GEMINI_API_KEY_VAR_NAME: &str = "GEMINI_API_KEY";
@@ -76,30 +78,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = GoogleLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = GoogleLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl GoogleLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/lmstudio.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Result, anyhow};
 use collections::HashMap;
+use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
@@ -52,6 +53,7 @@ pub struct LmStudioLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     http_client: Arc<dyn HttpClient>,
     available_models: Vec<lmstudio::Model>,
     fetch_model_task: Option<Task<Result<()>>>,
@@ -64,10 +66,15 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = LmStudioLanguageModelProvider::api_url(cx).into();
-        let task = self
-            .api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx);
+        let task = self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
         self.restart_fetch_models_task(cx);
         task
     }
@@ -114,10 +121,14 @@ impl State {
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = LmStudioLanguageModelProvider::api_url(cx).into();
-        let _task = self
-            .api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
+        let _task = self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
 
         if self.is_authenticated() {
             return Task::ready(Ok(()));
@@ -152,16 +163,29 @@ impl State {
 }
 
 impl LmStudioLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let this = Self {
             http_client: http_client.clone(),
             state: cx.new(|cx| {
                 let subscription = cx.observe_global::<SettingsStore>({
                     let mut settings = AllLanguageModelSettings::get_global(cx).lmstudio.clone();
                     move |this: &mut State, cx| {
-                        let new_settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
-                        if &settings != new_settings {
-                            settings = new_settings.clone();
+                        let new_settings =
+                            AllLanguageModelSettings::get_global(cx).lmstudio.clone();
+                        if settings != new_settings {
+                            let credentials_provider = this.credentials_provider.clone();
+                            let api_url = Self::api_url(cx).into();
+                            this.api_key_state.handle_url_change(
+                                api_url,
+                                |this| &mut this.api_key_state,
+                                credentials_provider,
+                                cx,
+                            );
+                            settings = new_settings;
                             this.restart_fetch_models_task(cx);
                             cx.notify();
                         }
@@ -173,6 +197,7 @@ impl LmStudioLanguageModelProvider {
                         Self::api_url(cx).into(),
                         (*API_KEY_ENV_VAR).clone(),
                     ),
+                    credentials_provider,
                     http_client,
                     available_models: Default::default(),
                     fetch_model_task: None,

crates/language_models/src/provider/mistral.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Result, anyhow};
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
@@ -43,6 +44,7 @@ pub struct MistralLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -51,15 +53,26 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = MistralLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = MistralLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
@@ -73,20 +86,30 @@ impl MistralLanguageModelProvider {
             .map(|this| &this.0)
     }
 
-    pub fn global(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Arc<Self> {
+    pub fn global(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Arc<Self> {
         if let Some(this) = cx.try_global::<GlobalMistralLanguageModelProvider>() {
             return this.0.clone();
         }
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/ollama.rs 🔗

@@ -1,4 +1,5 @@
 use anyhow::{Result, anyhow};
+use credentials_provider::CredentialsProvider;
 use fs::Fs;
 use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
 use futures::{Stream, TryFutureExt, stream};
@@ -54,6 +55,7 @@ pub struct OllamaLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     http_client: Arc<dyn HttpClient>,
     fetched_models: Vec<ollama::Model>,
     fetch_model_task: Option<Task<Result<()>>>,
@@ -65,10 +67,15 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OllamaLanguageModelProvider::api_url(cx);
-        let task = self
-            .api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx);
+        let task = self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
 
         self.fetched_models.clear();
         cx.spawn(async move |this, cx| {
@@ -80,10 +87,14 @@ impl State {
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OllamaLanguageModelProvider::api_url(cx);
-        let task = self
-            .api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
+        let task = self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
 
         // Always try to fetch models - if no API key is needed (local Ollama), it will work
         // If API key is needed and provided, it will work
@@ -157,7 +168,11 @@ impl State {
 }
 
 impl OllamaLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let this = Self {
             http_client: http_client.clone(),
             state: cx.new(|cx| {
@@ -170,6 +185,14 @@ impl OllamaLanguageModelProvider {
                             let url_changed = last_settings.api_url != current_settings.api_url;
                             last_settings = current_settings.clone();
                             if url_changed {
+                                let credentials_provider = this.credentials_provider.clone();
+                                let api_url = Self::api_url(cx);
+                                this.api_key_state.handle_url_change(
+                                    api_url,
+                                    |this| &mut this.api_key_state,
+                                    credentials_provider,
+                                    cx,
+                                );
                                 this.fetched_models.clear();
                                 this.authenticate(cx).detach();
                             }
@@ -184,6 +207,7 @@ impl OllamaLanguageModelProvider {
                     fetched_models: Default::default(),
                     fetch_model_task: None,
                     api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                    credentials_provider,
                 }
             }),
         };

crates/language_models/src/provider/open_ai.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::{Result, anyhow};
 use collections::{BTreeMap, HashMap};
+use credentials_provider::CredentialsProvider;
 use futures::Stream;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
@@ -55,6 +56,7 @@ pub struct OpenAiLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -63,30 +65,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenAiLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenAiLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl OpenAiLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/open_ai_compatible.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use convert_case::{Case, Casing};
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
@@ -44,6 +45,7 @@ pub struct State {
     id: Arc<str>,
     api_key_state: ApiKeyState,
     settings: OpenAiCompatibleSettings,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -52,20 +54,36 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = SharedString::new(self.settings.api_url.as_str());
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = SharedString::new(self.settings.api_url.clone());
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl OpenAiCompatibleLanguageModelProvider {
-    pub fn new(id: Arc<str>, http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        id: Arc<str>,
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> {
             crate::AllLanguageModelSettings::get_global(cx)
                 .openai_compatible
@@ -79,10 +97,12 @@ impl OpenAiCompatibleLanguageModelProvider {
                     return;
                 };
                 if &this.settings != &settings {
+                    let credentials_provider = this.credentials_provider.clone();
                     let api_url = SharedString::new(settings.api_url.as_str());
                     this.api_key_state.handle_url_change(
                         api_url,
                         |this| &mut this.api_key_state,
+                        credentials_provider,
                         cx,
                     );
                     this.settings = settings;
@@ -98,6 +118,7 @@ impl OpenAiCompatibleLanguageModelProvider {
                     EnvVar::new(api_key_env_var_name),
                 ),
                 settings,
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/open_router.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::HashMap;
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::HttpClient;
@@ -42,6 +43,7 @@ pub struct OpenRouterLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     http_client: Arc<dyn HttpClient>,
     available_models: Vec<open_router::Model>,
     fetch_models_task: Option<Task<Result<(), LanguageModelCompletionError>>>,
@@ -53,16 +55,26 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenRouterLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenRouterLanguageModelProvider::api_url(cx);
-        let task = self
-            .api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
+        let task = self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
 
         cx.spawn(async move |this, cx| {
             let result = task.await;
@@ -114,7 +126,11 @@ impl State {
 }
 
 impl OpenRouterLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>({
                 let mut last_settings = OpenRouterLanguageModelProvider::settings(cx).clone();
@@ -131,6 +147,7 @@ impl OpenRouterLanguageModelProvider {
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
                 http_client: http_client.clone(),
                 available_models: Vec::new(),
                 fetch_models_task: None,

crates/language_models/src/provider/opencode.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
@@ -43,6 +44,7 @@ pub struct OpenCodeLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -51,30 +53,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenCodeLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = OpenCodeLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl OpenCodeLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/vercel.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
@@ -38,6 +39,7 @@ pub struct VercelLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -46,30 +48,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = VercelLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = VercelLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl VercelLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/language_models/src/provider/vercel_ai_gateway.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http};
@@ -41,6 +42,7 @@ pub struct VercelAiGatewayLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
     http_client: Arc<dyn HttpClient>,
     available_models: Vec<AvailableModel>,
     fetch_models_task: Option<Task<Result<(), LanguageModelCompletionError>>>,
@@ -52,16 +54,26 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = VercelAiGatewayLanguageModelProvider::api_url(cx);
-        let task = self
-            .api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
+        let task = self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        );
 
         cx.spawn(async move |this, cx| {
             let result = task.await;
@@ -100,7 +112,11 @@ impl State {
 }
 
 impl VercelAiGatewayLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>({
                 let mut last_settings = VercelAiGatewayLanguageModelProvider::settings(cx).clone();
@@ -116,6 +132,7 @@ impl VercelAiGatewayLanguageModelProvider {
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
                 http_client: http_client.clone(),
                 available_models: Vec::new(),
                 fetch_models_task: None,

crates/language_models/src/provider/x_ai.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::BTreeMap;
+use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, StreamExt, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
 use http_client::HttpClient;
@@ -39,6 +40,7 @@ pub struct XAiLanguageModelProvider {
 
 pub struct State {
     api_key_state: ApiKeyState,
+    credentials_provider: Arc<dyn CredentialsProvider>,
 }
 
 impl State {
@@ -47,30 +49,51 @@ impl State {
     }
 
     fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = XAiLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .store(api_url, api_key, |this| &mut this.api_key_state, cx)
+        self.api_key_state.store(
+            api_url,
+            api_key,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
+        let credentials_provider = self.credentials_provider.clone();
         let api_url = XAiLanguageModelProvider::api_url(cx);
-        self.api_key_state
-            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
+        self.api_key_state.load_if_needed(
+            api_url,
+            |this| &mut this.api_key_state,
+            credentials_provider,
+            cx,
+        )
     }
 }
 
 impl XAiLanguageModelProvider {
-    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        cx: &mut App,
+    ) -> Self {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
+                let credentials_provider = this.credentials_provider.clone();
                 let api_url = Self::api_url(cx);
-                this.api_key_state
-                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
+                this.api_key_state.handle_url_change(
+                    api_url,
+                    |this| &mut this.api_key_state,
+                    credentials_provider,
+                    cx,
+                );
                 cx.notify();
             })
             .detach();
             State {
                 api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                credentials_provider,
             }
         });
 

crates/project/Cargo.toml 🔗

@@ -98,6 +98,7 @@ watch.workspace = true
 wax.workspace = true
 which.workspace = true
 worktree.workspace = true
+zed_credentials_provider.workspace = true
 zeroize.workspace = true
 zlog.workspace = true
 ztracing.workspace = true

crates/project/src/context_server_store.rs 🔗

@@ -684,7 +684,7 @@ impl ContextServerStore {
             let server_url = url.clone();
             let id = id.clone();
             cx.spawn(async move |_this, cx| {
-                let credentials_provider = cx.update(|cx| <dyn CredentialsProvider>::global(cx));
+                let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
                 if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await
                 {
                     log::warn!("{} failed to clear OAuth session on removal: {}", id, err);
@@ -797,8 +797,7 @@ impl ContextServerStore {
                 if configuration.has_static_auth_header() {
                     None
                 } else {
-                    let credentials_provider =
-                        cx.update(|cx| <dyn CredentialsProvider>::global(cx));
+                    let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
                     let http_client = cx.update(|cx| cx.http_client());
 
                     match Self::load_session(&credentials_provider, url, &cx).await {
@@ -1070,7 +1069,7 @@ impl ContextServerStore {
             .context("Failed to start OAuth callback server")?;
 
         let http_client = cx.update(|cx| cx.http_client());
-        let credentials_provider = cx.update(|cx| <dyn CredentialsProvider>::global(cx));
+        let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
         let server_url = match configuration.as_ref() {
             ContextServerConfiguration::Http { url, .. } => url.clone(),
             _ => anyhow::bail!("OAuth authentication only supported for HTTP servers"),
@@ -1233,7 +1232,7 @@ impl ContextServerStore {
         self.stop_server(&id, cx)?;
 
         cx.spawn(async move |this, cx| {
-            let credentials_provider = cx.update(|cx| <dyn CredentialsProvider>::global(cx));
+            let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
             if let Err(err) = Self::clear_session(&credentials_provider, &server_url, &cx).await {
                 log::error!("{} failed to clear OAuth session: {}", id, err);
             }
@@ -1451,7 +1450,7 @@ async fn resolve_start_failure(
     // (e.g. timeout because the server rejected the token silently). Clear it
     // so the next start attempt can get a clean 401 and trigger the auth flow.
     if www_authenticate.is_none() {
-        let credentials_provider = cx.update(|cx| <dyn CredentialsProvider>::global(cx));
+        let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx));
         match ContextServerStore::load_session(&credentials_provider, &server_url, cx).await {
             Ok(Some(_)) => {
                 log::info!("{id} start failed with a cached OAuth session present; clearing it");

crates/project/src/git_store.rs 🔗

@@ -560,6 +560,10 @@ impl GitStore {
         client.add_entity_request_handler(Self::handle_run_hook);
         client.add_entity_request_handler(Self::handle_reset);
         client.add_entity_request_handler(Self::handle_show);
+        client.add_entity_request_handler(Self::handle_create_checkpoint);
+        client.add_entity_request_handler(Self::handle_restore_checkpoint);
+        client.add_entity_request_handler(Self::handle_compare_checkpoints);
+        client.add_entity_request_handler(Self::handle_diff_checkpoints);
         client.add_entity_request_handler(Self::handle_load_commit_diff);
         client.add_entity_request_handler(Self::handle_file_history);
         client.add_entity_request_handler(Self::handle_checkout_files);
@@ -2620,6 +2624,92 @@ impl GitStore {
         })
     }
 
+    async fn handle_create_checkpoint(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitCreateCheckpoint>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitCreateCheckpointResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let checkpoint = repository_handle
+            .update(&mut cx, |repository, _| repository.checkpoint())
+            .await??;
+
+        Ok(proto::GitCreateCheckpointResponse {
+            commit_sha: checkpoint.commit_sha.as_bytes().to_vec(),
+        })
+    }
+
+    async fn handle_restore_checkpoint(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitRestoreCheckpoint>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let checkpoint = GitRepositoryCheckpoint {
+            commit_sha: Oid::from_bytes(&envelope.payload.commit_sha)?,
+        };
+
+        repository_handle
+            .update(&mut cx, |repository, _| {
+                repository.restore_checkpoint(checkpoint)
+            })
+            .await??;
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_compare_checkpoints(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitCompareCheckpoints>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitCompareCheckpointsResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let left = GitRepositoryCheckpoint {
+            commit_sha: Oid::from_bytes(&envelope.payload.left_commit_sha)?,
+        };
+        let right = GitRepositoryCheckpoint {
+            commit_sha: Oid::from_bytes(&envelope.payload.right_commit_sha)?,
+        };
+
+        let equal = repository_handle
+            .update(&mut cx, |repository, _| {
+                repository.compare_checkpoints(left, right)
+            })
+            .await??;
+
+        Ok(proto::GitCompareCheckpointsResponse { equal })
+    }
+
+    async fn handle_diff_checkpoints(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GitDiffCheckpoints>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::GitDiffCheckpointsResponse> {
+        let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+        let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+        let base = GitRepositoryCheckpoint {
+            commit_sha: Oid::from_bytes(&envelope.payload.base_commit_sha)?,
+        };
+        let target = GitRepositoryCheckpoint {
+            commit_sha: Oid::from_bytes(&envelope.payload.target_commit_sha)?,
+        };
+
+        let diff = repository_handle
+            .update(&mut cx, |repository, _| {
+                repository.diff_checkpoints(base, target)
+            })
+            .await??;
+
+        Ok(proto::GitDiffCheckpointsResponse { diff })
+    }
+
     async fn handle_load_commit_diff(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::LoadCommitDiff>,
@@ -6313,12 +6403,24 @@ impl Repository {
     }
 
     pub fn checkpoint(&mut self) -> oneshot::Receiver<Result<GitRepositoryCheckpoint>> {
-        self.send_job(None, |repo, _cx| async move {
+        let id = self.id;
+        self.send_job(None, move |repo, _cx| async move {
             match repo {
                 RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
                     backend.checkpoint().await
                 }
-                RepositoryState::Remote(..) => anyhow::bail!("not implemented yet"),
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    let response = client
+                        .request(proto::GitCreateCheckpoint {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                        })
+                        .await?;
+
+                    Ok(GitRepositoryCheckpoint {
+                        commit_sha: Oid::from_bytes(&response.commit_sha)?,
+                    })
+                }
             }
         })
     }
@@ -6327,12 +6429,22 @@ impl Repository {
         &mut self,
         checkpoint: GitRepositoryCheckpoint,
     ) -> oneshot::Receiver<Result<()>> {
+        let id = self.id;
         self.send_job(None, move |repo, _cx| async move {
             match repo {
                 RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
                     backend.restore_checkpoint(checkpoint).await
                 }
-                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    client
+                        .request(proto::GitRestoreCheckpoint {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            commit_sha: checkpoint.commit_sha.as_bytes().to_vec(),
+                        })
+                        .await?;
+                    Ok(())
+                }
             }
         })
     }
@@ -6426,12 +6538,23 @@ impl Repository {
         left: GitRepositoryCheckpoint,
         right: GitRepositoryCheckpoint,
     ) -> oneshot::Receiver<Result<bool>> {
+        let id = self.id;
         self.send_job(None, move |repo, _cx| async move {
             match repo {
                 RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
                     backend.compare_checkpoints(left, right).await
                 }
-                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    let response = client
+                        .request(proto::GitCompareCheckpoints {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            left_commit_sha: left.commit_sha.as_bytes().to_vec(),
+                            right_commit_sha: right.commit_sha.as_bytes().to_vec(),
+                        })
+                        .await?;
+                    Ok(response.equal)
+                }
             }
         })
     }
@@ -6441,6 +6564,7 @@ impl Repository {
         base_checkpoint: GitRepositoryCheckpoint,
         target_checkpoint: GitRepositoryCheckpoint,
     ) -> oneshot::Receiver<Result<String>> {
+        let id = self.id;
         self.send_job(None, move |repo, _cx| async move {
             match repo {
                 RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
@@ -6448,7 +6572,17 @@ impl Repository {
                         .diff_checkpoints(base_checkpoint, target_checkpoint)
                         .await
                 }
-                RepositoryState::Remote { .. } => anyhow::bail!("not implemented yet"),
+                RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+                    let response = client
+                        .request(proto::GitDiffCheckpoints {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            base_commit_sha: base_checkpoint.commit_sha.as_bytes().to_vec(),
+                            target_commit_sha: target_checkpoint.commit_sha.as_bytes().to_vec(),
+                        })
+                        .await?;
+                    Ok(response.diff)
+                }
             }
         })
     }

crates/proto/proto/git.proto 🔗

@@ -587,6 +587,43 @@ message GitCreateWorktree {
   optional string commit = 5;
 }
 
+message GitCreateCheckpoint {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+}
+
+message GitCreateCheckpointResponse {
+  bytes commit_sha = 1;
+}
+
+message GitRestoreCheckpoint {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+  bytes commit_sha = 3;
+}
+
+message GitCompareCheckpoints {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+  bytes left_commit_sha = 3;
+  bytes right_commit_sha = 4;
+}
+
+message GitCompareCheckpointsResponse {
+  bool equal = 1;
+}
+
+message GitDiffCheckpoints {
+  uint64 project_id = 1;
+  uint64 repository_id = 2;
+  bytes base_commit_sha = 3;
+  bytes target_commit_sha = 4;
+}
+
+message GitDiffCheckpointsResponse {
+  string diff = 1;
+}
+
 message GitRemoveWorktree {
   uint64 project_id = 1;
   uint64 repository_id = 2;

crates/proto/proto/zed.proto 🔗

@@ -467,7 +467,14 @@ message Envelope {
     SpawnKernelResponse spawn_kernel_response = 427;
     KillKernel kill_kernel = 428;
     GitRemoveWorktree git_remove_worktree = 431;
-    GitRenameWorktree git_rename_worktree = 432; // current max
+    GitRenameWorktree git_rename_worktree = 432;
+    GitCreateCheckpoint git_create_checkpoint = 433;
+    GitCreateCheckpointResponse git_create_checkpoint_response = 434;
+    GitRestoreCheckpoint git_restore_checkpoint = 435;
+    GitCompareCheckpoints git_compare_checkpoints = 436;
+    GitCompareCheckpointsResponse git_compare_checkpoints_response = 437;
+    GitDiffCheckpoints git_diff_checkpoints = 438;
+    GitDiffCheckpointsResponse git_diff_checkpoints_response = 439; // current max
   }
 
   reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -294,6 +294,13 @@ messages!(
     (GitCommitDetails, Background),
     (GitFileHistory, Background),
     (GitFileHistoryResponse, Background),
+    (GitCreateCheckpoint, Background),
+    (GitCreateCheckpointResponse, Background),
+    (GitRestoreCheckpoint, Background),
+    (GitCompareCheckpoints, Background),
+    (GitCompareCheckpointsResponse, Background),
+    (GitDiffCheckpoints, Background),
+    (GitDiffCheckpointsResponse, Background),
     (SetIndexText, Background),
     (Push, Background),
     (Fetch, Background),
@@ -514,6 +521,10 @@ request_messages!(
     (RegisterBufferWithLanguageServers, Ack),
     (GitShow, GitCommitDetails),
     (GitFileHistory, GitFileHistoryResponse),
+    (GitCreateCheckpoint, GitCreateCheckpointResponse),
+    (GitRestoreCheckpoint, Ack),
+    (GitCompareCheckpoints, GitCompareCheckpointsResponse),
+    (GitDiffCheckpoints, GitDiffCheckpointsResponse),
     (GitReset, Ack),
     (GitDeleteBranch, Ack),
     (GitCheckoutFiles, Ack),
@@ -696,6 +707,10 @@ entity_messages!(
     RegisterBufferWithLanguageServers,
     GitShow,
     GitFileHistory,
+    GitCreateCheckpoint,
+    GitRestoreCheckpoint,
+    GitCompareCheckpoints,
+    GitDiffCheckpoints,
     GitReset,
     GitDeleteBranch,
     GitCheckoutFiles,

crates/recent_projects/src/dev_container_suggest.rs 🔗

@@ -30,17 +30,20 @@ fn project_devcontainer_key(project_path: &str) -> String {
 }
 
 pub fn suggest_on_worktree_updated(
+    workspace: &mut Workspace,
     worktree_id: WorktreeId,
     updated_entries: &UpdatedEntriesSet,
     project: &gpui::Entity<Project>,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    let cli_auto_open = workspace.open_in_dev_container();
+
     let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
         path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
     });
 
-    if !devcontainer_updated {
+    if !devcontainer_updated && !cli_auto_open {
         return;
     }
 
@@ -54,7 +57,35 @@ pub fn suggest_on_worktree_updated(
         return;
     }
 
-    if find_configs_in_snapshot(worktree).is_empty() {
+    let has_configs = !find_configs_in_snapshot(worktree).is_empty();
+
+    if cli_auto_open {
+        workspace.set_open_in_dev_container(false);
+        let task = cx.spawn_in(window, async move |workspace, cx| {
+            let scans_complete =
+                workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?;
+            scans_complete.await;
+
+            workspace.update_in(cx, |workspace, window, cx| {
+                let has_configs = workspace
+                    .project()
+                    .read(cx)
+                    .worktrees(cx)
+                    .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty());
+                if has_configs {
+                    cx.on_next_frame(window, move |_workspace, window, cx| {
+                        window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
+                    });
+                } else {
+                    log::warn!("--dev-container: no devcontainer configuration found in project");
+                }
+            })
+        });
+        workspace.set_dev_container_task(task);
+        return;
+    }
+
+    if !has_configs {
         return;
     }
 

crates/recent_projects/src/recent_projects.rs 🔗

@@ -475,11 +475,12 @@ pub fn init(cx: &mut App) {
             cx.subscribe_in(
                 workspace.project(),
                 window,
-                move |_, project, event, window, cx| {
+                move |workspace, project, event, window, cx| {
                     if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
                         event
                     {
                         dev_container_suggest::suggest_on_worktree_updated(
+                            workspace,
                             *worktree_id,
                             updated_entries,
                             project,

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -1917,6 +1917,153 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
     assert_eq!(server_branch.name(), "totally-new-branch");
 }
 
+#[gpui::test]
+async fn test_remote_git_checkpoints(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        path!("/code"),
+        json!({
+            "project1": {
+                ".git": {},
+                "file.txt": "original content",
+            },
+        }),
+    )
+    .await;
+
+    let (project, _headless) = init_test(&fs, cx, server_cx).await;
+
+    let (_worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/code/project1"), true, cx)
+        })
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap());
+
+    // 1. Create a checkpoint of the original state
+    let checkpoint_1 = repository
+        .update(cx, |repository, _| repository.checkpoint())
+        .await
+        .unwrap()
+        .unwrap();
+
+    // 2. Modify a file on the server-side fs
+    fs.write(
+        Path::new(path!("/code/project1/file.txt")),
+        b"modified content",
+    )
+    .await
+    .unwrap();
+
+    // 3. Create a second checkpoint with the modified state
+    let checkpoint_2 = repository
+        .update(cx, |repository, _| repository.checkpoint())
+        .await
+        .unwrap()
+        .unwrap();
+
+    // 4. compare_checkpoints: same checkpoint with itself => equal
+    let equal = repository
+        .update(cx, |repository, _| {
+            repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_1.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(equal, "a checkpoint compared with itself should be equal");
+
+    // 5. compare_checkpoints: different states => not equal
+    let equal = repository
+        .update(cx, |repository, _| {
+            repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(
+        !equal,
+        "checkpoints of different states should not be equal"
+    );
+
+    // 6. diff_checkpoints: same checkpoint => empty diff
+    let diff = repository
+        .update(cx, |repository, _| {
+            repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_1.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(
+        diff.is_empty(),
+        "diff of identical checkpoints should be empty"
+    );
+
+    // 7. diff_checkpoints: different checkpoints => non-empty diff mentioning the changed file
+    let diff = repository
+        .update(cx, |repository, _| {
+            repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(
+        !diff.is_empty(),
+        "diff of different checkpoints should be non-empty"
+    );
+    assert!(
+        diff.contains("file.txt"),
+        "diff should mention the changed file"
+    );
+    assert!(
+        diff.contains("original content"),
+        "diff should contain removed content"
+    );
+    assert!(
+        diff.contains("modified content"),
+        "diff should contain added content"
+    );
+
+    // 8. restore_checkpoint: restore to original state
+    repository
+        .update(cx, |repository, _| {
+            repository.restore_checkpoint(checkpoint_1.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    cx.run_until_parked();
+
+    // 9. Create a checkpoint after restore
+    let checkpoint_3 = repository
+        .update(cx, |repository, _| repository.checkpoint())
+        .await
+        .unwrap()
+        .unwrap();
+
+    // 10. compare_checkpoints: restored state matches original
+    let equal = repository
+        .update(cx, |repository, _| {
+            repository.compare_checkpoints(checkpoint_1.clone(), checkpoint_3.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(equal, "restored state should match original checkpoint");
+
+    // 11. diff_checkpoints: restored state vs original => empty diff
+    let diff = repository
+        .update(cx, |repository, _| {
+            repository.diff_checkpoints(checkpoint_1.clone(), checkpoint_3.clone())
+        })
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(diff.is_empty(), "diff after restore should be empty");
+}
+
 #[gpui::test]
 async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
     let fs = FakeFs::new(server_cx.executor());

crates/search/src/buffer_search.rs 🔗

@@ -114,81 +114,23 @@ impl Render for BufferSearchBar {
                 .map(|splittable_editor| {
                     let editor_ref = splittable_editor.read(cx);
                     let diff_view_style = editor_ref.diff_view_style();
-                    let is_split = editor_ref.is_split();
+
+                    let is_split_set = diff_view_style == DiffViewStyle::Split;
+                    let is_split_active = editor_ref.is_split();
                     let min_columns =
                         EditorSettings::get_global(cx).minimum_split_diff_width as u32;
 
-                    let mut split_button = IconButton::new("diff-split", IconName::DiffSplit)
-                        .shape(IconButtonShape::Square)
-                        .tooltip(Tooltip::element(move |_, cx| {
-                            let message = if min_columns == 0 {
-                                SharedString::from("Split")
-                            } else {
-                                format!("Split when wider than {} columns", min_columns).into()
-                            };
-
-                            v_flex()
-                                .child(message)
-                                .child(
-                                    h_flex()
-                                        .gap_0p5()
-                                        .text_ui_sm(cx)
-                                        .text_color(Color::Muted.color(cx))
-                                        .children(render_modifiers(
-                                            &gpui::Modifiers::secondary_key(),
-                                            PlatformStyle::platform(),
-                                            None,
-                                            Some(TextSize::Small.rems(cx).into()),
-                                            false,
-                                        ))
-                                        .child("click to change min width"),
-                                )
-                                .into_any()
-                        }))
-                        .on_click({
-                            let splittable_editor = splittable_editor.downgrade();
-                            move |_, window, cx| {
-                                if window.modifiers().secondary() {
-                                    window.dispatch_action(
-                                        OpenSettingsAt {
-                                            path: "minimum_split_diff_width".to_string(),
-                                        }
-                                        .boxed_clone(),
-                                        cx,
-                                    );
-                                } else {
-                                    update_settings_file(
-                                        <dyn Fs>::global(cx),
-                                        cx,
-                                        |settings, _| {
-                                            settings.editor.diff_view_style =
-                                                Some(DiffViewStyle::Split);
-                                        },
-                                    );
-                                    if diff_view_style == DiffViewStyle::Unified {
-                                        splittable_editor
-                                            .update(cx, |editor, cx| {
-                                                editor.toggle_split(&ToggleSplitDiff, window, cx);
-                                            })
-                                            .ok();
-                                    }
-                                }
-                            }
-                        });
-
-                    if diff_view_style == DiffViewStyle::Split {
-                        if !is_split {
-                            split_button = split_button.icon_color(Color::Disabled)
-                        } else {
-                            split_button = split_button.toggle_state(true)
-                        }
-                    }
+                    let split_icon = if is_split_set && !is_split_active {
+                        IconName::DiffSplitAuto
+                    } else {
+                        IconName::DiffSplit
+                    };
 
                     h_flex()
                         .gap_1()
                         .child(
                             IconButton::new("diff-unified", IconName::DiffUnified)
-                                .shape(IconButtonShape::Square)
+                                .icon_size(IconSize::Small)
                                 .toggle_state(diff_view_style == DiffViewStyle::Unified)
                                 .tooltip(Tooltip::text("Unified"))
                                 .on_click({
@@ -216,7 +158,71 @@ impl Render for BufferSearchBar {
                                     }
                                 }),
                         )
-                        .child(split_button)
+                        .child(
+                            IconButton::new("diff-split", split_icon)
+                                .toggle_state(diff_view_style == DiffViewStyle::Split)
+                                .icon_size(IconSize::Small)
+                                .tooltip(Tooltip::element(move |_, cx| {
+                                    let message = if is_split_set && !is_split_active {
+                                        format!("Split when wider than {} columns", min_columns)
+                                            .into()
+                                    } else {
+                                        SharedString::from("Split")
+                                    };
+
+                                    v_flex()
+                                        .child(message)
+                                        .child(
+                                            h_flex()
+                                                .gap_0p5()
+                                                .text_ui_sm(cx)
+                                                .text_color(Color::Muted.color(cx))
+                                                .children(render_modifiers(
+                                                    &gpui::Modifiers::secondary_key(),
+                                                    PlatformStyle::platform(),
+                                                    None,
+                                                    Some(TextSize::Small.rems(cx).into()),
+                                                    false,
+                                                ))
+                                                .child("click to change min width"),
+                                        )
+                                        .into_any()
+                                }))
+                                .on_click({
+                                    let splittable_editor = splittable_editor.downgrade();
+                                    move |_, window, cx| {
+                                        if window.modifiers().secondary() {
+                                            window.dispatch_action(
+                                                OpenSettingsAt {
+                                                    path: "minimum_split_diff_width".to_string(),
+                                                }
+                                                .boxed_clone(),
+                                                cx,
+                                            );
+                                        } else {
+                                            update_settings_file(
+                                                <dyn Fs>::global(cx),
+                                                cx,
+                                                |settings, _| {
+                                                    settings.editor.diff_view_style =
+                                                        Some(DiffViewStyle::Split);
+                                                },
+                                            );
+                                            if diff_view_style == DiffViewStyle::Unified {
+                                                splittable_editor
+                                                    .update(cx, |editor, cx| {
+                                                        editor.toggle_split(
+                                                            &ToggleSplitDiff,
+                                                            window,
+                                                            cx,
+                                                        );
+                                                    })
+                                                    .ok();
+                                            }
+                                        }
+                                    }
+                                }),
+                        )
                 })
         } else {
             None
@@ -240,7 +246,7 @@ impl Render for BufferSearchBar {
 
             let collapse_expand_icon_button = |id| {
                 IconButton::new(id, icon)
-                    .shape(IconButtonShape::Square)
+                    .icon_size(IconSize::Small)
                     .tooltip(move |_, cx| {
                         Tooltip::for_action_in(
                             tooltip_label,

crates/settings_ui/Cargo.toml 🔗

@@ -59,6 +59,7 @@ ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
+zed_credentials_provider.workspace = true
 
 [dev-dependencies]
 fs = { workspace = true, features = ["test-support"] }

crates/settings_ui/src/page_data.rs 🔗

@@ -4990,7 +4990,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn terminal_panel_section() -> [SettingsPageItem; 3] {
+    fn terminal_panel_section() -> [SettingsPageItem; 4] {
         [
             SettingsPageItem::SectionHeader("Terminal Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -5006,6 +5006,19 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Terminal Panel Flexible Sizing",
+                description: "Whether the terminal panel should use flexible (proportional) sizing when docked to the left or right.",
+                field: Box::new(SettingField {
+                    json_path: Some("terminal.flexible"),
+                    pick: |settings_content| settings_content.terminal.as_ref()?.flexible.as_ref(),
+                    write: |settings_content, value| {
+                        settings_content.terminal.get_or_insert_default().flexible = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Show Count Badge",
                 description: "Show a badge on the terminal panel icon with the count of open terminals.",
@@ -5666,7 +5679,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn agent_panel_section() -> [SettingsPageItem; 5] {
+    fn agent_panel_section() -> [SettingsPageItem; 6] {
         [
             SettingsPageItem::SectionHeader("Agent Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -5695,6 +5708,19 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Agent Panel Flexible Sizing",
+                description: "Whether the agent panel should use flexible (proportional) sizing when docked to the left or right.",
+                field: Box::new(SettingField {
+                    json_path: Some("agent.flexible"),
+                    pick: |settings_content| settings_content.agent.as_ref()?.flexible.as_ref(),
+                    write: |settings_content, value| {
+                        settings_content.agent.get_or_insert_default().flexible = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Agent Panel Default Width",
                 description: "Default width when the agent panel is docked to the left or right.",

crates/settings_ui/src/pages/edit_prediction_provider_setup.rs 🔗

@@ -185,9 +185,15 @@ fn render_api_key_provider(
     cx: &mut Context<SettingsWindow>,
 ) -> impl IntoElement {
     let weak_page = cx.weak_entity();
+    let credentials_provider = zed_credentials_provider::global(cx);
     _ = window.use_keyed_state(current_url(cx), cx, |_, cx| {
         let task = api_key_state.update(cx, |key_state, cx| {
-            key_state.load_if_needed(current_url(cx), |state| state, cx)
+            key_state.load_if_needed(
+                current_url(cx),
+                |state| state,
+                credentials_provider.clone(),
+                cx,
+            )
         });
         cx.spawn(async move |_, cx| {
             task.await.ok();
@@ -208,10 +214,17 @@ fn render_api_key_provider(
     });
 
     let write_key = move |api_key: Option<String>, cx: &mut App| {
+        let credentials_provider = zed_credentials_provider::global(cx);
         api_key_state
             .update(cx, |key_state, cx| {
                 let url = current_url(cx);
-                key_state.store(url, api_key, |key_state| key_state, cx)
+                key_state.store(
+                    url,
+                    api_key,
+                    |key_state| key_state,
+                    credentials_provider,
+                    cx,
+                )
             })
             .detach_and_log_err(cx);
     };

crates/settings_ui/src/settings_ui.rs 🔗

@@ -500,18 +500,18 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::TerminalBlink>(render_dropdown)
         .add_basic_renderer::<settings::CursorShapeContent>(render_dropdown)
         .add_basic_renderer::<settings::EditPredictionPromptFormat>(render_dropdown)
-        .add_basic_renderer::<f32>(render_number_field)
-        .add_basic_renderer::<u32>(render_number_field)
-        .add_basic_renderer::<u64>(render_number_field)
-        .add_basic_renderer::<usize>(render_number_field)
-        .add_basic_renderer::<NonZero<usize>>(render_number_field)
-        .add_basic_renderer::<NonZeroU32>(render_number_field)
-        .add_basic_renderer::<settings::CodeFade>(render_number_field)
-        .add_basic_renderer::<settings::DelayMs>(render_number_field)
-        .add_basic_renderer::<settings::FontWeightContent>(render_number_field)
-        .add_basic_renderer::<settings::CenteredPaddingSettings>(render_number_field)
-        .add_basic_renderer::<settings::InactiveOpacity>(render_number_field)
-        .add_basic_renderer::<settings::MinimumContrast>(render_number_field)
+        .add_basic_renderer::<f32>(render_editable_number_field)
+        .add_basic_renderer::<u32>(render_editable_number_field)
+        .add_basic_renderer::<u64>(render_editable_number_field)
+        .add_basic_renderer::<usize>(render_editable_number_field)
+        .add_basic_renderer::<NonZero<usize>>(render_editable_number_field)
+        .add_basic_renderer::<NonZeroU32>(render_editable_number_field)
+        .add_basic_renderer::<settings::CodeFade>(render_editable_number_field)
+        .add_basic_renderer::<settings::DelayMs>(render_editable_number_field)
+        .add_basic_renderer::<settings::FontWeightContent>(render_editable_number_field)
+        .add_basic_renderer::<settings::CenteredPaddingSettings>(render_editable_number_field)
+        .add_basic_renderer::<settings::InactiveOpacity>(render_editable_number_field)
+        .add_basic_renderer::<settings::MinimumContrast>(render_editable_number_field)
         .add_basic_renderer::<settings::ShowScrollbar>(render_dropdown)
         .add_basic_renderer::<settings::ScrollbarDiagnostics>(render_dropdown)
         .add_basic_renderer::<settings::ShowMinimap>(render_dropdown)
@@ -4051,41 +4051,6 @@ fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
         .into_any_element()
 }
 
-fn render_number_field<T: NumberFieldType + Send + Sync>(
-    field: SettingField<T>,
-    file: SettingsUiFile,
-    _metadata: Option<&SettingsFieldMetadata>,
-    window: &mut Window,
-    cx: &mut App,
-) -> AnyElement {
-    let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
-    let value = value.copied().unwrap_or_else(T::min_value);
-
-    let id = field
-        .json_path
-        .map(|p| format!("numeric_stepper_{}", p))
-        .unwrap_or_else(|| "numeric_stepper".to_string());
-
-    NumberField::new(id, value, window, cx)
-        .tab_index(0_isize)
-        .on_change({
-            move |value, window, cx| {
-                let value = *value;
-                update_settings_file(
-                    file.clone(),
-                    field.json_path,
-                    window,
-                    cx,
-                    move |settings, _cx| {
-                        (field.write)(settings, Some(value));
-                    },
-                )
-                .log_err(); // todo(settings_ui) don't log err
-            }
-        })
-        .into_any_element()
-}
-
 fn render_editable_number_field<T: NumberFieldType + Send + Sync>(
     field: SettingField<T>,
     file: SettingsUiFile,

crates/sidebar/src/sidebar.rs 🔗

@@ -974,21 +974,21 @@ impl Sidebar {
 
                     let session_id = &thread.metadata.session_id;
 
-                    let is_thread_workspace_active = match &thread.workspace {
-                        ThreadEntryWorkspace::Open(thread_workspace) => active_workspace
-                            .as_ref()
-                            .is_some_and(|active| active == thread_workspace),
-                        ThreadEntryWorkspace::Closed(_) => false,
-                    };
+                    let is_active_thread = self.active_entry.as_ref().is_some_and(|entry| {
+                        entry.is_active_thread(session_id)
+                            && active_workspace
+                                .as_ref()
+                                .is_some_and(|active| active == entry.workspace())
+                    });
 
                     if thread.status == AgentThreadStatus::Completed
-                        && !is_thread_workspace_active
+                        && !is_active_thread
                         && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running)
                     {
                         notified_threads.insert(session_id.clone());
                     }
 
-                    if is_thread_workspace_active && !thread.is_background {
+                    if is_active_thread && !thread.is_background {
                         notified_threads.remove(session_id);
                     }
                 }
@@ -1280,7 +1280,7 @@ impl Sidebar {
             v_flex()
                 .w_full()
                 .border_t_1()
-                .border_color(cx.theme().colors().border.opacity(0.5))
+                .border_color(cx.theme().colors().border)
                 .child(rendered)
                 .into_any_element()
         } else {
@@ -1327,7 +1327,7 @@ impl Sidebar {
         has_running_threads: bool,
         waiting_thread_count: usize,
         is_active: bool,
-        is_selected: bool,
+        is_focused: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let id_prefix = if is_sticky { "sticky-" } else { "" };
@@ -1359,11 +1359,11 @@ impl Sidebar {
 
         let label = if highlight_positions.is_empty() {
             Label::new(label.clone())
-                .color(Color::Muted)
+                .when(!is_active, |this| this.color(Color::Muted))
                 .into_any_element()
         } else {
             HighlightedLabel::new(label.clone(), highlight_positions.to_vec())
-                .color(Color::Muted)
+                .when(!is_active, |this| this.color(Color::Muted))
                 .into_any_element()
         };
 
@@ -1381,14 +1381,13 @@ impl Sidebar {
             .pr_1p5()
             .border_1()
             .map(|this| {
-                if is_selected {
+                if is_focused {
                     this.border_color(color.border_focused)
                 } else {
                     this.border_color(gpui::transparent_black())
                 }
             })
             .justify_between()
-            .hover(|s| s.bg(hover_color))
             .child(
                 h_flex()
                     .when(!is_active, |this| this.cursor_pointer())
@@ -1469,7 +1468,6 @@ impl Sidebar {
                                 IconName::ListCollapse,
                             )
                             .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
                             .tooltip(Tooltip::text("Collapse Displayed Threads"))
                             .on_click(cx.listener({
                                 let path_list_for_collapse = path_list_for_collapse.clone();
@@ -1491,7 +1489,6 @@ impl Sidebar {
                                 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();
@@ -1508,7 +1505,9 @@ impl Sidebar {
                     })
             })
             .when(!is_active, |this| {
-                this.tooltip(Tooltip::text("Activate Workspace"))
+                this.cursor_pointer()
+                    .hover(|s| s.bg(hover_color))
+                    .tooltip(Tooltip::text("Activate Workspace"))
                     .on_click(cx.listener({
                         move |this, _, window, cx| {
                             this.active_entry =
@@ -1690,8 +1689,7 @@ impl Sidebar {
                     IconName::Ellipsis,
                 )
                 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted),
+                .icon_size(IconSize::Small),
             )
             .anchor(gpui::Corner::TopRight)
             .offset(gpui::Point {
@@ -2825,7 +2823,7 @@ impl Sidebar {
         let color = cx.theme().colors();
         let sidebar_bg = color
             .title_bar_background
-            .blend(color.panel_background.opacity(0.32));
+            .blend(color.panel_background.opacity(0.25));
 
         let timestamp = format_history_entry_timestamp(
             self.thread_last_message_sent_or_queued
@@ -3682,7 +3680,7 @@ impl Render for Sidebar {
         let color = cx.theme().colors();
         let bg = color
             .title_bar_background
-            .blend(color.panel_background.opacity(0.32));
+            .blend(color.panel_background.opacity(0.25));
 
         let no_open_projects = !self.contents.has_open_projects;
         let no_search_results = self.contents.entries.is_empty();

crates/ui/src/components.rs 🔗

@@ -29,6 +29,7 @@ mod notification;
 mod popover;
 mod popover_menu;
 mod progress;
+mod redistributable_columns;
 mod right_click_menu;
 mod scrollbar;
 mod stack;
@@ -73,6 +74,7 @@ pub use notification::*;
 pub use popover::*;
 pub use popover_menu::*;
 pub use progress::*;
+pub use redistributable_columns::*;
 pub use right_click_menu::*;
 pub use scrollbar::*;
 pub use stack::*;

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -1,7 +1,4 @@
-use crate::{
-    CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
-    IconDecorationKind, Tooltip, prelude::*,
-};
+use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*};
 
 use gpui::{
     Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
@@ -218,7 +215,7 @@ impl RenderOnce for ThreadItem {
         let color = cx.theme().colors();
         let sidebar_base_bg = color
             .title_bar_background
-            .blend(color.panel_background.opacity(0.32));
+            .blend(color.panel_background.opacity(0.25));
 
         let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg);
         let apparent_bg = color.background.blend(raw_bg);
@@ -266,31 +263,31 @@ impl RenderOnce for ThreadItem {
             Icon::new(self.icon).color(icon_color).size(IconSize::Small)
         };
 
-        let decoration = |icon: IconDecorationKind, color: Hsla| {
-            IconDecoration::new(icon, base_bg, cx)
-                .color(color)
-                .position(gpui::Point {
-                    x: px(-2.),
-                    y: px(-2.),
-                })
-        };
-
-        let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error {
+        let (status_icon, icon_tooltip) = if self.status == AgentThreadStatus::Error {
             (
-                Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
+                Some(
+                    Icon::new(IconName::Close)
+                        .size(IconSize::Small)
+                        .color(Color::Error),
+                ),
                 Some("Thread has an Error"),
             )
         } else if self.status == AgentThreadStatus::WaitingForConfirmation {
             (
-                Some(decoration(
-                    IconDecorationKind::Triangle,
-                    cx.theme().status().warning,
-                )),
+                Some(
+                    Icon::new(IconName::Warning)
+                        .size(IconSize::XSmall)
+                        .color(Color::Warning),
+                ),
                 Some("Thread is Waiting for Confirmation"),
             )
         } else if self.notified {
             (
-                Some(decoration(IconDecorationKind::Dot, color.text_accent)),
+                Some(
+                    Icon::new(IconName::Circle)
+                        .size(IconSize::Small)
+                        .color(Color::Accent),
+                ),
                 Some("Thread's Generation is Complete"),
             )
         } else {
@@ -306,9 +303,9 @@ impl RenderOnce for ThreadItem {
                         .with_rotate_animation(2),
                 )
                 .into_any_element()
-        } else if let Some(decoration) = decoration {
+        } else if let Some(status_icon) = status_icon {
             icon_container()
-                .child(DecoratedIcon::new(agent_icon, Some(decoration)))
+                .child(status_icon)
                 .when_some(icon_tooltip, |icon, tooltip| {
                     icon.tooltip(Tooltip::text(tooltip))
                 })
@@ -551,12 +548,17 @@ impl Component for ThreadItem {
     }
 
     fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let color = cx.theme().colors();
+        let bg = color
+            .title_bar_background
+            .blend(color.panel_background.opacity(0.25));
+
         let container = || {
             v_flex()
                 .w_72()
                 .border_1()
-                .border_color(cx.theme().colors().border_variant)
-                .bg(cx.theme().colors().panel_background)
+                .border_color(color.border_variant)
+                .bg(bg)
         };
 
         let thread_item_examples = vec![
@@ -570,16 +572,6 @@ impl Component for ThreadItem {
                     )
                     .into_any_element(),
             ),
-            single_example(
-                "Timestamp Only (hours)",
-                container()
-                    .child(
-                        ThreadItem::new("ti-1b", "Thread with just a timestamp")
-                            .icon(IconName::AiClaude)
-                            .timestamp("3h"),
-                    )
-                    .into_any_element(),
-            ),
             single_example(
                 "Notified (weeks)",
                 container()

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

@@ -1,19 +1,19 @@
 use std::{ops::Range, rc::Rc};
 
 use gpui::{
-    AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle,
-    Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful,
-    UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
+    DefiniteLength, Entity, EntityId, FocusHandle, Length, ListHorizontalSizingBehavior,
+    ListSizingBehavior, ListState, Point, Stateful, UniformListScrollHandle, WeakEntity, list,
+    transparent_black, uniform_list,
 };
-use itertools::intersperse_with;
 
 use crate::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
-    ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
-    InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
-    StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
-    px, single_example,
+    ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, HeaderResizeInfo,
+    Indicator, InteractiveElement, IntoElement, ParentElement, Pixels, RedistributableColumnsState,
+    RegisterComponent, RenderOnce, ScrollAxes, ScrollableHandle, Scrollbars, SharedString,
+    StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, WithScrollbar,
+    bind_redistributable_columns, div, example_group_with_title, h_flex, px,
+    render_redistributable_columns_resize_handles, single_example,
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
@@ -22,16 +22,10 @@ pub mod table_row;
 #[cfg(test)]
 mod tests;
 
-const RESIZE_COLUMN_WIDTH: f32 = 8.0;
-const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
-
 /// Represents an unchecked table row, which is a vector of elements.
 /// Will be converted into `TableRow<T>` internally
 pub type UncheckedTableRow<T> = Vec<T>;
 
-#[derive(Debug)]
-pub(crate) struct DraggedColumn(pub(crate) usize);
-
 struct UniformListData {
     render_list_of_rows_fn:
         Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<UncheckedTableRow<AnyElement>>>,
@@ -113,124 +107,6 @@ impl TableInteractionState {
     }
 }
 
-/// Renders invisible resize handles overlaid on top of table content.
-///
-/// - Spacer: invisible element that matches the width of table column content
-/// - Divider: contains the actual resize handle that users can drag to resize columns
-///
-/// Structure: [spacer] [divider] [spacer] [divider] [spacer]
-///
-/// Business logic:
-/// 1. Creates spacers matching each column width
-/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
-/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
-/// 4. Returns an absolute-positioned overlay that sits on top of table content
-fn render_resize_handles(
-    column_widths: &TableRow<Length>,
-    resizable_columns: &TableRow<TableResizeBehavior>,
-    initial_sizes: &TableRow<DefiniteLength>,
-    columns: Option<Entity<RedistributableColumnsState>>,
-    window: &mut Window,
-    cx: &mut App,
-) -> AnyElement {
-    let spacers = column_widths
-        .as_slice()
-        .iter()
-        .map(|width| base_cell_style(Some(*width)).into_any_element());
-
-    let mut column_ix = 0;
-    let resizable_columns_shared = Rc::new(resizable_columns.clone());
-    let initial_sizes_shared = Rc::new(initial_sizes.clone());
-    let mut resizable_columns_iter = resizable_columns.as_slice().iter();
-
-    let dividers = intersperse_with(spacers, || {
-        let resizable_columns = Rc::clone(&resizable_columns_shared);
-        let initial_sizes = Rc::clone(&initial_sizes_shared);
-        window.with_id(column_ix, |window| {
-            let mut resize_divider = div()
-                .id(column_ix)
-                .relative()
-                .top_0()
-                .w(px(RESIZE_DIVIDER_WIDTH))
-                .h_full()
-                .bg(cx.theme().colors().border.opacity(0.8));
-
-            let mut resize_handle = div()
-                .id("column-resize-handle")
-                .absolute()
-                .left_neg_0p5()
-                .w(px(RESIZE_COLUMN_WIDTH))
-                .h_full();
-
-            if resizable_columns_iter
-                .next()
-                .is_some_and(TableResizeBehavior::is_resizable)
-            {
-                let hovered = window.use_state(cx, |_window, _cx| false);
-
-                resize_divider = resize_divider.when(*hovered.read(cx), |div| {
-                    div.bg(cx.theme().colors().border_focused)
-                });
-
-                resize_handle = resize_handle
-                    .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
-                    .cursor_col_resize()
-                    .when_some(columns.clone(), |this, columns| {
-                        this.on_click(move |event, window, cx| {
-                            if event.click_count() >= 2 {
-                                columns.update(cx, |columns, _| {
-                                    columns.on_double_click(
-                                        column_ix,
-                                        &initial_sizes,
-                                        &resizable_columns,
-                                        window,
-                                    );
-                                })
-                            }
-
-                            cx.stop_propagation();
-                        })
-                    })
-                    .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
-                        cx.new(|_cx| gpui::Empty)
-                    })
-            }
-
-            column_ix += 1;
-            resize_divider.child(resize_handle).into_any_element()
-        })
-    });
-
-    h_flex()
-        .id("resize-handles")
-        .absolute()
-        .inset_0()
-        .w_full()
-        .children(dividers)
-        .into_any_element()
-}
-
-#[derive(Debug, Copy, Clone, PartialEq)]
-pub enum TableResizeBehavior {
-    None,
-    Resizable,
-    MinSize(f32),
-}
-
-impl TableResizeBehavior {
-    pub fn is_resizable(&self) -> bool {
-        *self != TableResizeBehavior::None
-    }
-
-    pub fn min_size(&self) -> Option<f32> {
-        match self {
-            TableResizeBehavior::None => None,
-            TableResizeBehavior::Resizable => Some(0.05),
-            TableResizeBehavior::MinSize(min_size) => Some(*min_size),
-        }
-    }
-}
-
 pub enum ColumnWidthConfig {
     /// Static column widths (no resize handles).
     Static {
@@ -278,6 +154,21 @@ impl ColumnWidthConfig {
         }
     }
 
+    /// Explicit column widths with no fixed table width.
+    pub fn explicit<T: Into<DefiniteLength>>(widths: Vec<T>) -> Self {
+        let cols = widths.len();
+        ColumnWidthConfig::Static {
+            widths: StaticColumnWidths::Explicit(
+                widths
+                    .into_iter()
+                    .map(Into::into)
+                    .collect::<Vec<_>>()
+                    .into_table_row(cols),
+            ),
+            table_width: None,
+        }
+    }
+
     /// Column widths for rendering.
     pub fn widths_to_render(&self, cx: &App) -> Option<TableRow<Length>> {
         match self {
@@ -292,10 +183,7 @@ impl ColumnWidthConfig {
             ColumnWidthConfig::Redistributable {
                 columns_state: entity,
                 ..
-            } => {
-                let state = entity.read(cx);
-                Some(state.preview_widths.map_cloned(Length::Definite))
-            }
+            } => Some(entity.read(cx).widths_to_render()),
         }
     }
 
@@ -316,296 +204,6 @@ impl ColumnWidthConfig {
             None => ListHorizontalSizingBehavior::FitList,
         }
     }
-
-    /// Render resize handles overlay if applicable.
-    pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
-        match self {
-            ColumnWidthConfig::Redistributable {
-                columns_state: entity,
-                ..
-            } => {
-                let (column_widths, resize_behavior, initial_widths) = {
-                    let state = entity.read(cx);
-                    (
-                        state.preview_widths.map_cloned(Length::Definite),
-                        state.resize_behavior.clone(),
-                        state.initial_widths.clone(),
-                    )
-                };
-                Some(render_resize_handles(
-                    &column_widths,
-                    &resize_behavior,
-                    &initial_widths,
-                    Some(entity.clone()),
-                    window,
-                    cx,
-                ))
-            }
-            _ => None,
-        }
-    }
-
-    /// Returns info needed for header double-click-to-reset, if applicable.
-    pub fn header_resize_info(&self, cx: &App) -> Option<HeaderResizeInfo> {
-        match self {
-            ColumnWidthConfig::Redistributable { columns_state, .. } => {
-                let state = columns_state.read(cx);
-                Some(HeaderResizeInfo {
-                    columns_state: columns_state.downgrade(),
-                    resize_behavior: state.resize_behavior.clone(),
-                    initial_widths: state.initial_widths.clone(),
-                })
-            }
-            _ => None,
-        }
-    }
-}
-
-#[derive(Clone)]
-pub struct HeaderResizeInfo {
-    pub columns_state: WeakEntity<RedistributableColumnsState>,
-    pub resize_behavior: TableRow<TableResizeBehavior>,
-    pub initial_widths: TableRow<DefiniteLength>,
-}
-
-pub struct RedistributableColumnsState {
-    pub(crate) initial_widths: TableRow<DefiniteLength>,
-    pub(crate) committed_widths: TableRow<DefiniteLength>,
-    pub(crate) preview_widths: TableRow<DefiniteLength>,
-    pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
-    pub(crate) cached_table_width: Pixels,
-}
-
-impl RedistributableColumnsState {
-    pub fn new(
-        cols: usize,
-        initial_widths: UncheckedTableRow<impl Into<DefiniteLength>>,
-        resize_behavior: UncheckedTableRow<TableResizeBehavior>,
-    ) -> Self {
-        let widths: TableRow<DefiniteLength> = initial_widths
-            .into_iter()
-            .map(Into::into)
-            .collect::<Vec<_>>()
-            .into_table_row(cols);
-        Self {
-            initial_widths: widths.clone(),
-            committed_widths: widths.clone(),
-            preview_widths: widths,
-            resize_behavior: resize_behavior.into_table_row(cols),
-            cached_table_width: Default::default(),
-        }
-    }
-
-    pub fn cols(&self) -> usize {
-        self.committed_widths.cols()
-    }
-
-    pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
-        &self.initial_widths
-    }
-
-    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
-        &self.resize_behavior
-    }
-
-    fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
-        match length {
-            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
-            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
-                rems_width.to_pixels(rem_size) / bounds_width
-            }
-            DefiniteLength::Fraction(fraction) => *fraction,
-        }
-    }
-
-    pub(crate) fn on_double_click(
-        &mut self,
-        double_click_position: usize,
-        initial_sizes: &TableRow<DefiniteLength>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-        window: &mut Window,
-    ) {
-        let bounds_width = self.cached_table_width;
-        let rem_size = window.rem_size();
-        let initial_sizes =
-            initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-        let widths = self
-            .committed_widths
-            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-
-        let updated_widths = Self::reset_to_initial_size(
-            double_click_position,
-            widths,
-            initial_sizes,
-            resize_behavior,
-        );
-        self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
-        self.preview_widths = self.committed_widths.clone();
-    }
-
-    pub(crate) fn reset_to_initial_size(
-        col_idx: usize,
-        mut widths: TableRow<f32>,
-        initial_sizes: TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-    ) -> TableRow<f32> {
-        let diff = initial_sizes[col_idx] - widths[col_idx];
-
-        let left_diff =
-            initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
-        let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
-            - widths[col_idx + 1..].iter().sum::<f32>();
-
-        let go_left_first = if diff < 0.0 {
-            left_diff > right_diff
-        } else {
-            left_diff < right_diff
-        };
-
-        if !go_left_first {
-            let diff_remaining =
-                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
-
-            if diff_remaining != 0.0 && col_idx > 0 {
-                Self::propagate_resize_diff(
-                    diff_remaining,
-                    col_idx,
-                    &mut widths,
-                    resize_behavior,
-                    -1,
-                );
-            }
-        } else {
-            let diff_remaining =
-                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
-
-            if diff_remaining != 0.0 {
-                Self::propagate_resize_diff(
-                    diff_remaining,
-                    col_idx,
-                    &mut widths,
-                    resize_behavior,
-                    1,
-                );
-            }
-        }
-
-        widths
-    }
-
-    pub(crate) fn on_drag_move(
-        &mut self,
-        drag_event: &DragMoveEvent<DraggedColumn>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let drag_position = drag_event.event.position;
-        let bounds = drag_event.bounds;
-
-        let mut col_position = 0.0;
-        let rem_size = window.rem_size();
-        let bounds_width = bounds.right() - bounds.left();
-        let col_idx = drag_event.drag(cx).0;
-
-        let divider_width = Self::get_fraction(
-            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
-            bounds_width,
-            rem_size,
-        );
-
-        let mut widths = self
-            .committed_widths
-            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
-
-        for length in widths[0..=col_idx].iter() {
-            col_position += length + divider_width;
-        }
-
-        let mut total_length_ratio = col_position;
-        for length in widths[col_idx + 1..].iter() {
-            total_length_ratio += length;
-        }
-        let cols = self.resize_behavior.cols();
-        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
-
-        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
-        let drag_fraction = drag_fraction * total_length_ratio;
-        let diff = drag_fraction - col_position - divider_width / 2.0;
-
-        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
-
-        self.preview_widths = widths.map(DefiniteLength::Fraction);
-    }
-
-    pub(crate) fn drag_column_handle(
-        diff: f32,
-        col_idx: usize,
-        widths: &mut TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-    ) {
-        if diff > 0.0 {
-            Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
-        } else {
-            Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
-        }
-    }
-
-    pub(crate) fn propagate_resize_diff(
-        diff: f32,
-        col_idx: usize,
-        widths: &mut TableRow<f32>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
-        direction: i8,
-    ) -> f32 {
-        let mut diff_remaining = diff;
-        if resize_behavior[col_idx].min_size().is_none() {
-            return diff;
-        }
-
-        let step_right;
-        let step_left;
-        if direction < 0 {
-            step_right = 0;
-            step_left = 1;
-        } else {
-            step_right = 1;
-            step_left = 0;
-        }
-        if col_idx == 0 && direction < 0 {
-            return diff;
-        }
-        let mut curr_column = col_idx + step_right - step_left;
-
-        while diff_remaining != 0.0 && curr_column < widths.cols() {
-            let Some(min_size) = resize_behavior[curr_column].min_size() else {
-                if curr_column == 0 {
-                    break;
-                }
-                curr_column -= step_left;
-                curr_column += step_right;
-                continue;
-            };
-
-            let curr_width = widths[curr_column] - diff_remaining;
-            widths[curr_column] = curr_width;
-
-            if min_size > curr_width {
-                diff_remaining = min_size - curr_width;
-                widths[curr_column] = min_size;
-            } else {
-                diff_remaining = 0.0;
-                break;
-            }
-            if curr_column == 0 {
-                break;
-            }
-            curr_column -= step_left;
-            curr_column += step_right;
-        }
-        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
-
-        diff_remaining
-    }
 }
 
 /// A table component
@@ -919,11 +517,8 @@ pub fn render_table_header(
                                     if event.click_count() > 1 {
                                         info.columns_state
                                             .update(cx, |column, _| {
-                                                column.on_double_click(
-                                                    header_idx,
-                                                    &info.initial_widths,
-                                                    &info.resize_behavior,
-                                                    window,
+                                                column.reset_column_to_initial_width(
+                                                    header_idx, window,
                                                 );
                                             })
                                             .ok();
@@ -962,6 +557,19 @@ impl TableRenderContext {
             disable_base_cell_style: table.disable_base_cell_style,
         }
     }
+
+    pub fn for_column_widths(column_widths: Option<TableRow<Length>>, use_ui_font: bool) -> Self {
+        Self {
+            striped: false,
+            show_row_borders: true,
+            show_row_hover: true,
+            total_row_count: 0,
+            column_widths,
+            map_row: None,
+            use_ui_font,
+            disable_base_cell_style: false,
+        }
+    }
 }
 
 impl RenderOnce for Table {
@@ -969,9 +577,15 @@ impl RenderOnce for Table {
         let table_context = TableRenderContext::new(&self, cx);
         let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
 
-        let header_resize_info = interaction_state
-            .as_ref()
-            .and_then(|_| self.column_width_config.header_resize_info(cx));
+        let header_resize_info =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable { columns_state, .. } => {
+                        Some(HeaderResizeInfo::from_state(columns_state, cx))
+                    }
+                    _ => None,
+                });
 
         let table_width = self.column_width_config.table_width();
         let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
@@ -985,13 +599,19 @@ impl RenderOnce for Table {
                     ColumnWidthConfig::Redistributable {
                         columns_state: entity,
                         ..
-                    } => Some(entity.downgrade()),
+                    } => Some(entity.clone()),
                     _ => None,
                 });
 
-        let resize_handles = interaction_state
-            .as_ref()
-            .and_then(|_| self.column_width_config.render_resize_handles(window, cx));
+        let resize_handles =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable { columns_state, .. } => Some(
+                        render_redistributable_columns_resize_handles(columns_state, window, cx),
+                    ),
+                    _ => None,
+                });
 
         let table = div()
             .when_some(table_width, |this, width| this.w(width))
@@ -1006,38 +626,8 @@ impl RenderOnce for Table {
                     cx,
                 ))
             })
-            .when_some(redistributable_entity, {
-                |this, widths| {
-                    this.on_drag_move::<DraggedColumn>({
-                        let widths = widths.clone();
-                        move |e, window, cx| {
-                            widths
-                                .update(cx, |widths, cx| {
-                                    widths.on_drag_move(e, window, cx);
-                                })
-                                .ok();
-                        }
-                    })
-                    .on_children_prepainted({
-                        let widths = widths.clone();
-                        move |bounds, _, cx| {
-                            widths
-                                .update(cx, |widths, _| {
-                                    // This works because all children x axis bounds are the same
-                                    widths.cached_table_width =
-                                        bounds[0].right() - bounds[0].left();
-                                })
-                                .ok();
-                        }
-                    })
-                    .on_drop::<DraggedColumn>(move |_, _, cx| {
-                        widths
-                            .update(cx, |widths, _| {
-                                widths.committed_widths = widths.preview_widths.clone();
-                            })
-                            .ok();
-                    })
-                }
+            .when_some(redistributable_entity, |this, widths| {
+                bind_redistributable_columns(this, widths)
             })
             .child({
                 let content = div()

crates/ui/src/components/data_table/tests.rs 🔗

@@ -1,4 +1,5 @@
-use super::*;
+use super::table_row::TableRow;
+use crate::{RedistributableColumnsState, TableResizeBehavior};
 
 fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
     a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)

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

@@ -0,0 +1,485 @@
+use std::rc::Rc;
+
+use gpui::{
+    AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity, Length,
+    WeakEntity,
+};
+use itertools::intersperse_with;
+
+use super::data_table::table_row::{IntoTableRow as _, TableRow};
+use crate::{
+    ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement,
+    IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
+    px,
+};
+
+const RESIZE_COLUMN_WIDTH: f32 = 8.0;
+const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
+
+#[derive(Debug)]
+struct DraggedColumn(usize);
+
+#[derive(Debug, Copy, Clone, PartialEq)]
+pub enum TableResizeBehavior {
+    None,
+    Resizable,
+    MinSize(f32),
+}
+
+impl TableResizeBehavior {
+    pub fn is_resizable(&self) -> bool {
+        *self != TableResizeBehavior::None
+    }
+
+    pub fn min_size(&self) -> Option<f32> {
+        match self {
+            TableResizeBehavior::None => None,
+            TableResizeBehavior::Resizable => Some(0.05),
+            TableResizeBehavior::MinSize(min_size) => Some(*min_size),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct HeaderResizeInfo {
+    pub columns_state: WeakEntity<RedistributableColumnsState>,
+    pub resize_behavior: TableRow<TableResizeBehavior>,
+}
+
+impl HeaderResizeInfo {
+    pub fn from_state(columns_state: &Entity<RedistributableColumnsState>, cx: &App) -> Self {
+        let resize_behavior = columns_state.read(cx).resize_behavior().clone();
+        Self {
+            columns_state: columns_state.downgrade(),
+            resize_behavior,
+        }
+    }
+}
+
+pub struct RedistributableColumnsState {
+    pub(crate) initial_widths: TableRow<DefiniteLength>,
+    pub(crate) committed_widths: TableRow<DefiniteLength>,
+    pub(crate) preview_widths: TableRow<DefiniteLength>,
+    pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
+    pub(crate) cached_container_width: Pixels,
+}
+
+impl RedistributableColumnsState {
+    pub fn new(
+        cols: usize,
+        initial_widths: Vec<impl Into<DefiniteLength>>,
+        resize_behavior: Vec<TableResizeBehavior>,
+    ) -> Self {
+        let widths: TableRow<DefiniteLength> = initial_widths
+            .into_iter()
+            .map(Into::into)
+            .collect::<Vec<_>>()
+            .into_table_row(cols);
+        Self {
+            initial_widths: widths.clone(),
+            committed_widths: widths.clone(),
+            preview_widths: widths,
+            resize_behavior: resize_behavior.into_table_row(cols),
+            cached_container_width: Default::default(),
+        }
+    }
+
+    pub fn cols(&self) -> usize {
+        self.committed_widths.cols()
+    }
+
+    pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
+        &self.initial_widths
+    }
+
+    pub fn preview_widths(&self) -> &TableRow<DefiniteLength> {
+        &self.preview_widths
+    }
+
+    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
+        &self.resize_behavior
+    }
+
+    pub fn widths_to_render(&self) -> TableRow<Length> {
+        self.preview_widths.map_cloned(Length::Definite)
+    }
+
+    pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow<f32> {
+        if self.cached_container_width > px(0.) {
+            self.preview_widths
+                .map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size))
+        } else {
+            self.preview_widths.map_ref(|length| match length {
+                DefiniteLength::Fraction(fraction) => *fraction,
+                DefiniteLength::Absolute(_) => 0.0,
+            })
+        }
+    }
+
+    pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option<Pixels> {
+        let width = self.preview_widths().as_slice().get(column_index)?;
+        match width {
+            DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => {
+                Some(self.cached_container_width * *fraction)
+            }
+            DefiniteLength::Fraction(_) => None,
+            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels),
+            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
+                Some(rems_width.to_pixels(window.rem_size()))
+            }
+        }
+    }
+
+    pub fn cached_container_width(&self) -> Pixels {
+        self.cached_container_width
+    }
+
+    pub fn set_cached_container_width(&mut self, width: Pixels) {
+        self.cached_container_width = width;
+    }
+
+    pub fn commit_preview(&mut self) {
+        self.committed_widths = self.preview_widths.clone();
+    }
+
+    pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) {
+        let bounds_width = self.cached_container_width;
+        if bounds_width <= px(0.) {
+            return;
+        }
+
+        let rem_size = window.rem_size();
+        let initial_sizes = self
+            .initial_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+        let widths = self
+            .committed_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+
+        let updated_widths =
+            Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior);
+        self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
+        self.preview_widths = self.committed_widths.clone();
+    }
+
+    fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
+        match length {
+            DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
+            DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
+                rems_width.to_pixels(rem_size) / bounds_width
+            }
+            DefiniteLength::Fraction(fraction) => *fraction,
+        }
+    }
+
+    pub(crate) fn reset_to_initial_size(
+        col_idx: usize,
+        mut widths: TableRow<f32>,
+        initial_sizes: TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+    ) -> TableRow<f32> {
+        let diff = initial_sizes[col_idx] - widths[col_idx];
+
+        let left_diff =
+            initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
+        let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
+            - widths[col_idx + 1..].iter().sum::<f32>();
+
+        let go_left_first = if diff < 0.0 {
+            left_diff > right_diff
+        } else {
+            left_diff < right_diff
+        };
+
+        if !go_left_first {
+            let diff_remaining =
+                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
+
+            if diff_remaining != 0.0 && col_idx > 0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
+                    &mut widths,
+                    resize_behavior,
+                    -1,
+                );
+            }
+        } else {
+            let diff_remaining =
+                Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
+
+            if diff_remaining != 0.0 {
+                Self::propagate_resize_diff(
+                    diff_remaining,
+                    col_idx,
+                    &mut widths,
+                    resize_behavior,
+                    1,
+                );
+            }
+        }
+
+        widths
+    }
+
+    fn on_drag_move(
+        &mut self,
+        drag_event: &DragMoveEvent<DraggedColumn>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let drag_position = drag_event.event.position;
+        let bounds = drag_event.bounds;
+        let bounds_width = bounds.right() - bounds.left();
+        if bounds_width <= px(0.) {
+            return;
+        }
+
+        let mut col_position = 0.0;
+        let rem_size = window.rem_size();
+        let col_idx = drag_event.drag(cx).0;
+
+        let divider_width = Self::get_fraction(
+            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
+            bounds_width,
+            rem_size,
+        );
+
+        let mut widths = self
+            .committed_widths
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
+
+        for length in widths[0..=col_idx].iter() {
+            col_position += length + divider_width;
+        }
+
+        let mut total_length_ratio = col_position;
+        for length in widths[col_idx + 1..].iter() {
+            total_length_ratio += length;
+        }
+        let cols = self.resize_behavior.cols();
+        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
+
+        let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
+        let drag_fraction = drag_fraction * total_length_ratio;
+        let diff = drag_fraction - col_position - divider_width / 2.0;
+
+        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
+
+        self.preview_widths = widths.map(DefiniteLength::Fraction);
+    }
+
+    pub(crate) fn drag_column_handle(
+        diff: f32,
+        col_idx: usize,
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+    ) {
+        if diff > 0.0 {
+            Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
+        } else {
+            Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
+        }
+    }
+
+    pub(crate) fn propagate_resize_diff(
+        diff: f32,
+        col_idx: usize,
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+        direction: i8,
+    ) -> f32 {
+        let mut diff_remaining = diff;
+        if resize_behavior[col_idx].min_size().is_none() {
+            return diff;
+        }
+
+        let step_right;
+        let step_left;
+        if direction < 0 {
+            step_right = 0;
+            step_left = 1;
+        } else {
+            step_right = 1;
+            step_left = 0;
+        }
+        if col_idx == 0 && direction < 0 {
+            return diff;
+        }
+        let mut curr_column = col_idx + step_right - step_left;
+
+        while diff_remaining != 0.0 && curr_column < widths.cols() {
+            let Some(min_size) = resize_behavior[curr_column].min_size() else {
+                if curr_column == 0 {
+                    break;
+                }
+                curr_column -= step_left;
+                curr_column += step_right;
+                continue;
+            };
+
+            let curr_width = widths[curr_column] - diff_remaining;
+            widths[curr_column] = curr_width;
+
+            if min_size > curr_width {
+                diff_remaining = min_size - curr_width;
+                widths[curr_column] = min_size;
+            } else {
+                diff_remaining = 0.0;
+                break;
+            }
+            if curr_column == 0 {
+                break;
+            }
+            curr_column -= step_left;
+            curr_column += step_right;
+        }
+        widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
+
+        diff_remaining
+    }
+}
+
+pub fn bind_redistributable_columns(
+    container: Div,
+    columns_state: Entity<RedistributableColumnsState>,
+) -> Div {
+    container
+        .on_drag_move::<DraggedColumn>({
+            let columns_state = columns_state.clone();
+            move |event, window, cx| {
+                columns_state.update(cx, |columns, cx| {
+                    columns.on_drag_move(event, window, cx);
+                });
+            }
+        })
+        .on_children_prepainted({
+            let columns_state = columns_state.clone();
+            move |bounds, _, cx| {
+                if let Some(width) = child_bounds_width(&bounds) {
+                    columns_state.update(cx, |columns, _| {
+                        columns.set_cached_container_width(width);
+                    });
+                }
+            }
+        })
+        .on_drop::<DraggedColumn>(move |_, _, cx| {
+            columns_state.update(cx, |columns, _| {
+                columns.commit_preview();
+            });
+        })
+}
+
+pub fn render_redistributable_columns_resize_handles(
+    columns_state: &Entity<RedistributableColumnsState>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let (column_widths, resize_behavior) = {
+        let state = columns_state.read(cx);
+        (state.widths_to_render(), state.resize_behavior().clone())
+    };
+
+    let mut column_ix = 0;
+    let resize_behavior = Rc::new(resize_behavior);
+    let dividers = intersperse_with(
+        column_widths
+            .as_slice()
+            .iter()
+            .copied()
+            .map(|width| resize_spacer(width).into_any_element()),
+        || {
+            let current_column_ix = column_ix;
+            let resize_behavior = Rc::clone(&resize_behavior);
+            let columns_state = columns_state.clone();
+            column_ix += 1;
+
+            window.with_id(current_column_ix, |window| {
+                let mut resize_divider = div()
+                    .id(current_column_ix)
+                    .relative()
+                    .top_0()
+                    .w(px(RESIZE_DIVIDER_WIDTH))
+                    .h_full()
+                    .bg(cx.theme().colors().border.opacity(0.8));
+
+                let mut resize_handle = div()
+                    .id("column-resize-handle")
+                    .absolute()
+                    .left_neg_0p5()
+                    .w(px(RESIZE_COLUMN_WIDTH))
+                    .h_full();
+
+                if resize_behavior[current_column_ix].is_resizable() {
+                    let is_highlighted = window.use_state(cx, |_window, _cx| false);
+
+                    resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
+                        div.bg(cx.theme().colors().border_focused)
+                    });
+
+                    resize_handle = resize_handle
+                        .on_hover({
+                            let is_highlighted = is_highlighted.clone();
+                            move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
+                        })
+                        .cursor_col_resize()
+                        .on_click({
+                            let columns_state = columns_state.clone();
+                            move |event, window, cx| {
+                                if event.click_count() >= 2 {
+                                    columns_state.update(cx, |columns, _| {
+                                        columns.reset_column_to_initial_width(
+                                            current_column_ix,
+                                            window,
+                                        );
+                                    });
+                                }
+
+                                cx.stop_propagation();
+                            }
+                        })
+                        .on_drag(DraggedColumn(current_column_ix), {
+                            let is_highlighted = is_highlighted.clone();
+                            move |_, _offset, _window, cx| {
+                                is_highlighted.write(cx, true);
+                                cx.new(|_cx| Empty)
+                            }
+                        })
+                        .on_drop::<DraggedColumn>(move |_, _, cx| {
+                            is_highlighted.write(cx, false);
+                            columns_state.update(cx, |state, _| {
+                                state.commit_preview();
+                            });
+                        });
+                }
+
+                resize_divider.child(resize_handle).into_any_element()
+            })
+        },
+    );
+
+    h_flex()
+        .id("resize-handles")
+        .absolute()
+        .inset_0()
+        .w_full()
+        .children(dividers)
+        .into_any_element()
+}
+
+fn resize_spacer(width: Length) -> Div {
+    div().w(width).h_full()
+}
+
+fn child_bounds_width(bounds: &[Bounds<Pixels>]) -> Option<Pixels> {
+    let first_bounds = bounds.first()?;
+    let mut left = first_bounds.left();
+    let mut right = first_bounds.right();
+
+    for bound in bounds.iter().skip(1) {
+        left = left.min(bound.left());
+        right = right.max(bound.right());
+    }
+
+    Some(right - left)
+}

crates/vim/src/test/vim_test_context.rs 🔗

@@ -109,12 +109,12 @@ impl VimTestContext {
         }
         cx.bind_keys(default_key_bindings);
         if enabled {
-            let vim_key_bindings = settings::KeymapFile::load_asset(
-                "keymaps/vim.json",
-                Some(settings::KeybindSource::Vim),
-                cx,
-            )
-            .unwrap();
+            let mut vim_key_bindings =
+                settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx)
+                    .unwrap();
+            for key_binding in &mut vim_key_bindings {
+                key_binding.set_meta(settings::KeybindSource::Vim.meta());
+            }
             cx.bind_keys(vim_key_bindings);
         }
     }

crates/web_search_providers/src/cloud.rs 🔗

@@ -1,13 +1,13 @@
 use std::sync::Arc;
 
 use anyhow::{Context as _, Result};
-use client::{Client, UserStore};
+use client::{Client, NeedsLlmTokenRefresh, UserStore, global_llm_token};
 use cloud_api_types::OrganizationId;
 use cloud_llm_client::{WebSearchBody, WebSearchResponse};
 use futures::AsyncReadExt as _;
 use gpui::{App, AppContext, Context, Entity, Task};
 use http_client::{HttpClient, Method};
-use language_model::{LlmApiToken, NeedsLlmTokenRefresh};
+use language_model::LlmApiToken;
 use web_search::{WebSearchProvider, WebSearchProviderId};
 
 pub struct CloudWebSearchProvider {
@@ -30,7 +30,7 @@ pub struct State {
 
 impl State {
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
-        let llm_api_token = LlmApiToken::global(cx);
+        let llm_api_token = global_llm_token(cx);
 
         Self {
             client,
@@ -73,8 +73,8 @@ async fn perform_web_search(
 
     let http_client = &client.http_client();
     let mut retries_remaining = MAX_RETRIES;
-    let mut token = llm_api_token
-        .acquire(&client, organization_id.clone())
+    let mut token = client
+        .acquire_llm_token(&llm_api_token, organization_id.clone())
         .await?;
 
     loop {
@@ -100,8 +100,8 @@ async fn perform_web_search(
             response.body_mut().read_to_string(&mut body).await?;
             return Ok(serde_json::from_str(&body)?);
         } else if response.needs_llm_token_refresh() {
-            token = llm_api_token
-                .refresh(&client, organization_id.clone())
+            token = client
+                .refresh_llm_token(&llm_api_token, organization_id.clone())
                 .await?;
             retries_remaining -= 1;
         } else {

crates/workspace/src/workspace.rs 🔗

@@ -1344,6 +1344,8 @@ pub struct Workspace {
     scheduled_tasks: Vec<Task<()>>,
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
+    open_in_dev_container: bool,
+    _dev_container_task: Option<Task<Result<()>>>,
     _panels_task: Option<Task<Result<()>>>,
     sidebar_focus_handle: Option<FocusHandle>,
     multi_workspace: Option<WeakEntity<MultiWorkspace>>,
@@ -1778,6 +1780,8 @@ impl Workspace {
             removing: false,
             sidebar_focus_handle: None,
             multi_workspace,
+            open_in_dev_container: false,
+            _dev_container_task: None,
         }
     }
 
@@ -2800,6 +2804,18 @@ impl Workspace {
         self.debugger_provider = Some(Arc::new(provider));
     }
 
+    pub fn set_open_in_dev_container(&mut self, value: bool) {
+        self.open_in_dev_container = value;
+    }
+
+    pub fn open_in_dev_container(&self) -> bool {
+        self.open_in_dev_container
+    }
+
+    pub fn set_dev_container_task(&mut self, task: Task<Result<()>>) {
+        self._dev_container_task = Some(task);
+    }
+
     pub fn debugger_provider(&self) -> Option<Arc<dyn DebuggerProvider>> {
         self.debugger_provider.clone()
     }
@@ -3026,7 +3042,6 @@ impl Workspace {
         self.project.read(cx).visible_worktrees(cx)
     }
 
-    #[cfg(any(test, feature = "test-support"))]
     pub fn worktree_scans_complete(&self, cx: &App) -> impl Future<Output = ()> + 'static + use<> {
         let futures = self
             .worktrees(cx)
@@ -9214,6 +9229,7 @@ pub struct OpenOptions {
     pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
     pub open_mode: OpenMode,
     pub env: Option<HashMap<String, String>>,
+    pub open_in_dev_container: bool,
 }
 
 /// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`],
@@ -9393,12 +9409,17 @@ pub fn open_paths(
             }
         }
 
+        let open_in_dev_container = open_options.open_in_dev_container;
+
         let result = if let Some((existing, target_workspace)) = existing {
             let open_task = existing
                 .update(cx, |multi_workspace, window, cx| {
                     window.activate_window();
                     multi_workspace.activate(target_workspace.clone(), window, cx);
                     target_workspace.update(cx, |workspace, cx| {
+                        if open_in_dev_container {
+                            workspace.set_open_in_dev_container(true);
+                        }
                         workspace.open_paths(
                             abs_paths,
                             OpenOptions {
@@ -9426,6 +9447,13 @@ pub fn open_paths(
 
             Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task })
         } else {
+            let init = if open_in_dev_container {
+                Some(Box::new(|workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>| {
+                    workspace.set_open_in_dev_container(true);
+                }) as Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>)
+            } else {
+                None
+            };
             let result = cx
                 .update(move |cx| {
                     Workspace::new_local(
@@ -9433,7 +9461,7 @@ pub fn open_paths(
                         app_state.clone(),
                         open_options.requesting_window,
                         open_options.env,
-                        None,
+                        init,
                         open_options.open_mode,
                         cx,
                     )

crates/zed/src/main.rs 🔗

@@ -10,7 +10,7 @@ use agent_ui::AgentPanel;
 use anyhow::{Context as _, Error, Result};
 use clap::Parser;
 use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
-use client::{Client, ProxySettings, UserStore, parse_zed_link};
+use client::{Client, ProxySettings, RefreshLlmTokenListener, UserStore, parse_zed_link};
 use collab_ui::channel_view::ChannelView;
 use collections::HashMap;
 use crashes::InitCrashHandler;
@@ -664,7 +664,12 @@ fn main() {
         );
 
         copilot_ui::init(&app_state, cx);
-        language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+        language_model::init(cx);
+        RefreshLlmTokenListener::register(
+            app_state.client.clone(),
+            app_state.user_store.clone(),
+            cx,
+        );
         language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
         acp_tools::init(cx);
         zed::telemetry_log::init(cx);
@@ -857,6 +862,7 @@ fn main() {
                 diff_paths,
                 wsl,
                 diff_all: diff_all_mode,
+                dev_container: args.dev_container,
             })
         }
 
@@ -1208,6 +1214,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
     }
 
     let mut task = None;
+    let dev_container = request.dev_container;
     if !request.open_paths.is_empty() || !request.diff_paths.is_empty() {
         let app_state = app_state.clone();
         task = Some(cx.spawn(async move |cx| {
@@ -1218,7 +1225,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 &request.diff_paths,
                 request.diff_all,
                 app_state,
-                workspace::OpenOptions::default(),
+                workspace::OpenOptions {
+                    open_in_dev_container: dev_container,
+                    ..Default::default()
+                },
                 cx,
             )
             .await?;
@@ -1636,6 +1646,13 @@ struct Args {
     #[arg(long, value_name = "USER@DISTRO")]
     wsl: Option<String>,
 
+    /// Open the project in a dev container.
+    ///
+    /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/`
+    /// configuration is found in the project directory.
+    #[arg(long)]
+    dev_container: bool,
+
     /// Instructs zed to run as a dev server on this machine. (not implemented)
     #[arg(long)]
     dev_server_token: Option<String>,

crates/zed/src/visual_test_runner.rs 🔗

@@ -201,7 +201,12 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         });
         prompt_store::init(cx);
         let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx);
-        language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+        language_model::init(cx);
+        client::RefreshLlmTokenListener::register(
+            app_state.client.clone(),
+            app_state.user_store.clone(),
+            cx,
+        );
         language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
         git_ui::init(cx);
         project::AgentRegistryStore::init_global(

crates/zed/src/zed.rs 🔗

@@ -5167,6 +5167,7 @@ mod tests {
             app_state.languages.add(markdown_lang());
 
             gpui_tokio::init(cx);
+            AppState::set_global(app_state.clone(), cx);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
             audio::init(cx);
             channel::init(&app_state.client, app_state.user_store.clone(), cx);
@@ -5188,7 +5189,12 @@ mod tests {
                 cx,
             );
             image_viewer::init(cx);
-            language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+            language_model::init(cx);
+            client::RefreshLlmTokenListener::register(
+                app_state.client.clone(),
+                app_state.user_store.clone(),
+                cx,
+            );
             language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
             web_search::init(cx);
             git_graph::init(cx);

crates/zed/src/zed/edit_prediction_registry.rs 🔗

@@ -313,7 +313,12 @@ mod tests {
         let app_state = cx.update(|cx| {
             let app_state = AppState::test(cx);
             client::init(&app_state.client, cx);
-            language_model::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+            language_model::init(cx);
+            client::RefreshLlmTokenListener::register(
+                app_state.client.clone(),
+                app_state.user_store.clone(),
+                cx,
+            );
             editor::init(cx);
             app_state
         });

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

@@ -37,6 +37,7 @@ pub struct OpenRequest {
     pub open_paths: Vec<String>,
     pub diff_paths: Vec<[String; 2]>,
     pub diff_all: bool,
+    pub dev_container: bool,
     pub open_channel_notes: Vec<(u64, Option<String>)>,
     pub join_channel: Option<u64>,
     pub remote_connection: Option<RemoteConnectionOptions>,
@@ -78,6 +79,7 @@ impl OpenRequest {
 
         this.diff_paths = request.diff_paths;
         this.diff_all = request.diff_all;
+        this.dev_container = request.dev_container;
         if let Some(wsl) = request.wsl {
             let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
                 if user.is_empty() {
@@ -256,6 +258,7 @@ pub struct RawOpenRequest {
     pub urls: Vec<String>,
     pub diff_paths: Vec<[String; 2]>,
     pub diff_all: bool,
+    pub dev_container: bool,
     pub wsl: Option<String>,
 }
 
@@ -413,6 +416,7 @@ pub async fn handle_cli_connection(
                 reuse,
                 env,
                 user_data_dir: _,
+                dev_container,
             } => {
                 if !urls.is_empty() {
                     cx.update(|cx| {
@@ -421,6 +425,7 @@ pub async fn handle_cli_connection(
                                 urls,
                                 diff_paths,
                                 diff_all,
+                                dev_container,
                                 wsl,
                             },
                             cx,
@@ -450,6 +455,7 @@ pub async fn handle_cli_connection(
                     reuse,
                     &responses,
                     wait,
+                    dev_container,
                     app_state.clone(),
                     env,
                     cx,
@@ -471,6 +477,7 @@ async fn open_workspaces(
     reuse: bool,
     responses: &IpcSender<CliResponse>,
     wait: bool,
+    dev_container: bool,
     app_state: Arc<AppState>,
     env: Option<collections::HashMap<String, String>>,
     cx: &mut AsyncApp,
@@ -532,6 +539,7 @@ async fn open_workspaces(
             requesting_window: replace_window,
             wait,
             env: env.clone(),
+            open_in_dev_container: dev_container,
             ..Default::default()
         };
 
@@ -1545,4 +1553,123 @@ mod tests {
             })
             .unwrap();
     }
+
+    #[gpui::test]
+    async fn test_dev_container_flag_opens_modal(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(|cx| recent_projects::init(cx));
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    ".devcontainer": {
+                        "devcontainer.json": "{}"
+                    },
+                    "src": {
+                        "main.rs": "fn main() {}"
+                    }
+                }),
+            )
+            .await;
+
+        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let errored = cx
+            .spawn({
+                let app_state = app_state.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        vec![path!("/project").to_owned()],
+                        vec![],
+                        false,
+                        workspace::OpenOptions {
+                            open_in_dev_container: true,
+                            ..Default::default()
+                        },
+                        &response_tx,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        assert!(!errored);
+
+        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                let flag = multi_workspace.workspace().read(cx).open_in_dev_container();
+                assert!(
+                    !flag,
+                    "open_in_dev_container flag should be consumed by suggest_on_worktree_updated"
+                );
+            })
+            .unwrap();
+    }
+
+    #[gpui::test]
+    async fn test_dev_container_flag_cleared_without_config(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(|cx| recent_projects::init(cx));
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    "src": {
+                        "main.rs": "fn main() {}"
+                    }
+                }),
+            )
+            .await;
+
+        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let errored = cx
+            .spawn({
+                let app_state = app_state.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        vec![path!("/project").to_owned()],
+                        vec![],
+                        false,
+                        workspace::OpenOptions {
+                            open_in_dev_container: true,
+                            ..Default::default()
+                        },
+                        &response_tx,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        assert!(!errored);
+
+        // Let any pending worktree scan events and updates settle.
+        cx.run_until_parked();
+
+        // With no .devcontainer config, the flag should be cleared once the
+        // worktree scan completes, rather than persisting on the workspace.
+        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                let flag = multi_workspace
+                    .workspace()
+                    .read(cx)
+                    .open_in_dev_container();
+                assert!(
+                    !flag,
+                    "open_in_dev_container flag should be cleared when no devcontainer config exists"
+                );
+            })
+            .unwrap();
+    }
 }

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

@@ -162,6 +162,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             reuse: false,
             env: None,
             user_data_dir: args.user_data_dir.clone(),
+            dev_container: args.dev_container,
         }
     };
 

crates/zed_credentials_provider/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "zed_credentials_provider"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/zed_credentials_provider.rs"
+
+[dependencies]
+anyhow.workspace = true
+credentials_provider.workspace = true
+futures.workspace = true
+gpui.workspace = true
+paths.workspace = true
+release_channel.workspace = true
+serde.workspace = true
+serde_json.workspace = true

crates/zed_credentials_provider/src/zed_credentials_provider.rs 🔗

@@ -0,0 +1,181 @@
+use std::collections::HashMap;
+use std::future::Future;
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::sync::{Arc, LazyLock};
+
+use anyhow::Result;
+use credentials_provider::CredentialsProvider;
+use futures::FutureExt as _;
+use gpui::{App, AsyncApp, Global};
+use release_channel::ReleaseChannel;
+
+/// An environment variable whose presence indicates that the system keychain
+/// should be used in development.
+///
+/// By default, running Zed in development uses the development credentials
+/// provider. Setting this environment variable allows you to interact with the
+/// system keychain (for instance, if you need to test something).
+///
+/// Only works in development. Setting this environment variable in other
+/// release channels is a no-op.
+static ZED_DEVELOPMENT_USE_KEYCHAIN: LazyLock<bool> = LazyLock::new(|| {
+    std::env::var("ZED_DEVELOPMENT_USE_KEYCHAIN").is_ok_and(|value| !value.is_empty())
+});
+
+pub struct ZedCredentialsProvider(pub Arc<dyn CredentialsProvider>);
+
+impl Global for ZedCredentialsProvider {}
+
+/// Returns the global [`CredentialsProvider`].
+pub fn init_global(cx: &mut App) {
+    // The `CredentialsProvider` trait has `Send + Sync` bounds on it, so it
+    // seems like this is a false positive from Clippy.
+    #[allow(clippy::arc_with_non_send_sync)]
+    let provider = new(cx);
+    cx.set_global(ZedCredentialsProvider(provider));
+}
+
+pub fn global(cx: &App) -> Arc<dyn CredentialsProvider> {
+    cx.try_global::<ZedCredentialsProvider>()
+        .map(|provider| provider.0.clone())
+        .unwrap_or_else(|| new(cx))
+}
+
+fn new(cx: &App) -> Arc<dyn CredentialsProvider> {
+    let use_development_provider = match ReleaseChannel::try_global(cx) {
+        Some(ReleaseChannel::Dev) => {
+            // In development we default to using the development
+            // credentials provider to avoid getting spammed by relentless
+            // keychain access prompts.
+            //
+            // However, if the `ZED_DEVELOPMENT_USE_KEYCHAIN` environment
+            // variable is set, we will use the actual keychain.
+            !*ZED_DEVELOPMENT_USE_KEYCHAIN
+        }
+        Some(ReleaseChannel::Nightly | ReleaseChannel::Preview | ReleaseChannel::Stable) | None => {
+            false
+        }
+    };
+
+    if use_development_provider {
+        Arc::new(DevelopmentCredentialsProvider::new())
+    } else {
+        Arc::new(KeychainCredentialsProvider)
+    }
+}
+
+/// A credentials provider that stores credentials in the system keychain.
+struct KeychainCredentialsProvider;
+
+impl CredentialsProvider for KeychainCredentialsProvider {
+    fn read_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
+        async move { cx.update(|cx| cx.read_credentials(url)).await }.boxed_local()
+    }
+
+    fn write_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        username: &'a str,
+        password: &'a [u8],
+        cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
+        async move {
+            cx.update(move |cx| cx.write_credentials(url, username, password))
+                .await
+        }
+        .boxed_local()
+    }
+
+    fn delete_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
+        async move { cx.update(move |cx| cx.delete_credentials(url)).await }.boxed_local()
+    }
+}
+
+/// A credentials provider that stores credentials in a local file.
+///
+/// This MUST only be used in development, as this is not a secure way of storing
+/// credentials on user machines.
+///
+/// Its existence is purely to work around the annoyance of having to constantly
+/// re-allow access to the system keychain when developing Zed.
+struct DevelopmentCredentialsProvider {
+    path: PathBuf,
+}
+
+impl DevelopmentCredentialsProvider {
+    fn new() -> Self {
+        let path = paths::config_dir().join("development_credentials");
+
+        Self { path }
+    }
+
+    fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
+        let json = std::fs::read(&self.path)?;
+        let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
+
+        Ok(credentials)
+    }
+
+    fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
+        let json = serde_json::to_string(credentials)?;
+        std::fs::write(&self.path, json)?;
+
+        Ok(())
+    }
+}
+
+impl CredentialsProvider for DevelopmentCredentialsProvider {
+    fn read_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        _cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
+        async move {
+            Ok(self
+                .load_credentials()
+                .unwrap_or_default()
+                .get(url)
+                .cloned())
+        }
+        .boxed_local()
+    }
+
+    fn write_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        username: &'a str,
+        password: &'a [u8],
+        _cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
+        async move {
+            let mut credentials = self.load_credentials().unwrap_or_default();
+            credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
+
+            self.save_credentials(&credentials)
+        }
+        .boxed_local()
+    }
+
+    fn delete_credentials<'a>(
+        &'a self,
+        url: &'a str,
+        _cx: &'a AsyncApp,
+    ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
+        async move {
+            let mut credentials = self.load_credentials()?;
+            credentials.remove(url);
+
+            self.save_credentials(&credentials)
+        }
+        .boxed_local()
+    }
+}

crates/zed_env_vars/src/zed_env_vars.rs 🔗

@@ -1,45 +1,6 @@
-use gpui::SharedString;
+pub use env_var::{EnvVar, bool_env_var, env_var};
 use std::sync::LazyLock;
 
 /// Whether Zed is running in stateless mode.
 /// When true, Zed will use in-memory databases instead of persistent storage.
 pub static ZED_STATELESS: LazyLock<bool> = bool_env_var!("ZED_STATELESS");
-
-#[derive(Clone)]
-pub struct EnvVar {
-    pub name: SharedString,
-    /// Value of the environment variable. Also `None` when set to an empty string.
-    pub value: Option<String>,
-}
-
-impl EnvVar {
-    pub fn new(name: SharedString) -> Self {
-        let value = std::env::var(name.as_str()).ok();
-        if value.as_ref().is_some_and(|v| v.is_empty()) {
-            Self { name, value: None }
-        } else {
-            Self { name, value }
-        }
-    }
-
-    pub fn or(self, other: EnvVar) -> EnvVar {
-        if self.value.is_some() { self } else { other }
-    }
-}
-
-/// Creates a `LazyLock<EnvVar>` expression for use in a `static` declaration.
-#[macro_export]
-macro_rules! env_var {
-    ($name:expr) => {
-        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()))
-    };
-}
-
-/// Generates a `LazyLock<bool>` expression for use in a `static` declaration. Checks if the
-/// environment variable exists and is non-empty.
-#[macro_export]
-macro_rules! bool_env_var {
-    ($name:expr) => {
-        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
-    };
-}

docs/src/ai/agent-panel.md 🔗

@@ -67,7 +67,9 @@ Right-click on any agent response in the thread view to access a context menu wi
 
 ### Navigating the Thread {#navigating-the-thread}
 
-In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread.
+In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. You can also scroll the thread using arrow keys, Page Up/Down, Home/End, and Shift+Page Up/Down to jump between messages, when the thread pane is focused.
+
+When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts.
 
 ### Navigating History {#navigating-history}
 

docs/src/performance.md 🔗

@@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r
 
 The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner.
 
-<img width="851" height="auto" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" />
+<img width="851" height="auto" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" style="display: block; margin: 0 auto;" />
 
 # In depth CPU profiling (Tracing)
 
@@ -53,20 +53,40 @@ Download the profiler:
 
 Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it.
 
-<img width="392" height="auto" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" />
+<img width="392" height="auto" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" style="display: block; margin: 0 auto;"/>
 
 Tracy is an incredibly powerful profiler which can do a lot however it's UI is not that friendly. This is not the place for an in depth guide to Tracy, I do however want to highlight one particular workflow that is helpful when figuring out why a piece of code is _sometimes_ slow.
 
 Here are the steps:
 
 1. Click the flamechart button at the top.
+
+<img width="1815" height="auto" alt="Click flamechart" src="https://github.com/user-attachments/assets/9b488c60-90fa-4013-a663-f4e35ea753d2" />
+
 2. Click on a function that takes a lot of time.
+
+<img width="2001" height="auto" alt="Click snapshot" src="https://github.com/user-attachments/assets/ddb838ed-2c83-4dba-a750-b8a2d4ac6202" />
+
 3. Expand the list of function calls by clicking on main thread.
+
+<img width="2313" height="auto" alt="Click main thread" src="https://github.com/user-attachments/assets/465dd883-9d3c-4384-a396-fce68b872d1a" />
+
 4. Filter that list to the slower calls then click on one of the slow calls in the list
+
+<img width="2264" height="auto" alt="Select the tail calls in the histogram to filter down the list of calls then click on one call" src="https://github.com/user-attachments/assets/a8fddc7c-f40a-4f11-a648-ca7cc193ff6f" />
+
 5. Click zoom to zone to go to that specific function call in the timeline
+
+<img width="1822" height="auto" alt="Click zoom to zone" src="https://github.com/user-attachments/assets/3391664d-7297-41d4-be17-ac9b2e2c85d1" />
+
 6. Scroll to zoom in and see more detail about the callers
+
+<img width="1964" height="auto" alt="Scroll to zoom in" src="https://github.com/user-attachments/assets/625c2bf4-a68d-40c4-becb-ade16bc9a8bc" />
+
 7. Click on a caller to to get statistics on _it_.
 
+<img width="1888" height="auto" alt="Click on any of the zones to get statistics" src="https://github.com/user-attachments/assets/7e578825-2b63-4b7f-88f7-0cb16b8a3387" />
+
 While normally the blue bars in the Tracy timeline correspond to function calls they can time any part of a codebase. In the example below we have added an extra span "for block in edits" and added metadata to it: the block_height. You can do that like this:
 
 ```rust
@@ -74,14 +94,6 @@ let span = ztracing::debug_span!("for block in edits", block_height = block.heig
 let _enter = span.enter(); // span guard, when this is dropped the span ends (and its duration is recorded)
 ```
 
-<img width="1815" height="auto" alt="Click flamechart" src="https://github.com/user-attachments/assets/9b488c60-90fa-4013-a663-f4e35ea753d2" />
-<img width="2001" height="auto" alt="Click snapshot" src="https://github.com/user-attachments/assets/ddb838ed-2c83-4dba-a750-b8a2d4ac6202" />
-<img width="2313" height="auto" alt="Click main thread" src="https://github.com/user-attachments/assets/465dd883-9d3c-4384-a396-fce68b872d1a" />
-<img width="2264" height="auto" alt="Select the tail calls in the histogram to filter down the list of calls then click on one call" src="https://github.com/user-attachments/assets/a8fddc7c-f40a-4f11-a648-ca7cc193ff6f" />
-<img width="1822" height="auto" alt="Click zoom to zone" src="https://github.com/user-attachments/assets/3391664d-7297-41d4-be17-ac9b2e2c85d1" />
-<img width="1964" height="auto" alt="Scroll to zoom in" src="https://github.com/user-attachments/assets/625c2bf4-a68d-40c4-becb-ade16bc9a8bc" />
-<img width="1888" height="auto" alt="Click on any of the zones to get statistics" src="https://github.com/user-attachments/assets/7e578825-2b63-4b7f-88f7-0cb16b8a3387" />
-
 # Task/Async profiling
 
 Get a profile of the zed foreground executor and background executors. Check if

docs/theme/css/general.css 🔗

@@ -70,10 +70,21 @@ h5,
 h6 {
   position: relative;
   font-family: var(--title-font);
-  font-weight: 480;
+  font-weight: 400;
+}
+
+h1 {
   color: var(--title-color);
 }
 
+h2,
+h3,
+h4,
+h5,
+h6 {
+  color: var(--full-contrast);
+}
+
 /* Don't change font size in headers. */
 h1 code,
 h2 code,
@@ -213,7 +224,7 @@ hr {
 }
 
 .content {
-  padding: 48px 32px 0 32px;
+  padding: 32px 32px 0 32px;
   display: flex;
   justify-content: space-between;
   gap: 36px;
@@ -272,10 +283,14 @@ hr {
   border-radius: 8px;
   overflow: clip;
 }
-.content .header:link,
-.content .header:visited {
+.content h1 .header:link,
+.content h1 .header:visited {
   color: var(--title-color);
 }
+.content :is(h2, h3, h4, h5, h6) .header:link,
+.content :is(h2, h3, h4, h5, h6) .header:visited {
+  color: var(--full-contrast);
+}
 .content .header:link,
 .content .header:visited:hover {
   text-decoration: none;
@@ -383,15 +398,17 @@ blockquote .warning:before {
 }
 
 kbd {
-  background-color: rgba(8, 76, 207, 0.1);
+  background-color: var(--keybinding-bg);
+  padding: 4px 4px 6px 4px;
   border-radius: 4px;
+  font-family: var(--mono-font);
+  display: inline-block;
+  margin: 0 2px;
   border: solid 1px var(--popover-border);
   box-shadow: inset 0 -1px 0 var(--theme-hover);
-  display: inline-block;
   font-size: var(--code-font-size);
-  font-family: var(--mono-font);
+  color: var(--full-contrast);
   line-height: 10px;
-  padding: 4px 5px;
   vertical-align: middle;
 }
 

docs/theme/css/variables.css 🔗

@@ -11,11 +11,12 @@
   --page-padding: 15px;
   --content-max-width: 690px;
   --menu-bar-height: 64px;
-  --font: "IA Writer Quattro S", sans-serif;
-  --title-font: "Lora", "Helvetica Neue", Helvetica, Arial, sans-serif;
+  --font: "iA Writer Quattro S", sans-serif;
+  --title-font:
+    "IBM Plex Serif", "Helvetica Neue", Helvetica, Arial, sans-serif;
   --mono-font:
-    ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono,
-    Courier New, monospace;
+    "Lilex", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+    Liberation Mono, Courier New, monospace;
   --code-font-size: 0.875em
     /* please adjust the ace font size accordingly in editor.js */;
 
@@ -151,7 +152,7 @@
   --inline-code-color: hsl(40, 100%, 80%);
   --code-text: hsl(220, 13%, 95%);
   --code-bg: hsl(220, 93%, 50%, 0.2);
-  --keybinding-bg: hsl(0, 0%, 12%);
+  --keybinding-bg: hsl(220, 20%, 10%);
 
   --pre-bg: hsl(220, 13%, 5%);
   --pre-border: hsla(220, 93%, 70%, 0.3);
@@ -162,7 +163,7 @@
   --popover-shadow:
     0 10px 15px -3px hsl(0, 0%, 0%, 0.1), 0 4px 6px -4px hsl(0, 0%, 0%, 0.1);
 
-  --theme-hover: hsl(220, 13%, 25%);
+  --theme-hover: hsl(220, 13%, 20%);
   --hover-section-title: hsl(220, 13%, 11%);
 
   --quote-bg: hsl(220, 13%, 25%, 0.4);

docs/theme/fonts/fonts.css 🔗

@@ -3,15 +3,37 @@
 /* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */
 
 @font-face {
-  font-family: "IA Writer Quattro S";
+  font-family: "iA Writer Quattro S";
+  src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV.woff2")
+    format("woff2-variations");
+  font-weight: 100 900;
   font-style: normal;
-  font-weight: 400;
-  src: url("iAWriterQuattroS-Regular.woff2") format("woff2");
+  font-display: swap;
 }
 
 @font-face {
-  font-family: "Lora";
-  src: url("Lora.var.woff2") format("woff2-variations");
+  font-family: "iA Writer Quattro S";
+  src: url("https://cdn.zed.dev/fonts/iAWriterQuattroV-Italic.woff2")
+    format("woff2-variations");
   font-weight: 100 900;
+  font-style: italic;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: "IBM Plex Serif";
+  src: url("https://cdn.zed.dev/fonts/IBMPlexSerif-Var.woff2")
+    format("woff2-variations");
+  font-weight: 400 700;
+  font-style: normal;
+  font-display: swap;
+}
+
+@font-face {
+  font-family: "Lilex";
+  src: url("https://cdn.zed.dev/fonts/Lilex-Regular.woff2")
+    format("woff2-variations");
+  font-weight: 400;
   font-style: normal;
+  font-display: swap;
 }

docs/theme/page-toc.css 🔗

@@ -5,7 +5,7 @@
   display: flex;
   flex-direction: column;
   gap: 4px;
-  padding: 28px 0 120px 0;
+  padding: 16px 0 120px 0;
   width: 200px;
   max-height: calc(100svh - 50px);
   overflow-x: hidden;

docs/theme/plugins.css 🔗

@@ -1,8 +1,8 @@
 kbd.keybinding {
   background-color: var(--keybinding-bg);
-  padding: 2px 4px;
-  border-radius: 3px;
-  font-family: monospace;
+  padding: 4px 4px 6px 4px;
+  border-radius: 4px;
+  font-family: var(--mono-font);
   display: inline-block;
   margin: 0 2px;
 }

script/docs-suggest-publish 🔗

@@ -131,14 +131,14 @@ if [[ "$DRY_RUN" == "true" ]]; then
     echo "Would auto-apply suggestions to docs via Droid and create a draft PR."
     echo "Model: $MODEL"
     echo ""
-    
+
     # Show each suggestion file
     for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do
         echo "--- $file ---"
         git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || echo "(file not found)"
         echo ""
     done
-    
+
     echo -e "${YELLOW}=== END DRY RUN ===${NC}"
     echo ""
     echo "Run without --dry-run to create the PR."
@@ -213,7 +213,7 @@ fi
 FLAGGED_PRS=()
 FLAGS_FILE="$REPO_ROOT/crates/feature_flags/src/flags.rs"
 if [[ -f "$FLAGS_FILE" ]]; then
-    # Extract feature flag struct names (e.g. SubagentsFeatureFlag, GitGraphFeatureFlag)
+    # Extract feature flag struct names (e.g. SubagentsFeatureFlag)
     FLAG_NAMES=$(grep -oE 'pub struct \w+FeatureFlag' "$FLAGS_FILE" | awk '{print $3}')
     if [[ -n "$FLAG_NAMES" ]]; then
         FLAG_PATTERN=$(echo "$FLAG_NAMES" | tr '\n' '|' | sed 's/|$//')
@@ -538,10 +538,10 @@ echo -e "${GREEN}PR created:${NC} $PR_URL"
 if [[ "$KEEP_QUEUE" != "true" ]]; then
     echo ""
     echo "Resetting suggestions queue..."
-    
+
     git checkout --orphan "${SUGGESTIONS_BRANCH}-reset"
     git rm -rf . > /dev/null 2>&1 || true
-    
+
     cat > README.md << 'EOF'
 # Documentation Suggestions Queue
 
@@ -562,19 +562,19 @@ run `script/docs-suggest-publish` to create a documentation PR from these sugges
 3. At preview release, suggestions are collected into a docs PR
 4. After docs PR is created, this branch is reset
 EOF
-    
+
     mkdir -p suggestions
     echo '{"suggestions":[]}' > manifest.json
     git add README.md suggestions manifest.json
     git commit -m "Reset documentation suggestions queue
 
 Previous suggestions published in: $PR_URL"
-    
+
     # Force push required: replacing the orphan suggestions branch with a clean slate
     git push -f origin "${SUGGESTIONS_BRANCH}-reset:$SUGGESTIONS_BRANCH"
     git checkout "$ORIGINAL_BRANCH"
     git branch -D "${SUGGESTIONS_BRANCH}-reset"
-    
+
     echo "Suggestions queue reset."
 else
     git checkout "$ORIGINAL_BRANCH"