diff --git a/.cargo/config.toml b/.cargo/config.toml index 717c5e18c8..8db58d2380 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -19,6 +19,8 @@ rustflags = [ "windows_slim_errors", # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes "-C", "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows + "-C", + "link-arg=-fuse-ld=lld", ] [env] diff --git a/.config/hakari.toml b/.config/hakari.toml index bd742b33cd..982542ca39 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -33,7 +33,6 @@ workspace-members = [ "zed_emmet", "zed_glsl", "zed_html", - "perplexity", "zed_proto", "zed_ruff", "slash_commands_example", diff --git a/.github/actions/install_trusted_signing/action.yml b/.github/actions/install_trusted_signing/action.yml new file mode 100644 index 0000000000..a99ff08eb1 --- /dev/null +++ b/.github/actions/install_trusted_signing/action.yml @@ -0,0 +1,64 @@ +name: "Trusted Signing on Windows" +description: "Install trusted signing on Windows." + +# Modified from https://github.com/Azure/trusted-signing-action +runs: + using: "composite" + steps: + - name: Set variables + id: set-variables + shell: "pwsh" + run: | + $defaultPath = $env:PSModulePath -split ';' | Select-Object -First 1 + "PSMODULEPATH=$defaultPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + "TRUSTED_SIGNING_MODULE_VERSION=0.5.3" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "BUILD_TOOLS_NUGET_VERSION=10.0.22621.3233" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "TRUSTED_SIGNING_NUGET_VERSION=1.0.53" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "DOTNET_SIGNCLI_NUGET_VERSION=0.9.1-beta.24469.1" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Cache TrustedSigning PowerShell module + id: cache-module + uses: actions/cache@v4 + env: + cache-name: cache-module + with: + path: ${{ steps.set-variables.outputs.PSMODULEPATH }}\TrustedSigning\${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }} + key: TrustedSigning-${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }} + if: ${{ inputs.cache-dependencies == 'true' }} + + - name: Cache Microsoft.Windows.SDK.BuildTools NuGet package + id: cache-buildtools + uses: actions/cache@v4 + env: + cache-name: cache-buildtools + with: + path: ~\AppData\Local\TrustedSigning\Microsoft.Windows.SDK.BuildTools\Microsoft.Windows.SDK.BuildTools.${{ steps.set-variables.outputs.BUILD_TOOLS_NUGET_VERSION }} + key: Microsoft.Windows.SDK.BuildTools-${{ steps.set-variables.outputs.BUILD_TOOLS_NUGET_VERSION }} + if: ${{ inputs.cache-dependencies == 'true' }} + + - name: Cache Microsoft.Trusted.Signing.Client NuGet package + id: cache-tsclient + uses: actions/cache@v4 + env: + cache-name: cache-tsclient + with: + path: ~\AppData\Local\TrustedSigning\Microsoft.Trusted.Signing.Client\Microsoft.Trusted.Signing.Client.${{ steps.set-variables.outputs.TRUSTED_SIGNING_NUGET_VERSION }} + key: Microsoft.Trusted.Signing.Client-${{ steps.set-variables.outputs.TRUSTED_SIGNING_NUGET_VERSION }} + if: ${{ inputs.cache-dependencies == 'true' }} + + - name: Cache SignCli NuGet package + id: cache-signcli + uses: actions/cache@v4 + env: + cache-name: cache-signcli + with: + path: ~\AppData\Local\TrustedSigning\sign\sign.${{ steps.set-variables.outputs.DOTNET_SIGNCLI_NUGET_VERSION }} + key: SignCli-${{ steps.set-variables.outputs.DOTNET_SIGNCLI_NUGET_VERSION }} + if: ${{ inputs.cache-dependencies == 'true' }} + + - name: Install Trusted Signing module + shell: "pwsh" + run: | + Install-Module -Name TrustedSigning -RequiredVersion ${{ steps.set-variables.outputs.TRUSTED_SIGNING_MODULE_VERSION }} -Force -Repository PSGallery + if: ${{ inputs.cache-dependencies != 'true' || steps.cache-module.outputs.cache-hit != 'true' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84c7a96828..25a1ed8670 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -411,11 +411,10 @@ jobs: with: clean: false - - name: Setup Cargo and Rustup + - name: Configure CI run: | - mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore - cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml - .\script\install-rustup.ps1 + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" - name: cargo clippy run: | @@ -430,18 +429,9 @@ jobs: - name: Limit target directory size run: ./script/clear-target-dir-if-larger-than.ps1 250 - # - name: Check dev drive space - # working-directory: ${{ env.ZED_WORKSPACE }} - # # `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive. - # run: ./script/exit-ci-if-dev-drive-is-full.ps1 95 - - # Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug. - name: Clean CI config file if: always() - run: | - if (Test-Path "${{ env.CARGO_HOME }}/config.toml") { - Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force - } + run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue tests_pass: name: Tests Pass @@ -763,12 +753,67 @@ jobs: # excludes the final package to only cache dependencies cachix-filter: "-zed-editor-[0-9.]*-nightly" + bundle-windows-x64: + timeout-minutes: 120 + name: Create a Windows installer + runs-on: [self-hosted, Windows, X64] + if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') }} + needs: [windows_tests] + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com" + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + - name: Determine version and release channel + working-directory: ${{ env.ZED_WORKSPACE }} + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + # This exports RELEASE_CHANNEL into env (GITHUB_ENV) + script/determine-release-channel.ps1 + + - name: Install trusted signing + uses: ./.github/actions/install_trusted_signing + + - name: Build Zed installer + working-directory: ${{ env.ZED_WORKSPACE }} + run: script/bundle-windows.ps1 + + - name: Upload installer (x86_64) to Workflow - zed (run-bundling) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: contains(github.event.pull_request.labels.*.name, 'run-bundling') + with: + name: ZedEditorUserSetup-x64-${{ github.event.pull_request.head.sha || github.sha }}.exe + path: ${{ env.SETUP_PATH }} + + - name: Upload Artifacts to release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 + if: ${{ !(contains(github.event.pull_request.labels.*.name, 'run-bundling')) && env.RELEASE_CHANNEL == 'preview' }} # upload only preview + with: + draft: true + prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} + files: ${{ env.SETUP_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + auto-release-preview: name: Auto release preview if: | startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') - needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, freebsd] + needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd] runs-on: - self-hosted - bundle diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index d9287cb082..df9f6ef40f 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -51,6 +51,32 @@ jobs: - name: Run tests uses: ./.github/actions/run_tests + windows-tests: + timeout-minutes: 60 + name: Run tests on Windows + if: github.repository_owner == 'zed-industries' + runs-on: [self-hosted, Windows, X64] + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + - name: Configure CI + run: | + New-Item -ItemType Directory -Path "./../.cargo" -Force + Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" + + - name: Run tests + uses: ./.github/actions/run_tests_windows + + - name: Limit target directory size + run: ./script/clear-target-dir-if-larger-than.ps1 1024 + + - name: Clean CI config file + if: always() + run: Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue + bundle-mac: timeout-minutes: 60 name: Create a macOS bundle @@ -213,10 +239,54 @@ jobs: bundle-nix: name: Build and cache Nix package + if: false needs: tests secrets: inherit uses: ./.github/workflows/nix.yml + bundle-windows-x64: + timeout-minutes: 60 + name: Create a Windows installer + if: github.repository_owner == 'zed-industries' + runs-on: [self-hosted, Windows, X64] + needs: windows-tests + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_SIGNING_CLIENT_SECRET }} + ACCOUNT_NAME: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }} + CERT_PROFILE_NAME: ${{ vars.AZURE_SIGNING_CERT_PROFILE_NAME }} + ENDPOINT: ${{ vars.AZURE_SIGNING_ENDPOINT }} + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + FILE_DIGEST: SHA256 + TIMESTAMP_DIGEST: SHA256 + TIMESTAMP_SERVER: "http://timestamp.acs.microsoft.com" + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + clean: false + + - name: Set release channel to nightly + working-directory: ${{ env.ZED_WORKSPACE }} + run: | + $ErrorActionPreference = "Stop" + $version = git rev-parse --short HEAD + Write-Host "Publishing version: $version on release channel nightly" + "nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL" + + - name: Install trusted signing + uses: ./.github/actions/install_trusted_signing + + - name: Build Zed installer + working-directory: ${{ env.ZED_WORKSPACE }} + run: script/bundle-windows.ps1 + + - name: Upload Zed Nightly + working-directory: ${{ env.ZED_WORKSPACE }} + run: script/upload-nightly.ps1 windows + update-nightly-tag: name: Update nightly tag if: github.repository_owner == 'zed-industries' @@ -225,6 +295,7 @@ jobs: - bundle-mac - bundle-linux-x86 - bundle-linux-arm + - bundle-windows-x64 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/Cargo.lock b/Cargo.lock index 58e482ee39..0815155ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acp" +version = "0.1.0" +dependencies = [ + "agent_servers", + "agentic-coding-protocol", + "anyhow", + "async-pipe", + "buffer_diff", + "editor", + "env_logger 0.11.8", + "futures 0.3.31", + "gpui", + "indoc", + "itertools 0.14.0", + "language", + "markdown", + "project", + "serde_json", + "settings", + "smol", + "tempfile", + "ui", + "util", + "workspace-hack", +] + [[package]] name = "activity_indicator" version = "0.1.0" @@ -107,6 +134,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "agent_servers" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "futures 0.3.31", + "gpui", + "paths", + "project", + "schemars", + "serde", + "settings", + "util", + "which 6.0.3", + "workspace-hack", +] + [[package]] name = "agent_settings" version = "0.1.0" @@ -130,8 +175,11 @@ dependencies = [ name = "agent_ui" version = "0.1.0" dependencies = [ + "acp", "agent", + "agent_servers", "agent_settings", + "agentic-coding-protocol", "anyhow", "assistant_context", "assistant_slash_command", @@ -191,6 +239,7 @@ dependencies = [ "settings", "smol", "streaming_diff", + "task", "telemetry", "telemetry_events", "terminal", @@ -212,6 +261,22 @@ dependencies = [ "zed_llm_client", ] +[[package]] +name = "agentic-coding-protocol" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b962eee17ee3924870d9b9d28cc8b6dcb5421e4d4e81cd864226374a122ceed1" +dependencies = [ + "anyhow", + "chrono", + "futures 0.3.31", + "log", + "parking_lot", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "ahash" version = "0.7.8" @@ -538,6 +603,8 @@ dependencies = [ "anyhow", "futures 0.3.31", "gpui", + "net", + "parking_lot", "smol", "tempfile", "util", @@ -5189,6 +5256,16 @@ dependencies = [ "libc", ] +[[package]] +name = "explorer_command_injector" +version = "0.1.0" +dependencies = [ + "windows 0.61.1", + "windows-core 0.61.0", + "windows-registry 0.5.1", + "workspace-hack", +] + [[package]] name = "exr" version = "1.73.0" @@ -8953,6 +9030,7 @@ dependencies = [ "credentials_provider", "deepseek", "editor", + "feature_flags", "fs", "futures 0.3.31", "google_ai", @@ -10231,6 +10309,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "net" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-io", + "smol", + "tempfile", + "windows 0.61.1", + "workspace-hack", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -11336,14 +11426,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "perplexity" -version = "0.1.0" -dependencies = [ - "serde", - "zed_extension_api 0.6.0", -] - [[package]] name = "pest" version = "2.8.0" @@ -12535,6 +12617,7 @@ dependencies = [ "prost 0.9.0", "prost-build 0.9.0", "serde", + "typed-path", "workspace-hack", ] @@ -13198,6 +13281,7 @@ dependencies = [ "fs", "futures 0.3.31", "git", + "git2", "git_hosting_providers", "gpui", "gpui_tokio", @@ -14059,6 +14143,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe8c9d1c68d67dd9f97ecbc6f932b60eb289c5dbddd8aa1405484a8fd2fcd984" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", @@ -17036,6 +17121,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" + [[package]] name = "typeid" version = "1.0.3" @@ -18282,6 +18373,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "feature_flags", "futures 0.3.31", "gpui", "http_client", @@ -19553,6 +19645,7 @@ dependencies = [ "rustix 1.0.7", "rustls 0.23.26", "rustls-webpki 0.103.1", + "schemars", "scopeguard", "sea-orm", "sea-query-binder", @@ -19946,10 +20039,11 @@ dependencies = [ [[package]] name = "zed" -version = "0.195.0" +version = "0.196.0" dependencies = [ "activity_indicator", "agent", + "agent_servers", "agent_settings", "agent_ui", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8dd7892329..aac12b7ff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,11 @@ resolver = "2" members = [ "crates/activity_indicator", + "crates/acp", "crates/agent_ui", "crates/agent", "crates/agent_settings", + "crates/agent_servers", "crates/anthropic", "crates/askpass", "crates/assets", @@ -45,6 +47,7 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/editor", + "crates/explorer_command_injector", "crates/eval", "crates/extension", "crates/extension_api", @@ -99,6 +102,7 @@ members = [ "crates/migrator", "crates/mistral", "crates/multi_buffer", + "crates/net", "crates/node_runtime", "crates/notifications", "crates/ollama", @@ -188,7 +192,6 @@ members = [ "extensions/emmet", "extensions/glsl", "extensions/html", - "extensions/perplexity", "extensions/proto", "extensions/ruff", "extensions/slash-commands-example", @@ -215,10 +218,12 @@ edition = "2024" # Workspace member crates # -activity_indicator = { path = "crates/activity_indicator" } +acp = { path = "crates/acp" } agent = { path = "crates/agent" } +activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } agent_settings = { path = "crates/agent_settings" } +agent_servers = { path = "crates/agent_servers" } ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } @@ -311,6 +316,7 @@ menu = { path = "crates/menu" } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } +net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } @@ -398,6 +404,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # +agentic-coding-protocol = "0.0.5" aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } any_vec = "0.14" @@ -624,6 +631,8 @@ wasmtime = { version = "29", default-features = false, features = [ ] } wasmtime-wasi = "29" which = "6.0.0" +windows-core = "0.61" +wit-component = "0.221" workspace-hack = "0.1.0" zed_llm_client = "= 0.8.6" zstd = "0.11" @@ -660,6 +669,7 @@ features = [ "Win32_Graphics_Gdi", "Win32_Graphics_Imaging", "Win32_Graphics_Imaging_D2D", + "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Credentials", "Win32_Storage_FileSystem", diff --git a/assets/icons/ai_gemini.svg b/assets/icons/ai_gemini.svg new file mode 100644 index 0000000000..60197dc4ad --- /dev/null +++ b/assets/icons/ai_gemini.svg @@ -0,0 +1 @@ +Google Gemini diff --git a/assets/icons/bolt_filled_alt.svg b/assets/icons/bolt_filled_alt.svg index 3c89387362..141e1c5f57 100644 --- a/assets/icons/bolt_filled_alt.svg +++ b/assets/icons/bolt_filled_alt.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/tool_bulb.svg b/assets/icons/tool_bulb.svg new file mode 100644 index 0000000000..54d5ac5fd7 --- /dev/null +++ b/assets/icons/tool_bulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg new file mode 100644 index 0000000000..9d3ac299d2 --- /dev/null +++ b/assets/icons/tool_folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tool_hammer.svg b/assets/icons/tool_hammer.svg new file mode 100644 index 0000000000..e66173ce70 --- /dev/null +++ b/assets/icons/tool_hammer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_pencil.svg b/assets/icons/tool_pencil.svg new file mode 100644 index 0000000000..b913015c08 --- /dev/null +++ b/assets/icons/tool_pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_regex.svg b/assets/icons/tool_regex.svg new file mode 100644 index 0000000000..0432cd570f --- /dev/null +++ b/assets/icons/tool_regex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_search.svg b/assets/icons/tool_search.svg new file mode 100644 index 0000000000..4f2750cfa2 --- /dev/null +++ b/assets/icons/tool_search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/tool_terminal.svg b/assets/icons/tool_terminal.svg new file mode 100644 index 0000000000..5154fa8e70 --- /dev/null +++ b/assets/icons/tool_terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/tool_web.svg b/assets/icons/tool_web.svg new file mode 100644 index 0000000000..49e9544b4a --- /dev/null +++ b/assets/icons/tool_web.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6f50945828..a19bc77dcc 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -306,6 +306,15 @@ "enter": "agent::AcceptSuggestedContext" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage" + } + }, { "context": "ThreadHistory", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index cbc90c05e6..875658c5a0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -357,6 +357,15 @@ "ctrl--": "pane::GoBack" } }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "up": "agent::PreviousHistoryMessage", + "down": "agent::NextHistoryMessage" + } + }, { "context": "ThreadHistory", "bindings": { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 106d85187b..b28086bb6e 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -189,6 +189,8 @@ "z shift-r": "editor::UnfoldAll", "z l": "vim::ColumnRight", "z h": "vim::ColumnLeft", + "z shift-l": "vim::HalfPageRight", + "z shift-h": "vim::HalfPageLeft", "shift-z shift-q": ["pane::CloseActiveItem", { "save_intent": "skip" }], "shift-z shift-z": ["pane::CloseActiveItem", { "save_intent": "save_all" }], // Count support @@ -218,35 +220,18 @@ "context": "vim_mode == normal", "bindings": { "ctrl-[": "editor::Cancel", - "escape": "editor::Cancel", ":": "command_palette::Toggle", "c": "vim::PushChange", "shift-c": "vim::ChangeToEndOfLine", "d": "vim::PushDelete", "delete": "vim::DeleteRight", - "shift-d": "vim::DeleteToEndOfLine", - "shift-j": "vim::JoinLines", "g shift-j": "vim::JoinLinesNoWhitespace", "y": "vim::PushYank", - "shift-y": "vim::YankLine", - "i": "vim::InsertBefore", - "shift-i": "vim::InsertFirstNonWhitespace", - "a": "vim::InsertAfter", - "shift-a": "vim::InsertEndOfLine", "x": "vim::DeleteRight", "shift-x": "vim::DeleteLeft", - "o": "vim::InsertLineBelow", - "shift-o": "vim::InsertLineAbove", - "~": "vim::ChangeCase", "ctrl-a": "vim::Increment", "ctrl-x": "vim::Decrement", - "p": "vim::Paste", - "shift-p": ["vim::Paste", { "before": true }], - "u": "vim::Undo", "ctrl-r": "vim::Redo", - "r": "vim::PushReplace", - "s": "vim::Substitute", - "shift-s": "vim::SubstituteLine", ">": "vim::PushIndent", "<": "vim::PushOutdent", "=": "vim::PushAutoIndent", @@ -256,11 +241,8 @@ "g ~": "vim::PushOppositeCase", "g ?": "vim::PushRot13", // "g ?": "vim::PushRot47", - "\"": "vim::PushRegister", "g w": "vim::PushRewrap", "g q": "vim::PushRewrap", - "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem", "insert": "vim::InsertBefore", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", @@ -364,18 +346,11 @@ } }, { - "context": "vim_mode == helix_normal && !menu", + "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", "bindings": { "escape": "editor::Cancel", - "ctrl-[": "editor::Cancel", - ":": "command_palette::Toggle", - "left": "vim::WrappingLeft", - "right": "vim::WrappingRight", - "h": "vim::WrappingLeft", - "l": "vim::WrappingRight", "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", - "y": "editor::Copy", "shift-y": "vim::YankLine", "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", @@ -390,27 +365,40 @@ "p": "vim::Paste", "shift-p": ["vim::Paste", { "before": true }], "u": "vim::Undo", + "shift-u": "vim::UndoLastLine", + "r": "vim::PushReplace", + "s": "vim::Substitute", + "shift-s": "vim::SubstituteLine", + "\"": "vim::PushRegister", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-pageup": "pane::ActivatePreviousItem" + } + }, + { + "context": "vim_mode == helix_normal && !menu", + "bindings": { + "ctrl-[": "editor::Cancel", + ":": "command_palette::Toggle", + "left": "vim::WrappingLeft", + "right": "vim::WrappingRight", + "h": "vim::WrappingLeft", + "l": "vim::WrappingRight", + "y": "editor::Copy", + "alt-;": "vim::OtherEnd", "ctrl-r": "vim::Redo", "f": ["vim::PushFindForward", { "before": false, "multiline": true }], "t": ["vim::PushFindForward", { "before": true, "multiline": true }], "shift-f": ["vim::PushFindBackward", { "after": false, "multiline": true }], "shift-t": ["vim::PushFindBackward", { "after": true, "multiline": true }], - "r": "vim::PushReplace", - "s": "vim::Substitute", - "shift-s": "vim::SubstituteLine", ">": "vim::Indent", "<": "vim::Outdent", "=": "vim::AutoIndent", "g u": "vim::PushLowercase", "g shift-u": "vim::PushUppercase", "g ~": "vim::PushOppositeCase", - "\"": "vim::PushRegister", "g q": "vim::PushRewrap", "g w": "vim::PushRewrap", - "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem", "insert": "vim::InsertBefore", - ".": "vim::Repeat", "alt-.": "vim::RepeatFind", // tree-sitter related commands "[ x": "editor::SelectLargerSyntaxNode", @@ -430,7 +418,6 @@ "g h": "vim::StartOfLine", "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g e": "vim::EndOfDocument", - "g y": "editor::GoToTypeDefinition", "g r": "editor::FindAllReferences", // zed specific "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", diff --git a/assets/settings/default.json b/assets/settings/default.json index 203c90f8ff..9e487a733d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -228,7 +228,12 @@ // Whether to show code action button at start of buffer line. "inline_code_actions": true, // Whether to allow drag and drop text selection in buffer. - "drag_and_drop_selection": true, + "drag_and_drop_selection": { + // When true, enables drag and drop text selection in buffer. + "enabled": true, + // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. + "delay": 300 + }, // What to do when go to definition yields no results. // // 1. Do nothing: `none` @@ -357,7 +362,9 @@ // Whether to show user picture in the titlebar. "show_user_picture": true, // Whether to show the sign in button in the titlebar. - "show_sign_in": true + "show_sign_in": true, + // Whether to show the menus in the titlebar. + "show_menus": false }, // Scrollbar related settings "scrollbar": { @@ -861,7 +868,11 @@ /// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. /// /// Default: true - "expand_edit_card": true + "expand_edit_card": true, + /// Whether to have terminal cards in the agent panel expanded, showing the whole command output. + /// + /// Default: true + "expand_terminal_card": true }, // The settings for slash commands. "slash_commands": { @@ -1599,6 +1610,9 @@ "use_on_type_format": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled" + }, "prettier": { "allowed": true } @@ -1612,6 +1626,9 @@ } }, "Plain Text": { + "completions": { + "words": "disabled" + }, "allow_rewrap": "anywhere" }, "Python": { @@ -1840,6 +1857,8 @@ "read_ssh_config": true, // Configures context servers for use by the agent. "context_servers": {}, + // Configures agent servers available in the agent panel. + "agent_servers": {}, "debugger": { "stepping_granularity": "line", "save_breakpoints": true, diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml new file mode 100644 index 0000000000..dae6292e28 --- /dev/null +++ b/crates/acp/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "acp" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/acp.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 +buffer_diff.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +itertools.workspace = true +language.workspace = true +markdown.workspace = true +project.workspace = true +settings.workspace = true +smol.workspace = true +ui.workspace = true +util.workspace = true +workspace-hack.workspace = true + +[dev-dependencies] +async-pipe.workspace = true +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 diff --git a/crates/acp/LICENSE-GPL b/crates/acp/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/acp/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs new file mode 100644 index 0000000000..ce83618288 --- /dev/null +++ b/crates/acp/src/acp.rs @@ -0,0 +1,1625 @@ +pub use acp::ToolCallId; +use agent_servers::AgentServer; +use agentic_coding_protocol::{self as acp, UserMessageChunk}; +use anyhow::{Context as _, Result, anyhow}; +use buffer_diff::BufferDiff; +use editor::{MultiBuffer, PathKey}; +use futures::{FutureExt, channel::oneshot, future::BoxFuture}; +use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; +use itertools::Itertools; +use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _}; +use markdown::Markdown; +use project::Project; +use std::error::Error; +use std::fmt::{Formatter, Write}; +use std::{ + fmt::Display, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{App, IconName}; +use util::ResultExt; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserMessage { + pub content: Entity, +} + +impl UserMessage { + pub fn from_acp( + message: acp::UserMessage, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let mut md_source = String::new(); + + for chunk in message.chunks { + match chunk { + UserMessageChunk::Text { chunk } => md_source.push_str(&chunk), + UserMessageChunk::Path { path } => { + write!(&mut md_source, "{}", MentionPath(&path)).unwrap() + } + } + } + + Self { + content: cx + .new(|cx| Markdown::new(md_source.into(), Some(language_registry), None, cx)), + } + } + + fn to_markdown(&self, cx: &App) -> String { + format!("## User\n\n{}\n\n", self.content.read(cx).source()) + } +} + +#[derive(Debug)] +pub struct MentionPath<'a>(&'a Path); + +impl<'a> MentionPath<'a> { + const PREFIX: &'static str = "@file:"; + + pub fn new(path: &'a Path) -> Self { + MentionPath(path) + } + + pub fn try_parse(url: &'a str) -> Option { + let path = url.strip_prefix(Self::PREFIX)?; + Some(MentionPath(Path::new(path))) + } + + pub fn path(&self) -> &Path { + self.0 + } +} + +impl Display for MentionPath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[@{}]({}{})", + self.0.file_name().unwrap_or_default().display(), + Self::PREFIX, + self.0.display() + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssistantMessage { + pub chunks: Vec, +} + +impl AssistantMessage { + fn to_markdown(&self, cx: &App) -> String { + format!( + "## Assistant\n\n{}\n\n", + self.chunks + .iter() + .map(|chunk| chunk.to_markdown(cx)) + .join("\n\n") + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssistantMessageChunk { + Text { chunk: Entity }, + Thought { chunk: Entity }, +} + +impl AssistantMessageChunk { + pub fn from_acp( + chunk: acp::AssistantMessageChunk, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match chunk { + acp::AssistantMessageChunk::Text { chunk } => Self::Text { + chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)), + }, + acp::AssistantMessageChunk::Thought { chunk } => Self::Thought { + chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)), + }, + } + } + + pub fn from_str(chunk: &str, language_registry: Arc, cx: &mut App) -> Self { + Self::Text { + chunk: cx.new(|cx| { + Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx) + }), + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Text { chunk } => chunk.read(cx).source().to_string(), + Self::Thought { chunk } => { + format!("\n{}\n", chunk.read(cx).source()) + } + } + } +} + +#[derive(Debug)] +pub enum AgentThreadEntry { + UserMessage(UserMessage), + AssistantMessage(AssistantMessage), + ToolCall(ToolCall), +} + +impl AgentThreadEntry { + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::UserMessage(message) => message.to_markdown(cx), + Self::AssistantMessage(message) => message.to_markdown(cx), + Self::ToolCall(too_call) => too_call.to_markdown(cx), + } + } +} + +#[derive(Debug)] +pub struct ToolCall { + pub id: acp::ToolCallId, + pub label: Entity, + pub icon: IconName, + pub content: Option, + pub status: ToolCallStatus, +} + +impl ToolCall { + fn to_markdown(&self, cx: &App) -> String { + let mut markdown = format!( + "**Tool Call: {}**\nStatus: {}\n\n", + self.label.read(cx).source(), + self.status + ); + if let Some(content) = &self.content { + markdown.push_str(content.to_markdown(cx).as_str()); + markdown.push_str("\n\n"); + } + markdown + } +} + +#[derive(Debug)] +pub enum ToolCallStatus { + WaitingForConfirmation { + confirmation: ToolCallConfirmation, + respond_tx: oneshot::Sender, + }, + Allowed { + status: acp::ToolCallStatus, + }, + Rejected, + Canceled, +} + +impl Display for ToolCallStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation", + ToolCallStatus::Allowed { status } => match status { + acp::ToolCallStatus::Running => "Running", + acp::ToolCallStatus::Finished => "Finished", + acp::ToolCallStatus::Error => "Error", + }, + ToolCallStatus::Rejected => "Rejected", + ToolCallStatus::Canceled => "Canceled", + } + ) + } +} + +#[derive(Debug)] +pub enum ToolCallConfirmation { + Edit { + description: Option>, + }, + Execute { + command: String, + root_command: String, + description: Option>, + }, + Mcp { + server_name: String, + tool_name: String, + tool_display_name: String, + description: Option>, + }, + Fetch { + urls: Vec, + description: Option>, + }, + Other { + description: Entity, + }, +} + +impl ToolCallConfirmation { + pub fn from_acp( + confirmation: acp::ToolCallConfirmation, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let to_md = |description: String, cx: &mut App| -> Entity { + cx.new(|cx| { + Markdown::new( + description.into(), + Some(language_registry.clone()), + None, + cx, + ) + }) + }; + + match confirmation { + acp::ToolCallConfirmation::Edit { description } => Self::Edit { + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Execute { + command, + root_command, + description, + } => Self::Execute { + command, + root_command, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Mcp { + server_name, + tool_name, + tool_display_name, + description, + } => Self::Mcp { + server_name, + tool_name, + tool_display_name, + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch { + urls: urls.iter().map(|url| url.into()).collect(), + description: description.map(|description| to_md(description, cx)), + }, + acp::ToolCallConfirmation::Other { description } => Self::Other { + description: to_md(description, cx), + }, + } + } +} + +#[derive(Debug)] +pub enum ToolCallContent { + Markdown { markdown: Entity }, + Diff { diff: Diff }, +} + +impl ToolCallContent { + pub fn from_acp( + content: acp::ToolCallContent, + language_registry: Arc, + cx: &mut App, + ) -> Self { + match content { + acp::ToolCallContent::Markdown { markdown } => Self::Markdown { + markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)), + }, + acp::ToolCallContent::Diff { diff } => Self::Diff { + diff: Diff::from_acp(diff, language_registry, cx), + }, + } + } + + fn to_markdown(&self, cx: &App) -> String { + match self { + Self::Markdown { markdown } => markdown.read(cx).source().to_string(), + Self::Diff { diff } => diff.to_markdown(cx), + } + } +} + +#[derive(Debug)] +pub struct Diff { + pub multibuffer: Entity, + pub path: PathBuf, + _task: Task>, +} + +impl Diff { + pub fn from_acp( + diff: acp::Diff, + language_registry: Arc, + cx: &mut App, + ) -> Self { + let acp::Diff { + path, + old_text, + new_text, + } = diff; + + let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); + + let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx)); + let new_buffer_snapshot = new_buffer.read(cx).text_snapshot(); + let old_buffer_snapshot = old_buffer.read(cx).snapshot(); + let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx)); + let diff_task = buffer_diff.update(cx, |diff, cx| { + diff.set_base_text( + old_buffer_snapshot, + Some(language_registry.clone()), + new_buffer_snapshot, + cx, + ) + }); + + let task = cx.spawn({ + let multibuffer = multibuffer.clone(); + let path = path.clone(); + async move |cx| { + diff_task.await?; + + multibuffer + .update(cx, |multibuffer, cx| { + let hunk_ranges = { + let buffer = new_buffer.read(cx); + let diff = buffer_diff.read(cx); + diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx) + .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer)) + .collect::>() + }; + + multibuffer.set_excerpts_for_path( + PathKey::for_buffer(&new_buffer, cx), + new_buffer.clone(), + hunk_ranges, + editor::DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + }) + .log_err(); + + if let Some(language) = language_registry + .language_for_file_path(&path) + .await + .log_err() + { + new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?; + } + + anyhow::Ok(()) + } + }); + + Self { + multibuffer, + path, + _task: task, + } + } + + fn to_markdown(&self, cx: &App) -> String { + let buffer_text = self + .multibuffer + .read(cx) + .all_buffers() + .iter() + .map(|buffer| buffer.read(cx).text()) + .join("\n"); + format!("Diff: {}\n```\n{}\n```\n", self.path.display(), buffer_text) + } +} + +pub struct AcpThread { + entries: Vec, + title: SharedString, + project: Entity, + send_task: Option>, + connection: Arc, + child_status: Option>>, + _io_task: Task<()>, +} + +pub enum AcpThreadEvent { + NewEntry, + EntryUpdated(usize), +} + +impl EventEmitter for AcpThread {} + +#[derive(PartialEq, Eq)] +pub enum ThreadStatus { + Idle, + WaitingForToolConfirmation, + Generating, +} + +#[derive(Debug, Clone)] +pub enum LoadError { + Unsupported { current_version: SharedString }, + Exited(i32), + Other(SharedString), +} + +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::Exited(status) => write!(f, "Server exited with status {}", status), + LoadError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for LoadError {} + +impl AcpThread { + pub async fn spawn( + server: impl AgentServer + 'static, + root_dir: &Path, + project: Entity, + cx: &mut AsyncApp, + ) -> Result> { + let command = match server.command(&project, cx).await { + Ok(command) => command, + Err(e) => return Err(anyhow!(LoadError::Other(format!("{e}").into()))), + }; + + 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 { + 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)))) + } + } + } + }); + + Self { + entries: Default::default(), + title: "ACP Thread".into(), + project, + send_task: None, + connection: Arc::new(connection), + child_status: Some(child_status), + _io_task: io_task, + } + }) + } + + #[cfg(test)] + pub fn fake( + stdin: async_pipe::PipeWriter, + stdout: async_pipe::PipeReader, + project: Entity, + cx: &mut Context, + ) -> 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(); + } + }); + + Self { + 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() + } + + pub fn entries(&self) -> &[AgentThreadEntry] { + &self.entries + } + + pub fn status(&self) -> ThreadStatus { + if self.send_task.is_some() { + if self.waiting_for_tool_confirmation() { + ThreadStatus::WaitingForToolConfirmation + } else { + ThreadStatus::Generating + } + } else { + ThreadStatus::Idle + } + } + + pub fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + self.entries.push(entry); + cx.emit(AcpThreadEvent::NewEntry); + } + + pub fn push_assistant_chunk( + &mut self, + chunk: acp::AssistantMessageChunk, + cx: &mut Context, + ) { + let entries_len = self.entries.len(); + if let Some(last_entry) = self.entries.last_mut() + && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry + { + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + + match (chunks.last_mut(), &chunk) { + ( + Some(AssistantMessageChunk::Text { chunk: old_chunk }), + acp::AssistantMessageChunk::Text { chunk: new_chunk }, + ) + | ( + Some(AssistantMessageChunk::Thought { chunk: old_chunk }), + acp::AssistantMessageChunk::Thought { chunk: new_chunk }, + ) => { + old_chunk.update(cx, |old_chunk, cx| { + old_chunk.append(&new_chunk, cx); + }); + } + _ => { + chunks.push(AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + )); + } + } + } else { + let chunk = AssistantMessageChunk::from_acp( + chunk, + self.project.read(cx).languages().clone(), + cx, + ); + + self.push_entry( + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks: vec![chunk], + }), + cx, + ); + } + } + + pub fn request_tool_call( + &mut self, + label: String, + icon: acp::Icon, + content: Option, + confirmation: acp::ToolCallConfirmation, + cx: &mut Context, + ) -> ToolCallRequest { + let (tx, rx) = oneshot::channel(); + + let status = ToolCallStatus::WaitingForConfirmation { + confirmation: ToolCallConfirmation::from_acp( + confirmation, + self.project.read(cx).languages().clone(), + cx, + ), + respond_tx: tx, + }; + + let id = self.insert_tool_call(label, status, icon, content, cx); + ToolCallRequest { id, outcome: rx } + } + + pub fn push_tool_call( + &mut self, + label: String, + icon: acp::Icon, + content: Option, + cx: &mut Context, + ) -> acp::ToolCallId { + let status = ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + }; + + self.insert_tool_call(label, status, icon, content, cx) + } + + fn insert_tool_call( + &mut self, + label: String, + status: ToolCallStatus, + icon: acp::Icon, + content: Option, + cx: &mut Context, + ) -> acp::ToolCallId { + let language_registry = self.project.read(cx).languages().clone(); + let id = acp::ToolCallId(self.entries.len() as u64); + + self.push_entry( + AgentThreadEntry::ToolCall(ToolCall { + id, + label: cx.new(|cx| { + Markdown::new(label.into(), Some(language_registry.clone()), None, cx) + }), + icon: acp_icon_to_ui_icon(icon), + content: content + .map(|content| ToolCallContent::from_acp(content, language_registry, cx)), + status, + }), + cx, + ); + + id + } + + pub fn authorize_tool_call( + &mut self, + id: acp::ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { + let Some((ix, call)) = self.tool_call_mut(id) else { + return; + }; + + let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject { + ToolCallStatus::Rejected + } else { + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + } + }; + + let curr_status = mem::replace(&mut call.status, new_status); + + if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status { + respond_tx.send(outcome).log_err(); + } else if cfg!(debug_assertions) { + panic!("tried to authorize an already authorized tool call"); + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + + pub fn update_tool_call( + &mut self, + id: acp::ToolCallId, + new_status: acp::ToolCallStatus, + new_content: Option, + cx: &mut Context, + ) -> Result<()> { + let language_registry = self.project.read(cx).languages().clone(); + let (ix, call) = self.tool_call_mut(id).context("Entry not found")?; + + call.content = new_content + .map(|new_content| ToolCallContent::from_acp(new_content, language_registry, cx)); + + match &mut call.status { + ToolCallStatus::Allowed { status } => { + *status = new_status; + } + ToolCallStatus::WaitingForConfirmation { .. } => { + anyhow::bail!("Tool call hasn't been authorized yet") + } + ToolCallStatus::Rejected => { + anyhow::bail!("Tool call was rejected and therefore can't be updated") + } + ToolCallStatus::Canceled => { + call.status = ToolCallStatus::Allowed { status: new_status }; + } + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + Ok(()) + } + + fn tool_call_mut(&mut self, id: acp::ToolCallId) -> Option<(usize, &mut ToolCall)> { + let entry = self.entries.get_mut(id.0 as usize); + debug_assert!( + entry.is_some(), + "We shouldn't give out ids to entries that don't exist" + ); + match entry { + Some(AgentThreadEntry::ToolCall(call)) if call.id == id => Some((id.0 as usize, call)), + _ => { + if cfg!(debug_assertions) { + panic!("entry is not a tool call"); + } + None + } + } + } + + /// Returns true if the last turn is awaiting tool authorization + pub fn waiting_for_tool_confirmation(&self) -> bool { + for entry in self.entries.iter().rev() { + match &entry { + AgentThreadEntry::ToolCall(call) => match call.status { + ToolCallStatus::WaitingForConfirmation { .. } => return true, + ToolCallStatus::Allowed { .. } + | ToolCallStatus::Rejected + | ToolCallStatus::Canceled => continue, + }, + AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => { + // Reached the beginning of the turn + return false; + } + } + } + false + } + + pub fn initialize(&self) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { Ok(connection.request(acp::InitializeParams).await?) } + } + + pub fn authenticate(&self) -> impl use<> + Future> { + let connection = self.connection.clone(); + async move { Ok(connection.request(acp::AuthenticateParams).await?) } + } + + pub fn send( + &mut self, + message: impl Into, + cx: &mut Context, + ) -> BoxFuture<'static, Result<()>> { + let agent = self.connection.clone(); + let message = message.into(); + self.push_entry( + AgentThreadEntry::UserMessage(UserMessage::from_acp( + message.clone(), + self.project.read(cx).languages().clone(), + cx, + )), + cx, + ); + + let (tx, rx) = oneshot::channel(); + let cancel = self.cancel(cx); + + self.send_task = Some(cx.spawn(async move |this, cx| { + cancel.await.log_err(); + + let result = agent.request(acp::SendUserMessageParams { message }).await; + tx.send(result).log_err(); + this.update(cx, |this, _cx| this.send_task.take()).log_err(); + })); + + async move { + match rx.await { + Ok(Err(e)) => Err(e)?, + _ => Ok(()), + } + } + .boxed() + } + + pub fn cancel(&mut self, cx: &mut Context) -> Task> { + let agent = self.connection.clone(); + + if self.send_task.take().is_some() { + cx.spawn(async move |this, cx| { + agent.request(acp::CancelSendMessageParams).await?; + + this.update(cx, |this, _cx| { + for entry in this.entries.iter_mut() { + if let AgentThreadEntry::ToolCall(call) = entry { + let cancel = matches!( + call.status, + ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running + } + ); + + if cancel { + let curr_status = + mem::replace(&mut call.status, ToolCallStatus::Canceled); + + if let ToolCallStatus::WaitingForConfirmation { + respond_tx, .. + } = curr_status + { + respond_tx + .send(acp::ToolCallConfirmationOutcome::Cancel) + .ok(); + } + } + } + } + }) + }) + } else { + Task::ready(Ok(())) + } + } + + pub fn child_status(&mut self) -> Option>> { + self.child_status.take() + } + + pub fn to_markdown(&self, cx: &App) -> String { + self.entries.iter().map(|e| e.to_markdown(cx)).collect() + } +} + +struct AcpClientDelegate { + thread: WeakEntity, + cx: AsyncApp, + // sent_buffer_versions: HashMap, HashMap>, +} + +impl AcpClientDelegate { + fn new(thread: WeakEntity, cx: AsyncApp) -> Self { + Self { thread, cx } + } +} + +impl acp::Client for AcpClientDelegate { + async fn stream_assistant_message_chunk( + &self, + params: acp::StreamAssistantMessageChunkParams, + ) -> Result<()> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread + .update(cx, |thread, cx| { + thread.push_assistant_chunk(params.chunk, cx) + }) + .ok(); + })?; + + Ok(()) + } + + async fn request_tool_call_confirmation( + &self, + request: acp::RequestToolCallConfirmationParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let ToolCallRequest { id, outcome } = cx + .update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.request_tool_call( + request.label, + request.icon, + request.content, + request.confirmation, + cx, + ) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::RequestToolCallConfirmationResponse { + id, + outcome: outcome.await?, + }) + } + + async fn push_tool_call( + &self, + request: acp::PushToolCallParams, + ) -> Result { + let cx = &mut self.cx.clone(); + let id = cx + .update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.push_tool_call(request.label, request.icon, request.content, cx) + }) + })? + .context("Failed to update thread")?; + + Ok(acp::PushToolCallResponse { id }) + } + + async fn update_tool_call(&self, request: acp::UpdateToolCallParams) -> Result<()> { + let cx = &mut self.cx.clone(); + + cx.update(|cx| { + self.thread.update(cx, |thread, cx| { + thread.update_tool_call(request.tool_call_id, request.status, request.content, cx) + }) + })? + .context("Failed to update thread")??; + + Ok(()) + } +} + +fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName { + match icon { + acp::Icon::FileSearch => IconName::ToolSearch, + acp::Icon::Folder => IconName::ToolFolder, + acp::Icon::Globe => IconName::ToolWeb, + acp::Icon::Hammer => IconName::ToolHammer, + acp::Icon::LightBulb => IconName::ToolBulb, + acp::Icon::Pencil => IconName::ToolPencil, + acp::Icon::Regex => IconName::ToolRegex, + acp::Icon::Terminal => IconName::ToolTerminal, + } +} + +pub struct ToolCallRequest { + pub id: acp::ToolCallId, + pub outcome: oneshot::Receiver, +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_servers::{AgentServerCommand, AgentServerVersion}; + use async_pipe::{PipeReader, PipeWriter}; + use futures::{channel::mpsc, future::LocalBoxFuture, select}; + use gpui::{AsyncApp, TestAppContext}; + use indoc::indoc; + use project::FakeFs; + 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 util::path; + + fn init_test(cx: &mut TestAppContext) { + 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); + }); + } + + #[gpui::test] + async fn test_thinking_concatenation(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, fake_server) = fake_acp_thread(project, cx); + + fake_server.update(cx, |fake_server, _| { + fake_server.on_user_message(move |_, server, mut cx| async move { + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + chunk: "Thinking ".into(), + }, + }) + })? + .await + .unwrap(); + server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::StreamAssistantMessageChunkParams { + chunk: acp::AssistantMessageChunk::Thought { + chunk: "hard!".into(), + }, + }) + })? + .await + .unwrap(); + + Ok(()) + }) + }); + + thread + .update(cx, |thread, cx| thread.send("Hello from Zed!", cx)) + .await + .unwrap(); + + let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx)); + assert_eq!( + output, + indoc! {r#" + ## User + + Hello from Zed! + + ## Assistant + + + Thinking hard! + + + "#} + ); + } + + #[gpui::test] + async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (thread, fake_server) = fake_acp_thread(project, cx); + + let (end_turn_tx, end_turn_rx) = oneshot::channel::<()>(); + + let tool_call_id = Rc::new(RefCell::new(None)); + let end_turn_rx = Rc::new(RefCell::new(Some(end_turn_rx))); + fake_server.update(cx, |fake_server, _| { + let tool_call_id = tool_call_id.clone(); + fake_server.on_user_message(move |_, server, mut cx| { + let end_turn_rx = end_turn_rx.clone(); + let tool_call_id = tool_call_id.clone(); + async move { + let tool_call_result = server + .update(&mut cx, |server, _| { + server.send_to_zed(acp::PushToolCallParams { + label: "Fetch".to_string(), + icon: acp::Icon::Globe, + content: None, + }) + })? + .await + .unwrap(); + *tool_call_id.clone().borrow_mut() = Some(tool_call_result.id); + end_turn_rx.take().unwrap().await.ok(); + + Ok(()) + } + }) + }); + + let request = thread.update(cx, |thread, cx| { + thread.send("Fetch https://example.com", cx) + }); + + run_until_first_tool_call(&thread, cx).await; + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + }, + .. + }) + )); + }); + + cx.run_until_parked(); + + thread + .update(cx, |thread, cx| thread.cancel(cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + &thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Canceled, + .. + }) + )); + }); + + fake_server + .update(cx, |fake_server, _| { + fake_server.send_to_zed(acp::UpdateToolCallParams { + tool_call_id: tool_call_id.borrow().unwrap(), + status: acp::ToolCallStatus::Finished, + content: None, + }) + }) + .await + .unwrap(); + + drop(end_turn_tx); + request.await.unwrap(); + + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.entries[1], + AgentThreadEntry::ToolCall(ToolCall { + status: ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + }, + .. + }) + )); + }); + } + + #[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("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::UserMessage { + chunks: vec![ + "Read the file ".into(), + Path::new("foo.rs").into(), + " 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( + "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(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(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(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, + cx: &mut TestAppContext, + ) -> usize { + let (mut tx, mut rx) = mpsc::channel::(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! { + _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => { + panic!("Timeout waiting for tool call") + } + ix = rx.next().fuse() => { + drop(subscription); + ix.unwrap() + } + } + } + + pub async fn gemini_acp_thread( + project: Entity, + current_dir: impl AsRef, + cx: &mut TestAppContext, + ) -> Entity { + struct DevGemini; + + impl agent_servers::AgentServer for DevGemini { + async fn command( + &self, + _project: &Entity, + _cx: &mut AsyncApp, + ) -> Result { + 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, "--acp".into()], + env: None, + }) + } + + async fn version( + &self, + _command: &agent_servers::AgentServerCommand, + ) -> Result { + 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, + cx: &mut TestAppContext, + ) -> (Entity, Entity) { + 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 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< + dyn Fn( + acp::SendUserMessageParams, + Entity, + AsyncApp, + ) -> LocalBoxFuture<'static, Result<()>>, + >, + >, + } + + #[derive(Clone)] + struct FakeAgent { + server: Entity, + cx: AsyncApp, + } + + impl acp::Agent for FakeAgent { + async fn initialize(&self) -> Result { + Ok(acp::InitializeResponse { + is_authenticated: true, + }) + } + + async fn authenticate(&self) -> Result<()> { + Ok(()) + } + + async fn cancel_send_message(&self) -> Result<()> { + Ok(()) + } + + async fn send_user_message(&self, request: acp::SendUserMessageParams) -> Result<()> { + let mut cx = self.cx.clone(); + let handler = self + .server + .update(&mut cx, |server, _| server.on_user_message.clone()) + .ok() + .flatten(); + if let Some(handler) = handler { + handler(request, self.server.clone(), self.cx.clone()).await + } else { + anyhow::bail!("No handler for on_user_message") + } + } + } + + impl FakeAcpServer { + fn new(stdin: PipeReader, stdout: PipeWriter, cx: &Context) -> Self { + let agent = FakeAgent { + server: cx.entity(), + cx: cx.to_async(), + }; + let foreground_executor = cx.foreground_executor().clone(); + + let (connection, io_fut) = acp::ClientConnection::connect_to_client( + agent.clone(), + stdout, + stdin, + move |fut| { + foreground_executor.spawn(fut).detach(); + }, + ); + FakeAcpServer { + connection: connection, + on_user_message: None, + _io_task: cx.background_spawn(async move { + io_fut.await.log_err(); + }), + } + } + + fn on_user_message( + &mut self, + handler: impl for<'a> Fn(acp::SendUserMessageParams, Entity, AsyncApp) -> F + + 'static, + ) where + F: Future> + 'static, + { + self.on_user_message + .replace(Rc::new(move |request, server, cx| { + handler(request, server, cx).boxed_local() + })); + } + + fn send_to_zed( + &self, + message: T, + ) -> BoxedLocal> { + self.connection + .request(message) + .map(|f| f.map_err(|err| anyhow!(err))) + .boxed_local() + } + } +} diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml new file mode 100644 index 0000000000..549162c5dd --- /dev/null +++ b/crates/agent_servers/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "agent_servers" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_servers.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +futures.workspace = true +gpui.workspace = true +paths.workspace = true +project.workspace = true +schemars.workspace = true +serde.workspace = true +settings.workspace = true +util.workspace = true +which.workspace = true +workspace-hack.workspace = true diff --git a/crates/agent_servers/LICENSE-GPL b/crates/agent_servers/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/agent_servers/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs new file mode 100644 index 0000000000..5d588cd4ae --- /dev/null +++ b/crates/agent_servers/src/agent_servers.rs @@ -0,0 +1,231 @@ +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}; + +pub fn init(cx: &mut App) { + AllAgentServersSettings::register(cx); +} + +#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AllAgentServersSettings { + gemini: Option, +} + +#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)] +pub struct AgentServerSettings { + #[serde(flatten)] + command: AgentServerCommand, +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct AgentServerCommand { + #[serde(rename = "command")] + pub path: PathBuf, + #[serde(default)] + pub args: Vec, + pub env: Option>, +} + +pub struct Gemini; + +pub struct AgentServerVersion { + pub current_version: SharedString, + pub supported: bool, +} + +pub trait AgentServer: Send { + fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> impl Future>; + + fn version( + &self, + command: &AgentServerCommand, + ) -> impl Future> + Send; +} + +const GEMINI_ACP_ARG: &str = "--acp"; + +impl AgentServer for Gemini { + async fn command( + &self, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result { + let custom_command = cx.read_global(|settings: &SettingsStore, _| { + let settings = settings.get::(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, + }); + } + + 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 { + 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, + }) + } +} + +async fn find_bin_in_path( + bin_name: &'static str, + project: &Entity, + cx: &mut AsyncApp, +) -> Option { + let (env_task, root_dir) = project + .update(cx, |project, cx| { + let worktree = project.visible_worktrees(cx).next(); + match worktree { + Some(worktree) => { + let env_task = project.environment().update(cx, |env, cx| { + env.get_worktree_environment(worktree.clone(), cx) + }); + + let path = worktree.read(cx).abs_path(); + (env_task, path) + } + None => { + let path: Arc = paths::home_dir().as_path().into(); + let env_task = project.environment().update(cx, |env, cx| { + env.get_directory_environment(path.clone(), cx) + }); + (env_task, path) + } + } + }) + .log_err()?; + + cx.background_executor() + .spawn(async move { + let which_result = if cfg!(windows) { + which::which(bin_name) + } else { + let env = env_task.await.unwrap_or_default(); + let shell_path = env.get("PATH").cloned(); + which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref()) + }; + + if let Err(which::Error::CannotFindBinaryPath) = which_result { + return None; + } + + which_result.log_err() + }) + .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::>() + }); + + 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, _: &mut App) -> Result { + 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) {} +} diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 30cd2552ef..131cd2dc3f 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -68,6 +68,7 @@ pub struct AgentSettings { pub preferred_completion_mode: CompletionMode, pub enable_feedback: bool, pub expand_edit_card: bool, + pub expand_terminal_card: bool, } impl AgentSettings { @@ -296,6 +297,10 @@ pub struct AgentSettingsContent { /// /// Default: true expand_edit_card: Option, + /// Whether to have terminal cards in the agent panel expanded, showing the whole command output. + /// + /// Default: true + expand_terminal_card: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] @@ -447,6 +452,10 @@ impl Settings for AgentSettings { ); merge(&mut settings.enable_feedback, value.enable_feedback); merge(&mut settings.expand_edit_card, value.expand_edit_card); + merge( + &mut settings.expand_terminal_card, + value.expand_terminal_card, + ); settings .model_parameters diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 070e8eb585..72466fe8e7 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,14 +13,14 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = [ - "gpui/test-support", - "language/test-support", -] +test-support = ["gpui/test-support", "language/test-support"] [dependencies] +acp.workspace = true agent.workspace = true +agentic-coding-protocol.workspace = true agent_settings.workspace = true +agent_servers.workspace = true anyhow.workspace = true assistant_context.workspace = true assistant_slash_command.workspace = true @@ -76,6 +76,7 @@ serde_json_lenient.workspace = true settings.workspace = true smol.workspace = true streaming_diff.workspace = true +task.workspace = true telemetry.workspace = true telemetry_events.workspace = true terminal.workspace = true diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs new file mode 100644 index 0000000000..23ada8d77a --- /dev/null +++ b/crates/agent_ui/src/acp.rs @@ -0,0 +1,5 @@ +mod completion_provider; +mod message_history; +mod thread_view; + +pub use thread_view::AcpThreadView; diff --git a/crates/agent_ui/src/acp/completion_provider.rs b/crates/agent_ui/src/acp/completion_provider.rs new file mode 100644 index 0000000000..fca4ae0300 --- /dev/null +++ b/crates/agent_ui/src/acp/completion_provider.rs @@ -0,0 +1,574 @@ +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use anyhow::Result; +use collections::HashMap; +use editor::display_map::CreaseId; +use editor::{CompletionProvider, Editor, ExcerptId}; +use file_icons::FileIcons; +use gpui::{App, Entity, Task, WeakEntity}; +use language::{Buffer, CodeLabel, HighlightId}; +use lsp::CompletionContext; +use parking_lot::Mutex; +use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, WorktreeId}; +use rope::Point; +use text::{Anchor, ToPoint}; +use ui::prelude::*; +use workspace::Workspace; + +use crate::context_picker::MentionLink; +use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files}; + +#[derive(Default)] +pub struct MentionSet { + paths_by_crease_id: HashMap, +} + +impl MentionSet { + pub fn insert(&mut self, crease_id: CreaseId, path: ProjectPath) { + self.paths_by_crease_id.insert(crease_id, path); + } + + pub fn path_for_crease_id(&self, crease_id: CreaseId) -> Option { + self.paths_by_crease_id.get(&crease_id).cloned() + } + + pub fn drain(&mut self) -> impl Iterator { + self.paths_by_crease_id.drain().map(|(id, _)| id) + } +} + +pub struct ContextPickerCompletionProvider { + workspace: WeakEntity, + editor: WeakEntity, + mention_set: Arc>, +} + +impl ContextPickerCompletionProvider { + pub fn new( + mention_set: Arc>, + workspace: WeakEntity, + editor: WeakEntity, + ) -> Self { + Self { + mention_set, + workspace, + editor, + } + } + + fn completion_for_path( + project_path: ProjectPath, + path_prefix: &str, + is_recent: bool, + is_directory: bool, + excerpt_id: ExcerptId, + source_range: Range, + editor: Entity, + mention_set: Arc>, + cx: &App, + ) -> Completion { + let (file_name, directory) = + extract_file_name_and_directory(&project_path.path, path_prefix); + + let label = + build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx); + let full_path = if let Some(directory) = directory { + format!("{}{}", directory, file_name) + } else { + file_name.to_string() + }; + + let crease_icon_path = if is_directory { + FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(&full_path), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + let completion_icon_path = if is_recent { + IconName::HistoryRerun.path().into() + } else { + crease_icon_path.clone() + }; + + let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path)); + let new_text_len = new_text.len(); + Completion { + replace_range: source_range.clone(), + new_text, + label, + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(completion_icon_path), + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_icon_path, + file_name, + project_path, + excerpt_id, + source_range.start, + new_text_len - 1, + editor, + mention_set, + )), + } + } +} + +fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel { + let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); + let mut label = CodeLabel::default(); + + label.push_str(&file_name, None); + label.push_str(" ", None); + + if let Some(directory) = directory { + label.push_str(&directory, comment_id); + } + + label.filter_range = 0..label.text().len(); + + label +} + +impl CompletionProvider for ContextPickerCompletionProvider { + fn completions( + &self, + excerpt_id: ExcerptId, + buffer: &Entity, + buffer_position: Anchor, + _trigger: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>> { + let state = buffer.update(cx, |buffer, _cx| { + let position = buffer_position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + let line = lines.next()?; + MentionCompletion::try_parse(line, offset_to_line) + }); + let Some(state) = state else { + return Task::ready(Ok(Vec::new())); + }; + + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Ok(Vec::new())); + }; + + let snapshot = buffer.read(cx).snapshot(); + let source_range = snapshot.anchor_before(state.source_range.start) + ..snapshot.anchor_after(state.source_range.end); + + let editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let MentionCompletion { argument, .. } = state; + let query = argument.unwrap_or_else(|| "".to_string()); + + let search_task = search_files(query.clone(), Arc::::default(), &workspace, cx); + + cx.spawn(async move |_, cx| { + let matches = search_task.await; + let Some(editor) = editor.upgrade() else { + return Ok(Vec::new()); + }; + + let completions = cx.update(|cx| { + matches + .into_iter() + .map(|mat| { + let path_match = &mat.mat; + let project_path = ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.worktree_id), + path: path_match.path.clone(), + }; + + Self::completion_for_path( + project_path, + &path_match.path_prefix, + mat.is_recent, + path_match.is_dir, + excerpt_id, + source_range.clone(), + editor.clone(), + mention_set.clone(), + cx, + ) + }) + .collect() + })?; + + Ok(vec![CompletionResponse { + completions, + // Since this does its own filtering (see `filter_completions()` returns false), + // there is no benefit to computing whether this set of completions is incomplete. + is_incomplete: true, + }]) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _menu_is_open: bool, + cx: &mut Context, + ) -> bool { + let buffer = buffer.read(cx); + let position = position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let offset_to_line = buffer.point_to_offset(line_start); + let mut lines = buffer.text_for_range(line_start..position).lines(); + if let Some(line) = lines.next() { + MentionCompletion::try_parse(line, offset_to_line) + .map(|completion| { + completion.source_range.start <= offset_to_line + position.column as usize + && completion.source_range.end >= offset_to_line + position.column as usize + }) + .unwrap_or(false) + } else { + false + } + } + + fn sort_completions(&self) -> bool { + false + } + + fn filter_completions(&self) -> bool { + false + } +} + +fn confirm_completion_callback( + crease_icon_path: SharedString, + crease_text: SharedString, + project_path: ProjectPath, + excerpt_id: ExcerptId, + start: Anchor, + content_len: usize, + editor: Entity, + mention_set: Arc>, +) -> Arc bool + Send + Sync> { + Arc::new(move |_, window, cx| { + let crease_text = crease_text.clone(); + let crease_icon_path = crease_icon_path.clone(); + let editor = editor.clone(); + let project_path = project_path.clone(); + let mention_set = mention_set.clone(); + window.defer(cx, move |window, cx| { + let crease_id = crate::context_picker::insert_crease_for_mention( + excerpt_id, + start, + content_len, + crease_text.clone(), + crease_icon_path, + editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + }); + false + }) +} + +#[derive(Debug, Default, PartialEq)] +struct MentionCompletion { + source_range: Range, + argument: Option, +} + +impl MentionCompletion { + fn try_parse(line: &str, offset_to_line: usize) -> Option { + let last_mention_start = line.rfind('@')?; + if last_mention_start >= line.len() { + return Some(Self::default()); + } + if last_mention_start > 0 + && line + .chars() + .nth(last_mention_start - 1) + .map_or(false, |c| !c.is_whitespace()) + { + return None; + } + + let rest_of_line = &line[last_mention_start + 1..]; + let mut argument = None; + + let mut parts = rest_of_line.split_whitespace(); + let mut end = last_mention_start + 1; + if let Some(argument_text) = parts.next() { + end += argument_text.len(); + argument = Some(argument_text.to_string()); + } + + Some(Self { + source_range: last_mention_start + offset_to_line..end + offset_to_line, + argument, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext}; + use project::{Project, ProjectPath}; + use serde_json::json; + use settings::SettingsStore; + use std::{ops::Deref, rc::Rc}; + use util::path; + use workspace::{AppState, Item}; + + #[test] + fn test_mention_completion_parse() { + assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None); + + assert_eq!( + MentionCompletion::try_parse("Lorem @", 0), + Some(MentionCompletion { + source_range: 6..7, + argument: None, + }) + ); + + assert_eq!( + MentionCompletion::try_parse("Lorem @main", 0), + Some(MentionCompletion { + source_range: 6..11, + argument: Some("main".to_string()), + }) + ); + + assert_eq!(MentionCompletion::try_parse("test@", 0), None); + } + + struct AtMentionEditor(Entity); + + impl Item for AtMentionEditor { + type Event = (); + + fn include_in_nav_history() -> bool { + false + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Test".into() + } + } + + impl EventEmitter<()> for AtMentionEditor {} + + impl Focusable for AtMentionEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.0.read(cx).focus_handle(cx).clone() + } + } + + impl Render for AtMentionEditor { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.0.clone().into_any_element() + } + } + + #[gpui::test] + async fn test_context_completion_provider(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + language::init(cx); + editor::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "editor": "", + "a": { + "one.txt": "", + "two.txt": "", + "three.txt": "", + "four.txt": "" + }, + "b": { + "five.txt": "", + "six.txt": "", + "seven.txt": "", + "eight.txt": "", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let paths = vec![ + path!("a/one.txt"), + path!("a/two.txt"), + path!("a/three.txt"), + path!("a/four.txt"), + path!("b/five.txt"), + path!("b/six.txt"), + path!("b/seven.txt"), + path!("b/eight.txt"), + ]; + + let mut opened_editors = Vec::new(); + for path in paths { + let buffer = workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: Path::new(path).into(), + }, + None, + false, + window, + cx, + ) + }) + .await + .unwrap(); + opened_editors.push(buffer); + } + + let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let editor = cx.new(|cx| { + Editor::new( + editor::EditorMode::full(), + multi_buffer::MultiBuffer::build_simple("", cx), + None, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| AtMentionEditor(editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + editor + }); + + let mention_set = Arc::new(Mutex::new(MentionSet::default())); + + let editor_entity = editor.downgrade(); + editor.update_in(&mut cx, |editor, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace.downgrade(), + editor_entity, + )))); + }); + + cx.simulate_input("Lorem "); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem "); + assert!(!editor.has_visible_completions_menu()); + }); + + cx.simulate_input("@"); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem @"); + assert!(editor.has_visible_completions_menu()); + assert_eq!( + current_completion_labels(editor), + &[ + "eight.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "five.txt dir/b/", + "four.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "one.txt dir/a/", + "dir ", + "a dir/", + "four.txt dir/a/", + "one.txt dir/a/", + "three.txt dir/a/", + "two.txt dir/a/", + "b dir/", + "eight.txt dir/b/", + "five.txt dir/b/", + "seven.txt dir/b/", + "six.txt dir/b/", + "editor dir/" + ] + ); + }); + + // Select and confirm "File" + editor.update_in(&mut cx, |editor, window, cx| { + assert!(editor.has_visible_completions_menu()); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx); + editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx); + }); + + cx.run_until_parked(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) "); + }); + } + + fn current_completion_labels(editor: &Editor) -> Vec { + let completions = editor.current_completions().expect("Missing completions"); + completions + .into_iter() + .map(|completion| completion.label.text.to_string()) + .collect::>() + } + + pub(crate) fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + editor::init_settings(cx); + }); + } +} diff --git a/crates/agent_ui/src/acp/message_history.rs b/crates/agent_ui/src/acp/message_history.rs new file mode 100644 index 0000000000..6d9626627a --- /dev/null +++ b/crates/agent_ui/src/acp/message_history.rs @@ -0,0 +1,81 @@ +pub struct MessageHistory { + items: Vec, + current: Option, +} + +impl MessageHistory { + pub fn new() -> Self { + MessageHistory { + items: Vec::new(), + current: None, + } + } + + pub fn push(&mut self, message: T) { + self.current.take(); + self.items.push(message); + } + + pub fn prev(&mut self) -> Option<&T> { + if self.items.is_empty() { + return None; + } + + let new_ix = self + .current + .get_or_insert(self.items.len()) + .saturating_sub(1); + + self.current = Some(new_ix); + self.items.get(new_ix) + } + + pub fn next(&mut self) -> Option<&T> { + let current = self.current.as_mut()?; + *current += 1; + + self.items.get(*current).or_else(|| { + self.current.take(); + None + }) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prev_next() { + let mut history = MessageHistory::new(); + + // Test empty history + assert_eq!(history.prev(), None); + assert_eq!(history.next(), None); + + // Add some messages + history.push("first"); + history.push("second"); + history.push("third"); + + // Test prev navigation + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.prev(), Some(&"second")); + assert_eq!(history.prev(), Some(&"first")); + assert_eq!(history.prev(), Some(&"first")); + + assert_eq!(history.next(), Some(&"second")); + + // Test mixed navigation + history.push("fourth"); + assert_eq!(history.prev(), Some(&"fourth")); + assert_eq!(history.prev(), Some(&"third")); + assert_eq!(history.next(), Some(&"fourth")); + assert_eq!(history.next(), None); + + // Test that push resets navigation + history.prev(); + history.prev(); + history.push("fifth"); + assert_eq!(history.prev(), Some(&"fifth")); + } +} diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs new file mode 100644 index 0000000000..f16d439da1 --- /dev/null +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -0,0 +1,1972 @@ +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use agentic_coding_protocol::{self as acp}; +use collections::{HashMap, HashSet}; +use editor::{ + AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, + EditorStyle, MinimapVisibility, MultiBuffer, +}; +use file_icons::FileIcons; +use futures::channel::oneshot; +use gpui::{ + Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, Focusable, + Hsla, Length, ListOffset, ListState, SharedString, StyleRefinement, Subscription, TextStyle, + TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, div, list, percentage, + prelude::*, pulsating_between, +}; +use gpui::{FocusHandle, Task}; +use language::language_settings::SoftWrap; +use language::{Buffer, Language}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; +use parking_lot::Mutex; +use project::Project; +use settings::Settings as _; +use theme::ThemeSettings; +use ui::{Disclosure, Tooltip, prelude::*}; +use util::ResultExt; +use workspace::Workspace; +use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; + +use ::acp::{ + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, Diff, + LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallConfirmation, ToolCallContent, + ToolCallId, ToolCallStatus, +}; + +use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet}; +use crate::acp::message_history::MessageHistory; + +const RESPONSE_PADDING_X: Pixels = px(19.); + +pub struct AcpThreadView { + workspace: WeakEntity, + project: Entity, + thread_state: ThreadState, + diff_editors: HashMap>, + message_editor: Entity, + mention_set: Arc>, + last_error: Option>, + list_state: ListState, + auth_task: Option>, + expanded_tool_calls: HashSet, + expanded_thinking_blocks: HashSet<(usize, usize)>, + message_history: MessageHistory, +} + +enum ThreadState { + Loading { + _task: Task<()>, + }, + Ready { + thread: Entity, + _subscription: Subscription, + }, + LoadError(LoadError), + Unauthenticated { + thread: Entity, + }, +} + +impl AcpThreadView { + pub fn new( + workspace: WeakEntity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let language = Language::new( + language::LanguageConfig { + completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']), + ..Default::default() + }, + None, + ); + + let mention_set = Arc::new(Mutex::new(MentionSet::default())); + + let message_editor = cx.new(|cx| { + let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let mut editor = Editor::new( + editor::EditorMode::AutoHeight { + min_lines: 4, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_placeholder_text("Message the agent - @ to include files", cx); + editor.set_show_indent_guides(false, cx); + editor.set_soft_wrap(); + editor.set_use_modal_editing(true); + editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new( + mention_set.clone(), + workspace.clone(), + cx.weak_entity(), + )))); + editor.set_context_menu_options(ContextMenuOptions { + min_entries_visible: 12, + max_entries_visible: 12, + placement: Some(ContextMenuPlacement::Above), + }); + editor + }); + + let list_state = ListState::new( + 0, + gpui::ListAlignment::Bottom, + px(2048.0), + cx.processor({ + move |this: &mut Self, index: usize, window, cx| { + let Some((entry, len)) = this.thread().and_then(|thread| { + let entries = &thread.read(cx).entries(); + Some((entries.get(index)?, entries.len())) + }) else { + return Empty.into_any(); + }; + this.render_entry(index, len, entry, window, cx) + } + }), + ); + + Self { + workspace, + project: project.clone(), + thread_state: Self::initial_state(project, window, cx), + message_editor, + mention_set, + diff_editors: Default::default(), + list_state: list_state, + last_error: None, + auth_task: None, + expanded_tool_calls: HashSet::default(), + expanded_thinking_blocks: HashSet::default(), + message_history: MessageHistory::new(), + } + } + + fn initial_state( + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> ThreadState { + let root_dir = project + .read(cx) + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()) + .unwrap_or_else(|| paths::home_dir().as_path().into()); + + let load_task = cx.spawn_in(window, async move |this, cx| { + let thread = match AcpThread::spawn(agent_servers::Gemini, &root_dir, project, cx).await + { + Ok(thread) => thread, + Err(err) => { + this.update(cx, |this, cx| { + this.handle_load_error(err, cx); + cx.notify(); + }) + .log_err(); + return; + } + }; + + let init_response = async { + let resp = thread + .read_with(cx, |thread, _cx| thread.initialize())? + .await?; + anyhow::Ok(resp) + }; + + let result = match init_response.await { + Err(e) => { + let mut cx = cx.clone(); + if e.downcast_ref::().is_some() { + let child_status = thread + .update(&mut cx, |thread, _| thread.child_status()) + .ok() + .flatten(); + if let Some(child_status) = child_status { + match child_status.await { + Ok(_) => Err(e), + Err(e) => Err(e), + } + } else { + Err(e) + } + } else { + Err(e) + } + } + Ok(response) => { + if !response.is_authenticated { + this.update(cx, |this, _| { + this.thread_state = ThreadState::Unauthenticated { thread }; + }) + .ok(); + return; + }; + Ok(()) + } + }; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => { + let subscription = + cx.subscribe_in(&thread, window, Self::handle_thread_event); + this.list_state + .splice(0..0, thread.read(cx).entries().len()); + + this.thread_state = ThreadState::Ready { + thread, + _subscription: subscription, + }; + cx.notify(); + } + Err(err) => { + this.handle_load_error(err, cx); + } + }; + }) + .log_err(); + }); + + ThreadState::Loading { _task: load_task } + } + + fn handle_load_error(&mut self, err: anyhow::Error, cx: &mut Context) { + if let Some(load_err) = err.downcast_ref::() { + self.thread_state = ThreadState::LoadError(load_err.clone()); + } else { + self.thread_state = ThreadState::LoadError(LoadError::Other(err.to_string().into())) + } + cx.notify(); + } + + fn thread(&self) -> Option<&Entity> { + match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => { + Some(thread) + } + ThreadState::Loading { .. } | ThreadState::LoadError(..) => None, + } + } + + pub fn title(&self, cx: &App) -> SharedString { + match &self.thread_state { + ThreadState::Ready { thread, .. } => thread.read(cx).title(), + ThreadState::Loading { .. } => "Loading…".into(), + ThreadState::LoadError(_) => "Failed to load".into(), + ThreadState::Unauthenticated { .. } => "Not authenticated".into(), + } + } + + pub fn cancel(&mut self, cx: &mut Context) { + self.last_error.take(); + + if let Some(thread) = self.thread() { + thread.update(cx, |thread, cx| thread.cancel(cx)).detach(); + } + } + + fn chat(&mut self, _: &Chat, window: &mut Window, cx: &mut Context) { + self.last_error.take(); + + let mut ix = 0; + let mut chunks: Vec = Vec::new(); + + let project = self.project.clone(); + self.message_editor.update(cx, |editor, cx| { + let text = editor.text(cx); + editor.display_map.update(cx, |map, cx| { + let snapshot = map.snapshot(cx); + for (crease_id, crease) in snapshot.crease_snapshot.creases() { + if let Some(project_path) = + self.mention_set.lock().path_for_crease_id(crease_id) + { + let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); + if crease_range.start > ix { + chunks.push(acp::UserMessageChunk::Text { + chunk: text[ix..crease_range.start].to_string(), + }); + } + if let Some(abs_path) = project.read(cx).absolute_path(&project_path, cx) { + chunks.push(acp::UserMessageChunk::Path { path: abs_path }); + } + ix = crease_range.end; + } + } + + if ix < text.len() { + let last_chunk = text[ix..].trim(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + }) + }); + + if chunks.is_empty() { + return; + } + + let Some(thread) = self.thread() else { return }; + let message = acp::UserMessage { chunks }; + let task = thread.update(cx, |thread, cx| thread.send(message.clone(), cx)); + + cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + if let Err(err) = result { + this.last_error = + Some(cx.new(|cx| Markdown::new(err.to_string().into(), None, None, cx))) + } + }) + }) + .detach(); + + let mention_set = self.mention_set.clone(); + + self.message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + + self.message_history.push(message); + } + + fn previous_history_message( + &mut self, + _: &PreviousHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.prev(), + window, + cx, + ); + } + + fn next_history_message( + &mut self, + _: &NextHistoryMessage, + window: &mut Window, + cx: &mut Context, + ) { + Self::set_draft_message( + self.message_editor.clone(), + self.mention_set.clone(), + self.project.clone(), + self.message_history.next(), + window, + cx, + ); + } + + fn set_draft_message( + message_editor: Entity, + mention_set: Arc>, + project: Entity, + message: Option<&acp::UserMessage>, + window: &mut Window, + cx: &mut Context, + ) { + cx.notify(); + + let Some(message) = message else { + message_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.remove_creases(mention_set.lock().drain(), cx) + }); + return; + }; + + let mut text = String::new(); + let mut mentions = Vec::new(); + + for chunk in &message.chunks { + match chunk { + acp::UserMessageChunk::Text { chunk } => { + text.push_str(&chunk); + } + acp::UserMessageChunk::Path { path } => { + let start = text.len(); + let content = MentionPath::new(path).to_string(); + text.push_str(&content); + let end = text.len(); + if let Some(project_path) = + project.read(cx).project_path_for_absolute_path(path, cx) + { + let filename: SharedString = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + mentions.push((start..end, project_path, filename)); + } + } + } + } + + let snapshot = message_editor.update(cx, |editor, cx| { + editor.set_text(text, window, cx); + editor.buffer().read(cx).snapshot(cx) + }); + + for (range, project_path, filename) in mentions { + let crease_icon_path = if project_path.path.is_dir() { + FileIcons::get_folder_icon(false, cx) + .unwrap_or_else(|| IconName::Folder.path().into()) + } else { + FileIcons::get_icon(Path::new(project_path.path.as_ref()), cx) + .unwrap_or_else(|| IconName::File.path().into()) + }; + + let anchor = snapshot.anchor_before(range.start); + let crease_id = crate::context_picker::insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + range.end - range.start, + filename, + crease_icon_path, + message_editor.clone(), + window, + cx, + ); + if let Some(crease_id) = crease_id { + mention_set.lock().insert(crease_id, project_path); + } + } + } + + fn handle_thread_event( + &mut self, + thread: &Entity, + event: &AcpThreadEvent, + window: &mut Window, + cx: &mut Context, + ) { + let count = self.list_state.item_count(); + match event { + AcpThreadEvent::NewEntry => { + self.sync_thread_entry_view(thread.read(cx).entries().len() - 1, window, cx); + self.list_state.splice(count..count, 1); + } + AcpThreadEvent::EntryUpdated(index) => { + let index = *index; + self.sync_thread_entry_view(index, window, cx); + self.list_state.splice(index..index + 1, 1); + } + } + cx.notify(); + } + + fn sync_thread_entry_view( + &mut self, + entry_ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multibuffer) = self.entry_diff_multibuffer(entry_ix, cx) else { + return; + }; + + if self.diff_editors.contains_key(&multibuffer.entity_id()) { + return; + } + + let editor = cx.new(|cx| { + let mut editor = Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sized_by_content: true, + }, + multibuffer.clone(), + None, + window, + cx, + ); + editor.set_show_gutter(false, cx); + editor.disable_inline_diagnostics(); + editor.disable_expand_excerpt_buttons(cx); + editor.set_show_vertical_scrollbar(false, cx); + editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); + editor.set_soft_wrap_mode(SoftWrap::None, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.set_show_indent_guides(false, cx); + editor.set_read_only(true); + editor.set_show_breakpoints(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_expand_all_diff_hunks(cx); + editor.set_text_style_refinement(TextStyleRefinement { + font_size: Some( + TextSize::Small + .rems(cx) + .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) + .into(), + ), + ..Default::default() + }); + editor + }); + let entity_id = multibuffer.entity_id(); + cx.observe_release(&multibuffer, move |this, _, _| { + this.diff_editors.remove(&entity_id); + }) + .detach(); + + self.diff_editors.insert(entity_id, editor); + } + + fn entry_diff_multibuffer(&self, entry_ix: usize, cx: &App) -> Option> { + let entry = self.thread()?.read(cx).entries().get(entry_ix)?; + if let AgentThreadEntry::ToolCall(ToolCall { + content: Some(ToolCallContent::Diff { diff }), + .. + }) = &entry + { + Some(diff.multibuffer.clone()) + } else { + None + } + } + + fn authenticate(&mut self, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + self.last_error.take(); + let authenticate = thread.read(cx).authenticate(); + self.auth_task = Some(cx.spawn_in(window, { + let project = self.project.clone(); + async move |this, cx| { + let result = authenticate.await; + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + this.last_error = Some(cx.new(|cx| { + Markdown::new(format!("Error: {err}").into(), None, None, cx) + })) + } else { + this.thread_state = Self::initial_state(project.clone(), window, cx) + } + this.auth_task.take() + }) + .ok(); + } + })); + } + + fn authorize_tool_call( + &mut self, + id: ToolCallId, + outcome: acp::ToolCallConfirmationOutcome, + cx: &mut Context, + ) { + let Some(thread) = self.thread() else { + return; + }; + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(id, outcome, cx); + }); + cx.notify(); + } + + fn render_entry( + &self, + index: usize, + total_entries: usize, + entry: &AgentThreadEntry, + window: &mut Window, + cx: &Context, + ) -> AnyElement { + match &entry { + AgentThreadEntry::UserMessage(message) => div() + .py_4() + .px_2() + .child( + v_flex() + .p_3() + .gap_1p5() + .rounded_lg() + .shadow_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .text_xs() + .child(self.render_markdown( + message.content.clone(), + user_message_markdown_style(window, cx), + )), + ) + .into_any(), + AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let style = default_markdown_style(false, window, cx); + let message_body = v_flex() + .w_full() + .gap_2p5() + .children(chunks.iter().enumerate().map(|(chunk_ix, chunk)| { + match chunk { + AssistantMessageChunk::Text { chunk } => self + .render_markdown(chunk.clone(), style.clone()) + .into_any_element(), + AssistantMessageChunk::Thought { chunk } => self.render_thinking_block( + index, + chunk_ix, + chunk.clone(), + window, + cx, + ), + } + })) + .into_any(); + + v_flex() + .px_5() + .py_1() + .when(index + 1 == total_entries, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(message_body) + .into_any() + } + AgentThreadEntry::ToolCall(tool_call) => div() + .py_1p5() + .px_5() + .child(self.render_tool_call(index, tool_call, window, cx)) + .into_any(), + } + } + + fn tool_card_header_bg(&self, cx: &Context) -> Hsla { + cx.theme() + .colors() + .element_background + .blend(cx.theme().colors().editor_foreground.opacity(0.025)) + } + + fn tool_card_border_color(&self, cx: &Context) -> Hsla { + cx.theme().colors().border.opacity(0.6) + } + + fn tool_name_font_size(&self) -> Rems { + rems_from_px(13.) + } + + fn render_thinking_block( + &self, + entry_ix: usize, + chunk_ix: usize, + chunk: Entity, + window: &Window, + cx: &Context, + ) -> AnyElement { + let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + + v_flex() + .child( + h_flex() + .id(header_id) + .group("disclosure-header") + .w_full() + .justify_between() + .opacity(0.8) + .hover(|style| style.opacity(1.)) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::ToolBulb) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + div() + .text_size(self.tool_name_font_size()) + .child("Thinking"), + ), + ) + .child( + div().visible_on_hover("disclosure-header").child( + Disclosure::new("thinking-disclosure", is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ), + ) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .relative() + .mt_1p5() + .ml(px(7.)) + .pl_4() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .text_ui_sm(cx) + .child( + self.render_markdown(chunk, default_markdown_style(false, window, cx)), + ), + ) + }) + .into_any_element() + } + + fn render_tool_call( + &self, + entry_ix: usize, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let header_id = SharedString::from(format!("tool-call-header-{}", entry_ix)); + + let status_icon = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => None, + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Running, + .. + } => Some( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_animation( + "running", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any(), + ), + ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Finished, + .. + } => None, + ToolCallStatus::Rejected + | ToolCallStatus::Canceled + | ToolCallStatus::Allowed { + status: acp::ToolCallStatus::Error, + .. + } => Some( + Icon::new(IconName::X) + .color(Color::Error) + .size(IconSize::Small) + .into_any_element(), + ), + }; + + let needs_confirmation = match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { .. } => true, + _ => tool_call + .content + .iter() + .any(|content| matches!(content, ToolCallContent::Diff { .. })), + }; + + let is_collapsible = tool_call.content.is_some() && !needs_confirmation; + let is_open = !is_collapsible || self.expanded_tool_calls.contains(&tool_call.id); + + let content = if is_open { + match &tool_call.status { + ToolCallStatus::WaitingForConfirmation { confirmation, .. } => { + Some(self.render_tool_call_confirmation( + tool_call.id, + confirmation, + tool_call.content.as_ref(), + window, + cx, + )) + } + ToolCallStatus::Allowed { .. } | ToolCallStatus::Canceled => { + tool_call.content.as_ref().map(|content| { + div() + .py_1p5() + .child(self.render_tool_call_content(content, window, cx)) + .into_any_element() + }) + } + ToolCallStatus::Rejected => None, + } + } else { + None + }; + + v_flex() + .when(needs_confirmation, |this| { + this.rounded_lg() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + }) + .child( + h_flex() + .id(header_id) + .w_full() + .gap_1() + .justify_between() + .map(|this| { + if needs_confirmation { + this.px_2() + .py_1() + .rounded_t_md() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + } else { + this.opacity(0.8).hover(|style| style.opacity(1.)) + } + }) + .child( + h_flex() + .id("tool-call-header") + .overflow_x_scroll() + .map(|this| { + if needs_confirmation { + this.text_xs() + } else { + this.text_size(self.tool_name_font_size()) + } + }) + .gap_1p5() + .child( + Icon::new(tool_call.icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.render_markdown( + tool_call.label.clone(), + default_markdown_style(needs_confirmation, window, cx), + )), + ) + .child( + h_flex() + .gap_0p5() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", tool_call.id.0), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + }) + .children(status_icon), + ) + .on_click(cx.listener({ + let id = tool_call.id; + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id); + } + cx.notify(); + } + })), + ) + .when(is_open, |this| { + this.child( + div() + .text_xs() + .when(is_collapsible, |this| { + this.mt_1() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .rounded_lg() + }) + .children(content), + ) + }) + } + + fn render_tool_call_content( + &self, + content: &ToolCallContent, + window: &Window, + cx: &Context, + ) -> AnyElement { + match content { + ToolCallContent::Markdown { markdown } => self + .render_markdown(markdown.clone(), default_markdown_style(false, window, cx)) + .into_any_element(), + ToolCallContent::Diff { + diff: Diff { + path, multibuffer, .. + }, + .. + } => self.render_diff_editor(multibuffer, path), + } + } + + fn render_tool_call_confirmation( + &self, + tool_call_id: ToolCallId, + confirmation: &ToolCallConfirmation, + content: Option<&ToolCallContent>, + window: &Window, + cx: &Context, + ) -> 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( + div() + .px_2() + .children(description.clone().map(|description| { + self.render_markdown( + description, + 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 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, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Execute { + command, + root_command, + description, + } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(command.clone()).children( + description.clone().map(|description| { + self.render_markdown(description, default_markdown_style(false, window, cx)) + .on_url_click({ + let workspace = self.workspace.clone(); + move |text, window, cx| { + Self::open_link(text, &workspace, 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), + 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, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Mcp { + server_name, + tool_name: _, + tool_display_name, + description, + } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .child(format!("{server_name} - {tool_display_name}")) + .children(description.clone().map(|description| { + self.render_markdown( + description, + 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_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, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Fetch { description, urls } => confirmation_container + .child( + v_flex() + .px_2() + .pb_1p5() + .gap_1() + .children(urls.iter().map(|url| { + h_flex().child( + Button::new(url.clone(), url) + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .on_click({ + let url = url.clone(); + move |_, _, cx| cx.open_url(&url) + }), + ) + })) + .children(description.clone().map(|description| { + self.render_markdown( + description, + 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, + ); + } + })), + ), + ) + .into_any(), + ToolCallConfirmation::Other { description } => confirmation_container + .child(v_flex().px_2().pb_1p5().child(self.render_markdown( + description.clone(), + 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, + ); + } + })), + ), + ) + .into_any(), + } + } + + fn render_diff_editor(&self, multibuffer: &Entity, path: &Path) -> AnyElement { + v_flex() + .h_full() + .child(path.to_string_lossy().to_string()) + .child( + if let Some(editor) = self.diff_editors.get(&multibuffer.entity_id()) { + editor.clone().into_any_element() + } else { + Empty.into_any() + }, + ) + .into_any() + } + + fn render_gemini_logo(&self) -> AnyElement { + Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element() + } + + fn render_error_gemini_logo(&self) -> AnyElement { + let logo = Icon::new(IconName::AiGemini) + .color(Color::Muted) + .size(IconSize::XLarge) + .into_any_element(); + + h_flex() + .relative() + .justify_center() + .child(div().opacity(0.3).child(logo)) + .child( + h_flex().absolute().right_1().bottom_0().child( + Icon::new(IconName::XCircle) + .color(Color::Error) + .size(IconSize::Small), + ), + ) + .into_any_element() + } + + fn render_empty_state(&self, loading: bool, cx: &App) -> AnyElement { + 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( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new(if loading { + "Connecting to Gemini…" + } else { + "Welcome to Gemini" + }).size(HeadlineSize::Medium)), + ) + .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) + }) + .child("Ask questions, edit files, run commands.\nBe specific for the best results.") + ) + .into_any() + } + + fn render_pending_auth_state(&self) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_logo()) + .child( + h_flex() + .mt_4() + .mb_1() + .justify_center() + .child(Headline::new("Not Authenticated").size(HeadlineSize::Medium)), + ) + .into_any() + } + + fn render_error_state(&self, e: &LoadError, cx: &Context) -> AnyElement { + let mut container = v_flex() + .items_center() + .justify_center() + .child(self.render_error_gemini_logo()) + .child( + v_flex() + .mt_4() + .mb_2() + .gap_0p5() + .text_center() + .items_center() + .child(Headline::new("Failed to launch").size(HeadlineSize::Medium)) + .child( + Label::new(e.to_string()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ); + + 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(); + }), + )); + } + + container.into_any() + } + + fn render_message_editor(&mut self, cx: &mut Context) -> AnyElement { + let settings = ThemeSettings::get_global(cx); + let font_size = TextSize::Small + .rems(cx) + .to_pixels(settings.agent_font_size(cx)); + let line_height = settings.buffer_line_height.value() * font_size; + + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: font_size.into(), + line_height: line_height.into(), + ..Default::default() + }; + + EditorElement::new( + &self.message_editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + syntax: cx.theme().syntax().clone(), + ..Default::default() + }, + ) + .into_any() + } + + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + let workspace = self.workspace.clone(); + MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { + Self::open_link(text, &workspace, window, cx); + }) + } + + fn open_link( + url: SharedString, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, + ) { + let Some(workspace) = workspace.upgrade() else { + cx.open_url(&url); + return; + }; + + if let Some(mention_path) = MentionPath::try_parse(&url) { + workspace.update(cx, |workspace, cx| { + let project = workspace.project(); + let Some((path, entry)) = project.update(cx, |project, cx| { + let path = project.find_project_path(mention_path.path(), cx)?; + let entry = project.entry_for_path(&path, cx)?; + Some((path, entry)) + }) else { + return; + }; + + if entry.is_dir() { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry.id)); + }); + } else { + workspace + .open_path(path, None, true, window, cx) + .detach_and_log_err(cx); + } + }) + } else { + cx.open_url(&url); + } + } + + pub fn open_thread_as_markdown( + &self, + workspace: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let markdown_language_task = workspace + .read(cx) + .app_state() + .languages + .language_for_name("Markdown"); + + let (thread_summary, markdown) = match &self.thread_state { + ThreadState::Ready { thread, .. } | ThreadState::Unauthenticated { thread } => { + let thread = thread.read(cx); + (thread.title().to_string(), thread.to_markdown(cx)) + } + ThreadState::Loading { .. } | ThreadState::LoadError(..) => return Task::ready(Ok(())), + }; + + window.spawn(cx, async move |cx| { + let markdown_language = markdown_language_task.await?; + + workspace.update_in(cx, |workspace, window, cx| { + let project = workspace.project().clone(); + + if !project.read(cx).is_local() { + anyhow::bail!("failed to open active thread as markdown in remote project"); + } + + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer(&markdown, Some(markdown_language), cx) + }); + let buffer = cx.new(|cx| { + MultiBuffer::singleton(buffer, cx).with_title(thread_summary.clone()) + }); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(thread_summary); + editor + })), + None, + true, + window, + cx, + ); + + anyhow::Ok(()) + })??; + anyhow::Ok(()) + }) + } + + fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.scroll_to(ListOffset::default()); + cx.notify(); + } +} + +impl Focusable for AcpThreadView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.message_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let text = self.message_editor.read(cx).text(cx); + let is_editor_empty = text.is_empty(); + let focus_handle = self.message_editor.focus_handle(cx); + + let open_as_markdown = IconButton::new("open-as-markdown", IconName::DocumentText) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Open Thread as Markdown")) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(workspace) = this.workspace.upgrade() { + this.open_thread_as_markdown(workspace, window, cx) + .detach_and_log_err(cx); + } + })); + + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUpAlt) + .icon_size(IconSize::XSmall) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Top")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_top(cx); + })); + + let feedback_container = h_flex() + .group("feedback_container") + .mt_1() + .py_2() + .px(RESPONSE_PADDING_X) + .mr_1() + .opacity(0.4) + .hover(|style| style.opacity(1.)) + .gap_1p5() + .flex_wrap() + .justify_end() + .child(h_flex().child(open_as_markdown)) + .child(scroll_to_top) + .into_any_element(); + + let show_controls = matches!(&self.thread_state, ThreadState::Ready { thread, .. } if thread.read(cx).status() == ThreadStatus::Idle); + + v_flex() + .size_full() + .key_context("AcpThread") + .on_action(cx.listener(Self::chat)) + .on_action(cx.listener(Self::previous_history_message)) + .on_action(cx.listener(Self::next_history_message)) + .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::LoadError(e) => v_flex() + .p_2() + .flex_1() + .items_center() + .justify_center() + .child(self.render_error_state(e, cx)), + ThreadState::Ready { thread, .. } => v_flex().flex_1().map(|this| { + if self.list_state.item_count() > 0 { + this.child( + list(self.list_state.clone()) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .children(match thread.read(cx).status() { + ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => None, + ThreadStatus::Generating => div() + .px_5() + .py_2() + .child(LoadingLabel::new("").size(LabelSize::Small)) + .into(), + }) + } else { + this.child(self.render_empty_state(false, cx)) + } + }), + }) + .when(show_controls, |el| el.child(feedback_container)) + .when_some(self.last_error.clone(), |el, error| { + el.child( + div() + .p_2() + .text_xs() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().status().error_background) + .child( + self.render_markdown(error, default_markdown_style(false, window, cx)), + ), + ) + }) + .child( + v_flex() + .p_2() + .pt_3() + .gap_1() + .bg(cx.theme().colors().editor_background) + .border_t_1() + .border_color(cx.theme().colors().border) + .child(self.render_message_editor(cx)) + .child({ + let thread = self.thread(); + + h_flex().justify_end().child( + if thread.map_or(true, |thread| { + thread.read(cx).status() == ThreadStatus::Idle + }) { + IconButton::new("send-message", IconName::Send) + .icon_color(Color::Accent) + .style(ButtonStyle::Filled) + .disabled(thread.is_none() || is_editor_empty) + .on_click({ + let focus_handle = focus_handle.clone(); + move |_event, window, cx| { + focus_handle.dispatch_action(&Chat, window, cx); + } + }) + .when(!is_editor_empty, |button| { + button.tooltip(move |window, cx| { + Tooltip::for_action("Send", &Chat, window, cx) + }) + }) + .when(is_editor_empty, |button| { + button.tooltip(Tooltip::text("Type a message to submit")) + }) + } else { + IconButton::new("stop-generation", IconName::StopFilled) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .tooltip(move |window, cx| { + Tooltip::for_action( + "Stop Generation", + &editor::actions::Cancel, + window, + cx, + ) + }) + .on_click(cx.listener(|this, _event, _, cx| this.cancel(cx))) + }, + ) + }), + ) + } +} + +fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let mut style = default_markdown_style(false, window, cx); + let mut text_style = window.text_style(); + let theme_settings = ThemeSettings::get_global(cx); + + let buffer_font = theme_settings.buffer_font.family.clone(); + let buffer_font_size = TextSize::Small.rems(cx); + + text_style.refine(&TextStyleRefinement { + font_family: Some(buffer_font), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }); + + style.base_text_style = text_style; + style.link_callback = Some(Rc::new(move |url, cx| { + if MentionPath::try_parse(url).is_some() { + let colors = cx.theme().colors(); + Some(TextStyleRefinement { + background_color: Some(colors.element_background), + ..Default::default() + }) + } else { + None + } + })); + style +} + +fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + + let buffer_font_size = TextSize::Small.rems(cx); + + let mut text_style = window.text_style(); + let line_height = buffer_font_size * 1.75; + + let font_family = if buffer_font { + theme_settings.buffer_font.family.clone() + } else { + theme_settings.ui_font.family.clone() + }; + + let font_size = if buffer_font { + TextSize::Small.rems(cx) + } else { + TextSize::Default.rems(cx) + }; + + text_style.refine(&TextStyleRefinement { + font_family: Some(font_family), + font_fallbacks: theme_settings.ui_font.fallbacks.clone(), + font_features: Some(theme_settings.ui_font.features.clone()), + font_size: Some(font_size.into()), + line_height: Some(line_height.into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().colors().element_selection_background, + code_block_overflow_x_scroll: true, + table_overflow_x_scroll: true, + heading_level_styles: Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.15).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.05).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.875).into()), + ..Default::default() + }), + }), + code_block: StyleRefinement { + padding: EdgesRefinement { + top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))), + }, + margin: EdgesRefinement { + top: Some(Length::Definite(Pixels(8.).into())), + left: Some(Length::Definite(Pixels(0.).into())), + right: Some(Length::Definite(Pixels(0.).into())), + bottom: Some(Length::Definite(Pixels(12.).into())), + }, + border_style: Some(BorderStyle::Solid), + border_widths: EdgesRefinement { + top: Some(AbsoluteLength::Pixels(Pixels(1.))), + left: Some(AbsoluteLength::Pixels(Pixels(1.))), + right: Some(AbsoluteLength::Pixels(Pixels(1.))), + bottom: Some(AbsoluteLength::Pixels(Pixels(1.))), + }, + border_color: Some(colors.border_variant), + background: Some(colors.editor_background.into()), + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + font_size: Some(buffer_font_size.into()), + background_color: Some(colors.editor_foreground.opacity(0.08)), + ..Default::default() + }, + link: TextStyleRefinement { + background_color: Some(colors.editor_foreground.opacity(0.025)), + underline: Some(UnderlineStyle { + color: Some(colors.text_accent.opacity(0.5)), + thickness: px(1.), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + } +} diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index ba0021c33c..9e5f6e09c8 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -740,7 +740,9 @@ fn wait_for_context_server( }); cx.spawn(async move |_cx| { - let result = rx.await.unwrap(); + let result = rx + .await + .map_err(|_| Arc::from("Context server store was dropped"))?; drop(subscription); result }) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5f58e0bd8d..e726dd6640 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -7,12 +7,14 @@ use std::time::Duration; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use crate::NewGeminiThread; use crate::language_model_selector::ToggleModelSelector; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu, + acp::AcpThreadView, active_thread::{self, ActiveThread, ActiveThreadEvent}, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, agent_diff::AgentDiff, @@ -38,6 +40,7 @@ use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; use client::{UserStore, zed_urls}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use feature_flags::{self, FeatureFlagAppExt}; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, @@ -109,6 +112,12 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.new_prompt_editor(window, cx)); } }) + .register_action(|workspace, _: &NewGeminiThread, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + workspace.focus_panel::(window, cx); + panel.update(cx, |panel, cx| panel.new_gemini_thread(window, cx)); + } + }) .register_action(|workspace, action: &OpenRulesLibrary, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); @@ -125,7 +134,8 @@ pub fn init(cx: &mut App) { let thread = thread.read(cx).thread().clone(); AgentDiffPane::deploy_in_workspace(thread, workspace, window, cx); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } @@ -188,6 +198,9 @@ enum ActiveView { message_editor: Entity, _subscriptions: Vec, }, + AcpThread { + thread_view: Entity, + }, TextThread { context_editor: Entity, title_editor: Entity, @@ -207,7 +220,9 @@ enum WhichFontSize { impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { - ActiveView::Thread { .. } | ActiveView::History => WhichFontSize::AgentFont, + ActiveView::Thread { .. } | ActiveView::AcpThread { .. } | ActiveView::History => { + WhichFontSize::AgentFont + } ActiveView::TextThread { .. } => WhichFontSize::BufferFont, ActiveView::Configuration => WhichFontSize::None, } @@ -238,6 +253,7 @@ impl ActiveView { thread.scroll_to_bottom(cx); }); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -653,7 +669,8 @@ impl AgentPanel { .clone() .update(cx, |thread, cx| thread.get_or_init_configured_model(cx)); } - ActiveView::TextThread { .. } + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} }, @@ -733,6 +750,9 @@ impl AgentPanel { ActiveView::Thread { thread, .. } => { thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx)); } + ActiveView::AcpThread { thread_view, .. } => { + thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx)); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -740,7 +760,10 @@ impl AgentPanel { fn active_message_editor(&self) -> Option<&Entity> { match &self.active_view { ActiveView::Thread { message_editor, .. } => Some(message_editor), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, } } @@ -862,6 +885,21 @@ impl AgentPanel { context_editor.focus_handle(cx).focus(window); } + fn new_gemini_thread(&mut self, window: &mut Window, cx: &mut Context) { + let workspace = self.workspace.clone(); + let project = self.project.clone(); + + cx.spawn_in(window, async move |this, cx| { + let thread_view = cx.new_window_entity(|window, cx| { + crate::acp::AcpThreadView::new(workspace, project, window, cx) + })?; + this.update_in(cx, |this, window, cx| { + this.set_active_view(ActiveView::AcpThread { thread_view }, window, cx); + }) + }) + .detach(); + } + fn deploy_rules_library( &mut self, action: &OpenRulesLibrary, @@ -994,6 +1032,7 @@ impl AgentPanel { cx, ) }); + let message_editor = cx.new(|cx| { MessageEditor::new( self.fs.clone(), @@ -1025,6 +1064,9 @@ impl AgentPanel { ActiveView::Thread { message_editor, .. } => { message_editor.focus_handle(cx).focus(window); } + ActiveView::AcpThread { thread_view } => { + thread_view.focus_handle(cx).focus(window); + } ActiveView::TextThread { context_editor, .. } => { context_editor.focus_handle(cx).focus(window); } @@ -1144,7 +1186,10 @@ impl AgentPanel { }) .log_err(); } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => {} } } @@ -1197,6 +1242,13 @@ impl AgentPanel { ) .detach_and_log_err(cx); } + ActiveView::AcpThread { thread_view } => { + thread_view + .update(cx, |thread_view, cx| { + thread_view.open_thread_as_markdown(workspace, window, cx) + }) + .detach_and_log_err(cx); + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} } } @@ -1351,7 +1403,8 @@ impl AgentPanel { } }) } - _ => {} + ActiveView::AcpThread { .. } => {} + ActiveView::History | ActiveView::Configuration => {} } if current_is_special && !new_is_special { @@ -1437,6 +1490,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::History => self.history.focus_handle(cx), ActiveView::TextThread { context_editor, .. } => context_editor.focus_handle(cx), ActiveView::Configuration => { @@ -1593,6 +1647,9 @@ impl AgentPanel { .into_any_element(), } } + ActiveView::AcpThread { thread_view } => Label::new(thread_view.read(cx).title(cx)) + .truncate() + .into_any_element(), ActiveView::TextThread { title_editor, context_editor, @@ -1727,7 +1784,10 @@ impl AgentPanel { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()), - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => None, + ActiveView::AcpThread { .. } + | ActiveView::TextThread { .. } + | ActiveView::History + | ActiveView::Configuration => None, }; let agent_extra_menu = PopoverMenu::new("agent-options-menu") @@ -1755,6 +1815,9 @@ impl AgentPanel { menu = menu .action("New Thread", NewThread::default().boxed_clone()) .action("New Text Thread", NewTextThread.boxed_clone()) + .when(cx.has_flag::(), |this| { + this.action("New Gemini Thread", NewGeminiThread.boxed_clone()) + }) .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); if !thread.is_empty() { @@ -1893,6 +1956,9 @@ impl AgentPanel { message_editor, .. } => (thread.read(cx), message_editor.read(cx)), + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2031,6 +2097,9 @@ impl AgentPanel { return false; } } + ActiveView::AcpThread { .. } => { + return false; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return false; } @@ -2615,6 +2684,9 @@ impl AgentPanel { ) -> Option { let active_thread = match &self.active_view { ActiveView::Thread { thread, .. } => thread, + ActiveView::AcpThread { .. } => { + return None; + } ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { return None; } @@ -2961,6 +3033,9 @@ impl AgentPanel { .detach(); }); } + ActiveView::AcpThread { .. } => { + unimplemented!() + } ActiveView::TextThread { context_editor, .. } => { context_editor.update(cx, |context_editor, cx| { TextThreadEditor::insert_dragged_files( @@ -3034,6 +3109,7 @@ impl Render for AgentPanel { }); this.continue_conversation(window, cx); } + ActiveView::AcpThread { .. } => {} ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {} @@ -3075,6 +3151,10 @@ impl Render for AgentPanel { }) .child(h_flex().child(message_editor.clone())) .child(self.render_drag_target(cx)), + ActiveView::AcpThread { thread_view, .. } => parent + .relative() + .child(thread_view.clone()) + .child(self.render_drag_target(cx)), ActiveView::History => parent.child(self.history.clone()), ActiveView::TextThread { context_editor, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e488cf5a1e..10912cc055 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,3 +1,4 @@ +mod acp; mod active_thread; mod agent_configuration; mod agent_diff; @@ -56,6 +57,8 @@ actions!( [ /// Creates a new text-based conversation thread. NewTextThread, + /// Creates a new Gemini CLI-based conversation thread. + NewGeminiThread, /// Toggles the context picker interface for adding files, symbols, or other context. ToggleContextPicker, /// Toggles the navigation menu for switching between threads and views. @@ -76,8 +79,6 @@ actions!( AddContextServer, /// Removes the currently selected thread. RemoveSelectedThread, - /// Starts a chat conversation with the agent. - Chat, /// Starts a chat conversation with follow-up enabled. ChatWithFollow, /// Cycles to the next inline assist suggestion. diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index 73fc0b36ce..5cc56b014e 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -1,6 +1,6 @@ mod completion_provider; mod fetch_context_picker; -mod file_context_picker; +pub(crate) mod file_context_picker; mod rules_context_picker; mod symbol_context_picker; mod thread_context_picker; diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 38065b828a..d1eae02246 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -47,13 +47,14 @@ use ui::{ }; use util::ResultExt as _; use workspace::{CollaboratorId, Workspace}; +use zed_actions::agent::Chat; use zed_llm_client::CompletionIntent; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; use crate::profile_selector::ProfileSelector; use crate::{ - ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, + ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector, register_agent_preview, }; diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index d64ee9f7c3..0527399af8 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -15,6 +15,8 @@ path = "src/askpass.rs" anyhow.workspace = true futures.workspace = true gpui.workspace = true +net.workspace = true +parking_lot.workspace = true smol.workspace = true tempfile.workspace = true util.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index 519c08aa26..f085a2be72 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -1,21 +1,14 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::{ffi::OsStr, time::Duration}; -#[cfg(unix)] -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use futures::channel::{mpsc, oneshot}; -#[cfg(unix)] -use futures::{AsyncBufReadExt as _, io::BufReader}; -#[cfg(unix)] -use futures::{AsyncWriteExt as _, FutureExt as _, select_biased}; -use futures::{SinkExt, StreamExt}; +use futures::{ + AsyncBufReadExt as _, AsyncWriteExt as _, FutureExt as _, SinkExt, StreamExt, io::BufReader, + select_biased, +}; use gpui::{AsyncApp, BackgroundExecutor, Task}; -#[cfg(unix)] use smol::fs; -#[cfg(unix)] -use smol::net::unix::UnixListener; -#[cfg(unix)] -use util::{ResultExt as _, fs::make_file_executable, get_shell_safe_zed_path}; +use util::ResultExt as _; #[derive(PartialEq, Eq)] pub enum AskPassResult { @@ -42,41 +35,56 @@ impl AskPassDelegate { Self { tx, _task: task } } - pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result { + pub async fn ask_password(&mut self, prompt: String) -> Result { let (tx, rx) = oneshot::channel(); self.tx.send((prompt, tx)).await?; Ok(rx.await?) } } -#[cfg(unix)] pub struct AskPassSession { - script_path: PathBuf, + #[cfg(not(target_os = "windows"))] + script_path: std::path::PathBuf, + #[cfg(target_os = "windows")] + askpass_helper: String, + #[cfg(target_os = "windows")] + secret: std::sync::Arc>, _askpass_task: Task<()>, askpass_opened_rx: Option>, askpass_kill_master_rx: Option>, } -#[cfg(unix)] +#[cfg(not(target_os = "windows"))] +const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(target_os = "windows")] +const ASKPASS_SCRIPT_NAME: &str = "askpass.ps1"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. #[must_use] - pub async fn new( - executor: &BackgroundExecutor, - mut delegate: AskPassDelegate, - ) -> anyhow::Result { + pub async fn new(executor: &BackgroundExecutor, mut delegate: AskPassDelegate) -> Result { + use net::async_net::UnixListener; + use util::fs::make_file_executable; + + #[cfg(target_os = "windows")] + let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new())); let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; let askpass_socket = temp_dir.path().join("askpass.sock"); - let askpass_script_path = temp_dir.path().join("askpass.sh"); + let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>(); - let listener = - UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?; - let zed_path = get_shell_safe_zed_path()?; + let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?; + #[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 askpass")?; let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>(); let mut kill_tx = Some(askpass_kill_master_tx); + #[cfg(target_os = "windows")] + let askpass_secret = secret.clone(); let askpass_task = executor.spawn(async move { let mut askpass_opened_tx = Some(askpass_opened_tx); @@ -93,10 +101,14 @@ impl AskPassSession { if let Some(password) = delegate .ask_password(prompt.to_string()) .await - .context("failed to get askpass password") + .context("getting askpass password") .log_err() { stream.write_all(password.as_bytes()).await.log_err(); + #[cfg(target_os = "windows")] + { + *askpass_secret.lock() = password; + } } else { if let Some(kill_tx) = kill_tx.take() { kill_tx.send(()).log_err(); @@ -112,34 +124,49 @@ impl AskPassSession { }); // Create an askpass script that communicates back to this process. - let askpass_script = format!( - "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", - zed_exe = zed_path, - askpass_socket = askpass_socket.display(), - print_args = "printf '%s\\0' \"$@\"", - shebang = "#!/bin/sh", - ); - fs::write(&askpass_script_path, askpass_script).await?; + let askpass_script = generate_askpass_script(&zed_path, &askpass_socket); + fs::write(&askpass_script_path, askpass_script) + .await + .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; make_file_executable(&askpass_script_path).await?; + #[cfg(target_os = "windows")] + let askpass_helper = format!( + "powershell.exe -ExecutionPolicy Bypass -File {}", + askpass_script_path.display() + ); Ok(Self { + #[cfg(not(target_os = "windows"))] script_path: askpass_script_path, + + #[cfg(target_os = "windows")] + secret, + #[cfg(target_os = "windows")] + askpass_helper, + _askpass_task: askpass_task, askpass_kill_master_rx: Some(askpass_kill_master_rx), askpass_opened_rx: Some(askpass_opened_rx), }) } - pub fn script_path(&self) -> &Path { + #[cfg(not(target_os = "windows"))] + pub fn script_path(&self) -> impl AsRef { &self.script_path } + #[cfg(target_os = "windows")] + pub fn script_path(&self) -> impl AsRef { + &self.askpass_helper + } + // This will run the askpass task forever, resolving as many authentication requests as needed. // The caller is responsible for examining the result of their own commands and cancelling this // future when this is no longer needed. Note that this can only be called once, but due to the // drop order this takes an &mut, so you can `drop()` it after you're done with the master process. pub async fn run(&mut self) -> AskPassResult { - let connection_timeout = Duration::from_secs(10); + // This is the default timeout setting used by VSCode. + let connection_timeout = Duration::from_secs(17); let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_kill_master_rx = self .askpass_kill_master_rx @@ -158,14 +185,19 @@ impl AskPassSession { } } } + + /// This will return the password that was last set by the askpass script. + #[cfg(target_os = "windows")] + pub fn get_password(&self) -> String { + self.secret.lock().clone() + } } /// The main function for when Zed is running in netcat mode for use in askpass. /// Called from both the remote server binary and the zed binary in their respective main functions. -#[cfg(unix)] pub fn main(socket: &str) { + use net::UnixStream; use std::io::{self, Read, Write}; - use std::os::unix::net::UnixStream; use std::process::exit; let mut stream = match UnixStream::connect(socket) { @@ -182,6 +214,10 @@ pub fn main(socket: &str) { exit(1); } + #[cfg(target_os = "windows")] + while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') { + buffer.pop(); + } if buffer.last() != Some(&b'\0') { buffer.push(b'\0'); } @@ -202,28 +238,28 @@ pub fn main(socket: &str) { exit(1); } } -#[cfg(not(unix))] -pub fn main(_socket: &str) {} -#[cfg(not(unix))] -pub struct AskPassSession { - path: PathBuf, +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String { + format!( + "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", + zed_exe = zed_path, + askpass_socket = askpass_socket.display(), + print_args = "printf '%s\\0' \"$@\"", + shebang = "#!/bin/sh", + ) } -#[cfg(not(unix))] -impl AskPassSession { - pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result { - Ok(Self { - path: PathBuf::new(), - }) - } - - pub fn script_path(&self) -> &Path { - &self.path - } - - pub async fn run(&mut self) -> AskPassResult { - futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))).await; - AskPassResult::Timedout - } +#[inline] +#[cfg(target_os = "windows")] +fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String { + format!( + r#" + $ErrorActionPreference = 'Stop'; + ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null + "#, + zed_exe = zed_path.display(), + askpass_socket = askpass_socket.display(), + ) } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 9a3eac907c..6641873182 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -2,12 +2,13 @@ use crate::{ schema::json_schema_for, ui::{COLLAPSED_LINES, ToolOutputPreview}, }; +use agent_settings; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; use futures::{FutureExt as _, future::Shared}; use gpui::{ - AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement, - WeakEntity, Window, + Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, + TextStyleRefinement, Transformation, WeakEntity, Window, percentage, }; use language::LineEnding; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; @@ -247,6 +248,7 @@ impl Tool for TerminalTool { command_markdown.clone(), working_dir.clone(), cx.entity_id(), + cx, ) }); @@ -441,7 +443,10 @@ impl TerminalToolCard { input_command: Entity, working_dir: Option, entity_id: EntityId, + cx: &mut Context, ) -> Self { + let expand_terminal_card = + agent_settings::AgentSettings::get_global(cx).expand_terminal_card; Self { input_command, working_dir, @@ -453,7 +458,7 @@ impl TerminalToolCard { finished_with_empty_output: false, original_content_len: 0, content_line_count: 0, - preview_expanded: true, + preview_expanded: expand_terminal_card, start_instant: Instant::now(), elapsed_time: None, } @@ -518,6 +523,46 @@ impl ToolCard for TerminalToolCard { .color(Color::Muted), ), ) + .when(!self.command_finished, |header| { + header.child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ), + ) + }) + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", self.entity_id)) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when(command_failed && self.exit_status.is_some(), |this| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + self.exit_status + .and_then(|status| status.code()) + .unwrap_or(-1), + ))) + }) + .when( + !command_failed && tool_failed && status.error().is_some(), + |this| { + this.tooltip(Tooltip::text(format!( + "Error: {}", + status.error().unwrap(), + ))) + }, + ), + ) + }) .when(self.was_content_truncated, |header| { let tooltip = if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { "Output exceeded terminal max lines and was \ @@ -555,34 +600,6 @@ impl ToolCard for TerminalToolCard { .size(LabelSize::Small), ) }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", self.entity_id)) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when(command_failed && self.exit_status.is_some(), |this| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - self.exit_status - .and_then(|status| status.code()) - .unwrap_or(-1), - ))) - }) - .when( - !command_failed && tool_failed && status.error().is_some(), - |this| { - this.tooltip(Tooltip::text(format!( - "Error: {}", - status.error().unwrap(), - ))) - }, - ), - ) - }) .when(!self.finished_with_empty_output, |header| { header.child( Disclosure::new( @@ -634,6 +651,7 @@ impl ToolCard for TerminalToolCard { div() .pt_2() .border_t_1() + .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) .bg(cx.theme().colors().editor_background) .rounded_b_md() diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 1123d3f8e2..d62a9cdbe3 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -638,7 +638,7 @@ impl AutoUpdater { let filename = match OS { "macos" => anyhow::Ok("Zed.dmg"), "linux" => Ok("zed.tar.gz"), - "windows" => Ok("ZedUpdateInstaller.exe"), + "windows" => Ok("zed_editor_installer.exe"), unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), }?; diff --git a/crates/auto_update_helper/app-icon.ico b/crates/auto_update_helper/app-icon.ico index e69de29bb2..321e90fcfa 100644 Binary files a/crates/auto_update_helper/app-icon.ico and b/crates/auto_update_helper/app-icon.ico differ diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a97985e692..d6ddf79ea6 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -130,6 +130,13 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result { } fn main() -> Result<()> { + #[cfg(all(not(debug_assertions), target_os = "windows"))] + unsafe { + use ::windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole}; + + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + } + #[cfg(unix)] util::prevent_root_execution(); diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 91cf4d7af0..ca840493ad 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -26,7 +26,7 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id" CREATE TABLE "access_tokens" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "user_id" INTEGER REFERENCES users (id), + "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, "impersonated_user_id" INTEGER REFERENCES users (id), "hash" VARCHAR(128) ); diff --git a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql new file mode 100644 index 0000000000..ae0ffe24f6 --- /dev/null +++ b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql @@ -0,0 +1,3 @@ +ALTER TABLE access_tokens DROP CONSTRAINT access_tokens_user_id_fkey; +ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/crates/collab/src/db/tests/user_tests.rs b/crates/collab/src/db/tests/user_tests.rs index bb2dac1f77..dd61da55ca 100644 --- a/crates/collab/src/db/tests/user_tests.rs +++ b/crates/collab/src/db/tests/user_tests.rs @@ -44,3 +44,53 @@ async fn test_accepted_tos(db: &Arc) { let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); assert!(user.accepted_tos_at.is_none()); } + +test_both_dbs!( + test_destroy_user_cascade_deletes_access_tokens, + test_destroy_user_cascade_deletes_access_tokens_postgres, + test_destroy_user_cascade_deletes_access_tokens_sqlite +); + +async fn test_destroy_user_cascade_deletes_access_tokens(db: &Arc) { + let user_id = db + .create_user( + "user1@example.com", + Some("user1"), + false, + NewUserParams { + github_login: "user1".to_string(), + github_user_id: 12345, + }, + ) + .await + .unwrap() + .user_id; + + let user = db.get_user_by_id(user_id).await.unwrap(); + assert!(user.is_some()); + + let token_1_id = db + .create_access_token(user_id, None, "token-1", 10) + .await + .unwrap(); + + let token_2_id = db + .create_access_token(user_id, None, "token-2", 10) + .await + .unwrap(); + + let token_1 = db.get_access_token(token_1_id).await; + let token_2 = db.get_access_token(token_2_id).await; + assert!(token_1.is_ok()); + assert!(token_2.is_ok()); + + db.destroy_user(user_id).await.unwrap(); + + let user = db.get_user_by_id(user_id).await.unwrap(); + assert!(user.is_none()); + + let token_1 = db.get_access_token(token_1_id).await; + let token_2 = db.get_access_token(token_2_id).await; + assert!(token_1.is_err()); + assert!(token_2.is_err()); +} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3352d21ef8..aa2408d6d9 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1066,7 +1066,7 @@ impl DisplaySnapshot { } let font_size = editor_style.text.font_size.to_pixels(*rem_size); - text_system.layout_line(&line, font_size, &runs) + text_system.layout_line(&line, font_size, &runs, None) } pub fn x_for_display_point( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6d529287a7..c5fe0db74c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -865,9 +865,19 @@ pub trait Addon: 'static { } } +struct ChangeLocation { + current: Option>, + original: Vec, +} +impl ChangeLocation { + fn locations(&self) -> &[Anchor] { + self.current.as_ref().unwrap_or(&self.original) + } +} + /// A set of caret positions, registered when the editor was edited. pub struct ChangeList { - changes: Vec>, + changes: Vec, /// Currently "selected" change. position: Option, } @@ -894,20 +904,38 @@ impl ChangeList { (prev + count).min(self.changes.len() - 1) }; self.position = Some(next); - self.changes.get(next).map(|anchors| anchors.as_slice()) + self.changes.get(next).map(|change| change.locations()) } /// Adds a new change to the list, resetting the change list position. - pub fn push_to_change_list(&mut self, pop_state: bool, new_positions: Vec) { + pub fn push_to_change_list(&mut self, group: bool, new_positions: Vec) { self.position.take(); - if pop_state { - self.changes.pop(); + if let Some(last) = self.changes.last_mut() + && group + { + last.current = Some(new_positions) + } else { + self.changes.push(ChangeLocation { + original: new_positions, + current: None, + }); } - self.changes.push(new_positions.clone()); } pub fn last(&self) -> Option<&[Anchor]> { - self.changes.last().map(|anchors| anchors.as_slice()) + self.changes.last().map(|change| change.locations()) + } + + pub fn last_before_grouping(&self) -> Option<&[Anchor]> { + self.changes.last().map(|change| change.original.as_slice()) + } + + pub fn invert_last_group(&mut self) { + if let Some(last) = self.changes.last_mut() { + if let Some(current) = last.current.as_mut() { + mem::swap(&mut last.original, current); + } + } } } @@ -1142,7 +1170,6 @@ pub struct Editor { pub change_list: ChangeList, inline_value_cache: InlineValueCache, selection_drag_state: SelectionDragState, - drag_and_drop_selection_enabled: bool, next_color_inlay_id: usize, colors: Option, folding_newlines: Task<()>, @@ -2174,7 +2201,6 @@ impl Editor { change_list: ChangeList::new(), mode, selection_drag_state: SelectionDragState::None, - drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection, folding_newlines: Task::ready(()), }; if let Some(breakpoints) = editor.breakpoint_store.as_ref() { @@ -19871,7 +19897,6 @@ impl Editor { self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default(); - self.drag_and_drop_selection_enabled = editor_settings.drag_and_drop_selection; } if old_cursor_shape != self.cursor_shape { diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index d7b8bac359..5d8379ddfb 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -52,7 +52,7 @@ pub struct EditorSettings { #[serde(default)] pub diagnostics_max_severity: Option, pub inline_code_actions: bool, - pub drag_and_drop_selection: bool, + pub drag_and_drop_selection: DragAndDropSelection, pub lsp_document_colors: DocumentColorsRenderMode, } @@ -275,6 +275,26 @@ pub struct ScrollbarAxes { pub vertical: bool, } +/// Whether to allow drag and drop text selection in buffer. +#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct DragAndDropSelection { + /// When true, enables drag and drop text selection in buffer. + /// + /// Default: true + #[serde(default = "default_true")] + pub enabled: bool, + + /// The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. + /// + /// Default: 300 + #[serde(default = "default_drag_and_drop_selection_delay_ms")] + pub delay: u64, +} + +fn default_drag_and_drop_selection_delay_ms() -> u64 { + 300 +} + /// Which diagnostic indicators to show in the scrollbar. /// /// Default: all @@ -536,10 +556,8 @@ pub struct EditorSettingsContent { /// Default: true pub inline_code_actions: Option, - /// Whether to allow drag and drop text selection in buffer. - /// - /// Default: true - pub drag_and_drop_selection: Option, + /// Drag and drop related settings + pub drag_and_drop_selection: Option, /// How to render LSP `textDocument/documentColor` colors in the editor. /// diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d5c8eae99c..8a5bfb3bab 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -87,7 +87,6 @@ use util::{RangeExt, ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; -const SELECTION_DRAG_DELAY: Duration = Duration::from_millis(300); /// Determines what kinds of highlights should be applied to a lines background. #[derive(Clone, Copy, Default)] @@ -644,7 +643,11 @@ impl EditorElement { return; } - if editor.drag_and_drop_selection_enabled && click_count == 1 { + if EditorSettings::get_global(cx) + .drag_and_drop_selection + .enabled + && click_count == 1 + { let newest_anchor = editor.selections.newest_anchor(); let snapshot = editor.snapshot(window, cx); let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); @@ -1022,7 +1025,10 @@ impl EditorElement { ref click_position, ref mouse_down_time, } => { - if mouse_down_time.elapsed() >= SELECTION_DRAG_DELAY { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx).drag_and_drop_selection.delay, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { let drop_cursor = Selection { id: post_inc(&mut editor.selections.next_selection_id), start: drop_anchor, @@ -1611,6 +1617,7 @@ impl EditorElement { strikethrough: None, underline: None, }], + None, ) }) } else { @@ -3263,10 +3270,12 @@ impl EditorElement { underline: None, strikethrough: None, }; - let line = - window - .text_system() - .shape_line(line.to_string().into(), font_size, &[run]); + let line = window.text_system().shape_line( + line.to_string().into(), + font_size, + &[run], + None, + ); LineWithInvisibles { width: line.width, len: line.len, @@ -5707,6 +5716,19 @@ impl EditorElement { let editor = self.editor.read(cx); if editor.mouse_cursor_hidden { window.set_window_cursor_style(CursorStyle::None); + } else if let SelectionDragState::ReadyToDrag { + mouse_down_time, .. + } = &editor.selection_drag_state + { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx).drag_and_drop_selection.delay, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { + window.set_cursor_style( + CursorStyle::DragCopy, + &layout.position_map.text_hitbox, + ); + } } else if matches!( editor.selection_drag_state, SelectionDragState::Dragging { .. } @@ -6888,6 +6910,7 @@ impl EditorElement { underline: None, strikethrough: None, }], + None, ); layout.width @@ -6916,6 +6939,7 @@ impl EditorElement { text, self.style.text.font_size.to_pixels(window.rem_size()), &[run], + None, ) } @@ -7184,10 +7208,12 @@ impl LineWithInvisibles { }]) { if let Some(replacement) = highlighted_chunk.replacement { if !line.is_empty() { - let shaped_line = - window - .text_system() - .shape_line(line.clone().into(), font_size, &styles); + let shaped_line = window.text_system().shape_line( + line.clone().into(), + font_size, + &styles, + None, + ); width += shaped_line.width; len += shaped_line.len; fragments.push(LineFragment::Text(shaped_line)); @@ -7207,6 +7233,7 @@ impl LineWithInvisibles { chunk, font_size, &[text_style.to_run(highlighted_chunk.text.len())], + None, ); AvailableSpace::Definite(shaped_line.width) } else { @@ -7251,7 +7278,7 @@ impl LineWithInvisibles { }; let line_layout = window .text_system() - .shape_line(x, font_size, &[run]) + .shape_line(x, font_size, &[run], None) .with_len(highlighted_chunk.text.len()); width += line_layout.width; @@ -7266,6 +7293,7 @@ impl LineWithInvisibles { line.clone().into(), font_size, &styles, + None, ); width += shaped_line.width; len += shaped_line.len; @@ -7935,6 +7963,7 @@ impl Element for EditorElement { editor.last_bounds = Some(bounds); editor.gutter_dimensions = gutter_dimensions; editor.set_visible_line_count(bounds.size.height / line_height, window, cx); + editor.set_visible_column_count(editor_content_width / em_advance); if matches!( editor.mode, @@ -8440,6 +8469,7 @@ impl Element for EditorElement { scroll_width, em_advance, &line_layouts, + window, cx, ) } else { @@ -8594,6 +8624,7 @@ impl Element for EditorElement { scroll_width, em_advance, &line_layouts, + window, cx, ) } else { @@ -8831,6 +8862,7 @@ impl Element for EditorElement { underline: None, strikethrough: None, }], + None ); let space_invisible = window.text_system().shape_line( "•".into(), @@ -8843,6 +8875,7 @@ impl Element for EditorElement { underline: None, strikethrough: None, }], + None ); let mode = snapshot.mode.clone(); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index cae4789535..bda229e346 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -381,10 +381,14 @@ fn show_hover( .anchor_after(local_diagnostic.range.end), }; + let scroll_handle = ScrollHandle::new(); + Some(DiagnosticPopover { local_diagnostic, markdown, border_color, + scrollbar_state: ScrollbarState::new(scroll_handle.clone()), + scroll_handle, background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, @@ -955,6 +959,8 @@ pub struct DiagnosticPopover { pub keyboard_grace: Rc>, pub anchor: Anchor, _subscription: Subscription, + pub scroll_handle: ScrollHandle, + pub scrollbar_state: ScrollbarState, } impl DiagnosticPopover { @@ -968,10 +974,7 @@ impl DiagnosticPopover { let this = cx.entity().downgrade(); div() .id("diagnostic") - .block() - .max_h(max_size.height) - .overflow_y_scroll() - .max_w(max_size.width) + .occlude() .elevation_2_borderless(cx) // Don't draw the background color if the theme // allows transparent surfaces. @@ -992,27 +995,72 @@ impl DiagnosticPopover { div() .py_1() .px_2() - .child( - MarkdownElement::new( - self.markdown.clone(), - diagnostics_markdown_style(window, cx), - ) - .on_url_click(move |link, window, cx| { - if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) { - this.update(cx, |this, cx| { - renderer.as_ref().open_link(this, link, window, cx); - }) - .ok(); - } - }), - ) .bg(self.background_color) .border_1() .border_color(self.border_color) - .rounded_lg(), + .rounded_lg() + .child( + div() + .id("diagnostic-content-container") + .overflow_y_scroll() + .max_w(max_size.width) + .max_h(max_size.height) + .track_scroll(&self.scroll_handle) + .child( + MarkdownElement::new( + self.markdown.clone(), + diagnostics_markdown_style(window, cx), + ) + .on_url_click( + move |link, window, cx| { + if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) + { + this.update(cx, |this, cx| { + renderer.as_ref().open_link(this, link, window, cx); + }) + .ok(); + } + }, + ), + ), + ) + .child(self.render_vertical_scrollbar(cx)), ) .into_any_element() } + + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .occlude() + .id("diagnostic-popover-vertical-scroll") + .on_mouse_move(cx.listener(|_, _, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, _, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scrollbar_state.clone())) + } } #[cfg(test)] diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index fa6bd93ab8..2e4631a62b 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1607,24 +1607,10 @@ impl SearchableItem for Editor { let text = self.buffer.read(cx); let text = text.snapshot(cx); let mut edits = vec![]; - let mut last_point: Option = None; for m in matches { - let point = m.start.to_point(&text); let text = text.text_for_range(m.clone()).collect::>(); - // Check if the row for the current match is different from the last - // match. If that's not the case and we're still replacing matches - // in the same row/line, skip this match if the `one_match_per_line` - // option is enabled. - if last_point.is_none() { - last_point = Some(point); - } else if last_point.is_some() && point.row != last_point.unwrap().row { - last_point = Some(point); - } else if query.one_match_per_line().is_some_and(|enabled| enabled) { - continue; - } - let text: Cow<_> = if text.len() == 1 { text.first().cloned().unwrap().into() } else { diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 0642b2b20e..b3007d3091 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -13,6 +13,7 @@ use crate::{ pub use autoscroll::{Autoscroll, AutoscrollStrategy}; use core::fmt::Debug; use gpui::{App, Axis, Context, Global, Pixels, Task, Window, point, px}; +use language::language_settings::{AllLanguageSettings, SoftWrap}; use language::{Bias, Point}; pub use scroll_amount::ScrollAmount; use settings::Settings; @@ -151,12 +152,16 @@ pub struct ScrollManager { pub(crate) vertical_scroll_margin: f32, anchor: ScrollAnchor, ongoing: OngoingScroll, + /// The second element indicates whether the autoscroll request is local + /// (true) or remote (false). Local requests are initiated by user actions, + /// while remote requests come from external sources. autoscroll_request: Option<(Autoscroll, bool)>, last_autoscroll: Option<(gpui::Point, f32, f32, AutoscrollStrategy)>, show_scrollbars: bool, hide_scrollbar_task: Option>, active_scrollbar: Option, visible_line_count: Option, + visible_column_count: Option, forbid_vertical_scroll: bool, minimap_thumb_state: Option, } @@ -173,6 +178,7 @@ impl ScrollManager { active_scrollbar: None, last_autoscroll: None, visible_line_count: None, + visible_column_count: None, forbid_vertical_scroll: false, minimap_thumb_state: None, } @@ -210,7 +216,7 @@ impl ScrollManager { window: &mut Window, cx: &mut Context, ) { - let (new_anchor, top_row) = if scroll_position.y <= 0. { + let (new_anchor, top_row) = if scroll_position.y <= 0. && scroll_position.x <= 0. { ( ScrollAnchor { anchor: Anchor::min(), @@ -218,6 +224,22 @@ impl ScrollManager { }, 0, ) + } else if scroll_position.y <= 0. { + let buffer_point = map + .clip_point( + DisplayPoint::new(DisplayRow(0), scroll_position.x as u32), + Bias::Left, + ) + .to_point(map); + let anchor = map.buffer_snapshot.anchor_at(buffer_point, Bias::Right); + + ( + ScrollAnchor { + anchor: anchor, + offset: scroll_position.max(&gpui::Point::default()), + }, + 0, + ) } else { let scroll_top = scroll_position.y; let scroll_top = match EditorSettings::get_global(cx).scroll_beyond_last_line { @@ -242,8 +264,13 @@ impl ScrollManager { } }; - let scroll_top_buffer_point = - DisplayPoint::new(DisplayRow(scroll_top as u32), 0).to_point(map); + let scroll_top_row = DisplayRow(scroll_top as u32); + let scroll_top_buffer_point = map + .clip_point( + DisplayPoint::new(scroll_top_row, scroll_position.x as u32), + Bias::Left, + ) + .to_point(map); let top_anchor = map .buffer_snapshot .anchor_at(scroll_top_buffer_point, Bias::Right); @@ -476,6 +503,10 @@ impl Editor { .map(|line_count| line_count as u32 - 1) } + pub fn visible_column_count(&self) -> Option { + self.scroll_manager.visible_column_count + } + pub(crate) fn set_visible_line_count( &mut self, lines: f32, @@ -497,6 +528,10 @@ impl Editor { } } + pub(crate) fn set_visible_column_count(&mut self, columns: f32) { + self.scroll_manager.visible_column_count = Some(columns); + } + pub fn apply_scroll_delta( &mut self, scroll_delta: gpui::Point, @@ -675,25 +710,48 @@ impl Editor { let Some(visible_line_count) = self.visible_line_count() else { return; }; + let Some(mut visible_column_count) = self.visible_column_count() else { + return; + }; + + // If the user has a preferred line length, and has the editor + // configured to wrap at the preferred line length, or bounded to it, + // use that value over the visible column count. This was mostly done so + // that tests could actually be written for vim's `z l`, `z h`, `z + // shift-l` and `z shift-h` commands, as there wasn't a good way to + // configure the editor to only display a certain number of columns. If + // that ever happens, this could probably be removed. + let settings = AllLanguageSettings::get_global(cx); + if matches!( + settings.defaults.soft_wrap, + SoftWrap::PreferredLineLength | SoftWrap::Bounded + ) { + if (settings.defaults.preferred_line_length as f32) < visible_column_count { + visible_column_count = settings.defaults.preferred_line_length as f32; + } + } // If the scroll position is currently at the left edge of the document // (x == 0.0) and the intent is to scroll right, the gutter's margin // should first be added to the current position, otherwise the cursor // will end at the column position minus the margin, which looks off. - if current_position.x == 0.0 && amount.columns() > 0. { + if current_position.x == 0.0 && amount.columns(visible_column_count) > 0. { if let Some(last_position_map) = &self.last_position_map { current_position.x += self.gutter_dimensions.margin / last_position_map.em_advance; } } - let new_position = - current_position + point(amount.columns(), amount.lines(visible_line_count)); + let new_position = current_position + + point( + amount.columns(visible_column_count), + amount.lines(visible_line_count), + ); self.set_scroll_position(new_position, window, cx); } /// Returns an ordering. The newest selection is: /// Ordering::Equal => on screen - /// Ordering::Less => above the screen - /// Ordering::Greater => below the screen + /// Ordering::Less => above or to the left of the screen + /// Ordering::Greater => below or to the right of the screen pub fn newest_selection_on_screen(&self, cx: &mut App) -> Ordering { let snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let newest_head = self @@ -711,8 +769,12 @@ impl Editor { return Ordering::Less; } - if let Some(visible_lines) = self.visible_line_count() { - if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) { + if let (Some(visible_lines), Some(visible_columns)) = + (self.visible_line_count(), self.visible_column_count()) + { + if newest_head.row() <= DisplayRow(screen_top.row().0 + visible_lines as u32) + && newest_head.column() <= screen_top.column() + visible_columns as u32 + { return Ordering::Equal; } } diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 55998aa2fd..340277633a 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -274,12 +274,14 @@ impl Editor { start_row: DisplayRow, viewport_width: Pixels, scroll_width: Pixels, - max_glyph_width: Pixels, + em_advance: Pixels, layouts: &[LineWithInvisibles], + window: &mut Window, cx: &mut Context, ) -> bool { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = self.selections.all::(cx); + let mut scroll_position = self.scroll_manager.scroll_position(&display_map); let mut target_left; let mut target_right; @@ -295,16 +297,17 @@ impl Editor { if head.row() >= start_row && head.row() < DisplayRow(start_row.0 + layouts.len() as u32) { - let start_column = head.column().saturating_sub(3); - let end_column = cmp::min(display_map.line_len(head.row()), head.column() + 3); + let start_column = head.column(); + let end_column = cmp::min(display_map.line_len(head.row()), head.column()); target_left = target_left.min( layouts[head.row().minus(start_row) as usize] - .x_for_index(start_column as usize), + .x_for_index(start_column as usize) + + self.gutter_dimensions.margin, ); target_right = target_right.max( layouts[head.row().minus(start_row) as usize] .x_for_index(end_column as usize) - + max_glyph_width, + + em_advance, ); } } @@ -319,14 +322,16 @@ impl Editor { return false; } - let scroll_left = self.scroll_manager.anchor.offset.x * max_glyph_width; + let scroll_left = self.scroll_manager.anchor.offset.x * em_advance; let scroll_right = scroll_left + viewport_width; if target_left < scroll_left { - self.scroll_manager.anchor.offset.x = target_left / max_glyph_width; + scroll_position.x = target_left / em_advance; + self.set_scroll_position_internal(scroll_position, true, true, window, cx); true } else if target_right > scroll_right { - self.scroll_manager.anchor.offset.x = (target_right - viewport_width) / max_glyph_width; + scroll_position.x = (target_right - viewport_width) / em_advance; + self.set_scroll_position_internal(scroll_position, true, true, window, cx); true } else { false diff --git a/crates/editor/src/scroll/scroll_amount.rs b/crates/editor/src/scroll/scroll_amount.rs index bc9d4757f1..b2af4f8e4f 100644 --- a/crates/editor/src/scroll/scroll_amount.rs +++ b/crates/editor/src/scroll/scroll_amount.rs @@ -23,6 +23,8 @@ pub enum ScrollAmount { Page(f32), // Scroll N columns (positive is towards the right of the document) Column(f32), + // Scroll N page width (positive is towards the right of the document) + PageWidth(f32), } impl ScrollAmount { @@ -37,14 +39,16 @@ impl ScrollAmount { (visible_line_count * count).trunc() } Self::Column(_count) => 0.0, + Self::PageWidth(_count) => 0.0, } } - pub fn columns(&self) -> f32 { + pub fn columns(&self, visible_column_count: f32) -> f32 { match self { Self::Line(_count) => 0.0, Self::Page(_count) => 0.0, Self::Column(count) => *count, + Self::PageWidth(count) => (visible_column_count * count).trunc(), } } @@ -58,6 +62,7 @@ impl ScrollAmount { // so I'm leaving this at 0.0 for now to try and make it clear that // this should not have an impact on that? ScrollAmount::Column(_) => px(0.0), + ScrollAmount::PageWidth(_) => px(0.0), } } diff --git a/crates/eval/src/explorer.html b/crates/eval/src/explorer.html index fec4597163..04c41090d3 100644 --- a/crates/eval/src/explorer.html +++ b/crates/eval/src/explorer.html @@ -324,20 +324,8 @@

Thread Explorer

- - + + @@ -368,8 +352,7 @@ ← Previous
- Thread 1 of - 1: + Thread 1 of 1: Default Thread