diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e69286..a55b1cbf71 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter", confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c..093c5c2b29 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -40,6 +40,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { @@ -1678,7 +1679,7 @@ export const ChatComposer = memo( return true; } } - if (key === "Enter" && !event.shiftKey) { + if (shouldSubmitComposerOnEnter(settings.composerSubmitKeybinding, event)) { submitComposer(); return true; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da480..c66c04bdce 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { + type ComposerSubmitKeybinding, defaultInstanceIdForDriver, type DesktopUpdateChannel, PROVIDER_DISPLAY_NAMES, @@ -99,6 +100,26 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const COMPOSER_SUBMIT_KEYBINDING_OPTIONS = [ + { value: "enter", label: "Enter (Default)" }, + { value: "shiftEnter", label: "Enter + shift" }, + { value: "metaEnter", label: "Enter + Command/Meta" }, + { value: "altEnter", label: "Enter + Option/Alt" }, + { value: "ctrlEnter", label: "Enter + Ctrl" }, + { value: "buttonOnly", label: "Button only" }, +] as const satisfies ReadonlyArray<{ + readonly value: ComposerSubmitKeybinding; + readonly label: string; +}>; + +const COMPOSER_SUBMIT_KEYBINDING_LABELS = Object.fromEntries( + COMPOSER_SUBMIT_KEYBINDING_OPTIONS.map((option) => [option.value, option.label]), +) as Record; + +function isComposerSubmitKeybinding(value: string | null): value is ComposerSubmitKeybinding { + return COMPOSER_SUBMIT_KEYBINDING_OPTIONS.some((option) => option.value === value); +} + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -405,6 +426,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.composerSubmitKeybinding !== DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding + ? ["Composer submit"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -432,6 +456,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.composerSubmitKeybinding, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, @@ -460,6 +485,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + composerSubmitKeybinding: DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -695,6 +721,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + composerSubmitKeybinding: DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding, + }) + } + /> + ) : null + } + control={ + + } + /> + = {}, +) => ({ + key: modifiers.key ?? "Enter", + shiftKey: modifiers.shiftKey ?? false, + metaKey: modifiers.metaKey ?? false, + altKey: modifiers.altKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, +}); + +describe("shouldSubmitComposerOnEnter", () => { + it("matches exact configured Enter shortcuts", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent())).toBe(true); + expect(shouldSubmitComposerOnEnter("shiftEnter", enterEvent({ shiftKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("metaEnter", enterEvent({ metaKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("altEnter", enterEvent({ altKey: true }))).toBe(true); + expect(shouldSubmitComposerOnEnter("ctrlEnter", enterEvent({ ctrlKey: true }))).toBe(true); + }); + + it("does not submit on non-matching modifiers", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent({ shiftKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("shiftEnter", enterEvent())).toBe(false); + expect( + shouldSubmitComposerOnEnter("metaEnter", enterEvent({ metaKey: true, shiftKey: true })), + ).toBe(false); + expect( + shouldSubmitComposerOnEnter("altEnter", enterEvent({ altKey: true, ctrlKey: true })), + ).toBe(false); + }); + + it("lets every Enter shortcut insert text when button-only is selected", () => { + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent())).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ shiftKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ metaKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ altKey: true }))).toBe(false); + expect(shouldSubmitComposerOnEnter("buttonOnly", enterEvent({ ctrlKey: true }))).toBe(false); + }); + + it("ignores non-Enter keys", () => { + expect(shouldSubmitComposerOnEnter("enter", enterEvent({ key: "Tab" }))).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index fb63d2581c..a687b3edf7 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,5 +1,6 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +import type { ComposerSubmitKeybinding } from "@t3tools/contracts/settings"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; @@ -11,6 +12,43 @@ export interface ComposerTrigger { rangeEnd: number; } +export interface ComposerEnterKeyEventLike { + readonly key: string; + readonly shiftKey: boolean; + readonly metaKey: boolean; + readonly altKey: boolean; + readonly ctrlKey: boolean; +} + +export function shouldSubmitComposerOnEnter( + keybinding: ComposerSubmitKeybinding, + event: ComposerEnterKeyEventLike, +): boolean { + if (event.key !== "Enter" || keybinding === "buttonOnly") { + return false; + } + + const modifiers = { + shift: event.shiftKey, + meta: event.metaKey, + alt: event.altKey, + ctrl: event.ctrlKey, + }; + + switch (keybinding) { + case "enter": + return !modifiers.shift && !modifiers.meta && !modifiers.alt && !modifiers.ctrl; + case "shiftEnter": + return modifiers.shift && !modifiers.meta && !modifiers.alt && !modifiers.ctrl; + case "metaEnter": + return modifiers.meta && !modifiers.shift && !modifiers.alt && !modifiers.ctrl; + case "altEnter": + return modifiers.alt && !modifiers.shift && !modifiers.meta && !modifiers.ctrl; + case "ctrlEnter": + return modifiers.ctrl && !modifiers.shift && !modifiers.meta && !modifiers.alt; + } +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8bfb0e599a..5b8cca394b 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -600,6 +600,7 @@ describe("wsApi", () => { it("reads and writes persistence through the desktop bridge when available", async () => { const clientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], @@ -663,6 +664,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); const clientSettings = { autoOpenPlanSidebar: false, + composerSubmitKeybinding: "shiftEnter" as const, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 39695fe3b0..b94aeb7dfc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,11 +2,40 @@ import { describe, expect, it } from "vitest"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); + +describe("ClientSettings.composerSubmitKeybinding", () => { + it("defaults to Enter send", () => { + expect(DEFAULT_CLIENT_SETTINGS.composerSubmitKeybinding).toBe("enter"); + expect(decodeClientSettings({}).composerSubmitKeybinding).toBe("enter"); + }); + + it("accepts all composer submit shortcuts", () => { + for (const composerSubmitKeybinding of [ + "enter", + "shiftEnter", + "metaEnter", + "altEnter", + "ctrlEnter", + "buttonOnly", + ]) { + expect(decodeClientSettings({ composerSubmitKeybinding }).composerSubmitKeybinding).toBe( + composerSubmitKeybinding, + ); + } + }); +}); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98..72d5526ed4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,8 +39,22 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const ComposerSubmitKeybinding = Schema.Literals([ + "enter", + "shiftEnter", + "metaEnter", + "altEnter", + "ctrlEnter", + "buttonOnly", +]); +export type ComposerSubmitKeybinding = typeof ComposerSubmitKeybinding.Type; +export const DEFAULT_COMPOSER_SUBMIT_KEYBINDING: ComposerSubmitKeybinding = "enter"; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + composerSubmitKeybinding: ComposerSubmitKeybinding.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_COMPOSER_SUBMIT_KEYBINDING)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -476,6 +490,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + composerSubmitKeybinding: Schema.optionalKey(ComposerSubmitKeybinding), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),