Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb928630e | ||
|
|
cd0263bb85 | ||
|
|
6d775db481 | ||
|
|
dab0b3509d | ||
|
|
0f72d7ed52 | ||
|
|
1ceda2babd | ||
|
|
ae0d4f6a0d | ||
|
|
8980526a85 | ||
|
|
b4dc7f8a8a | ||
|
|
b94649ce1e | ||
|
|
948c1f22bb | ||
|
|
b0eac4267d | ||
|
|
8e4555455c | ||
|
|
5b97cd1900 | ||
|
|
ceab139f54 | ||
|
|
4df7f52bf3 | ||
|
|
1e67e30034 | ||
|
|
758c5fb955 | ||
|
|
acb3ecef0c | ||
|
|
ad2bfa3edd | ||
|
|
1d72fa8e9e | ||
|
|
1ce384bbda | ||
|
|
9f302df6d6 | ||
|
|
ebad5ca50e | ||
|
|
b9ff538747 |
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -3,10 +3,9 @@
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acp"
|
||||
name = "acp_thread"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent_servers",
|
||||
"agentic-coding-protocol",
|
||||
"anyhow",
|
||||
"assistant_tool",
|
||||
@@ -21,6 +20,7 @@ dependencies = [
|
||||
"language",
|
||||
"markdown",
|
||||
"project",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
@@ -139,16 +139,29 @@ dependencies = [
|
||||
name = "agent_servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp_thread",
|
||||
"agentic-coding-protocol",
|
||||
"anyhow",
|
||||
"collections",
|
||||
"context_server",
|
||||
"env_logger 0.11.8",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"log",
|
||||
"paths",
|
||||
"project",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"ui",
|
||||
"util",
|
||||
"watch",
|
||||
"which 6.0.3",
|
||||
"workspace-hack",
|
||||
]
|
||||
@@ -176,7 +189,7 @@ dependencies = [
|
||||
name = "agent_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acp",
|
||||
"acp_thread",
|
||||
"agent",
|
||||
"agent_servers",
|
||||
"agent_settings",
|
||||
@@ -3411,12 +3424,14 @@ dependencies = [
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"log",
|
||||
"net",
|
||||
"parking_lot",
|
||||
"postage",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"url",
|
||||
"util",
|
||||
"workspace-hack",
|
||||
@@ -4412,6 +4427,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"rpc",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_json_lenient",
|
||||
@@ -9689,7 +9705,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.95.1"
|
||||
source = "git+https://github.com/zed-industries/lsp-types?rev=6add7052b598ea1f40f7e8913622c3958b009b60#6add7052b598ea1f40f7e8913622c3958b009b60"
|
||||
source = "git+https://github.com/zed-industries/lsp-types?rev=39f629bdd03d59abd786ed9fc27e8bca02c0c0ec#39f629bdd03d59abd786ed9fc27e8bca02c0c0ec"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"serde",
|
||||
@@ -10288,6 +10304,17 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.31",
|
||||
"net",
|
||||
"smol",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
@@ -20171,6 +20198,7 @@ dependencies = [
|
||||
"menu",
|
||||
"migrator",
|
||||
"mimalloc",
|
||||
"nc",
|
||||
"nix 0.29.0",
|
||||
"node_runtime",
|
||||
"notifications",
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -2,7 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/activity_indicator",
|
||||
"crates/acp",
|
||||
"crates/acp_thread",
|
||||
"crates/agent_ui",
|
||||
"crates/agent",
|
||||
"crates/agent_settings",
|
||||
@@ -102,6 +102,7 @@ members = [
|
||||
"crates/migrator",
|
||||
"crates/mistral",
|
||||
"crates/multi_buffer",
|
||||
"crates/nc",
|
||||
"crates/net",
|
||||
"crates/node_runtime",
|
||||
"crates/notifications",
|
||||
@@ -219,7 +220,7 @@ edition = "2024"
|
||||
# Workspace member crates
|
||||
#
|
||||
|
||||
acp = { path = "crates/acp" }
|
||||
acp_thread = { path = "crates/acp_thread" }
|
||||
agent = { path = "crates/agent" }
|
||||
activity_indicator = { path = "crates/activity_indicator" }
|
||||
agent_ui = { path = "crates/agent_ui" }
|
||||
@@ -317,6 +318,7 @@ menu = { path = "crates/menu" }
|
||||
migrator = { path = "crates/migrator" }
|
||||
mistral = { path = "crates/mistral" }
|
||||
multi_buffer = { path = "crates/multi_buffer" }
|
||||
nc = { path = "crates/nc" }
|
||||
net = { path = "crates/net" }
|
||||
node_runtime = { path = "crates/node_runtime" }
|
||||
notifications = { path = "crates/notifications" }
|
||||
@@ -406,7 +408,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agentic-coding-protocol = { version = "0.0.9" }
|
||||
agentic-coding-protocol = "0.0.9"
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -494,7 +496,7 @@ libc = "0.2"
|
||||
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
|
||||
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "6add7052b598ea1f40f7e8913622c3958b009b60" }
|
||||
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "39f629bdd03d59abd786ed9fc27e8bca02c0c0ec" }
|
||||
markup5ever_rcdom = "0.3.0"
|
||||
metal = "0.29"
|
||||
moka = { version = "0.12.10", features = ["sync"] }
|
||||
|
||||
3
assets/icons/ai_claude.svg
Normal file
3
assets/icons/ai_claude.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.8481 26.5925L15.7165 22.1806L15.8481 21.7961L15.7165 21.5836H15.3316L14.0152 21.5027L9.51899 21.3812L5.62025 21.2193L1.84304 21.0169L0.891139 20.8146L0 19.6408L0.0911392 19.0539L0.891139 18.5176L2.03544 18.6188L4.56709 18.7908L8.36456 19.0539L11.119 19.2158L15.2 19.6408H15.8481L15.9392 19.3777L15.7165 19.2158L15.5443 19.0539L11.6152 16.3926L7.36203 13.5796L5.13418 11.9605L3.92911 11.1409L3.32152 10.3719L3.05823 8.69213L4.1519 7.48798L5.62025 7.58917L5.99494 7.69036L7.48354 8.8338L10.6633 11.2927L14.8152 14.3486L15.4228 14.8545L15.6658 14.6825L15.6962 14.5611L15.4228 14.1057L13.1646 10.0278L10.7544 5.87908L9.68101 4.15887L9.39747 3.12674C9.2962 2.70175 9.22532 2.34758 9.22532 1.91247L10.4709 0.222616L11.1595 0L12.8203 0.222616L13.519 0.82975L14.5519 3.18745L16.2228 6.90109L18.8152 11.9504L19.5747 13.448L19.9797 14.8343L20.1316 15.2593H20.3949V15.0164L20.6076 12.173L21.0025 8.68201L21.3873 4.18922L21.519 2.92436L22.1468 1.40653L23.3924 0.586896L24.3646 1.05237L25.1646 2.1958L25.0532 2.93448L24.5772 6.02074L23.6456 10.8576L23.038 14.0956H23.3924L23.7975 13.6909L25.438 11.5153L28.1924 8.07488L29.4076 6.70883L30.8253 5.20111L31.7367 4.48267H33.4582L34.7241 6.36479L34.157 8.30761L32.3848 10.554L30.9165 12.4564L28.8101 15.2897L27.4937 17.5563L27.6152 17.7384L27.9291 17.7081L32.6886 16.6962L35.2608 16.2307L38.3291 15.7045L39.7165 16.3521L39.8684 17.0099L39.3215 18.3557L36.0405 19.1652L32.1924 19.9342L26.4608 21.2902L26.3899 21.3408L26.4709 21.4419L29.0532 21.6848L30.157 21.7455H32.8608L37.8937 22.1199L39.2101 22.9901L40 24.0526L39.8684 24.8621L37.843 25.8943L35.1089 25.2466L28.7291 23.7288L26.5418 23.1824H26.238V23.3645L28.0608 25.1455L31.4025 28.1609L35.5848 32.0465L35.7975 33.0078L35.2608 33.7668L34.6937 33.6858L31.0177 30.9233L29.6 29.6787L26.3899 26.977H26.1772V27.2603L26.9165 28.343L30.8253 34.212L31.0278 36.0132L30.7443 36.6L29.7316 36.9542L28.6177 36.7518L26.3291 33.5441L23.9696 29.9317L22.0658 26.6937L21.8329 26.8252L20.7089 38.9173L20.1823 39.5345L18.9671 40L17.9544 39.231L17.4177 37.9863L17.9544 35.5274L18.6025 32.3198L19.1291 29.7698L19.6051 26.6026L19.8886 25.5502L19.8684 25.4794L19.6354 25.5097L17.2456 28.7883L13.6101 33.6959L10.7342 36.7721L10.0456 37.0453L8.85063 36.428L8.96203 35.3251L9.63038 34.3435L13.6101 29.2841L16.0101 26.1472L17.5595 24.3359L17.5494 24.0729H17.4582L6.88608 30.9335L5.00253 31.1763L4.1924 30.4174L4.29367 29.1728L4.67848 28.768L7.85823 26.5823L7.8481 26.5925Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -269,10 +269,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && acp_thread",
|
||||
"context": "AgentPanel && external_agent_thread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewAcpThread",
|
||||
"ctrl-n": "agent::NewExternalAgentThread",
|
||||
"ctrl-alt-t": "agent::NewThread"
|
||||
}
|
||||
},
|
||||
@@ -1118,7 +1118,9 @@
|
||||
"ctrl-f": "search::FocusSearch",
|
||||
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -310,10 +310,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && acp_thread",
|
||||
"context": "AgentPanel && external_agent_thread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-n": "agent::NewAcpThread",
|
||||
"cmd-n": "agent::NewExternalAgentThread",
|
||||
"cmd-alt-t": "agent::NewThread"
|
||||
}
|
||||
},
|
||||
@@ -1217,7 +1217,9 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,22 +66,46 @@
|
||||
"context": "Editor && mode == full",
|
||||
"bindings": {
|
||||
"ctrl-f12": "outline::Toggle",
|
||||
"alt-7": "outline::Toggle",
|
||||
"ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }],
|
||||
"ctrl-shift-n": "file_finder::Toggle",
|
||||
"ctrl-g": "go_to_line::Toggle",
|
||||
"alt-enter": "editor::ToggleCodeActions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar || ProjectSearchBar",
|
||||
"bindings": {
|
||||
"shift-enter": "search::SelectPreviousMatch",
|
||||
"ctrl-alt-c": "search::ToggleCaseSensitive",
|
||||
"ctrl-alt-e": "search::ToggleSelection",
|
||||
"ctrl-alt-w": "search::ToggleWholeWord",
|
||||
"ctrl-alt-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"ctrl-shift-f12": "workspace::CloseAllDocks",
|
||||
"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-shift-n": "file_finder::Toggle",
|
||||
"ctrl-shift-a": "command_palette::Toggle",
|
||||
"shift shift": "command_palette::Toggle",
|
||||
"ctrl-alt-shift-n": "project_symbols::Toggle",
|
||||
"alt-0": "git_panel::ToggleFocus",
|
||||
"alt-1": "workspace::ToggleLeftDock",
|
||||
"ctrl-e": "tab_switcher::Toggle",
|
||||
"alt-6": "diagnostics::Deploy"
|
||||
"alt-5": "debug_panel::ToggleFocus",
|
||||
"alt-6": "diagnostics::Deploy",
|
||||
"alt-7": "outline_panel::ToggleFocus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace || Editor",
|
||||
"bindings": {
|
||||
"alt-f12": "terminal_panel::ToggleFocus",
|
||||
"ctrl-shift-k": "git::Push"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,10 +119,33 @@
|
||||
"context": "ProjectPanel",
|
||||
"bindings": {
|
||||
"enter": "project_panel::Open",
|
||||
"ctrl-shift-f": "project_panel::NewSearchInDirectory",
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"delete": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"shift-f6": "project_panel::Rename"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Terminal",
|
||||
"bindings": {
|
||||
"ctrl-shift-t": "workspace::NewTerminal",
|
||||
"alt-f12": "workspace::CloseActiveDock",
|
||||
"alt-left": "pane::ActivatePreviousItem",
|
||||
"alt-right": "pane::ActivateNextItem",
|
||||
"ctrl-up": "terminal::ScrollLineUp",
|
||||
"ctrl-down": "terminal::ScrollLineDown",
|
||||
"shift-pageup": "terminal::ScrollPageUp",
|
||||
"shift-pagedown": "terminal::ScrollPageDown"
|
||||
}
|
||||
},
|
||||
{ "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } },
|
||||
{ "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } },
|
||||
{ "context": "DebugPanel", "bindings": { "alt-5": "workspace::CloseActiveDock" } },
|
||||
{ "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } },
|
||||
{ "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } },
|
||||
{
|
||||
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
|
||||
"bindings": { "escape": "editor::ToggleFocus" }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"bindings": {
|
||||
"cmd-{": "pane::ActivatePreviousItem",
|
||||
"cmd-}": "pane::ActivateNextItem",
|
||||
"cmd-0": "git_panel::ToggleFocus", // overrides `cmd-0` zoom reset
|
||||
"ctrl-f2": "debugger::Stop",
|
||||
"f6": "debugger::Pause",
|
||||
"f7": "debugger::StepInto",
|
||||
@@ -63,28 +64,50 @@
|
||||
"context": "Editor && mode == full",
|
||||
"bindings": {
|
||||
"cmd-f12": "outline::Toggle",
|
||||
"cmd-7": "outline::Toggle",
|
||||
"cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }],
|
||||
"cmd-shift-o": "file_finder::Toggle",
|
||||
"cmd-l": "go_to_line::Toggle",
|
||||
"alt-enter": "editor::ToggleCodeActions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "BufferSearchBar > Editor",
|
||||
"context": "BufferSearchBar || ProjectSearchBar",
|
||||
"bindings": {
|
||||
"shift-enter": "search::SelectPreviousMatch"
|
||||
"shift-enter": "search::SelectPreviousMatch",
|
||||
"alt-c": "search::ToggleCaseSensitive",
|
||||
"alt-e": "search::ToggleSelection",
|
||||
"alt-x": "search::ToggleRegex",
|
||||
"alt-w": "search::ToggleWholeWord",
|
||||
"ctrl-alt-c": "search::ToggleCaseSensitive",
|
||||
"ctrl-alt-e": "search::ToggleSelection",
|
||||
"ctrl-alt-w": "search::ToggleWholeWord",
|
||||
"ctrl-alt-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace",
|
||||
"bindings": {
|
||||
"cmd-shift-f12": "workspace::CloseAllDocks",
|
||||
"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-shift-o": "file_finder::Toggle",
|
||||
"cmd-shift-a": "command_palette::Toggle",
|
||||
"shift shift": "command_palette::Toggle",
|
||||
"cmd-alt-o": "project_symbols::Toggle", // JetBrains: Go to Symbol
|
||||
"cmd-o": "project_symbols::Toggle", // JetBrains: Go to Class
|
||||
"cmd-1": "workspace::ToggleLeftDock",
|
||||
"cmd-6": "diagnostics::Deploy"
|
||||
"cmd-1": "project_panel::ToggleFocus",
|
||||
"cmd-5": "debug_panel::ToggleFocus",
|
||||
"cmd-6": "diagnostics::Deploy",
|
||||
"cmd-7": "outline_panel::ToggleFocus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Workspace || Editor",
|
||||
"bindings": {
|
||||
"alt-f12": "terminal_panel::ToggleFocus",
|
||||
"cmd-shift-k": "git::Push"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -98,11 +121,31 @@
|
||||
"context": "ProjectPanel",
|
||||
"bindings": {
|
||||
"enter": "project_panel::Open",
|
||||
"cmd-shift-f": "project_panel::NewSearchInDirectory",
|
||||
"cmd-backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"backspace": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"delete": ["project_panel::Trash", { "skip_prompt": false }],
|
||||
"shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
|
||||
"shift-f6": "project_panel::Rename"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Terminal",
|
||||
"bindings": {
|
||||
"cmd-t": "workspace::NewTerminal",
|
||||
"alt-f12": "workspace::CloseActiveDock",
|
||||
"cmd-up": "terminal::ScrollLineUp",
|
||||
"cmd-down": "terminal::ScrollLineDown",
|
||||
"shift-pageup": "terminal::ScrollPageUp",
|
||||
"shift-pagedown": "terminal::ScrollPageDown"
|
||||
}
|
||||
},
|
||||
{ "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } },
|
||||
{ "context": "DebugPanel", "bindings": { "cmd-5": "workspace::CloseActiveDock" } },
|
||||
{ "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } },
|
||||
{ "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } },
|
||||
{
|
||||
"context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)",
|
||||
"bindings": { "escape": "editor::ToggleFocus" }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -197,6 +197,8 @@
|
||||
// "inline"
|
||||
// 3. Place snippets at the bottom of the completion list:
|
||||
// "bottom"
|
||||
// 4. Do not show snippets in the completion list:
|
||||
// "none"
|
||||
"snippet_sort_order": "inline",
|
||||
// How to highlight the current line in the editor.
|
||||
//
|
||||
@@ -817,7 +819,7 @@
|
||||
"edit_file": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"move_path": true,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
@@ -837,7 +839,7 @@
|
||||
"diagnostics": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
"read_file": true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "acp"
|
||||
name = "acp_thread"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
@@ -9,15 +9,13 @@ license = "GPL-3.0-or-later"
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/acp.rs"
|
||||
path = "src/acp_thread.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = ["gpui/test-support", "project/test-support"]
|
||||
gemini = []
|
||||
|
||||
[dependencies]
|
||||
agent_servers.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
assistant_tool.workspace = true
|
||||
@@ -29,6 +27,8 @@ itertools.workspace = true
|
||||
language.workspace = true
|
||||
markdown.workspace = true
|
||||
project.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
ui.workspace = true
|
||||
@@ -41,7 +41,6 @@ env_logger.workspace = true
|
||||
gpui = { workspace = true, "features" = ["test-support"] }
|
||||
indoc.workspace = true
|
||||
project = { workspace = true, "features" = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
util.workspace = true
|
||||
settings.workspace = true
|
||||
@@ -1,7 +1,12 @@
|
||||
mod connection;
|
||||
pub use connection::*;
|
||||
|
||||
pub use acp::ToolCallId;
|
||||
use agent_servers::AgentServer;
|
||||
use agentic_coding_protocol::{self as acp, ToolCallLocation, UserMessageChunk};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use agentic_coding_protocol::{
|
||||
self as acp, AgentRequest, ProtocolVersion, ToolCallConfirmationOutcome, ToolCallLocation,
|
||||
UserMessageChunk,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use assistant_tool::ActionLog;
|
||||
use buffer_diff::BufferDiff;
|
||||
use editor::{Bias, MultiBuffer, PathKey};
|
||||
@@ -97,7 +102,7 @@ pub struct AssistantMessage {
|
||||
}
|
||||
|
||||
impl AssistantMessage {
|
||||
fn to_markdown(&self, cx: &App) -> String {
|
||||
pub fn to_markdown(&self, cx: &App) -> String {
|
||||
format!(
|
||||
"## Assistant\n\n{}\n\n",
|
||||
self.chunks
|
||||
@@ -455,9 +460,8 @@ pub struct AcpThread {
|
||||
action_log: Entity<ActionLog>,
|
||||
shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
|
||||
send_task: Option<Task<()>>,
|
||||
connection: Arc<acp::AgentConnection>,
|
||||
connection: Arc<dyn AgentConnection>,
|
||||
child_status: Option<Task<Result<()>>>,
|
||||
_io_task: Task<()>,
|
||||
}
|
||||
|
||||
pub enum AcpThreadEvent {
|
||||
@@ -476,7 +480,11 @@ pub enum ThreadStatus {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LoadError {
|
||||
Unsupported { current_version: SharedString },
|
||||
Unsupported {
|
||||
error_message: SharedString,
|
||||
upgrade_message: SharedString,
|
||||
upgrade_command: String,
|
||||
},
|
||||
Exited(i32),
|
||||
Other(SharedString),
|
||||
}
|
||||
@@ -484,13 +492,7 @@ pub enum LoadError {
|
||||
impl Display for LoadError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LoadError::Unsupported { current_version } => {
|
||||
write!(
|
||||
f,
|
||||
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
|
||||
current_version
|
||||
)
|
||||
}
|
||||
LoadError::Unsupported { error_message, .. } => write!(f, "{}", error_message),
|
||||
LoadError::Exited(status) => write!(f, "Server exited with status {}", status),
|
||||
LoadError::Other(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
@@ -500,75 +502,38 @@ impl Display for LoadError {
|
||||
impl Error for LoadError {}
|
||||
|
||||
impl AcpThread {
|
||||
pub async fn spawn(
|
||||
server: impl AgentServer + 'static,
|
||||
root_dir: &Path,
|
||||
pub fn new(
|
||||
connection: impl AgentConnection + 'static,
|
||||
title: SharedString,
|
||||
child_status: Option<Task<Result<()>>>,
|
||||
project: Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Entity<Self>> {
|
||||
let command = match server.command(&project, cx).await {
|
||||
Ok(command) => command,
|
||||
Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))),
|
||||
};
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
Self {
|
||||
action_log,
|
||||
shared_buffers: Default::default(),
|
||||
entries: Default::default(),
|
||||
title,
|
||||
project,
|
||||
send_task: None,
|
||||
connection: Arc::new(connection),
|
||||
child_status,
|
||||
}
|
||||
}
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
cx.new(|cx| {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| foreground_executor.spawn(fut).detach(),
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
});
|
||||
|
||||
let child_status = cx.background_spawn(async move {
|
||||
match child.status().await {
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
Ok(result) if result.success() => Ok(()),
|
||||
Ok(result) => {
|
||||
if let Some(version) = server.version(&command).await.log_err()
|
||||
&& !version.supported
|
||||
{
|
||||
Err(anyhow!(LoadError::Unsupported {
|
||||
current_version: version.current_version
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
|
||||
Self {
|
||||
action_log,
|
||||
shared_buffers: Default::default(),
|
||||
entries: Default::default(),
|
||||
title: "ACP Thread".into(),
|
||||
project,
|
||||
send_task: None,
|
||||
connection: Arc::new(connection),
|
||||
child_status: Some(child_status),
|
||||
_io_task: io_task,
|
||||
}
|
||||
})
|
||||
/// Send a request to the agent and wait for a response.
|
||||
pub fn request<R: AgentRequest + 'static>(
|
||||
&self,
|
||||
params: R,
|
||||
) -> impl use<R> + Future<Output = Result<R::Response>> {
|
||||
let params = params.into_any();
|
||||
let result = self.connection.request_any(params);
|
||||
async move {
|
||||
let result = result.await?;
|
||||
Ok(R::response_from_any(result)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action_log(&self) -> &Entity<ActionLog> {
|
||||
@@ -579,45 +544,6 @@ impl AcpThread {
|
||||
&self.project
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn fake(
|
||||
stdin: async_pipe::PipeWriter,
|
||||
stdout: async_pipe::PipeReader,
|
||||
project: Entity<Project>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| {
|
||||
foreground_executor.spawn(fut).detach();
|
||||
},
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn({
|
||||
async move {
|
||||
io_fut.await.log_err();
|
||||
}
|
||||
});
|
||||
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
||||
|
||||
Self {
|
||||
action_log,
|
||||
shared_buffers: Default::default(),
|
||||
entries: Default::default(),
|
||||
title: "ACP Thread".into(),
|
||||
project,
|
||||
send_task: None,
|
||||
connection: Arc::new(connection),
|
||||
child_status: None,
|
||||
_io_task: io_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> SharedString {
|
||||
self.title.clone()
|
||||
}
|
||||
@@ -711,7 +637,7 @@ impl AcpThread {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_tool_call(
|
||||
pub fn request_new_tool_call(
|
||||
&mut self,
|
||||
tool_call: acp::RequestToolCallConfirmationParams,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -731,6 +657,30 @@ impl AcpThread {
|
||||
ToolCallRequest { id, outcome: rx }
|
||||
}
|
||||
|
||||
pub fn request_tool_call_confirmation(
|
||||
&mut self,
|
||||
tool_call_id: ToolCallId,
|
||||
confirmation: acp::ToolCallConfirmation,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<ToolCallRequest> {
|
||||
let project = self.project.read(cx).languages().clone();
|
||||
let Some((_, call)) = self.tool_call_mut(tool_call_id) else {
|
||||
anyhow::bail!("Tool call not found");
|
||||
};
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
call.status = ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::from_acp(confirmation, project, cx),
|
||||
respond_tx: tx,
|
||||
};
|
||||
|
||||
Ok(ToolCallRequest {
|
||||
id: tool_call_id,
|
||||
outcome: rx,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push_tool_call(
|
||||
&mut self,
|
||||
request: acp::PushToolCallParams,
|
||||
@@ -912,19 +862,17 @@ impl AcpThread {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn initialize(
|
||||
&self,
|
||||
) -> impl use<> + Future<Output = Result<acp::InitializeResponse, acp::Error>> {
|
||||
let connection = self.connection.clone();
|
||||
async move { connection.initialize().await }
|
||||
pub fn initialize(&self) -> impl use<> + Future<Output = Result<acp::InitializeResponse>> {
|
||||
self.request(acp::InitializeParams {
|
||||
protocol_version: ProtocolVersion::latest(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authenticate(&self) -> impl use<> + Future<Output = Result<(), acp::Error>> {
|
||||
let connection = self.connection.clone();
|
||||
async move { connection.request(acp::AuthenticateParams).await }
|
||||
pub fn authenticate(&self) -> impl use<> + Future<Output = Result<()>> {
|
||||
self.request(acp::AuthenticateParams)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn send_raw(
|
||||
&mut self,
|
||||
message: &str,
|
||||
@@ -945,7 +893,6 @@ impl AcpThread {
|
||||
message: acp::SendUserMessageParams,
|
||||
cx: &mut Context<Self>,
|
||||
) -> BoxFuture<'static, Result<(), acp::Error>> {
|
||||
let agent = self.connection.clone();
|
||||
self.push_entry(
|
||||
AgentThreadEntry::UserMessage(UserMessage::from_acp(
|
||||
&message,
|
||||
@@ -959,11 +906,16 @@ impl AcpThread {
|
||||
let cancel = self.cancel(cx);
|
||||
|
||||
self.send_task = Some(cx.spawn(async move |this, cx| {
|
||||
cancel.await.log_err();
|
||||
async {
|
||||
cancel.await.log_err();
|
||||
|
||||
let result = agent.request(message).await;
|
||||
tx.send(result).log_err();
|
||||
this.update(cx, |this, _cx| this.send_task.take()).log_err();
|
||||
let result = this.update(cx, |this, _| this.request(message))?.await;
|
||||
tx.send(result).log_err();
|
||||
this.update(cx, |this, _cx| this.send_task.take())?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await
|
||||
.log_err();
|
||||
}));
|
||||
|
||||
async move {
|
||||
@@ -976,12 +928,10 @@ impl AcpThread {
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<Result<(), acp::Error>> {
|
||||
let agent = self.connection.clone();
|
||||
|
||||
if self.send_task.take().is_some() {
|
||||
let request = self.request(acp::CancelSendMessageParams);
|
||||
cx.spawn(async move |this, cx| {
|
||||
agent.request(acp::CancelSendMessageParams).await?;
|
||||
|
||||
request.await?;
|
||||
this.update(cx, |this, _cx| {
|
||||
for entry in this.entries.iter_mut() {
|
||||
if let AgentThreadEntry::ToolCall(call) = entry {
|
||||
@@ -1019,6 +969,7 @@ impl AcpThread {
|
||||
pub fn read_text_file(
|
||||
&self,
|
||||
request: acp::ReadTextFileParams,
|
||||
reuse_shared_snapshot: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<String>> {
|
||||
let project = self.project.clone();
|
||||
@@ -1032,28 +983,60 @@ impl AcpThread {
|
||||
});
|
||||
let buffer = load??.await?;
|
||||
|
||||
action_log.update(cx, |action_log, cx| {
|
||||
action_log.buffer_read(buffer.clone(), cx);
|
||||
})?;
|
||||
project.update(cx, |project, cx| {
|
||||
let position = buffer
|
||||
.read(cx)
|
||||
.snapshot()
|
||||
.anchor_before(Point::new(request.line.unwrap_or_default(), 0));
|
||||
project.set_agent_location(
|
||||
Some(AgentLocation {
|
||||
buffer: buffer.downgrade(),
|
||||
position,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
|
||||
let snapshot = if reuse_shared_snapshot {
|
||||
this.read_with(cx, |this, _| {
|
||||
this.shared_buffers.get(&buffer.clone()).cloned()
|
||||
})
|
||||
.log_err()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let snapshot = if let Some(snapshot) = snapshot {
|
||||
snapshot
|
||||
} else {
|
||||
action_log.update(cx, |action_log, cx| {
|
||||
action_log.buffer_read(buffer.clone(), cx);
|
||||
})?;
|
||||
project.update(cx, |project, cx| {
|
||||
let position = buffer
|
||||
.read(cx)
|
||||
.snapshot()
|
||||
.anchor_before(Point::new(request.line.unwrap_or_default(), 0));
|
||||
project.set_agent_location(
|
||||
Some(AgentLocation {
|
||||
buffer: buffer.downgrade(),
|
||||
position,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
buffer.update(cx, |buffer, _| buffer.snapshot())?
|
||||
};
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
let text = snapshot.text();
|
||||
this.shared_buffers.insert(buffer.clone(), snapshot);
|
||||
text
|
||||
})
|
||||
if request.line.is_none() && request.limit.is_none() {
|
||||
return Ok(text);
|
||||
}
|
||||
let limit = request.limit.unwrap_or(u32::MAX) as usize;
|
||||
let Some(line) = request.line else {
|
||||
return Ok(text.lines().take(limit).collect::<String>());
|
||||
};
|
||||
|
||||
let count = text.lines().count();
|
||||
if count < line as usize {
|
||||
anyhow::bail!("There are only {} lines", count);
|
||||
}
|
||||
Ok(text
|
||||
.lines()
|
||||
.skip(line as usize + 1)
|
||||
.take(limit)
|
||||
.collect::<String>())
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,16 +1117,49 @@ impl AcpThread {
|
||||
}
|
||||
}
|
||||
|
||||
struct AcpClientDelegate {
|
||||
#[derive(Clone)]
|
||||
pub struct AcpClientDelegate {
|
||||
thread: WeakEntity<AcpThread>,
|
||||
cx: AsyncApp,
|
||||
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
|
||||
}
|
||||
|
||||
impl AcpClientDelegate {
|
||||
fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
|
||||
pub fn new(thread: WeakEntity<AcpThread>, cx: AsyncApp) -> Self {
|
||||
Self { thread, cx }
|
||||
}
|
||||
|
||||
pub async fn request_existing_tool_call_confirmation(
|
||||
&self,
|
||||
tool_call_id: ToolCallId,
|
||||
confirmation: acp::ToolCallConfirmation,
|
||||
) -> Result<ToolCallConfirmationOutcome> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let ToolCallRequest { outcome, .. } = cx
|
||||
.update(|cx| {
|
||||
self.thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_confirmation(tool_call_id, confirmation, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")??;
|
||||
|
||||
Ok(outcome.await?)
|
||||
}
|
||||
|
||||
pub async fn read_text_file_reusing_snapshot(
|
||||
&self,
|
||||
request: acp::ReadTextFileParams,
|
||||
) -> Result<acp::ReadTextFileResponse, acp::Error> {
|
||||
let content = self
|
||||
.cx
|
||||
.update(|cx| {
|
||||
self.thread
|
||||
.update(cx, |thread, cx| thread.read_text_file(request, true, cx))
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
.await?;
|
||||
Ok(acp::ReadTextFileResponse { content })
|
||||
}
|
||||
}
|
||||
|
||||
impl acp::Client for AcpClientDelegate {
|
||||
@@ -1172,7 +1188,7 @@ impl acp::Client for AcpClientDelegate {
|
||||
let ToolCallRequest { id, outcome } = cx
|
||||
.update(|cx| {
|
||||
self.thread
|
||||
.update(cx, |thread, cx| thread.request_tool_call(request, cx))
|
||||
.update(cx, |thread, cx| thread.request_new_tool_call(request, cx))
|
||||
})?
|
||||
.context("Failed to update thread")?;
|
||||
|
||||
@@ -1218,7 +1234,7 @@ impl acp::Client for AcpClientDelegate {
|
||||
.cx
|
||||
.update(|cx| {
|
||||
self.thread
|
||||
.update(cx, |thread, cx| thread.read_text_file(request, cx))
|
||||
.update(cx, |thread, cx| thread.read_text_file(request, false, cx))
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
.await?;
|
||||
@@ -1260,7 +1276,7 @@ pub struct ToolCallRequest {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agent_servers::{AgentServerCommand, AgentServerVersion};
|
||||
use anyhow::anyhow;
|
||||
use async_pipe::{PipeReader, PipeWriter};
|
||||
use futures::{channel::mpsc, future::LocalBoxFuture, select};
|
||||
use gpui::{AsyncApp, TestAppContext};
|
||||
@@ -1269,7 +1285,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use smol::{future::BoxedLocal, stream::StreamExt as _};
|
||||
use std::{cell::RefCell, env, path::Path, rc::Rc, time::Duration};
|
||||
use std::{cell::RefCell, rc::Rc, time::Duration};
|
||||
use util::path;
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
@@ -1515,265 +1531,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(not(feature = "gemini"), ignore)]
|
||||
async fn test_gemini_basic(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.entries.len(), 2);
|
||||
assert!(matches!(
|
||||
thread.entries[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
thread.entries[1],
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(not(feature = "gemini"), ignore)]
|
||||
async fn test_gemini_path_mentions(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.executor().allow_parking();
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tempdir.path().join("foo.rs"),
|
||||
indoc! {"
|
||||
fn main() {
|
||||
println!(\"Hello, world!\");
|
||||
}
|
||||
"},
|
||||
)
|
||||
.expect("failed to write file");
|
||||
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
|
||||
let thread = gemini_acp_thread(project.clone(), tempdir.path(), cx).await;
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(
|
||||
acp::SendUserMessageParams {
|
||||
chunks: vec![
|
||||
acp::UserMessageChunk::Text {
|
||||
text: "Read the file ".into(),
|
||||
},
|
||||
acp::UserMessageChunk::Path {
|
||||
path: Path::new("foo.rs").into(),
|
||||
},
|
||||
acp::UserMessageChunk::Text {
|
||||
text: " and tell me what the content of the println! is".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries.len(), 3);
|
||||
assert!(matches!(
|
||||
thread.entries[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(thread.entries[1], AgentThreadEntry::ToolCall(_)));
|
||||
let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries[2] else {
|
||||
panic!("Expected AssistantMessage")
|
||||
};
|
||||
assert!(
|
||||
assistant_message.to_markdown(cx).contains("Hello, world!"),
|
||||
"unexpected assistant message: {:?}",
|
||||
assistant_message.to_markdown(cx)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(not(feature = "gemini"), ignore)]
|
||||
async fn test_gemini_tool_call(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/private/tmp"),
|
||||
json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(
|
||||
"Read the '/private/tmp/foo' file and tell me what you see.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
assert!(matches!(
|
||||
&thread.entries()[2],
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
thread.entries[3],
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(not(feature = "gemini"), ignore)]
|
||||
async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
|
||||
});
|
||||
|
||||
run_until_first_tool_call(&thread, cx).await;
|
||||
|
||||
let tool_call_id = thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[2]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
assert_eq!(root_command, "echo");
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
|
||||
|
||||
assert!(matches!(
|
||||
&thread.entries()[2],
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
full_turn.await.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
content: Some(ToolCallContent::Markdown { markdown }),
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
}) = &thread.entries()[2]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
markdown.read_with(cx, |md, _cx| {
|
||||
assert!(
|
||||
md.source().contains("Hello, world!"),
|
||||
r#"Expected '{}' to contain "Hello, world!""#,
|
||||
md.source()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg_attr(not(feature = "gemini"), ignore)]
|
||||
async fn test_gemini_cancel(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = gemini_acp_thread(project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
|
||||
});
|
||||
|
||||
let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await;
|
||||
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!("{:?}", thread.entries()[1]);
|
||||
};
|
||||
|
||||
assert_eq!(root_command, "echo");
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.cancel(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
full_turn.await.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Canceled,
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(matches!(
|
||||
&thread.entries().last().unwrap(),
|
||||
AgentThreadEntry::AssistantMessage(..),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_until_first_tool_call(
|
||||
thread: &Entity<AcpThread>,
|
||||
cx: &mut TestAppContext,
|
||||
@@ -1801,66 +1558,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn gemini_acp_thread(
|
||||
project: Entity<Project>,
|
||||
current_dir: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<AcpThread> {
|
||||
struct DevGemini;
|
||||
|
||||
impl agent_servers::AgentServer for DevGemini {
|
||||
async fn command(
|
||||
&self,
|
||||
_project: &Entity<Project>,
|
||||
_cx: &mut AsyncApp,
|
||||
) -> Result<agent_servers::AgentServerCommand> {
|
||||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../gemini-cli/packages/cli")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path: "node".into(),
|
||||
args: vec![cli_path, "--experimental-acp".into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(
|
||||
&self,
|
||||
_command: &agent_servers::AgentServerCommand,
|
||||
) -> Result<AgentServerVersion> {
|
||||
Ok(AgentServerVersion {
|
||||
current_version: "0.1.0".into(),
|
||||
supported: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let thread = AcpThread::spawn(DevGemini, current_dir.as_ref(), project, &mut cx.to_async())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread
|
||||
.update(cx, |thread, _| thread.initialize())
|
||||
.await
|
||||
.unwrap();
|
||||
thread
|
||||
}
|
||||
|
||||
pub fn fake_acp_thread(
|
||||
project: Entity<Project>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<AcpThread>, Entity<FakeAcpServer>) {
|
||||
let (stdin_tx, stdin_rx) = async_pipe::pipe();
|
||||
let (stdout_tx, stdout_rx) = async_pipe::pipe();
|
||||
let thread = cx.update(|cx| cx.new(|cx| AcpThread::fake(stdin_tx, stdout_rx, project, cx)));
|
||||
|
||||
let thread = cx.new(|cx| {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
|
||||
stdin_tx,
|
||||
stdout_rx,
|
||||
move |fut| {
|
||||
foreground_executor.spawn(fut).detach();
|
||||
},
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn({
|
||||
async move {
|
||||
io_fut.await.log_err();
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
AcpThread::new(connection, "Test".into(), Some(io_task), project, cx)
|
||||
});
|
||||
let agent = cx.update(|cx| cx.new(|cx| FakeAcpServer::new(stdin_rx, stdout_tx, cx)));
|
||||
(thread, agent)
|
||||
}
|
||||
|
||||
pub struct FakeAcpServer {
|
||||
connection: acp::ClientConnection,
|
||||
|
||||
_io_task: Task<()>,
|
||||
on_user_message: Option<
|
||||
Rc<
|
||||
20
crates/acp_thread/src/connection.rs
Normal file
20
crates/acp_thread/src/connection.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use agentic_coding_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use futures::future::{FutureExt as _, LocalBoxFuture};
|
||||
|
||||
pub trait AgentConnection {
|
||||
fn request_any(
|
||||
&self,
|
||||
params: acp::AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>>;
|
||||
}
|
||||
|
||||
impl AgentConnection for acp::AgentConnection {
|
||||
fn request_any(
|
||||
&self,
|
||||
params: acp::AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
|
||||
let task = self.request_any(params);
|
||||
async move { Ok(task.await?) }.boxed_local()
|
||||
}
|
||||
}
|
||||
@@ -3583,6 +3583,7 @@ fn main() {{
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore] // turn this test on when project_notifications tool is re-enabled
|
||||
async fn test_stale_buffer_notification(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[features]
|
||||
test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"]
|
||||
e2e = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -13,15 +17,32 @@ path = "src/agent_servers.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
acp_thread.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
collections.workspace = true
|
||||
context_server.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
paths.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
watch.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
language.workspace = true
|
||||
indoc.workspace = true
|
||||
acp_thread = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -1,30 +1,82 @@
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod settings;
|
||||
mod stdio_agent_server;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
|
||||
pub use claude::*;
|
||||
pub use codex::*;
|
||||
pub use gemini::*;
|
||||
pub use settings::*;
|
||||
pub use stdio_agent_server::*;
|
||||
|
||||
use acp_thread::AcpThread;
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use gpui::{App, AsyncApp, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use gpui::{App, AsyncApp, Entity, SharedString};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources, SettingsStore};
|
||||
use util::{ResultExt, paths};
|
||||
use util::ResultExt as _;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AllAgentServersSettings::register(cx);
|
||||
settings::init(cx);
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AllAgentServersSettings {
|
||||
gemini: Option<AgentServerSettings>,
|
||||
pub trait AgentServer: Send {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> &'static str;
|
||||
fn empty_state_headline(&self) -> &'static str;
|
||||
fn empty_state_message(&self) -> &'static str;
|
||||
fn supports_always_allow(&self) -> bool;
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>>;
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AgentServerSettings {
|
||||
#[serde(flatten)]
|
||||
command: AgentServerCommand,
|
||||
impl std::fmt::Debug for AgentServerCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let filtered_env = self.env.as_ref().map(|env| {
|
||||
env.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
if util::redact::should_redact(k) {
|
||||
"[REDACTED]"
|
||||
} else {
|
||||
v
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
f.debug_struct("AgentServerCommand")
|
||||
.field("path", &self.path)
|
||||
.field("args", &self.args)
|
||||
.field("env", &filtered_env)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AgentServerVersion {
|
||||
Supported,
|
||||
Unsupported {
|
||||
error_message: SharedString,
|
||||
upgrade_message: SharedString,
|
||||
upgrade_command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
|
||||
@@ -36,105 +88,34 @@ pub struct AgentServerCommand {
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
pub struct Gemini;
|
||||
|
||||
pub struct AgentServerVersion {
|
||||
pub current_version: SharedString,
|
||||
pub supported: bool,
|
||||
}
|
||||
|
||||
pub trait AgentServer: Send {
|
||||
fn command(
|
||||
&self,
|
||||
impl AgentServerCommand {
|
||||
pub(crate) async fn resolve(
|
||||
path_bin_name: &'static str,
|
||||
extra_args: &[&'static str],
|
||||
settings: Option<AgentServerSettings>,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> impl Future<Output = Result<AgentServerCommand>>;
|
||||
|
||||
fn version(
|
||||
&self,
|
||||
command: &AgentServerCommand,
|
||||
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
|
||||
}
|
||||
|
||||
const GEMINI_ACP_ARG: &str = "--experimental-acp";
|
||||
|
||||
impl AgentServer for Gemini {
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let custom_command = cx.read_global(|settings: &SettingsStore, _| {
|
||||
let settings = settings.get::<AllAgentServersSettings>(None);
|
||||
settings
|
||||
.gemini
|
||||
.as_ref()
|
||||
.map(|gemini_settings| AgentServerCommand {
|
||||
path: gemini_settings.command.path.clone(),
|
||||
args: gemini_settings
|
||||
.command
|
||||
.args
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(std::iter::once(GEMINI_ACP_ARG.into()))
|
||||
.collect(),
|
||||
env: gemini_settings.command.env.clone(),
|
||||
})
|
||||
})?;
|
||||
|
||||
if let Some(custom_command) = custom_command {
|
||||
return Ok(custom_command);
|
||||
}
|
||||
|
||||
if let Some(path) = find_bin_in_path("gemini", project, cx).await {
|
||||
return Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![GEMINI_ACP_ARG.into()],
|
||||
env: None,
|
||||
) -> Option<Self> {
|
||||
if let Some(agent_settings) = settings {
|
||||
return Some(Self {
|
||||
path: agent_settings.command.path,
|
||||
args: agent_settings
|
||||
.command
|
||||
.args
|
||||
.into_iter()
|
||||
.chain(extra_args.iter().map(|arg| arg.to_string()))
|
||||
.collect(),
|
||||
env: agent_settings.command.env,
|
||||
});
|
||||
} else {
|
||||
find_bin_in_path(path_bin_name, project, cx)
|
||||
.await
|
||||
.map(|path| Self {
|
||||
path,
|
||||
args: extra_args.iter().map(|arg| arg.to_string()).collect(),
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("gemini not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("gemini");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/gemini");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![GEMINI_ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?.into();
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(GEMINI_ACP_ARG);
|
||||
|
||||
Ok(AgentServerVersion {
|
||||
current_version,
|
||||
supported,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,48 +165,3 @@ async fn find_bin_in_path(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentServerCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let filtered_env = self.env.as_ref().map(|env| {
|
||||
env.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
k,
|
||||
if util::redact::should_redact(k) {
|
||||
"[REDACTED]"
|
||||
} else {
|
||||
v
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
f.debug_struct("AgentServerCommand")
|
||||
.field("path", &self.path)
|
||||
.field("args", &self.args)
|
||||
.field("env", &filtered_env)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Settings for AllAgentServersSettings {
|
||||
const KEY: Option<&'static str> = Some("agent_servers");
|
||||
|
||||
type FileContent = Self;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for value in sources.defaults_and_customizations() {
|
||||
if value.gemini.is_some() {
|
||||
settings.gemini = value.gemini.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
701
crates/agent_servers/src/claude.rs
Normal file
701
crates/agent_servers/src/claude.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
mod mcp_server;
|
||||
mod tools;
|
||||
|
||||
use collections::HashMap;
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Display;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use agentic_coding_protocol::{
|
||||
self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion,
|
||||
StreamAssistantMessageChunkParams, ToolCallContent, UpdateToolCallParams,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::LocalBoxFuture;
|
||||
use futures::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use futures::{
|
||||
AsyncRead, AsyncWrite, FutureExt, StreamExt,
|
||||
channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
io::BufReader,
|
||||
select_biased,
|
||||
};
|
||||
use gpui::{App, AppContext, Entity, Task};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::claude::mcp_server::ClaudeMcpServer;
|
||||
use crate::claude::tools::ClaudeTool;
|
||||
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
|
||||
use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeCode;
|
||||
|
||||
impl AgentServer for ClaudeCode {
|
||||
fn name(&self) -> &'static str {
|
||||
"Claude Code"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiClaude
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let project = project.clone();
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let title = self.name().into();
|
||||
cx.spawn(async move |cx| {
|
||||
let (mut delegate_tx, delegate_rx) = watch::channel(None);
|
||||
let tool_id_map = Rc::new(RefCell::new(HashMap::default()));
|
||||
|
||||
let permission_mcp_server =
|
||||
ClaudeMcpServer::new(delegate_rx, tool_id_map.clone(), cx).await?;
|
||||
|
||||
let mut mcp_servers = HashMap::default();
|
||||
mcp_servers.insert(
|
||||
mcp_server::SERVER_NAME.to_string(),
|
||||
permission_mcp_server.server_config()?,
|
||||
);
|
||||
let mcp_config = McpConfig { mcp_servers };
|
||||
|
||||
let mcp_config_file = tempfile::NamedTempFile::new()?;
|
||||
let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
|
||||
|
||||
let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
|
||||
mcp_config_file
|
||||
.write_all(serde_json::to_string(&mcp_config)?.as_bytes())
|
||||
.await?;
|
||||
mcp_config_file.flush().await?;
|
||||
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).claude.clone()
|
||||
})?;
|
||||
|
||||
let Some(command) =
|
||||
AgentServerCommand::resolve("claude", &[], settings, &project, cx).await
|
||||
else {
|
||||
anyhow::bail!("Failed to find claude binary");
|
||||
};
|
||||
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(
|
||||
[
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--print",
|
||||
"--verbose",
|
||||
"--mcp-config",
|
||||
mcp_config_path.to_string_lossy().as_ref(),
|
||||
"--permission-prompt-tool",
|
||||
&format!(
|
||||
"mcp__{}__{}",
|
||||
mcp_server::SERVER_NAME,
|
||||
mcp_server::PERMISSION_TOOL
|
||||
),
|
||||
"--allowedTools",
|
||||
"mcp__zed__Read,mcp__zed__Edit",
|
||||
"--disallowedTools",
|
||||
"Read,Edit",
|
||||
]
|
||||
.into_iter()
|
||||
.chain(command.args.iter().map(|arg| arg.as_str())),
|
||||
)
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
|
||||
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
|
||||
|
||||
let io_task =
|
||||
ClaudeAgentConnection::handle_io(outgoing_rx, incoming_message_tx, stdin, stdout);
|
||||
cx.background_spawn(async move {
|
||||
io_task.await.log_err();
|
||||
drop(mcp_config_path);
|
||||
drop(child);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| {
|
||||
let end_turn_tx = Rc::new(RefCell::new(None));
|
||||
let delegate = AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async());
|
||||
delegate_tx.send(Some(delegate.clone())).log_err();
|
||||
|
||||
let handler_task = cx.foreground_executor().spawn({
|
||||
let end_turn_tx = end_turn_tx.clone();
|
||||
let tool_id_map = tool_id_map.clone();
|
||||
async move {
|
||||
while let Some(message) = incoming_message_rx.next().await {
|
||||
ClaudeAgentConnection::handle_message(
|
||||
delegate.clone(),
|
||||
message,
|
||||
end_turn_tx.clone(),
|
||||
tool_id_map.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut connection = ClaudeAgentConnection {
|
||||
outgoing_tx,
|
||||
end_turn_tx,
|
||||
_handler_task: handler_task,
|
||||
_mcp_server: None,
|
||||
};
|
||||
|
||||
connection._mcp_server = Some(permission_mcp_server);
|
||||
acp_thread::AcpThread::new(connection, title, None, project.clone(), cx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConnection for ClaudeAgentConnection {
|
||||
/// Send a request to the agent and wait for a response.
|
||||
fn request_any(
|
||||
&self,
|
||||
params: AnyAgentRequest,
|
||||
) -> LocalBoxFuture<'static, Result<acp::AnyAgentResult>> {
|
||||
let end_turn_tx = self.end_turn_tx.clone();
|
||||
let outgoing_tx = self.outgoing_tx.clone();
|
||||
async move {
|
||||
match params {
|
||||
// todo: consider sending an empty request so we get the init response?
|
||||
AnyAgentRequest::InitializeParams(_) => Ok(AnyAgentResult::InitializeResponse(
|
||||
acp::InitializeResponse {
|
||||
is_authenticated: true,
|
||||
protocol_version: ProtocolVersion::latest(),
|
||||
},
|
||||
)),
|
||||
AnyAgentRequest::AuthenticateParams(_) => {
|
||||
Err(anyhow!("Authentication not supported"))
|
||||
}
|
||||
AnyAgentRequest::SendUserMessageParams(message) => {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
end_turn_tx.borrow_mut().replace(tx);
|
||||
let mut content = String::new();
|
||||
for chunk in message.chunks {
|
||||
match chunk {
|
||||
agentic_coding_protocol::UserMessageChunk::Text { text } => {
|
||||
content.push_str(&text)
|
||||
}
|
||||
agentic_coding_protocol::UserMessageChunk::Path { path } => {
|
||||
content.push_str(&format!("@{path:?}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoing_tx.unbounded_send(SdkMessage::User {
|
||||
message: Message {
|
||||
role: Role::User,
|
||||
content: Content::UntaggedText(content),
|
||||
id: None,
|
||||
model: None,
|
||||
stop_reason: None,
|
||||
stop_sequence: None,
|
||||
usage: None,
|
||||
},
|
||||
session_id: None,
|
||||
})?;
|
||||
rx.await??;
|
||||
Ok(AnyAgentResult::SendUserMessageResponse(
|
||||
acp::SendUserMessageResponse,
|
||||
))
|
||||
}
|
||||
AnyAgentRequest::CancelSendMessageParams(_) => Ok(
|
||||
AnyAgentResult::CancelSendMessageResponse(acp::CancelSendMessageResponse),
|
||||
),
|
||||
}
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}
|
||||
|
||||
struct ClaudeAgentConnection {
|
||||
outgoing_tx: UnboundedSender<SdkMessage>,
|
||||
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
|
||||
_mcp_server: Option<ClaudeMcpServer>,
|
||||
_handler_task: Task<()>,
|
||||
}
|
||||
|
||||
impl ClaudeAgentConnection {
|
||||
async fn handle_message(
|
||||
delegate: AcpClientDelegate,
|
||||
message: SdkMessage,
|
||||
end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
) {
|
||||
match message {
|
||||
SdkMessage::Assistant { message, .. } | SdkMessage::User { message, .. } => {
|
||||
for chunk in message.content.chunks() {
|
||||
match chunk {
|
||||
ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
|
||||
delegate
|
||||
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
|
||||
chunk: acp::AssistantMessageChunk::Text { text },
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
ContentChunk::ToolUse { id, name, input } => {
|
||||
if let Some(resp) = delegate
|
||||
.push_tool_call(ClaudeTool::infer(&name, input).as_acp())
|
||||
.await
|
||||
.log_err()
|
||||
{
|
||||
tool_id_map.borrow_mut().insert(id, resp.id);
|
||||
}
|
||||
}
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
let id = tool_id_map.borrow_mut().remove(&tool_use_id);
|
||||
if let Some(id) = id {
|
||||
delegate
|
||||
.update_tool_call(UpdateToolCallParams {
|
||||
tool_call_id: id,
|
||||
status: acp::ToolCallStatus::Finished,
|
||||
content: Some(ToolCallContent::Markdown {
|
||||
// For now we only include text content
|
||||
markdown: content.to_string(),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
ContentChunk::Image
|
||||
| ContentChunk::Document
|
||||
| ContentChunk::Thinking
|
||||
| ContentChunk::RedactedThinking
|
||||
| ContentChunk::WebSearchToolResult => {
|
||||
delegate
|
||||
.stream_assistant_message_chunk(StreamAssistantMessageChunkParams {
|
||||
chunk: acp::AssistantMessageChunk::Text {
|
||||
text: format!("Unsupported content: {:?}", chunk),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SdkMessage::Result {
|
||||
is_error, subtype, ..
|
||||
} => {
|
||||
if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() {
|
||||
if is_error {
|
||||
end_turn_tx.send(Err(anyhow!("Error: {subtype}"))).ok();
|
||||
} else {
|
||||
end_turn_tx.send(Ok(())).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
SdkMessage::System { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_io(
|
||||
mut outgoing_rx: UnboundedReceiver<SdkMessage>,
|
||||
incoming_tx: UnboundedSender<SdkMessage>,
|
||||
mut outgoing_bytes: impl Unpin + AsyncWrite,
|
||||
incoming_bytes: impl Unpin + AsyncRead,
|
||||
) -> Result<()> {
|
||||
let mut output_reader = BufReader::new(incoming_bytes);
|
||||
let mut outgoing_line = Vec::new();
|
||||
let mut incoming_line = String::new();
|
||||
loop {
|
||||
select_biased! {
|
||||
message = outgoing_rx.next() => {
|
||||
if let Some(message) = message {
|
||||
outgoing_line.clear();
|
||||
serde_json::to_writer(&mut outgoing_line, &message)?;
|
||||
log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
|
||||
outgoing_line.push(b'\n');
|
||||
outgoing_bytes.write_all(&outgoing_line).await.ok();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
log::trace!("recv: {}", &incoming_line);
|
||||
match serde_json::from_str::<SdkMessage>(&incoming_line) {
|
||||
Ok(message) => {
|
||||
incoming_tx.unbounded_send(message).log_err();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
|
||||
}
|
||||
}
|
||||
incoming_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Message {
|
||||
role: Role,
|
||||
content: Content,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop_sequence: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Content {
|
||||
UntaggedText(String),
|
||||
Chunks(Vec<ContentChunk>),
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
|
||||
match self {
|
||||
Self::Chunks(chunks) => chunks.into_iter(),
|
||||
Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Content {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Content::UntaggedText(txt) => write!(f, "{}", txt),
|
||||
Content::Chunks(chunks) => {
|
||||
for chunk in chunks {
|
||||
write!(f, "{}", chunk)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ContentChunk {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
ToolResult {
|
||||
content: Content,
|
||||
tool_use_id: String,
|
||||
},
|
||||
// TODO
|
||||
Image,
|
||||
Document,
|
||||
Thinking,
|
||||
RedactedThinking,
|
||||
WebSearchToolResult,
|
||||
#[serde(untagged)]
|
||||
UntaggedText(String),
|
||||
}
|
||||
|
||||
impl Display for ContentChunk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ContentChunk::Text { text } => write!(f, "{}", text),
|
||||
ContentChunk::UntaggedText(text) => write!(f, "{}", text),
|
||||
ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
|
||||
ContentChunk::Image
|
||||
| ContentChunk::Document
|
||||
| ContentChunk::Thinking
|
||||
| ContentChunk::RedactedThinking
|
||||
| ContentChunk::ToolUse { .. }
|
||||
| ContentChunk::WebSearchToolResult => {
|
||||
write!(f, "\n{:?}\n", &self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Usage {
|
||||
input_tokens: u32,
|
||||
cache_creation_input_tokens: u32,
|
||||
cache_read_input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
service_tier: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Role {
|
||||
System,
|
||||
Assistant,
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MessageParam {
|
||||
role: Role,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum SdkMessage {
|
||||
// An assistant message
|
||||
Assistant {
|
||||
message: Message, // from Anthropic SDK
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
|
||||
// A user message
|
||||
User {
|
||||
message: Message, // from Anthropic SDK
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
|
||||
// Emitted as the last message in a conversation
|
||||
Result {
|
||||
subtype: ResultErrorType,
|
||||
duration_ms: f64,
|
||||
duration_api_ms: f64,
|
||||
is_error: bool,
|
||||
num_turns: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<String>,
|
||||
session_id: String,
|
||||
total_cost_usd: f64,
|
||||
},
|
||||
// Emitted as the first message at the start of a conversation
|
||||
System {
|
||||
cwd: String,
|
||||
session_id: String,
|
||||
tools: Vec<String>,
|
||||
model: String,
|
||||
mcp_servers: Vec<McpServer>,
|
||||
#[serde(rename = "apiKeySource")]
|
||||
api_key_source: String,
|
||||
#[serde(rename = "permissionMode")]
|
||||
permission_mode: PermissionMode,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ResultErrorType {
|
||||
Success,
|
||||
ErrorMaxTurns,
|
||||
ErrorDuringExecution,
|
||||
}
|
||||
|
||||
impl Display for ResultErrorType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ResultErrorType::Success => write!(f, "success"),
|
||||
ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
|
||||
ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct McpServer {
|
||||
name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum PermissionMode {
|
||||
Default,
|
||||
AcceptEdits,
|
||||
BypassPermissions,
|
||||
Plan,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpConfig {
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpServerConfig {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// crate::common_e2e_tests!(ClaudeCode);
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
AgentServerCommand {
|
||||
path: "claude".into(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_content_untagged_text() {
|
||||
let json = json!("Hello, world!");
|
||||
let content: Content = serde_json::from_value(json).unwrap();
|
||||
match content {
|
||||
Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
|
||||
_ => panic!("Expected UntaggedText variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_content_chunks() {
|
||||
let json = json!([
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_123",
|
||||
"name": "calculator",
|
||||
"input": {"operation": "add", "a": 1, "b": 2}
|
||||
}
|
||||
]);
|
||||
let content: Content = serde_json::from_value(json).unwrap();
|
||||
match content {
|
||||
Content::Chunks(chunks) => {
|
||||
assert_eq!(chunks.len(), 2);
|
||||
match &chunks[0] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Hello"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
match &chunks[1] {
|
||||
ContentChunk::ToolUse { id, name, input } => {
|
||||
assert_eq!(id, "tool_123");
|
||||
assert_eq!(name, "calculator");
|
||||
assert_eq!(input["operation"], "add");
|
||||
assert_eq!(input["a"], 1);
|
||||
assert_eq!(input["b"], 2);
|
||||
}
|
||||
_ => panic!("Expected ToolUse chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Chunks variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_tool_result_untagged_text() {
|
||||
let json = json!({
|
||||
"type": "tool_result",
|
||||
"content": "Result content",
|
||||
"tool_use_id": "tool_456"
|
||||
});
|
||||
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
|
||||
match chunk {
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
match content {
|
||||
Content::UntaggedText(text) => assert_eq!(text, "Result content"),
|
||||
_ => panic!("Expected UntaggedText content"),
|
||||
}
|
||||
assert_eq!(tool_use_id, "tool_456");
|
||||
}
|
||||
_ => panic!("Expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_tool_result_chunks() {
|
||||
let json = json!({
|
||||
"type": "tool_result",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Processing complete"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Result: 42"
|
||||
}
|
||||
],
|
||||
"tool_use_id": "tool_789"
|
||||
});
|
||||
let chunk: ContentChunk = serde_json::from_value(json).unwrap();
|
||||
match chunk {
|
||||
ContentChunk::ToolResult {
|
||||
content,
|
||||
tool_use_id,
|
||||
} => {
|
||||
match content {
|
||||
Content::Chunks(chunks) => {
|
||||
assert_eq!(chunks.len(), 2);
|
||||
match &chunks[0] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
match &chunks[1] {
|
||||
ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
|
||||
_ => panic!("Expected Text chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Chunks content"),
|
||||
}
|
||||
assert_eq!(tool_use_id, "tool_789");
|
||||
}
|
||||
_ => panic!("Expected ToolResult variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
303
crates/agent_servers/src/claude/mcp_server.rs
Normal file
303
crates/agent_servers/src/claude/mcp_server.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use acp_thread::AcpClientDelegate;
|
||||
use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
|
||||
use anyhow::{Context, Result};
|
||||
use collections::HashMap;
|
||||
use context_server::{
|
||||
listener::McpServer,
|
||||
types::{
|
||||
CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
|
||||
ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
|
||||
ToolResponseContent, ToolsCapabilities, requests,
|
||||
},
|
||||
};
|
||||
use gpui::{App, AsyncApp, Task};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::debug_panic;
|
||||
|
||||
use crate::claude::{
|
||||
McpServerConfig,
|
||||
tools::{ClaudeTool, EditToolParams, EditToolResponse, ReadToolParams, ReadToolResponse},
|
||||
};
|
||||
|
||||
pub struct ClaudeMcpServer {
|
||||
server: McpServer,
|
||||
}
|
||||
|
||||
pub const SERVER_NAME: &str = "zed";
|
||||
pub const READ_TOOL: &str = "Read";
|
||||
pub const EDIT_TOOL: &str = "Edit";
|
||||
pub const PERMISSION_TOOL: &str = "Confirmation";
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
struct PermissionToolParams {
|
||||
tool_name: String,
|
||||
input: serde_json::Value,
|
||||
tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior,
|
||||
updated_input: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum PermissionToolBehavior {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl ClaudeMcpServer {
|
||||
pub async fn new(
|
||||
delegate: watch::Receiver<Option<AcpClientDelegate>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut mcp_server = McpServer::new(cx).await?;
|
||||
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
|
||||
mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
|
||||
mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
|
||||
Self::handle_call_tool(request, delegate.clone(), tool_id_map.clone(), cx)
|
||||
});
|
||||
|
||||
Ok(Self { server: mcp_server })
|
||||
}
|
||||
|
||||
pub fn server_config(&self) -> Result<McpServerConfig> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let zed_path = util::get_shell_safe_zed_path()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let zed_path = std::env::current_exe()
|
||||
.context("finding current executable path for use in mcp_server")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(McpServerConfig {
|
||||
command: zed_path,
|
||||
args: vec![
|
||||
"--nc".into(),
|
||||
self.server.socket_path().display().to_string(),
|
||||
],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
Ok(InitializeResponse {
|
||||
protocol_version: ProtocolVersion("2025-06-18".into()),
|
||||
capabilities: ServerCapabilities {
|
||||
experimental: None,
|
||||
logging: None,
|
||||
completions: None,
|
||||
prompts: None,
|
||||
resources: None,
|
||||
tools: Some(ToolsCapabilities {
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
},
|
||||
server_info: Implementation {
|
||||
name: SERVER_NAME.into(),
|
||||
version: "0.1.0".into(),
|
||||
},
|
||||
meta: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_tools(_: (), cx: &App) -> Task<Result<ListToolsResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
Ok(ListToolsResponse {
|
||||
tools: vec![
|
||||
Tool {
|
||||
name: PERMISSION_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(PermissionToolParams).into(),
|
||||
description: None,
|
||||
annotations: None,
|
||||
},
|
||||
Tool {
|
||||
name: READ_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(ReadToolParams).into(),
|
||||
description: Some("Read the contents of a file. In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.".to_string()),
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("Read file".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
open_world_hint: Some(false),
|
||||
// if time passes the contents might change, but it's not going to do anything different
|
||||
// true or false seem too strong, let's try a none.
|
||||
idempotent_hint: None,
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: EDIT_TOOL.into(),
|
||||
input_schema: schemars::schema_for!(EditToolParams).into(),
|
||||
description: Some("Edits a file. In sessions with mcp__zed__Edit always use it instead of Edit as it will show the diff to the user better.".to_string()),
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("Edit file".to_string()),
|
||||
read_only_hint: Some(false),
|
||||
destructive_hint: Some(false),
|
||||
open_world_hint: Some(false),
|
||||
idempotent_hint: Some(false),
|
||||
}),
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_call_tool(
|
||||
request: CallToolParams,
|
||||
mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &App,
|
||||
) -> Task<Result<CallToolResponse>> {
|
||||
cx.spawn(async move |cx| {
|
||||
let Some(delegate) = delegate_watch.recv().await? else {
|
||||
debug_panic!("Sent None delegate");
|
||||
anyhow::bail!("Server not available");
|
||||
};
|
||||
|
||||
if request.name.as_str() == PERMISSION_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result =
|
||||
Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else if request.name.as_str() == READ_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result = Self::handle_read_tool_call(input, delegate, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else if request.name.as_str() == EDIT_TOOL {
|
||||
let input =
|
||||
serde_json::from_value(request.arguments.context("Arguments required")?)?;
|
||||
|
||||
let result = Self::handle_edit_tool_call(input, delegate, cx).await?;
|
||||
Ok(CallToolResponse {
|
||||
content: vec![ToolResponseContent::Text {
|
||||
text: serde_json::to_string(&result)?,
|
||||
}],
|
||||
is_error: None,
|
||||
meta: None,
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!("Unsupported tool");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read_tool_call(
|
||||
params: ReadToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<ReadToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let response = delegate
|
||||
.read_text_file(ReadTextFileParams {
|
||||
path: params.abs_path,
|
||||
line: params.offset,
|
||||
limit: params.limit,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(ReadToolResponse {
|
||||
content: response.content,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_edit_tool_call(
|
||||
params: EditToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<EditToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let response = delegate
|
||||
.read_text_file_reusing_snapshot(ReadTextFileParams {
|
||||
path: params.abs_path.clone(),
|
||||
line: None,
|
||||
limit: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_content = response.content.replace(¶ms.old_text, ¶ms.new_text);
|
||||
if new_content == response.content {
|
||||
return Err(anyhow::anyhow!("The old_text was not found in the content"));
|
||||
}
|
||||
|
||||
delegate
|
||||
.write_text_file(WriteTextFileParams {
|
||||
path: params.abs_path,
|
||||
content: new_content,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(EditToolResponse)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_permissions_tool_call(
|
||||
params: PermissionToolParams,
|
||||
delegate: AcpClientDelegate,
|
||||
tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
|
||||
cx: &AsyncApp,
|
||||
) -> Task<Result<PermissionToolResponse>> {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let claude_tool = ClaudeTool::infer(¶ms.tool_name, params.input.clone());
|
||||
|
||||
let tool_call_id = match params.tool_use_id {
|
||||
Some(tool_use_id) => tool_id_map
|
||||
.borrow()
|
||||
.get(&tool_use_id)
|
||||
.cloned()
|
||||
.context("Tool call ID not found")?,
|
||||
|
||||
None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
|
||||
};
|
||||
|
||||
let outcome = delegate
|
||||
.request_existing_tool_call_confirmation(
|
||||
tool_call_id,
|
||||
claude_tool.confirmation(None),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match outcome {
|
||||
acp::ToolCallConfirmationOutcome::Allow
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllow
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
|
||||
| acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior::Allow,
|
||||
updated_input: params.input,
|
||||
}),
|
||||
acp::ToolCallConfirmationOutcome::Reject
|
||||
| acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
|
||||
behavior: PermissionToolBehavior::Deny,
|
||||
updated_input: params.input,
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
670
crates/agent_servers/src/claude/tools.rs
Normal file
670
crates/agent_servers/src/claude/tools.rs
Normal file
@@ -0,0 +1,670 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use agentic_coding_protocol::{self as acp, PushToolCallParams, ToolCallLocation};
|
||||
use itertools::Itertools;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use util::ResultExt;
|
||||
|
||||
pub enum ClaudeTool {
|
||||
Task(Option<TaskToolParams>),
|
||||
NotebookRead(Option<NotebookReadToolParams>),
|
||||
NotebookEdit(Option<NotebookEditToolParams>),
|
||||
Edit(Option<EditToolParams>),
|
||||
MultiEdit(Option<MultiEditToolParams>),
|
||||
ReadFile(Option<ReadToolParams>),
|
||||
Write(Option<WriteToolParams>),
|
||||
Ls(Option<LsToolParams>),
|
||||
Glob(Option<GlobToolParams>),
|
||||
Grep(Option<GrepToolParams>),
|
||||
Terminal(Option<BashToolParams>),
|
||||
WebFetch(Option<WebFetchToolParams>),
|
||||
WebSearch(Option<WebSearchToolParams>),
|
||||
TodoWrite(Option<TodoWriteToolParams>),
|
||||
ExitPlanMode(Option<ExitPlanModeToolParams>),
|
||||
Other {
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClaudeTool {
|
||||
pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
|
||||
match tool_name {
|
||||
// Known tools
|
||||
"mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
|
||||
"mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
|
||||
"MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
|
||||
"Write" => Self::Write(serde_json::from_value(input).log_err()),
|
||||
"LS" => Self::Ls(serde_json::from_value(input).log_err()),
|
||||
"Glob" => Self::Glob(serde_json::from_value(input).log_err()),
|
||||
"Grep" => Self::Grep(serde_json::from_value(input).log_err()),
|
||||
"Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
|
||||
"WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
|
||||
"WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
|
||||
"TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
|
||||
"exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
|
||||
"Task" => Self::Task(serde_json::from_value(input).log_err()),
|
||||
"NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
|
||||
"NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
|
||||
// Inferred from name
|
||||
_ => {
|
||||
let tool_name = tool_name.to_lowercase();
|
||||
|
||||
if tool_name.contains("edit") || tool_name.contains("write") {
|
||||
Self::Edit(None)
|
||||
} else if tool_name.contains("terminal") {
|
||||
Self::Terminal(None)
|
||||
} else {
|
||||
Self::Other {
|
||||
name: tool_name.to_string(),
|
||||
input,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match &self {
|
||||
Self::Task(Some(params)) => params.description.clone(),
|
||||
Self::Task(None) => "Task".into(),
|
||||
Self::NotebookRead(Some(params)) => {
|
||||
format!("Read Notebook {}", params.notebook_path.display())
|
||||
}
|
||||
Self::NotebookRead(None) => "Read Notebook".into(),
|
||||
Self::NotebookEdit(Some(params)) => {
|
||||
format!("Edit Notebook {}", params.notebook_path.display())
|
||||
}
|
||||
Self::NotebookEdit(None) => "Edit Notebook".into(),
|
||||
Self::Terminal(Some(params)) => format!("`{}`", params.command),
|
||||
Self::Terminal(None) => "Terminal".into(),
|
||||
Self::ReadFile(_) => "Read File".into(),
|
||||
Self::Ls(Some(params)) => {
|
||||
format!("List Directory {}", params.path.display())
|
||||
}
|
||||
Self::Ls(None) => "List Directory".into(),
|
||||
Self::Edit(Some(params)) => {
|
||||
format!("Edit {}", params.abs_path.display())
|
||||
}
|
||||
Self::Edit(None) => "Edit".into(),
|
||||
Self::MultiEdit(Some(params)) => {
|
||||
format!("Multi Edit {}", params.file_path.display())
|
||||
}
|
||||
Self::MultiEdit(None) => "Multi Edit".into(),
|
||||
Self::Write(Some(params)) => {
|
||||
format!("Write {}", params.file_path.display())
|
||||
}
|
||||
Self::Write(None) => "Write".into(),
|
||||
Self::Glob(Some(params)) => {
|
||||
format!("Glob `{params}`")
|
||||
}
|
||||
Self::Glob(None) => "Glob".into(),
|
||||
Self::Grep(Some(params)) => format!("`{params}`"),
|
||||
Self::Grep(None) => "Grep".into(),
|
||||
Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
|
||||
Self::WebFetch(None) => "Fetch".into(),
|
||||
Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
|
||||
Self::WebSearch(None) => "Web Search".into(),
|
||||
Self::TodoWrite(Some(params)) => format!(
|
||||
"Update TODOs: {}",
|
||||
params.todos.iter().map(|todo| &todo.content).join(", ")
|
||||
),
|
||||
Self::TodoWrite(None) => "Update TODOs".into(),
|
||||
Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
|
||||
Self::Other { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(&self) -> Option<acp::ToolCallContent> {
|
||||
match &self {
|
||||
ClaudeTool::Other { input, .. } => Some(acp::ToolCallContent::Markdown {
|
||||
markdown: format!(
|
||||
"```json\n{}```",
|
||||
serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
|
||||
),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> acp::Icon {
|
||||
match self {
|
||||
Self::Task(_) => acp::Icon::Hammer,
|
||||
Self::NotebookRead(_) => acp::Icon::FileSearch,
|
||||
Self::NotebookEdit(_) => acp::Icon::Pencil,
|
||||
Self::Edit(_) => acp::Icon::Pencil,
|
||||
Self::MultiEdit(_) => acp::Icon::Pencil,
|
||||
Self::Write(_) => acp::Icon::Pencil,
|
||||
Self::ReadFile(_) => acp::Icon::FileSearch,
|
||||
Self::Ls(_) => acp::Icon::Folder,
|
||||
Self::Glob(_) => acp::Icon::FileSearch,
|
||||
Self::Grep(_) => acp::Icon::Regex,
|
||||
Self::Terminal(_) => acp::Icon::Terminal,
|
||||
Self::WebSearch(_) => acp::Icon::Globe,
|
||||
Self::WebFetch(_) => acp::Icon::Globe,
|
||||
Self::TodoWrite(_) => acp::Icon::LightBulb,
|
||||
Self::ExitPlanMode(_) => acp::Icon::Hammer,
|
||||
Self::Other { .. } => acp::Icon::Hammer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn confirmation(&self, description: Option<String>) -> acp::ToolCallConfirmation {
|
||||
match &self {
|
||||
Self::Edit(_) | Self::Write(_) | Self::NotebookEdit(_) | Self::MultiEdit(_) => {
|
||||
acp::ToolCallConfirmation::Edit { description }
|
||||
}
|
||||
Self::WebFetch(params) => acp::ToolCallConfirmation::Fetch {
|
||||
urls: params
|
||||
.as_ref()
|
||||
.map(|p| vec![p.url.clone()])
|
||||
.unwrap_or_default(),
|
||||
description,
|
||||
},
|
||||
Self::Terminal(Some(BashToolParams {
|
||||
description,
|
||||
command,
|
||||
..
|
||||
})) => acp::ToolCallConfirmation::Execute {
|
||||
command: command.clone(),
|
||||
root_command: command.clone(),
|
||||
description: description.clone(),
|
||||
},
|
||||
Self::ExitPlanMode(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {}", params.plan)
|
||||
} else {
|
||||
params.plan.clone()
|
||||
},
|
||||
},
|
||||
Self::Task(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {}", params.description)
|
||||
} else {
|
||||
params.description.clone()
|
||||
},
|
||||
},
|
||||
Self::Ls(Some(LsToolParams { path, .. }))
|
||||
| Self::ReadFile(Some(ReadToolParams { abs_path: path, .. })) => {
|
||||
let path = path.display();
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {path}")
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
|
||||
let path = notebook_path.display();
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {path}")
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::Glob(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::Grep(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::WebSearch(Some(params)) => acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params.to_string()
|
||||
},
|
||||
},
|
||||
Self::TodoWrite(Some(params)) => {
|
||||
let params = params.todos.iter().map(|todo| &todo.content).join(", ");
|
||||
acp::ToolCallConfirmation::Other {
|
||||
description: if let Some(description) = description {
|
||||
format!("{description} {params}")
|
||||
} else {
|
||||
params
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::Terminal(None)
|
||||
| Self::Task(None)
|
||||
| Self::NotebookRead(None)
|
||||
| Self::ExitPlanMode(None)
|
||||
| Self::Ls(None)
|
||||
| Self::Glob(None)
|
||||
| Self::Grep(None)
|
||||
| Self::ReadFile(None)
|
||||
| Self::WebSearch(None)
|
||||
| Self::TodoWrite(None)
|
||||
| Self::Other { .. } => acp::ToolCallConfirmation::Other {
|
||||
description: description.unwrap_or("".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
|
||||
match &self {
|
||||
Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![ToolCallLocation {
|
||||
path: abs_path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: file_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::Write(Some(WriteToolParams { file_path, .. })) => vec![ToolCallLocation {
|
||||
path: file_path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::ReadFile(Some(ReadToolParams {
|
||||
abs_path, offset, ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: abs_path.clone(),
|
||||
line: *offset,
|
||||
}],
|
||||
Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: notebook_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
|
||||
vec![ToolCallLocation {
|
||||
path: notebook_path.clone(),
|
||||
line: None,
|
||||
}]
|
||||
}
|
||||
Self::Glob(Some(GlobToolParams {
|
||||
path: Some(path), ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::Ls(Some(LsToolParams { path, .. })) => vec![ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
}],
|
||||
Self::Grep(Some(GrepToolParams {
|
||||
path: Some(path), ..
|
||||
})) => vec![ToolCallLocation {
|
||||
path: PathBuf::from(path),
|
||||
line: None,
|
||||
}],
|
||||
Self::Task(_)
|
||||
| Self::NotebookRead(None)
|
||||
| Self::NotebookEdit(None)
|
||||
| Self::Edit(None)
|
||||
| Self::MultiEdit(None)
|
||||
| Self::Write(None)
|
||||
| Self::ReadFile(None)
|
||||
| Self::Ls(None)
|
||||
| Self::Glob(_)
|
||||
| Self::Grep(_)
|
||||
| Self::Terminal(_)
|
||||
| Self::WebFetch(_)
|
||||
| Self::WebSearch(_)
|
||||
| Self::TodoWrite(_)
|
||||
| Self::ExitPlanMode(_)
|
||||
| Self::Other { .. } => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_acp(&self) -> PushToolCallParams {
|
||||
PushToolCallParams {
|
||||
label: self.label(),
|
||||
content: self.content(),
|
||||
icon: self.icon(),
|
||||
locations: self.locations(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct EditToolParams {
|
||||
/// The absolute path to the file to read.
|
||||
pub abs_path: PathBuf,
|
||||
/// The old text to replace (must be unique in the file)
|
||||
pub old_text: String,
|
||||
/// The new text.
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditToolResponse;
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct ReadToolParams {
|
||||
/// The absolute path to the file to read.
|
||||
pub abs_path: PathBuf,
|
||||
/// Which line to start reading from. Omit to start from the beginning.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
/// How many lines to read. Omit for the whole file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadToolResponse {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WriteToolParams {
|
||||
/// Absolute path for new file
|
||||
pub file_path: PathBuf,
|
||||
/// File content
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct BashToolParams {
|
||||
/// Shell command to execute
|
||||
pub command: String,
|
||||
/// 5-10 word description of what command does
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Timeout in ms (max 600000ms/10min, default 120000ms)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct GlobToolParams {
|
||||
/// Glob pattern like **/*.js or src/**/*.ts
|
||||
pub pattern: String,
|
||||
/// Directory to search in (omit for current directory)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GlobToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(path) = &self.path {
|
||||
write!(f, "{}", path.display())?;
|
||||
}
|
||||
write!(f, "{}", self.pattern)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct LsToolParams {
|
||||
/// Absolute path to directory
|
||||
pub path: PathBuf,
|
||||
/// Array of glob patterns to ignore
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ignore: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct GrepToolParams {
|
||||
/// Regex pattern to search for
|
||||
pub pattern: String,
|
||||
/// File/directory to search (defaults to current directory)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// "content" (shows lines), "files_with_matches" (default), "count"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_mode: Option<GrepOutputMode>,
|
||||
/// Filter files with glob pattern like "*.js"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub glob: Option<String>,
|
||||
/// File type filter like "js", "py", "rust"
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub file_type: Option<String>,
|
||||
/// Case insensitive search
|
||||
#[serde(rename = "-i", default, skip_serializing_if = "is_false")]
|
||||
pub case_insensitive: bool,
|
||||
/// Show line numbers (content mode only)
|
||||
#[serde(rename = "-n", default, skip_serializing_if = "is_false")]
|
||||
pub line_numbers: bool,
|
||||
/// Lines after match (content mode only)
|
||||
#[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
|
||||
pub after_context: Option<u32>,
|
||||
/// Lines before match (content mode only)
|
||||
#[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
|
||||
pub before_context: Option<u32>,
|
||||
/// Lines before and after match (content mode only)
|
||||
#[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<u32>,
|
||||
/// Enable multiline/cross-line matching
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub multiline: bool,
|
||||
/// Limit output to first N results
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub head_limit: Option<u32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GrepToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "grep")?;
|
||||
|
||||
// Boolean flags
|
||||
if self.case_insensitive {
|
||||
write!(f, " -i")?;
|
||||
}
|
||||
if self.line_numbers {
|
||||
write!(f, " -n")?;
|
||||
}
|
||||
|
||||
// Context options
|
||||
if let Some(after) = self.after_context {
|
||||
write!(f, " -A {}", after)?;
|
||||
}
|
||||
if let Some(before) = self.before_context {
|
||||
write!(f, " -B {}", before)?;
|
||||
}
|
||||
if let Some(context) = self.context {
|
||||
write!(f, " -C {}", context)?;
|
||||
}
|
||||
|
||||
// Output mode
|
||||
if let Some(mode) = &self.output_mode {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
|
||||
GrepOutputMode::Count => write!(f, " -c")?,
|
||||
GrepOutputMode::Content => {} // Default mode
|
||||
}
|
||||
}
|
||||
|
||||
// Head limit
|
||||
if let Some(limit) = self.head_limit {
|
||||
write!(f, " | head -{}", limit)?;
|
||||
}
|
||||
|
||||
// Glob pattern
|
||||
if let Some(glob) = &self.glob {
|
||||
write!(f, " --include=\"{}\"", glob)?;
|
||||
}
|
||||
|
||||
// File type
|
||||
if let Some(file_type) = &self.file_type {
|
||||
write!(f, " --type={}", file_type)?;
|
||||
}
|
||||
|
||||
// Multiline
|
||||
if self.multiline {
|
||||
write!(f, " -P")?; // Perl-compatible regex for multiline
|
||||
}
|
||||
|
||||
// Pattern (escaped if contains special characters)
|
||||
write!(f, " \"{}\"", self.pattern)?;
|
||||
|
||||
// Path
|
||||
if let Some(path) = &self.path {
|
||||
write!(f, " {}", path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TodoPriority {
|
||||
High,
|
||||
Medium,
|
||||
Low,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TodoStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
pub struct Todo {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// Task description
|
||||
pub content: String,
|
||||
/// Priority level of the todo
|
||||
pub priority: TodoPriority,
|
||||
/// Current status of the todo
|
||||
pub status: TodoStatus,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct TodoWriteToolParams {
|
||||
pub todos: Vec<Todo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct ExitPlanModeToolParams {
|
||||
/// Implementation plan in markdown format
|
||||
pub plan: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct TaskToolParams {
|
||||
/// Short 3-5 word description of task
|
||||
pub description: String,
|
||||
/// Detailed task for agent to perform
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct NotebookReadToolParams {
|
||||
/// Absolute path to .ipynb file
|
||||
pub notebook_path: PathBuf,
|
||||
/// Specific cell ID to read
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CellType {
|
||||
Code,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EditMode {
|
||||
Replace,
|
||||
Insert,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct NotebookEditToolParams {
|
||||
/// Absolute path to .ipynb file
|
||||
pub notebook_path: PathBuf,
|
||||
/// New cell content
|
||||
pub new_source: String,
|
||||
/// Cell ID to edit
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_id: Option<String>,
|
||||
/// Type of cell (code or markdown)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_type: Option<CellType>,
|
||||
/// Edit operation mode
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edit_mode: Option<EditMode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
|
||||
pub struct MultiEditItem {
|
||||
/// The text to search for and replace
|
||||
pub old_string: String,
|
||||
/// The replacement text
|
||||
pub new_string: String,
|
||||
/// Whether to replace all occurrences or just the first
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct MultiEditToolParams {
|
||||
/// Absolute path to file
|
||||
pub file_path: PathBuf,
|
||||
/// List of edits to apply
|
||||
pub edits: Vec<MultiEditItem>,
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrepOutputMode {
|
||||
Content,
|
||||
FilesWithMatches,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WebFetchToolParams {
|
||||
/// Valid URL to fetch
|
||||
#[serde(rename = "url")]
|
||||
pub url: String,
|
||||
/// What to extract from content
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Debug)]
|
||||
pub struct WebSearchToolParams {
|
||||
/// Search query (min 2 chars)
|
||||
pub query: String,
|
||||
/// Only include these domains
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Exclude these domains
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub blocked_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WebSearchToolParams {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "\"{}\"", self.query)?;
|
||||
|
||||
if !self.allowed_domains.is_empty() {
|
||||
write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
|
||||
}
|
||||
|
||||
if !self.blocked_domains.is_empty() {
|
||||
write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
121
crates/agent_servers/src/codex.rs
Normal file
121
crates/agent_servers/src/codex.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::stdio_agent_server::StdioAgentServer;
|
||||
use crate::{AgentServerCommand, AgentServerVersion};
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Entity};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
|
||||
use crate::AllAgentServersSettings;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Codex;
|
||||
|
||||
const ACP_ARG: &str = "experimental-acp";
|
||||
|
||||
impl StdioAgentServer for Codex {
|
||||
fn name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
"Welcome to Codex"
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiOpenAi
|
||||
}
|
||||
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).codex.clone()
|
||||
})?;
|
||||
|
||||
if let Some(command) =
|
||||
AgentServerCommand::resolve("codex", &[ACP_ARG], settings, &project, cx).await
|
||||
{
|
||||
return Ok(command);
|
||||
};
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("codex not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("codex");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@openai/codex", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/codex");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?;
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
|
||||
|
||||
if supported {
|
||||
Ok(AgentServerVersion::Supported)
|
||||
} else {
|
||||
Ok(AgentServerVersion::Unsupported {
|
||||
error_message: format!(
|
||||
"Your installed version of Codex {} doesn't support the Agentic Coding Protocol (ACP).",
|
||||
current_version
|
||||
).into(),
|
||||
upgrade_message: "Upgrade Codex to Latest".into(),
|
||||
upgrade_command: "npm install -g @openai/codex@latest".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::AgentServerCommand;
|
||||
use std::path::Path;
|
||||
|
||||
crate::common_e2e_tests!(Codex);
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../codex/codex-rs/target/debug/codex");
|
||||
|
||||
AgentServerCommand {
|
||||
path: cli_path,
|
||||
args: vec![],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
371
crates/agent_servers/src/e2e_tests.rs
Normal file
371
crates/agent_servers/src/e2e_tests.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use std::{path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings};
|
||||
use acp_thread::{
|
||||
AcpThread, AgentThreadEntry, ToolCall, ToolCallConfirmation, ToolCallContent, ToolCallStatus,
|
||||
};
|
||||
use agentic_coding_protocol as acp;
|
||||
use futures::{FutureExt, StreamExt, channel::mpsc, select};
|
||||
use gpui::{Entity, TestAppContext};
|
||||
use indoc::indoc;
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use util::path;
|
||||
|
||||
pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.entries().len(), 2);
|
||||
assert!(matches!(
|
||||
thread.entries()[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
thread.entries()[1],
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let _fs = init_test(cx).await;
|
||||
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tempdir.path().join("foo.rs"),
|
||||
indoc! {"
|
||||
fn main() {
|
||||
println!(\"Hello, world!\");
|
||||
}
|
||||
"},
|
||||
)
|
||||
.expect("failed to write file");
|
||||
let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
|
||||
let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(
|
||||
acp::SendUserMessageParams {
|
||||
chunks: vec![
|
||||
acp::UserMessageChunk::Text {
|
||||
text: "Read the file ".into(),
|
||||
},
|
||||
acp::UserMessageChunk::Path {
|
||||
path: Path::new("foo.rs").into(),
|
||||
},
|
||||
acp::UserMessageChunk::Text {
|
||||
text: " and tell me what the content of the println! is".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries().len(), 3);
|
||||
assert!(matches!(
|
||||
thread.entries()[0],
|
||||
AgentThreadEntry::UserMessage(_)
|
||||
));
|
||||
assert!(matches!(thread.entries()[1], AgentThreadEntry::ToolCall(_)));
|
||||
let AgentThreadEntry::AssistantMessage(assistant_message) = &thread.entries()[2] else {
|
||||
panic!("Expected AssistantMessage")
|
||||
};
|
||||
assert!(
|
||||
assistant_message.to_markdown(cx).contains("Hello, world!"),
|
||||
"unexpected assistant message: {:?}",
|
||||
assistant_message.to_markdown(cx)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
fs.insert_tree(
|
||||
path!("/private/tmp"),
|
||||
json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(
|
||||
"Read the '/private/tmp/foo' file and tell me what you see.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
assert!(matches!(
|
||||
&thread.entries()[2],
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
thread.entries()[3],
|
||||
AgentThreadEntry::AssistantMessage(_)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_tool_call_with_confirmation(
|
||||
server: impl AgentServer + 'static,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let fs = init_test(cx).await;
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
|
||||
});
|
||||
|
||||
run_until_first_tool_call(&thread, cx).await;
|
||||
|
||||
let tool_call_id = thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[2]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
assert_eq!(root_command, "echo");
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
|
||||
|
||||
assert!(matches!(
|
||||
&thread.entries()[2],
|
||||
AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
full_turn.await.unwrap();
|
||||
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
content: Some(ToolCallContent::Markdown { markdown }),
|
||||
status: ToolCallStatus::Allowed { .. },
|
||||
..
|
||||
}) = &thread.entries()[2]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
|
||||
markdown.read_with(cx, |md, _cx| {
|
||||
assert!(
|
||||
md.source().contains("Hello, world!"),
|
||||
r#"Expected '{}' to contain "Hello, world!""#,
|
||||
md.source()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
|
||||
let fs = init_test(cx).await;
|
||||
|
||||
let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
|
||||
let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
|
||||
let full_turn = thread.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Run `echo "Hello, world!"`"#, cx)
|
||||
});
|
||||
|
||||
let first_tool_call_ix = run_until_first_tool_call(&thread, cx).await;
|
||||
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
id,
|
||||
status:
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
confirmation: ToolCallConfirmation::Execute { root_command, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!("{:?}", thread.entries()[1]);
|
||||
};
|
||||
|
||||
assert_eq!(root_command, "echo");
|
||||
|
||||
*id
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.cancel(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
full_turn.await.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
let AgentThreadEntry::ToolCall(ToolCall {
|
||||
status: ToolCallStatus::Canceled,
|
||||
..
|
||||
}) = &thread.entries()[first_tool_call_ix]
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send_raw(r#"Stop running and say goodbye to me."#, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(matches!(
|
||||
&thread.entries().last().unwrap(),
|
||||
AgentThreadEntry::AssistantMessage(..),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! common_e2e_tests {
|
||||
($server:expr) => {
|
||||
mod common_e2e {
|
||||
use super::*;
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn basic(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_basic($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn path_mentions(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_path_mentions($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn tool_call(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_tool_call($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn tool_call_with_confirmation(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_tool_call_with_confirmation($server, cx).await;
|
||||
}
|
||||
|
||||
#[::gpui::test]
|
||||
#[cfg_attr(not(feature = "e2e"), ignore)]
|
||||
async fn cancel(cx: &mut ::gpui::TestAppContext) {
|
||||
$crate::e2e_tests::test_cancel($server, cx).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
|
||||
env_logger::try_init().ok();
|
||||
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
Project::init_settings(cx);
|
||||
language::init(cx);
|
||||
crate::settings::init(cx);
|
||||
|
||||
crate::AllAgentServersSettings::override_global(
|
||||
AllAgentServersSettings {
|
||||
claude: Some(AgentServerSettings {
|
||||
command: crate::claude::tests::local_command(),
|
||||
}),
|
||||
codex: Some(AgentServerSettings {
|
||||
command: crate::codex::tests::local_command(),
|
||||
}),
|
||||
gemini: Some(AgentServerSettings {
|
||||
command: crate::gemini::tests::local_command(),
|
||||
}),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
FakeFs::new(cx.executor())
|
||||
}
|
||||
|
||||
pub async fn new_test_thread(
|
||||
server: impl AgentServer + 'static,
|
||||
project: Entity<Project>,
|
||||
current_dir: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<AcpThread> {
|
||||
let thread = cx
|
||||
.update(|cx| server.new_thread(current_dir.as_ref(), &project, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
thread
|
||||
.update(cx, |thread, _| thread.initialize())
|
||||
.await
|
||||
.unwrap();
|
||||
thread
|
||||
}
|
||||
|
||||
pub async fn run_until_first_tool_call(
|
||||
thread: &Entity<AcpThread>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> usize {
|
||||
let (mut tx, mut rx) = mpsc::channel::<usize>(1);
|
||||
|
||||
let subscription = cx.update(|cx| {
|
||||
cx.subscribe(thread, move |thread, _, cx| {
|
||||
for (ix, entry) in thread.read(cx).entries().iter().enumerate() {
|
||||
if matches!(entry, AgentThreadEntry::ToolCall(_)) {
|
||||
return tx.try_send(ix).unwrap();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
select! {
|
||||
// We have to use a smol timer here because
|
||||
// cx.background_executor().timer isn't real in the test context
|
||||
_ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
|
||||
panic!("Timeout waiting for tool call")
|
||||
}
|
||||
ix = rx.next().fuse() => {
|
||||
drop(subscription);
|
||||
ix.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
123
crates/agent_servers/src/gemini.rs
Normal file
123
crates/agent_servers/src/gemini.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use crate::stdio_agent_server::StdioAgentServer;
|
||||
use crate::{AgentServerCommand, AgentServerVersion};
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Entity};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
|
||||
use crate::AllAgentServersSettings;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Gemini;
|
||||
|
||||
const ACP_ARG: &str = "--experimental-acp";
|
||||
|
||||
impl StdioAgentServer for Gemini {
|
||||
fn name(&self) -> &'static str {
|
||||
"Gemini"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
"Welcome to Gemini"
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
"Ask questions, edit files, run commands.\nBe specific for the best results."
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiGemini
|
||||
}
|
||||
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).gemini.clone()
|
||||
})?;
|
||||
|
||||
if let Some(command) =
|
||||
AgentServerCommand::resolve("gemini", &[ACP_ARG], settings, &project, cx).await
|
||||
{
|
||||
return Ok(command);
|
||||
};
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("gemini not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("gemini");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/gemini");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?;
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
|
||||
|
||||
if supported {
|
||||
Ok(AgentServerVersion::Supported)
|
||||
} else {
|
||||
Ok(AgentServerVersion::Unsupported {
|
||||
error_message: format!(
|
||||
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
|
||||
current_version
|
||||
).into(),
|
||||
upgrade_message: "Upgrade Gemini to Latest".into(),
|
||||
upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::AgentServerCommand;
|
||||
use std::path::Path;
|
||||
|
||||
crate::common_e2e_tests!(Gemini);
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../gemini-cli/packages/cli")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
AgentServerCommand {
|
||||
path: "node".into(),
|
||||
args: vec![cli_path, ACP_ARG.into()],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
54
crates/agent_servers/src/settings.rs
Normal file
54
crates/agent_servers/src/settings.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::AgentServerCommand;
|
||||
use anyhow::Result;
|
||||
use gpui::App;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsSources};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AllAgentServersSettings::register(cx);
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AllAgentServersSettings {
|
||||
pub gemini: Option<AgentServerSettings>,
|
||||
pub claude: Option<AgentServerSettings>,
|
||||
pub codex: Option<AgentServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
pub struct AgentServerSettings {
|
||||
#[serde(flatten)]
|
||||
pub command: AgentServerCommand,
|
||||
}
|
||||
|
||||
impl settings::Settings for AllAgentServersSettings {
|
||||
const KEY: Option<&'static str> = Some("agent_servers");
|
||||
|
||||
type FileContent = Self;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for AllAgentServersSettings {
|
||||
gemini,
|
||||
claude,
|
||||
codex,
|
||||
} in sources.defaults_and_customizations()
|
||||
{
|
||||
if gemini.is_some() {
|
||||
settings.gemini = gemini.clone();
|
||||
}
|
||||
if claude.is_some() {
|
||||
settings.claude = claude.clone();
|
||||
}
|
||||
if codex.is_some() {
|
||||
settings.codex = codex.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
119
crates/agent_servers/src/stdio_agent_server.rs
Normal file
119
crates/agent_servers/src/stdio_agent_server.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use crate::{AgentServer, AgentServerCommand, AgentServerVersion};
|
||||
use acp_thread::{AcpClientDelegate, AcpThread, LoadError};
|
||||
use agentic_coding_protocol as acp;
|
||||
use anyhow::{Result, anyhow};
|
||||
use gpui::{App, AsyncApp, Entity, Task, prelude::*};
|
||||
use project::Project;
|
||||
use std::path::Path;
|
||||
use util::ResultExt;
|
||||
|
||||
pub trait StdioAgentServer: Send + Clone {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> &'static str;
|
||||
fn empty_state_headline(&self) -> &'static str;
|
||||
fn empty_state_message(&self) -> &'static str;
|
||||
fn supports_always_allow(&self) -> bool;
|
||||
|
||||
fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> impl Future<Output = Result<AgentServerCommand>>;
|
||||
|
||||
fn version(
|
||||
&self,
|
||||
command: &AgentServerCommand,
|
||||
) -> impl Future<Output = Result<AgentServerVersion>> + Send;
|
||||
}
|
||||
|
||||
impl<T: StdioAgentServer + 'static> AgentServer for T {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
self.empty_state_headline()
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
self.empty_state_message()
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
self.logo()
|
||||
}
|
||||
|
||||
fn supports_always_allow(&self) -> bool {
|
||||
self.supports_always_allow()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
&self,
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let project = project.clone();
|
||||
let this = self.clone();
|
||||
let title = self.name().into();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let command = this.command(&project, cx).await?;
|
||||
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
cx.new(|cx| {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let (connection, io_fut) = acp::AgentConnection::connect_to_agent(
|
||||
AcpClientDelegate::new(cx.entity().downgrade(), cx.to_async()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| foreground_executor.spawn(fut).detach(),
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
});
|
||||
|
||||
let child_status = cx.background_spawn(async move {
|
||||
let result = match child.status().await {
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
Ok(result) if result.success() => Ok(()),
|
||||
Ok(result) => {
|
||||
if let Some(AgentServerVersion::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command,
|
||||
}) = this.version(&command).await.log_err()
|
||||
{
|
||||
Err(anyhow!(LoadError::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(io_task);
|
||||
result
|
||||
});
|
||||
|
||||
AcpThread::new(connection, title, Some(child_status), project.clone(), cx)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ doctest = false
|
||||
test-support = ["gpui/test-support", "language/test-support"]
|
||||
|
||||
[dependencies]
|
||||
acp.workspace = true
|
||||
acp_thread.workspace = true
|
||||
agent.workspace = true
|
||||
agentic-coding-protocol.workspace = true
|
||||
agent_settings.workspace = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use agent_servers::AgentServer;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
@@ -35,7 +36,7 @@ use util::ResultExt;
|
||||
use workspace::{CollaboratorId, Workspace};
|
||||
use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
|
||||
|
||||
use ::acp::{
|
||||
use ::acp_thread::{
|
||||
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff,
|
||||
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent,
|
||||
ToolCallId, ToolCallStatus,
|
||||
@@ -49,6 +50,7 @@ use crate::{AgentDiffPane, Follow, KeepAll, OpenAgentDiff, RejectAll};
|
||||
const RESPONSE_PADDING_X: Pixels = px(19.);
|
||||
|
||||
pub struct AcpThreadView {
|
||||
agent: Rc<dyn AgentServer>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_state: ThreadState,
|
||||
@@ -80,8 +82,15 @@ enum ThreadState {
|
||||
},
|
||||
}
|
||||
|
||||
struct AlwaysAllowOption {
|
||||
id: &'static str,
|
||||
label: SharedString,
|
||||
outcome: acp::ToolCallConfirmationOutcome,
|
||||
}
|
||||
|
||||
impl AcpThreadView {
|
||||
pub fn new(
|
||||
agent: Rc<dyn AgentServer>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
message_history: Rc<RefCell<MessageHistory<acp::SendUserMessageParams>>>,
|
||||
@@ -158,9 +167,10 @@ impl AcpThreadView {
|
||||
);
|
||||
|
||||
Self {
|
||||
agent: agent.clone(),
|
||||
workspace: workspace.clone(),
|
||||
project: project.clone(),
|
||||
thread_state: Self::initial_state(workspace, project, window, cx),
|
||||
thread_state: Self::initial_state(agent, workspace, project, window, cx),
|
||||
message_editor,
|
||||
message_set_from_history: false,
|
||||
_message_editor_subscription: message_editor_subscription,
|
||||
@@ -177,6 +187,7 @@ impl AcpThreadView {
|
||||
}
|
||||
|
||||
fn initial_state(
|
||||
agent: Rc<dyn AgentServer>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
@@ -189,9 +200,9 @@ impl AcpThreadView {
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
.unwrap_or_else(|| paths::home_dir().as_path().into());
|
||||
|
||||
let task = agent.new_thread(&root_dir, &project, cx);
|
||||
let load_task = cx.spawn_in(window, async move |this, cx| {
|
||||
let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await
|
||||
{
|
||||
let thread = match task.await {
|
||||
Ok(thread) => thread,
|
||||
Err(err) => {
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -410,6 +421,33 @@ impl AcpThreadView {
|
||||
);
|
||||
}
|
||||
|
||||
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(thread) = self.thread() {
|
||||
AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_edited_buffer(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(diff) =
|
||||
AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
diff.update(cx, |diff, cx| {
|
||||
diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_draft_message(
|
||||
message_editor: Entity<Editor>,
|
||||
mention_set: Arc<Mutex<MentionSet>>,
|
||||
@@ -485,33 +523,6 @@ impl AcpThreadView {
|
||||
true
|
||||
}
|
||||
|
||||
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(thread) = self.thread() {
|
||||
AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err();
|
||||
}
|
||||
}
|
||||
|
||||
fn open_edited_buffer(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(diff) =
|
||||
AgentDiffPane::deploy(thread.clone(), self.workspace.clone(), window, cx).log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
diff.update(cx, |diff, cx| {
|
||||
diff.move_to_path(PathKey::for_buffer(&buffer, cx), window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_thread_event(
|
||||
&mut self,
|
||||
thread: &Entity<AcpThread>,
|
||||
@@ -608,6 +619,7 @@ impl AcpThreadView {
|
||||
let authenticate = thread.read(cx).authenticate();
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
let project = self.project.clone();
|
||||
let agent = self.agent.clone();
|
||||
async move |this, cx| {
|
||||
let result = authenticate.await;
|
||||
|
||||
@@ -617,8 +629,13 @@ impl AcpThreadView {
|
||||
Markdown::new(format!("Error: {err}").into(), None, None, cx)
|
||||
}))
|
||||
} else {
|
||||
this.thread_state =
|
||||
Self::initial_state(this.workspace.clone(), project.clone(), window, cx)
|
||||
this.thread_state = Self::initial_state(
|
||||
agent,
|
||||
this.workspace.clone(),
|
||||
project.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
this.auth_task.take()
|
||||
})
|
||||
@@ -1047,14 +1064,6 @@ impl AcpThreadView {
|
||||
) -> AnyElement {
|
||||
let confirmation_container = v_flex().mt_1().py_1p5();
|
||||
|
||||
let button_container = h_flex()
|
||||
.pt_1p5()
|
||||
.px_1p5()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx));
|
||||
|
||||
match confirmation {
|
||||
ToolCallConfirmation::Edit { description } => confirmation_container
|
||||
.child(
|
||||
@@ -1068,60 +1077,15 @@ impl AcpThreadView {
|
||||
})),
|
||||
)
|
||||
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
|
||||
.child(
|
||||
button_container
|
||||
.child(
|
||||
Button::new(("always_allow", tool_call_id.0), "Always Allow Edits")
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_confirmation_buttons(
|
||||
&[AlwaysAllowOption {
|
||||
id: "always_allow",
|
||||
label: "Always Allow Edits".into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
}],
|
||||
tool_call_id,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallConfirmation::Execute {
|
||||
command,
|
||||
@@ -1140,66 +1104,15 @@ impl AcpThreadView {
|
||||
}),
|
||||
))
|
||||
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
|
||||
.child(
|
||||
button_container
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow", tool_call_id.0),
|
||||
format!("Always Allow {root_command}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_confirmation_buttons(
|
||||
&[AlwaysAllowOption {
|
||||
id: "always_allow",
|
||||
label: format!("Always Allow {root_command}").into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
}],
|
||||
tool_call_id,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallConfirmation::Mcp {
|
||||
server_name,
|
||||
@@ -1220,87 +1133,22 @@ impl AcpThreadView {
|
||||
})),
|
||||
)
|
||||
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
|
||||
.child(
|
||||
button_container
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow_server", tool_call_id.0),
|
||||
format!("Always Allow {server_name}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(
|
||||
("always_allow_tool", tool_call_id.0),
|
||||
format!("Always Allow {tool_display_name}"),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_confirmation_buttons(
|
||||
&[
|
||||
AlwaysAllowOption {
|
||||
id: "always_allow_server",
|
||||
label: format!("Always Allow {server_name}").into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
|
||||
},
|
||||
AlwaysAllowOption {
|
||||
id: "always_allow_tool",
|
||||
label: format!("Always Allow {tool_display_name}").into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllowTool,
|
||||
},
|
||||
],
|
||||
tool_call_id,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallConfirmation::Fetch { description, urls } => confirmation_container
|
||||
.child(
|
||||
@@ -1328,63 +1176,15 @@ impl AcpThreadView {
|
||||
})),
|
||||
)
|
||||
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
|
||||
.child(
|
||||
button_container
|
||||
.child(
|
||||
Button::new(("always_allow", tool_call_id.0), "Always Allow")
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_confirmation_buttons(
|
||||
&[AlwaysAllowOption {
|
||||
id: "always_allow",
|
||||
label: "Always Allow".into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
}],
|
||||
tool_call_id,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallConfirmation::Other { description } => confirmation_container
|
||||
.child(v_flex().px_2().pb_1p5().child(self.render_markdown(
|
||||
@@ -1392,67 +1192,87 @@ impl AcpThreadView {
|
||||
default_markdown_style(false, window, cx),
|
||||
)))
|
||||
.children(content.map(|content| self.render_tool_call_content(content, window, cx)))
|
||||
.child(
|
||||
button_container
|
||||
.child(
|
||||
Button::new(("always_allow", tool_call_id.0), "Always Allow")
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(self.render_confirmation_buttons(
|
||||
&[AlwaysAllowOption {
|
||||
id: "always_allow",
|
||||
label: "Always Allow".into(),
|
||||
outcome: acp::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
}],
|
||||
tool_call_id,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_confirmation_buttons(
|
||||
&self,
|
||||
always_allow_options: &[AlwaysAllowOption],
|
||||
tool_call_id: ToolCallId,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
h_flex()
|
||||
.pt_1p5()
|
||||
.px_1p5()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.when(self.agent.supports_always_allow(), |this| {
|
||||
this.children(always_allow_options.into_iter().map(|always_allow_option| {
|
||||
let outcome = always_allow_option.outcome;
|
||||
Button::new(
|
||||
(always_allow_option.id, tool_call_id.0),
|
||||
always_allow_option.label.clone(),
|
||||
)
|
||||
.icon(IconName::CheckDouble)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(id, outcome, cx);
|
||||
}
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
Button::new(("allow", tool_call_id.0), "Allow")
|
||||
.icon(IconName::Check)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Success)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Allow,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("reject", tool_call_id.0), "Reject")
|
||||
.icon(IconName::X)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_color(Color::Error)
|
||||
.on_click(cx.listener({
|
||||
let id = tool_call_id;
|
||||
move |this, _, _, cx| {
|
||||
this.authorize_tool_call(
|
||||
id,
|
||||
acp::ToolCallConfirmationOutcome::Reject,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_diff_editor(&self, multibuffer: &Entity<MultiBuffer>) -> AnyElement {
|
||||
v_flex()
|
||||
.h_full()
|
||||
@@ -1466,15 +1286,15 @@ impl AcpThreadView {
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_gemini_logo(&self) -> AnyElement {
|
||||
Icon::new(IconName::AiGemini)
|
||||
fn render_agent_logo(&self) -> AnyElement {
|
||||
Icon::new(self.agent.logo())
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XLarge)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_error_gemini_logo(&self) -> AnyElement {
|
||||
let logo = Icon::new(IconName::AiGemini)
|
||||
fn render_error_agent_logo(&self) -> AnyElement {
|
||||
let logo = Icon::new(self.agent.logo())
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XLarge)
|
||||
.into_any_element();
|
||||
@@ -1493,49 +1313,50 @@ impl AcpThreadView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement {
|
||||
fn render_empty_state(&self, cx: &App) -> AnyElement {
|
||||
let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
if loading {
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.child(self.render_gemini_logo())
|
||||
.with_animation(
|
||||
"pulsating_icon",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.4, 1.0)),
|
||||
|icon, delta| icon.opacity(delta),
|
||||
).into_any()
|
||||
} else {
|
||||
self.render_gemini_logo().into_any_element()
|
||||
}
|
||||
)
|
||||
.child(
|
||||
.child(if loading {
|
||||
h_flex()
|
||||
.mt_4()
|
||||
.mb_1()
|
||||
.justify_center()
|
||||
.child(Headline::new(if loading {
|
||||
"Connecting to Gemini…"
|
||||
} else {
|
||||
"Welcome to Gemini"
|
||||
}).size(HeadlineSize::Medium)),
|
||||
)
|
||||
.child(self.render_agent_logo())
|
||||
.with_animation(
|
||||
"pulsating_icon",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.4, 1.0)),
|
||||
|icon, delta| icon.opacity(delta),
|
||||
)
|
||||
.into_any()
|
||||
} else {
|
||||
self.render_agent_logo().into_any_element()
|
||||
})
|
||||
.child(h_flex().mt_4().mb_1().justify_center().child(if loading {
|
||||
div()
|
||||
.child(LoadingLabel::new("").size(LabelSize::Large))
|
||||
.into_any_element()
|
||||
} else {
|
||||
Headline::new(self.agent.empty_state_headline())
|
||||
.size(HeadlineSize::Medium)
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.max_w_1_2()
|
||||
.text_sm()
|
||||
.text_center()
|
||||
.map(|this| if loading {
|
||||
this.invisible()
|
||||
} else {
|
||||
this.text_color(cx.theme().colors().text_muted)
|
||||
.map(|this| {
|
||||
if loading {
|
||||
this.invisible()
|
||||
} else {
|
||||
this.text_color(cx.theme().colors().text_muted)
|
||||
}
|
||||
})
|
||||
.child("Ask questions, edit files, run commands.\nBe specific for the best results.")
|
||||
.child(self.agent.empty_state_message()),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
@@ -1544,7 +1365,7 @@ impl AcpThreadView {
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(self.render_error_gemini_logo())
|
||||
.child(self.render_error_agent_logo())
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_4()
|
||||
@@ -1559,7 +1380,7 @@ impl AcpThreadView {
|
||||
let mut container = v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(self.render_error_gemini_logo())
|
||||
.child(self.render_error_agent_logo())
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_4()
|
||||
@@ -1575,43 +1396,47 @@ impl AcpThreadView {
|
||||
),
|
||||
);
|
||||
|
||||
if matches!(e, LoadError::Unsupported { .. }) {
|
||||
container =
|
||||
container.child(Button::new("upgrade", "Upgrade Gemini to Latest").on_click(
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
let cwd = project.first_project_directory(cx);
|
||||
let shell = project.terminal_settings(&cwd, cx).shell.clone();
|
||||
let command =
|
||||
"npm install -g @google/gemini-cli@latest".to_string();
|
||||
let spawn_in_terminal = task::SpawnInTerminal {
|
||||
id: task::TaskId("install".to_string()),
|
||||
full_label: command.clone(),
|
||||
label: command.clone(),
|
||||
command: Some(command.clone()),
|
||||
args: Vec::new(),
|
||||
command_label: command.clone(),
|
||||
cwd,
|
||||
env: Default::default(),
|
||||
use_new_terminal: true,
|
||||
allow_concurrent_runs: true,
|
||||
reveal: Default::default(),
|
||||
reveal_target: Default::default(),
|
||||
hide: Default::default(),
|
||||
shell,
|
||||
show_summary: true,
|
||||
show_command: true,
|
||||
show_rerun: false,
|
||||
};
|
||||
workspace
|
||||
.spawn_in_terminal(spawn_in_terminal, window, cx)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
));
|
||||
if let LoadError::Unsupported {
|
||||
upgrade_message,
|
||||
upgrade_command,
|
||||
..
|
||||
} = &e
|
||||
{
|
||||
let upgrade_message = upgrade_message.clone();
|
||||
let upgrade_command = upgrade_command.clone();
|
||||
container = container.child(Button::new("upgrade", upgrade_message).on_click(
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
let cwd = project.first_project_directory(cx);
|
||||
let shell = project.terminal_settings(&cwd, cx).shell.clone();
|
||||
let spawn_in_terminal = task::SpawnInTerminal {
|
||||
id: task::TaskId("install".to_string()),
|
||||
full_label: upgrade_command.clone(),
|
||||
label: upgrade_command.clone(),
|
||||
command: Some(upgrade_command.clone()),
|
||||
args: Vec::new(),
|
||||
command_label: upgrade_command.clone(),
|
||||
cwd,
|
||||
env: Default::default(),
|
||||
use_new_terminal: true,
|
||||
allow_concurrent_runs: true,
|
||||
reveal: Default::default(),
|
||||
reveal_target: Default::default(),
|
||||
hide: Default::default(),
|
||||
shell,
|
||||
show_summary: true,
|
||||
show_command: true,
|
||||
show_rerun: false,
|
||||
};
|
||||
workspace
|
||||
.spawn_in_terminal(spawn_in_terminal, window, cx)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
container.into_any()
|
||||
@@ -2267,20 +2092,23 @@ impl Render for AcpThreadView {
|
||||
.on_action(cx.listener(Self::next_history_message))
|
||||
.on_action(cx.listener(Self::open_agent_diff))
|
||||
.child(match &self.thread_state {
|
||||
ThreadState::Unauthenticated { .. } => v_flex()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(self.render_pending_auth_state())
|
||||
.child(h_flex().mt_1p5().justify_center().child(
|
||||
Button::new("sign-in", "Sign in to Gemini").on_click(
|
||||
cx.listener(|this, _, window, cx| this.authenticate(window, cx)),
|
||||
),
|
||||
)),
|
||||
ThreadState::Loading { .. } => {
|
||||
v_flex().flex_1().child(self.render_empty_state(true, cx))
|
||||
ThreadState::Unauthenticated { .. } => {
|
||||
v_flex()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(self.render_pending_auth_state())
|
||||
.child(
|
||||
h_flex().mt_1p5().justify_center().child(
|
||||
Button::new("sign-in", format!("Sign in to {}", self.agent.name()))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.authenticate(window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
|
||||
ThreadState::LoadError(e) => v_flex()
|
||||
.p_2()
|
||||
.flex_1()
|
||||
@@ -2321,7 +2149,7 @@ impl Render for AcpThreadView {
|
||||
})
|
||||
.children(self.render_edits_bar(&thread, window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty_state(false, cx))
|
||||
this.child(self.render_empty_state(cx))
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll};
|
||||
use acp::{AcpThread, AcpThreadEvent};
|
||||
use acp_thread::{AcpThread, AcpThreadEvent};
|
||||
use agent::{Thread, ThreadEvent, ThreadSummary};
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::Result;
|
||||
@@ -81,7 +81,7 @@ impl AgentDiffThread {
|
||||
match self {
|
||||
AgentDiffThread::Native(thread) => thread.read(cx).is_generating(),
|
||||
AgentDiffThread::AcpThread(thread) => {
|
||||
thread.read(cx).status() == acp::ThreadStatus::Generating
|
||||
thread.read(cx).status() == acp_thread::ThreadStatus::Generating
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_servers::AgentServer;
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::NewAcpThread;
|
||||
use crate::NewExternalAgentThread;
|
||||
use crate::agent_diff::AgentDiffThread;
|
||||
use crate::language_model_selector::ToggleModelSelector;
|
||||
use crate::{
|
||||
@@ -114,10 +115,12 @@ pub fn init(cx: &mut App) {
|
||||
panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx));
|
||||
}
|
||||
})
|
||||
.register_action(|workspace, _: &NewAcpThread, window, cx| {
|
||||
.register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx));
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_external_thread(action.agent, window, cx)
|
||||
});
|
||||
}
|
||||
})
|
||||
.register_action(|workspace, action: &OpenRulesLibrary, window, cx| {
|
||||
@@ -136,7 +139,7 @@ pub fn init(cx: &mut App) {
|
||||
let thread = thread.read(cx).thread().clone();
|
||||
AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx);
|
||||
}
|
||||
ActiveView::AcpThread { .. }
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -200,7 +203,7 @@ enum ActiveView {
|
||||
message_editor: Entity<MessageEditor>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
},
|
||||
AcpThread {
|
||||
ExternalAgentThread {
|
||||
thread_view: Entity<AcpThreadView>,
|
||||
},
|
||||
TextThread {
|
||||
@@ -222,9 +225,9 @@ enum WhichFontSize {
|
||||
impl ActiveView {
|
||||
pub fn which_font_size_used(&self) -> WhichFontSize {
|
||||
match self {
|
||||
ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => {
|
||||
WhichFontSize::AgentFont
|
||||
}
|
||||
ActiveView::Thread { .. }
|
||||
| ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::History => WhichFontSize::AgentFont,
|
||||
ActiveView::TextThread { .. } => WhichFontSize::BufferFont,
|
||||
ActiveView::Configuration => WhichFontSize::None,
|
||||
}
|
||||
@@ -255,7 +258,7 @@ impl ActiveView {
|
||||
thread.scroll_to_bottom(cx);
|
||||
});
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {}
|
||||
ActiveView::ExternalAgentThread { .. } => {}
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -674,7 +677,7 @@ impl AgentPanel {
|
||||
.clone()
|
||||
.update(cx, |thread, cx| thread.get_or_init_configured_model(cx));
|
||||
}
|
||||
ActiveView::AcpThread { .. }
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -757,7 +760,7 @@ impl AgentPanel {
|
||||
ActiveView::Thread { thread, .. } => {
|
||||
thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
|
||||
}
|
||||
ActiveView::AcpThread { thread_view, .. } => {
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => {
|
||||
thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx));
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
@@ -767,7 +770,7 @@ impl AgentPanel {
|
||||
fn active_message_editor(&self) -> Option<&Entity<MessageEditor>> {
|
||||
match &self.active_view {
|
||||
ActiveView::Thread { message_editor, .. } => Some(message_editor),
|
||||
ActiveView::AcpThread { .. }
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => None,
|
||||
@@ -889,35 +892,77 @@ impl AgentPanel {
|
||||
context_editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn new_external_thread(
|
||||
&mut self,
|
||||
agent_choice: Option<crate::ExternalAgent>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let workspace = self.workspace.clone();
|
||||
let project = self.project.clone();
|
||||
let message_history = self.acp_message_history.clone();
|
||||
|
||||
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct LastUsedExternalAgent {
|
||||
agent: crate::ExternalAgent,
|
||||
}
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let thread_view = cx.new_window_entity(|window, cx| {
|
||||
crate::acp::AcpThreadView::new(
|
||||
workspace.clone(),
|
||||
project,
|
||||
message_history,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
let server: Rc<dyn AgentServer> = match agent_choice {
|
||||
Some(agent) => {
|
||||
cx.background_spawn(async move {
|
||||
if let Some(serialized) =
|
||||
serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
|
||||
{
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
agent.server()
|
||||
}
|
||||
None => cx
|
||||
.background_spawn(async move {
|
||||
KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY)
|
||||
})
|
||||
.await
|
||||
.log_err()
|
||||
.flatten()
|
||||
.and_then(|value| {
|
||||
serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.agent
|
||||
.server(),
|
||||
};
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
let thread_view = cx.new(|cx| {
|
||||
crate::acp::AcpThreadView::new(
|
||||
server,
|
||||
workspace.clone(),
|
||||
project,
|
||||
message_history,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
this.set_active_view(
|
||||
ActiveView::AcpThread {
|
||||
ActiveView::ExternalAgentThread {
|
||||
thread_view: thread_view.clone(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn deploy_rules_library(
|
||||
@@ -1084,7 +1129,7 @@ impl AgentPanel {
|
||||
ActiveView::Thread { message_editor, .. } => {
|
||||
message_editor.focus_handle(cx).focus(window);
|
||||
}
|
||||
ActiveView::AcpThread { thread_view } => {
|
||||
ActiveView::ExternalAgentThread { thread_view } => {
|
||||
thread_view.focus_handle(cx).focus(window);
|
||||
}
|
||||
ActiveView::TextThread { context_editor, .. } => {
|
||||
@@ -1211,7 +1256,7 @@ impl AgentPanel {
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
ActiveView::AcpThread { .. }
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -1267,7 +1312,7 @@ impl AgentPanel {
|
||||
)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
ActiveView::AcpThread { thread_view } => {
|
||||
ActiveView::ExternalAgentThread { thread_view } => {
|
||||
thread_view
|
||||
.update(cx, |thread_view, cx| {
|
||||
thread_view.open_thread_as_markdown(workspace, window, cx)
|
||||
@@ -1428,7 +1473,7 @@ impl AgentPanel {
|
||||
}
|
||||
})
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {}
|
||||
ActiveView::ExternalAgentThread { .. } => {}
|
||||
ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
|
||||
@@ -1517,7 +1562,7 @@ impl Focusable for AgentPanel {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match &self.active_view {
|
||||
ActiveView::Thread { message_editor, .. } => message_editor.focus_handle(cx),
|
||||
ActiveView::AcpThread { thread_view, .. } => thread_view.focus_handle(cx),
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => thread_view.focus_handle(cx),
|
||||
ActiveView::History => self.history.focus_handle(cx),
|
||||
ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx),
|
||||
ActiveView::Configuration => {
|
||||
@@ -1674,9 +1719,11 @@ impl AgentPanel {
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx))
|
||||
.truncate()
|
||||
.into_any_element(),
|
||||
ActiveView::ExternalAgentThread { thread_view } => {
|
||||
Label::new(thread_view.read(cx).title(cx))
|
||||
.truncate()
|
||||
.into_any_element()
|
||||
}
|
||||
ActiveView::TextThread {
|
||||
title_editor,
|
||||
context_editor,
|
||||
@@ -1811,7 +1858,7 @@ impl AgentPanel {
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::AcpThread { .. }
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => None,
|
||||
@@ -1849,7 +1896,27 @@ impl AgentPanel {
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.separator()
|
||||
.header("External Agents")
|
||||
.action("New Gemini Thread", NewAcpThread.boxed_clone())
|
||||
.action(
|
||||
"New Gemini Thread",
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Gemini),
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"New Claude Code Thread",
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::ClaudeCode),
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"New Codex Thread",
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Codex),
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
});
|
||||
menu
|
||||
}))
|
||||
@@ -2090,7 +2157,11 @@ impl AgentPanel {
|
||||
|
||||
Some(element.into_any_element())
|
||||
}
|
||||
_ => None,
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2119,7 +2190,7 @@ impl AgentPanel {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
ActiveView::ExternalAgentThread { .. } => {
|
||||
return false;
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
|
||||
@@ -2706,7 +2777,7 @@ impl AgentPanel {
|
||||
) -> Option<AnyElement> {
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => thread,
|
||||
ActiveView::AcpThread { .. } => {
|
||||
ActiveView::ExternalAgentThread { .. } => {
|
||||
return None;
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {
|
||||
@@ -3055,7 +3126,7 @@ impl AgentPanel {
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {
|
||||
ActiveView::ExternalAgentThread { .. } => {
|
||||
unimplemented!()
|
||||
}
|
||||
ActiveView::TextThread { context_editor, .. } => {
|
||||
@@ -3077,7 +3148,7 @@ impl AgentPanel {
|
||||
let mut key_context = KeyContext::new_with_defaults();
|
||||
key_context.add("AgentPanel");
|
||||
match &self.active_view {
|
||||
ActiveView::AcpThread { .. } => key_context.add("acp_thread"),
|
||||
ActiveView::ExternalAgentThread { .. } => key_context.add("external_agent_thread"),
|
||||
ActiveView::TextThread { .. } => key_context.add("prompt_editor"),
|
||||
ActiveView::Thread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
@@ -3133,7 +3204,7 @@ impl Render for AgentPanel {
|
||||
});
|
||||
this.continue_conversation(window, cx);
|
||||
}
|
||||
ActiveView::AcpThread { .. } => {}
|
||||
ActiveView::ExternalAgentThread { .. } => {}
|
||||
ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => {}
|
||||
@@ -3175,7 +3246,7 @@ impl Render for AgentPanel {
|
||||
})
|
||||
.child(h_flex().child(message_editor.clone()))
|
||||
.child(self.render_drag_target(cx)),
|
||||
ActiveView::AcpThread { thread_view, .. } => parent
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => parent
|
||||
.relative()
|
||||
.child(thread_view.clone())
|
||||
.child(self.render_drag_target(cx)),
|
||||
|
||||
@@ -25,6 +25,7 @@ mod thread_history;
|
||||
mod tool_compatibility;
|
||||
mod ui;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent::{Thread, ThreadId};
|
||||
@@ -40,7 +41,7 @@ use language_model::{
|
||||
};
|
||||
use prompt_store::PromptBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings as _, SettingsStore};
|
||||
|
||||
pub use crate::active_thread::ActiveThread;
|
||||
@@ -57,8 +58,6 @@ actions!(
|
||||
[
|
||||
/// Creates a new text-based conversation thread.
|
||||
NewTextThread,
|
||||
/// Creates a new external agent conversation thread.
|
||||
NewAcpThread,
|
||||
/// Toggles the context picker interface for adding files, symbols, or other context.
|
||||
ToggleContextPicker,
|
||||
/// Toggles the navigation menu for switching between threads and views.
|
||||
@@ -133,6 +132,34 @@ pub struct NewThread {
|
||||
from_thread_id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
/// Creates a new external agent conversation thread.
|
||||
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NewExternalAgentThread {
|
||||
/// Which agent to use for the conversation.
|
||||
agent: Option<ExternalAgent>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ExternalAgent {
|
||||
#[default]
|
||||
Gemini,
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl ExternalAgent {
|
||||
pub fn server(&self) -> Rc<dyn agent_servers::AgentServer> {
|
||||
match self {
|
||||
ExternalAgent::Gemini => Rc::new(agent_servers::Gemini),
|
||||
ExternalAgent::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
|
||||
ExternalAgent::Codex => Rc::new(agent_servers::Codex),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the profile management interface for configuring agent tools and settings.
|
||||
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = agent)]
|
||||
|
||||
@@ -331,16 +331,17 @@ impl ActionLog {
|
||||
.get_mut(buffer)
|
||||
.context("buffer not tracked")?;
|
||||
|
||||
if let ChangeAuthor::User = author {
|
||||
tracked_buffer.has_unnotified_user_edits = true;
|
||||
}
|
||||
|
||||
let rebase = cx.background_spawn({
|
||||
let mut base_text = tracked_buffer.diff_base.clone();
|
||||
let old_snapshot = tracked_buffer.snapshot.clone();
|
||||
let new_snapshot = buffer_snapshot.clone();
|
||||
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||
if let ChangeAuthor::User = author
|
||||
&& !edits.is_empty()
|
||||
{
|
||||
tracked_buffer.has_unnotified_user_edits = true;
|
||||
}
|
||||
async move {
|
||||
if let ChangeAuthor::User = author {
|
||||
apply_non_conflicting_edits(
|
||||
|
||||
@@ -21,12 +21,14 @@ collections.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
net.workspace = true
|
||||
parking_lot.workspace = true
|
||||
postage.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
util.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
@@ -70,12 +70,12 @@ fn is_null_value<T: Serialize>(value: &T) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Request<'a, T> {
|
||||
jsonrpc: &'static str,
|
||||
id: RequestId,
|
||||
method: &'a str,
|
||||
pub struct Request<'a, T> {
|
||||
pub jsonrpc: &'static str,
|
||||
pub id: RequestId,
|
||||
pub method: &'a str,
|
||||
#[serde(skip_serializing_if = "is_null_value")]
|
||||
params: T,
|
||||
pub params: T,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -88,18 +88,18 @@ struct AnyResponse<'a> {
|
||||
result: Option<&'a RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Response<T> {
|
||||
jsonrpc: &'static str,
|
||||
id: RequestId,
|
||||
pub(crate) struct Response<T> {
|
||||
pub jsonrpc: &'static str,
|
||||
pub id: RequestId,
|
||||
#[serde(flatten)]
|
||||
value: CspResult<T>,
|
||||
pub value: CspResult<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum CspResult<T> {
|
||||
pub(crate) enum CspResult<T> {
|
||||
#[serde(rename = "result")]
|
||||
Ok(Option<T>),
|
||||
#[allow(dead_code)]
|
||||
@@ -123,8 +123,9 @@ struct AnyNotification<'a> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Error {
|
||||
message: String,
|
||||
pub(crate) struct Error {
|
||||
pub message: String,
|
||||
pub code: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod listener;
|
||||
pub mod protocol;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test;
|
||||
|
||||
236
crates/context_server/src/listener.rs
Normal file
236
crates/context_server/src/listener.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use ::serde::{Deserialize, Serialize};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use futures::{
|
||||
AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt,
|
||||
channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
|
||||
io::BufReader,
|
||||
select_biased,
|
||||
};
|
||||
use gpui::{App, AppContext, AsyncApp, Task};
|
||||
use net::async_net::{UnixListener, UnixStream};
|
||||
use serde_json::{json, value::RawValue};
|
||||
use smol::stream::StreamExt;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{
|
||||
client::{CspResult, RequestId, Response},
|
||||
types::Request,
|
||||
};
|
||||
|
||||
pub struct McpServer {
|
||||
socket_path: PathBuf,
|
||||
handlers: Rc<RefCell<HashMap<&'static str, McpHandler>>>,
|
||||
_server_task: Task<()>,
|
||||
}
|
||||
|
||||
type McpHandler = Box<dyn Fn(RequestId, Option<Box<RawValue>>, &App) -> Task<String>>;
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(cx: &AsyncApp) -> Task<Result<Self>> {
|
||||
let task = cx.background_spawn(async move {
|
||||
let temp_dir = tempfile::Builder::new().prefix("zed-mcp").tempdir()?;
|
||||
let socket_path = temp_dir.path().join("mcp.sock");
|
||||
let listener = UnixListener::bind(&socket_path).context("creating mcp socket")?;
|
||||
|
||||
anyhow::Ok((temp_dir, socket_path, listener))
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (temp_dir, socket_path, listener) = task.await?;
|
||||
let handlers = Rc::new(RefCell::new(HashMap::default()));
|
||||
let server_task = cx.spawn({
|
||||
let handlers = handlers.clone();
|
||||
async move |cx| {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
Self::serve_connection(stream, handlers.clone(), cx);
|
||||
}
|
||||
drop(temp_dir)
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
socket_path,
|
||||
_server_task: server_task,
|
||||
handlers: handlers.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_request<R: Request>(
|
||||
&mut self,
|
||||
f: impl Fn(R::Params, &App) -> Task<Result<R::Response>> + 'static,
|
||||
) {
|
||||
let f = Box::new(f);
|
||||
self.handlers.borrow_mut().insert(
|
||||
R::METHOD,
|
||||
Box::new(move |req_id, opt_params, cx| {
|
||||
let result = match opt_params {
|
||||
Some(params) => serde_json::from_str(params.get()),
|
||||
None => serde_json::from_value(serde_json::Value::Null),
|
||||
};
|
||||
|
||||
let params: R::Params = match result {
|
||||
Ok(params) => params,
|
||||
Err(e) => {
|
||||
return Task::ready(
|
||||
serde_json::to_string(&Response::<R::Response> {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Error(Some(crate::client::Error {
|
||||
message: format!("{e}"),
|
||||
code: -32700,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let task = f(params, cx);
|
||||
cx.background_spawn(async move {
|
||||
match task.await {
|
||||
Ok(result) => serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Ok(Some(result)),
|
||||
})
|
||||
.unwrap(),
|
||||
Err(e) => serde_json::to_string(&Response {
|
||||
jsonrpc: "2.0",
|
||||
id: req_id,
|
||||
value: CspResult::Error::<R::Response>(Some(crate::client::Error {
|
||||
message: format!("{e}"),
|
||||
code: -32603,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
fn serve_connection(
|
||||
stream: UnixStream,
|
||||
handlers: Rc<RefCell<HashMap<&'static str, McpHandler>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
let (read, write) = smol::io::split(stream);
|
||||
let (incoming_tx, mut incoming_rx) = unbounded();
|
||||
let (outgoing_tx, outgoing_rx) = unbounded();
|
||||
|
||||
cx.background_spawn(Self::handle_io(outgoing_rx, incoming_tx, write, read))
|
||||
.detach();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
while let Some(request) = incoming_rx.next().await {
|
||||
let Some(request_id) = request.id.clone() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(handler) = handlers.borrow().get(&request.method.as_ref()) {
|
||||
let outgoing_tx = outgoing_tx.clone();
|
||||
|
||||
if let Some(task) = cx
|
||||
.update(|cx| handler(request_id, request.params, cx))
|
||||
.log_err()
|
||||
{
|
||||
cx.spawn(async move |_| {
|
||||
let response = task.await;
|
||||
outgoing_tx.unbounded_send(response).ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
} else {
|
||||
outgoing_tx
|
||||
.unbounded_send(
|
||||
serde_json::to_string(&Response::<()> {
|
||||
jsonrpc: "2.0",
|
||||
id: request.id.unwrap(),
|
||||
value: CspResult::Error(Some(crate::client::Error {
|
||||
message: format!("unhandled method {}", request.method),
|
||||
code: -32601,
|
||||
})),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
async fn handle_io(
|
||||
mut outgoing_rx: UnboundedReceiver<String>,
|
||||
incoming_tx: UnboundedSender<RawRequest>,
|
||||
mut outgoing_bytes: impl Unpin + AsyncWrite,
|
||||
incoming_bytes: impl Unpin + AsyncRead,
|
||||
) -> Result<()> {
|
||||
let mut output_reader = BufReader::new(incoming_bytes);
|
||||
let mut incoming_line = String::new();
|
||||
loop {
|
||||
select_biased! {
|
||||
message = outgoing_rx.next().fuse() => {
|
||||
if let Some(message) = message {
|
||||
log::trace!("send: {}", &message);
|
||||
outgoing_bytes.write_all(message.as_bytes()).await?;
|
||||
outgoing_bytes.write_all(&[b'\n']).await?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
log::trace!("recv: {}", &incoming_line);
|
||||
match serde_json::from_str(&incoming_line) {
|
||||
Ok(message) => {
|
||||
incoming_tx.unbounded_send(message).log_err();
|
||||
}
|
||||
Err(error) => {
|
||||
outgoing_bytes.write_all(serde_json::to_string(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"error": json!({
|
||||
"code": -32603,
|
||||
"message": format!("Failed to parse: {error}"),
|
||||
}),
|
||||
}))?.as_bytes()).await?;
|
||||
outgoing_bytes.write_all(&[b'\n']).await?;
|
||||
log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
|
||||
}
|
||||
}
|
||||
incoming_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct RawRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<RequestId>,
|
||||
method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct RawResponse {
|
||||
jsonrpc: &'static str,
|
||||
id: RequestId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<crate::client::Error>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Box<serde_json::value::RawValue>>,
|
||||
}
|
||||
@@ -153,7 +153,7 @@ pub struct InitializeParams {
|
||||
pub struct CallToolParams {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<HashMap<String, serde_json::Value>>,
|
||||
pub arguments: Option<serde_json::Value>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ command_palette_hooks.workspace = true
|
||||
dap.workspace = true
|
||||
dap_adapters = { workspace = true, optional = true }
|
||||
db.workspace = true
|
||||
debugger_tools.workspace = true
|
||||
editor.workspace = true
|
||||
file_icons.workspace = true
|
||||
futures.workspace = true
|
||||
@@ -54,6 +55,7 @@ picker.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
project.workspace = true
|
||||
rpc.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
@@ -66,14 +68,13 @@ telemetry.workspace = true
|
||||
terminal_view.workspace = true
|
||||
text.workspace = true
|
||||
theme.workspace = true
|
||||
tree-sitter.workspace = true
|
||||
tree-sitter-json.workspace = true
|
||||
tree-sitter.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
debugger_tools.workspace = true
|
||||
unindent = { workspace = true, optional = true }
|
||||
util.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -83,8 +84,8 @@ debugger_tools = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
tree-sitter-go.workspace = true
|
||||
unindent.workspace = true
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
zlog.workspace = true
|
||||
tree-sitter-go.workspace = true
|
||||
|
||||
@@ -3,10 +3,12 @@ use std::any::TypeId;
|
||||
use dap::debugger_settings::DebuggerSettings;
|
||||
use debugger_panel::DebugPanel;
|
||||
use editor::Editor;
|
||||
use gpui::{App, DispatchPhase, EntityInputHandler, actions};
|
||||
use gpui::{Action, App, DispatchPhase, EntityInputHandler, actions};
|
||||
use new_process_modal::{NewProcessModal, NewProcessMode};
|
||||
use onboarding_modal::DebuggerOnboardingModal;
|
||||
use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::ThreadStatus};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use session::DebugSession;
|
||||
use settings::Settings;
|
||||
use stack_trace_view::StackTraceView;
|
||||
@@ -83,11 +85,23 @@ actions!(
|
||||
Rerun,
|
||||
/// Toggles expansion of the selected item in the debugger UI.
|
||||
ToggleExpandItem,
|
||||
/// Set a data breakpoint on the selected variable or memory region.
|
||||
ToggleDataBreakpoint,
|
||||
]
|
||||
);
|
||||
|
||||
/// Extends selection down by a specified number of lines.
|
||||
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
||||
#[action(namespace = debugger)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
/// Set a data breakpoint on the selected variable or memory region.
|
||||
pub struct ToggleDataBreakpoint {
|
||||
/// The type of data breakpoint
|
||||
/// Read & Write
|
||||
/// Read
|
||||
/// Write
|
||||
#[serde(default)]
|
||||
pub access_type: Option<dap::DataBreakpointAccessType>,
|
||||
}
|
||||
|
||||
actions!(
|
||||
dev,
|
||||
[
|
||||
|
||||
@@ -688,7 +688,7 @@ impl MemoryView {
|
||||
menu = menu.action_disabled_when(
|
||||
*memory_unreadable,
|
||||
"Set Data Breakpoint",
|
||||
ToggleDataBreakpoint.boxed_clone(),
|
||||
ToggleDataBreakpoint { access_type: None }.boxed_clone(),
|
||||
);
|
||||
}
|
||||
menu.context(self.focus_handle.clone())
|
||||
|
||||
@@ -670,9 +670,9 @@ impl VariableList {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let can_toggle_data_breakpoint = if let Some(task) = can_toggle_data_breakpoint {
|
||||
task.await.is_some()
|
||||
task.await
|
||||
} else {
|
||||
true
|
||||
None
|
||||
};
|
||||
cx.update(|window, cx| {
|
||||
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
|
||||
@@ -686,11 +686,35 @@ impl VariableList {
|
||||
menu.action("Go To Memory", GoToMemory.boxed_clone())
|
||||
})
|
||||
.action("Watch Variable", AddWatch.boxed_clone())
|
||||
.when(can_toggle_data_breakpoint, |menu| {
|
||||
menu.action(
|
||||
"Toggle Data Breakpoint",
|
||||
crate::ToggleDataBreakpoint.boxed_clone(),
|
||||
)
|
||||
.when_some(can_toggle_data_breakpoint, |mut menu, data_info| {
|
||||
menu = menu.separator();
|
||||
if let Some(access_types) = data_info.access_types {
|
||||
for access in access_types {
|
||||
menu = menu.action(
|
||||
format!(
|
||||
"Toggle {} Data Breakpoint",
|
||||
match access {
|
||||
dap::DataBreakpointAccessType::Read => "Read",
|
||||
dap::DataBreakpointAccessType::Write => "Write",
|
||||
dap::DataBreakpointAccessType::ReadWrite =>
|
||||
"Read/Write",
|
||||
}
|
||||
),
|
||||
crate::ToggleDataBreakpoint {
|
||||
access_type: Some(access),
|
||||
}
|
||||
.boxed_clone(),
|
||||
);
|
||||
}
|
||||
|
||||
menu
|
||||
} else {
|
||||
menu.action(
|
||||
"Toggle Data Breakpoint",
|
||||
crate::ToggleDataBreakpoint { access_type: None }
|
||||
.boxed_clone(),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(entry.as_watcher().is_some(), |menu| {
|
||||
@@ -729,7 +753,7 @@ impl VariableList {
|
||||
|
||||
fn toggle_data_breakpoint(
|
||||
&mut self,
|
||||
_: &crate::ToggleDataBreakpoint,
|
||||
data_info: &crate::ToggleDataBreakpoint,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -759,17 +783,34 @@ impl VariableList {
|
||||
});
|
||||
|
||||
let session = self.session.downgrade();
|
||||
let access_type = data_info.access_type;
|
||||
cx.spawn(async move |_, cx| {
|
||||
let Some(data_id) = data_breakpoint.await.and_then(|info| info.data_id) else {
|
||||
let Some((data_id, access_types)) = data_breakpoint
|
||||
.await
|
||||
.and_then(|info| Some((info.data_id?, info.access_types)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Because user's can manually add this action to the keymap
|
||||
// we check if access type is supported
|
||||
let access_type = match access_types {
|
||||
None => None,
|
||||
Some(access_types) => {
|
||||
if access_type.is_some_and(|access_type| access_types.contains(&access_type)) {
|
||||
access_type
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
_ = session.update(cx, |session, cx| {
|
||||
session.create_data_breakpoint(
|
||||
context,
|
||||
data_id.clone(),
|
||||
dap::DataBreakpoint {
|
||||
data_id,
|
||||
access_type: None,
|
||||
access_type,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
},
|
||||
|
||||
@@ -1074,6 +1074,20 @@ impl CompletionsMenu {
|
||||
.and_then(|q| q.chars().next())
|
||||
.and_then(|c| c.to_lowercase().next());
|
||||
|
||||
if snippet_sort_order == SnippetSortOrder::None {
|
||||
matches.retain(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
let is_snippet = matches!(
|
||||
&completion.source,
|
||||
CompletionSource::Lsp { lsp_completion, .. }
|
||||
if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
|
||||
);
|
||||
|
||||
!is_snippet
|
||||
});
|
||||
}
|
||||
|
||||
matches.sort_unstable_by_key(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
@@ -1112,6 +1126,7 @@ impl CompletionsMenu {
|
||||
SnippetSortOrder::Top => Reverse(if is_snippet { 1 } else { 0 }),
|
||||
SnippetSortOrder::Bottom => Reverse(if is_snippet { 0 } else { 1 }),
|
||||
SnippetSortOrder::Inline => Reverse(0),
|
||||
SnippetSortOrder::None => Reverse(0),
|
||||
};
|
||||
let sort_positions = string_match.positions.clone();
|
||||
let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
|
||||
|
||||
@@ -395,6 +395,8 @@ pub enum SnippetSortOrder {
|
||||
Inline,
|
||||
/// Place snippets at the bottom of the completion list
|
||||
Bottom,
|
||||
/// Do not show snippets in the completion list
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
|
||||
@@ -949,6 +949,7 @@ impl EditorElement {
|
||||
if !pending_nonempty_selections && hovered_link_modifier && text_hitbox.is_hovered(window) {
|
||||
let point = position_map.point_for_position(event.up.position);
|
||||
editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
|
||||
editor.selection_drag_state = SelectionDragState::None;
|
||||
|
||||
cx.stop_propagation();
|
||||
}
|
||||
|
||||
@@ -1313,10 +1313,17 @@ impl ExtensionStore {
|
||||
}
|
||||
|
||||
for snippets_path in &snippets_to_add {
|
||||
if let Some(snippets_contents) = fs.load(snippets_path).await.log_err() {
|
||||
proxy
|
||||
.register_snippet(snippets_path, &snippets_contents)
|
||||
.log_err();
|
||||
match fs
|
||||
.load(snippets_path)
|
||||
.await
|
||||
.with_context(|| format!("Loading snippets from {snippets_path:?}"))
|
||||
{
|
||||
Ok(snippets_contents) => {
|
||||
proxy
|
||||
.register_snippet(snippets_path, &snippets_contents)
|
||||
.log_err();
|
||||
}
|
||||
Err(e) => log::error!("Cannot load snippets: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1331,20 +1338,25 @@ impl ExtensionStore {
|
||||
|
||||
let extension_path = root_dir.join(extension.manifest.id.as_ref());
|
||||
let wasm_extension = WasmExtension::load(
|
||||
extension_path,
|
||||
&extension_path,
|
||||
&extension.manifest,
|
||||
wasm_host.clone(),
|
||||
&cx,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.with_context(|| format!("Loading extension from {extension_path:?}"));
|
||||
|
||||
if let Some(wasm_extension) = wasm_extension.log_err() {
|
||||
wasm_extensions.push((extension.manifest.clone(), wasm_extension));
|
||||
} else {
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(Event::ExtensionFailedToLoad(extension.manifest.id.clone()))
|
||||
})
|
||||
.ok();
|
||||
match wasm_extension {
|
||||
Ok(wasm_extension) => {
|
||||
wasm_extensions.push((extension.manifest.clone(), wasm_extension))
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load extension: {e:#}");
|
||||
this.update(cx, |_, cx| {
|
||||
cx.emit(Event::ExtensionFailedToLoad(extension.manifest.id.clone()))
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,9 +173,8 @@ impl HeadlessExtensionStore {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wasm_extension: Arc<dyn Extension> = Arc::new(
|
||||
WasmExtension::load(extension_dir.clone(), &manifest, wasm_host.clone(), &cx).await?,
|
||||
);
|
||||
let wasm_extension: Arc<dyn Extension> =
|
||||
Arc::new(WasmExtension::load(&extension_dir, &manifest, wasm_host.clone(), &cx).await?);
|
||||
|
||||
for (language_server_id, language_server_config) in &manifest.language_servers {
|
||||
for language in language_server_config.languages() {
|
||||
|
||||
@@ -715,7 +715,7 @@ fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVe
|
||||
|
||||
impl WasmExtension {
|
||||
pub async fn load(
|
||||
extension_dir: PathBuf,
|
||||
extension_dir: &Path,
|
||||
manifest: &Arc<ExtensionManifest>,
|
||||
wasm_host: Arc<WasmHost>,
|
||||
cx: &AsyncApp,
|
||||
|
||||
@@ -77,6 +77,8 @@ actions!(
|
||||
Commit,
|
||||
/// Amends the last commit with staged changes.
|
||||
Amend,
|
||||
/// Enable the --signoff option.
|
||||
Signoff,
|
||||
/// Cancels the current git operation.
|
||||
Cancel,
|
||||
/// Expands the commit message editor.
|
||||
|
||||
@@ -96,6 +96,7 @@ impl Upstream {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct CommitOptions {
|
||||
pub amend: bool,
|
||||
pub signoff: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
@@ -1209,6 +1210,10 @@ impl GitRepository for RealGitRepository {
|
||||
cmd.arg("--amend");
|
||||
}
|
||||
|
||||
if options.signoff {
|
||||
cmd.arg("--signoff");
|
||||
}
|
||||
|
||||
if let Some((name, email)) = name_and_email {
|
||||
cmd.arg("--author").arg(&format!("{name} <{email}>"));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::branch_picker::{self, BranchList};
|
||||
use crate::git_panel::{GitPanel, commit_message_editor};
|
||||
use git::repository::CommitOptions;
|
||||
use git::{Amend, Commit, GenerateCommitMessage};
|
||||
use panel::{panel_button, panel_editor_style, panel_filled_button};
|
||||
use git::{Amend, Commit, GenerateCommitMessage, Signoff};
|
||||
use panel::{panel_button, panel_editor_style};
|
||||
use ui::{
|
||||
ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
|
||||
};
|
||||
@@ -273,14 +273,51 @@ impl CommitModal {
|
||||
.child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
|
||||
),
|
||||
)
|
||||
.menu(move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
||||
context_menu
|
||||
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
||||
el.context(keybinding_target.clone())
|
||||
})
|
||||
.action("Amend", Amend.boxed_clone())
|
||||
}))
|
||||
.menu({
|
||||
let git_panel_entity = self.git_panel.clone();
|
||||
move |window, cx| {
|
||||
let git_panel = git_panel_entity.read(cx);
|
||||
let amend_enabled = git_panel.amend_pending();
|
||||
let signoff_enabled = git_panel.signoff_enabled();
|
||||
let has_previous_commit = git_panel.head_commit(cx).is_some();
|
||||
|
||||
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
||||
context_menu
|
||||
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
||||
el.context(keybinding_target.clone())
|
||||
})
|
||||
.when(has_previous_commit, |this| {
|
||||
this.toggleable_entry(
|
||||
"Amend",
|
||||
amend_enabled,
|
||||
IconPosition::Start,
|
||||
Some(Box::new(Amend)),
|
||||
{
|
||||
let git_panel = git_panel_entity.clone();
|
||||
move |window, cx| {
|
||||
git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.toggle_amend_pending(&Amend, window, cx);
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.toggleable_entry(
|
||||
"Signoff",
|
||||
signoff_enabled,
|
||||
IconPosition::Start,
|
||||
Some(Box::new(Signoff)),
|
||||
{
|
||||
let git_panel = git_panel_entity.clone();
|
||||
move |window, cx| {
|
||||
git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.toggle_signoff_enabled(&Signoff, window, cx);
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.with_handle(self.commit_menu_handle.clone())
|
||||
.anchor(Corner::TopRight)
|
||||
@@ -295,7 +332,7 @@ impl CommitModal {
|
||||
generate_commit_message,
|
||||
active_repo,
|
||||
is_amend_pending,
|
||||
has_previous_commit,
|
||||
is_signoff_enabled,
|
||||
) = self.git_panel.update(cx, |git_panel, cx| {
|
||||
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
|
||||
let title = git_panel.commit_button_title();
|
||||
@@ -303,10 +340,7 @@ impl CommitModal {
|
||||
let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
|
||||
let active_repo = git_panel.active_repository.clone();
|
||||
let is_amend_pending = git_panel.amend_pending();
|
||||
let has_previous_commit = active_repo
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).head_commit.as_ref())
|
||||
.is_some();
|
||||
let is_signoff_enabled = git_panel.signoff_enabled();
|
||||
(
|
||||
can_commit,
|
||||
tooltip,
|
||||
@@ -315,7 +349,7 @@ impl CommitModal {
|
||||
generate_commit_message,
|
||||
active_repo,
|
||||
is_amend_pending,
|
||||
has_previous_commit,
|
||||
is_signoff_enabled,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -396,126 +430,59 @@ impl CommitModal {
|
||||
.px_1()
|
||||
.gap_4()
|
||||
.children(close_kb_hint)
|
||||
.when(is_amend_pending, |this| {
|
||||
let focus_handle = focus_handle.clone();
|
||||
this.child(
|
||||
panel_filled_button(commit_label)
|
||||
.tooltip(move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::for_action_in(
|
||||
tooltip,
|
||||
&Amend,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
})
|
||||
.disabled(!can_commit)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
|
||||
telemetry::event!("Git Amended", source = "Git Modal");
|
||||
this.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.set_amend_pending(false, cx);
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: true },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
})),
|
||||
.child(SplitButton::new(
|
||||
ui::ButtonLike::new_rounded_left(ElementId::Name(
|
||||
format!("split-button-left-{}", commit_label).into(),
|
||||
))
|
||||
.layer(ui::ElevationIndex::ModalSurface)
|
||||
.size(ui::ButtonSize::Compact)
|
||||
.child(
|
||||
div()
|
||||
.child(Label::new(commit_label).size(LabelSize::Small))
|
||||
.mr_0p5(),
|
||||
)
|
||||
})
|
||||
.when(!is_amend_pending, |this| {
|
||||
this.when(has_previous_commit, |this| {
|
||||
this.child(SplitButton::new(
|
||||
ui::ButtonLike::new_rounded_left(ElementId::Name(
|
||||
format!("split-button-left-{}", commit_label).into(),
|
||||
))
|
||||
.layer(ui::ElevationIndex::ModalSurface)
|
||||
.size(ui::ButtonSize::Compact)
|
||||
.child(
|
||||
div()
|
||||
.child(Label::new(commit_label).size(LabelSize::Small))
|
||||
.mr_0p5(),
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Modal");
|
||||
this.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(
|
||||
CommitOptions {
|
||||
amend: is_amend_pending,
|
||||
signoff: is_signoff_enabled,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Modal");
|
||||
this.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: false },
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
.disabled(!can_commit)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
"git commit",
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
}
|
||||
}),
|
||||
self.render_git_commit_menu(
|
||||
ElementId::Name(
|
||||
format!("split-button-right-{}", commit_label).into(),
|
||||
),
|
||||
Some(focus_handle.clone()),
|
||||
)
|
||||
.into_any_element(),
|
||||
))
|
||||
})
|
||||
.when(!has_previous_commit, |this| {
|
||||
this.child(
|
||||
panel_filled_button(commit_label)
|
||||
.tooltip(move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
"git commit",
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
})
|
||||
.disabled(!can_commit)
|
||||
.on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, window, cx| {
|
||||
telemetry::event!(
|
||||
"Git Committed",
|
||||
source = "Git Modal"
|
||||
);
|
||||
this.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: false },
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
.disabled(!can_commit)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
format!(
|
||||
"git commit{}{}",
|
||||
if is_amend_pending { " --amend" } else { "" },
|
||||
if is_signoff_enabled { " --signoff" } else { "" }
|
||||
),
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
}
|
||||
}),
|
||||
self.render_git_commit_menu(
|
||||
ElementId::Name(format!("split-button-right-{}", commit_label).into()),
|
||||
Some(focus_handle.clone()),
|
||||
)
|
||||
.into_any_element(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -534,7 +501,14 @@ impl CommitModal {
|
||||
}
|
||||
telemetry::event!("Git Committed", source = "Git Modal");
|
||||
self.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(CommitOptions { amend: false }, window, cx)
|
||||
git_panel.commit_changes(
|
||||
CommitOptions {
|
||||
amend: false,
|
||||
signoff: git_panel.signoff_enabled(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
@@ -559,7 +533,14 @@ impl CommitModal {
|
||||
telemetry::event!("Git Amended", source = "Git Modal");
|
||||
self.git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.set_amend_pending(false, cx);
|
||||
git_panel.commit_changes(CommitOptions { amend: true }, window, cx);
|
||||
git_panel.commit_changes(
|
||||
CommitOptions {
|
||||
amend: true,
|
||||
signoff: git_panel.signoff_enabled(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use git::repository::{
|
||||
UpstreamTrackingStatus, get_git_committer,
|
||||
};
|
||||
use git::status::StageStatus;
|
||||
use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus};
|
||||
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
|
||||
use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
|
||||
use gpui::{
|
||||
Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
|
||||
@@ -61,10 +61,11 @@ use std::{collections::HashSet, sync::Arc, time::Duration, usize};
|
||||
use strum::{IntoEnumIterator, VariantNames};
|
||||
use time::OffsetDateTime;
|
||||
use ui::{
|
||||
Checkbox, ContextMenu, ElevationIndex, PopoverMenu, Scrollbar, ScrollbarState, SplitButton,
|
||||
Tooltip, prelude::*,
|
||||
Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
|
||||
ScrollbarState, SplitButton, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, TryFutureExt, maybe};
|
||||
use workspace::SERIALIZATION_THROTTLE_TIME;
|
||||
|
||||
use workspace::{
|
||||
Workspace,
|
||||
@@ -174,6 +175,10 @@ pub enum Event {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SerializedGitPanel {
|
||||
width: Option<Pixels>,
|
||||
#[serde(default)]
|
||||
amend_pending: bool,
|
||||
#[serde(default)]
|
||||
signoff_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
@@ -337,7 +342,8 @@ pub struct GitPanel {
|
||||
pending: Vec<PendingOperation>,
|
||||
pending_commit: Option<Task<()>>,
|
||||
amend_pending: bool,
|
||||
pending_serialization: Task<Option<()>>,
|
||||
signoff_enabled: bool,
|
||||
pending_serialization: Task<()>,
|
||||
pub(crate) project: Entity<Project>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
max_width_item_index: Option<usize>,
|
||||
@@ -512,7 +518,8 @@ impl GitPanel {
|
||||
pending: Vec::new(),
|
||||
pending_commit: None,
|
||||
amend_pending: false,
|
||||
pending_serialization: Task::ready(None),
|
||||
signoff_enabled: false,
|
||||
pending_serialization: Task::ready(()),
|
||||
single_staged_entry: None,
|
||||
single_tracked_entry: None,
|
||||
project,
|
||||
@@ -690,20 +697,54 @@ impl GitPanel {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn serialization_key(workspace: &Workspace) -> Option<String> {
|
||||
workspace
|
||||
.database_id()
|
||||
.map(|id| i64::from(id).to_string())
|
||||
.or(workspace.session_id())
|
||||
.map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
|
||||
}
|
||||
|
||||
fn serialize(&mut self, cx: &mut Context<Self>) {
|
||||
let width = self.width;
|
||||
self.pending_serialization = cx.background_spawn(
|
||||
async move {
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(
|
||||
GIT_PANEL_KEY.into(),
|
||||
serde_json::to_string(&SerializedGitPanel { width })?,
|
||||
)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
);
|
||||
let amend_pending = self.amend_pending;
|
||||
let signoff_enabled = self.signoff_enabled;
|
||||
|
||||
self.pending_serialization = cx.spawn(async move |git_panel, cx| {
|
||||
cx.background_executor()
|
||||
.timer(SERIALIZATION_THROTTLE_TIME)
|
||||
.await;
|
||||
let Some(serialization_key) = git_panel
|
||||
.update(cx, |git_panel, cx| {
|
||||
git_panel
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| Self::serialization_key(workspace))
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
cx.background_spawn(
|
||||
async move {
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(
|
||||
serialization_key,
|
||||
serde_json::to_string(&SerializedGitPanel {
|
||||
width,
|
||||
amend_pending,
|
||||
signoff_enabled,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
|
||||
@@ -1432,7 +1473,14 @@ impl GitPanel {
|
||||
.contains_focused(window, cx)
|
||||
{
|
||||
telemetry::event!("Git Committed", source = "Git Panel");
|
||||
self.commit_changes(CommitOptions { amend: false }, window, cx)
|
||||
self.commit_changes(
|
||||
CommitOptions {
|
||||
amend: false,
|
||||
signoff: self.signoff_enabled,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
cx.propagate();
|
||||
}
|
||||
@@ -1444,19 +1492,21 @@ impl GitPanel {
|
||||
.focus_handle(cx)
|
||||
.contains_focused(window, cx)
|
||||
{
|
||||
if self
|
||||
.active_repository
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).head_commit.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
if self.head_commit(cx).is_some() {
|
||||
if !self.amend_pending {
|
||||
self.set_amend_pending(true, cx);
|
||||
self.load_last_commit_message_if_empty(cx);
|
||||
} else {
|
||||
telemetry::event!("Git Amended", source = "Git Panel");
|
||||
self.set_amend_pending(false, cx);
|
||||
self.commit_changes(CommitOptions { amend: true }, window, cx);
|
||||
self.commit_changes(
|
||||
CommitOptions {
|
||||
amend: true,
|
||||
signoff: self.signoff_enabled,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1464,21 +1514,21 @@ impl GitPanel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
|
||||
self.active_repository
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).head_commit.as_ref())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.commit_editor.read(cx).is_empty(cx) {
|
||||
return;
|
||||
}
|
||||
let Some(active_repository) = self.active_repository.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(recent_sha) = active_repository
|
||||
.read(cx)
|
||||
.head_commit
|
||||
.as_ref()
|
||||
.map(|commit| commit.sha.to_string())
|
||||
else {
|
||||
let Some(head_commit) = self.head_commit(cx) else {
|
||||
return;
|
||||
};
|
||||
let recent_sha = head_commit.sha.to_string();
|
||||
let detail_task = self.load_commit_details(recent_sha, cx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Ok(message) = detail_task.await.map(|detail| detail.message) {
|
||||
@@ -1495,12 +1545,6 @@ impl GitPanel {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &git::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.amend_pending {
|
||||
self.set_amend_pending(false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_or_suggested_commit_message(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
@@ -3003,14 +3047,35 @@ impl GitPanel {
|
||||
.child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
|
||||
),
|
||||
)
|
||||
.menu(move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
||||
context_menu
|
||||
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
||||
el.context(keybinding_target.clone())
|
||||
})
|
||||
.action("Amend", Amend.boxed_clone())
|
||||
}))
|
||||
.menu({
|
||||
let has_previous_commit = self.head_commit(cx).is_some();
|
||||
let amend = self.amend_pending();
|
||||
let signoff = self.signoff_enabled;
|
||||
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
|
||||
context_menu
|
||||
.when_some(keybinding_target.clone(), |el, keybinding_target| {
|
||||
el.context(keybinding_target.clone())
|
||||
})
|
||||
.when(has_previous_commit, |this| {
|
||||
this.toggleable_entry(
|
||||
"Amend",
|
||||
amend,
|
||||
IconPosition::Start,
|
||||
Some(Box::new(Amend)),
|
||||
move |window, cx| window.dispatch_action(Box::new(Amend), cx),
|
||||
)
|
||||
})
|
||||
.toggleable_entry(
|
||||
"Signoff",
|
||||
signoff,
|
||||
IconPosition::Start,
|
||||
Some(Box::new(Signoff)),
|
||||
move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.anchor(Corner::TopRight)
|
||||
}
|
||||
@@ -3187,7 +3252,6 @@ impl GitPanel {
|
||||
let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
|
||||
editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
|
||||
});
|
||||
let has_previous_commit = head_commit.is_some();
|
||||
|
||||
let footer = v_flex()
|
||||
.child(PanelRepoFooter::new(
|
||||
@@ -3231,7 +3295,7 @@ impl GitPanel {
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.children(enable_coauthors)
|
||||
.child(self.render_commit_button(has_previous_commit, cx)),
|
||||
.child(self.render_commit_button(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -3280,14 +3344,12 @@ impl GitPanel {
|
||||
Some(footer)
|
||||
}
|
||||
|
||||
fn render_commit_button(
|
||||
&self,
|
||||
has_previous_commit: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (can_commit, tooltip) = self.configure_commit_button(cx);
|
||||
let title = self.commit_button_title();
|
||||
let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
|
||||
let amend = self.amend_pending();
|
||||
let signoff = self.signoff_enabled;
|
||||
|
||||
div()
|
||||
.id("commit-wrapper")
|
||||
@@ -3296,165 +3358,86 @@ impl GitPanel {
|
||||
*hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
|
||||
cx.notify()
|
||||
}))
|
||||
.when(self.amend_pending, {
|
||||
|this| {
|
||||
this.h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
panel_filled_button("Cancel")
|
||||
.tooltip({
|
||||
let handle = commit_tooltip_focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Cancel amend",
|
||||
&git::Cancel,
|
||||
&handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(git::Cancel), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
panel_filled_button(title)
|
||||
.tooltip({
|
||||
let handle = commit_tooltip_focus_handle.clone();
|
||||
move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::for_action_in(
|
||||
tooltip, &Amend, &handle, window, cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(!can_commit || self.modal_open)
|
||||
.on_click({
|
||||
let git_panel = cx.weak_entity();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Git Amended", source = "Git Panel");
|
||||
git_panel
|
||||
.update(cx, |git_panel, cx| {
|
||||
git_panel.set_amend_pending(false, cx);
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: true },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when(!self.amend_pending, |this| {
|
||||
this.when(has_previous_commit, |this| {
|
||||
this.child(SplitButton::new(
|
||||
ui::ButtonLike::new_rounded_left(ElementId::Name(
|
||||
format!("split-button-left-{}", title).into(),
|
||||
))
|
||||
.layer(ui::ElevationIndex::ModalSurface)
|
||||
.size(ui::ButtonSize::Compact)
|
||||
.child(
|
||||
div()
|
||||
.child(Label::new(title).size(LabelSize::Small))
|
||||
.mr_0p5(),
|
||||
)
|
||||
.on_click({
|
||||
let git_panel = cx.weak_entity();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Panel");
|
||||
git_panel
|
||||
.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: false },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.disabled(!can_commit || self.modal_open)
|
||||
.tooltip({
|
||||
let handle = commit_tooltip_focus_handle.clone();
|
||||
move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
"git commit",
|
||||
&handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
}
|
||||
}),
|
||||
self.render_git_commit_menu(
|
||||
ElementId::Name(format!("split-button-right-{}", title).into()),
|
||||
Some(commit_tooltip_focus_handle.clone()),
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
))
|
||||
})
|
||||
.when(!has_previous_commit, |this| {
|
||||
this.child(
|
||||
panel_filled_button(title)
|
||||
.tooltip(move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
"git commit",
|
||||
&commit_tooltip_focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
.child(SplitButton::new(
|
||||
ui::ButtonLike::new_rounded_left(ElementId::Name(
|
||||
format!("split-button-left-{}", title).into(),
|
||||
))
|
||||
.layer(ui::ElevationIndex::ModalSurface)
|
||||
.size(ui::ButtonSize::Compact)
|
||||
.child(
|
||||
div()
|
||||
.child(Label::new(title).size(LabelSize::Small))
|
||||
.mr_0p5(),
|
||||
)
|
||||
.on_click({
|
||||
let git_panel = cx.weak_entity();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Panel");
|
||||
git_panel
|
||||
.update(cx, |git_panel, cx| {
|
||||
git_panel.set_amend_pending(false, cx);
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend, signoff },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.disabled(!can_commit || self.modal_open)
|
||||
.on_click({
|
||||
let git_panel = cx.weak_entity();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Panel");
|
||||
git_panel
|
||||
.update(cx, |git_panel, cx| {
|
||||
git_panel.commit_changes(
|
||||
CommitOptions { amend: false },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
})
|
||||
.disabled(!can_commit || self.modal_open)
|
||||
.tooltip({
|
||||
let handle = commit_tooltip_focus_handle.clone();
|
||||
move |window, cx| {
|
||||
if can_commit {
|
||||
Tooltip::with_meta_in(
|
||||
tooltip,
|
||||
Some(&git::Commit),
|
||||
format!(
|
||||
"git commit{}{}",
|
||||
if amend { " --amend" } else { "" },
|
||||
if signoff { " --signoff" } else { "" }
|
||||
),
|
||||
&handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
Tooltip::simple(tooltip, cx)
|
||||
}
|
||||
}
|
||||
}),
|
||||
self.render_git_commit_menu(
|
||||
ElementId::Name(format!("split-button-right-{}", title).into()),
|
||||
Some(commit_tooltip_focus_handle.clone()),
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.p_2()
|
||||
h_flex()
|
||||
.py_1p5()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.justify_between()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.border_color(cx.theme().colors().border.opacity(0.8))
|
||||
.child(
|
||||
Label::new(
|
||||
"This will update your most recent commit. Cancel to make a new one instead.",
|
||||
)
|
||||
.size(LabelSize::Small),
|
||||
div()
|
||||
.flex_grow()
|
||||
.overflow_hidden()
|
||||
.max_w(relative(0.85))
|
||||
.child(
|
||||
Label::new("This will update your most recent commit.")
|
||||
.size(LabelSize::Small)
|
||||
.truncate(),
|
||||
),
|
||||
)
|
||||
.child(panel_button("Cancel").size(ButtonSize::Default).on_click(
|
||||
cx.listener(|this, _, window, cx| this.toggle_amend_pending(&Amend, window, cx)),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
@@ -4218,17 +4201,56 @@ impl GitPanel {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_amend_pending(
|
||||
&mut self,
|
||||
_: &Amend,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.set_amend_pending(!self.amend_pending, cx);
|
||||
self.serialize(cx);
|
||||
}
|
||||
|
||||
pub fn signoff_enabled(&self) -> bool {
|
||||
self.signoff_enabled
|
||||
}
|
||||
|
||||
pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
|
||||
self.signoff_enabled = value;
|
||||
self.serialize(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_signoff_enabled(
|
||||
&mut self,
|
||||
_: &Signoff,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.set_signoff_enabled(!self.signoff_enabled, cx);
|
||||
}
|
||||
|
||||
pub async fn load(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
mut cx: AsyncWindowContext,
|
||||
) -> anyhow::Result<Entity<Self>> {
|
||||
let serialized_panel = cx
|
||||
.background_spawn(async move { KEY_VALUE_STORE.read_kvp(&GIT_PANEL_KEY) })
|
||||
.await
|
||||
.context("loading git panel")
|
||||
.log_err()
|
||||
let serialized_panel = match workspace
|
||||
.read_with(&cx, |workspace, _| Self::serialization_key(workspace))
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|panel| serde_json::from_str::<SerializedGitPanel>(&panel).log_err());
|
||||
{
|
||||
Some(serialization_key) => cx
|
||||
.background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
|
||||
.await
|
||||
.context("loading git panel")
|
||||
.log_err()
|
||||
.flatten()
|
||||
.map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
|
||||
.transpose()
|
||||
.log_err()
|
||||
.flatten(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = GitPanel::new(workspace, window, cx);
|
||||
@@ -4236,6 +4258,8 @@ impl GitPanel {
|
||||
if let Some(serialized_panel) = serialized_panel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.width = serialized_panel.width;
|
||||
panel.amend_pending = serialized_panel.amend_pending;
|
||||
panel.signoff_enabled = serialized_panel.signoff_enabled;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
@@ -4320,7 +4344,8 @@ impl Render for GitPanel {
|
||||
.on_action(cx.listener(Self::stage_range))
|
||||
.on_action(cx.listener(GitPanel::commit))
|
||||
.on_action(cx.listener(GitPanel::amend))
|
||||
.on_action(cx.listener(GitPanel::cancel))
|
||||
.on_action(cx.listener(GitPanel::toggle_amend_pending))
|
||||
.on_action(cx.listener(GitPanel::toggle_signoff_enabled))
|
||||
.on_action(cx.listener(Self::stage_all))
|
||||
.on_action(cx.listener(Self::unstage_all))
|
||||
.on_action(cx.listener(Self::stage_selected))
|
||||
|
||||
@@ -822,11 +822,28 @@ impl crate::Keystroke {
|
||||
Keysym::underscore => "_".to_owned(),
|
||||
Keysym::equal => "=".to_owned(),
|
||||
Keysym::plus => "+".to_owned(),
|
||||
Keysym::space => "space".to_owned(),
|
||||
Keysym::BackSpace => "backspace".to_owned(),
|
||||
Keysym::Tab => "tab".to_owned(),
|
||||
Keysym::Delete => "delete".to_owned(),
|
||||
Keysym::Escape => "escape".to_owned(),
|
||||
|
||||
_ => {
|
||||
let name = xkb::keysym_get_name(key_sym).to_lowercase();
|
||||
if key_sym.is_keypad_key() {
|
||||
name.replace("kp_", "")
|
||||
} else if let Some(key) = key_utf8.chars().next()
|
||||
&& key_utf8.len() == 1
|
||||
&& key.is_ascii()
|
||||
{
|
||||
if key.is_ascii_graphic() {
|
||||
key_utf8.to_lowercase()
|
||||
// map ctrl-a to a
|
||||
} else if key_utf32 <= 0x1f {
|
||||
((key_utf32 as u8 + 0x60) as char).to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
} else if let Some(key_en) = guess_ascii(keycode, modifiers.shift) {
|
||||
String::from(key_en)
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub enum IconName {
|
||||
Ai,
|
||||
AiAnthropic,
|
||||
AiBedrock,
|
||||
AiClaude,
|
||||
AiDeepSeek,
|
||||
AiEdit,
|
||||
AiGemini,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use editor::{Bias, CompletionProvider, Editor, EditorEvent, EditorMode, ExcerptId, MultiBuffer};
|
||||
use editor::{
|
||||
Bias, CompletionProvider, Editor, EditorEvent, EditorMode, ExcerptId, MinimapVisibility,
|
||||
MultiBuffer,
|
||||
};
|
||||
use fuzzy::StringMatch;
|
||||
use gpui::{
|
||||
AsyncWindowContext, DivInspectorState, Entity, InspectorElementId, IntoElement,
|
||||
@@ -499,6 +502,7 @@ impl DivInspector {
|
||||
editor.set_show_git_diff_gutter(false, cx);
|
||||
editor.set_show_runnables(false, cx);
|
||||
editor.set_show_edit_predictions(Some(false), window, cx);
|
||||
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
|
||||
editor
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
; Functions names start with `Test`
|
||||
(
|
||||
(
|
||||
[
|
||||
(function_declaration name: (_) @run
|
||||
(#match? @run "^Test.*"))
|
||||
) @_
|
||||
(method_declaration
|
||||
receiver: (parameter_list
|
||||
(parameter_declaration
|
||||
name: (identifier) @_receiver_name
|
||||
type: [
|
||||
(pointer_type (type_identifier) @_receiver_type)
|
||||
(type_identifier) @_receiver_type
|
||||
]
|
||||
)
|
||||
)
|
||||
name: (field_identifier) @run @_method_name
|
||||
(#match? @_method_name "^Test.*"))
|
||||
] @_
|
||||
(#set! tag go-test)
|
||||
)
|
||||
|
||||
|
||||
@@ -633,7 +633,7 @@ impl LanguageServer {
|
||||
inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
|
||||
refresh_support: Some(true),
|
||||
}),
|
||||
diagnostic: Some(DiagnosticWorkspaceClientCapabilities {
|
||||
diagnostics: Some(DiagnosticWorkspaceClientCapabilities {
|
||||
refresh_support: Some(true),
|
||||
})
|
||||
.filter(|_| pull_diagnostics),
|
||||
|
||||
20
crates/nc/Cargo.toml
Normal file
20
crates/nc/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "nc"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/nc.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
net.workspace = true
|
||||
smol.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
1
crates/nc/LICENSE-GPL
Symbolic link
1
crates/nc/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
56
crates/nc/src/nc.rs
Normal file
56
crates/nc/src/nc.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn main(_socket: &str) -> Result<()> {
|
||||
// It looks like we can't get an async stdio stream on Windows from smol.
|
||||
//
|
||||
// We decided to merge this with a panic on Windows since this is only used
|
||||
// by the experimental Claude Code Agent Server.
|
||||
//
|
||||
// We're tracking this internally, and we will address it before shipping the integration.
|
||||
panic!("--nc isn't yet supported on Windows");
|
||||
}
|
||||
|
||||
/// The main function for when Zed is running in netcat mode
|
||||
#[cfg(not(windows))]
|
||||
pub fn main(socket: &str) -> Result<()> {
|
||||
use futures::{AsyncReadExt as _, AsyncWriteExt as _, FutureExt as _, io::BufReader, select};
|
||||
use net::async_net::UnixStream;
|
||||
use smol::{Async, io::AsyncBufReadExt};
|
||||
|
||||
smol::block_on(async {
|
||||
let socket_stream = UnixStream::connect(socket).await?;
|
||||
let (socket_read, mut socket_write) = socket_stream.split();
|
||||
let mut socket_reader = BufReader::new(socket_read);
|
||||
|
||||
let mut stdout = Async::new(std::io::stdout())?;
|
||||
let stdin = Async::new(std::io::stdin())?;
|
||||
let mut stdin_reader = BufReader::new(stdin);
|
||||
|
||||
let mut socket_line = Vec::new();
|
||||
let mut stdin_line = Vec::new();
|
||||
|
||||
loop {
|
||||
select! {
|
||||
bytes_read = socket_reader.read_until(b'\n', &mut socket_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
stdout.write_all(&socket_line).await?;
|
||||
stdout.flush().await?;
|
||||
socket_line.clear();
|
||||
}
|
||||
bytes_read = stdin_reader.read_until(b'\n', &mut stdin_line).fuse() => {
|
||||
if bytes_read? == 0 {
|
||||
break
|
||||
}
|
||||
socket_write.write_all(&stdin_line).await?;
|
||||
socket_write.flush().await?;
|
||||
stdin_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1738,6 +1738,7 @@ impl GitStore {
|
||||
name.zip(email),
|
||||
CommitOptions {
|
||||
amend: options.amend,
|
||||
signoff: options.signoff,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
@@ -3488,6 +3489,7 @@ impl Repository {
|
||||
email: email.map(String::from),
|
||||
options: Some(proto::commit::CommitOptions {
|
||||
amend: options.amend,
|
||||
signoff: options.signoff,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -298,6 +298,7 @@ message Commit {
|
||||
|
||||
message CommitOptions {
|
||||
bool amend = 1;
|
||||
bool signoff = 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ struct KeymapEditor {
|
||||
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
||||
previous_edit: Option<PreviousEdit>,
|
||||
humanized_action_names: HashMap<&'static str, SharedString>,
|
||||
show_hover_menus: bool,
|
||||
}
|
||||
|
||||
enum PreviousEdit {
|
||||
@@ -357,6 +358,7 @@ impl KeymapEditor {
|
||||
previous_edit: None,
|
||||
humanized_action_names,
|
||||
search_query_debounce: None,
|
||||
show_hover_menus: true,
|
||||
};
|
||||
|
||||
this.on_keymap_changed(cx);
|
||||
@@ -683,7 +685,7 @@ impl KeymapEditor {
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext {
|
||||
fn key_context(&self) -> KeyContext {
|
||||
let mut dispatch_context = KeyContext::new_with_defaults();
|
||||
dispatch_context.add("KeymapEditor");
|
||||
dispatch_context.add("menu");
|
||||
@@ -718,14 +720,19 @@ impl KeymapEditor {
|
||||
self.selected_index.take();
|
||||
}
|
||||
|
||||
fn selected_keybind_idx(&self) -> Option<usize> {
|
||||
fn selected_keybind_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
.and_then(|match_index| self.matches.get(match_index))
|
||||
.map(|r#match| r#match.candidate_id)
|
||||
}
|
||||
|
||||
fn selected_keybind_and_index(&self) -> Option<(&ProcessedKeybinding, usize)> {
|
||||
self.selected_keybind_index()
|
||||
.map(|keybind_index| (&self.keybindings[keybind_index], keybind_index))
|
||||
}
|
||||
|
||||
fn selected_binding(&self) -> Option<&ProcessedKeybinding> {
|
||||
self.selected_keybind_idx()
|
||||
self.selected_keybind_index()
|
||||
.and_then(|keybind_index| self.keybindings.get(keybind_index))
|
||||
}
|
||||
|
||||
@@ -757,40 +764,41 @@ impl KeymapEditor {
|
||||
let selected_binding_is_unbound = selected_binding.keystrokes().is_none();
|
||||
|
||||
let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Edit",
|
||||
Box::new(EditBinding),
|
||||
)
|
||||
.action("Create", Box::new(CreateBinding))
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Delete",
|
||||
Box::new(DeleteBinding),
|
||||
)
|
||||
.separator()
|
||||
.action("Copy Action", Box::new(CopyAction))
|
||||
.action_disabled_when(
|
||||
selected_binding_has_no_context,
|
||||
"Copy Context",
|
||||
Box::new(CopyContext),
|
||||
)
|
||||
.entry("Show matching keybindings", None, {
|
||||
let weak = weak.clone();
|
||||
let key_strokes = key_strokes.clone();
|
||||
menu.context(self.focus_handle.clone())
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Edit",
|
||||
Box::new(EditBinding),
|
||||
)
|
||||
.action("Create", Box::new(CreateBinding))
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Delete",
|
||||
Box::new(DeleteBinding),
|
||||
)
|
||||
.separator()
|
||||
.action("Copy Action", Box::new(CopyAction))
|
||||
.action_disabled_when(
|
||||
selected_binding_has_no_context,
|
||||
"Copy Context",
|
||||
Box::new(CopyContext),
|
||||
)
|
||||
.entry("Show matching keybindings", None, {
|
||||
let weak = weak.clone();
|
||||
let key_strokes = key_strokes.clone();
|
||||
|
||||
move |_, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.filter_state = FilterState::All;
|
||||
this.search_mode = SearchMode::KeyStroke { exact_match: true };
|
||||
move |_, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.filter_state = FilterState::All;
|
||||
this.search_mode = SearchMode::KeyStroke { exact_match: true };
|
||||
|
||||
this.keystroke_editor.update(cx, |editor, cx| {
|
||||
editor.set_keystrokes(key_strokes.clone(), cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
this.keystroke_editor.update(cx, |editor, cx| {
|
||||
editor.set_keystrokes(key_strokes.clone(), cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let context_menu_handle = context_menu.focus_handle(cx);
|
||||
@@ -819,6 +827,7 @@ impl KeymapEditor {
|
||||
}
|
||||
|
||||
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_hover_menus = false;
|
||||
if let Some(selected) = self.selected_index {
|
||||
let selected = selected + 1;
|
||||
if selected >= self.matches.len() {
|
||||
@@ -839,6 +848,7 @@ impl KeymapEditor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_hover_menus = false;
|
||||
if let Some(selected) = self.selected_index {
|
||||
if selected == 0 {
|
||||
return;
|
||||
@@ -864,6 +874,7 @@ impl KeymapEditor {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_hover_menus = false;
|
||||
if self.matches.get(0).is_some() {
|
||||
self.selected_index = Some(0);
|
||||
self.scroll_to_item(0, ScrollStrategy::Center, cx);
|
||||
@@ -872,6 +883,7 @@ impl KeymapEditor {
|
||||
}
|
||||
|
||||
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_hover_menus = false;
|
||||
if self.matches.last().is_some() {
|
||||
let index = self.matches.len() - 1;
|
||||
self.selected_index = Some(index);
|
||||
@@ -880,22 +892,17 @@ impl KeymapEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open_edit_keybinding_modal(false, window, cx);
|
||||
}
|
||||
|
||||
fn open_edit_keybinding_modal(
|
||||
&mut self,
|
||||
create: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((keybind_idx, keybind)) = self
|
||||
.selected_keybind_idx()
|
||||
.zip(self.selected_binding().cloned())
|
||||
else {
|
||||
self.show_hover_menus = false;
|
||||
let Some((keybind, keybind_index)) = self.selected_keybind_and_index() else {
|
||||
return;
|
||||
};
|
||||
let keybind = keybind.clone();
|
||||
let keymap_editor = cx.entity();
|
||||
|
||||
let arguments = keybind
|
||||
@@ -925,7 +932,7 @@ impl KeymapEditor {
|
||||
let modal = KeybindingEditorModal::new(
|
||||
create,
|
||||
keybind,
|
||||
keybind_idx,
|
||||
keybind_index,
|
||||
keymap_editor,
|
||||
workspace_weak,
|
||||
fs,
|
||||
@@ -1142,20 +1149,19 @@ impl Item for KeymapEditor {
|
||||
}
|
||||
|
||||
impl Render for KeymapEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
|
||||
let row_count = self.matches.len();
|
||||
let theme = cx.theme();
|
||||
|
||||
v_flex()
|
||||
.id("keymap-editor")
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(self.dispatch_context(window, cx))
|
||||
.key_context(self.key_context())
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_previous))
|
||||
.on_action(cx.listener(Self::select_first))
|
||||
.on_action(cx.listener(Self::select_last))
|
||||
.on_action(cx.listener(Self::focus_search))
|
||||
.on_action(cx.listener(Self::confirm))
|
||||
.on_action(cx.listener(Self::edit_binding))
|
||||
.on_action(cx.listener(Self::create_binding))
|
||||
.on_action(cx.listener(Self::delete_binding))
|
||||
@@ -1168,6 +1174,9 @@ impl Render for KeymapEditor {
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.bg(theme.colors().editor_background)
|
||||
.on_mouse_move(cx.listener(|this, _, _window, _cx| {
|
||||
this.show_hover_menus = true;
|
||||
}))
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
@@ -1269,6 +1278,18 @@ impl Render for KeymapEditor {
|
||||
"keystrokes-exact-match",
|
||||
IconName::Equal,
|
||||
)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
if exact_match {
|
||||
"Partial match mode"
|
||||
} else {
|
||||
"Exact match mode"
|
||||
},
|
||||
&ToggleExactKeystrokeMatching,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
@@ -1309,16 +1330,16 @@ impl Render for KeymapEditor {
|
||||
let binding = &this.keybindings[candidate_id];
|
||||
let action_name = binding.action_name;
|
||||
|
||||
let icon = (this.filter_state != FilterState::Conflicts
|
||||
&& this.has_conflict(index))
|
||||
.then(|| {
|
||||
let icon = if this.filter_state != FilterState::Conflicts
|
||||
&& this.has_conflict(index)
|
||||
{
|
||||
base_button_style(index, IconName::Warning)
|
||||
.icon_color(Color::Warning)
|
||||
.tooltip(|window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Edit Keybinding",
|
||||
None,
|
||||
"Use alt+click to show conflicts",
|
||||
"View conflicts",
|
||||
Some(&ToggleConflictFilter),
|
||||
"Use alt+click to show all conflicts",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1339,18 +1360,34 @@ impl Render for KeymapEditor {
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
.into_any_element()
|
||||
} else {
|
||||
base_button_style(index, IconName::Pencil)
|
||||
.visible_on_hover(row_group_id(index))
|
||||
.tooltip(Tooltip::text("Edit Keybinding"))
|
||||
.visible_on_hover(
|
||||
if this.selected_index == Some(index) {
|
||||
"".into()
|
||||
} else if this.show_hover_menus {
|
||||
row_group_id(index)
|
||||
} else {
|
||||
"never-show".into()
|
||||
},
|
||||
)
|
||||
.when(
|
||||
this.show_hover_menus && !context_menu_deployed,
|
||||
|this| {
|
||||
this.tooltip(Tooltip::for_action_title(
|
||||
"Edit Keybinding",
|
||||
&EditBinding,
|
||||
))
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.select_index(index, cx);
|
||||
this.open_edit_keybinding_modal(false, window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
})
|
||||
.into_any_element();
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let action = div()
|
||||
.id(("keymap action", index))
|
||||
@@ -1368,20 +1405,24 @@ impl Render for KeymapEditor {
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
.when(!context_menu_deployed, |this| {
|
||||
this.tooltip({
|
||||
let action_name = binding.action_name;
|
||||
let action_docs = binding.action_docs;
|
||||
move |_, cx| {
|
||||
let action_tooltip = Tooltip::new(action_name);
|
||||
let action_tooltip = match action_docs {
|
||||
Some(docs) => action_tooltip.meta(docs),
|
||||
None => action_tooltip,
|
||||
};
|
||||
cx.new(|_| action_tooltip).into()
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(
|
||||
!context_menu_deployed && this.show_hover_menus,
|
||||
|this| {
|
||||
this.tooltip({
|
||||
let action_name = binding.action_name;
|
||||
let action_docs = binding.action_docs;
|
||||
move |_, cx| {
|
||||
let action_tooltip =
|
||||
Tooltip::new(action_name);
|
||||
let action_tooltip = match action_docs {
|
||||
Some(docs) => action_tooltip.meta(docs),
|
||||
None => action_tooltip,
|
||||
};
|
||||
cx.new(|_| action_tooltip).into()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element();
|
||||
let keystrokes = binding.ui_key_binding.clone().map_or(
|
||||
binding.keystroke_text.clone().into_any_element(),
|
||||
@@ -1406,13 +1447,18 @@ impl Render for KeymapEditor {
|
||||
div()
|
||||
.id(("keymap context", index))
|
||||
.child(context.clone())
|
||||
.when(is_local && !context_menu_deployed, |this| {
|
||||
this.tooltip(Tooltip::element({
|
||||
move |_, _| {
|
||||
context.clone().into_any_element()
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when(
|
||||
is_local
|
||||
&& !context_menu_deployed
|
||||
&& this.show_hover_menus,
|
||||
|this| {
|
||||
this.tooltip(Tooltip::element({
|
||||
move |_, _| {
|
||||
context.clone().into_any_element()
|
||||
}
|
||||
}))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
},
|
||||
);
|
||||
@@ -2545,6 +2591,8 @@ impl KeystrokeInput {
|
||||
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
|
||||
self.keystrokes.push(Self::dummy(keystroke.modifiers));
|
||||
}
|
||||
} else if close_keystroke_result != CloseKeystrokeResult::Partial {
|
||||
self.clear_keystrokes(&ClearKeystrokes, window, cx);
|
||||
}
|
||||
}
|
||||
self.keystrokes_changed(cx);
|
||||
|
||||
@@ -22,9 +22,9 @@ use auto_update::AutoUpdateStatus;
|
||||
use call::ActiveCall;
|
||||
use client::{Client, UserStore};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, Context, Corner, Element, Entity, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription,
|
||||
WeakEntity, Window, actions, div,
|
||||
Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled,
|
||||
Subscription, WeakEntity, Window, actions, div,
|
||||
};
|
||||
use onboarding_banner::OnboardingBanner;
|
||||
use project::Project;
|
||||
@@ -504,7 +504,8 @@ impl TitleBar {
|
||||
)
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
let _ = workspace.update(cx, |_this, cx| {
|
||||
let _ = workspace.update(cx, |this, cx| {
|
||||
window.focus(&this.active_pane().focus_handle(cx));
|
||||
window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -972,12 +972,10 @@ impl ContextMenu {
|
||||
.children(action.as_ref().and_then(|action| {
|
||||
self.action_context
|
||||
.as_ref()
|
||||
.map(|focus| {
|
||||
.and_then(|focus| {
|
||||
KeyBinding::for_action_in(&**action, focus, window, cx)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
KeyBinding::for_action(&**action, window, cx)
|
||||
})
|
||||
.or_else(|| KeyBinding::for_action(&**action, window, cx))
|
||||
.map(|binding| {
|
||||
div().ml_4().child(binding.disabled(*disabled)).when(
|
||||
*disabled && documentation_aside.is_some(),
|
||||
|
||||
@@ -873,6 +873,8 @@ impl Render for PanelButtons {
|
||||
(action, icon_tooltip.into())
|
||||
};
|
||||
|
||||
let focus_handle = dock.focus_handle(cx);
|
||||
|
||||
Some(
|
||||
right_click_menu(name)
|
||||
.menu(move |window, cx| {
|
||||
@@ -909,6 +911,7 @@ impl Render for PanelButtons {
|
||||
.on_click({
|
||||
let action = action.boxed_clone();
|
||||
move |_, window, cx| {
|
||||
window.focus(&focus_handle);
|
||||
window.dispatch_action(action.boxed_clone(), cx)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -95,6 +95,7 @@ svg_preview.workspace = true
|
||||
menu.workspace = true
|
||||
migrator.workspace = true
|
||||
mimalloc = { version = "0.1", optional = true }
|
||||
nc.workspace = true
|
||||
nix = { workspace = true, features = ["pthread", "signal"] }
|
||||
node_runtime.workspace = true
|
||||
notifications.workspace = true
|
||||
|
||||
@@ -175,6 +175,17 @@ pub fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// `zed --nc` Makes zed operate in nc/netcat mode for use with MCP
|
||||
if let Some(socket) = &args.nc {
|
||||
match nc::main(socket) {
|
||||
Ok(()) => return,
|
||||
Err(err) => {
|
||||
eprintln!("Error: {}", err);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `zed --printenv` Outputs environment variables as JSON to stdout
|
||||
if args.printenv {
|
||||
util::shell_env::print_env();
|
||||
@@ -1168,6 +1179,11 @@ struct Args {
|
||||
#[arg(long, hide = true)]
|
||||
askpass: Option<String>,
|
||||
|
||||
/// Used for the MCP Server, to remove the need for netcat as a dependency,
|
||||
/// by having Zed act like netcat communicating over a Unix socket.
|
||||
#[arg(long, hide = true)]
|
||||
nc: Option<String>,
|
||||
|
||||
/// Run zed in the foreground, only used on Windows, to match the behavior on macOS.
|
||||
#[arg(long)]
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -639,6 +639,12 @@ List of `string` values
|
||||
"snippet_sort_order": "bottom"
|
||||
```
|
||||
|
||||
4. Do not show snippets in the completion list at all:
|
||||
|
||||
```json
|
||||
"snippet_sort_order": "none"
|
||||
```
|
||||
|
||||
## Editor Scrollbar
|
||||
|
||||
- Description: Whether or not to show the editor scrollbar and various elements in it.
|
||||
|
||||
@@ -177,8 +177,8 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W
|
||||
### Stepping granularity
|
||||
|
||||
- Description: The Step granularity that the debugger will use
|
||||
- Default: line
|
||||
- Setting: debugger.stepping_granularity
|
||||
- Default: `line`
|
||||
- Setting: `debugger.stepping_granularity`
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -217,8 +217,8 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W
|
||||
### Save Breakpoints
|
||||
|
||||
- Description: Whether the breakpoints should be saved across Zed sessions.
|
||||
- Default: true
|
||||
- Setting: debugger.save_breakpoints
|
||||
- Default: `true`
|
||||
- Setting: `debugger.save_breakpoints`
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -235,8 +235,8 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W
|
||||
### Button
|
||||
|
||||
- Description: Whether the button should be displayed in the debugger toolbar.
|
||||
- Default: true
|
||||
- Setting: debugger.show_button
|
||||
- Default: `true`
|
||||
- Setting: `debugger.show_button`
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -253,8 +253,8 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W
|
||||
### Timeout
|
||||
|
||||
- Description: Time in milliseconds until timeout error when connecting to a TCP debug adapter.
|
||||
- Default: 2000
|
||||
- Setting: debugger.timeout
|
||||
- Default: `2000`
|
||||
- Setting: `debugger.timeout`
|
||||
|
||||
**Options**
|
||||
|
||||
@@ -268,6 +268,24 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Values
|
||||
|
||||
- Description: Whether to enable editor inlay hints showing the values of variables in your code during debugging sessions.
|
||||
- Default: `true`
|
||||
- Setting: `inlay_hints.show_value_hints`
|
||||
|
||||
**Options**
|
||||
|
||||
```json
|
||||
{
|
||||
"inlay_hints": {
|
||||
"show_value_hints": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inline value hints can also be toggled from the Editor Controls menu in the editor toolbar.
|
||||
|
||||
### Log Dap Communications
|
||||
|
||||
- Description: Whether to log messages between active debug adapters and Zed. (Used for DAP development)
|
||||
|
||||
@@ -317,7 +317,7 @@ TBD: Centered layout related settings
|
||||
### Editor Completions, Snippets, Actions, Diagnostics {#editor-lsp}
|
||||
|
||||
```json
|
||||
"snippet_sort_order": "inline", // Snippets completions: top, inline, bottom
|
||||
"snippet_sort_order": "inline", // Snippets completions: top, inline, bottom, none
|
||||
"show_completions_on_input": true, // Show completions while typing
|
||||
"show_completion_documentation": true, // Show documentation in completions
|
||||
"auto_signature_help": false, // Show method signatures inside parentheses
|
||||
|
||||
Reference in New Issue
Block a user