diff --git a/.config/hakari.toml b/.config/hakari.toml
index 2050065cc2..f71e97b45c 100644
--- a/.config/hakari.toml
+++ b/.config/hakari.toml
@@ -25,6 +25,8 @@ third-party = [
{ name = "reqwest", version = "0.11.27" },
# build of remote_server should not include scap / its x11 dependency
{ name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" },
+ # build of remote_server should not need to include on libalsa through rodio
+ { name = "rodio" },
]
[final-excludes]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b70271e57..f4ba227168 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -718,7 +718,7 @@ jobs:
timeout-minutes: 60
runs-on: github-8vcpu-ubuntu-2404
if: |
- ( startsWith(github.ref, 'refs/tags/v')
+ false && ( startsWith(github.ref, 'refs/tags/v')
|| contains(github.event.pull_request.labels.*.name, 'run-bundling') )
needs: [linux_tests]
name: Build Zed on FreeBSD
diff --git a/Cargo.lock b/Cargo.lock
index 39881cbdaa..7b9bf9ab57 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7,12 +7,14 @@ name = "acp_thread"
version = "0.1.0"
dependencies = [
"action_log",
+ "agent",
"agent-client-protocol",
"anyhow",
"buffer_diff",
"collections",
"editor",
"env_logger 0.11.8",
+ "file_icons",
"futures 0.3.31",
"gpui",
"indoc",
@@ -21,6 +23,7 @@ dependencies = [
"markdown",
"parking_lot",
"project",
+ "prompt_store",
"rand 0.8.5",
"serde",
"serde_json",
@@ -31,6 +34,7 @@ dependencies = [
"ui",
"url",
"util",
+ "uuid",
"watch",
"workspace-hack",
]
@@ -168,9 +172,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
-version = "0.0.23"
+version = "0.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8"
+checksum = "8fd68bbbef8e424fb8a605c5f0b00c360f682c4528b0a5feb5ec928aaf5ce28e"
dependencies = [
"anyhow",
"futures 0.3.31",
@@ -391,6 +395,7 @@ dependencies = [
"ui",
"ui_input",
"unindent",
+ "url",
"urlencoding",
"util",
"uuid",
@@ -1299,9 +1304,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
-version = "0.1.88"
+version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
@@ -6446,6 +6451,7 @@ dependencies = [
"log",
"parking_lot",
"pretty_assertions",
+ "rand 0.8.5",
"regex",
"rope",
"schemars",
@@ -7877,6 +7883,12 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "hound"
+version = "3.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+
[[package]]
name = "html5ever"
version = "0.27.0"
@@ -9705,6 +9717,7 @@ dependencies = [
"objc",
"parking_lot",
"postage",
+ "rodio",
"scap",
"serde",
"serde_json",
@@ -11150,12 +11163,10 @@ dependencies = [
"ai_onboarding",
"anyhow",
"client",
- "command_palette_hooks",
"component",
"db",
"documented",
"editor",
- "feature_flags",
"fs",
"fuzzy",
"git",
@@ -11170,6 +11181,7 @@ dependencies = [
"schemars",
"serde",
"settings",
+ "telemetry",
"theme",
"ui",
"util",
@@ -13967,6 +13979,7 @@ checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183"
dependencies = [
"cpal",
"dasp_sample",
+ "hound",
"num-rational",
"symphonia",
"tracing",
@@ -15049,8 +15062,10 @@ dependencies = [
"ui",
"ui_input",
"util",
+ "vim",
"workspace",
"workspace-hack",
+ "zed_actions",
]
[[package]]
@@ -18886,33 +18901,6 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
-[[package]]
-name = "welcome"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "component",
- "db",
- "documented",
- "editor",
- "fuzzy",
- "gpui",
- "install_cli",
- "language",
- "picker",
- "project",
- "serde",
- "settings",
- "telemetry",
- "ui",
- "util",
- "vim_mode_setting",
- "workspace",
- "workspace-hack",
- "zed_actions",
-]
-
[[package]]
name = "which"
version = "4.4.2"
@@ -20298,7 +20286,7 @@ dependencies = [
[[package]]
name = "xim"
version = "0.4.0"
-source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
+source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
dependencies = [
"ahash 0.8.11",
"hashbrown 0.14.5",
@@ -20311,7 +20299,7 @@ dependencies = [
[[package]]
name = "xim-ctext"
version = "0.3.0"
-source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
+source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
dependencies = [
"encoding_rs",
]
@@ -20319,7 +20307,7 @@ dependencies = [
[[package]]
name = "xim-parser"
version = "0.2.1"
-source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
+source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
dependencies = [
"bitflags 2.9.0",
]
@@ -20527,7 +20515,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.200.0"
+version = "0.201.0"
dependencies = [
"activity_indicator",
"agent",
@@ -20597,6 +20585,7 @@ dependencies = [
"language_tools",
"languages",
"libc",
+ "livekit_client",
"log",
"markdown",
"markdown_preview",
@@ -20667,7 +20656,6 @@ dependencies = [
"watch",
"web_search",
"web_search_providers",
- "welcome",
"windows 0.61.1",
"winresource",
"workspace",
@@ -20691,7 +20679,7 @@ dependencies = [
[[package]]
name = "zed_emmet"
-version = "0.0.5"
+version = "0.0.6"
dependencies = [
"zed_extension_api 0.1.0",
]
diff --git a/Cargo.toml b/Cargo.toml
index dd14078dd2..baa4ee7f4e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -185,7 +185,6 @@ members = [
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
- "crates/welcome",
"crates/workspace",
"crates/worktree",
"crates/x_ai",
@@ -364,6 +363,7 @@ remote_server = { path = "crates/remote_server" }
repl = { path = "crates/repl" }
reqwest_client = { path = "crates/reqwest_client" }
rich_text = { path = "crates/rich_text" }
+rodio = { version = "0.21.1", default-features = false }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
rules_library = { path = "crates/rules_library" }
@@ -412,7 +412,6 @@ vim_mode_setting = { path = "crates/vim_mode_setting" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
web_search_providers = { path = "crates/web_search_providers" }
-welcome = { path = "crates/welcome" }
workspace = { path = "crates/workspace" }
worktree = { path = "crates/worktree" }
x_ai = { path = "crates/x_ai" }
@@ -427,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" }
#
agentic-coding-protocol = "0.0.10"
-agent-client-protocol = "0.0.23"
+agent-client-protocol = "0.0.24"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf b/assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf
new file mode 100644
index 0000000000..1d66b1a2e9
Binary files /dev/null and b/assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf differ
diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf b/assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf
new file mode 100644
index 0000000000..e07bc1f527
Binary files /dev/null and b/assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf differ
diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf b/assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf
new file mode 100644
index 0000000000..efe8a1fb9d
Binary files /dev/null and b/assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf differ
diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf b/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf
new file mode 100644
index 0000000000..bd6817d520
Binary files /dev/null and b/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf differ
diff --git a/assets/fonts/plex-mono/license.txt b/assets/fonts/ibm-plex-sans/license.txt
similarity index 100%
rename from assets/fonts/plex-mono/license.txt
rename to assets/fonts/ibm-plex-sans/license.txt
diff --git a/assets/fonts/lilex/Lilex-Bold.ttf b/assets/fonts/lilex/Lilex-Bold.ttf
new file mode 100644
index 0000000000..45930ee30b
Binary files /dev/null and b/assets/fonts/lilex/Lilex-Bold.ttf differ
diff --git a/assets/fonts/lilex/Lilex-BoldItalic.ttf b/assets/fonts/lilex/Lilex-BoldItalic.ttf
new file mode 100644
index 0000000000..10c6ab5f74
Binary files /dev/null and b/assets/fonts/lilex/Lilex-BoldItalic.ttf differ
diff --git a/assets/fonts/lilex/Lilex-Italic.ttf b/assets/fonts/lilex/Lilex-Italic.ttf
new file mode 100644
index 0000000000..e7aef10f7e
Binary files /dev/null and b/assets/fonts/lilex/Lilex-Italic.ttf differ
diff --git a/assets/fonts/lilex/Lilex-Regular.ttf b/assets/fonts/lilex/Lilex-Regular.ttf
new file mode 100644
index 0000000000..cb98a69b0f
Binary files /dev/null and b/assets/fonts/lilex/Lilex-Regular.ttf differ
diff --git a/assets/fonts/plex-sans/license.txt b/assets/fonts/lilex/OFL.txt
similarity index 96%
rename from assets/fonts/plex-sans/license.txt
rename to assets/fonts/lilex/OFL.txt
index f72f76504c..156240bc90 100644
--- a/assets/fonts/plex-sans/license.txt
+++ b/assets/fonts/lilex/OFL.txt
@@ -1,8 +1,9 @@
-Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
+Copyright 2019 The Lilex Project Authors (https://github.com/mishamyrt/Lilex)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
+https://scripts.sil.org/OFL
+
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
@@ -89,4 +90,4 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf b/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf
deleted file mode 100644
index d5f4b5e285..0000000000
Binary files a/assets/fonts/plex-mono/ZedPlexMono-Bold.ttf and /dev/null differ
diff --git a/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf b/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf
deleted file mode 100644
index 05eaf7cccd..0000000000
Binary files a/assets/fonts/plex-mono/ZedPlexMono-BoldItalic.ttf and /dev/null differ
diff --git a/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf b/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf
deleted file mode 100644
index 3b07821757..0000000000
Binary files a/assets/fonts/plex-mono/ZedPlexMono-Italic.ttf and /dev/null differ
diff --git a/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf b/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf
deleted file mode 100644
index 61dbb58361..0000000000
Binary files a/assets/fonts/plex-mono/ZedPlexMono-Regular.ttf and /dev/null differ
diff --git a/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf b/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf
deleted file mode 100644
index f1e66392f7..0000000000
Binary files a/assets/fonts/plex-sans/ZedPlexSans-Bold.ttf and /dev/null differ
diff --git a/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf
deleted file mode 100644
index 7612dc5167..0000000000
Binary files a/assets/fonts/plex-sans/ZedPlexSans-BoldItalic.ttf and /dev/null differ
diff --git a/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf b/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf
deleted file mode 100644
index 8769c232ee..0000000000
Binary files a/assets/fonts/plex-sans/ZedPlexSans-Italic.ttf and /dev/null differ
diff --git a/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf b/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf
deleted file mode 100644
index 3ea293d59a..0000000000
Binary files a/assets/fonts/plex-sans/ZedPlexSans-Regular.ttf and /dev/null differ
diff --git a/assets/icons/json.svg b/assets/icons/json.svg
new file mode 100644
index 0000000000..5f012f8838
--- /dev/null
+++ b/assets/icons/json.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 708432393c..01c0b4e969 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -239,6 +239,7 @@
"ctrl-shift-a": "agent::ToggleContextPicker",
"ctrl-shift-j": "agent::ToggleNavigationMenu",
"ctrl-shift-i": "agent::ToggleOptionsMenu",
+ "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "assistant::QuoteSelection",
"ctrl-alt-e": "agent::RemoveAllContext",
@@ -330,8 +331,6 @@
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
- "up": "agent::PreviousHistoryMessage",
- "down": "agent::NextHistoryMessage",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll"
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index abb741af29..e5b7fff9e1 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -279,6 +279,7 @@
"cmd-shift-a": "agent::ToggleContextPicker",
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-shift-i": "agent::ToggleOptionsMenu",
+ "cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "assistant::QuoteSelection",
"cmd-alt-e": "agent::RemoveAllContext",
@@ -382,8 +383,6 @@
"use_key_equivalents": true,
"bindings": {
"enter": "agent::Chat",
- "up": "agent::PreviousHistoryMessage",
- "down": "agent::NextHistoryMessage",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll"
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index 98f9cafc40..be6d34a134 100644
--- a/assets/keymaps/vim.json
+++ b/assets/keymaps/vim.json
@@ -58,6 +58,8 @@
"[ space": "vim::InsertEmptyLineAbove",
"[ e": "editor::MoveLineUp",
"] e": "editor::MoveLineDown",
+ "[ f": "workspace::FollowNextCollaborator",
+ "] f": "workspace::FollowNextCollaborator",
// Word motions
"w": "vim::NextWordStart",
@@ -390,7 +392,7 @@
"right": "vim::WrappingRight",
"h": "vim::WrappingLeft",
"l": "vim::WrappingRight",
- "y": "editor::Copy",
+ "y": "vim::HelixYank",
"alt-;": "vim::OtherEnd",
"ctrl-r": "vim::Redo",
"f": ["vim::PushFindForward", { "before": false, "multiline": true }],
@@ -407,6 +409,7 @@
"g w": "vim::PushRewrap",
"insert": "vim::InsertBefore",
"alt-.": "vim::RepeatFind",
+ "alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }],
// tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode",
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 28cf591ee7..2c3bf6930d 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -28,7 +28,9 @@
"edit_prediction_provider": "zed"
},
// The name of a font to use for rendering text in the editor
- "buffer_font_family": "Zed Plex Mono",
+ // ".ZedMono" currently aliases to Lilex
+ // but this may change in the future.
+ "buffer_font_family": ".ZedMono",
// Set the buffer text's font fallbacks, this will be merged with
// the platform's default fallbacks.
"buffer_font_fallbacks": null,
@@ -54,7 +56,9 @@
"buffer_line_height": "comfortable",
// The name of a font to use for rendering text in the UI
// You can set this to ".SystemUIFont" to use the system font
- "ui_font_family": "Zed Plex Sans",
+ // ".ZedSans" currently aliases to "IBM Plex Sans", but this may
+ // change in the future
+ "ui_font_family": ".ZedSans",
// Set the UI's font fallbacks, this will be merged with the platform's
// default font fallbacks.
"ui_font_fallbacks": null,
@@ -82,10 +86,10 @@
// Layout mode of the bottom dock. Defaults to "contained"
// choices: contained, full, left_aligned, right_aligned
"bottom_dock_layout": "contained",
- // The direction that you want to split panes horizontally. Defaults to "up"
- "pane_split_direction_horizontal": "up",
- // The direction that you want to split panes vertically. Defaults to "left"
- "pane_split_direction_vertical": "left",
+ // The direction that you want to split panes horizontally. Defaults to "down"
+ "pane_split_direction_horizontal": "down",
+ // The direction that you want to split panes vertically. Defaults to "right"
+ "pane_split_direction_vertical": "right",
// Centered layout related settings.
"centered_layout": {
// The relative width of the left padding of the central pane from the
@@ -1402,7 +1406,7 @@
// "font_size": 15,
// Set the terminal's font family. If this option is not included,
// the terminal will default to matching the buffer's font family.
- // "font_family": "Zed Plex Mono",
+ // "font_family": ".ZedMono",
// Set the terminal's font fallbacks. If this option is not included,
// the terminal will default to matching the buffer's font fallbacks.
// This will be merged with the platform's default font fallbacks
diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml
index fd01b31786..2b9a6513c8 100644
--- a/crates/acp_thread/Cargo.toml
+++ b/crates/acp_thread/Cargo.toml
@@ -13,21 +13,25 @@ path = "src/acp_thread.rs"
doctest = false
[features]
-test-support = ["gpui/test-support", "project/test-support"]
+test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
[dependencies]
action_log.workspace = true
agent-client-protocol.workspace = true
+agent.workspace = true
anyhow.workspace = true
buffer_diff.workspace = true
collections.workspace = true
editor.workspace = true
+file_icons.workspace = true
futures.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
markdown.workspace = true
+parking_lot = { workspace = true, optional = true }
project.workspace = true
+prompt_store.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -36,6 +40,7 @@ terminal.workspace = true
ui.workspace = true
url.workspace = true
util.workspace = true
+uuid.workspace = true
watch.workspace = true
workspace-hack.workspace = true
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index d1957e1c2a..4995ddb9df 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -9,18 +9,19 @@ pub use mention::*;
pub use terminal::*;
use action_log::ActionLog;
-use agent_client_protocol::{self as acp};
-use anyhow::{Context as _, Result};
+use agent_client_protocol as acp;
+use anyhow::{Context as _, Result, anyhow};
use editor::Bias;
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
use itertools::Itertools;
use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
use markdown::Markdown;
-use project::{AgentLocation, Project};
+use project::{AgentLocation, Project, git_store::GitStoreCheckpoint};
use std::collections::HashMap;
use std::error::Error;
-use std::fmt::Formatter;
+use std::fmt::{Formatter, Write};
+use std::ops::Range;
use std::process::ExitStatus;
use std::rc::Rc;
use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
@@ -29,24 +30,34 @@ use util::ResultExt;
#[derive(Debug)]
pub struct UserMessage {
+ pub id: Option,
pub content: ContentBlock,
+ pub chunks: Vec,
+ pub checkpoint: Option,
+}
+
+#[derive(Debug)]
+pub struct Checkpoint {
+ git_checkpoint: GitStoreCheckpoint,
+ pub show: bool,
}
impl UserMessage {
- pub fn from_acp(
- message: impl IntoIterator- ,
- language_registry: Arc,
- cx: &mut App,
- ) -> Self {
- let mut content = ContentBlock::Empty;
- for chunk in message {
- content.append(chunk, &language_registry, cx)
- }
- Self { content: content }
- }
-
fn to_markdown(&self, cx: &App) -> String {
- format!("## User\n\n{}\n\n", self.content.to_markdown(cx))
+ let mut markdown = String::new();
+ if self
+ .checkpoint
+ .as_ref()
+ .map_or(false, |checkpoint| checkpoint.show)
+ {
+ writeln!(markdown, "## User (checkpoint)").unwrap();
+ } else {
+ writeln!(markdown, "## User").unwrap();
+ }
+ writeln!(markdown).unwrap();
+ writeln!(markdown, "{}", self.content.to_markdown(cx)).unwrap();
+ writeln!(markdown).unwrap();
+ markdown
}
}
@@ -399,7 +410,7 @@ impl ContentBlock {
}
}
- let new_content = self.extract_content_from_block(block);
+ let new_content = self.block_string_contents(block);
match self {
ContentBlock::Empty => {
@@ -409,7 +420,7 @@ impl ContentBlock {
markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
}
ContentBlock::ResourceLink { resource_link } => {
- let existing_content = Self::resource_link_to_content(&resource_link.uri);
+ let existing_content = Self::resource_link_md(&resource_link.uri);
let combined = format!("{}\n{}", existing_content, new_content);
*self = Self::create_markdown_block(combined, language_registry, cx);
@@ -417,14 +428,6 @@ impl ContentBlock {
}
}
- fn resource_link_to_content(uri: &str) -> String {
- if let Some(uri) = MentionUri::parse(&uri).log_err() {
- uri.to_link()
- } else {
- uri.to_string().clone()
- }
- }
-
fn create_markdown_block(
content: String,
language_registry: &Arc,
@@ -436,11 +439,11 @@ impl ContentBlock {
}
}
- fn extract_content_from_block(&self, block: acp::ContentBlock) -> String {
+ fn block_string_contents(&self, block: acp::ContentBlock) -> String {
match block {
acp::ContentBlock::Text(text_content) => text_content.text.clone(),
acp::ContentBlock::ResourceLink(resource_link) => {
- Self::resource_link_to_content(&resource_link.uri)
+ Self::resource_link_md(&resource_link.uri)
}
acp::ContentBlock::Resource(acp::EmbeddedResource {
resource:
@@ -449,13 +452,24 @@ impl ContentBlock {
..
}),
..
- }) => Self::resource_link_to_content(&uri),
- acp::ContentBlock::Image(_)
- | acp::ContentBlock::Audio(_)
- | acp::ContentBlock::Resource(_) => String::new(),
+ }) => Self::resource_link_md(&uri),
+ acp::ContentBlock::Image(image) => Self::image_md(&image),
+ acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(),
}
}
+ fn resource_link_md(uri: &str) -> String {
+ if let Some(uri) = MentionUri::parse(&uri).log_err() {
+ uri.as_link().to_string()
+ } else {
+ uri.to_string()
+ }
+ }
+
+ fn image_md(_image: &acp::ImageContent) -> String {
+ "`Image`".into()
+ }
+
fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
match self {
ContentBlock::Empty => "",
@@ -633,6 +647,7 @@ pub struct AcpThread {
pub enum AcpThreadEvent {
NewEntry,
EntryUpdated(usize),
+ EntriesRemoved(Range),
ToolAuthorizationRequired,
Stopped,
Error,
@@ -772,7 +787,7 @@ impl AcpThread {
) -> Result<()> {
match update {
acp::SessionUpdate::UserMessageChunk { content } => {
- self.push_user_content_block(content, cx);
+ self.push_user_content_block(None, content, cx);
}
acp::SessionUpdate::AgentMessageChunk { content } => {
self.push_assistant_content_block(content, false, cx);
@@ -793,18 +808,39 @@ impl AcpThread {
Ok(())
}
- pub fn push_user_content_block(&mut self, chunk: acp::ContentBlock, cx: &mut Context) {
+ pub fn push_user_content_block(
+ &mut self,
+ message_id: Option,
+ chunk: acp::ContentBlock,
+ cx: &mut Context,
+ ) {
let language_registry = self.project.read(cx).languages().clone();
let entries_len = self.entries.len();
if let Some(last_entry) = self.entries.last_mut()
- && let AgentThreadEntry::UserMessage(UserMessage { content }) = last_entry
+ && let AgentThreadEntry::UserMessage(UserMessage {
+ id,
+ content,
+ chunks,
+ ..
+ }) = last_entry
{
- content.append(chunk, &language_registry, cx);
- cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
+ *id = message_id.or(id.take());
+ content.append(chunk.clone(), &language_registry, cx);
+ chunks.push(chunk);
+ let idx = entries_len - 1;
+ cx.emit(AcpThreadEvent::EntryUpdated(idx));
} else {
- let content = ContentBlock::new(chunk, &language_registry, cx);
- self.push_entry(AgentThreadEntry::UserMessage(UserMessage { content }), cx);
+ let content = ContentBlock::new(chunk.clone(), &language_registry, cx);
+ self.push_entry(
+ AgentThreadEntry::UserMessage(UserMessage {
+ id: message_id,
+ content,
+ chunks: vec![chunk],
+ checkpoint: None,
+ }),
+ cx,
+ );
}
}
@@ -819,7 +855,8 @@ impl AcpThread {
if let Some(last_entry) = self.entries.last_mut()
&& let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
{
- cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
+ let idx = entries_len - 1;
+ cx.emit(AcpThreadEvent::EntryUpdated(idx));
match (chunks.last_mut(), is_thought) {
(Some(AssistantMessageChunk::Message { block }), false)
| (Some(AssistantMessageChunk::Thought { block }), true) => {
@@ -1118,69 +1155,112 @@ impl AcpThread {
self.project.read(cx).languages().clone(),
cx,
);
+ let request = acp::PromptRequest {
+ prompt: message.clone(),
+ session_id: self.session_id.clone(),
+ };
+ let git_store = self.project.read(cx).git_store().clone();
+
+ let message_id = if self
+ .connection
+ .session_editor(&self.session_id, cx)
+ .is_some()
+ {
+ Some(UserMessageId::new())
+ } else {
+ None
+ };
self.push_entry(
- AgentThreadEntry::UserMessage(UserMessage { content: block }),
+ AgentThreadEntry::UserMessage(UserMessage {
+ id: message_id.clone(),
+ content: block,
+ chunks: message,
+ checkpoint: None,
+ }),
cx,
);
+
+ self.run_turn(cx, async move |this, cx| {
+ let old_checkpoint = git_store
+ .update(cx, |git, cx| git.checkpoint(cx))?
+ .await
+ .context("failed to get old checkpoint")
+ .log_err();
+ this.update(cx, |this, cx| {
+ if let Some((_ix, message)) = this.last_user_message() {
+ message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint {
+ git_checkpoint,
+ show: false,
+ });
+ }
+ this.connection.prompt(message_id, request, cx)
+ })?
+ .await
+ })
+ }
+
+ pub fn resume(&mut self, cx: &mut Context) -> BoxFuture<'static, Result<()>> {
+ self.run_turn(cx, async move |this, cx| {
+ this.update(cx, |this, cx| {
+ this.connection
+ .resume(&this.session_id, cx)
+ .map(|resume| resume.run(cx))
+ })?
+ .context("resuming a session is not supported")?
+ .await
+ })
+ }
+
+ fn run_turn(
+ &mut self,
+ cx: &mut Context,
+ f: impl 'static + AsyncFnOnce(WeakEntity, &mut AsyncApp) -> Result,
+ ) -> BoxFuture<'static, Result<()>> {
self.clear_completed_plan_entries(cx);
let (tx, rx) = oneshot::channel();
let cancel_task = self.cancel(cx);
self.send_task = Some(cx.spawn(async move |this, cx| {
- async {
- cancel_task.await;
-
- let result = this
- .update(cx, |this, cx| {
- this.connection.prompt(
- acp::PromptRequest {
- prompt: message,
- session_id: this.session_id.clone(),
- },
- cx,
- )
- })?
- .await;
-
- tx.send(result).log_err();
-
- anyhow::Ok(())
- }
- .await
- .log_err();
+ cancel_task.await;
+ tx.send(f(this, cx).await).ok();
}));
- cx.spawn(async move |this, cx| match rx.await {
- Ok(Err(e)) => {
- this.update(cx, |this, cx| {
- this.send_task.take();
- cx.emit(AcpThreadEvent::Error)
- })
- .log_err();
- Err(e)?
- }
- result => {
- let cancelled = matches!(
- result,
- Ok(Ok(acp::PromptResponse {
- stop_reason: acp::StopReason::Cancelled
- }))
- );
+ cx.spawn(async move |this, cx| {
+ let response = rx.await;
- // We only take the task if the current prompt wasn't cancelled.
- //
- // This prompt may have been cancelled because another one was sent
- // while it was still generating. In these cases, dropping `send_task`
- // would cause the next generation to be cancelled.
- if !cancelled {
- this.update(cx, |this, _cx| this.send_task.take()).ok();
+ this.update(cx, |this, cx| this.update_last_checkpoint(cx))?
+ .await?;
+
+ this.update(cx, |this, cx| {
+ match response {
+ Ok(Err(e)) => {
+ this.send_task.take();
+ cx.emit(AcpThreadEvent::Error);
+ Err(e)
+ }
+ result => {
+ let cancelled = matches!(
+ result,
+ Ok(Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::Cancelled
+ }))
+ );
+
+ // We only take the task if the current prompt wasn't cancelled.
+ //
+ // This prompt may have been cancelled because another one was sent
+ // while it was still generating. In these cases, dropping `send_task`
+ // would cause the next generation to be cancelled.
+ if !cancelled {
+ this.send_task.take();
+ }
+
+ cx.emit(AcpThreadEvent::Stopped);
+ Ok(())
+ }
}
-
- this.update(cx, |_, cx| cx.emit(AcpThreadEvent::Stopped))
- .log_err();
- Ok(())
- }
+ })?
})
.boxed()
}
@@ -1212,6 +1292,122 @@ impl AcpThread {
cx.foreground_executor().spawn(send_task)
}
+ /// Rewinds this thread to before the entry at `index`, removing it and all
+ /// subsequent entries while reverting any changes made from that point.
+ pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context) -> Task> {
+ let Some(session_editor) = self.connection.session_editor(&self.session_id, cx) else {
+ return Task::ready(Err(anyhow!("not supported")));
+ };
+ let Some(message) = self.user_message(&id) else {
+ return Task::ready(Err(anyhow!("message not found")));
+ };
+
+ let checkpoint = message
+ .checkpoint
+ .as_ref()
+ .map(|c| c.git_checkpoint.clone());
+
+ let git_store = self.project.read(cx).git_store().clone();
+ cx.spawn(async move |this, cx| {
+ if let Some(checkpoint) = checkpoint {
+ git_store
+ .update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
+ .await?;
+ }
+
+ cx.update(|cx| session_editor.truncate(id.clone(), cx))?
+ .await?;
+ this.update(cx, |this, cx| {
+ if let Some((ix, _)) = this.user_message_mut(&id) {
+ let range = ix..this.entries.len();
+ this.entries.truncate(ix);
+ cx.emit(AcpThreadEvent::EntriesRemoved(range));
+ }
+ })
+ })
+ }
+
+ fn update_last_checkpoint(&mut self, cx: &mut Context) -> Task> {
+ let git_store = self.project.read(cx).git_store().clone();
+
+ let old_checkpoint = if let Some((_, message)) = self.last_user_message() {
+ if let Some(checkpoint) = message.checkpoint.as_ref() {
+ checkpoint.git_checkpoint.clone()
+ } else {
+ return Task::ready(Ok(()));
+ }
+ } else {
+ return Task::ready(Ok(()));
+ };
+
+ let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
+ cx.spawn(async move |this, cx| {
+ let new_checkpoint = new_checkpoint
+ .await
+ .context("failed to get new checkpoint")
+ .log_err();
+ if let Some(new_checkpoint) = new_checkpoint {
+ let equal = git_store
+ .update(cx, |git, cx| {
+ git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
+ })?
+ .await
+ .unwrap_or(true);
+ this.update(cx, |this, cx| {
+ let (ix, message) = this.last_user_message().context("no user message")?;
+ let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?;
+ checkpoint.show = !equal;
+ cx.emit(AcpThreadEvent::EntryUpdated(ix));
+ anyhow::Ok(())
+ })??;
+ }
+
+ Ok(())
+ })
+ }
+
+ fn last_user_message(&mut self) -> Option<(usize, &mut UserMessage)> {
+ self.entries
+ .iter_mut()
+ .enumerate()
+ .rev()
+ .find_map(|(ix, entry)| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ Some((ix, message))
+ } else {
+ None
+ }
+ })
+ }
+
+ fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
+ self.entries.iter().find_map(|entry| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ if message.id.as_ref() == Some(&id) {
+ Some(message)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ }
+
+ fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
+ self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
+ if let AgentThreadEntry::UserMessage(message) = entry {
+ if message.id.as_ref() == Some(&id) {
+ Some((ix, message))
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ }
+
pub fn read_text_file(
&self,
path: PathBuf,
@@ -1414,13 +1610,19 @@ mod tests {
use futures::{channel::mpsc, future::LocalBoxFuture, select};
use gpui::{AsyncApp, TestAppContext, WeakEntity};
use indoc::indoc;
- use project::FakeFs;
+ use project::{FakeFs, Fs};
use rand::Rng as _;
use serde_json::json;
use settings::SettingsStore;
use smol::stream::StreamExt as _;
- use std::{cell::RefCell, path::Path, rc::Rc, time::Duration};
-
+ use std::{
+ any::Any,
+ cell::RefCell,
+ path::Path,
+ rc::Rc,
+ sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
+ time::Duration,
+ };
use util::path;
fn init_test(cx: &mut TestAppContext) {
@@ -1441,17 +1643,14 @@ mod tests {
let project = Project::test(fs, [], cx).await;
let connection = Rc::new(FakeAgentConnection::new());
let thread = cx
- .spawn(async move |mut cx| {
- connection
- .new_thread(project, Path::new(path!("/test")), &mut cx)
- .await
- })
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
// Test creating a new user message
thread.update(cx, |thread, cx| {
thread.push_user_content_block(
+ None,
acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "Hello, ".to_string(),
@@ -1463,6 +1662,7 @@ mod tests {
thread.update(cx, |thread, cx| {
assert_eq!(thread.entries.len(), 1);
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
+ assert_eq!(user_msg.id, None);
assert_eq!(user_msg.content.to_markdown(cx), "Hello, ");
} else {
panic!("Expected UserMessage");
@@ -1470,8 +1670,10 @@ mod tests {
});
// Test appending to existing user message
+ let message_1_id = UserMessageId::new();
thread.update(cx, |thread, cx| {
thread.push_user_content_block(
+ Some(message_1_id.clone()),
acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "world!".to_string(),
@@ -1483,6 +1685,7 @@ mod tests {
thread.update(cx, |thread, cx| {
assert_eq!(thread.entries.len(), 1);
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
+ assert_eq!(user_msg.id, Some(message_1_id));
assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!");
} else {
panic!("Expected UserMessage");
@@ -1501,8 +1704,10 @@ mod tests {
);
});
+ let message_2_id = UserMessageId::new();
thread.update(cx, |thread, cx| {
thread.push_user_content_block(
+ Some(message_2_id.clone()),
acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "New user message".to_string(),
@@ -1514,6 +1719,7 @@ mod tests {
thread.update(cx, |thread, cx| {
assert_eq!(thread.entries.len(), 3);
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] {
+ assert_eq!(user_msg.id, Some(message_2_id));
assert_eq!(user_msg.content.to_markdown(cx), "New user message");
} else {
panic!("Expected UserMessage at index 2");
@@ -1557,11 +1763,7 @@ mod tests {
));
let thread = cx
- .spawn(async move |mut cx| {
- connection
- .new_thread(project, Path::new(path!("/test")), &mut cx)
- .await
- })
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -1644,7 +1846,7 @@ mod tests {
.unwrap();
let thread = cx
- .spawn(|mut cx| connection.new_thread(project, Path::new(path!("/tmp")), &mut cx))
+ .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
.await
.unwrap();
@@ -1707,11 +1909,7 @@ mod tests {
}));
let thread = cx
- .spawn(async move |mut cx| {
- connection
- .new_thread(project, Path::new(path!("/test")), &mut cx)
- .await
- })
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
@@ -1819,10 +2017,11 @@ mod tests {
}
}));
- let thread = connection
- .new_thread(project, Path::new(path!("/test")), &mut cx.to_async())
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
.await
.unwrap();
+
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Hi".into()], cx)))
.await
.unwrap();
@@ -1830,6 +2029,180 @@ mod tests {
assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls()));
}
+ #[gpui::test(iterations = 10)]
+ async fn test_checkpoints(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ path!("/test"),
+ json!({
+ ".git": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+
+ let simulate_changes = Arc::new(AtomicBool::new(true));
+ let next_filename = Arc::new(AtomicUsize::new(0));
+ let connection = Rc::new(FakeAgentConnection::new().on_user_message({
+ let simulate_changes = simulate_changes.clone();
+ let next_filename = next_filename.clone();
+ let fs = fs.clone();
+ move |request, thread, mut cx| {
+ let fs = fs.clone();
+ let simulate_changes = simulate_changes.clone();
+ let next_filename = next_filename.clone();
+ async move {
+ if simulate_changes.load(SeqCst) {
+ let filename = format!("/test/file-{}", next_filename.fetch_add(1, SeqCst));
+ fs.write(Path::new(&filename), b"").await?;
+ }
+
+ let acp::ContentBlock::Text(content) = &request.prompt[0] else {
+ panic!("expected text content block");
+ };
+ thread.update(&mut cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AgentMessageChunk {
+ content: content.text.to_uppercase().into(),
+ },
+ cx,
+ )
+ .unwrap();
+ })?;
+ Ok(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ }
+ .boxed_local()
+ }
+ }));
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Lorem".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ "}
+ );
+ });
+ assert_eq!(fs.files(), vec![Path::new("/test/file-0")]);
+
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["ipsum".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ ## User (checkpoint)
+
+ ipsum
+
+ ## Assistant
+
+ IPSUM
+
+ "}
+ );
+ });
+ assert_eq!(
+ fs.files(),
+ vec![Path::new("/test/file-0"), Path::new("/test/file-1")]
+ );
+
+ // Checkpoint isn't stored when there are no changes.
+ simulate_changes.store(false, SeqCst);
+ cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["dolor".into()], cx)))
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ ## User (checkpoint)
+
+ ipsum
+
+ ## Assistant
+
+ IPSUM
+
+ ## User
+
+ dolor
+
+ ## Assistant
+
+ DOLOR
+
+ "}
+ );
+ });
+ assert_eq!(
+ fs.files(),
+ vec![Path::new("/test/file-0"), Path::new("/test/file-1")]
+ );
+
+ // Rewinding the conversation truncates the history and restores the checkpoint.
+ thread
+ .update(cx, |thread, cx| {
+ let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
+ panic!("unexpected entries {:?}", thread.entries)
+ };
+ thread.rewind(message.id.clone().unwrap(), cx)
+ })
+ .await
+ .unwrap();
+ thread.read_with(cx, |thread, cx| {
+ assert_eq!(
+ thread.to_markdown(cx),
+ indoc! {"
+ ## User (checkpoint)
+
+ Lorem
+
+ ## Assistant
+
+ LOREM
+
+ "}
+ );
+ });
+ assert_eq!(fs.files(), vec![Path::new("/test/file-0")]);
+ }
+
async fn run_until_first_tool_call(
thread: &Entity,
cx: &mut TestAppContext,
@@ -1911,7 +2284,7 @@ mod tests {
self: Rc,
project: Entity,
_cwd: &Path,
- cx: &mut gpui::AsyncApp,
+ cx: &mut gpui::App,
) -> Task>> {
let session_id = acp::SessionId(
rand::thread_rng()
@@ -1921,9 +2294,8 @@ mod tests {
.collect::()
.into(),
);
- let thread = cx
- .new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx))
- .unwrap();
+ let thread =
+ cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
self.sessions.lock().insert(session_id, thread.downgrade());
Task::ready(Ok(thread))
}
@@ -1938,6 +2310,7 @@ mod tests {
fn prompt(
&self,
+ _id: Option,
params: acp::PromptRequest,
cx: &mut App,
) -> Task> {
@@ -1966,5 +2339,29 @@ mod tests {
})
.detach();
}
+
+ fn session_editor(
+ &self,
+ session_id: &acp::SessionId,
+ _cx: &mut App,
+ ) -> Option> {
+ Some(Rc::new(FakeAgentSessionEditor {
+ _session_id: session_id.clone(),
+ }))
+ }
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+ }
+
+ struct FakeAgentSessionEditor {
+ _session_id: acp::SessionId,
+ }
+
+ impl AgentSessionEditor for FakeAgentSessionEditor {
+ fn truncate(&self, _message_id: UserMessageId, _cx: &mut App) -> Task> {
+ Task::ready(Ok(()))
+ }
}
}
diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs
index 8e6294b3ce..b2116020fb 100644
--- a/crates/acp_thread/src/connection.rs
+++ b/crates/acp_thread/src/connection.rs
@@ -1,31 +1,59 @@
-use std::{error::Error, fmt, path::Path, rc::Rc};
-
+use crate::AcpThread;
use agent_client_protocol::{self as acp};
use anyhow::Result;
use collections::IndexMap;
-use gpui::{AsyncApp, Entity, SharedString, Task};
+use gpui::{Entity, SharedString, Task};
use project::Project;
+use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc};
use ui::{App, IconName};
+use uuid::Uuid;
-use crate::AcpThread;
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UserMessageId(Arc);
+
+impl UserMessageId {
+ pub fn new() -> Self {
+ Self(Uuid::new_v4().to_string().into())
+ }
+}
pub trait AgentConnection {
fn new_thread(
self: Rc,
project: Entity,
cwd: &Path,
- cx: &mut AsyncApp,
+ cx: &mut App,
) -> Task>>;
fn auth_methods(&self) -> &[acp::AuthMethod];
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task>;
- fn prompt(&self, params: acp::PromptRequest, cx: &mut App)
- -> Task>;
+ fn prompt(
+ &self,
+ user_message_id: Option,
+ params: acp::PromptRequest,
+ cx: &mut App,
+ ) -> Task>;
+
+ fn resume(
+ &self,
+ _session_id: &acp::SessionId,
+ _cx: &mut App,
+ ) -> Option> {
+ None
+ }
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
+ fn session_editor(
+ &self,
+ _session_id: &acp::SessionId,
+ _cx: &mut App,
+ ) -> Option> {
+ None
+ }
+
/// Returns this agent as an [Rc] if the model selection capability is supported.
///
/// If the agent does not support model selection, returns [None].
@@ -33,6 +61,22 @@ pub trait AgentConnection {
fn model_selector(&self) -> Option> {
None
}
+
+ fn into_any(self: Rc) -> Rc;
+}
+
+impl dyn AgentConnection {
+ pub fn downcast(self: Rc) -> Option> {
+ self.into_any().downcast().ok()
+ }
+}
+
+pub trait AgentSessionEditor {
+ fn truncate(&self, message_id: UserMessageId, cx: &mut App) -> Task>;
+}
+
+pub trait AgentSessionResume {
+ fn run(&self, cx: &mut App) -> Task>;
}
#[derive(Debug)]
@@ -136,3 +180,159 @@ impl AgentModelList {
}
}
}
+
+#[cfg(feature = "test-support")]
+mod test_support {
+ use std::sync::Arc;
+
+ use collections::HashMap;
+ use futures::future::try_join_all;
+ use gpui::{AppContext as _, WeakEntity};
+ use parking_lot::Mutex;
+
+ use super::*;
+
+ #[derive(Clone, Default)]
+ pub struct StubAgentConnection {
+ sessions: Arc>>>,
+ permission_requests: HashMap>,
+ next_prompt_updates: Arc>>,
+ }
+
+ impl StubAgentConnection {
+ pub fn new() -> Self {
+ Self {
+ next_prompt_updates: Default::default(),
+ permission_requests: HashMap::default(),
+ sessions: Arc::default(),
+ }
+ }
+
+ pub fn set_next_prompt_updates(&self, updates: Vec) {
+ *self.next_prompt_updates.lock() = updates;
+ }
+
+ pub fn with_permission_requests(
+ mut self,
+ permission_requests: HashMap>,
+ ) -> Self {
+ self.permission_requests = permission_requests;
+ self
+ }
+
+ pub fn send_update(
+ &self,
+ session_id: acp::SessionId,
+ update: acp::SessionUpdate,
+ cx: &mut App,
+ ) {
+ self.sessions
+ .lock()
+ .get(&session_id)
+ .unwrap()
+ .update(cx, |thread, cx| {
+ thread.handle_session_update(update.clone(), cx).unwrap();
+ })
+ .unwrap();
+ }
+ }
+
+ impl AgentConnection for StubAgentConnection {
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ &[]
+ }
+
+ fn new_thread(
+ self: Rc,
+ project: Entity,
+ _cwd: &Path,
+ cx: &mut gpui::App,
+ ) -> Task>> {
+ let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
+ let thread =
+ cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
+ self.sessions.lock().insert(session_id, thread.downgrade());
+ Task::ready(Ok(thread))
+ }
+
+ fn authenticate(
+ &self,
+ _method_id: acp::AuthMethodId,
+ _cx: &mut App,
+ ) -> Task> {
+ unimplemented!()
+ }
+
+ fn prompt(
+ &self,
+ _id: Option,
+ params: acp::PromptRequest,
+ 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.next_prompt_updates.lock().drain(..) {
+ 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_authorization(
+ 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(acp::PromptResponse {
+ stop_reason: acp::StopReason::EndTurn,
+ })
+ })
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+
+ fn session_editor(
+ &self,
+ _session_id: &agent_client_protocol::SessionId,
+ _cx: &mut App,
+ ) -> Option> {
+ Some(Rc::new(StubAgentSessionEditor))
+ }
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+ }
+
+ struct StubAgentSessionEditor;
+
+ impl AgentSessionEditor for StubAgentSessionEditor {
+ fn truncate(&self, _: UserMessageId, _: &mut App) -> Task> {
+ Task::ready(Ok(()))
+ }
+ }
+}
+
+#[cfg(feature = "test-support")]
+pub use test_support::*;
diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs
index 59c479d87b..b9b021c4ca 100644
--- a/crates/acp_thread/src/mention.rs
+++ b/crates/acp_thread/src/mention.rs
@@ -1,13 +1,46 @@
-use agent_client_protocol as acp;
-use anyhow::{Result, bail};
-use std::path::PathBuf;
+use agent::ThreadId;
+use anyhow::{Context as _, Result, bail};
+use file_icons::FileIcons;
+use prompt_store::{PromptId, UserPromptId};
+use std::{
+ fmt,
+ ops::Range,
+ path::{Path, PathBuf},
+ str::FromStr,
+};
+use ui::{App, IconName, SharedString};
+use url::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MentionUri {
- File(PathBuf),
- Symbol(PathBuf, String),
- Thread(acp::SessionId),
- Rule(String),
+ File {
+ abs_path: PathBuf,
+ is_directory: bool,
+ },
+ Symbol {
+ path: PathBuf,
+ name: String,
+ line_range: Range,
+ },
+ Thread {
+ id: ThreadId,
+ name: String,
+ },
+ TextThread {
+ path: PathBuf,
+ name: String,
+ },
+ Rule {
+ id: PromptId,
+ name: String,
+ },
+ Selection {
+ path: PathBuf,
+ line_range: Range,
+ },
+ Fetch {
+ url: Url,
+ },
}
impl MentionUri {
@@ -17,58 +50,219 @@ impl MentionUri {
match url.scheme() {
"file" => {
if let Some(fragment) = url.fragment() {
- Ok(Self::Symbol(path.into(), fragment.into()))
+ let range = fragment
+ .strip_prefix("L")
+ .context("Line range must start with \"L\"")?;
+ let (start, end) = range
+ .split_once(":")
+ .context("Line range must use colon as separator")?;
+ let line_range = start
+ .parse::()
+ .context("Parsing line range start")?
+ .checked_sub(1)
+ .context("Line numbers should be 1-based")?
+ ..end
+ .parse::()
+ .context("Parsing line range end")?
+ .checked_sub(1)
+ .context("Line numbers should be 1-based")?;
+ if let Some(name) = single_query_param(&url, "symbol")? {
+ Ok(Self::Symbol {
+ name,
+ path: path.into(),
+ line_range,
+ })
+ } else {
+ Ok(Self::Selection {
+ path: path.into(),
+ line_range,
+ })
+ }
} else {
let file_path =
PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path));
+ let is_directory = input.ends_with("/");
- Ok(Self::File(file_path))
+ Ok(Self::File {
+ abs_path: file_path,
+ is_directory,
+ })
}
}
"zed" => {
- if let Some(thread) = path.strip_prefix("/agent/thread/") {
- Ok(Self::Thread(acp::SessionId(thread.into())))
- } else if let Some(rule) = path.strip_prefix("/agent/rule/") {
- Ok(Self::Rule(rule.into()))
+ if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
+ let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+ Ok(Self::Thread {
+ id: thread_id.into(),
+ name,
+ })
+ } else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
+ let name = single_query_param(&url, "name")?.context("Missing thread name")?;
+ Ok(Self::TextThread {
+ path: path.into(),
+ name,
+ })
+ } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
+ let name = single_query_param(&url, "name")?.context("Missing rule name")?;
+ let rule_id = UserPromptId(rule_id.parse()?);
+ Ok(Self::Rule {
+ id: rule_id.into(),
+ name,
+ })
} else {
bail!("invalid zed url: {:?}", input);
}
}
+ "http" | "https" => Ok(MentionUri::Fetch { url }),
other => bail!("unrecognized scheme {:?}", other),
}
}
pub fn name(&self) -> String {
match self {
- MentionUri::File(path) => path.file_name().unwrap().to_string_lossy().into_owned(),
- MentionUri::Symbol(_path, name) => name.clone(),
- MentionUri::Thread(thread) => thread.to_string(),
- MentionUri::Rule(rule) => rule.clone(),
+ MentionUri::File { abs_path, .. } => abs_path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned(),
+ MentionUri::Symbol { name, .. } => name.clone(),
+ MentionUri::Thread { name, .. } => name.clone(),
+ MentionUri::TextThread { name, .. } => name.clone(),
+ MentionUri::Rule { name, .. } => name.clone(),
+ MentionUri::Selection {
+ path, line_range, ..
+ } => selection_name(path, line_range),
+ MentionUri::Fetch { url } => url.to_string(),
}
}
- pub fn to_link(&self) -> String {
- let name = self.name();
- let uri = self.to_uri();
- format!("[{name}]({uri})")
- }
-
- pub fn to_uri(&self) -> String {
+ pub fn icon_path(&self, cx: &mut App) -> SharedString {
match self {
- MentionUri::File(path) => {
- format!("file://{}", path.display())
- }
- MentionUri::Symbol(path, name) => {
- format!("file://{}#{}", path.display(), name)
- }
- MentionUri::Thread(thread) => {
- format!("zed:///agent/thread/{}", thread.0)
- }
- MentionUri::Rule(rule) => {
- format!("zed:///agent/rule/{}", rule)
+ MentionUri::File {
+ abs_path,
+ is_directory,
+ } => {
+ if *is_directory {
+ FileIcons::get_folder_icon(false, cx)
+ .unwrap_or_else(|| IconName::Folder.path().into())
+ } else {
+ FileIcons::get_icon(&abs_path, cx)
+ .unwrap_or_else(|| IconName::File.path().into())
+ }
}
+ MentionUri::Symbol { .. } => IconName::Code.path().into(),
+ MentionUri::Thread { .. } => IconName::Thread.path().into(),
+ MentionUri::TextThread { .. } => IconName::Thread.path().into(),
+ MentionUri::Rule { .. } => IconName::Reader.path().into(),
+ MentionUri::Selection { .. } => IconName::Reader.path().into(),
+ MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
}
}
+
+ pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
+ MentionLink(self)
+ }
+
+ pub fn to_uri(&self) -> Url {
+ match self {
+ MentionUri::File {
+ abs_path,
+ is_directory,
+ } => {
+ let mut url = Url::parse("file:///").unwrap();
+ let mut path = abs_path.to_string_lossy().to_string();
+ if *is_directory && !path.ends_with("/") {
+ path.push_str("/");
+ }
+ url.set_path(&path);
+ url
+ }
+ MentionUri::Symbol {
+ path,
+ name,
+ line_range,
+ } => {
+ let mut url = Url::parse("file:///").unwrap();
+ url.set_path(&path.to_string_lossy());
+ url.query_pairs_mut().append_pair("symbol", name);
+ url.set_fragment(Some(&format!(
+ "L{}:{}",
+ line_range.start + 1,
+ line_range.end + 1
+ )));
+ url
+ }
+ MentionUri::Selection { path, line_range } => {
+ let mut url = Url::parse("file:///").unwrap();
+ url.set_path(&path.to_string_lossy());
+ url.set_fragment(Some(&format!(
+ "L{}:{}",
+ line_range.start + 1,
+ line_range.end + 1
+ )));
+ url
+ }
+ MentionUri::Thread { name, id } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!("/agent/thread/{id}"));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::TextThread { path, name } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy()));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::Rule { name, id } => {
+ let mut url = Url::parse("zed:///").unwrap();
+ url.set_path(&format!("/agent/rule/{id}"));
+ url.query_pairs_mut().append_pair("name", name);
+ url
+ }
+ MentionUri::Fetch { url } => url.clone(),
+ }
+ }
+}
+
+impl FromStr for MentionUri {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> anyhow::Result {
+ Self::parse(s)
+ }
+}
+
+pub struct MentionLink<'a>(&'a MentionUri);
+
+impl fmt::Display for MentionLink<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
+ }
+}
+
+fn single_query_param(url: &Url, name: &'static str) -> Result