diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index de80aa28d6..0c868d13d4 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -187,6 +187,10 @@ that agent in Buzz; they do not notify the external app through this connection.
⌘1–9 switch communities, and ⇧Esc marks everything read. If a plugin uses
the same key as another shortcut, Settings → Shortcuts marks it "Also used
by"; change one of them to choose which fires.
+- **Send key**: choose **Settings → Appearance → Conversations → Send messages
+ with**. Enter sends by default. Choose Cmd+Enter (Ctrl+Enter on Windows/Linux)
+ to make Enter add a new line instead. Shift+Enter always adds a new line.
+ This choice is saved on this device and applies to messages and thread replies.
- **Spelling**: in the Mac app or Safari, the message box underlines
misspellings but does not correct them unless you turn on **Settings →
Appearance → Allow automatic spelling correction**. Correct Spelling
diff --git a/src/app/AppearanceSettings.test.tsx b/src/app/AppearanceSettings.test.tsx
index 01cc368dbc..e7c0703101 100644
--- a/src/app/AppearanceSettings.test.tsx
+++ b/src/app/AppearanceSettings.test.tsx
@@ -1,3 +1,7 @@
+import {
+ messageSendKeyPreference,
+ setMessageSendKeyPreference,
+} from "../features/messages/send-key-preference";
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import {
@@ -43,6 +47,7 @@ afterEach(() => {
setThreadPlacementPreference("side");
setFollowUpBehaviorPreference("immediate");
setMessageAutocorrectPreference(false);
+ setMessageSendKeyPreference("enter");
localStorage.clear();
vi.unstubAllGlobals();
});
@@ -521,3 +526,61 @@ it.each([
).toBe(shown);
},
);
+
+it.each([
+ ["MacIntel", "Cmd+Enter"],
+ ["Win32", "Ctrl+Enter"],
+])(
+ "saves the %s send-key choice and observes other windows",
+ async (platform, label) => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
+ const user = userEvent.setup();
+ render(preferencesPage());
+ expect(
+ screen.getByRole("combobox", { name: "Send messages with" }),
+ ).toHaveTextContent("Enter");
+ await user.click(
+ screen.getByRole("combobox", { name: "Send messages with" }),
+ );
+ // Base UI mounts/exposes the popup asynchronously after opening it.
+ await user.click(await screen.findByRole("option", { name: label }));
+ expect(messageSendKeyPreference()).toBe("mod-enter");
+ expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter");
+ act(() => {
+ localStorage.setItem("buzz-message-send-key.v1", "enter");
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: "buzz-message-send-key.v1" }),
+ );
+ });
+ expect(
+ screen.getByRole("combobox", { name: "Send messages with" }),
+ ).toHaveTextContent("Enter");
+ expect(messageSendKeyPreference()).toBe("enter");
+ },
+);
+
+it("keeps an unsaved send-key choice across remounts and retries saving", async () => {
+ const write = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("denied");
+ });
+ setMessageSendKeyPreference("mod-enter");
+ const view = render(preferencesPage(), { wrapper: ToastProvider });
+ expect(messageSendKeyPreference()).toBe("mod-enter");
+ expect(
+ await screen.findByRole("button", { name: "Retry saving send key" }),
+ ).toBeInTheDocument();
+ view.unmount();
+ render(preferencesPage(), { wrapper: ToastProvider });
+ write.mockRestore();
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Retry saving send key" }),
+ );
+ expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter");
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("button", { name: "Retry saving send key" }),
+ ).not.toBeInTheDocument(),
+ );
+});
diff --git a/src/app/AppearanceSettings.tsx b/src/app/AppearanceSettings.tsx
index 715883b057..82c802c1b8 100644
--- a/src/app/AppearanceSettings.tsx
+++ b/src/app/AppearanceSettings.tsx
@@ -1,3 +1,8 @@
+import {
+ setMessageSendKeyPreference,
+ useMessageSendKeyPreference,
+} from "../features/messages/send-key-preference";
+import { isApplePlatform } from "../features/shortcuts/format";
import { Select } from "../shared/design-system/ui/Select";
import { ACCENTS, accentButtonStyle } from "../shared/theme/accent";
@@ -46,6 +51,10 @@ export function AppearanceSettings({
/** Only Mac WebKit applies the composer's autocorrect attribute. */
autocorrectAvailable?: boolean;
}) {
+ const sendKey = useMessageSendKeyPreference();
+ const sendChord = isApplePlatform(navigator.platform)
+ ? "Cmd+Enter"
+ : "Ctrl+Enter";
const followUp = useFollowUpBehaviorPreference();
const autocorrect = useMessageAutocorrectPreference();
const { placement, unsaved } = useThreadPlacementPreference();
@@ -238,6 +247,30 @@ export function AppearanceSettings({
+ {
+ if (value === "enter" || value === "mod-enter")
+ setMessageSendKeyPreference(value);
+ }}
+ />
+ }
+ />
)}
+ {active && sendKey.unsaved && (
+
+
+
+ )}
{active && followUp.unsaved && (
{
await kit.conversation(delivery({ type: "mention", event }), "thread"),
).toBeUndefined();
const workflow = message("3", "@Ampy go", {
+ // Workflow-shaped messages must not inherit the fixture's owner signer.
+ pubkey: mallory,
tags: [
["h", channel],
["buzz:workflow", "w"],
diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx
index d73478f6db..25992a3eb0 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -1,3 +1,4 @@
+import { setMessageSendKeyPreference } from "./send-key-preference";
// @vitest-environment jsdom
import { Context } from "@deepseek-ai/cordis";
import { PluginRuntime } from "../../plugins/runtime";
@@ -172,6 +173,7 @@ beforeEach(() => {
});
afterEach(() => {
cleanup();
+ setMessageSendKeyPreference("enter");
for (const owner of owners.splice(0)) owner.dispose();
Reflect.deleteProperty(navigator, "locks");
vi.unstubAllGlobals();
@@ -4733,6 +4735,84 @@ it("sends channel messages and thread replies through real form and keyboard eve
expect(h.input()).toHaveValue("");
});
+it.each(["MacIntel", "Win32", "Linux x86_64"])(
+ "uses the chosen send key on %s without sending line breaks or IME confirmation",
+ async (platform) => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
+ onTestFinished(() => {
+ vi.restoreAllMocks();
+ });
+ const h = mount();
+ await h.user.type(h.input(), "first");
+ act(() => setMessageSendKeyPreference("mod-enter"));
+ await h.user.keyboard("{Enter}second{Shift>}{Enter}{/Shift}third");
+ expect(h.input()).toHaveValue("first\nsecond\nthird");
+ const mod = platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true };
+ fireEvent.keyDown(h.input(), { key: "Enter", ...mod, repeat: true });
+ fireEvent.keyDown(h.input(), { key: "Enter", ...mod, isComposing: true });
+ fireEvent.keyDown(h.input(), { key: "Enter", ...mod, keyCode: 229 });
+ expect(h.messages.send).not.toHaveBeenCalled();
+ fireEvent.keyDown(h.input(), { key: "Enter", ...mod });
+ await waitFor(() =>
+ expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
+ "channel",
+ "first\nsecond\nthird",
+ [],
+ [],
+ ),
+ );
+ h.retarget({ threadRootId: "root" });
+ await h.user.type(h.input(), "reply{Enter}next");
+ expect(h.messages.reply).not.toHaveBeenCalled();
+ fireEvent.keyDown(h.input(), { key: "Enter", ...mod });
+ await waitFor(() =>
+ expect(h.messages.reply).toHaveBeenCalledExactlyOnceWith(
+ "channel",
+ "root",
+ "reply\nnext",
+ [],
+ [],
+ ),
+ );
+ },
+);
+
+it("continues lists with Enter but sends them with the chosen modified Enter", async () => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
+ onTestFinished(() => {
+ vi.restoreAllMocks();
+ });
+ const h = mount();
+ act(() => setMessageSendKeyPreference("mod-enter"));
+ await h.user.type(h.input(), "- first{Enter}second");
+ expect(h.input().querySelectorAll("ul > li")).toHaveLength(2);
+ expect(h.messages.send).not.toHaveBeenCalled();
+ fireEvent.keyDown(h.input(), { key: "Enter", metaKey: true });
+ await waitFor(() =>
+ expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
+ "channel",
+ "- first\n- second",
+ [],
+ [],
+ ),
+ );
+});
+
+it("switches a mounted composer back to Enter-to-send", async () => {
+ const h = mount();
+ act(() => setMessageSendKeyPreference("mod-enter"));
+ await h.user.type(h.input(), "draft{Enter}next");
+ expect(h.messages.send).not.toHaveBeenCalled();
+ act(() => setMessageSendKeyPreference("enter"));
+ await h.user.keyboard("{Enter}");
+ expect(h.messages.send).toHaveBeenCalledExactlyOnceWith(
+ "channel",
+ "draft\nnext",
+ [],
+ [],
+ );
+});
+
it.each([
["- ", "ul", "- first\n- second"],
["3. ", "ol", "3. first\n4. second"],
diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx
index 783e2cfe66..6e2f6b8ad1 100644
--- a/src/features/messages/MessageComposer.tsx
+++ b/src/features/messages/MessageComposer.tsx
@@ -1,3 +1,5 @@
+import { useMessageSendKeyPreference } from "./send-key-preference";
+import { isApplePlatform } from "../shortcuts/format";
import { useChatLinkSharing } from "./useChatLinkSharing";
import {
draftGif,
@@ -491,6 +493,8 @@ function Composer({
trailingTool,
}: MessageComposerProps) {
const active = useConversationPresentation();
+ const sendKey = useMessageSendKeyPreference();
+ const apple = isApplePlatform(navigator.platform);
// Previews replace the list on every message; subscribe to this channel's facts.
const listed =
session.channels?.get || soloConversation ? session.channels : undefined;
@@ -3190,15 +3194,19 @@ function Composer({
event.key === "Enter" &&
!event.shiftKey &&
!event.altKey &&
- !event.ctrlKey &&
- !event.metaKey
+ (sendKey.behavior === "enter"
+ ? !event.ctrlKey && !event.metaKey
+ : apple
+ ? event.metaKey && !event.ctrlKey
+ : event.ctrlKey && !event.metaKey)
) {
event.preventDefault();
// Holding Enter must not send after an empty list item exits.
if (event.repeat) return;
if (
- activeFormats.includes("bullet_list") ||
- activeFormats.includes("ordered_list")
+ sendKey.behavior === "enter" &&
+ (activeFormats.includes("bullet_list") ||
+ activeFormats.includes("ordered_list"))
) {
event.currentTarget.insertLineBreak();
return;
diff --git a/src/features/messages/send-key-preference.ts b/src/features/messages/send-key-preference.ts
new file mode 100644
index 0000000000..6e63235d18
--- /dev/null
+++ b/src/features/messages/send-key-preference.ts
@@ -0,0 +1,50 @@
+import { useSyncExternalStore } from "react";
+
+export type MessageSendKey = "enter" | "mod-enter";
+const preferenceKey = "buzz-message-send-key.v1";
+const changedEvent = "buzz-message-send-key-changed";
+let unsavedPreference: MessageSendKey | undefined;
+
+/** Device-local send key; Enter-to-send remains the default. */
+export function messageSendKeyPreference(): MessageSendKey {
+ if (unsavedPreference !== undefined) return unsavedPreference;
+ try {
+ return localStorage.getItem(preferenceKey) === "mod-enter"
+ ? "mod-enter"
+ : "enter";
+ } catch {
+ return "enter";
+ }
+}
+function subscribe(listener: () => void) {
+ const storage = (event: StorageEvent) => {
+ if (event.key !== preferenceKey && event.key !== null) return;
+ unsavedPreference = undefined;
+ listener();
+ };
+ window.addEventListener(changedEvent, listener);
+ window.addEventListener("storage", storage);
+ return () => {
+ window.removeEventListener(changedEvent, listener);
+ window.removeEventListener("storage", storage);
+ };
+}
+export function useMessageSendKeyPreference() {
+ const behavior = useSyncExternalStore(subscribe, messageSendKeyPreference);
+ // Saving the same active choice changes recovery state, not its behavior.
+ const unsaved = useSyncExternalStore(
+ subscribe,
+ () => unsavedPreference !== undefined,
+ );
+ return { behavior, unsaved };
+}
+export function setMessageSendKeyPreference(value: MessageSendKey): void {
+ unsavedPreference = value;
+ try {
+ localStorage.setItem(preferenceKey, value);
+ unsavedPreference = undefined;
+ } catch {
+ // Retain the active choice and its recovery state across settings remounts.
+ }
+ window.dispatchEvent(new Event(changedEvent));
+}
diff --git a/tests/browser/composer-send-key.spec.mjs b/tests/browser/composer-send-key.spec.mjs
new file mode 100644
index 0000000000..e1dedb8eaa
--- /dev/null
+++ b/tests/browser/composer-send-key.spec.mjs
@@ -0,0 +1,44 @@
+import { test, expect } from "./source-fixture.mjs";
+
+// Native editor line splitting and modified Enter delivery need a real engine;
+// platform modifier and IME/repeat matrices stay in the mounted composer tests.
+test("the saved send key inserts lines, survives reload, and sends with modified Enter", async ({
+ page,
+}) => {
+ await page.goto("/tests/fixtures/link-messages.html");
+ // Seed once, not with addInitScript: reload must read the saved preference.
+ await page.evaluate(() => {
+ localStorage.setItem("buzz-message-send-key.v1", "mod-enter");
+ });
+ await page.reload();
+ const input = page.getByRole("textbox", {
+ name: "Message #design",
+ exact: true,
+ });
+ await input.fill("first");
+ await input.press("End");
+ await input.press("Enter");
+ await page.keyboard.type("second");
+ await input.press("Shift+Enter");
+ await page.keyboard.type("third");
+ await expect
+ .poll(() => input.evaluate((element) => element.value))
+ .toBe("first\nsecond\nthird");
+ expect(
+ await page.evaluate(() => window.linkComposerFixture.sent),
+ ).toHaveLength(0);
+ await page.reload();
+ await expect
+ .poll(() => input.evaluate((element) => element.value))
+ .toBe("first\nsecond\nthird");
+ const apple = await page.evaluate(() =>
+ /Mac|iPhone|iPad/.test(navigator.platform),
+ );
+ await input.press(apple ? "Meta+Enter" : "Control+Enter");
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.linkComposerFixture.sent.at(-1)?.text),
+ )
+ .toBe("first\nsecond\nthird");
+ await expect.poll(() => input.evaluate((element) => element.value)).toBe("");
+});
diff --git a/tests/browser/send-key-settings.spec.mjs b/tests/browser/send-key-settings.spec.mjs
new file mode 100644
index 0000000000..2b9a1b1bec
--- /dev/null
+++ b/tests/browser/send-key-settings.spec.mjs
@@ -0,0 +1,65 @@
+import { test, expect } from "./fixture.mjs";
+import {
+ openSettings,
+ selectSettingsSection,
+ closeSettings,
+ chooseColorMode,
+} from "./navigation.mjs";
+
+test.use({ productionBroker: true, historyCounts: { alpha: 1, beta: 0 } });
+
+// The host Settings preference must update a retained composer, with keyboard
+// input and choice controls usable in both themes and a narrow enlarged layout.
+test("the send-key setting changes a retained composer", async ({
+ page,
+ app,
+}) => {
+ await page.goto(app.origin);
+ await page.getByRole("button", { name: "Alpha", exact: true }).click();
+ const input = page.getByRole("textbox", {
+ name: "Message #Alpha",
+ exact: true,
+ });
+ await expect(input).toBeVisible();
+ await openSettings(page);
+ await selectSettingsSection(page, "Appearance");
+ const choice = page.getByRole("combobox", {
+ name: "Send messages with",
+ exact: true,
+ });
+ const apple = await page.evaluate(() =>
+ /Mac|iPhone|iPad/.test(navigator.platform),
+ );
+ for (const mode of ["Dark", "Light"]) {
+ await chooseColorMode(page, mode);
+ await choice.scrollIntoViewIfNeeded();
+ await expect(choice).toBeVisible();
+ }
+ await page.setViewportSize({ width: 480, height: 800 });
+ for (let step = 0; step < 10; step++)
+ await page
+ .getByRole("button", { name: "Increase interface size", exact: true })
+ .click();
+ await choice.scrollIntoViewIfNeeded();
+ await choice.click();
+ await page
+ .getByRole("option", {
+ name: apple ? "Cmd+Enter" : "Ctrl+Enter",
+ exact: true,
+ })
+ .click();
+ await expect(choice).toContainText(apple ? "Cmd+Enter" : "Ctrl+Enter");
+ expect(
+ await page.evaluate(() => localStorage.getItem("buzz-message-send-key.v1")),
+ ).toBe("mod-enter");
+ await closeSettings(page);
+ await input.fill("first");
+ await input.press("End");
+ await input.press("Enter");
+ await page.keyboard.type("second");
+ await expect
+ .poll(() => input.evaluate((node) => node.value))
+ .toBe("first\nsecond");
+ await input.press(apple ? "Meta+Enter" : "Control+Enter");
+ await expect.poll(() => input.evaluate((node) => node.value)).toBe("");
+});