Fix merge conflicts, clippppppy, and some tests

Anthony created

Change summary

Cargo.lock                                       |   2 
assets/keymaps/vim.json                          |   5 
crates/acp_thread/src/acp_thread.rs              | 140 +++++++++++++----
crates/agent2/src/tools/read_file_tool.rs        |  21 +-
crates/agent_ui/src/acp/thread_view.rs           |   2 
crates/editor/src/editor_tests.rs                |   8 
crates/editor/src/selections_collection.rs       |  23 ++
crates/git_ui/src/blame_ui.rs                    | 139 +++++++++--------
crates/language/src/language_settings.rs         |  10 
crates/remote_server/Cargo.toml                  |   2 
crates/remote_server/src/remote_editing_tests.rs |  88 +++++++++++
crates/settings/src/settings_content/language.rs |   2 
crates/settings/src/settings_store.rs            |  16 ++
crates/terminal/src/terminal.rs                  |   9 
crates/terminal_view/src/terminal_panel.rs       |  38 ++++
crates/vim/src/normal/convert.rs                 |  24 ++
crates/workspace/src/tasks.rs                    |  18 +
17 files changed, 397 insertions(+), 150 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14028,6 +14028,7 @@ dependencies = [
  "clap",
  "client",
  "clock",
+ "collections",
  "crash-handler",
  "crashes",
  "dap",
@@ -14056,6 +14057,7 @@ dependencies = [
  "minidumper",
  "node_runtime",
  "paths",
+ "pretty_assertions",
  "project",
  "proto",
  "release_channel",

assets/keymaps/vim.json 🔗

@@ -442,9 +442,8 @@
       ">": "vim::Indent",
       "<": "vim::Outdent",
       "=": "vim::AutoIndent",
-      "g u": "vim::PushLowercase",
-      "g shift-u": "vim::PushUppercase",
-      "g ~": "vim::PushOppositeCase",
+      "`": "vim::ConvertToLowerCase",
+      "alt-`": "vim::ConvertToUpperCase",
       "g q": "vim::PushRewrap",
       "g w": "vim::PushRewrap",
       "insert": "vim::InsertBefore",

crates/acp_thread/src/acp_thread.rs 🔗

@@ -1781,6 +1781,9 @@ impl AcpThread {
         reuse_shared_snapshot: bool,
         cx: &mut Context<Self>,
     ) -> Task<Result<String>> {
+        // Args are 1-based, move to 0-based
+        let line = line.unwrap_or_default().saturating_sub(1);
+        let limit = limit.unwrap_or(u32::MAX);
         let project = self.project.clone();
         let action_log = self.action_log.clone();
         cx.spawn(async move |this, cx| {
@@ -1808,44 +1811,37 @@ impl AcpThread {
                 action_log.update(cx, |action_log, cx| {
                     action_log.buffer_read(buffer.clone(), cx);
                 })?;
-                project.update(cx, |project, cx| {
-                    let position = buffer
-                        .read(cx)
-                        .snapshot()
-                        .anchor_before(Point::new(line.unwrap_or_default(), 0));
-                    project.set_agent_location(
-                        Some(AgentLocation {
-                            buffer: buffer.downgrade(),
-                            position,
-                        }),
-                        cx,
-                    );
-                })?;
 
-                buffer.update(cx, |buffer, _| buffer.snapshot())?
+                let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+                this.update(cx, |this, _| {
+                    this.shared_buffers.insert(buffer.clone(), snapshot.clone());
+                })?;
+                snapshot
             };
 
-            this.update(cx, |this, _| {
-                let text = snapshot.text();
-                this.shared_buffers.insert(buffer.clone(), snapshot);
-                if line.is_none() && limit.is_none() {
-                    return Ok(text);
-                }
-                let limit = limit.unwrap_or(u32::MAX) as usize;
-                let Some(line) = line else {
-                    return Ok(text.lines().take(limit).collect::<String>());
-                };
+            let max_point = snapshot.max_point();
+            if line >= max_point.row {
+                anyhow::bail!(
+                    "Attempting to read beyond the end of the file, line {}:{}",
+                    max_point.row + 1,
+                    max_point.column
+                );
+            }
 
-                let count = text.lines().count();
-                if count < line as usize {
-                    anyhow::bail!("There are only {} lines", count);
-                }
-                Ok(text
-                    .lines()
-                    .skip(line as usize + 1)
-                    .take(limit)
-                    .collect::<String>())
-            })?
+            let start = snapshot.anchor_before(Point::new(line, 0));
+            let end = snapshot.anchor_before(Point::new(line.saturating_add(limit), 0));
+
+            project.update(cx, |project, cx| {
+                project.set_agent_location(
+                    Some(AgentLocation {
+                        buffer: buffer.downgrade(),
+                        position: start,
+                    }),
+                    cx,
+                );
+            })?;
+
+            Ok(snapshot.text_for_range(start..end).collect::<String>())
         })
     }
 
@@ -2391,6 +2387,82 @@ mod tests {
         request.await.unwrap();
     }
 
+    #[gpui::test]
+    async fn test_reading_from_line(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\nfour\n"}))
+            .await;
+        let project = Project::test(fs.clone(), [], cx).await;
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
+            })
+            .await
+            .unwrap();
+
+        let connection = Rc::new(FakeAgentConnection::new());
+
+        let thread = cx
+            .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+            .await
+            .unwrap();
+
+        // Whole file
+        let content = thread
+            .update(cx, |thread, cx| {
+                thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(content, "one\ntwo\nthree\nfour\n");
+
+        // Only start line
+        let content = thread
+            .update(cx, |thread, cx| {
+                thread.read_text_file(path!("/tmp/foo").into(), Some(3), None, false, cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(content, "three\nfour\n");
+
+        // Only limit
+        let content = thread
+            .update(cx, |thread, cx| {
+                thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(content, "one\ntwo\n");
+
+        // Range
+        let content = thread
+            .update(cx, |thread, cx| {
+                thread.read_text_file(path!("/tmp/foo").into(), Some(2), Some(2), false, cx)
+            })
+            .await
+            .unwrap();
+
+        assert_eq!(content, "two\nthree\n");
+
+        // Invalid
+        let err = thread
+            .update(cx, |thread, cx| {
+                thread.read_text_file(path!("/tmp/foo").into(), Some(5), Some(2), false, cx)
+            })
+            .await
+            .unwrap_err();
+
+        assert_eq!(
+            err.to_string(),
+            "Attempting to read beyond the end of the file, line 5:0"
+        );
+    }
+
     #[gpui::test]
     async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
         init_test(cx);

crates/agent2/src/tools/read_file_tool.rs 🔗

@@ -201,7 +201,6 @@ impl AgentTool for ReadFileTool {
             // Check if specific line ranges are provided
             let result = if input.start_line.is_some() || input.end_line.is_some() {
                 let result = buffer.read_with(cx, |buffer, _cx| {
-                    let text = buffer.text();
                     // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
                     let start = input.start_line.unwrap_or(1).max(1);
                     let start_row = start - 1;
@@ -210,13 +209,13 @@ impl AgentTool for ReadFileTool {
                         anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
                     }
 
-                    let lines = text.split('\n').skip(start_row as usize);
-                    if let Some(end) = input.end_line {
-                        let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
-                        itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
-                    } else {
-                        itertools::intersperse(lines, "\n").collect::<String>()
+                    let mut end_row = input.end_line.unwrap_or(u32::MAX);
+                    if end_row <= start_row {
+                        end_row = start_row + 1; // read at least one lines
                     }
+                    let start = buffer.anchor_before(Point::new(start_row, 0));
+                    let end = buffer.anchor_before(Point::new(end_row, 0));
+                    buffer.text_for_range(start..end).collect::<String>()
                 })?;
 
                 action_log.update(cx, |log, cx| {
@@ -445,7 +444,7 @@ mod test {
                 tool.run(input, ToolCallEventStream::test().0, cx)
             })
             .await;
-        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into());
+        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
     }
 
     #[gpui::test]
@@ -475,7 +474,7 @@ mod test {
                 tool.clone().run(input, ToolCallEventStream::test().0, cx)
             })
             .await;
-        assert_eq!(result.unwrap(), "Line 1\nLine 2".into());
+        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 
         // end_line of 0 should result in at least 1 line
         let result = cx
@@ -488,7 +487,7 @@ mod test {
                 tool.clone().run(input, ToolCallEventStream::test().0, cx)
             })
             .await;
-        assert_eq!(result.unwrap(), "Line 1".into());
+        assert_eq!(result.unwrap(), "Line 1\n".into());
 
         // when start_line > end_line, should still return at least 1 line
         let result = cx
@@ -501,7 +500,7 @@ mod test {
                 tool.clone().run(input, ToolCallEventStream::test().0, cx)
             })
             .await;
-        assert_eq!(result.unwrap(), "Line 3".into());
+        assert_eq!(result.unwrap(), "Line 3\n".into());
     }
 
     fn init_test(cx: &mut TestAppContext) {

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

@@ -1591,7 +1591,7 @@ impl AcpThreadView {
             task.shell = shell;
 
             let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
-                terminal_panel.spawn_task(login.clone(), window, cx)
+                terminal_panel.spawn_task(&login, window, cx)
             })?;
 
             let terminal = terminal.await?;

crates/editor/src/editor_tests.rs 🔗

@@ -41,7 +41,10 @@ use project::{
     project_settings::LspSettings,
 };
 use serde_json::{self, json};
-use settings::{AllLanguageSettingsContent, ProjectSettingsContent};
+use settings::{
+    AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring,
+    ProjectSettingsContent,
+};
 use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
 use std::{
     iter,
@@ -19965,7 +19968,8 @@ fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) -
             enabled: true,
             line_width: 1,
             active_line_width: 1,
-            ..Default::default()
+            coloring: IndentGuideColoring::default(),
+            background_coloring: IndentGuideBackgroundColoring::default(),
         },
     }
 }

crates/editor/src/selections_collection.rs 🔗

@@ -469,13 +469,24 @@ impl<'a> MutableSelectionsCollection<'a> {
     }
 
     pub(crate) fn set_pending_anchor_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
+        let buffer = self.buffer.read(self.cx).snapshot(self.cx);
         self.collection.pending = Some(PendingSelection {
-            selection: Selection {
-                id: post_inc(&mut self.collection.next_selection_id),
-                start: range.start,
-                end: range.end,
-                reversed: false,
-                goal: SelectionGoal::None,
+            selection: {
+                let mut start = range.start;
+                let mut end = range.end;
+                let reversed = if start.cmp(&end, &buffer).is_gt() {
+                    mem::swap(&mut start, &mut end);
+                    true
+                } else {
+                    false
+                };
+                Selection {
+                    id: post_inc(&mut self.collection.next_selection_id),
+                    start,
+                    end,
+                    reversed,
+                    goal: SelectionGoal::None,
+                }
             },
             mode,
         });

crates/git_ui/src/blame_ui.rs 🔗

@@ -47,75 +47,84 @@ impl BlameRenderer for GitBlameRenderer {
         let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
 
         Some(
-            h_flex()
-                .w_full()
-                .justify_between()
-                .font_family(style.font().family)
-                .line_height(style.line_height)
-                .id(("blame", ix))
-                .text_color(cx.theme().status().hint)
-                .pr_2()
-                .gap_2()
+            div()
+                .mr_2()
                 .child(
                     h_flex()
-                        .items_center()
+                        .w_full()
+                        .justify_between()
+                        .font_family(style.font().family)
+                        .line_height(style.line_height)
+                        .id(("blame", ix))
+                        .text_color(cx.theme().status().hint)
                         .gap_2()
-                        .child(div().text_color(sha_color).child(short_commit_id))
-                        .child(name),
-                )
-                .child(relative_timestamp)
-                .hover(|style| style.bg(cx.theme().colors().element_hover))
-                .cursor_pointer()
-                .on_mouse_down(MouseButton::Right, {
-                    let blame_entry = blame_entry.clone();
-                    let details = details.clone();
-                    move |event, window, cx| {
-                        deploy_blame_entry_context_menu(
-                            &blame_entry,
-                            details.as_ref(),
-                            editor.clone(),
-                            event.position,
-                            window,
-                            cx,
-                        );
-                    }
-                })
-                .on_click({
-                    let blame_entry = blame_entry.clone();
-                    let repository = repository.clone();
-                    let workspace = workspace.clone();
-                    move |_, window, cx| {
-                        CommitView::open(
-                            CommitSummary {
-                                sha: blame_entry.sha.to_string().into(),
-                                subject: blame_entry.summary.clone().unwrap_or_default().into(),
-                                commit_timestamp: blame_entry.committer_time.unwrap_or_default(),
-                                author_name: blame_entry
-                                    .committer_name
-                                    .clone()
-                                    .unwrap_or_default()
-                                    .into(),
-                                has_parent: true,
-                            },
-                            repository.downgrade(),
-                            workspace.clone(),
-                            window,
-                            cx,
-                        )
-                    }
-                })
-                .hoverable_tooltip(move |_window, cx| {
-                    cx.new(|cx| {
-                        CommitTooltip::blame_entry(
-                            &blame_entry,
-                            details.clone(),
-                            repository.clone(),
-                            workspace.clone(),
-                            cx,
+                        .child(
+                            h_flex()
+                                .items_center()
+                                .gap_2()
+                                .child(div().text_color(sha_color).child(short_commit_id))
+                                .child(name),
                         )
-                    })
-                    .into()
-                })
+                        .child(relative_timestamp)
+                        .hover(|style| style.bg(cx.theme().colors().element_hover))
+                        .cursor_pointer()
+                        .on_mouse_down(MouseButton::Right, {
+                            let blame_entry = blame_entry.clone();
+                            let details = details.clone();
+                            move |event, window, cx| {
+                                deploy_blame_entry_context_menu(
+                                    &blame_entry,
+                                    details.as_ref(),
+                                    editor.clone(),
+                                    event.position,
+                                    window,
+                                    cx,
+                                );
+                            }
+                        })
+                        .on_click({
+                            let blame_entry = blame_entry.clone();
+                            let repository = repository.clone();
+                            let workspace = workspace.clone();
+                            move |_, window, cx| {
+                                CommitView::open(
+                                    CommitSummary {
+                                        sha: blame_entry.sha.to_string().into(),
+                                        subject: blame_entry
+                                            .summary
+                                            .clone()
+                                            .unwrap_or_default()
+                                            .into(),
+                                        commit_timestamp: blame_entry
+                                            .committer_time
+                                            .unwrap_or_default(),
+                                        author_name: blame_entry
+                                            .committer_name
+                                            .clone()
+                                            .unwrap_or_default()
+                                            .into(),
+                                        has_parent: true,
+                                    },
+                                    repository.downgrade(),
+                                    workspace.clone(),
+                                    window,
+                                    cx,
+                                )
+                            }
+                        })
+                        .hoverable_tooltip(move |_window, cx| {
+                            cx.new(|cx| {
+                                CommitTooltip::blame_entry(
+                                    &blame_entry,
+                                    details.clone(),
+                                    repository.clone(),
+                                    workspace.clone(),
+                                    cx,
+                                )
+                            })
+                            .into()
+                        }),
+                )
                 .into_any(),
         )
     }

crates/language/src/language_settings.rs 🔗

@@ -13,13 +13,13 @@ use schemars::json_schema;
 
 pub use settings::{
     CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
-    Formatter, FormatterList, InlayHintKind, LspInsertMode, RewrapBehavior, SelectedFormatter,
-    ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
+    Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LspInsertMode,
+    RewrapBehavior, SelectedFormatter, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
 };
 use settings::{
-    IndentGuideSettingsContent, LanguageSettingsContent, LanguageTaskSettingsContent,
-    ParameterizedJsonSchema, PrettierSettingsContent, Settings, SettingsContent, SettingsLocation,
-    SettingsStore, SettingsUi,
+    IndentGuideSettingsContent, LanguageTaskSettingsContent, ParameterizedJsonSchema,
+    PrettierSettingsContent, Settings, SettingsContent, SettingsLocation, SettingsStore,
+    SettingsUi,
 };
 use shellexpand;
 use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc};

crates/remote_server/Cargo.toml 🔗

@@ -77,6 +77,7 @@ assistant_tool.workspace = true
 assistant_tools.workspace = true
 client = { workspace = true, features = ["test-support"] }
 clock = { workspace = true, features = ["test-support"] }
+collections.workspace = true
 dap = { workspace = true, features = ["test-support"] }
 editor = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
@@ -85,6 +86,7 @@ gpui = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
 node_runtime = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 remote = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -6,6 +6,7 @@ use assistant_tool::{Tool as _, ToolResultContent};
 use assistant_tools::{ReadFileTool, ReadFileToolInput};
 use client::{Client, UserStore};
 use clock::FakeSystemClock;
+use collections::{HashMap, HashSet};
 use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModel};
 
 use extension::ExtensionHostProxy;
@@ -20,6 +21,7 @@ use lsp::{CompletionContext, CompletionResponse, CompletionTriggerKind, Language
 use node_runtime::NodeRuntime;
 use project::{
     Project, ProjectPath,
+    agent_server_store::AgentServerCommand,
     search::{SearchQuery, SearchResult},
 };
 use remote::RemoteClient;
@@ -27,7 +29,6 @@ use serde_json::json;
 use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content};
 use smol::stream::StreamExt;
 use std::{
-    collections::HashSet,
     path::{Path, PathBuf},
     sync::Arc,
 };
@@ -1770,6 +1771,91 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu
     does_not_exist_result.output.await.unwrap_err();
 }
 
