Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
composerSubmitKeybinding: "shiftEnter",
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
detectComposerTrigger,
expandCollapsedComposerCursor,
replaceTextRange,
shouldSubmitComposerOnEnter,
} from "../../composer-logic";
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
import {
Expand Down Expand Up @@ -1678,7 +1679,7 @@ export const ChatComposer = memo(
return true;
}
}
if (key === "Enter" && !event.shiftKey) {
if (shouldSubmitComposerOnEnter(settings.composerSubmitKeybinding, event)) {
submitComposer();
return true;
}
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ComposerSubmitKeybinding, string>;

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<V>(
Expand Down Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -432,6 +456,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.addProjectBaseDirectory,
settings.composerSubmitKeybinding,
settings.defaultThreadEnvMode,
settings.diffIgnoreWhitespace,
settings.diffWordWrap,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -695,6 +721,47 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Composer submit"
description="Choose which Enter shortcut submits prompts from the composer."
resetAction={
settings.composerSubmitKeybinding !==
DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding ? (
<SettingResetButton
label="Composer submit"
onClick={() =>
updateSettings({
composerSubmitKeybinding: DEFAULT_UNIFIED_SETTINGS.composerSubmitKeybinding,
})
}
/>
) : null
}
control={
<Select
value={settings.composerSubmitKeybinding}
onValueChange={(value) => {
if (isComposerSubmitKeybinding(value)) {
updateSettings({ composerSubmitKeybinding: value });
}
}}
>
<SelectTrigger className="w-full sm:w-56" aria-label="Composer submit">
<SelectValue>
{COMPOSER_SUBMIT_KEYBINDING_LABELS[settings.composerSubmitKeybinding]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{COMPOSER_SUBMIT_KEYBINDING_OPTIONS.map((option) => (
<SelectItem hideIndicator key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>

<SettingsRow
title="New threads"
description="Pick the default workspace mode for newly created draft threads."
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,59 @@ import {
isCollapsedCursorAdjacentToInlineToken,
parseStandaloneComposerSlashCommand,
replaceTextRange,
shouldSubmitComposerOnEnter,
} from "./composer-logic";
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";

const enterEvent = (
modifiers: Partial<{
key: string;
shiftKey: boolean;
metaKey: boolean;
altKey: boolean;
ctrlKey: boolean;
}> = {},
) => ({
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";
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down
31 changes: 30 additions & 1 deletion packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
Loading