diff --git a/.github/actions/build_docs/action.yml b/.github/actions/build_docs/action.yml
index 9a2d7e1ec7..a7effad247 100644
--- a/.github/actions/build_docs/action.yml
+++ b/.github/actions/build_docs/action.yml
@@ -19,7 +19,7 @@ runs:
shell: bash -euxo pipefail {0}
run: ./script/linux
- - name: Check for broken links
+ - name: Check for broken links (in MD)
uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
with:
args: --no-progress --exclude '^http' './docs/src/**/*'
@@ -30,3 +30,9 @@ runs:
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
+
+ - name: Check for broken links (in HTML)
+ uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
+ with:
+ args: --no-progress --exclude '^http' 'target/deploy/docs/'
+ fail: true
diff --git a/Cargo.lock b/Cargo.lock
index f16b67f49a..a922f0e74e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,7 +20,9 @@ dependencies = [
"itertools 0.14.0",
"language",
"markdown",
+ "parking_lot",
"project",
+ "rand 0.8.5",
"serde",
"serde_json",
"settings",
@@ -114,7 +116,6 @@ dependencies = [
"pretty_assertions",
"project",
"prompt_store",
- "proto",
"rand 0.8.5",
"ref-cast",
"rope",
@@ -355,10 +356,10 @@ name = "ai_onboarding"
version = "0.1.0"
dependencies = [
"client",
+ "cloud_llm_client",
"component",
"gpui",
"language_model",
- "proto",
"serde",
"smallvec",
"telemetry",
@@ -1075,17 +1076,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "async-recursion"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7d78656ba01f1b93024b7c3a0467f1608e4be67d725749fdcd7d2c7678fd7a2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -2971,11 +2961,11 @@ name = "client"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-recursion 0.3.2",
"async-tungstenite",
"base64 0.22.1",
"chrono",
"clock",
+ "cloud_api_client",
"cloud_llm_client",
"cocoa 0.26.0",
"collections",
@@ -3031,6 +3021,31 @@ dependencies = [
"workspace-hack",
]
+[[package]]
+name = "cloud_api_client"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cloud_api_types",
+ "futures 0.3.31",
+ "http_client",
+ "parking_lot",
+ "serde_json",
+ "workspace-hack",
+]
+
+[[package]]
+name = "cloud_api_types"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "cloud_llm_client",
+ "pretty_assertions",
+ "serde",
+ "serde_json",
+ "workspace-hack",
+]
+
[[package]]
name = "cloud_llm_client"
version = "0.1.0"
@@ -4269,41 +4284,6 @@ dependencies = [
"workspace-hack",
]
-[[package]]
-name = "darling"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
-dependencies = [
- "darling_core",
- "darling_macro",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
-dependencies = [
- "fnv",
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn 2.0.101",
-]
-
-[[package]]
-name = "darling_macro"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
-dependencies = [
- "darling_core",
- "quote",
- "syn 2.0.101",
-]
-
[[package]]
name = "dashmap"
version = "5.5.3"
@@ -4519,37 +4499,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "derive_builder"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
-dependencies = [
- "derive_builder_macro",
-]
-
-[[package]]
-name = "derive_builder_core"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
-dependencies = [
- "darling",
- "proc-macro2",
- "quote",
- "syn 2.0.101",
-]
-
-[[package]]
-name = "derive_builder_macro"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
-dependencies = [
- "derive_builder_core",
- "syn 2.0.101",
-]
-
[[package]]
name = "derive_more"
version = "0.99.19"
@@ -4960,6 +4909,7 @@ dependencies = [
"theme",
"time",
"tree-sitter-bash",
+ "tree-sitter-c",
"tree-sitter-html",
"tree-sitter-python",
"tree-sitter-rust",
@@ -5928,7 +5878,7 @@ dependencies = [
"ignore",
"libc",
"log",
- "notify",
+ "notify 8.0.0",
"objc",
"parking_lot",
"paths",
@@ -7485,18 +7435,16 @@ dependencies = [
[[package]]
name = "handlebars"
-version = "6.3.2"
+version = "5.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098"
+checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b"
dependencies = [
- "derive_builder",
"log",
- "num-order",
"pest",
"pest_derive",
"serde",
"serde_json",
- "thiserror 2.0.12",
+ "thiserror 1.0.69",
]
[[package]]
@@ -7677,12 +7625,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-[[package]]
-name = "hex-literal"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bcaaec4551594c969335c98c903c1397853d4198408ea609190f420500f6be71"
-
[[package]]
name = "hexf-parse"
version = "0.2.1"
@@ -7860,6 +7802,7 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"log",
+ "parking_lot",
"serde",
"serde_json",
"url",
@@ -8167,12 +8110,6 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005"
-[[package]]
-name = "ident_case"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
-
[[package]]
name = "idna"
version = "1.0.3"
@@ -8397,6 +8334,17 @@ dependencies = [
"zeta",
]
+[[package]]
+name = "inotify"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
[[package]]
name = "inotify"
version = "0.11.0"
@@ -8550,7 +8498,7 @@ dependencies = [
"fnv",
"lazy_static",
"libc",
- "mio",
+ "mio 1.0.3",
"rand 0.8.5",
"serde",
"tempfile",
@@ -9132,7 +9080,6 @@ dependencies = [
"open_router",
"partial-json-fixer",
"project",
- "proto",
"release_channel",
"schemars",
"serde",
@@ -9404,7 +9351,7 @@ dependencies = [
[[package]]
name = "libwebrtc"
version = "0.3.10"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"cxx",
"jni",
@@ -9484,7 +9431,7 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
[[package]]
name = "livekit"
version = "0.7.8"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"chrono",
"futures-util",
@@ -9507,7 +9454,7 @@ dependencies = [
[[package]]
name = "livekit-api"
version = "0.4.2"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"futures-util",
"http 0.2.12",
@@ -9531,7 +9478,7 @@ dependencies = [
[[package]]
name = "livekit-protocol"
version = "0.3.9"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"futures-util",
"livekit-runtime",
@@ -9548,7 +9495,7 @@ dependencies = [
[[package]]
name = "livekit-runtime"
version = "0.4.0"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"tokio",
"tokio-stream",
@@ -9870,7 +9817,7 @@ name = "markdown_preview"
version = "0.1.0"
dependencies = [
"anyhow",
- "async-recursion 1.1.1",
+ "async-recursion",
"collections",
"editor",
"fs",
@@ -9990,9 +9937,9 @@ dependencies = [
[[package]]
name = "mdbook"
-version = "0.4.48"
+version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b6fbb4ac2d9fd7aa987c3510309ea3c80004a968d063c42f0d34fea070817c1"
+checksum = "b45a38e19bd200220ef07c892b0157ad3d2365e5b5a267ca01ad12182491eea5"
dependencies = [
"ammonia",
"anyhow",
@@ -10002,12 +9949,11 @@ dependencies = [
"elasticlunr-rs",
"env_logger 0.11.8",
"futures-util",
- "handlebars 6.3.2",
- "hex",
+ "handlebars 5.1.2",
"ignore",
"log",
"memchr",
- "notify",
+ "notify 6.1.1",
"notify-debouncer-mini",
"once_cell",
"opener",
@@ -10016,7 +9962,6 @@ dependencies = [
"regex",
"serde",
"serde_json",
- "sha2",
"shlex",
"tempfile",
"tokio",
@@ -10159,6 +10104,18 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "mio"
version = "1.0.3"
@@ -10505,6 +10462,25 @@ dependencies = [
"zed_actions",
]
+[[package]]
+name = "notify"
+version = "6.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
+dependencies = [
+ "bitflags 2.9.0",
+ "crossbeam-channel",
+ "filetime",
+ "fsevent-sys 4.1.0",
+ "inotify 0.9.6",
+ "kqueue",
+ "libc",
+ "log",
+ "mio 0.8.11",
+ "walkdir",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "notify"
version = "8.0.0"
@@ -10513,11 +10489,11 @@ dependencies = [
"bitflags 2.9.0",
"filetime",
"fsevent-sys 4.1.0",
- "inotify",
+ "inotify 0.11.0",
"kqueue",
"libc",
"log",
- "mio",
+ "mio 1.0.3",
"notify-types",
"walkdir",
"windows-sys 0.59.0",
@@ -10525,14 +10501,13 @@ dependencies = [
[[package]]
name = "notify-debouncer-mini"
-version = "0.6.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8"
+checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43"
dependencies = [
+ "crossbeam-channel",
"log",
- "notify",
- "notify-types",
- "tempfile",
+ "notify 6.1.1",
]
[[package]]
@@ -10672,21 +10647,6 @@ dependencies = [
"num-traits",
]
-[[package]]
-name = "num-modular"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
-
-[[package]]
-name = "num-order"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
-dependencies = [
- "num-modular",
-]
-
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -10968,21 +10928,33 @@ dependencies = [
name = "onboarding"
version = "0.1.0"
dependencies = [
+ "ai_onboarding",
"anyhow",
+ "client",
"command_palette_hooks",
+ "component",
"db",
+ "documented",
"editor",
"feature_flags",
"fs",
"gpui",
+ "itertools 0.14.0",
"language",
+ "language_model",
+ "menu",
"project",
+ "schemars",
+ "serde",
"settings",
"theme",
"ui",
+ "util",
+ "vim_mode_setting",
"workspace",
"workspace-hack",
"zed_actions",
+ "zlog",
]
[[package]]
@@ -14746,6 +14718,27 @@ dependencies = [
"zlog",
]
+[[package]]
+name = "settings_profile_selector"
+version = "0.1.0"
+dependencies = [
+ "client",
+ "editor",
+ "fuzzy",
+ "gpui",
+ "language",
+ "menu",
+ "picker",
+ "project",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "workspace",
+ "workspace-hack",
+ "zed_actions",
+]
+
[[package]]
name = "settings_ui"
version = "0.1.0"
@@ -14768,7 +14761,6 @@ dependencies = [
"notifications",
"paths",
"project",
- "schemars",
"search",
"serde",
"serde_json",
@@ -16205,7 +16197,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"assistant_slash_command",
- "async-recursion 1.1.1",
+ "async-recursion",
"breadcrumbs",
"client",
"collections",
@@ -16554,6 +16546,7 @@ dependencies = [
"call",
"chrono",
"client",
+ "cloud_llm_client",
"collections",
"db",
"gpui",
@@ -16589,7 +16582,7 @@ dependencies = [
"backtrace",
"bytes 1.10.1",
"libc",
- "mio",
+ "mio 1.0.3",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -18567,7 +18560,7 @@ dependencies = [
[[package]]
name = "webrtc-sys"
version = "0.3.7"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"cc",
"cxx",
@@ -18580,15 +18573,13 @@ dependencies = [
[[package]]
name = "webrtc-sys-build"
version = "0.3.6"
-source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=383e5377f8b7de1f8627ee16f0cf11c5293337bd#383e5377f8b7de1f8627ee16f0cf11c5293337bd"
+source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=5f04705ac3f356350ae31534ffbc476abc9ea83d#5f04705ac3f356350ae31534ffbc476abc9ea83d"
dependencies = [
"fs2",
- "hex-literal",
"regex",
"reqwest 0.11.27",
"scratch",
"semver",
- "sha2",
"zip",
]
@@ -18617,7 +18608,6 @@ dependencies = [
"serde",
"settings",
"telemetry",
- "theme",
"ui",
"util",
"vim_mode_setting",
@@ -19632,7 +19622,7 @@ version = "0.1.0"
dependencies = [
"any_vec",
"anyhow",
- "async-recursion 1.1.1",
+ "async-recursion",
"bincode",
"call",
"client",
@@ -19766,7 +19756,7 @@ dependencies = [
"md-5",
"memchr",
"miniz_oxide",
- "mio",
+ "mio 1.0.3",
"naga",
"nix 0.29.0",
"nom",
@@ -20157,7 +20147,7 @@ dependencies = [
"async-io",
"async-lock",
"async-process",
- "async-recursion 1.1.1",
+ "async-recursion",
"async-task",
"async-trait",
"blocking",
@@ -20210,7 +20200,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.198.0"
+version = "0.199.0"
dependencies = [
"activity_indicator",
"agent",
@@ -20314,6 +20304,7 @@ dependencies = [
"serde_json",
"session",
"settings",
+ "settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
"smol",
@@ -20590,6 +20581,7 @@ dependencies = [
"call",
"client",
"clock",
+ "cloud_api_types",
"cloud_llm_client",
"collections",
"command_palette_hooks",
@@ -20610,7 +20602,6 @@ dependencies = [
"menu",
"postage",
"project",
- "proto",
"regex",
"release_channel",
"reqwest_client",
@@ -20635,6 +20626,42 @@ dependencies = [
"zlog",
]
+[[package]]
+name = "zeta_cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "client",
+ "debug_adapter_extension",
+ "extension",
+ "fs",
+ "futures 0.3.31",
+ "gpui",
+ "gpui_tokio",
+ "language",
+ "language_extension",
+ "language_model",
+ "language_models",
+ "languages",
+ "node_runtime",
+ "paths",
+ "project",
+ "prompt_store",
+ "release_channel",
+ "reqwest_client",
+ "serde",
+ "serde_json",
+ "settings",
+ "shellexpand 2.1.2",
+ "smol",
+ "terminal_view",
+ "util",
+ "watch",
+ "workspace-hack",
+ "zeta",
+]
+
[[package]]
name = "zip"
version = "0.6.6"
diff --git a/Cargo.toml b/Cargo.toml
index a6428d897b..5b97596d0c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,13 +1,13 @@
[workspace]
resolver = "2"
members = [
- "crates/activity_indicator",
"crates/acp_thread",
- "crates/agent_ui",
+ "crates/activity_indicator",
"crates/agent",
- "crates/agent_settings",
- "crates/ai_onboarding",
"crates/agent_servers",
+ "crates/agent_settings",
+ "crates/agent_ui",
+ "crates/ai_onboarding",
"crates/anthropic",
"crates/askpass",
"crates/assets",
@@ -29,6 +29,8 @@ members = [
"crates/cli",
"crates/client",
"crates/clock",
+ "crates/cloud_api_client",
+ "crates/cloud_api_types",
"crates/cloud_llm_client",
"crates/collab",
"crates/collab_ui",
@@ -49,8 +51,8 @@ members = [
"crates/diagnostics",
"crates/docs_preprocessor",
"crates/editor",
- "crates/explorer_command_injector",
"crates/eval",
+ "crates/explorer_command_injector",
"crates/extension",
"crates/extension_api",
"crates/extension_cli",
@@ -99,7 +101,6 @@ members = [
"crates/markdown_preview",
"crates/media",
"crates/menu",
- "crates/svg_preview",
"crates/migrator",
"crates/mistral",
"crates/multi_buffer",
@@ -140,6 +141,7 @@ members = [
"crates/semantic_version",
"crates/session",
"crates/settings",
+ "crates/settings_profile_selector",
"crates/settings_ui",
"crates/snippet",
"crates/snippet_provider",
@@ -152,6 +154,7 @@ members = [
"crates/sum_tree",
"crates/supermaven",
"crates/supermaven_api",
+ "crates/svg_preview",
"crates/tab_switcher",
"crates/task",
"crates/tasks_ui",
@@ -186,6 +189,7 @@ members = [
"crates/zed",
"crates/zed_actions",
"crates/zeta",
+ "crates/zeta_cli",
"crates/zlog",
"crates/zlog_settings",
@@ -251,6 +255,8 @@ channel = { path = "crates/channel" }
cli = { path = "crates/cli" }
client = { path = "crates/client" }
clock = { path = "crates/clock" }
+cloud_api_client = { path = "crates/cloud_api_client" }
+cloud_api_types = { path = "crates/cloud_api_types" }
cloud_llm_client = { path = "crates/cloud_llm_client" }
collab = { path = "crates/collab" }
collab_ui = { path = "crates/collab_ui" }
@@ -338,6 +344,7 @@ picker = { path = "crates/picker" }
plugin = { path = "crates/plugin" }
plugin_macros = { path = "crates/plugin_macros" }
prettier = { path = "crates/prettier" }
+settings_profile_selector = { path = "crates/settings_profile_selector" }
project = { path = "crates/project" }
project_panel = { path = "crates/project_panel" }
project_symbols = { path = "crates/project_symbols" }
@@ -672,14 +679,16 @@ features = [
"UI_ViewManagement",
"Wdk_System_SystemServices",
"Win32_Globalization",
- "Win32_Graphics_Direct2D",
- "Win32_Graphics_Direct2D_Common",
+ "Win32_Graphics_Direct3D",
+ "Win32_Graphics_Direct3D11",
+ "Win32_Graphics_Direct3D_Fxc",
+ "Win32_Graphics_DirectComposition",
"Win32_Graphics_DirectWrite",
"Win32_Graphics_Dwm",
+ "Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_Graphics_Imaging",
- "Win32_Graphics_Imaging_D2D",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Credentials",
diff --git a/assets/icons/editor_atom.svg b/assets/icons/editor_atom.svg
new file mode 100644
index 0000000000..cc5fa83843
--- /dev/null
+++ b/assets/icons/editor_atom.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/editor_cursor.svg b/assets/icons/editor_cursor.svg
new file mode 100644
index 0000000000..338697be8a
--- /dev/null
+++ b/assets/icons/editor_cursor.svg
@@ -0,0 +1,9 @@
+
diff --git a/assets/icons/editor_emacs.svg b/assets/icons/editor_emacs.svg
new file mode 100644
index 0000000000..951d7b2be1
--- /dev/null
+++ b/assets/icons/editor_emacs.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/editor_jet_brains.svg b/assets/icons/editor_jet_brains.svg
new file mode 100644
index 0000000000..7d9cf0c65c
--- /dev/null
+++ b/assets/icons/editor_jet_brains.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/editor_sublime.svg b/assets/icons/editor_sublime.svg
new file mode 100644
index 0000000000..95a04f6b54
--- /dev/null
+++ b/assets/icons/editor_sublime.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/editor_vs_code.svg b/assets/icons/editor_vs_code.svg
new file mode 100644
index 0000000000..2a71ad52af
--- /dev/null
+++ b/assets/icons/editor_vs_code.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/shield_check.svg b/assets/icons/shield_check.svg
new file mode 100644
index 0000000000..6e58c31468
--- /dev/null
+++ b/assets/icons/shield_check.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 9d5c6b2043..ef5354e82d 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -598,6 +598,7 @@
"ctrl-shift-t": "pane::ReopenClosedItem",
"ctrl-k ctrl-s": "zed::OpenKeymapEditor",
"ctrl-k ctrl-t": "theme_selector::Toggle",
+ "ctrl-alt-super-p": "settings_profile_selector::Toggle",
"ctrl-t": "project_symbols::Toggle",
"ctrl-p": "file_finder::Toggle",
"ctrl-tab": "tab_switcher::Toggle",
@@ -1167,5 +1168,14 @@
"up": "menu::SelectPrevious",
"down": "menu::SelectNext"
}
+ },
+ {
+ "context": "Onboarding",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-1": "onboarding::ActivateBasicsPage",
+ "ctrl-2": "onboarding::ActivateEditingPage",
+ "ctrl-3": "onboarding::ActivateAISetupPage"
+ }
}
]
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 4c44906d55..3287e50acb 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -665,6 +665,7 @@
"cmd-shift-t": "pane::ReopenClosedItem",
"cmd-k cmd-s": "zed::OpenKeymapEditor",
"cmd-k cmd-t": "theme_selector::Toggle",
+ "ctrl-alt-cmd-p": "settings_profile_selector::Toggle",
"cmd-t": "project_symbols::Toggle",
"cmd-p": "file_finder::Toggle",
"ctrl-tab": "tab_switcher::Toggle",
@@ -1269,5 +1270,14 @@
"up": "menu::SelectPrevious",
"down": "menu::SelectNext"
}
+ },
+ {
+ "context": "Onboarding",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-1": "onboarding::ActivateBasicsPage",
+ "cmd-2": "onboarding::ActivateEditingPage",
+ "cmd-3": "onboarding::ActivateAISetupPage"
+ }
}
]
diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json
index f81f363ae0..9bc1f24bfb 100644
--- a/assets/keymaps/linux/jetbrains.json
+++ b/assets/keymaps/linux/jetbrains.json
@@ -95,7 +95,7 @@
"ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
"alt-shift-f10": "task::Spawn",
"ctrl-e": "file_finder::Toggle",
- "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
+ // "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"ctrl-shift-n": "file_finder::Toggle",
"ctrl-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json
index 5795d2ac7e..b1cd51a338 100644
--- a/assets/keymaps/macos/jetbrains.json
+++ b/assets/keymaps/macos/jetbrains.json
@@ -97,7 +97,7 @@
"cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }],
"ctrl-alt-r": "task::Spawn",
"cmd-e": "file_finder::Toggle",
- "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
+ // "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor
"cmd-shift-o": "file_finder::Toggle",
"cmd-shift-a": "command_palette::Toggle",
"shift shift": "command_palette::Toggle",
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 3a7a48efc2..4734b5d118 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -1877,5 +1877,25 @@
"save_breakpoints": true,
"dock": "bottom",
"button": true
- }
+ },
+ // Configures any number of settings profiles that are temporarily applied on
+ // top of your existing user settings when selected from
+ // `settings profile selector: toggle`.
+ // Examples:
+ // "profiles": {
+ // "Presenting": {
+ // "agent_font_size": 20.0,
+ // "buffer_font_size": 20.0,
+ // "theme": "One Light",
+ // "ui_font_size": 20.0
+ // },
+ // "Python (ty)": {
+ // "languages": {
+ // "Python": {
+ // "language_servers": ["ty"]
+ // }
+ // }
+ // }
+ // }
+ "profiles": []
}
diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml
index 011f26f364..cd7a5c3808 100644
--- a/crates/acp_thread/Cargo.toml
+++ b/crates/acp_thread/Cargo.toml
@@ -41,7 +41,9 @@ async-pipe.workspace = true
env_logger.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
+parking_lot.workspace = true
project = { workspace = true, "features" = ["test-support"] }
+rand.workspace = true
tempfile.workspace = true
util.workspace = true
settings.workspace = true
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index 7203580410..0996dee723 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -580,6 +580,9 @@ pub struct AcpThread {
pub enum AcpThreadEvent {
NewEntry,
EntryUpdated(usize),
+ ToolAuthorizationRequired,
+ Stopped,
+ Error,
}
impl EventEmitter for AcpThread {}
@@ -668,7 +671,18 @@ impl AcpThread {
for entry in self.entries.iter().rev() {
match entry {
AgentThreadEntry::UserMessage(_) => return false,
- AgentThreadEntry::ToolCall(call) if call.diffs().next().is_some() => return true,
+ AgentThreadEntry::ToolCall(
+ call @ ToolCall {
+ status:
+ ToolCallStatus::Allowed {
+ status:
+ acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending,
+ },
+ ..
+ },
+ ) if call.diffs().next().is_some() => {
+ return true;
+ }
AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
}
}
@@ -676,6 +690,18 @@ impl AcpThread {
false
}
+ pub fn used_tools_since_last_user_message(&self) -> bool {
+ for entry in self.entries.iter().rev() {
+ match entry {
+ AgentThreadEntry::UserMessage(..) => return false,
+ AgentThreadEntry::AssistantMessage(..) => continue,
+ AgentThreadEntry::ToolCall(..) => return true,
+ }
+ }
+
+ false
+ }
+
pub fn handle_session_update(
&mut self,
update: acp::SessionUpdate,
@@ -879,6 +905,7 @@ impl AcpThread {
};
self.upsert_tool_call_inner(tool_call, status, cx);
+ cx.emit(AcpThreadEvent::ToolAuthorizationRequired);
rx
}
@@ -1018,12 +1045,18 @@ impl AcpThread {
.log_err();
}));
- async move {
- match rx.await {
- Ok(Err(e)) => Err(e)?,
- _ => Ok(()),
+ cx.spawn(async move |this, cx| match rx.await {
+ Ok(Err(e)) => {
+ this.update(cx, |_, cx| cx.emit(AcpThreadEvent::Error))
+ .log_err();
+ Err(e)?
}
- }
+ _ => {
+ this.update(cx, |_, cx| cx.emit(AcpThreadEvent::Stopped))
+ .log_err();
+ Ok(())
+ }
+ })
.boxed()
}
@@ -1209,10 +1242,15 @@ mod tests {
use agentic_coding_protocol as acp_old;
use anyhow::anyhow;
use async_pipe::{PipeReader, PipeWriter};
- use futures::{channel::mpsc, future::LocalBoxFuture, select};
- use gpui::{AsyncApp, TestAppContext};
+ use futures::{
+ channel::mpsc,
+ future::{LocalBoxFuture, try_join_all},
+ select,
+ };
+ use gpui::{AsyncApp, TestAppContext, WeakEntity};
use indoc::indoc;
use project::FakeFs;
+ use rand::Rng as _;
use serde_json::json;
use settings::SettingsStore;
use smol::{future::BoxedLocal, stream::StreamExt as _};
@@ -1540,6 +1578,42 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(path!("/test"), json!({})).await;
+ let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
+
+ let connection = Rc::new(StubAgentConnection::new(vec![
+ acp::SessionUpdate::ToolCall(acp::ToolCall {
+ id: acp::ToolCallId("test".into()),
+ label: "Label".into(),
+ kind: acp::ToolKind::Edit,
+ status: acp::ToolCallStatus::Completed,
+ content: vec![acp::ToolCallContent::Diff {
+ diff: acp::Diff {
+ path: "/test/test.txt".into(),
+ old_text: None,
+ new_text: "foo".into(),
+ },
+ }],
+ locations: vec![],
+ raw_input: None,
+ }),
+ ]));
+
+ let thread = connection
+ .new_thread(project, Path::new(path!("/test")), &mut cx.to_async())
+ .await
+ .unwrap();
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Hi".into()], cx)))
+ .await
+ .unwrap();
+
+ assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls()));
+ }
+
async fn run_until_first_tool_call(
thread: &Entity,
cx: &mut TestAppContext,
@@ -1567,6 +1641,96 @@ mod tests {
}
}
+ #[derive(Clone, Default)]
+ struct StubAgentConnection {
+ sessions: Arc>>>,
+ permission_requests: HashMap>,
+ updates: Vec,
+ }
+
+ impl StubAgentConnection {
+ fn new(updates: Vec) -> Self {
+ Self {
+ updates,
+ permission_requests: HashMap::default(),
+ sessions: Arc::default(),
+ }
+ }
+ }
+
+ impl AgentConnection for StubAgentConnection {
+ fn name(&self) -> &'static str {
+ "StubAgentConnection"
+ }
+
+ fn new_thread(
+ self: Rc,
+ project: Entity,
+ _cwd: &Path,
+ cx: &mut gpui::AsyncApp,
+ ) -> Task>> {
+ let session_id = acp::SessionId(
+ rand::thread_rng()
+ .sample_iter(&rand::distributions::Alphanumeric)
+ .take(7)
+ .map(char::from)
+ .collect::()
+ .into(),
+ );
+ let thread = cx
+ .new(|cx| AcpThread::new(self.clone(), project, session_id.clone(), cx))
+ .unwrap();
+ self.sessions.lock().insert(session_id, thread.downgrade());
+ Task::ready(Ok(thread))
+ }
+
+ fn authenticate(&self, _cx: &mut App) -> Task> {
+ unimplemented!()
+ }
+
+ fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task> {
+ let sessions = self.sessions.lock();
+ let thread = sessions.get(¶ms.session_id).unwrap();
+ let mut tasks = vec![];
+ for update in &self.updates {
+ let thread = thread.clone();
+ let update = update.clone();
+ let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update
+ && let Some(options) = self.permission_requests.get(&tool_call.id)
+ {
+ Some((tool_call.clone(), options.clone()))
+ } else {
+ None
+ };
+ let task = cx.spawn(async move |cx| {
+ if let Some((tool_call, options)) = permission_request {
+ let permission = thread.update(cx, |thread, cx| {
+ thread.request_tool_call_permission(
+ tool_call.clone(),
+ options.clone(),
+ cx,
+ )
+ })?;
+ permission.await?;
+ }
+ thread.update(cx, |thread, cx| {
+ thread.handle_session_update(update.clone(), cx).unwrap();
+ })?;
+ anyhow::Ok(())
+ });
+ tasks.push(task);
+ }
+ cx.spawn(async move |_| {
+ try_join_all(tasks).await?;
+ Ok(())
+ })
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+ }
+
pub fn fake_acp_thread(
project: Entity,
cx: &mut TestAppContext,
diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml
index c89a7f3303..7bc0e82cad 100644
--- a/crates/agent/Cargo.toml
+++ b/crates/agent/Cargo.toml
@@ -47,7 +47,6 @@ paths.workspace = true
postage.workspace = true
project.workspace = true
prompt_store.workspace = true
-proto.workspace = true
ref-cast.workspace = true
rope.workspace = true
schemars.workspace = true
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index 0e5da2d43b..8558dd528d 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -13,7 +13,7 @@ use anyhow::{Result, anyhow};
use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet};
use chrono::{DateTime, Utc};
use client::{ModelRequestUsage, RequestUsage};
-use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
+use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit};
use collections::HashMap;
use feature_flags::{self, FeatureFlagAppExt};
use futures::{FutureExt, StreamExt as _, future::Shared};
@@ -37,7 +37,6 @@ use project::{
git_store::{GitStore, GitStoreCheckpoint, RepositoryState},
};
use prompt_store::{ModelContext, PromptBuilder};
-use proto::Plan;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -3255,8 +3254,10 @@ impl Thread {
}
fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) {
- self.project.update(cx, |project, cx| {
- project.user_store().update(cx, |user_store, cx| {
+ self.project
+ .read(cx)
+ .user_store()
+ .update(cx, |user_store, cx| {
user_store.update_model_request_usage(
ModelRequestUsage(RequestUsage {
amount: amount as i32,
@@ -3264,8 +3265,7 @@ impl Thread {
}),
cx,
)
- })
- });
+ });
}
pub fn deny_tool_use(
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index e46e1ae3ab..e058284abc 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -1,5 +1,7 @@
use acp_thread::{AgentConnection, Plan};
use agent_servers::AgentServer;
+use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
+use audio::{Audio, Sound};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
@@ -18,10 +20,10 @@ use editor::{
use file_icons::FileIcons;
use gpui::{
Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId,
- FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement,
- Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
- Window, div, linear_color_stop, linear_gradient, list, percentage, point, prelude::*,
- pulsating_between,
+ FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, PlatformDisplay, SharedString,
+ StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation,
+ UnderlineStyle, WeakEntity, Window, WindowHandle, div, linear_color_stop, linear_gradient,
+ list, percentage, point, prelude::*, pulsating_between,
};
use language::language_settings::SoftWrap;
use language::{Buffer, Language};
@@ -29,7 +31,7 @@ use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::Project;
use settings::Settings as _;
-use text::Anchor;
+use text::{Anchor, BufferSnapshot};
use theme::ThemeSettings;
use ui::{Disclosure, Divider, DividerColor, KeyBinding, Tooltip, prelude::*};
use util::ResultExt;
@@ -45,7 +47,10 @@ use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSe
use crate::acp::message_history::MessageHistory;
use crate::agent_diff::AgentDiff;
use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES};
-use crate::{AgentDiffPane, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, RejectAll};
+use crate::ui::{AgentNotification, AgentNotificationEvent};
+use crate::{
+ AgentDiffPane, AgentPanel, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, RejectAll,
+};
const RESPONSE_PADDING_X: Pixels = px(19.);
@@ -56,9 +61,11 @@ pub struct AcpThreadView {
thread_state: ThreadState,
diff_editors: HashMap>,
message_editor: Entity,
- message_set_from_history: bool,
+ message_set_from_history: Option,
_message_editor_subscription: Subscription,
mention_set: Arc>,
+ notifications: Vec>,
+ notification_subscriptions: HashMap, Vec>,
last_error: Option>,
list_state: ListState,
auth_task: Option>,
@@ -137,14 +144,28 @@ impl AcpThreadView {
editor
});
- let message_editor_subscription = cx.subscribe(&message_editor, |this, _, event, _| {
- if let editor::EditorEvent::BufferEdited = &event {
- if !this.message_set_from_history {
- this.message_history.borrow_mut().reset_position();
+ let message_editor_subscription =
+ cx.subscribe(&message_editor, |this, editor, event, cx| {
+ if let editor::EditorEvent::BufferEdited = &event {
+ let buffer = editor
+ .read(cx)
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .unwrap()
+ .read(cx)
+ .snapshot();
+ if let Some(message) = this.message_set_from_history.clone()
+ && message.version() != buffer.version()
+ {
+ this.message_set_from_history = None;
+ }
+
+ if this.message_set_from_history.is_none() {
+ this.message_history.borrow_mut().reset_position();
+ }
}
- this.message_set_from_history = false;
- }
- });
+ });
let mention_set = mention_set.clone();
@@ -171,9 +192,11 @@ impl AcpThreadView {
project: project.clone(),
thread_state: Self::initial_state(agent, workspace, project, window, cx),
message_editor,
- message_set_from_history: false,
+ message_set_from_history: None,
_message_editor_subscription: message_editor_subscription,
mention_set,
+ notifications: Vec::new(),
+ notification_subscriptions: HashMap::default(),
diff_editors: Default::default(),
list_state: list_state,
last_error: None,
@@ -381,7 +404,9 @@ impl AcpThreadView {
return;
}
- let Some(thread) = self.thread() else { return };
+ let Some(thread) = self.thread() else {
+ return;
+ };
let task = thread.update(cx, |thread, cx| thread.send(chunks.clone(), cx));
cx.spawn(async move |this, cx| {
@@ -413,11 +438,21 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context,
) {
+ if self.message_set_from_history.is_none() && !self.message_editor.read(cx).is_empty(cx) {
+ self.message_editor.update(cx, |editor, cx| {
+ editor.move_up(&Default::default(), window, cx);
+ });
+ return;
+ }
+
self.message_set_from_history = Self::set_draft_message(
self.message_editor.clone(),
self.mention_set.clone(),
self.project.clone(),
- self.message_history.borrow_mut().prev(),
+ self.message_history
+ .borrow_mut()
+ .prev()
+ .map(|blocks| blocks.as_slice()),
window,
cx,
);
@@ -429,14 +464,35 @@ impl AcpThreadView {
window: &mut Window,
cx: &mut Context,
) {
- self.message_set_from_history = Self::set_draft_message(
+ if self.message_set_from_history.is_none() {
+ self.message_editor.update(cx, |editor, cx| {
+ editor.move_down(&Default::default(), window, cx);
+ });
+ return;
+ }
+
+ let mut message_history = self.message_history.borrow_mut();
+ let next_history = message_history.next();
+
+ let set_draft_message = Self::set_draft_message(
self.message_editor.clone(),
self.mention_set.clone(),
self.project.clone(),
- self.message_history.borrow_mut().next(),
+ Some(
+ next_history
+ .map(|blocks| blocks.as_slice())
+ .unwrap_or_else(|| &[]),
+ ),
window,
cx,
);
+ // If we reset the text to an empty string because we ran out of history,
+ // we don't want to mark it as coming from the history
+ self.message_set_from_history = if next_history.is_some() {
+ set_draft_message
+ } else {
+ None
+ };
}
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context) {
@@ -470,15 +526,13 @@ impl AcpThreadView {
message_editor: Entity,
mention_set: Arc>,
project: Entity,
- message: Option<&Vec>,
+ message: Option<&[acp::ContentBlock]>,
window: &mut Window,
cx: &mut Context,
- ) -> bool {
+ ) -> Option {
cx.notify();
- let Some(message) = message else {
- return false;
- };
+ let message = message?;
let mut text = String::new();
let mut mentions = Vec::new();
@@ -542,7 +596,8 @@ impl AcpThreadView {
}
}
- true
+ let snapshot = snapshot.as_singleton().unwrap().2.clone();
+ Some(snapshot.text)
}
fn handle_thread_event(
@@ -564,6 +619,30 @@ impl AcpThreadView {
self.sync_thread_entry_view(index, window, cx);
self.list_state.splice(index..index + 1, 1);
}
+ AcpThreadEvent::ToolAuthorizationRequired => {
+ self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
+ }
+ AcpThreadEvent::Stopped => {
+ let used_tools = thread.read(cx).used_tools_since_last_user_message();
+ self.notify_with_sound(
+ if used_tools {
+ "Finished running tools"
+ } else {
+ "New message"
+ },
+ IconName::ZedAssistant,
+ window,
+ cx,
+ );
+ }
+ AcpThreadEvent::Error => {
+ self.notify_with_sound(
+ "Agent stopped due to an error",
+ IconName::Warning,
+ window,
+ cx,
+ );
+ }
}
cx.notify();
}
@@ -2160,6 +2239,154 @@ impl AcpThreadView {
self.list_state.scroll_to(ListOffset::default());
cx.notify();
}
+
+ fn notify_with_sound(
+ &mut self,
+ caption: impl Into,
+ icon: IconName,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.play_notification_sound(window, cx);
+ self.show_notification(caption, icon, window, cx);
+ }
+
+ fn play_notification_sound(&self, window: &Window, cx: &mut App) {
+ let settings = AgentSettings::get_global(cx);
+ if settings.play_sound_when_agent_done && !window.is_window_active() {
+ Audio::play_sound(Sound::AgentDone, cx);
+ }
+ }
+
+ fn show_notification(
+ &mut self,
+ caption: impl Into,
+ icon: IconName,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if window.is_window_active() || !self.notifications.is_empty() {
+ return;
+ }
+
+ let title = self.title(cx);
+
+ match AgentSettings::get_global(cx).notify_when_agent_waiting {
+ NotifyWhenAgentWaiting::PrimaryScreen => {
+ if let Some(primary) = cx.primary_display() {
+ self.pop_up(icon, caption.into(), title, window, primary, cx);
+ }
+ }
+ NotifyWhenAgentWaiting::AllScreens => {
+ let caption = caption.into();
+ for screen in cx.displays() {
+ self.pop_up(icon, caption.clone(), title.clone(), window, screen, cx);
+ }
+ }
+ NotifyWhenAgentWaiting::Never => {
+ // Don't show anything
+ }
+ }
+ }
+
+ fn pop_up(
+ &mut self,
+ icon: IconName,
+ caption: SharedString,
+ title: SharedString,
+ window: &mut Window,
+ screen: Rc,
+ cx: &mut Context,
+ ) {
+ let options = AgentNotification::window_options(screen, cx);
+
+ let project_name = self.workspace.upgrade().and_then(|workspace| {
+ workspace
+ .read(cx)
+ .project()
+ .read(cx)
+ .visible_worktrees(cx)
+ .next()
+ .map(|worktree| worktree.read(cx).root_name().to_string())
+ });
+
+ if let Some(screen_window) = cx
+ .open_window(options, |_, cx| {
+ cx.new(|_| {
+ AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
+ })
+ })
+ .log_err()
+ {
+ if let Some(pop_up) = screen_window.entity(cx).log_err() {
+ self.notification_subscriptions
+ .entry(screen_window)
+ .or_insert_with(Vec::new)
+ .push(cx.subscribe_in(&pop_up, window, {
+ |this, _, event, window, cx| match event {
+ AgentNotificationEvent::Accepted => {
+ let handle = window.window_handle();
+ cx.activate(true);
+
+ let workspace_handle = this.workspace.clone();
+
+ // If there are multiple Zed windows, activate the correct one.
+ cx.defer(move |cx| {
+ handle
+ .update(cx, |_view, window, _cx| {
+ window.activate_window();
+
+ if let Some(workspace) = workspace_handle.upgrade() {
+ workspace.update(_cx, |workspace, cx| {
+ workspace.focus_panel::(window, cx);
+ });
+ }
+ })
+ .log_err();
+ });
+
+ this.dismiss_notifications(cx);
+ }
+ AgentNotificationEvent::Dismissed => {
+ this.dismiss_notifications(cx);
+ }
+ }
+ }));
+
+ self.notifications.push(screen_window);
+
+ // If the user manually refocuses the original window, dismiss the popup.
+ self.notification_subscriptions
+ .entry(screen_window)
+ .or_insert_with(Vec::new)
+ .push({
+ let pop_up_weak = pop_up.downgrade();
+
+ cx.observe_window_activation(window, move |_, window, cx| {
+ if window.is_window_active() {
+ if let Some(pop_up) = pop_up_weak.upgrade() {
+ pop_up.update(cx, |_, cx| {
+ cx.emit(AgentNotificationEvent::Dismissed);
+ });
+ }
+ }
+ })
+ });
+ }
+ }
+ }
+
+ fn dismiss_notifications(&mut self, cx: &mut Context) {
+ for window in self.notifications.drain(..) {
+ window
+ .update(cx, |_, window, _| {
+ window.remove_window();
+ })
+ .ok();
+
+ self.notification_subscriptions.remove(&window);
+ }
+ }
}
impl Focusable for AcpThreadView {
@@ -2441,3 +2668,331 @@ fn plan_label_markdown_style(
..default_md_style
}
}
+
+#[cfg(test)]
+mod tests {
+ use agent_client_protocol::SessionId;
+ use editor::EditorSettings;
+ use fs::FakeFs;
+ use futures::future::try_join_all;
+ use gpui::{SemanticVersion, TestAppContext, VisualTestContext};
+ use rand::Rng;
+ use settings::SettingsStore;
+
+ use super::*;
+
+ #[gpui::test]
+ async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let (thread_view, cx) = setup_thread_view(StubAgentServer::default(), cx).await;
+
+ let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Hello", window, cx);
+ });
+
+ cx.deactivate_window();
+
+ thread_view.update_in(cx, |thread_view, window, cx| {
+ thread_view.chat(&Chat, window, cx);
+ });
+
+ cx.run_until_parked();
+
+ assert!(
+ cx.windows()
+ .iter()
+ .any(|window| window.downcast::().is_some())
+ );
+ }
+
+ #[gpui::test]
+ async fn test_notification_for_error(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let (thread_view, cx) =
+ setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await;
+
+ let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Hello", window, cx);
+ });
+
+ cx.deactivate_window();
+
+ thread_view.update_in(cx, |thread_view, window, cx| {
+ thread_view.chat(&Chat, window, cx);
+ });
+
+ cx.run_until_parked();
+
+ assert!(
+ cx.windows()
+ .iter()
+ .any(|window| window.downcast::().is_some())
+ );
+ }
+
+ #[gpui::test]
+ async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let tool_call_id = acp::ToolCallId("1".into());
+ let tool_call = acp::ToolCall {
+ id: tool_call_id.clone(),
+ label: "Label".into(),
+ kind: acp::ToolKind::Edit,
+ status: acp::ToolCallStatus::Pending,
+ content: vec!["hi".into()],
+ locations: vec![],
+ raw_input: None,
+ };
+ let connection = StubAgentConnection::new(vec![acp::SessionUpdate::ToolCall(tool_call)])
+ .with_permission_requests(HashMap::from_iter([(
+ tool_call_id,
+ vec![acp::PermissionOption {
+ id: acp::PermissionOptionId("1".into()),
+ label: "Allow".into(),
+ kind: acp::PermissionOptionKind::AllowOnce,
+ }],
+ )]));
+ let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
+
+ let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
+ message_editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("Hello", window, cx);
+ });
+
+ cx.deactivate_window();
+
+ thread_view.update_in(cx, |thread_view, window, cx| {
+ thread_view.chat(&Chat, window, cx);
+ });
+
+ cx.run_until_parked();
+
+ assert!(
+ cx.windows()
+ .iter()
+ .any(|window| window.downcast::().is_some())
+ );
+ }
+
+ async fn setup_thread_view(
+ agent: impl AgentServer + 'static,
+ cx: &mut TestAppContext,
+ ) -> (Entity, &mut VisualTestContext) {
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let thread_view = cx.update(|window, cx| {
+ cx.new(|cx| {
+ AcpThreadView::new(
+ Rc::new(agent),
+ workspace.downgrade(),
+ project,
+ Rc::new(RefCell::new(MessageHistory::default())),
+ 1,
+ None,
+ window,
+ cx,
+ )
+ })
+ });
+ cx.run_until_parked();
+ (thread_view, cx)
+ }
+
+ struct StubAgentServer {
+ connection: C,
+ }
+
+ impl StubAgentServer {
+ fn new(connection: C) -> Self {
+ Self { connection }
+ }
+ }
+
+ impl StubAgentServer {
+ fn default() -> Self {
+ Self::new(StubAgentConnection::default())
+ }
+ }
+
+ impl AgentServer for StubAgentServer
+ where
+ C: 'static + AgentConnection + Send + Clone,
+ {
+ fn logo(&self) -> ui::IconName {
+ unimplemented!()
+ }
+
+ fn name(&self) -> &'static str {
+ unimplemented!()
+ }
+
+ fn empty_state_headline(&self) -> &'static str {
+ unimplemented!()
+ }
+
+ fn empty_state_message(&self) -> &'static str {
+ unimplemented!()
+ }
+
+ fn connect(
+ &self,
+ _root_dir: &Path,
+ _project: &Entity,
+ _cx: &mut App,
+ ) -> Task>> {
+ Task::ready(Ok(Rc::new(self.connection.clone())))
+ }
+ }
+
+ #[derive(Clone, Default)]
+ struct StubAgentConnection {
+ sessions: Arc>>>,
+ permission_requests: HashMap>,
+ updates: Vec,
+ }
+
+ impl StubAgentConnection {
+ fn new(updates: Vec) -> Self {
+ Self {
+ updates,
+ permission_requests: HashMap::default(),
+ sessions: Arc::default(),
+ }
+ }
+
+ fn with_permission_requests(
+ mut self,
+ permission_requests: HashMap>,
+ ) -> Self {
+ self.permission_requests = permission_requests;
+ self
+ }
+ }
+
+ impl AgentConnection for StubAgentConnection {
+ fn name(&self) -> &'static str {
+ "StubAgentConnection"
+ }
+
+ fn new_thread(
+ self: Rc,
+ project: Entity,
+ _cwd: &Path,
+ cx: &mut gpui::AsyncApp,
+ ) -> Task>> {
+ let session_id = SessionId(
+ rand::thread_rng()
+ .sample_iter(&rand::distributions::Alphanumeric)
+ .take(7)
+ .map(char::from)
+ .collect::()
+ .into(),
+ );
+ let thread = cx
+ .new(|cx| AcpThread::new(self.clone(), project, session_id.clone(), cx))
+ .unwrap();
+ self.sessions.lock().insert(session_id, thread.downgrade());
+ Task::ready(Ok(thread))
+ }
+
+ fn authenticate(&self, _cx: &mut App) -> Task> {
+ unimplemented!()
+ }
+
+ fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task> {
+ let sessions = self.sessions.lock();
+ let thread = sessions.get(¶ms.session_id).unwrap();
+ let mut tasks = vec![];
+ for update in &self.updates {
+ let thread = thread.clone();
+ let update = update.clone();
+ let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update
+ && let Some(options) = self.permission_requests.get(&tool_call.id)
+ {
+ Some((tool_call.clone(), options.clone()))
+ } else {
+ None
+ };
+ let task = cx.spawn(async move |cx| {
+ if let Some((tool_call, options)) = permission_request {
+ let permission = thread.update(cx, |thread, cx| {
+ thread.request_tool_call_permission(
+ tool_call.clone(),
+ options.clone(),
+ cx,
+ )
+ })?;
+ permission.await?;
+ }
+ thread.update(cx, |thread, cx| {
+ thread.handle_session_update(update.clone(), cx).unwrap();
+ })?;
+ anyhow::Ok(())
+ });
+ tasks.push(task);
+ }
+ cx.spawn(async move |_| {
+ try_join_all(tasks).await?;
+ Ok(())
+ })
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+ }
+
+ #[derive(Clone)]
+ struct SaboteurAgentConnection;
+
+ impl AgentConnection for SaboteurAgentConnection {
+ fn name(&self) -> &'static str {
+ "SaboteurAgentConnection"
+ }
+
+ fn new_thread(
+ self: Rc,
+ project: Entity,
+ _cwd: &Path,
+ cx: &mut gpui::AsyncApp,
+ ) -> Task>> {
+ Task::ready(Ok(cx
+ .new(|cx| AcpThread::new(self, project, SessionId("test".into()), cx))
+ .unwrap()))
+ }
+
+ fn authenticate(&self, _cx: &mut App) -> Task> {
+ unimplemented!()
+ }
+
+ fn prompt(&self, _params: acp::PromptArguments, _cx: &mut App) -> Task> {
+ Task::ready(Err(anyhow::anyhow!("Error prompting")))
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+ }
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ language::init(cx);
+ Project::init_settings(cx);
+ AgentSettings::register(cx);
+ workspace::init_settings(cx);
+ ThemeSettings::register(cx);
+ release_channel::init(SemanticVersion::default(), cx);
+ EditorSettings::register(cx);
+ });
+ }
+}
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index fae04188eb..dad930be9e 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/crates/agent_ui/src/agent_configuration.rs
@@ -7,6 +7,7 @@ use std::{sync::Arc, time::Duration};
use agent_settings::AgentSettings;
use assistant_tool::{ToolSource, ToolWorkingSet};
+use cloud_llm_client::Plan;
use collections::HashMap;
use context_server::ContextServerId;
use extension::ExtensionManifest;
@@ -25,7 +26,6 @@ use project::{
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
-use proto::Plan;
use settings::{Settings, update_settings_file};
use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
@@ -180,7 +180,7 @@ impl AgentConfiguration {
let current_plan = if is_zed_provider {
self.workspace
.upgrade()
- .and_then(|workspace| workspace.read(cx).user_store().read(cx).current_plan())
+ .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan())
} else {
None
};
@@ -406,7 +406,9 @@ impl AgentConfiguration {
SwitchField::new(
"always-allow-tool-actions-switch",
"Allow running commands without asking for confirmation",
- "The agent can perform potentially destructive actions without asking for your confirmation.",
+ Some(
+ "The agent can perform potentially destructive actions without asking for your confirmation.".into(),
+ ),
always_allow_tool_actions,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
@@ -424,7 +426,7 @@ impl AgentConfiguration {
SwitchField::new(
"single-file-review",
"Enable single-file agent reviews",
- "Agent edits are also displayed in single-file editors for review.",
+ Some("Agent edits are also displayed in single-file editors for review.".into()),
single_file_review,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
@@ -442,7 +444,9 @@ impl AgentConfiguration {
SwitchField::new(
"sound-notification",
"Play sound when finished generating",
- "Hear a notification sound when the agent is done generating changes or needs your input.",
+ Some(
+ "Hear a notification sound when the agent is done generating changes or needs your input.".into(),
+ ),
play_sound_when_agent_done,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
@@ -460,7 +464,9 @@ impl AgentConfiguration {
SwitchField::new(
"modifier-send",
"Use modifier to submit a message",
- "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux) required to send messages.",
+ Some(
+ "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux) required to send messages.".into(),
+ ),
use_modifier_to_send,
move |state, _window, cx| {
let allow = state == &ToggleState::Selected;
@@ -502,7 +508,7 @@ impl AgentConfiguration {
.blend(cx.theme().colors().text_accent.opacity(0.2));
let (plan_name, label_color, bg_color) = match plan {
- Plan::Free => ("Free", Color::Default, free_chip_bg),
+ Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
};
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index ec0a11f86b..c4dc359093 100644
--- a/crates/agent_ui/src/agent_diff.rs
+++ b/crates/agent_ui/src/agent_diff.rs
@@ -1521,6 +1521,9 @@ impl AgentDiff {
self.update_reviewing_editors(workspace, window, cx);
}
}
+ AcpThreadEvent::Stopped
+ | AcpThreadEvent::ToolAuthorizationRequired
+ | AcpThreadEvent::Error => {}
}
}
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index e7b1943561..fcb8dfbac2 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -44,7 +44,7 @@ use assistant_context::{AssistantContext, ContextEvent, ContextSummary};
use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet;
use client::{DisableAiSettings, UserStore, zed_urls};
-use cloud_llm_client::{CompletionIntent, UsageLimit};
+use cloud_llm_client::{CompletionIntent, Plan, UsageLimit};
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use feature_flags::{self, FeatureFlagAppExt};
use fs::Fs;
@@ -60,7 +60,6 @@ use language_model::{
};
use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
-use proto::Plan;
use rules_library::{RulesLibrary, open_rules_library};
use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
@@ -579,7 +578,6 @@ impl AgentPanel {
MessageEditor::new(
fs.clone(),
workspace.clone(),
- user_store.clone(),
message_editor_context_store.clone(),
prompt_store.clone(),
thread_store.downgrade(),
@@ -848,7 +846,6 @@ impl AgentPanel {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
- self.user_store.clone(),
context_store.clone(),
self.prompt_store.clone(),
self.thread_store.downgrade(),
@@ -1122,7 +1119,6 @@ impl AgentPanel {
MessageEditor::new(
self.fs.clone(),
self.workspace.clone(),
- self.user_store.clone(),
context_store,
self.prompt_store.clone(),
self.thread_store.downgrade(),
@@ -2293,10 +2289,10 @@ impl AgentPanel {
| ActiveView::Configuration => return false,
}
- let plan = self.user_store.read(cx).current_plan();
+ let plan = self.user_store.read(cx).plan();
let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some();
- matches!(plan, Some(Plan::Free)) && has_previous_trial
+ matches!(plan, Some(Plan::ZedFree)) && has_previous_trial
}
fn should_render_onboarding(&self, cx: &mut Context) -> bool {
@@ -2911,7 +2907,7 @@ impl AgentPanel {
) -> AnyElement {
let error_message = match plan {
Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
- Plan::ZedProTrial | Plan::Free => "Upgrade to Zed Pro for more prompts.",
+ Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.",
};
let icon = Icon::new(IconName::XCircle)
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index 082d1dfb51..2185885347 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/crates/agent_ui/src/message_editor.rs
@@ -17,7 +17,6 @@ use agent::{
use agent_settings::{AgentSettings, CompletionMode};
use ai_onboarding::ApiKeysWithProviders;
use buffer_diff::BufferDiff;
-use client::UserStore;
use cloud_llm_client::CompletionIntent;
use collections::{HashMap, HashSet};
use editor::actions::{MoveUp, Paste};
@@ -43,7 +42,6 @@ use language_model::{
use multi_buffer;
use project::Project;
use prompt_store::PromptStore;
-use proto::Plan;
use settings::Settings;
use std::time::Duration;
use theme::ThemeSettings;
@@ -79,7 +77,6 @@ pub struct MessageEditor {
editor: Entity,
workspace: WeakEntity,
project: Entity,
- user_store: Entity,
context_store: Entity,
prompt_store: Option>,
history_store: Option>,
@@ -159,7 +156,6 @@ impl MessageEditor {
pub fn new(
fs: Arc,
workspace: WeakEntity,
- user_store: Entity,
context_store: Entity,
prompt_store: Option>,
thread_store: WeakEntity,
@@ -231,7 +227,6 @@ impl MessageEditor {
Self {
editor: editor.clone(),
project: thread.read(cx).project().clone(),
- user_store,
thread,
incompatible_tools_state: incompatible_tools.clone(),
workspace,
@@ -1287,24 +1282,12 @@ impl MessageEditor {
return None;
}
- let user_store = self.user_store.read(cx);
-
- let ubb_enable = user_store
- .usage_based_billing_enabled()
- .map_or(false, |enabled| enabled);
-
- if ubb_enable {
+ let user_store = self.project.read(cx).user_store().read(cx);
+ if user_store.is_usage_based_billing_enabled() {
return None;
}
- let plan = user_store
- .current_plan()
- .map(|plan| match plan {
- Plan::Free => cloud_llm_client::Plan::ZedFree,
- Plan::ZedPro => cloud_llm_client::Plan::ZedPro,
- Plan::ZedProTrial => cloud_llm_client::Plan::ZedProTrial,
- })
- .unwrap_or(cloud_llm_client::Plan::ZedFree);
+ let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree);
let usage = user_store.model_request_usage()?;
@@ -1769,7 +1752,6 @@ impl AgentPreview for MessageEditor {
) -> Option {
if let Some(workspace) = workspace.upgrade() {
let fs = workspace.read(cx).app_state().fs.clone();
- let user_store = workspace.read(cx).app_state().user_store.clone();
let project = workspace.read(cx).project().clone();
let weak_project = project.downgrade();
let context_store = cx.new(|_cx| ContextStore::new(weak_project, None));
@@ -1782,7 +1764,6 @@ impl AgentPreview for MessageEditor {
MessageEditor::new(
fs,
workspace.downgrade(),
- user_store,
context_store,
None,
thread_store.downgrade(),
diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml
index 9031e14e29..95a45b1a6f 100644
--- a/crates/ai_onboarding/Cargo.toml
+++ b/crates/ai_onboarding/Cargo.toml
@@ -16,10 +16,10 @@ default = []
[dependencies]
client.workspace = true
+cloud_llm_client.workspace = true
component.workspace = true
gpui.workspace = true
language_model.workspace = true
-proto.workspace = true
serde.workspace = true
smallvec.workspace = true
telemetry.workspace = true
diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
index e8a62f7ff2..f1629eeff8 100644
--- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
+++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs
@@ -1,6 +1,7 @@
use std::sync::Arc;
use client::{Client, UserStore};
+use cloud_llm_client::Plan;
use gpui::{Entity, IntoElement, ParentElement};
use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID};
use ui::prelude::*;
@@ -56,15 +57,8 @@ impl AgentPanelOnboarding {
impl Render for AgentPanelOnboarding {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let enrolled_in_trial = matches!(
- self.user_store.read(cx).current_plan(),
- Some(proto::Plan::ZedProTrial)
- );
-
- let is_pro_user = matches!(
- self.user_store.read(cx).current_plan(),
- Some(proto::Plan::ZedPro)
- );
+ let enrolled_in_trial = self.user_store.read(cx).plan() == Some(Plan::ZedProTrial);
+ let is_pro_user = self.user_store.read(cx).plan() == Some(Plan::ZedPro);
AgentPanelOnboardingCard::new()
.child(
diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs
index 3aec9c62cd..c252b65f20 100644
--- a/crates/ai_onboarding/src/ai_onboarding.rs
+++ b/crates/ai_onboarding/src/ai_onboarding.rs
@@ -9,6 +9,7 @@ pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProvider
pub use agent_panel_onboarding_card::AgentPanelOnboardingCard;
pub use agent_panel_onboarding_content::AgentPanelOnboarding;
pub use ai_upsell_card::AiUpsellCard;
+use cloud_llm_client::Plan;
pub use edit_prediction_onboarding_content::EditPredictionOnboarding;
pub use young_account_banner::YoungAccountBanner;
@@ -79,7 +80,7 @@ impl From for SignInStatus {
pub struct ZedAiOnboarding {
pub sign_in_status: SignInStatus,
pub has_accepted_terms_of_service: bool,
- pub plan: Option,
+ pub plan: Option,
pub account_too_young: bool,
pub continue_with_zed_ai: Arc,
pub sign_in: Arc,
@@ -99,8 +100,8 @@ impl ZedAiOnboarding {
Self {
sign_in_status: status.into(),
- has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false),
- plan: store.current_plan(),
+ has_accepted_terms_of_service: store.has_accepted_terms_of_service(),
+ plan: store.plan(),
account_too_young: store.account_too_young(),
continue_with_zed_ai,
accept_terms_of_service: Arc::new({
@@ -113,11 +114,9 @@ impl ZedAiOnboarding {
sign_in: Arc::new(move |_window, cx| {
cx.spawn({
let client = client.clone();
- async move |cx| {
- client.authenticate_and_connect(true, cx).await;
- }
+ async move |cx| client.sign_in_with_optional_connect(true, cx).await
})
- .detach();
+ .detach_and_log_err(cx);
}),
dismiss_onboarding: None,
}
@@ -411,9 +410,9 @@ impl RenderOnce for ZedAiOnboarding {
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
if self.has_accepted_terms_of_service {
match self.plan {
- None | Some(proto::Plan::Free) => self.render_free_plan_state(cx),
- Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx),
- Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx),
+ None | Some(Plan::ZedFree) => self.render_free_plan_state(cx),
+ Some(Plan::ZedProTrial) => self.render_trial_state(cx),
+ Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
}
} else {
self.render_accept_terms_of_service()
@@ -433,7 +432,7 @@ impl Component for ZedAiOnboarding {
fn onboarding(
sign_in_status: SignInStatus,
has_accepted_terms_of_service: bool,
- plan: Option,
+ plan: Option,
account_too_young: bool,
) -> AnyElement {
ZedAiOnboarding {
@@ -468,25 +467,15 @@ impl Component for ZedAiOnboarding {
),
single_example(
"Free Plan",
- onboarding(SignInStatus::SignedIn, true, Some(proto::Plan::Free), false),
+ onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedFree), false),
),
single_example(
"Pro Trial",
- onboarding(
- SignInStatus::SignedIn,
- true,
- Some(proto::Plan::ZedProTrial),
- false,
- ),
+ onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedProTrial), false),
),
single_example(
"Pro Plan",
- onboarding(
- SignInStatus::SignedIn,
- true,
- Some(proto::Plan::ZedPro),
- false,
- ),
+ onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedPro), false),
),
])
.into_any_element(),
diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs
index 041e0d87ec..2408b6aa37 100644
--- a/crates/ai_onboarding/src/ai_upsell_card.rs
+++ b/crates/ai_onboarding/src/ai_upsell_card.rs
@@ -1,6 +1,7 @@
use std::sync::Arc;
use client::{Client, zed_urls};
+use cloud_llm_client::Plan;
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use ui::{Divider, List, Vector, VectorName, prelude::*};
@@ -10,22 +11,22 @@ use crate::{BulletItem, SignInStatus};
pub struct AiUpsellCard {
pub sign_in_status: SignInStatus,
pub sign_in: Arc,
+ pub user_plan: Option,
}
impl AiUpsellCard {
- pub fn new(client: Arc) -> Self {
+ pub fn new(client: Arc, user_plan: Option) -> Self {
let status = *client.status().borrow();
Self {
+ user_plan,
sign_in_status: status.into(),
sign_in: Arc::new(move |_window, cx| {
cx.spawn({
let client = client.clone();
- async move |cx| {
- client.authenticate_and_connect(true, cx).await;
- }
+ async move |cx| client.sign_in_with_optional_connect(true, cx).await
})
- .detach();
+ .detach_and_log_err(cx);
}),
}
}
@@ -34,6 +35,7 @@ impl AiUpsellCard {
impl RenderOnce for AiUpsellCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let pro_section = v_flex()
+ .flex_grow()
.w_full()
.gap_1()
.child(
@@ -56,6 +58,7 @@ impl RenderOnce for AiUpsellCard {
);
let free_section = v_flex()
+ .flex_grow()
.w_full()
.gap_1()
.child(
@@ -71,7 +74,7 @@ impl RenderOnce for AiUpsellCard {
)
.child(
List::new()
- .child(BulletItem::new("50 prompts with the Claude models"))
+ .child(BulletItem::new("50 prompts with Claude models"))
.child(BulletItem::new("2,000 accepted edit predictions")),
);
@@ -132,22 +135,28 @@ impl RenderOnce for AiUpsellCard {
v_flex()
.relative()
- .p_6()
- .pt_4()
+ .p_4()
+ .pt_3()
.border_1()
.border_color(cx.theme().colors().border)
.rounded_lg()
.overflow_hidden()
.child(grid_bg)
.child(gradient_bg)
- .child(Headline::new("Try Zed AI"))
- .child(Label::new(DESCRIPTION).color(Color::Muted).mb_2())
+ .child(Label::new("Try Zed AI").size(LabelSize::Large))
+ .child(
+ div()
+ .max_w_3_4()
+ .mb_2()
+ .child(Label::new(DESCRIPTION).color(Color::Muted)),
+ )
.child(
h_flex()
+ .w_full()
.mt_1p5()
.mb_2p5()
.items_start()
- .gap_12()
+ .gap_6()
.child(free_section)
.child(pro_section),
)
@@ -183,6 +192,7 @@ impl Component for AiUpsellCard {
AiUpsellCard {
sign_in_status: SignInStatus::SignedOut,
sign_in: Arc::new(|_, _| {}),
+ user_plan: None,
}
.into_any_element(),
),
@@ -191,6 +201,7 @@ impl Component for AiUpsellCard {
AiUpsellCard {
sign_in_status: SignInStatus::SignedIn,
sign_in: Arc::new(|_, _| {}),
+ user_plan: None,
}
.into_any_element(),
),
diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs
index b7ba811421..4ad156b9fb 100644
--- a/crates/channel/src/channel_store.rs
+++ b/crates/channel/src/channel_store.rs
@@ -126,7 +126,7 @@ impl ChannelMembership {
proto::channel_member::Kind::Member => 0,
proto::channel_member::Kind::Invitee => 1,
},
- username_order: self.user.github_login.as_str(),
+ username_order: &self.user.github_login,
}
}
}
diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs
index f8f5de3c39..c92226eeeb 100644
--- a/crates/channel/src/channel_store_tests.rs
+++ b/crates/channel/src/channel_store_tests.rs
@@ -259,20 +259,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
assert_channels(&channel_store, &[(0, "the-channel".to_string())], cx);
});
- let get_users = server.receive::().await.unwrap();
- assert_eq!(get_users.payload.user_ids, vec![5]);
- server.respond(
- get_users.receipt(),
- proto::UsersResponse {
- users: vec![proto::User {
- id: 5,
- github_login: "nathansobo".into(),
- avatar_url: "http://avatar.com/nathansobo".into(),
- name: None,
- }],
- },
- );
-
// Join a channel and populate its existing messages.
let channel = channel_store.update(cx, |store, cx| {
let channel_id = store.ordered_channels().next().unwrap().1.id;
@@ -334,7 +320,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::>(),
&[
- ("nathansobo".into(), "a".into()),
+ ("user-5".into(), "a".into()),
("maxbrunsfeld".into(), "b".into())
]
);
@@ -437,7 +423,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
.collect::>(),
&[
- ("nathansobo".into(), "y".into()),
+ ("user-5".into(), "y".into()),
("maxbrunsfeld".into(), "z".into())
]
);
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index dd97bd9ca4..365625b445 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -17,11 +17,11 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup
[dependencies]
anyhow.workspace = true
-async-recursion = "0.3"
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] }
base64.workspace = true
chrono = { workspace = true, features = ["serde"] }
clock.workspace = true
+cloud_api_client.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
credentials_provider.workspace = true
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index 07e708f11b..e6d8f10d12 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -6,22 +6,21 @@ pub mod telemetry;
pub mod user;
pub mod zed_urls;
-use anyhow::{Context as _, Result, anyhow, bail};
-use async_recursion::async_recursion;
+use anyhow::{Context as _, Result, anyhow};
use async_tungstenite::tungstenite::{
client::IntoClientRequest,
error::Error as WebsocketError,
http::{HeaderValue, Request, StatusCode},
};
-use chrono::{DateTime, Utc};
use clock::SystemClock;
+use cloud_api_client::CloudApiClient;
use credentials_provider::CredentialsProvider;
use futures::{
AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt,
channel::oneshot, future::BoxFuture,
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
-use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http};
+use http_client::{HttpClient, HttpClientWithUrl, http};
use parking_lot::RwLock;
use postage::watch;
use proxy::connect_proxy_stream;
@@ -162,20 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) {
let client = client.clone();
move |_: &SignIn, cx| {
if let Some(client) = client.upgrade() {
- cx.spawn(
- async move |cx| match client.authenticate_and_connect(true, &cx).await {
- ConnectionResult::Timeout => {
- log::error!("Initial authentication timed out");
- }
- ConnectionResult::ConnectionReset => {
- log::error!("Initial authentication connection reset");
- }
- ConnectionResult::Result(r) => {
- r.log_err();
- }
- },
- )
- .detach();
+ cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, &cx).await)
+ .detach_and_log_err(cx);
}
}
});
@@ -213,6 +200,7 @@ pub struct Client {
id: AtomicU64,
peer: Arc,
http: Arc,
+ cloud_client: Arc,
telemetry: Arc,
credentials_provider: ClientCredentialsProvider,
state: RwLock,
@@ -283,6 +271,8 @@ pub enum Status {
SignedOut,
UpgradeRequired,
Authenticating,
+ Authenticated,
+ AuthenticationError,
Connecting,
ConnectionError,
Connected {
@@ -586,6 +576,7 @@ impl Client {
id: AtomicU64::new(0),
peer: Peer::new(0),
telemetry: Telemetry::new(clock, http.clone(), cx),
+ cloud_client: Arc::new(CloudApiClient::new(http.clone())),
http,
credentials_provider: ClientCredentialsProvider::new(cx),
state: Default::default(),
@@ -618,6 +609,10 @@ impl Client {
self.http.clone()
}
+ pub fn cloud_client(&self) -> Arc {
+ self.cloud_client.clone()
+ }
+
pub fn set_id(&self, id: u64) -> &Self {
self.id.store(id, Ordering::SeqCst);
self
@@ -704,7 +699,7 @@ impl Client {
let mut delay = INITIAL_RECONNECTION_DELAY;
loop {
- match client.authenticate_and_connect(true, &cx).await {
+ match client.connect(true, &cx).await {
ConnectionResult::Timeout => {
log::error!("client connect attempt timed out")
}
@@ -874,17 +869,123 @@ impl Client {
.is_some()
}
- #[async_recursion(?Send)]
- pub async fn authenticate_and_connect(
+ pub async fn sign_in(
+ self: &Arc,
+ try_provider: bool,
+ cx: &AsyncApp,
+ ) -> Result {
+ if self.status().borrow().is_signed_out() {
+ self.set_status(Status::Authenticating, cx);
+ } else {
+ self.set_status(Status::Reauthenticating, cx);
+ }
+
+ let mut credentials = None;
+
+ let old_credentials = self.state.read().credentials.clone();
+ if let Some(old_credentials) = old_credentials {
+ if self
+ .cloud_client
+ .validate_credentials(
+ old_credentials.user_id as u32,
+ &old_credentials.access_token,
+ )
+ .await?
+ {
+ credentials = Some(old_credentials);
+ }
+ }
+
+ if credentials.is_none() && try_provider {
+ if let Some(stored_credentials) = self.credentials_provider.read_credentials(cx).await {
+ if self
+ .cloud_client
+ .validate_credentials(
+ stored_credentials.user_id as u32,
+ &stored_credentials.access_token,
+ )
+ .await?
+ {
+ credentials = Some(stored_credentials);
+ } else {
+ self.credentials_provider
+ .delete_credentials(cx)
+ .await
+ .log_err();
+ }
+ }
+ }
+
+ if credentials.is_none() {
+ let mut status_rx = self.status();
+ let _ = status_rx.next().await;
+ futures::select_biased! {
+ authenticate = self.authenticate(cx).fuse() => {
+ match authenticate {
+ Ok(creds) => {
+ if IMPERSONATE_LOGIN.is_none() {
+ self.credentials_provider
+ .write_credentials(creds.user_id, creds.access_token.clone(), cx)
+ .await
+ .log_err();
+ }
+
+ credentials = Some(creds);
+ },
+ Err(err) => {
+ self.set_status(Status::AuthenticationError, cx);
+ return Err(err);
+ }
+ }
+ }
+ _ = status_rx.next().fuse() => {
+ return Err(anyhow!("authentication canceled"));
+ }
+ }
+ }
+
+ let credentials = credentials.unwrap();
+ self.set_id(credentials.user_id);
+ self.cloud_client
+ .set_credentials(credentials.user_id as u32, credentials.access_token.clone());
+ self.state.write().credentials = Some(credentials.clone());
+ self.set_status(Status::Authenticated, cx);
+
+ Ok(credentials)
+ }
+
+ /// Performs a sign-in and also connects to Collab.
+ ///
+ /// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls
+ /// to `sign_in` when we're ready to remove auto-connection to Collab.
+ pub async fn sign_in_with_optional_connect(
+ self: &Arc,
+ try_provider: bool,
+ cx: &AsyncApp,
+ ) -> Result<()> {
+ let credentials = self.sign_in(try_provider, cx).await?;
+
+ let connect_result = match self.connect_with_credentials(credentials, cx).await {
+ ConnectionResult::Timeout => Err(anyhow!("connection timed out")),
+ ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")),
+ ConnectionResult::Result(result) => result.context("client auth and connect"),
+ };
+ connect_result.log_err();
+
+ Ok(())
+ }
+
+ pub async fn connect(
self: &Arc,
try_provider: bool,
cx: &AsyncApp,
) -> ConnectionResult<()> {
let was_disconnected = match *self.status().borrow() {
- Status::SignedOut => true,
+ Status::SignedOut | Status::Authenticated => true,
Status::ConnectionError
| Status::ConnectionLost
| Status::Authenticating { .. }
+ | Status::AuthenticationError
| Status::Reauthenticating { .. }
| Status::ReconnectionError { .. } => false,
Status::Connected { .. } | Status::Connecting { .. } | Status::Reconnecting { .. } => {
@@ -897,39 +998,10 @@ impl Client {
);
}
};
- if was_disconnected {
- self.set_status(Status::Authenticating, cx);
- } else {
- self.set_status(Status::Reauthenticating, cx)
- }
-
- let mut read_from_provider = false;
- let mut credentials = self.state.read().credentials.clone();
- if credentials.is_none() && try_provider {
- credentials = self.credentials_provider.read_credentials(cx).await;
- read_from_provider = credentials.is_some();
- }
-
- if credentials.is_none() {
- let mut status_rx = self.status();
- let _ = status_rx.next().await;
- futures::select_biased! {
- authenticate = self.authenticate(cx).fuse() => {
- match authenticate {
- Ok(creds) => credentials = Some(creds),
- Err(err) => {
- self.set_status(Status::ConnectionError, cx);
- return ConnectionResult::Result(Err(err));
- }
- }
- }
- _ = status_rx.next().fuse() => {
- return ConnectionResult::Result(Err(anyhow!("authentication canceled")));
- }
- }
- }
- let credentials = credentials.unwrap();
- self.set_id(credentials.user_id);
+ let credentials = match self.sign_in(try_provider, cx).await {
+ Ok(credentials) => credentials,
+ Err(err) => return ConnectionResult::Result(Err(err)),
+ };
if was_disconnected {
self.set_status(Status::Connecting, cx);
@@ -937,17 +1009,20 @@ impl Client {
self.set_status(Status::Reconnecting, cx);
}
+ self.connect_with_credentials(credentials, cx).await
+ }
+
+ async fn connect_with_credentials(
+ self: &Arc,
+ credentials: Credentials,
+ cx: &AsyncApp,
+ ) -> ConnectionResult<()> {
let mut timeout =
futures::FutureExt::fuse(cx.background_executor().timer(CONNECTION_TIMEOUT));
futures::select_biased! {
connection = self.establish_connection(&credentials, cx).fuse() => {
match connection {
Ok(conn) => {
- self.state.write().credentials = Some(credentials.clone());
- if !read_from_provider && IMPERSONATE_LOGIN.is_none() {
- self.credentials_provider.write_credentials(credentials.user_id, credentials.access_token, cx).await.log_err();
- }
-
futures::select_biased! {
result = self.set_connection(conn, cx).fuse() => {
match result.context("client auth and connect") {
@@ -965,15 +1040,8 @@ impl Client {
}
}
Err(EstablishConnectionError::Unauthorized) => {
- self.state.write().credentials.take();
- if read_from_provider {
- self.credentials_provider.delete_credentials(cx).await.log_err();
- self.set_status(Status::SignedOut, cx);
- self.authenticate_and_connect(false, cx).await
- } else {
- self.set_status(Status::ConnectionError, cx);
- ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect"))
- }
+ self.set_status(Status::ConnectionError, cx);
+ ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect"))
}
Err(EstablishConnectionError::UpgradeRequired) => {
self.set_status(Status::UpgradeRequired, cx);
@@ -1368,96 +1436,31 @@ impl Client {
self: &Arc,
http: Arc,
login: String,
- mut api_token: String,
+ api_token: String,
) -> Result {
- #[derive(Deserialize)]
- struct AuthenticatedUserResponse {
- user: User,
+ #[derive(Serialize)]
+ struct ImpersonateUserBody {
+ github_login: String,
}
#[derive(Deserialize)]
- struct User {
- id: u64,
+ struct ImpersonateUserResponse {
+ user_id: u64,
+ access_token: String,
}
- let github_user = {
- #[derive(Deserialize)]
- struct GithubUser {
- id: i32,
- login: String,
- created_at: DateTime,
- }
-
- let request = {
- let mut request_builder =
- Request::get(&format!("https://api.github.com/users/{login}"));
- if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
- request_builder =
- request_builder.header("Authorization", format!("Bearer {}", github_token));
- }
-
- request_builder.body(AsyncBody::empty())?
- };
-
- let mut response = http
- .send(request)
- .await
- .context("error fetching GitHub user")?;
-
- let mut body = Vec::new();
- response
- .body_mut()
- .read_to_end(&mut body)
- .await
- .context("error reading GitHub user")?;
-
- if !response.status().is_success() {
- let text = String::from_utf8_lossy(body.as_slice());
- bail!(
- "status error {}, response: {text:?}",
- response.status().as_u16()
- );
- }
-
- serde_json::from_slice::(body.as_slice()).map_err(|err| {
- log::error!("Error deserializing: {:?}", err);
- log::error!(
- "GitHub API response text: {:?}",
- String::from_utf8_lossy(body.as_slice())
- );
- anyhow!("error deserializing GitHub user")
- })?
- };
-
- let query_params = [
- ("github_login", &github_user.login),
- ("github_user_id", &github_user.id.to_string()),
- (
- "github_user_created_at",
- &github_user.created_at.to_rfc3339(),
- ),
- ];
-
- // Use the collab server's admin API to retrieve the ID
- // of the impersonated user.
- let mut url = self.rpc_url(http.clone(), None).await?;
- url.set_path("/user");
- url.set_query(Some(
- &query_params
- .iter()
- .map(|(key, value)| {
- format!(
- "{}={}",
- key,
- url::form_urlencoded::byte_serialize(value.as_bytes()).collect::()
- )
- })
- .collect::>()
- .join("&"),
- ));
- let request: http_client::Request = Request::get(url.as_str())
- .header("Authorization", format!("token {api_token}"))
- .body("".into())?;
+ let url = self
+ .http
+ .build_zed_cloud_url("/internal/users/impersonate", &[])?;
+ let request = Request::post(url.as_str())
+ .header("Content-Type", "application/json")
+ .header("Authorization", format!("Bearer {api_token}"))
+ .body(
+ serde_json::to_string(&ImpersonateUserBody {
+ github_login: login,
+ })?
+ .into(),
+ )?;
let mut response = http.send(request).await?;
let mut body = String::new();
@@ -1468,18 +1471,17 @@ impl Client {
response.status().as_u16(),
body,
);
- let response: AuthenticatedUserResponse = serde_json::from_str(&body)?;
+ let response: ImpersonateUserResponse = serde_json::from_str(&body)?;
- // Use the admin API token to authenticate as the impersonated user.
- api_token.insert_str(0, "ADMIN_TOKEN:");
Ok(Credentials {
- user_id: response.user.id,
- access_token: api_token,
+ user_id: response.user_id,
+ access_token: response.access_token,
})
}
pub async fn sign_out(self: &Arc, cx: &AsyncApp) {
self.state.write().credentials = None;
+ self.cloud_client.clear_credentials();
self.disconnect(cx);
if self.has_credentials(cx).await {
@@ -1708,7 +1710,7 @@ pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::test::FakeServer;
+ use crate::test::{FakeServer, parse_authorization_header};
use clock::FakeSystemClock;
use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
@@ -1789,7 +1791,7 @@ mod tests {
});
let auth_and_connect = cx.spawn({
let client = client.clone();
- |cx| async move { client.authenticate_and_connect(false, &cx).await }
+ |cx| async move { client.connect(false, &cx).await }
});
executor.run_until_parked();
assert!(matches!(status.next().await, Some(Status::Connecting)));
@@ -1834,6 +1836,75 @@ mod tests {
));
}
+ #[gpui::test(iterations = 10)]
+ async fn test_reauthenticate_only_if_unauthorized(cx: &mut TestAppContext) {
+ init_test(cx);
+ let auth_count = Arc::new(Mutex::new(0));
+ let http_client = FakeHttpClient::create(|_request| async move {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body("".into())
+ .unwrap())
+ });
+ let client =
+ cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx));
+ client.override_authenticate({
+ let auth_count = auth_count.clone();
+ move |cx| {
+ let auth_count = auth_count.clone();
+ cx.background_spawn(async move {
+ *auth_count.lock() += 1;
+ Ok(Credentials {
+ user_id: 1,
+ access_token: auth_count.lock().to_string(),
+ })
+ })
+ }
+ });
+
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 1);
+ assert_eq!(credentials.access_token, "1");
+
+ // If credentials are still valid, signing in doesn't trigger authentication.
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 1);
+ assert_eq!(credentials.access_token, "1");
+
+ // If the server is unavailable, signing in doesn't trigger authentication.
+ http_client
+ .as_fake()
+ .replace_handler(|_, _request| async move {
+ Ok(http_client::Response::builder()
+ .status(503)
+ .body("".into())
+ .unwrap())
+ });
+ client.sign_in(false, &cx.to_async()).await.unwrap_err();
+ assert_eq!(*auth_count.lock(), 1);
+
+ // If credentials became invalid, signing in triggers authentication.
+ http_client
+ .as_fake()
+ .replace_handler(|_, request| async move {
+ let credentials = parse_authorization_header(&request).unwrap();
+ if credentials.access_token == "2" {
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body("".into())
+ .unwrap())
+ } else {
+ Ok(http_client::Response::builder()
+ .status(401)
+ .body("".into())
+ .unwrap())
+ }
+ });
+ let credentials = client.sign_in(false, &cx.to_async()).await.unwrap();
+ assert_eq!(*auth_count.lock(), 2);
+ assert_eq!(credentials.access_token, "2");
+ }
+
#[gpui::test(iterations = 10)]
async fn test_authenticating_more_than_once(
cx: &mut TestAppContext,
@@ -1866,7 +1937,7 @@ mod tests {
let _authenticate = cx.spawn({
let client = client.clone();
- move |cx| async move { client.authenticate_and_connect(false, &cx).await }
+ move |cx| async move { client.connect(false, &cx).await }
});
executor.run_until_parked();
assert_eq!(*auth_count.lock(), 1);
@@ -1874,7 +1945,7 @@ mod tests {
let _authenticate = cx.spawn({
let client = client.clone();
- |cx| async move { client.authenticate_and_connect(false, &cx).await }
+ |cx| async move { client.connect(false, &cx).await }
});
executor.run_until_parked();
assert_eq!(*auth_count.lock(), 2);
diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs
index 6ce79fa9c5..439fb100d2 100644
--- a/crates/client/src/test.rs
+++ b/crates/client/src/test.rs
@@ -1,8 +1,11 @@
use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
use anyhow::{Context as _, Result, anyhow};
use chrono::Duration;
+use cloud_api_client::{AuthenticatedUser, GetAuthenticatedUserResponse, PlanInfo};
+use cloud_llm_client::{CurrentUsage, Plan, UsageData, UsageLimit};
use futures::{StreamExt, stream::BoxStream};
use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext};
+use http_client::{AsyncBody, Method, Request, http};
use parking_lot::Mutex;
use rpc::{
ConnectionId, Peer, Receipt, TypedEnvelope,
@@ -39,6 +42,44 @@ impl FakeServer {
executor: cx.executor(),
};
+ client.http_client().as_fake().replace_handler({
+ let state = server.state.clone();
+ move |old_handler, req| {
+ let state = state.clone();
+ let old_handler = old_handler.clone();
+ async move {
+ match (req.method(), req.uri().path()) {
+ (&Method::GET, "/client/users/me") => {
+ let credentials = parse_authorization_header(&req);
+ if credentials
+ != Some(Credentials {
+ user_id: client_user_id,
+ access_token: state.lock().access_token.to_string(),
+ })
+ {
+ return Ok(http_client::Response::builder()
+ .status(401)
+ .body("Unauthorized".into())
+ .unwrap());
+ }
+
+ Ok(http_client::Response::builder()
+ .status(200)
+ .body(
+ serde_json::to_string(&make_get_authenticated_user_response(
+ client_user_id as i32,
+ format!("user-{client_user_id}"),
+ ))
+ .unwrap()
+ .into(),
+ )
+ .unwrap())
+ }
+ _ => old_handler(req).await,
+ }
+ }
+ }
+ });
client
.override_authenticate({
let state = Arc::downgrade(&server.state);
@@ -105,7 +146,7 @@ impl FakeServer {
});
client
- .authenticate_and_connect(false, &cx.to_async())
+ .connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
@@ -223,3 +264,54 @@ impl Drop for FakeServer {
self.disconnect();
}
}
+
+pub fn parse_authorization_header(req: &Request) -> Option {
+ let mut auth_header = req
+ .headers()
+ .get(http::header::AUTHORIZATION)?
+ .to_str()
+ .ok()?
+ .split_whitespace();
+ let user_id = auth_header.next()?.parse().ok()?;
+ let access_token = auth_header.next()?;
+ Some(Credentials {
+ user_id,
+ access_token: access_token.to_string(),
+ })
+}
+
+pub fn make_get_authenticated_user_response(
+ user_id: i32,
+ github_login: String,
+) -> GetAuthenticatedUserResponse {
+ GetAuthenticatedUserResponse {
+ user: AuthenticatedUser {
+ id: user_id,
+ metrics_id: format!("metrics-id-{user_id}"),
+ avatar_url: "".to_string(),
+ github_login,
+ name: None,
+ is_staff: false,
+ accepted_tos_at: None,
+ },
+ feature_flags: vec![],
+ plan: PlanInfo {
+ plan: Plan::ZedPro,
+ subscription_period: None,
+ usage: CurrentUsage {
+ model_requests: UsageData {
+ used: 0,
+ limit: UsageLimit::Limited(500),
+ },
+ edit_predictions: UsageData {
+ used: 250,
+ limit: UsageLimit::Unlimited,
+ },
+ },
+ trial_started_at: None,
+ is_usage_based_billing_enabled: false,
+ is_account_too_young: false,
+ has_overdue_invoices: false,
+ },
+ }
+}
diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs
index a7dab2a8d3..3c125a0882 100644
--- a/crates/client/src/user.rs
+++ b/crates/client/src/user.rs
@@ -1,6 +1,7 @@
use super::{Client, Status, TypedEnvelope, proto};
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
+use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo};
use cloud_llm_client::{
EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME,
MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit,
@@ -20,7 +21,7 @@ use std::{
sync::{Arc, Weak},
};
use text::ReplicaId;
-use util::{TryFutureExt as _, maybe};
+use util::{ResultExt, TryFutureExt as _};
pub type UserId = u64;
@@ -55,7 +56,7 @@ pub struct ParticipantIndex(pub u32);
#[derive(Default, Debug)]
pub struct User {
pub id: UserId,
- pub github_login: String,
+ pub github_login: SharedString,
pub avatar_uri: SharedUri,
pub name: Option,
}
@@ -107,19 +108,14 @@ pub enum ContactRequestStatus {
pub struct UserStore {
users: HashMap>,
- by_github_login: HashMap,
+ by_github_login: HashMap,
participant_indices: HashMap,
update_contacts_tx: mpsc::UnboundedSender,
- current_plan: Option,
- subscription_period: Option<(DateTime, DateTime)>,
- trial_started_at: Option>,
model_request_usage: Option,
edit_prediction_usage: Option,
- is_usage_based_billing_enabled: Option,
- account_too_young: Option,
- has_overdue_invoices: Option,
+ plan_info: Option,
current_user: watch::Receiver