Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 9e0b384b45 feat: add keyboard preset configuration system
Introduces a 2-preset keyboard configuration system to address user
requests for customizable keybinds (issue #895).

Presets:
- "big-AGI" (default): Enter sends, Shift+Enter newline, Ctrl+Enter beams
- "Classic Send": Shift+Enter sends, Ctrl+Enter sends (no beam shortcut)

Changes:
- Add keyboardUtils.ts with centralized keyboard mapping logic
- Replace enterIsNewline boolean with keyboardPreset in store-ui.ts
- Update Settings UI with dropdown selector for keyboard presets
- Update Composer, PromptComposer, BlockEdit_TextFragment, InlineTextarea
  to use the new keyboard mapping system
- Update shortcut displays in ExecuteModeMenu and ButtonBeam

Closes #895

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Enrico Ros <enricoros@users.noreply.github.com>
2025-12-10 13:56:13 +00:00
9 changed files with 303 additions and 65 deletions
+34 -27
View File
@@ -43,6 +43,7 @@ import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
import { useChatComposerOverlayStore } from '~/common/chat-overlay/store-perchat_vanilla';
import { useComposerStartupText, useLogicSherpaStore } from '~/common/logic/store-logic-sherpa';
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
import { enterIsNewline as computeEnterIsNewline, getKeyboardActionFromEvent, getSendShortcut, KeyboardPreset } from '~/common/util/keyboardUtils';
import { useUICounter, useUIPreferencesStore } from '~/common/stores/store-ui';
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
@@ -147,7 +148,7 @@ export function Composer(props: {
const { novel: explainAltEnter, touch: touchAltEnter } = useUICounter('composer-alt-enter');
const { novel: explainCtrlEnter, touch: touchCtrlEnter } = useUICounter('composer-ctrl-enter');
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
const composerQuickButton = useUIPreferencesStore(state => state.composerQuickButton);
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, abortConversationTemp } = useChatStore(useShallow(state => {
@@ -217,6 +218,7 @@ export function Composer(props: {
const isMobile = props.isMobile;
const isDesktop = !props.isMobile;
const noConversation = !targetConversationId;
const enterIsNewline = computeEnterIsNewline(keyboardPreset);
const composerTextSuffix = chatExecuteMode === 'generate-image' && isDesktop && drawRepeat > 1 ? ` x${drawRepeat}` : '';
@@ -543,36 +545,41 @@ export function Composer(props: {
if (actileInterceptKeydown(e))
return;
// Enter: primary action
if (e.key === 'Enter') {
// Use the keyboard mapping system to determine the action
const action = getKeyboardActionFromEvent(e, keyboardPreset);
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
if (e.altKey && !e.metaKey && !e.ctrlKey) {
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
touchAltEnter();
return e.preventDefault();
}
if (action) {
switch (action) {
case 'append':
if (await handleSendAction('append-user', composeText))
touchAltEnter();
return e.preventDefault();
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
if (e.ctrlKey && !e.metaKey && !e.altKey) {
if (await handleSendAction('beam-content', composeText)) { // 'ctrl+enter' -> beam
touchCtrlEnter();
e.stopPropagation();
}
return e.preventDefault();
}
case 'beam':
if (await handleSendAction('beam-content', composeText)) {
touchCtrlEnter();
e.stopPropagation();
}
return e.preventDefault();
// Shift: toggles the 'enter is newline'
if (e.shiftKey)
touchShiftEnter();
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
await handleSendAction(chatExecuteMode, composeText); // enter -> send
return e.preventDefault();
case 'send':
// Track shift+enter usage for tips
if (e.shiftKey)
touchShiftEnter();
if (!assistantAbortible)
await handleSendAction(chatExecuteMode, composeText);
return e.preventDefault();
case 'newline':
// Track shift+enter usage for tips
if (e.shiftKey)
touchShiftEnter();
// Allow default behavior (newline)
break;
}
}
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, keyboardPreset, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
// Focus mode
@@ -730,10 +737,10 @@ export function Composer(props: {
if (isDesktop && timeToShowTips && !isDraw) {
if (explainShiftEnter)
textPlaceholder += !enterIsNewline ? '\n\n⏎ Shift + Enter to add a new line' : '\n\n➤ Shift + Enter to send';
textPlaceholder += !enterIsNewline ? '\n\n⏎ Shift + Enter to add a new line' : `\n\n➤ ${getSendShortcut(keyboardPreset)} to send`;
// else if (explainAltEnter)
// textPlaceholder += platformAwareKeystrokes('\n\n⭳ Tip: Alt + Enter to just append the message');
else if (explainCtrlEnter)
else if (explainCtrlEnter && keyboardPreset === 'big-agi')
textPlaceholder += platformAwareKeystrokes('\n\n⫷ Tip: Ctrl + Enter to beam');
}
@@ -6,18 +6,8 @@ import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { KeyStroke } from '~/common/components/KeyStroke';
import { animationEnterBelow } from '~/common/util/animUtils';
const desktopLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Combine the answers from multiple models<br />
<KeyStroke combo='Ctrl + Enter' sx={{ mt: 0.5, mb: 0.25 }} />
</Box>;
const desktopLegendNoContent =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Enter the text to Beam, then press this
</Box>;
import { getBeamShortcut, KeyboardPreset } from '~/common/util/keyboardUtils';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
const mobileSx: SxProps = {
mr: { xs: 1, md: 2 },
@@ -33,6 +23,29 @@ const desktopSx: SxProps = {
};
function DesktopLegend(props: { hasContent: boolean, keyboardPreset: KeyboardPreset }) {
const beamShortcut = getBeamShortcut(props.keyboardPreset);
if (!props.hasContent) {
return (
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Enter the text to Beam, then press this
</Box>
);
}
return (
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Combine the answers from multiple models
{beamShortcut && <>
<br />
<KeyStroke combo={beamShortcut} sx={{ mt: 0.5, mb: 0.25 }} />
</>}
</Box>
);
}
export const ButtonBeamMemo = React.memo(ButtonBeam);
function ButtonBeam(props: {
@@ -42,12 +55,14 @@ function ButtonBeam(props: {
hasContent?: boolean,
onClick: () => void,
}) {
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
return props.isMobile ? (
<IconButton variant='outlined' color={props.color ?? 'primary'} disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
<ChatBeamIcon />
</IconButton>
) : (
<Tooltip disableInteractive variant='solid' arrow placement='right' title={props.hasContent ? desktopLegend : desktopLegendNoContent}>
<Tooltip disableInteractive variant='solid' arrow placement='right' title={<DesktopLegend hasContent={!!props.hasContent} keyboardPreset={keyboardPreset} />}>
<Button variant='soft' color={props.color ?? 'primary'} disabled={props.disabled} onClick={props.onClick} endDecorator={<ChatBeamIcon />} sx={desktopSx}>
Beam
</Button>
@@ -4,6 +4,7 @@ import { BlocksTextarea } from '~/modules/blocks/BlocksContainers';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
import { enterIsNewline as computeEnterIsNewline, shouldSendOnEnter } from '~/common/util/keyboardUtils';
import { Is } from '~/common/util/pwaUtils';
import { ShortcutKey, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
@@ -84,7 +85,11 @@ export function BlockEdit_TextFragment(props: {
// NOTE2: as per #https://github.com/enricoros/big-AGI/issues/760, this is a UX break of behavior.
// adding a configuration option to quickly
const isControlled = !!props.controlled;
const enterIsNewline = useUIPreferencesStore(state => isControlled ? true : FORCE_ENTER_IS_NEWLINE !== undefined ? FORCE_ENTER_IS_NEWLINE : state.enterIsNewline);
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
const isMobile = !Is.Desktop;
// Compute enterIsNewline: controlled mode forces true, mobile forces true, otherwise use preset
const enterIsNewline = isControlled ? true : FORCE_ENTER_IS_NEWLINE !== undefined ? FORCE_ENTER_IS_NEWLINE : computeEnterIsNewline(keyboardPreset);
// derived state
const { fragmentId, setEditedText, onSubmit, onEscapePressed } = props;
@@ -4,6 +4,7 @@ import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseablePopup } from '~/common/components/CloseablePopup';
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { getSendShortcut, KeyboardPreset } from '~/common/util/keyboardUtils';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
import type { ChatExecuteMode } from './execute-mode.types';
@@ -20,7 +21,7 @@ export function ExecuteModeMenu(props: {
}) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
return (
<CloseablePopup
@@ -48,11 +49,11 @@ export function ExecuteModeMenu(props: {
</Box>
{(key === props.chatExecuteMode || !!data.shortcut) && (
<KeyStroke variant='outlined' combo={platformAwareKeystrokes(
newLineShortcut(
getShortcutForMode(
(key === props.chatExecuteMode) ? 'ENTER'
: data.shortcut ? data.shortcut
: 'ENTER',
enterIsNewline,
keyboardPreset,
),
)} />
)}
@@ -64,8 +65,8 @@ export function ExecuteModeMenu(props: {
);
}
function newLineShortcut(shortcut: string, enterIsNewLine: boolean) {
function getShortcutForMode(shortcut: string, keyboardPreset: KeyboardPreset) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return getSendShortcut(keyboardPreset);
return shortcut;
}
+6 -4
View File
@@ -17,6 +17,7 @@ import { imaginePromptFromTextOrThrow } from '~/modules/aifn/imagine/imagineProm
import { agiUuid } from '~/common/util/idUtils';
import { animationEnterBelow } from '~/common/util/animUtils';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { enterIsNewline as computeEnterIsNewline, shouldSendOnEnter } from '~/common/util/keyboardUtils';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
import { ButtonPromptFromIdea } from './ButtonPromptFromIdea';
@@ -56,7 +57,8 @@ export function PromptComposer(props: {
// external state
const { currentIdea, nextRandomIdea, clearCurrentIdea } = useDrawIdeas();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
const enterIsNewline = computeEnterIsNewline(keyboardPreset);
// derived state
@@ -101,13 +103,13 @@ export function PromptComposer(props: {
if (e.key !== 'Enter')
return;
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
// Use keyboard mapping to determine if this should send
if (shouldSendOnEnter(e, keyboardPreset)) {
if (userHasText)
handlePromptEnqueue();
return e.preventDefault();
}
}, [enterIsNewline, handlePromptEnqueue, userHasText]);
}, [keyboardPreset, handlePromptEnqueue, userHasText]);
// Ideas
@@ -1,13 +1,14 @@
import * as React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Button, FormControl, Switch } from '@mui/joy';
import { Button, FormControl, Option, Select, Switch } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import WidthNormalIcon from '@mui/icons-material/WidthNormal';
import WidthWideIcon from '@mui/icons-material/WidthWide';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
import { KEYBOARD_PRESET_DESCRIPTIONS, KEYBOARD_PRESET_LABELS, KeyboardPreset } from '~/common/util/keyboardUtils';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
import { isPwa } from '~/common/util/pwaUtils';
import { optimaOpenModels } from '~/common/layout/optima/useOptima';
@@ -58,17 +59,17 @@ export function AppChatSettingsUI() {
centerMode, setCenterMode,
disableMarkdown, setDisableMarkdown,
doubleClickToEdit, setDoubleClickToEdit,
enterIsNewline, setEnterIsNewline,
keyboardPreset, setKeyboardPreset,
showPersonaFinder, setShowPersonaFinder,
} = useUIPreferencesStore(useShallow(state => ({
centerMode: state.centerMode, setCenterMode: state.setCenterMode,
disableMarkdown: state.disableMarkdown, setDisableMarkdown: state.setDisableMarkdown,
doubleClickToEdit: state.doubleClickToEdit, setDoubleClickToEdit: state.setDoubleClickToEdit,
enterIsNewline: state.enterIsNewline, setEnterIsNewline: state.setEnterIsNewline,
keyboardPreset: state.keyboardPreset, setKeyboardPreset: state.setKeyboardPreset,
showPersonaFinder: state.showPersonaFinder, setShowPersonaFinder: state.setShowPersonaFinder,
})));
const handleEnterIsNewlineChange = (event: React.ChangeEvent<HTMLInputElement>) => setEnterIsNewline(!event.target.checked);
const handleKeyboardPresetChange = (_event: any, value: KeyboardPreset | null) => value && setKeyboardPreset(value);
const handleDoubleClickToEditChange = (event: React.ChangeEvent<HTMLInputElement>) => setDoubleClickToEdit(event.target.checked);
@@ -85,11 +86,19 @@ export function AppChatSettingsUI() {
</FormControl>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
<FormLabelStart title='Enter sends ⏎'
description={enterIsNewline ? 'New line' : 'Sends message'} />
<Switch checked={!enterIsNewline} onChange={handleEnterIsNewlineChange}
endDecorator={enterIsNewline ? 'Off' : 'On'}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
<FormLabelStart title='Keyboard'
description={KEYBOARD_PRESET_DESCRIPTIONS[keyboardPreset]} />
<Select
value={keyboardPreset}
onChange={handleKeyboardPresetChange}
sx={{ minWidth: 140 }}
>
{(Object.keys(KEYBOARD_PRESET_LABELS) as KeyboardPreset[]).map((preset) => (
<Option key={preset} value={preset}>
{KEYBOARD_PRESET_LABELS[preset]}
</Option>
))}
</Select>
</FormControl>
{SHOW_MARKDOWN_DISABLE_SETTING && (
+3 -1
View File
@@ -3,6 +3,7 @@ import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Textarea } from '@mui/joy';
import { enterIsNewline as computeEnterIsNewline } from '~/common/util/keyboardUtils';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
/**
@@ -24,7 +25,8 @@ export function InlineTextarea(props: {
}) {
const [text, setText] = React.useState(props.initialText);
const enterIsNewline = useUIPreferencesStore(state => (!props.disableAutoSaveOnBlur && state.enterIsNewline));
const keyboardPreset = useUIPreferencesStore(state => state.keyboardPreset);
const enterIsNewline = !props.disableAutoSaveOnBlur && computeEnterIsNewline(keyboardPreset);
// [effect] optional syncing of the text to the initial text. warning, will discard the current partial edit
+19 -5
View File
@@ -3,6 +3,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ContentScaling, UIComplexityMode } from '~/common/app.theme';
import type { KeyboardPreset } from '~/common/util/keyboardUtils';
import { BrowserLang } from '~/common/util/pwaUtils';
import { Release } from '~/common/app.release';
@@ -33,8 +34,8 @@ interface UIPreferencesStore {
doubleClickToEdit: boolean;
setDoubleClickToEdit: (doubleClickToEdit: boolean) => void;
enterIsNewline: boolean;
setEnterIsNewline: (enterIsNewline: boolean) => void;
keyboardPreset: KeyboardPreset;
setKeyboardPreset: (keyboardPreset: KeyboardPreset) => void;
renderCodeLineNumbers: boolean;
setRenderCodeLineNumbers: (renderCodeLineNumbers: boolean) => void;
@@ -107,8 +108,8 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
disableMarkdown: false,
setDisableMarkdown: (disableMarkdown: boolean) => set({ disableMarkdown }),
enterIsNewline: false,
setEnterIsNewline: (enterIsNewline: boolean) => set({ enterIsNewline }),
keyboardPreset: 'big-agi',
setKeyboardPreset: (keyboardPreset: KeyboardPreset) => set({ keyboardPreset }),
renderCodeLineNumbers: false,
setRenderCodeLineNumbers: (renderCodeLineNumbers: boolean) => set({ renderCodeLineNumbers }),
@@ -172,8 +173,9 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
* 1: rename 'enterToSend' to 'enterIsNewline' (flip the meaning)
* 2: new Big-AGI 2 defaults
* 3: centerMode: 'full' is the new default
* 4: replace 'enterIsNewline' with 'keyboardPreset' for full keyboard configuration
*/
version: 3,
version: 4,
partialize: (state) => {
if (Release.IsNodeDevBuild) return state; // in dev, persist everything
@@ -199,6 +201,14 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
state.centerMode = 'full';
}
// 4: migrate 'enterIsNewline' to 'keyboardPreset'
// Note: Both old enterIsNewline values map to 'big-agi' preset because
// Ctrl+Enter was always hard-coded to Beam before this change
if (state && fromVersion < 4) {
state.keyboardPreset = 'big-agi';
delete state.enterIsNewline;
}
return state;
},
},
@@ -222,6 +232,10 @@ export function getAixInspectorEnabled(): boolean {
return useUIPreferencesStore.getState().aixInspector;
}
export function getKeyboardPreset(): KeyboardPreset {
return useUIPreferencesStore.getState().keyboardPreset;
}
export function useUIIsDismissed(key: string | null): boolean | undefined {
return useUIPreferencesStore((state) => !key ? undefined : Boolean(state.dismissals[key]));
+183
View File
@@ -0,0 +1,183 @@
/**
* Keyboard configuration presets for Big-AGI
*
* This module provides a centralized way to handle keyboard shortcuts across the application,
* supporting different keyboard presets (modes) that users can choose from.
*
* Presets:
* - 'big-agi': Default behavior with Ctrl+Enter triggering Beam
* - 'classic-send': Classic chat behavior where Ctrl+Enter sends the message
*/
// Types
export type KeyboardPreset = 'big-agi' | 'classic-send';
export type KeyboardAction = 'send' | 'newline' | 'beam' | 'append';
export interface KeyboardMapping {
enter: KeyboardAction;
shiftEnter: KeyboardAction;
ctrlEnter: KeyboardAction;
altEnter: KeyboardAction;
}
// Preset Definitions
const KEYBOARD_PRESETS: Record<KeyboardPreset, KeyboardMapping> = {
'big-agi': {
enter: 'send',
shiftEnter: 'newline',
ctrlEnter: 'beam',
altEnter: 'append',
},
'classic-send': {
enter: 'newline',
shiftEnter: 'send',
ctrlEnter: 'send',
altEnter: 'append',
},
};
// Mobile always uses newline for Enter (no modifier keys available)
const MOBILE_MAPPING: Partial<KeyboardMapping> = {
enter: 'newline',
};
/**
* Get the keyboard mapping for a given preset
* @param preset - The keyboard preset to use
* @param isMobile - Whether the device is mobile (forces enter=newline)
*/
export function getKeyboardMapping(preset: KeyboardPreset, isMobile?: boolean): KeyboardMapping {
const baseMapping = KEYBOARD_PRESETS[preset];
if (isMobile) {
return { ...baseMapping, ...MOBILE_MAPPING };
}
return baseMapping;
}
/**
* Determine the action for a keyboard event based on the current preset
* @param event - The keyboard event (or its relevant properties)
* @param preset - The keyboard preset to use
* @param isMobile - Whether the device is mobile
* @returns The action to perform, or null if the event doesn't match any shortcut
*/
export function getKeyboardActionFromEvent(
event: { key: string; shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean },
preset: KeyboardPreset,
isMobile?: boolean,
): KeyboardAction | null {
// Only handle Enter key
if (event.key !== 'Enter') return null;
const mapping = getKeyboardMapping(preset, isMobile);
// Alt+Enter: always append (regardless of other modifiers)
if (event.altKey && !event.metaKey && !event.ctrlKey) {
return mapping.altEnter;
}
// Ctrl+Enter (or Cmd+Enter on Mac): beam or send based on preset
if (event.ctrlKey && !event.metaKey && !event.altKey) {
return mapping.ctrlEnter;
}
// Shift+Enter: newline or send based on preset
if (event.shiftKey) {
return mapping.shiftEnter;
}
// Plain Enter: send or newline based on preset
return mapping.enter;
}
/**
* Check if the event should trigger a "send" action (either via Enter or Shift+Enter depending on preset)
* This is a convenience function for components that just need to know if they should send
*/
export function shouldSendOnEnter(
event: { key: string; shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean },
preset: KeyboardPreset,
isMobile?: boolean,
): boolean {
const action = getKeyboardActionFromEvent(event, preset, isMobile);
return action === 'send';
}
/**
* Check if the event should trigger a "beam" action
*/
export function shouldBeamOnEnter(
event: { key: string; shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean },
preset: KeyboardPreset,
isMobile?: boolean,
): boolean {
const action = getKeyboardActionFromEvent(event, preset, isMobile);
return action === 'beam';
}
/**
* Get the shortcut string for displaying in UI based on the preset
* @param action - The action to get the shortcut for
* @param preset - The keyboard preset
* @returns Human-readable shortcut string
*/
export function getShortcutForAction(action: KeyboardAction, preset: KeyboardPreset): string {
const mapping = KEYBOARD_PRESETS[preset];
if (mapping.enter === action) return 'Enter';
if (mapping.shiftEnter === action) return 'Shift + Enter';
if (mapping.ctrlEnter === action) return 'Ctrl + Enter';
if (mapping.altEnter === action) return 'Alt + Enter';
return '';
}
/**
* Get the send shortcut string for the current preset (for UI display)
*/
export function getSendShortcut(preset: KeyboardPreset): string {
return getShortcutForAction('send', preset);
}
/**
* Get the beam shortcut string for the current preset (for UI display)
* Returns empty string if beam is not available via keyboard in this preset
*/
export function getBeamShortcut(preset: KeyboardPreset): string {
return getShortcutForAction('beam', preset);
}
/**
* Check if Enter key should behave as newline (for enterKeyHint attribute)
*/
export function enterIsNewline(preset: KeyboardPreset, isMobile?: boolean): boolean {
const mapping = getKeyboardMapping(preset, isMobile);
return mapping.enter === 'newline';
}
// Preset metadata for UI
export const KEYBOARD_PRESET_LABELS: Record<KeyboardPreset, string> = {
'big-agi': 'big-AGI',
'classic-send': 'Classic Send',
};
export const KEYBOARD_PRESET_DESCRIPTIONS: Record<KeyboardPreset, string> = {
'big-agi': 'Enter sends, Ctrl+Enter beams',
'classic-send': 'Shift+Enter sends, Ctrl+Enter sends',
};