mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e0b384b45 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user