| author | |
| committer | |
| log | f4ef5155fb8aab7776a41fc8ec73d5367f53ba1f |
| tree | 775471832b0f2f9c17bad372a47f79fb5ffafe42 |
| parent | 0b83045d00d88d0a0b0866fe4942a50a41a08471 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 172 insertions(+), 23 deletions(-)
apps/web/src/components/ChatView.tsx+1-1| ... | @@ -7516,7 +7516,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7516,7 +7516,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7516 | !directAnnotation && | 7516 | !directAnnotation && |
| 7517 | phase === "running" && | 7517 | phase === "running" && |
| 7518 | activeThreadKey && | 7518 | activeThreadKey && |
| 7519 | settings.followUpBehavior === "queue" | 7519 | (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate") |
| 7520 | ) { | 7520 | ) { |
| 7521 | if (composerRef.current?.validateProviderInput(promptForSend) === false) { | 7521 | if (composerRef.current?.validateProviderInput(promptForSend) === false) { |
| 7522 | return; | 7522 | return; |
apps/web/src/components/chat/ChatComposer.tsx+3| ... | @@ -3955,6 +3955,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3955,6 +3955,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3955 | shiftKey: event.shiftKey, | 3955 | shiftKey: event.shiftKey, |
| 3956 | modifierKey: event.metaKey || event.ctrlKey, | 3956 | modifierKey: event.metaKey || event.ctrlKey, |
| 3957 | isDraftThread: routeKind === "draft", | 3957 | isDraftThread: routeKind === "draft", |
| 3958 | isRunning: phase === "running", | ||
| 3959 | sendShortcut: settings.sendShortcut, | ||
| 3960 | prompt: promptRef.current, | ||
| 3958 | }) | 3961 | }) |
| 3959 | : null; | 3962 | : null; |
| 3960 | if (submissionIntent) { | 3963 | if (submissionIntent) { |
apps/web/src/components/settings/SettingsPanels.tsx+76-19| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | import { NotificationSettings } from "./NotificationSettings"; | 2 | import { NotificationSettings } from "./NotificationSettings"; |
| 3 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; | 3 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; |
| 4 | import { Link, useNavigate } from "@tanstack/react-router"; | 4 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 5 | import type { CSSProperties, ReactNode } from "react"; | 5 | import type { CSSProperties, ReactNode } from "react"; |
| 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | @@ -105,6 +105,7 @@ import { | ... | @@ -105,6 +105,7 @@ import { |
| 105 | AlertDialogTitle, | 105 | AlertDialogTitle, |
| 106 | } from "../ui/alert-dialog"; | 106 | } from "../ui/alert-dialog"; |
| 107 | import { Button } from "../ui/button"; | 107 | import { Button } from "../ui/button"; |
| 108 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | ||
| 108 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; | 109 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 109 | import { | 110 | import { |
| 110 | Dialog, | 111 | Dialog, |
| ... | @@ -578,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -578,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 578 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll | 579 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll |
| 579 | ? ["Collapse composer on scroll"] | 580 | ? ["Collapse composer on scroll"] |
| 580 | : []), | 581 | : []), |
| 582 | ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), | ||
| 581 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior | 583 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior |
| 582 | ? ["Follow-up behavior"] | 584 | ? ["Follow-up behavior"] |
| 583 | : []), | 585 | : []), |
| ... | @@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 639 | settings.confirmThreadDelete, | 641 | settings.confirmThreadDelete, |
| 640 | settings.confirmThreadUnpin, | 642 | settings.confirmThreadUnpin, |
| 641 | settings.composerCollapseOnScroll, | 643 | settings.composerCollapseOnScroll, |
| 644 | settings.sendShortcut, | ||
| 642 | settings.followUpBehavior, | 645 | settings.followUpBehavior, |
| 643 | settings.addProjectBaseDirectory, | 646 | settings.addProjectBaseDirectory, |
| 644 | settings.defaultThreadEnvMode, | 647 | settings.defaultThreadEnvMode, |
| ... | @@ -752,6 +755,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -752,6 +755,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 752 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, | 755 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, |
| 753 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, | 756 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 754 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, | 757 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, |
| 758 | sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, | ||
| 755 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, | 759 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, |
| 756 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, | 760 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| 757 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, | 761 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, |
| ... | @@ -2086,6 +2090,12 @@ function LegacyFeaturesSection() { | ... | @@ -2086,6 +2090,12 @@ function LegacyFeaturesSection() { |
| 2086 | } | 2090 | } |
| 2087 | 2091 | ||
| 2088 | export function GeneralSettingsPanel() { | 2092 | export function GeneralSettingsPanel() { |
| 2093 | const modifierLabel = isMacPlatform(navigator.platform) ? "⌘" : "Ctrl"; | ||
| 2094 | const sendShortcutOptions = [ | ||
| 2095 | { value: "enter", label: "Enter" }, | ||
| 2096 | { value: "mod-enter-multiline", label: `${modifierLabel} + Enter for multiline prompts` }, | ||
| 2097 | { value: "mod-enter", label: `${modifierLabel} + Enter always` }, | ||
| 2098 | ] as const; | ||
| 2089 | const settings = useScopedSettings(); | 2099 | const settings = useScopedSettings(); |
| 2090 | const updateSettings = useUpdateScopedSettings(); | 2100 | const updateSettings = useUpdateScopedSettings(); |
| 2091 | const navigate = useNavigate(); | 2101 | const navigate = useNavigate(); |
| ... | @@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() { | ... | @@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() { |
| 2594 | } | 2604 | } |
| 2595 | /> | 2605 | /> |
| 2596 | 2606 | ||
| 2607 | <SettingsRow | ||
| 2608 | {...searchableSetting("send-shortcut")} | ||
| 2609 | description="Choose when Enter sends a prompt or inserts a new line" | ||
| 2610 | resetAction={ | ||
| 2611 | settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ( | ||
| 2612 | <SettingResetButton | ||
| 2613 | label="send shortcut" | ||
| 2614 | onClick={() => | ||
| 2615 | updateSettings({ sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut }) | ||
| 2616 | } | ||
| 2617 | /> | ||
| 2618 | ) : null | ||
| 2619 | } | ||
| 2620 | control={ | ||
| 2621 | <Select | ||
| 2622 | value={settings.sendShortcut} | ||
| 2623 | onValueChange={(value) => { | ||
| 2624 | const option = sendShortcutOptions.find((option) => option.value === value); | ||
| 2625 | if (option) updateSettings({ sendShortcut: option.value }); | ||
| 2626 | }} | ||
| 2627 | > | ||
| 2628 | <SelectTrigger | ||
| 2629 | size="sm" | ||
| 2630 | className="w-auto min-w-0 max-w-full" | ||
| 2631 | aria-label="Send shortcut" | ||
| 2632 | > | ||
| 2633 | <SelectValue> | ||
| 2634 | { | ||
| 2635 | sendShortcutOptions.find((option) => option.value === settings.sendShortcut) | ||
| 2636 | ?.label | ||
| 2637 | } | ||
| 2638 | </SelectValue> | ||
| 2639 | </SelectTrigger> | ||
| 2640 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 2641 | {sendShortcutOptions.map((option) => ( | ||
| 2642 | <SelectItem key={option.value} value={option.value}> | ||
| 2643 | <span className="flex items-center justify-between gap-4"> | ||
| 2644 | {option.label} | ||
| 2645 | {settings.sendShortcut === option.value && <CheckIcon aria-hidden="true" />} | ||
| 2646 | </span> | ||
| 2647 | </SelectItem> | ||
| 2648 | ))} | ||
| 2649 | </SelectPopup> | ||
| 2650 | </Select> | ||
| 2651 | } | ||
| 2652 | /> | ||
| 2653 | |||
| 2597 | <SettingsRow | 2654 | <SettingsRow |
| 2598 | {...searchableSetting("follow-up-behavior")} | 2655 | {...searchableSetting("follow-up-behavior")} |
| 2599 | description="Queue follow-ups while the agent runs or steer the current turn." | 2656 | description={ |
| 2657 | "Queue follow-ups while the agent runs or steer the current run. " + | ||
| 2658 | (settings.sendShortcut === "mod-enter-multiline" | ||
| 2659 | ? `Press ${modifierLabel} + Enter for single-line prompts or ${modifierLabel} + Shift + Enter for multiline prompts to do the opposite for one message.` | ||
| 2660 | : `Press ${modifierLabel}${settings.sendShortcut === "mod-enter" ? " + Shift" : ""} + Enter to do the opposite for one message.`) | ||
| 2661 | } | ||
| 2600 | resetAction={ | 2662 | resetAction={ |
| 2601 | settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ( | 2663 | settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ( |
| 2602 | <SettingResetButton | 2664 | <SettingResetButton |
| ... | @@ -2610,28 +2672,23 @@ export function GeneralSettingsPanel() { | ... | @@ -2610,28 +2672,23 @@ export function GeneralSettingsPanel() { |
| 2610 | ) : null | 2672 | ) : null |
| 2611 | } | 2673 | } |
| 2612 | control={ | 2674 | control={ |
| 2613 | <Select | 2675 | <ToggleGroup |
| 2614 | value={settings.followUpBehavior} | 2676 | aria-label="Follow-up behavior" |
| 2615 | onValueChange={(value) => { | 2677 | variant="default" |
| 2678 | value={[settings.followUpBehavior]} | ||
| 2679 | onValueChange={(values) => { | ||
| 2680 | const value = values[0]; | ||
| 2616 | if (value === "queue" || value === "steer") { | 2681 | if (value === "queue" || value === "steer") { |
| 2617 | updateSettings({ followUpBehavior: value }); | 2682 | updateSettings({ followUpBehavior: value }); |
| 2618 | } | 2683 | } |
| 2619 | }} | 2684 | }} |
| 2620 | > | 2685 | > |
| 2621 | <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Follow-up behavior"> | 2686 | {(["queue", "steer"] as const).map((value) => ( |
| 2622 | <SelectValue> | 2687 | <Toggle key={value} value={value} variant="pill"> |
| 2623 | {settings.followUpBehavior === "queue" ? "Queue" : "Steer"} | 2688 | {value === "queue" ? "Queue" : "Steer"} |
| 2624 | </SelectValue> | 2689 | </Toggle> |
| 2625 | </SelectTrigger> | 2690 | ))} |
| 2626 | <SelectPopup align="end" alignItemWithTrigger={false}> | 2691 | </ToggleGroup> |
| 2627 | <SelectItem hideIndicator value="queue"> | ||
| 2628 | Queue | ||
| 2629 | </SelectItem> | ||
| 2630 | <SelectItem hideIndicator value="steer"> | ||
| 2631 | Steer | ||
| 2632 | </SelectItem> | ||
| 2633 | </SelectPopup> | ||
| 2634 | </Select> | ||
| 2635 | } | 2692 | } |
| 2636 | /> | 2693 | /> |
| 2637 | 2694 |
apps/web/src/components/settings/settingsSearch.test.ts+4| ... | @@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ | ... | @@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ |
| 45 | ]; | 45 | ]; |
| 46 | 46 | ||
| 47 | describe("searchSettings", () => { | 47 | describe("searchSettings", () => { |
| 48 | it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => { | ||
| 49 | expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut"); | ||
| 50 | }); | ||
| 51 | |||
| 48 | it("matches titles, sections, and remembered setting details", () => { | 52 | it("matches titles, sections, and remembered setting details", () => { |
| 49 | expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); | 53 | expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); |
| 50 | expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]); | 54 | expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]); |
apps/web/src/components/settings/settingsSearch.ts+6| ... | @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 298 | to: "/settings/general", | 298 | to: "/settings/general", |
| 299 | searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], | 299 | searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], |
| 300 | }, | 300 | }, |
| 301 | { | ||
| 302 | id: "send-shortcut", | ||
| 303 | title: "Send shortcut", | ||
| 304 | to: "/settings/general", | ||
| 305 | searchTerms: ["enter return command ctrl multiline prompt new line composer"], | ||
| 306 | }, | ||
| 301 | { | 307 | { |
| 302 | id: "follow-up-behavior", | 308 | id: "follow-up-behavior", |
| 303 | title: "Follow-up behavior", | 309 | title: "Follow-up behavior", |
apps/web/src/components/ui/toggle.tsx+1| ... | @@ -25,6 +25,7 @@ const toggleVariants = cva( | ... | @@ -25,6 +25,7 @@ const toggleVariants = cva( |
| 25 | }, | 25 | }, |
| 26 | variant: { | 26 | variant: { |
| 27 | default: "border-transparent", | 27 | default: "border-transparent", |
| 28 | pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground", | ||
| 28 | ghost: | 29 | ghost: |
| 29 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", | 30 | "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", |
| 30 | outline: | 31 | outline: |
apps/web/src/composer-logic.test.ts+45| ... | @@ -61,6 +61,51 @@ describe("formatAssistantCitationForComposer", () => { | ... | @@ -61,6 +61,51 @@ describe("formatAssistantCitationForComposer", () => { |
| 61 | }); | 61 | }); |
| 62 | 62 | ||
| 63 | describe("composerSubmissionIntentForEnter", () => { | 63 | describe("composerSubmissionIntentForEnter", () => { |
| 64 | it.each([ | ||
| 65 | ["enter", "one line", false, "foreground"], | ||
| 66 | ["enter", "two\nlines", false, "foreground"], | ||
| 67 | ["mod-enter-multiline", "one line", false, "foreground"], | ||
| 68 | ["mod-enter-multiline", "two\nlines", false, null], | ||
| 69 | ["mod-enter-multiline", "two\nlines", true, "foreground"], | ||
| 70 | ["mod-enter", "one line", false, null], | ||
| 71 | ["mod-enter", "one line", true, "foreground"], | ||
| 72 | ] as const)("uses %s for %j with modifier=%s", (sendShortcut, prompt, modifierKey, expected) => { | ||
| 73 | expect( | ||
| 74 | composerSubmissionIntentForEnter({ | ||
| 75 | isMobileViewport: false, | ||
| 76 | shiftKey: false, | ||
| 77 | modifierKey, | ||
| 78 | isDraftThread: false, | ||
| 79 | sendShortcut, | ||
| 80 | prompt, | ||
| 81 | }), | ||
| 82 | ).toBe(expected); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it.each([ | ||
| 86 | ["enter", false, "alternate"], | ||
| 87 | ["enter", true, null], | ||
| 88 | ["mod-enter-multiline", false, "foreground"], | ||
| 89 | ["mod-enter-multiline", true, "alternate"], | ||
| 90 | ["mod-enter", false, "foreground"], | ||
| 91 | ["mod-enter", true, "alternate"], | ||
| 92 | ] as const)( | ||
| 93 | "resolves running follow-ups with %s and shift=%s", | ||
| 94 | (sendShortcut, shiftKey, expected) => { | ||
| 95 | expect( | ||
| 96 | composerSubmissionIntentForEnter({ | ||
| 97 | isMobileViewport: false, | ||
| 98 | shiftKey, | ||
| 99 | modifierKey: true, | ||
| 100 | isDraftThread: false, | ||
| 101 | isRunning: true, | ||
| 102 | sendShortcut, | ||
| 103 | prompt: "two\nlines", | ||
| 104 | }), | ||
| 105 | ).toBe(expected); | ||
| 106 | }, | ||
| 107 | ); | ||
| 108 | |||
| 64 | it("submits plain Enter on desktop", () => { | 109 | it("submits plain Enter on desktop", () => { |
| 65 | expect( | 110 | expect( |
| 66 | composerSubmissionIntentForEnter({ | 111 | composerSubmissionIntentForEnter({ |
apps/web/src/composer-logic.ts+12-3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ClientSettings } from "@t3tools/contracts/settings"; | ||
| 1 | import type { AssistantCitation } from "@t3tools/contracts"; | 2 | import type { AssistantCitation } from "@t3tools/contracts"; |
| 2 | import { | 3 | import { |
| 3 | serializeAssistantCitation, | 4 | serializeAssistantCitation, |
| ... | @@ -10,7 +11,7 @@ import { | ... | @@ -10,7 +11,7 @@ import { |
| 10 | 11 | ||
| 11 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; | 12 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; |
| 12 | export type ComposerSlashCommand = "model" | "plan" | "default"; | 13 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 13 | export type ComposerSubmissionIntent = "foreground" | "background"; | 14 | export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; |
| 14 | 15 | ||
| 15 | export interface ComposerTrigger { | 16 | export interface ComposerTrigger { |
| 16 | kind: ComposerTriggerKind; | 17 | kind: ComposerTriggerKind; |
| ... | @@ -28,9 +29,17 @@ export function composerSubmissionIntentForEnter(input: { | ... | @@ -28,9 +29,17 @@ export function composerSubmissionIntentForEnter(input: { |
| 28 | shiftKey: boolean; | 29 | shiftKey: boolean; |
| 29 | modifierKey: boolean; | 30 | modifierKey: boolean; |
| 30 | isDraftThread: boolean; | 31 | isDraftThread: boolean; |
| 32 | isRunning?: boolean; | ||
| 33 | sendShortcut?: ClientSettings["sendShortcut"]; | ||
| 34 | prompt?: string; | ||
| 31 | }): ComposerSubmissionIntent | null { | 35 | }): ComposerSubmissionIntent | null { |
| 32 | if (input.isMobileViewport || input.shiftKey) { | 36 | const requiresModifier = |
| 33 | return null; | 37 | input.sendShortcut === "mod-enter" || |
| 38 | (input.sendShortcut === "mod-enter-multiline" && /[\r\n]/.test(input.prompt ?? "")); | ||
| 39 | if (input.isMobileViewport || (requiresModifier && !input.modifierKey)) return null; | ||
| 40 | if (input.shiftKey && !(requiresModifier && input.modifierKey && input.isRunning)) return null; | ||
| 41 | if (input.isRunning && input.modifierKey && (!requiresModifier || input.shiftKey)) { | ||
| 42 | return "alternate"; | ||
| 34 | } | 43 | } |
| 35 | return input.modifierKey && input.isDraftThread ? "background" : "foreground"; | 44 | return input.modifierKey && input.isDraftThread ? "background" : "foreground"; |
| 36 | } | 45 | } |
docs/user/keybindings.md+9| ... | @@ -5,6 +5,15 @@ also lists the command IDs and defaults available in your version. | ... | @@ -5,6 +5,15 @@ also lists the command IDs and defaults available in your version. |
| 5 | 5 | ||
| 6 | ## Composer controls | 6 | ## Composer controls |
| 7 | 7 | ||
| 8 | In **Settings → General → Send shortcut**, choose whether Enter sends, requires | ||
| 9 | `mod+Enter` for multiline prompts, or always requires `mod+Enter`. `Shift+Enter` | ||
| 10 | inserts a new line. This applies to the web and desktop composer at desktop widths. | ||
| 11 | |||
| 12 | **Follow-up behavior** chooses Queue or Steer while the agent runs. Use | ||
| 13 | `mod+Enter` to do the opposite for one message. When sending requires `mod+Enter`, | ||
| 14 | use `mod+Shift+Enter` for the opposite action. In a new thread, `mod+Enter` keeps | ||
| 15 | starting the thread in the background. | ||
| 16 | |||
| 8 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. | 17 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. |
| 9 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the | 18 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the |
| 10 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the | 19 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the |
packages/contracts/src/settings.test.ts+11| ... | @@ -529,6 +529,17 @@ describe("ClientSettings context window meter", () => { | ... | @@ -529,6 +529,17 @@ describe("ClientSettings context window meter", () => { |
| 529 | }); | 529 | }); |
| 530 | }); | 530 | }); |
| 531 | 531 | ||
| 532 | describe("ClientSettings send shortcut", () => { | ||
| 533 | it("defaults to Enter and validates the supported choices", () => { | ||
| 534 | expect(decodeClientSettings({}).sendShortcut).toBe("enter"); | ||
| 535 | for (const sendShortcut of ["enter", "mod-enter-multiline", "mod-enter"]) { | ||
| 536 | expect(decodeClientSettings({ sendShortcut }).sendShortcut).toBe(sendShortcut); | ||
| 537 | expect(decodeClientSettingsPatch({ sendShortcut }).sendShortcut).toBe(sendShortcut); | ||
| 538 | } | ||
| 539 | expect(() => decodeClientSettingsPatch({ sendShortcut: "invalid" })).toThrow(); | ||
| 540 | }); | ||
| 541 | }); | ||
| 542 | |||
| 532 | describe("ClientSettings follow-up behavior", () => { | 543 | describe("ClientSettings follow-up behavior", () => { |
| 533 | it("defaults to queue and accepts either behavior", () => { | 544 | it("defaults to queue and accepts either behavior", () => { |
| 534 | expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); | 545 | expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); |
packages/contracts/src/settings.ts+4| ... | @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 430 | // Desktop resting composer: scrolling an existing thread's conversation | 430 | // Desktop resting composer: scrolling an existing thread's conversation |
| 431 | // settles the composer into its single-line layout. Losing focus never does. | 431 | // settles the composer into its single-line layout. Losing focus never does. |
| 432 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 432 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 433 | sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( | ||
| 434 | Schema.withDecodingDefault(Effect.succeed("enter")), | ||
| 435 | ), | ||
| 433 | followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( | 436 | followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( |
| 434 | Schema.withDecodingDefault(Effect.succeed("queue")), | 437 | Schema.withDecodingDefault(Effect.succeed("queue")), |
| 435 | ), | 438 | ), |
| ... | @@ -1502,6 +1505,7 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -1502,6 +1505,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1502 | planModeEnabled: Schema.optionalKey(Schema.Boolean), | 1505 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1503 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), | 1506 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1504 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), | 1507 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |
| 1508 | sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), | ||
| 1505 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), | 1509 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), |
| 1506 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), | 1510 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1507 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), | 1511 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |