Prompt history shortcuts

This commit is contained in:
Kent Keirsey
2025-09-15 17:34:16 -04:00
committed by psychedelicious
parent 4528bcafaf
commit d6442d9a34
5 changed files with 168 additions and 23 deletions

View File

@@ -235,6 +235,7 @@
"compatibleEmbeddings": "Compatible Embeddings",
"noMatchingTriggers": "No matching triggers",
"generateFromImage": "Generate prompt from image",
"historyHotkeyHint": "Tip: Use Alt+Up/Down to browse history from the prompt.",
"expandCurrentPrompt": "Expand Current Prompt",
"uploadImageForPromptGeneration": "Upload Image for Prompt Generation",
"expandingPrompt": "Expanding prompt...",
@@ -480,6 +481,14 @@
"title": "Focus Prompt",
"desc": "Move cursor focus to the positive prompt."
},
"promptHistoryPrev": {
"title": "Previous Prompt in History",
"desc": "When the prompt is focused, move to the previous (older) prompt in your history."
},
"promptHistoryNext": {
"title": "Next Prompt in History",
"desc": "When the prompt is focused, move to the next (newer) prompt in your history."
},
"toggleLeftPanel": {
"title": "Toggle Left Panel",
"desc": "Show or hide the left panel."

View File

@@ -199,11 +199,11 @@ const slice = createSlice({
return;
}
if (state.positivePromptHistory.includes(prompt)) {
return;
}
state.positivePromptHistory.unshift(prompt);
state.positivePromptHistory = [
prompt,
...state.positivePromptHistory.filter((p) => p !== prompt),
];
if (state.positivePromptHistory.length > MAX_POSITIVE_PROMPT_HISTORY) {
state.positivePromptHistory = state.positivePromptHistory.slice(0, MAX_POSITIVE_PROMPT_HISTORY);

View File

@@ -3,9 +3,11 @@ import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize';
import {
positivePromptAddedToHistory,
positivePromptChanged,
selectModelSupportsNegativePrompt,
selectPositivePrompt,
selectPositivePromptHistory,
} from 'features/controlLayers/store/paramsSlice';
import { promptGenerationFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -27,7 +29,7 @@ import {
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectAllowPromptExpansion } from 'features/system/store/configSlice';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import type { HotkeyCallback } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
@@ -43,6 +45,7 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
export const ParamPositivePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector(selectPositivePrompt);
const history = useAppSelector(selectPositivePromptHistory);
const viewMode = useAppSelector(selectStylePresetViewMode);
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt);
@@ -77,6 +80,22 @@ export const ParamPositivePrompt = memo(() => {
isDisabled: isPromptExpansionPending,
});
// Browsing state for boundary Up/Down traversal
const browsingIndexRef = useRef<number | null>(null); // null => not browsing; 0..n => index in history
const preBrowsePromptRef = useRef<string>(''); // original prompt when browsing started
const lastHistoryFirstRef = useRef<string | undefined>(undefined);
// Reset browsing when history updates due to a new generation (first item changes or history mutates)
useEffect(() => {
if (lastHistoryFirstRef.current !== history[0]) {
browsingIndexRef.current = null;
preBrowsePromptRef.current = '';
lastHistoryFirstRef.current = history[0];
}
}, [history]);
// Boundary navigation via Up/Down keys was replaced by explicit hotkeys below.
const focus: HotkeyCallback = useCallback(
(e) => {
onFocus();
@@ -93,6 +112,112 @@ export const ParamPositivePrompt = memo(() => {
dependencies: [focus],
});
// Helper: check if prompt textarea is focused
const isPromptFocused = useCallback(() => document.activeElement === textareaRef.current, []);
// Compute a starting working history and ensure current prompt is bumped into history
const startBrowsing = useCallback(() => {
if (browsingIndexRef.current !== null) return;
preBrowsePromptRef.current = prompt ?? '';
const trimmedCurrent = (prompt ?? '').trim();
if (trimmedCurrent) {
dispatch(positivePromptAddedToHistory(trimmedCurrent));
}
browsingIndexRef.current = 0;
}, [dispatch, prompt]);
const applyHistoryAtIndex = useCallback(
(idx: number, placeCaretAt: 'start' | 'end') => {
const list = history;
if (list.length === 0) return;
const clamped = Math.max(0, Math.min(idx, list.length - 1));
browsingIndexRef.current = clamped;
dispatch(positivePromptChanged(list[clamped]));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
if (placeCaretAt === 'start') {
el.selectionStart = 0;
el.selectionEnd = 0;
} else {
const end = el.value.length;
el.selectionStart = end;
el.selectionEnd = end;
}
});
},
[dispatch, history]
);
const browsePrev = useCallback(() => {
if (!isPromptFocused()) return;
if (history.length === 0) return;
if (browsingIndexRef.current === null) {
startBrowsing();
// Move to older entry on first activation
if (history.length > 1) {
applyHistoryAtIndex(1, 'start');
} else {
applyHistoryAtIndex(0, 'start');
}
return;
}
// Already browsing, go older if possible
const next = Math.min((browsingIndexRef.current ?? 0) + 1, history.length - 1);
applyHistoryAtIndex(next, 'start');
}, [applyHistoryAtIndex, history.length, isPromptFocused, startBrowsing]);
const browseNext = useCallback(() => {
if (!isPromptFocused()) return;
if (history.length === 0) return;
if (browsingIndexRef.current === null) {
// Not browsing; Down does nothing (matches shell semantics)
return;
}
if ((browsingIndexRef.current ?? 0) > 0) {
const next = (browsingIndexRef.current ?? 0) - 1;
applyHistoryAtIndex(next, 'end');
} else {
// Exit browsing and restore pre-browse prompt
browsingIndexRef.current = null;
dispatch(positivePromptChanged(preBrowsePromptRef.current));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
const end = el.value.length;
el.selectionStart = end;
el.selectionEnd = end;
}
});
}
}, [applyHistoryAtIndex, dispatch, history.length, isPromptFocused]);
// Register hotkeys for browsing
useRegisteredHotkeys({
id: 'promptHistoryPrev',
category: 'app',
callback: (e) => {
if (isPromptFocused()) {
e.preventDefault();
browsePrev();
}
},
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
dependencies: [browsePrev, isPromptFocused],
});
useRegisteredHotkeys({
id: 'promptHistoryNext',
category: 'app',
callback: (e) => {
if (isPromptFocused()) {
e.preventDefault();
browseNext();
}
},
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
dependencies: [browseNext, isPromptFocused],
});
const dndTargetData = useMemo(() => promptGenerationFromImageDndTarget.getData(), []);
return (

View File

@@ -96,25 +96,32 @@ const PromptHistoryContent = memo(() => {
</Button>
</Flex>
<Divider />
{positivePromptHistory.length === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No prompt history recorded.</Text>
</Flex>
)}
{positivePromptHistory.length !== 0 && filteredPrompts.length === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No matching prompts in history.</Text>{' '}
</Flex>
)}
{filteredPrompts.length > 0 && (
<ScrollableContent>
<Flex flexDir="column">
{filteredPrompts.map((prompt, index) => (
<PromptItem key={`${prompt}-${index}`} prompt={prompt} />
))}
<Flex flexDir="column" flexGrow={1} minH={0}>
{positivePromptHistory.length === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No prompt history recorded.</Text>
</Flex>
</ScrollableContent>
)}
)}
{positivePromptHistory.length !== 0 && filteredPrompts.length === 0 && (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No matching prompts in history.</Text>{' '}
</Flex>
)}
{filteredPrompts.length > 0 && (
<ScrollableContent>
<Flex flexDir="column">
{filteredPrompts.map((prompt, index) => (
<PromptItem key={`${prompt}-${index}`} prompt={prompt} />
))}
</Flex>
</ScrollableContent>
)}
</Flex>
<Flex alignItems="center" justifyContent="center" pt={1}>
<Text fontSize="xs" color="base.400" textAlign="center">
<Text as="span" fontWeight="semibold">Alt + Up/Down</Text> to switch between prompts.
</Text>
</Flex>
</Flex>
);
});

View File

@@ -81,6 +81,10 @@ export const useHotkeyData = (): HotkeysData => {
addHotkey('app', 'selectGenerateTab', ['1']);
addHotkey('app', 'selectCanvasTab', ['2']);
addHotkey('app', 'selectUpscalingTab', ['3']);
// Prompt/history navigation (when prompt textarea is focused)
addHotkey('app', 'promptHistoryPrev', ['alt+up']);
addHotkey('app', 'promptHistoryNext', ['alt+down']);
if (isVideoEnabled) {
addHotkey('app', 'selectVideoTab', ['4']);
addHotkey('app', 'selectWorkflowsTab', ['5']);