Repository navigation
feat(chat): add prompt history picker to the chat input #2155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7c0338a
9222cbd
22aeee9
f92b896
7646282
91e5c91
e3171d7
b8e6593
1581e06
b8aafef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| ContextMenuOptionType, | ||
| getContextMenuOptions, | ||
| insertMention, | ||
| isHistoryTrigger, | ||
| removeMention, | ||
| shouldShowContextMenu, | ||
| SearchResult, | ||
|
|
@@ -251,14 +252,29 @@ | |
| const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) | ||
| const [isFocused, setIsFocused] = useState(false) | ||
|
|
||
| // Text for which the user explicitly dismissed the prompt history picker. | ||
| // Without this, recalling (or typing) a message that starts with "!" would | ||
| // pop the picker back open on the very next keystroke. | ||
| const suppressedHistoryRef = useRef<string | null>(null) | ||
|
|
||
| // A controlled reset (for example sending the message) does not go through | ||
| // handleInputChange, so clear the dismissal once the input is empty again. | ||
| useEffect(() => { | ||
| if (!normalizedInputValue) { | ||
|
Check warning on line 263 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| suppressedHistoryRef.current = null | ||
| } | ||
| }, [normalizedInputValue]) | ||
|
|
||
| // Use custom hook for prompt history navigation | ||
| const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ | ||
| clineMessages, | ||
| taskHistory, | ||
| cwd, | ||
| inputValue: normalizedInputValue, | ||
| setInputValue, | ||
| }) | ||
| const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange, promptHistory } = usePromptHistory( | ||
| { | ||
| clineMessages, | ||
| taskHistory, | ||
| cwd, | ||
| inputValue: normalizedInputValue, | ||
| setInputValue, | ||
| }, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| // Fetch git commits when Git is selected or when typing a hash. | ||
| useEffect(() => { | ||
|
|
@@ -346,6 +362,27 @@ | |
| return | ||
| } | ||
|
|
||
| if (type === ContextMenuOptionType.History && value) { | ||
|
Check warning on line 365 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| // Replace the whole input with the recalled prompt instead of | ||
| // inserting it as a mention: the "!" prefix is only a trigger | ||
| // and must not survive into the message. Deliberately does not | ||
| // send, so the user can edit the prompt before re-sending. | ||
| suppressedHistoryRef.current = value | ||
| setShowContextMenu(false) | ||
|
Check warning on line 371 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| setSelectedType(null) | ||
|
Check warning on line 372 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| setSelectedMenuIndex(-1) | ||
|
Check warning on line 373 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| setSearchQuery("") | ||
|
Check warning on line 374 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| setInputValue(value) | ||
| setCursorPosition(value.length) | ||
|
Check warning on line 376 in webview-ui/src/components/chat/ChatTextArea.tsx
|
||
| setIntendedCursorPosition(value.length) | ||
| resetHistoryNavigation() | ||
|
|
||
| setTimeout(() => { | ||
| textAreaRef.current?.focus() | ||
| }, 0) | ||
| return | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| if (type === ContextMenuOptionType.Mode && value) { | ||
| // Handle mode selection. | ||
| setMode(value) | ||
|
|
@@ -439,10 +476,20 @@ | |
|
|
||
| const handleKeyDown = useCallback( | ||
| (event: React.KeyboardEvent<HTMLTextAreaElement>) => { | ||
| const isComposing = event.nativeEvent?.isComposing ?? false | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| if (showContextMenu) { | ||
| if (event.key === "Escape") { | ||
| if (event.key === "Escape" && !isComposing) { | ||
| // Dismiss the menu outright. `!` is easy to type by | ||
| // accident, so Escape must actually close the picker | ||
| // rather than leave the arrow keys captured by it. | ||
| // While composing, Escape belongs to the IME candidate | ||
| // window and must not dismiss the picker. | ||
| suppressedHistoryRef.current = normalizedInputValue | ||
| setShowContextMenu(false) | ||
| setSearchQuery("") | ||
| setSelectedType(null) | ||
| setSelectedMenuIndex(3) // File by default | ||
| setSelectedMenuIndex(-1) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| return | ||
| } | ||
|
|
||
|
|
@@ -457,6 +504,7 @@ | |
| fileSearchResults, | ||
| allModes, | ||
| commands, | ||
| promptHistory, | ||
| ) | ||
| const optionsLength = options.length | ||
|
|
||
|
|
@@ -486,30 +534,47 @@ | |
| }) | ||
| return | ||
| } | ||
| if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { | ||
| event.preventDefault() | ||
| // Only a plain Enter/Tab commits a row. Shift+Enter, Ctrl/Cmd+Enter | ||
| // and an IME-composed Enter stay on their normal newline/send path. | ||
| const isRowSelectKey = | ||
| (event.key === "Enter" || event.key === "Tab") && | ||
| !event.shiftKey && | ||
| !event.ctrlKey && | ||
| !event.metaKey && | ||
| !event.altKey && | ||
| !isComposing | ||
|
|
||
| if (isRowSelectKey && selectedMenuIndex !== -1) { | ||
| const selectedOption = getContextMenuOptions( | ||
| searchQuery, | ||
| selectedType, | ||
| queryItems, | ||
| fileSearchResults, | ||
| allModes, | ||
| commands, | ||
| promptHistory, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could history selection require an unmodified non-composing key event?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes — fixed in |
||
| )[selectedMenuIndex] | ||
| if ( | ||
| selectedOption && | ||
| selectedOption.type !== ContextMenuOptionType.URL && | ||
| selectedOption.type !== ContextMenuOptionType.NoResults && | ||
| selectedOption.type !== ContextMenuOptionType.SectionHeader | ||
| ) { | ||
| event.preventDefault() | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this fall-through be limited to unmatched history queries?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes — fixed in |
||
| handleMentionSelect(selectedOption.type, selectedOption.value) | ||
| return | ||
| } | ||
|
|
||
| // Nothing selectable (e.g. "No results"). A history query is the | ||
| // whole message, so let Enter send it. Other menus keep their | ||
| // previous behavior and absorb the key. | ||
| if (!isHistoryTrigger(searchQuery)) { | ||
| event.preventDefault() | ||
| return | ||
| } | ||
| return | ||
| } | ||
| } | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| const isComposing = event.nativeEvent?.isComposing ?? false | ||
|
|
||
| // Handle prompt history navigation using custom hook | ||
| if (handleHistoryNavigation(event, showContextMenu, isComposing)) { | ||
| return | ||
|
|
@@ -596,6 +661,7 @@ | |
| handleHistoryNavigation, | ||
| resetHistoryNavigation, | ||
| commands, | ||
| promptHistory, | ||
| enterBehavior, | ||
| ], | ||
| ) | ||
|
|
@@ -622,10 +688,31 @@ | |
| setCursorPosition(newCursorPosition) | ||
|
|
||
| const showMenu = shouldShowContextMenu(newValue, newCursorPosition) | ||
|
|
||
| if (isHistoryTrigger(newValue)) { | ||
| const suppressed = suppressedHistoryRef.current | ||
|
|
||
| // Keep the picker closed when the user dismissed it for this text, | ||
| // or when there is nothing to recall. Without the second guard an | ||
| // empty picker would swallow Enter for a message starting with "!". | ||
| if ((suppressed !== null && newValue.startsWith(suppressed)) || promptHistory.length === 0) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could external empty-value resets clear this suppression?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes — fixed in |
||
| setShowContextMenu(false) | ||
| setSearchQuery("") | ||
| setSelectedMenuIndex(-1) | ||
| return | ||
| } | ||
| } else { | ||
| suppressedHistoryRef.current = null | ||
| } | ||
|
|
||
| setShowContextMenu(showMenu) | ||
|
|
||
| if (showMenu) { | ||
| if (newValue.startsWith("/") && !newValue.includes(" ")) { | ||
| if (isHistoryTrigger(newValue)) { | ||
| // getContextMenuOptions dispatches on the leading "!" itself. | ||
| setSearchQuery(newValue) | ||
| setSelectedMenuIndex(0) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } else if (newValue.startsWith("/") && !newValue.includes(" ")) { | ||
| // Handle slash command - request fresh commands | ||
| const query = newValue | ||
| setSearchQuery(query) | ||
|
|
@@ -675,7 +762,14 @@ | |
| setFileSearchResults([]) // Clear file search results. | ||
| } | ||
| }, | ||
| [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], | ||
| [ | ||
| setInputValue, | ||
| setSearchRequestId, | ||
| setFileSearchResults, | ||
| setSearchLoading, | ||
| resetOnInputChange, | ||
| promptHistory, | ||
| ], | ||
| ) | ||
|
|
||
| useEffect(() => { | ||
|
|
@@ -955,7 +1049,8 @@ | |
| } | ||
| }) | ||
|
|
||
| const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})` | ||
| const dragHint = shouldDisableImages ? t("chat:dragFiles") : t("chat:dragFilesImages") | ||
| const placeholderBottomText = `\n(${t("chat:addContext")}, ${t("chat:historyHint")}, ${dragHint})` | ||
|
|
||
| // Common mode selector handler | ||
| const handleModeChange = useCallback( | ||
|
|
@@ -1039,6 +1134,7 @@ | |
| loading={searchLoading} | ||
| dynamicSearchResults={fileSearchResults} | ||
| commands={commands} | ||
| history={promptHistory} | ||
| /> | ||
| </div> | ||
| )} | ||
|
|
@@ -1099,8 +1195,16 @@ | |
| }} | ||
| onFocus={() => setIsFocused(true)} | ||
| onKeyDown={(e) => { | ||
| // Handle ESC to cancel in edit mode | ||
| if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) { | ||
| // Handle ESC to cancel in edit mode. The context menu | ||
| // takes precedence: while the history picker is open, | ||
| // ESC only closes it, so cancelling the edit (which | ||
| // discards the draft) needs a second press. | ||
| if ( | ||
| isEditMode && | ||
| e.key === "Escape" && | ||
| !showContextMenu && | ||
| !e.nativeEvent?.isComposing | ||
| ) { | ||
| e.preventDefault() | ||
| onCancel?.() | ||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| loading?: boolean | ||
| dynamicSearchResults?: SearchResult[] | ||
| commands?: Command[] | ||
| history?: string[] | ||
| } | ||
|
|
||
| const ContextMenu: React.FC<ContextMenuProps> = ({ | ||
|
|
@@ -43,13 +44,22 @@ | |
| modes, | ||
| dynamicSearchResults = [], | ||
| commands = [], | ||
| history = [], | ||
|
Check warning on line 47 in webview-ui/src/components/chat/ContextMenu.tsx
|
||
| }) => { | ||
| const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") | ||
| const menuRef = useRef<HTMLDivElement>(null) | ||
|
|
||
| const filteredOptions = useMemo(() => { | ||
| return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands) | ||
| }, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands]) | ||
| return getContextMenuOptions( | ||
| searchQuery, | ||
| selectedType, | ||
| queryItems, | ||
| dynamicSearchResults, | ||
| modes, | ||
| commands, | ||
| history, | ||
| ) | ||
| }, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands, history]) | ||
|
Check warning on line 62 in webview-ui/src/components/chat/ContextMenu.tsx
|
||
|
|
||
| useEffect(() => { | ||
| if (menuRef.current) { | ||
|
|
@@ -138,6 +148,17 @@ | |
| )} | ||
| </div> | ||
| ) | ||
| case ContextMenuOptionType.History: | ||
| // Prompts can be long and multi-line; keep every row to one line so | ||
| // the picker stays scannable. The full text lands in the input on | ||
| // selection, and line breaks become visible there. | ||
| return ( | ||
| <span | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this new row use Tailwind classes?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in |
||
| title={option.value} | ||
| className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-[1.4]"> | ||
| {option.value?.replace(/\s+/g, " ").trim()} | ||
|
Check warning on line 159 in webview-ui/src/components/chat/ContextMenu.tsx
|
||
| </span> | ||
| ) | ||
| case ContextMenuOptionType.Problems: | ||
| return <span>{t("chat:contextMenu.problems")}</span> | ||
| case ContextMenuOptionType.Terminal: | ||
|
|
@@ -230,6 +251,8 @@ | |
| return "link" | ||
| case ContextMenuOptionType.Git: | ||
| return "git-commit" | ||
| case ContextMenuOptionType.History: | ||
| return "history" | ||
| case ContextMenuOptionType.NoResults: | ||
| return "info" | ||
| default: | ||
|
|
@@ -267,6 +290,7 @@ | |
|
|
||
| return ( | ||
| <div | ||
| data-testid="context-menu" | ||
| style={{ | ||
| position: "absolute", | ||
| bottom: "calc(100% - 10px)", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this picker include the initial task prompt?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, fixed in
f92b896.The initial prompt is stored as the first message with
say: "text", andChatViewskips that entry when it renders the message rows, so the pickernever saw it.
usePromptHistorynow adds it as the oldest entry, but onlywhen the first message is a nonblank
say: "text"— latersay: "text"entries are assistant output, so we do not scan for them. Image-only tasks
have no text to recall and are skipped.
Covered by tests: initial prompt included, no-feedback task, assistant text
ignored, image-only task.