Skip to content

Commit d6442d9

Browse files
hipsterusernamepsychedelicious
authored andcommitted
Prompt history shortcuts
1 parent 4528bca commit d6442d9

File tree

5 files changed

+168
-23
lines changed

5 files changed

+168
-23
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
"compatibleEmbeddings": "Compatible Embeddings",
236236
"noMatchingTriggers": "No matching triggers",
237237
"generateFromImage": "Generate prompt from image",
238+
"historyHotkeyHint": "Tip: Use Alt+Up/Down to browse history from the prompt.",
238239
"expandCurrentPrompt": "Expand Current Prompt",
239240
"uploadImageForPromptGeneration": "Upload Image for Prompt Generation",
240241
"expandingPrompt": "Expanding prompt...",
@@ -480,6 +481,14 @@
480481
"title": "Focus Prompt",
481482
"desc": "Move cursor focus to the positive prompt."
482483
},
484+
"promptHistoryPrev": {
485+
"title": "Previous Prompt in History",
486+
"desc": "When the prompt is focused, move to the previous (older) prompt in your history."
487+
},
488+
"promptHistoryNext": {
489+
"title": "Next Prompt in History",
490+
"desc": "When the prompt is focused, move to the next (newer) prompt in your history."
491+
},
483492
"toggleLeftPanel": {
484493
"title": "Toggle Left Panel",
485494
"desc": "Show or hide the left panel."

invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,11 @@ const slice = createSlice({
199199
return;
200200
}
201201

202-
if (state.positivePromptHistory.includes(prompt)) {
203-
return;
204-
}
205202

206-
state.positivePromptHistory.unshift(prompt);
203+
state.positivePromptHistory = [
204+
prompt,
205+
...state.positivePromptHistory.filter((p) => p !== prompt),
206+
];
207207

208208
if (state.positivePromptHistory.length > MAX_POSITIVE_PROMPT_HISTORY) {
209209
state.positivePromptHistory = state.positivePromptHistory.slice(0, MAX_POSITIVE_PROMPT_HISTORY);

invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { useStore } from '@nanostores/react';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
44
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize';
55
import {
6+
positivePromptAddedToHistory,
67
positivePromptChanged,
78
selectModelSupportsNegativePrompt,
89
selectPositivePrompt,
10+
selectPositivePromptHistory,
911
} from 'features/controlLayers/store/paramsSlice';
1012
import { promptGenerationFromImageDndTarget } from 'features/dnd/dnd';
1113
import { DndDropTarget } from 'features/dnd/DndDropTarget';
@@ -27,7 +29,7 @@ import {
2729
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
2830
import { selectAllowPromptExpansion } from 'features/system/store/configSlice';
2931
import { selectActiveTab } from 'features/ui/store/uiSelectors';
30-
import { memo, useCallback, useMemo, useRef } from 'react';
32+
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
3133
import type { HotkeyCallback } from 'react-hotkeys-hook';
3234
import { useTranslation } from 'react-i18next';
3335
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
@@ -43,6 +45,7 @@ const persistOptions: Parameters<typeof usePersistedTextAreaSize>[2] = {
4345
export const ParamPositivePrompt = memo(() => {
4446
const dispatch = useAppDispatch();
4547
const prompt = useAppSelector(selectPositivePrompt);
48+
const history = useAppSelector(selectPositivePromptHistory);
4649
const viewMode = useAppSelector(selectStylePresetViewMode);
4750
const activeStylePresetId = useAppSelector(selectStylePresetActivePresetId);
4851
const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt);
@@ -77,6 +80,22 @@ export const ParamPositivePrompt = memo(() => {
7780
isDisabled: isPromptExpansionPending,
7881
});
7982

83+
// Browsing state for boundary Up/Down traversal
84+
const browsingIndexRef = useRef<number | null>(null); // null => not browsing; 0..n => index in history
85+
const preBrowsePromptRef = useRef<string>(''); // original prompt when browsing started
86+
const lastHistoryFirstRef = useRef<string | undefined>(undefined);
87+
88+
// Reset browsing when history updates due to a new generation (first item changes or history mutates)
89+
useEffect(() => {
90+
if (lastHistoryFirstRef.current !== history[0]) {
91+
browsingIndexRef.current = null;
92+
preBrowsePromptRef.current = '';
93+
lastHistoryFirstRef.current = history[0];
94+
}
95+
}, [history]);
96+
97+
// Boundary navigation via Up/Down keys was replaced by explicit hotkeys below.
98+
8099
const focus: HotkeyCallback = useCallback(
81100
(e) => {
82101
onFocus();
@@ -93,6 +112,112 @@ export const ParamPositivePrompt = memo(() => {
93112
dependencies: [focus],
94113
});
95114

115+
// Helper: check if prompt textarea is focused
116+
const isPromptFocused = useCallback(() => document.activeElement === textareaRef.current, []);
117+
118+
// Compute a starting working history and ensure current prompt is bumped into history
119+
const startBrowsing = useCallback(() => {
120+
if (browsingIndexRef.current !== null) return;
121+
preBrowsePromptRef.current = prompt ?? '';
122+
const trimmedCurrent = (prompt ?? '').trim();
123+
if (trimmedCurrent) {
124+
dispatch(positivePromptAddedToHistory(trimmedCurrent));
125+
}
126+
browsingIndexRef.current = 0;
127+
}, [dispatch, prompt]);
128+
129+
const applyHistoryAtIndex = useCallback(
130+
(idx: number, placeCaretAt: 'start' | 'end') => {
131+
const list = history;
132+
if (list.length === 0) return;
133+
const clamped = Math.max(0, Math.min(idx, list.length - 1));
134+
browsingIndexRef.current = clamped;
135+
dispatch(positivePromptChanged(list[clamped]));
136+
requestAnimationFrame(() => {
137+
const el = textareaRef.current;
138+
if (!el) return;
139+
if (placeCaretAt === 'start') {
140+
el.selectionStart = 0;
141+
el.selectionEnd = 0;
142+
} else {
143+
const end = el.value.length;
144+
el.selectionStart = end;
145+
el.selectionEnd = end;
146+
}
147+
});
148+
},
149+
[dispatch, history]
150+
);
151+
152+
const browsePrev = useCallback(() => {
153+
if (!isPromptFocused()) return;
154+
if (history.length === 0) return;
155+
if (browsingIndexRef.current === null) {
156+
startBrowsing();
157+
// Move to older entry on first activation
158+
if (history.length > 1) {
159+
applyHistoryAtIndex(1, 'start');
160+
} else {
161+
applyHistoryAtIndex(0, 'start');
162+
}
163+
return;
164+
}
165+
// Already browsing, go older if possible
166+
const next = Math.min((browsingIndexRef.current ?? 0) + 1, history.length - 1);
167+
applyHistoryAtIndex(next, 'start');
168+
}, [applyHistoryAtIndex, history.length, isPromptFocused, startBrowsing]);
169+
170+
const browseNext = useCallback(() => {
171+
if (!isPromptFocused()) return;
172+
if (history.length === 0) return;
173+
if (browsingIndexRef.current === null) {
174+
// Not browsing; Down does nothing (matches shell semantics)
175+
return;
176+
}
177+
if ((browsingIndexRef.current ?? 0) > 0) {
178+
const next = (browsingIndexRef.current ?? 0) - 1;
179+
applyHistoryAtIndex(next, 'end');
180+
} else {
181+
// Exit browsing and restore pre-browse prompt
182+
browsingIndexRef.current = null;
183+
dispatch(positivePromptChanged(preBrowsePromptRef.current));
184+
requestAnimationFrame(() => {
185+
const el = textareaRef.current;
186+
if (el) {
187+
const end = el.value.length;
188+
el.selectionStart = end;
189+
el.selectionEnd = end;
190+
}
191+
});
192+
}
193+
}, [applyHistoryAtIndex, dispatch, history.length, isPromptFocused]);
194+
195+
// Register hotkeys for browsing
196+
useRegisteredHotkeys({
197+
id: 'promptHistoryPrev',
198+
category: 'app',
199+
callback: (e) => {
200+
if (isPromptFocused()) {
201+
e.preventDefault();
202+
browsePrev();
203+
}
204+
},
205+
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
206+
dependencies: [browsePrev, isPromptFocused],
207+
});
208+
useRegisteredHotkeys({
209+
id: 'promptHistoryNext',
210+
category: 'app',
211+
callback: (e) => {
212+
if (isPromptFocused()) {
213+
e.preventDefault();
214+
browseNext();
215+
}
216+
},
217+
options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] },
218+
dependencies: [browseNext, isPromptFocused],
219+
});
220+
96221
const dndTargetData = useMemo(() => promptGenerationFromImageDndTarget.getData(), []);
97222

98223
return (

invokeai/frontend/web/src/features/parameters/components/Core/PositivePromptHistory.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,25 +96,32 @@ const PromptHistoryContent = memo(() => {
9696
</Button>
9797
</Flex>
9898
<Divider />
99-
{positivePromptHistory.length === 0 && (
100-
<Flex w="full" h="full" alignItems="center" justifyContent="center">
101-
<Text color="base.300">No prompt history recorded.</Text>
102-
</Flex>
103-
)}
104-
{positivePromptHistory.length !== 0 && filteredPrompts.length === 0 && (
105-
<Flex w="full" h="full" alignItems="center" justifyContent="center">
106-
<Text color="base.300">No matching prompts in history.</Text>{' '}
107-
</Flex>
108-
)}
109-
{filteredPrompts.length > 0 && (
110-
<ScrollableContent>
111-
<Flex flexDir="column">
112-
{filteredPrompts.map((prompt, index) => (
113-
<PromptItem key={`${prompt}-${index}`} prompt={prompt} />
114-
))}
99+
<Flex flexDir="column" flexGrow={1} minH={0}>
100+
{positivePromptHistory.length === 0 && (
101+
<Flex w="full" h="full" alignItems="center" justifyContent="center">
102+
<Text color="base.300">No prompt history recorded.</Text>
115103
</Flex>
116-
</ScrollableContent>
117-
)}
104+
)}
105+
{positivePromptHistory.length !== 0 && filteredPrompts.length === 0 && (
106+
<Flex w="full" h="full" alignItems="center" justifyContent="center">
107+
<Text color="base.300">No matching prompts in history.</Text>{' '}
108+
</Flex>
109+
)}
110+
{filteredPrompts.length > 0 && (
111+
<ScrollableContent>
112+
<Flex flexDir="column">
113+
{filteredPrompts.map((prompt, index) => (
114+
<PromptItem key={`${prompt}-${index}`} prompt={prompt} />
115+
))}
116+
</Flex>
117+
</ScrollableContent>
118+
)}
119+
</Flex>
120+
<Flex alignItems="center" justifyContent="center" pt={1}>
121+
<Text fontSize="xs" color="base.400" textAlign="center">
122+
<Text as="span" fontWeight="semibold">Alt + Up/Down</Text> to switch between prompts.
123+
</Text>
124+
</Flex>
118125
</Flex>
119126
);
120127
});

invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export const useHotkeyData = (): HotkeysData => {
8181
addHotkey('app', 'selectGenerateTab', ['1']);
8282
addHotkey('app', 'selectCanvasTab', ['2']);
8383
addHotkey('app', 'selectUpscalingTab', ['3']);
84+
// Prompt/history navigation (when prompt textarea is focused)
85+
addHotkey('app', 'promptHistoryPrev', ['alt+up']);
86+
addHotkey('app', 'promptHistoryNext', ['alt+down']);
87+
8488
if (isVideoEnabled) {
8589
addHotkey('app', 'selectVideoTab', ['4']);
8690
addHotkey('app', 'selectWorkflowsTab', ['5']);

0 commit comments

Comments
 (0)