From 5e4fa861bb52e7667e9c4e15eac38fd63b8a62c7 Mon Sep 17 00:00:00 2001 From: Ernstjan Freriks Date: Thu, 12 Nov 2026 13:55:06 +0100 Subject: [PATCH] fix(agents): move the caret with Home and End in the agent composer on macOS (#2943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit maps Home / End in a text box to scrolling and leaves the caret where it is, so they did nothing in the agent composer on macOS. The composer now moves the caret to the start or end of the visual line (Shift extends the selection); with ⌘, Ctrl or Alt the keys keep their native behaviour, and other platforms are unchanged. Co-authored-by: EjaYF <9842286+EjaYF@users.noreply.github.com> --- src/features/agents/Composer.tsx | 7 ++++ src/features/agents/composerCommands.test.tsx | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index fbae7ff59..84e7682ec 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -62,6 +62,7 @@ import { useCompletionAnchor } from "./useCompletionAnchor"; import { useAgentSettings } from "./agentSettings"; import styles from "./Composer.module.css"; import { isImeKey } from "@/platform/ime"; +import { isMacHost } from "@/platform/keymap"; const MAX_PROMPT_HEIGHT = 280; const MAX_ITEMS = 12; @@ -454,6 +455,12 @@ export function Composer(props: ComposerProps) { recalled.current = { index: -1, stash: "" }; return; } + // macOS text boxes scroll on Home / End and leave the caret where it is. + if ((e.key === "Home" || e.key === "End") && !e.altKey && !e.ctrlKey && !e.metaKey && isMacHost()) { + e.preventDefault(); + getSelection()?.modify(e.shiftKey ? "extend" : "move", e.key === "Home" ? "backward" : "forward", "lineboundary"); + return; + } if (e.key === "Tab" && e.shiftKey) { const mode = modeOption(props.config); const value = mode && nextValue(mode); diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 0aaf28e80..94726b8c2 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sendKeyAction } from "./Composer"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -43,6 +43,7 @@ vi.mock("@/platform/tauriCore", async (importOriginal) => ({ import { isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { usePlatform } from "@/platform/platformInfo"; import type { ProjectInfo } from "@/bindings"; import { defaultTaskProject } from "./project"; import { HomeEditor } from "./HomeEditor"; @@ -243,6 +244,35 @@ describe("New Task composer commands", () => { }); }); +describe("New Task composer Home / End", () => { + const os = usePlatform.getState().os; + afterEach(() => { + usePlatform.setState({ os }); + vi.restoreAllMocks(); + }); + + it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => { + usePlatform.setState({ os: "macos" }); + const modify = vi.fn(); + vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection); + render(); + const box = screen.getByRole("textbox"); + expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false); + fireEvent.keyDown(box, { key: "End", shiftKey: true }); + fireEvent.keyDown(box, { key: "End", metaKey: true }); + expect(modify.mock.calls).toEqual([ + ["move", "backward", "lineboundary"], + ["extend", "forward", "lineboundary"], + ]); + }); + + it("leaves Home / End to the text box elsewhere", () => { + usePlatform.setState({ os: "windows" }); + render(); + expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true); + }); +}); + describe("New Task composer paste", () => { it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => { render();