+#[gpui::test]
+async fn test_remote_external_agent_server(
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(path!("/project"), json!({})).await;
+
+    let (project, _headless_project) = init_test(&fs, cx, server_cx).await;
+    project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/project"), true, cx)
+        })
+        .await
+        .unwrap();
+    let names = project.update(cx, |project, cx| {
+        project
+            .agent_server_store()
+            .read(cx)
+            .external_agents()
+            .map(|name| name.to_string())
+            .collect::<Vec<_>>()
+    });
+    pretty_assertions::assert_eq!(names, ["gemini", "claude"]);
+    server_cx.update_global::<SettingsStore, _>(|settings_store, cx| {
+        settings_store
+            .set_raw_server_settings(
+                Some(json!({
+                    "agent_servers": {
+                        "foo": {
+                            "command": "foo-cli",
+                            "args": ["--flag"],
+                            "env": {
+                                "VAR": "val"
+                            }
+                        }
+                    }
+                })),
+                cx,
+            )
+            .unwrap();
+    });
+    server_cx.run_until_parked();
+    cx.run_until_parked();
+    let names = project.update(cx, |project, cx| {
+        project
+            .agent_server_store()
+            .read(cx)
+            .external_agents()
+            .map(|name| name.to_string())
+            .collect::<Vec<_>>()
+    });
+    pretty_assertions::assert_eq!(names, ["gemini", "foo", "claude"]);
+    let (command, root, login) = project
+        .update(cx, |project, cx| {
+            project.agent_server_store().update(cx, |store, cx| {
+                store
+                    .get_external_agent(&"foo".into())
+                    .unwrap()
+                    .get_command(
+                        None,
+                        HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]),
+                        None,
+                        None,
+                        &mut cx.to_async(),
+                    )
+            })
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        command,
+        AgentServerCommand {
+            path: "ssh".into(),
+            args: vec!["foo-cli".into(), "--flag".into()],
+            env: Some(HashMap::from_iter([
+                ("VAR".into(), "val".into()),
+                ("OTHER_VAR".into(), "other-val".into())
+            ]))
+        }
+    );
+    assert_eq!(&PathBuf::from(root), paths::home_dir());
+    assert!(login.is_none());
+}
+
 pub async fn init_test(
     server_fs: &Arc<FakeFs>,
     cx: &mut TestAppContext,

crates/settings/src/settings_content/language.rs 🔗

@@ -432,7 +432,7 @@ impl InlayHintKind {
     }
 }
 
-/// Controls how completions are processedfor this anguage.
+/// Controls how completions are processed for this language.
 #[skip_serializing_none]
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
 #[serde(rename_all = "snake_case")]

crates/settings/src/settings_store.rs 🔗

@@ -362,6 +362,22 @@ impl SettingsStore {
         Ok(())
     }
 
+    /// Replaces current settings with the values from the given JSON.
+    pub fn set_raw_server_settings(
+        &mut self,
+        new_settings: Option<Value>,
+        cx: &mut App,
+    ) -> Result<()> {
+        // Rewrite the server settings into a content type
+        self.server_settings = new_settings
+            .map(|settings| settings.to_string())
+            .and_then(|str| parse_json_with_comments::<SettingsContent>(&str).ok())
+            .map(Box::new);
+
+        self.recompute_values(None, cx)?;
+        Ok(())
+    }
+
     /// Get the configured settings profile names.
     pub fn configured_settings_profiles(&self) -> impl Iterator<Item = &str> {
         self.user_settings

crates/terminal/src/terminal.rs 🔗

@@ -364,6 +364,7 @@ impl TerminalBuilder {
         env.insert("ZED_TERM".to_string(), "true".to_string());
         env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
         env.insert("TERM".to_string(), "xterm-256color".to_string());
+        env.insert("COLORTERM".to_string(), "truecolor".to_string());
         env.insert(
             "TERM_PROGRAM_VERSION".to_string(),
             release_channel::AppVersion::global(cx).to_string(),
@@ -532,14 +533,10 @@ impl TerminalBuilder {
             child_exited: None,
         };
 
-        if !activation_script.is_empty() && no_task {
+        if cfg!(not(target_os = "windows")) && !activation_script.is_empty() && no_task {
             for activation_script in activation_script {
                 terminal.input(activation_script.into_bytes());
-                terminal.write_to_pty(if cfg!(windows) {
-                    &b"\r\n"[..]
-                } else {
-                    &b"\n"[..]
-                });
+                terminal.write_to_pty(b"\n");
             }
             terminal.clear();
         }

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -19,7 +19,7 @@ use itertools::Itertools;
 use project::{Fs, Project, ProjectEntryId};
 use search::{BufferSearchBar, buffer_search::DivRegistrar};
 use settings::{Settings, TerminalDockPosition};
-use task::{RevealStrategy, RevealTarget, SpawnInTerminal, TaskId};
+use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId};
 use terminal::{Terminal, terminal_settings::TerminalSettings};
 use ui::{
     ButtonCommon, Clickable, ContextMenu, FluentBuilder, PopoverMenu, Toggleable, Tooltip,
@@ -518,10 +518,42 @@ impl TerminalPanel {
 
     pub fn spawn_task(
         &mut self,
-        task: SpawnInTerminal,
+        task: &SpawnInTerminal,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<WeakEntity<Terminal>>> {
+        let remote_client = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let project = workspace.project().read(cx);
+                if project.is_via_collab() {
+                    Err(anyhow!("cannot spawn tasks as a guest"))
+                } else {
+                    Ok(project.remote_client())
+                }
+            })
+            .flatten();
+
+        let remote_client = match remote_client {
+            Ok(remote_client) => remote_client,
+            Err(e) => return Task::ready(Err(e)),
+        };
+
+        let remote_shell = remote_client
+            .as_ref()
+            .and_then(|remote_client| remote_client.read(cx).shell());
+
+        let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell);
+        let command_label = builder.command_label(&task.command_label);
+        let (command, args) = builder.build(task.command.clone(), &task.args);
+
+        let task = SpawnInTerminal {
+            command_label,
+            command: Some(command),
+            args,
+            ..task.clone()
+        };
+
         if task.allow_concurrent_runs && task.use_new_terminal {
             return self.spawn_in_new_terminal(task, window, cx);
         }
@@ -1551,7 +1583,7 @@ impl workspace::TerminalProvider for TerminalProvider {
         window.spawn(cx, async move |cx| {
             let terminal = terminal_panel
                 .update_in(cx, |terminal_panel, window, cx| {
-                    terminal_panel.spawn_task(task, window, cx)
+                    terminal_panel.spawn_task(&task, window, cx)
                 })
                 .ok()?
                 .await;

crates/vim/src/normal/convert.rs 🔗

@@ -214,11 +214,10 @@ impl Vim {
 
                     Mode::HelixNormal | Mode::HelixSelect => {
                         if selection.is_empty() {
-                            // Handle empty selection by operating on the whole word
-                            let (word_range, _) = snapshot.surrounding_word(selection.start, false);
-                            let word_start = snapshot.offset_to_point(word_range.start);
-                            let word_end = snapshot.offset_to_point(word_range.end);
-                            ranges.push(word_start..word_end);
+                            // Handle empty selection by operating on single character
+                            let start = selection.start;
+                            let end = snapshot.clip_point(start + Point::new(0, 1), Bias::Right);
+                            ranges.push(start..end);
                             cursor_positions.push(selection.start..selection.start);
                         } else {
                             ranges.push(selection.start..selection.end);
@@ -445,15 +444,26 @@ mod test {
         cx.simulate_keystrokes("~");
         cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal);
 
-        // Cursor-only (empty) selection
+        // Cursor-only (empty) selection - switch case
         cx.set_state("The ˇquick brown", Mode::HelixNormal);
         cx.simulate_keystrokes("~");
-        cx.assert_state("The ˇQUICK brown", Mode::HelixNormal);
+        cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("~");
+        cx.assert_state("The ˇquick brown", Mode::HelixNormal);
+
+        // Cursor-only (empty) selection - switch to uppercase and lowercase explicitly
+        cx.set_state("The ˇquick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("alt-`");
+        cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("`");
+        cx.assert_state("The ˇquick brown", Mode::HelixNormal);
 
         // With `e` motion (which extends selection to end of word in Helix)
         cx.set_state("The ˇquick brown fox", Mode::HelixNormal);
         cx.simulate_keystrokes("e");
         cx.simulate_keystrokes("~");
         cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal);
+
+        // Cursor-only
     }
 }

crates/workspace/src/tasks.rs 🔗

@@ -8,7 +8,7 @@ use remote::ConnectionState;
 use task::{DebugScenario, ResolvedTask, SpawnInTerminal, TaskContext, TaskTemplate};
 use ui::Window;
 
-use crate::Workspace;
+use crate::{Toast, Workspace, notifications::NotificationId};
 
 impl Workspace {
     pub fn schedule_task(
@@ -73,8 +73,10 @@ impl Workspace {
 
         if let Some(terminal_provider) = self.terminal_provider.as_ref() {
             let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx);
-            let task = cx.background_spawn(async move {
-                match task_status.await {
+
+            let task = cx.spawn(async |w, cx| {
+                let res = cx.background_spawn(task_status).await;
+                match res {
                     Some(Ok(status)) => {
                         if status.success() {
                             log::debug!("Task spawn succeeded");
@@ -82,9 +84,15 @@ impl Workspace {
                             log::debug!("Task spawn failed, code: {:?}", status.code());
                         }
                     }
-                    Some(Err(e)) => log::error!("Task spawn failed: {e:#}"),
+                    Some(Err(e)) => {
+                        log::error!("Task spawn failed: {e:#}");
+                        _ = w.update(cx, |w, cx| {
+                            let id = NotificationId::unique::<ResolvedTask>();
+                            w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx);
+                        })
+                    }
                     None => log::debug!("Task spawn got cancelled"),
-                }
+                };
             });
             self.scheduled_tasks.push(task);
         }