diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 7d169431ce..a69c8f7adf 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -155,6 +155,10 @@ export const stories: Record = { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") return }, + "chat-text-area-history": async () => { + const { ChatTextAreaHistoryStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") + return + }, "history-empty": async () => { const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8ba0df7ab2..ecfd2c9e8a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -16,6 +16,7 @@ import { ContextMenuOptionType, getContextMenuOptions, insertMention, + isHistoryTrigger, removeMention, shouldShowContextMenu, SearchResult, @@ -251,14 +252,29 @@ export const ChatTextArea = forwardRef( 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(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) { + 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, + }, + ) // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { @@ -346,6 +362,27 @@ export const ChatTextArea = forwardRef( return } + if (type === ContextMenuOptionType.History && value) { + // 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) + setSelectedType(null) + setSelectedMenuIndex(-1) + setSearchQuery("") + setInputValue(value) + setCursorPosition(value.length) + setIntendedCursorPosition(value.length) + resetHistoryNavigation() + + setTimeout(() => { + textAreaRef.current?.focus() + }, 0) + return + } + if (type === ContextMenuOptionType.Mode && value) { // Handle mode selection. setMode(value) @@ -439,10 +476,20 @@ export const ChatTextArea = forwardRef( const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + const isComposing = event.nativeEvent?.isComposing ?? false + 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) return } @@ -457,6 +504,7 @@ export const ChatTextArea = forwardRef( fileSearchResults, allModes, commands, + promptHistory, ) const optionsLength = options.length @@ -486,8 +534,17 @@ export const ChatTextArea = forwardRef( }) 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, @@ -495,6 +552,7 @@ export const ChatTextArea = forwardRef( fileSearchResults, allModes, commands, + promptHistory, )[selectedMenuIndex] if ( selectedOption && @@ -502,14 +560,21 @@ export const ChatTextArea = forwardRef( selectedOption.type !== ContextMenuOptionType.NoResults && selectedOption.type !== ContextMenuOptionType.SectionHeader ) { + event.preventDefault() 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 } } - const isComposing = event.nativeEvent?.isComposing ?? false - // Handle prompt history navigation using custom hook if (handleHistoryNavigation(event, showContextMenu, isComposing)) { return @@ -596,6 +661,7 @@ export const ChatTextArea = forwardRef( handleHistoryNavigation, resetHistoryNavigation, commands, + promptHistory, enterBehavior, ], ) @@ -622,10 +688,31 @@ export const ChatTextArea = forwardRef( 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) { + 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) + } else if (newValue.startsWith("/") && !newValue.includes(" ")) { // Handle slash command - request fresh commands const query = newValue setSearchQuery(query) @@ -675,7 +762,14 @@ export const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + promptHistory, + ], ) useEffect(() => { @@ -955,7 +1049,8 @@ export const ChatTextArea = forwardRef( } }) - 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 @@ export const ChatTextArea = forwardRef( loading={searchLoading} dynamicSearchResults={fileSearchResults} commands={commands} + history={promptHistory} /> )} @@ -1099,8 +1195,16 @@ export const ChatTextArea = forwardRef( }} 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 diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 04ceb2eab6..9ac5796937 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -30,6 +30,7 @@ interface ContextMenuProps { loading?: boolean dynamicSearchResults?: SearchResult[] commands?: Command[] + history?: string[] } const ContextMenu: React.FC = ({ @@ -43,13 +44,22 @@ const ContextMenu: React.FC = ({ modes, dynamicSearchResults = [], commands = [], + history = [], }) => { const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") const menuRef = useRef(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]) useEffect(() => { if (menuRef.current) { @@ -138,6 +148,17 @@ const ContextMenu: React.FC = ({ )} ) + 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 ( + + {option.value?.replace(/\s+/g, " ").trim()} + + ) case ContextMenuOptionType.Problems: return {t("chat:contextMenu.problems")} case ContextMenuOptionType.Terminal: @@ -230,6 +251,8 @@ const ContextMenu: React.FC = ({ return "link" case ContextMenuOptionType.Git: return "git-commit" + case ContextMenuOptionType.History: + return "history" case ContextMenuOptionType.NoResults: return "info" default: @@ -267,6 +290,7 @@ const ContextMenu: React.FC = ({ return (
{ expect(setInputValue).toHaveBeenCalledWith("New input") }) + it("should open the prompt history picker when typing a leading !", () => { + const setInputValue = vi.fn() + const onSend = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + + // Newest prompt first. + expect(container.textContent).toContain("Third prompt") + expect(container.querySelector('[data-testid="context-menu"]')).not.toBeNull() + + fireEvent.keyDown(textarea, { key: "Enter" }) + + // Selecting only fills the input; it must not send the message. + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + expect(onSend).not.toHaveBeenCalled() + }) + + it("should close the prompt history picker on Escape without sending", () => { + const onSend = vi.fn() + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + expect(container.textContent).toContain("Third prompt") + + fireEvent.keyDown(textarea, { key: "Escape" }) + + expect(container.textContent).not.toContain("Third prompt") + expect(onSend).not.toHaveBeenCalled() + }) + + it("should not open the prompt history picker for a mid-message exclamation mark", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "Great!" } }) + + expect(container.textContent).not.toContain("Third prompt") + }) + + it("should close the picker before cancelling an edit on Escape", () => { + const onCancel = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + expect(container.textContent).toContain("Third prompt") + + // First press closes the picker and keeps the edit (and its draft). + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(onCancel).not.toHaveBeenCalled() + expect(container.textContent).not.toContain("Third prompt") + + // Second press, with no picker open, cancels the edit. + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it("should still send on Enter when the history query matches nothing", () => { + const onSend = vi.fn() + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!zzz", selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: "Enter" }) + + // "No results" is not selectable, so Enter must not be swallowed. + expect(onSend).toHaveBeenCalledTimes(1) + expect(setInputValue).not.toHaveBeenCalledWith("Third prompt") + }) + + it("should send on Ctrl+Enter instead of selecting a picker row", () => { + const onSend = vi.fn() + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + expect(container.textContent).toContain("Third prompt") + + fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true }) + + expect(setInputValue).not.toHaveBeenCalledWith("Third prompt") + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it("should not select a picker row on Shift+Enter", () => { + const onSend = vi.fn() + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }) + + // Shift+Enter is a newline, so it neither selects nor sends. + expect(setInputValue).not.toHaveBeenCalledWith("Third prompt") + expect(onSend).not.toHaveBeenCalled() + }) + + it("should not select a picker row on a composing Enter", () => { + const onSend = vi.fn() + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: "Enter", isComposing: true }) + + expect(setInputValue).not.toHaveBeenCalledWith("Third prompt") + expect(onSend).not.toHaveBeenCalled() + }) + + it("should reopen the picker after the controlled value is reset", () => { + const { container, rerender } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + expect(container.textContent).toContain("Third prompt") + + // Dismiss the picker with the value still in the box. + rerender() + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(container.textContent).not.toContain("Third prompt") + + // The parent clears the value without an input event (e.g. after send). + rerender() + + // Typing "!" again must reopen the picker, not stay suppressed. + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + expect(container.textContent).toContain("Third prompt") + }) + + it("should keep the picker open on a composing Escape", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + expect(container.textContent).toContain("Third prompt") + + // Escape belongs to the IME candidate window while composing. + fireEvent.keyDown(textarea, { key: "Escape", isComposing: true }) + expect(container.textContent).toContain("Third prompt") + + // A plain Escape still dismisses the picker. + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(container.textContent).not.toContain("Third prompt") + }) + + it("should select the active row on Tab", () => { + const setInputValue = vi.fn() + const onSend = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: "Tab" }) + + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + expect(onSend).not.toHaveBeenCalled() + }) + + it("should move the active row with ArrowDown before selecting", () => { + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + + // The picker starts on the newest prompt; one ArrowDown moves to + // the next entry, so Enter commits "Second prompt". + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + fireEvent.keyDown(textarea, { key: "Enter" }) + + expect(setInputValue).toHaveBeenCalledWith("Second prompt") + }) + + it("should keep the picker closed and let Enter send when history is empty", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + taskHistory: [], + clineMessages: [], + cwd: "/test/workspace", + }) + + const onSend = vi.fn() + const setInputValue = vi.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + + // The fixture has no prompts, so asserting on prompt text would pass + // even if the picker opened on a "No results" row. Assert the picker + // itself is absent. + expect(container.querySelector('[data-testid="context-menu"]')).toBeNull() + + fireEvent.keyDown(textarea, { key: "Enter" }) + + expect(onSend).toHaveBeenCalledTimes(1) + expect(setInputValue).not.toHaveBeenCalledWith("Third prompt") + }) + + it("should open the picker with a full-width exclamation mark", () => { + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + + expect(container.textContent).toContain("Third prompt") + }) + + it("should place the cursor at the end of the recalled prompt", () => { + const onSend = vi.fn() + + const ControlledArea = () => { + const [value, setValue] = useState("") + + return ( + + ) + } + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } }) + fireEvent.keyDown(textarea, { key: "Enter" }) + + expect(textarea.value).toBe("Third prompt") + expect(textarea.selectionStart).toBe("Third prompt".length) + expect(onSend).not.toHaveBeenCalled() + }) + + it("should not reopen the picker when typing after a recalled trigger-shaped prompt", () => { + // A prompt that is itself shaped like the "!" trigger: typing more + // after recalling it would reopen the picker without the dismissal + // guard, because the new value still matches the trigger pattern. + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + taskHistory: [], + clineMessages: [{ type: "say", say: "user_feedback", text: "!dpl", ts: 1 }], + cwd: "/test/workspace", + }) + + const ControlledArea = () => { + const [value, setValue] = useState("") + + return + } + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } }) + expect(container.querySelector('[title="!dpl"]')).not.toBeNull() + + fireEvent.keyDown(textarea, { key: "Enter" }) + expect(textarea.value).toBe("!dpl") + expect(container.querySelector('[title="!dpl"]')).toBeNull() + + fireEvent.change(textarea, { target: { value: "!dplo", selectionStart: 5 } }) + expect(container.querySelector('[title="!dpl"]')).toBeNull() + }) + it("should reset history navigation when sending message", () => { const onSend = vi.fn() const setInputValue = vi.fn() diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx index 7585881f14..480ee58362 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" import { defaultModeSlug, type Mode } from "@roo/modes" +import type { ClineMessage } from "@roo-code/types" import { AppProviders } from "../../../../playwright/AppProviders" import { ChatTextArea } from "../ChatTextArea" @@ -33,3 +34,41 @@ export function ChatTextAreaStory() { ) } + +// Prompt history is derived from the current task's user messages, so the +// picker needs a non-empty conversation. The extra top padding keeps the +// popover, which opens upward from the composer, inside the screenshot bounds. +const historyMessages: ClineMessage[] = [ + { ts: 1, type: "say", say: "user_feedback", text: "Add a retry guard to the upload test" }, + { ts: 2, type: "say", say: "user_feedback", text: "Explain the diff for the parser change" }, +] + +export function ChatTextAreaHistoryStory() { + const [inputValue, setInputValue] = useState("") + const [selectedImages, setSelectedImages] = useState([]) + const [mode, setMode] = useState(defaultModeSlug) + + return ( + +
+ undefined} + onSelectImages={() => undefined} + shouldDisableImages={false} + mode={mode} + setMode={setMode} + modeShortcutText="Ctrl+. for next mode" + /> +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index 69ad3e675c..55efb4885f 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -22,4 +22,22 @@ for (const theme of visualThemes) { focusedControl: editor, }) }) + + test(`renders the prompt history picker in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("chat-text-area-history")) + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after gallery tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const story = component.getByTestId("chat-text-area-story") + const editor = story.getByRole("textbox") + await expect(editor).toBeVisible() + + // A leading "!" opens the picker. The newest prompt is listed first. + await editor.click() + await editor.pressSequentially("!") + await expect(story.getByText("Explain the diff for the parser change")).toBeVisible() + await expect(story.getByText("Add a retry guard to the upload test")).toBeVisible() + + await expect(story).toHaveScreenshot(`chat-composer-history-${theme.name}.png`) + }) } diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-dark.png new file mode 100644 index 0000000000..8b273d71b9 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast-light.png new file mode 100644 index 0000000000..110a0788b4 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast.png new file mode 100644 index 0000000000..3330e4413c Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-light.png new file mode 100644 index 0000000000..c57d5c28af Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-composer-history-light.png differ diff --git a/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.tsx b/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.tsx new file mode 100644 index 0000000000..cce5b15999 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.tsx @@ -0,0 +1,60 @@ +import { renderHook } from "@testing-library/react" + +import type { ClineMessage, HistoryItem } from "@roo-code/types" + +import { usePromptHistory } from "../usePromptHistory" + +const userFeedback = (text: string, ts: number): ClineMessage => ({ + type: "say", + say: "user_feedback", + text, + ts, +}) + +const sayText = (text: string, ts: number): ClineMessage => ({ type: "say", say: "text", text, ts }) + +// Keep these references stable across renders. The hook memoizes on them, so a +// fresh array per render would re-run the sync effect on every pass. +const taskHistory: HistoryItem[] = [] +const setInputValue = () => undefined + +const renderPromptHistory = (clineMessages: ClineMessage[]) => + renderHook(() => + usePromptHistory({ + clineMessages, + taskHistory, + cwd: "/test/workspace", + inputValue: "", + setInputValue, + }), + ) + +describe("usePromptHistory", () => { + it("includes the initial task prompt as the oldest entry", () => { + const { result } = renderPromptHistory([sayText("Initial task", 1), userFeedback("Follow up", 2)]) + + expect(result.current.promptHistory).toEqual(["Follow up", "Initial task"]) + }) + + it("returns the task prompt when the task has no user feedback yet", () => { + const { result } = renderPromptHistory([sayText("Initial task", 1)]) + + expect(result.current.promptHistory).toEqual(["Initial task"]) + }) + + it("ignores assistant text messages after the first entry", () => { + const { result } = renderPromptHistory([ + sayText("Initial task", 1), + sayText("Assistant reply", 2), + userFeedback("Follow up", 3), + ]) + + expect(result.current.promptHistory).toEqual(["Follow up", "Initial task"]) + }) + + it("does not look past the first entry for an image-only task", () => { + const { result } = renderPromptHistory([sayText("", 1), sayText("Assistant reply", 2)]) + + expect(result.current.promptHistory).toEqual([]) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index 402538182a..ee94231763 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -46,9 +46,21 @@ export const usePromptHistory = ({ ?.filter((message) => message.type === "say" && message.say === "user_feedback" && message.text?.trim()) .map((message) => message.text!) + // The task prompt lives in the first message as `say: "text"`; ChatView + // skips that entry when it renders the message rows. Include it only at + // index 0: later `say: "text"` messages are assistant output, and an + // image-only task has no text to recall. + const firstMessage = clineMessages?.[0] + const initialPrompt = + firstMessage?.type === "say" && firstMessage.say === "text" && firstMessage.text?.trim() + ? firstMessage.text + : undefined + + const allPrompts = initialPrompt ? [initialPrompt, ...(conversationPrompts ?? [])] : conversationPrompts + // If we have conversation messages, use those (newest first when navigating up) - if (conversationPrompts?.length) { - return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() + if (allPrompts?.length) { + return allPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() } // If we have clineMessages array (meaning we're in an active task), don't fall back to task history diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 24418c4317..5a9cacc031 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Escriu un missatge...", "typeTask": "Escriu la teva tasca aquí...", "addContext": "@ per afegir context, / per a comandes", + "historyHint": "! per a l'historial d'usuari", "dragFiles": "manté premut shift per arrossegar fitxers", "dragFilesImages": "manté premut shift per arrossegar fitxers/imatges", "enhancePromptDescription": "El botó 'Millora la sol·licitud' ajuda a millorar la teva sol·licitud proporcionant context addicional, aclariments o reformulacions. Prova d'escriure una sol·licitud aquí i fes clic al botó de nou per veure com funciona.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 47c0b095ed..e232faa9eb 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Nachricht eingeben...", "typeTask": "Gib deine Aufgabe hier ein...", "addContext": "@ für Kontext, / für Befehle", + "historyHint": "! für den Nutzerverlauf", "dragFiles": "Shift halten, um Dateien einzufügen", "dragFilesImages": "Shift halten, um Dateien/Bilder einzufügen", "enhancePromptDescription": "Die Schaltfläche 'Prompt verbessern' hilft, deine Anfrage durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Versuche, hier eine Anfrage einzugeben und klicke erneut auf die Schaltfläche, um zu sehen, wie es funktioniert.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 615f248dea..b2a6da937e 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -160,6 +160,7 @@ "typeMessage": "Type a message...", "typeTask": "Type your task here...", "addContext": "@ to add context, / for commands", + "historyHint": "! for user history", "dragFiles": "hold shift to drag in files", "dragFilesImages": "hold shift to drag in files/images", "errorReadingFile": "Error reading file", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index b433dae00e..65f1dcf727 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Escribe un mensaje...", "typeTask": "Escribe tu tarea aquí...", "addContext": "@ para agregar contexto, / para comandos", + "historyHint": "! para el historial de usuario", "dragFiles": "mantén shift para arrastrar archivos", "dragFilesImages": "mantén shift para arrastrar archivos/imágenes", "enhancePromptDescription": "El botón 'Mejorar el mensaje' ayuda a mejorar tu petición proporcionando contexto adicional, aclaraciones o reformulaciones. Intenta escribir una petición aquí y haz clic en el botón nuevamente para ver cómo funciona.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index ec8a117900..a7d93302f4 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Écrivez un message...", "typeTask": "Écrivez votre tâche ici...", "addContext": "@ pour ajouter du contexte, / pour les commandes", + "historyHint": "! pour l'historique utilisateur", "dragFiles": "maintenir Maj pour glisser des fichiers", "dragFilesImages": "maintenir Maj pour glisser des fichiers/images", "enhancePromptDescription": "Le bouton 'Améliorer la requête' aide à améliorer votre demande en fournissant un contexte supplémentaire, des clarifications ou des reformulations. Essayez de taper une demande ici et cliquez à nouveau sur le bouton pour voir comment cela fonctionne.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 7993969783..86a3ec9803 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -124,6 +124,7 @@ "typeMessage": "एक संदेश लिखें...", "typeTask": "अपना कार्य यहां लिखें...", "addContext": "संदर्भ जोड़ने के लिए @, कमांड के लिए /", + "historyHint": "उपयोगकर्ता इतिहास के लिए !", "dragFiles": "फ़ाइलें खींचने के लिए shift दबाकर रखें", "dragFilesImages": "फ़ाइलें/चित्र खींचने के लिए shift दबाकर रखें", "enhancePromptDescription": "'प्रॉम्प्ट बढ़ाएँ' बटन अतिरिक्त संदर्भ, स्पष्टीकरण या पुनर्विचार प्रदान करके आपके अनुरोध को बेहतर बनाने में मदद करता है। यहां अनुरोध लिखकर देखें और यह कैसे काम करता है यह देखने के लिए बटन पर फिर से क्लिक करें।", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index fc02940748..556b58d876 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -163,6 +163,7 @@ "typeMessage": "Ketik pesan...", "typeTask": "Bangun, cari, tanya sesuatu", "addContext": "@ untuk menambah konteks, / untuk perintah", + "historyHint": "! untuk riwayat pengguna", "dragFiles": "tahan shift untuk drag file", "dragFilesImages": "tahan shift untuk drag file/gambar", "errorReadingFile": "Error membaca file", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 33b30ee9be..1d6e71f895 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Scrivi un messaggio...", "typeTask": "Scrivi la tua attività qui...", "addContext": "@ per aggiungere contesto, / per i comandi", + "historyHint": "! per la cronologia utente", "dragFiles": "tieni premuto shift per trascinare file", "dragFilesImages": "tieni premuto shift per trascinare file/immagini", "enhancePromptDescription": "Il pulsante 'Migliora prompt' aiuta a migliorare la tua richiesta fornendo contesto aggiuntivo, chiarimenti o riformulazioni. Prova a digitare una richiesta qui e fai di nuovo clic sul pulsante per vedere come funziona.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 067624786f..c1a2a58d9b 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -124,6 +124,7 @@ "typeMessage": "メッセージを入力...", "typeTask": "ここにタスクを入力...", "addContext": "コンテキスト追加は@、コマンドは/", + "historyHint": "ユーザー履歴は!", "dragFiles": "ファイルをドラッグするにはShiftキーを押したまま", "dragFilesImages": "ファイル/画像をドラッグするにはShiftキーを押したまま", "enhancePromptDescription": "「プロンプトを強化」ボタンは、追加コンテキスト、説明、または言い換えを提供することで、リクエストを改善します。ここにリクエストを入力し、ボタンを再度クリックして動作を確認してください。", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 9bb9173bc8..1fe388b044 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -116,6 +116,7 @@ "typeMessage": "메시지 입력...", "typeTask": "여기에 작업 입력...", "addContext": "컨텍스트 추가는 @, 명령어는 /", + "historyHint": "사용자 기록은 !", "dragFiles": "파일을 드래그하려면 shift 키 누르기", "dragFilesImages": "파일/이미지를 드래그하려면 shift 키 누르기", "enhancePromptDescription": "'프롬프트 향상' 버튼은 추가 컨텍스트, 명확화 또는 재구성을 제공하여 요청을 개선합니다. 여기에 요청을 입력한 다음 버튼을 다시 클릭하여 작동 방식을 확인해보세요.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 81f4c22916..59116c02c5 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -133,6 +133,7 @@ "typeMessage": "Typ een bericht...", "typeTask": "Typ hier je taak...", "addContext": "@ om context toe te voegen, / voor commando's", + "historyHint": "! voor gebruikersgeschiedenis", "dragFiles": "houd shift ingedrukt om bestanden te slepen", "dragFilesImages": "houd shift ingedrukt om bestanden/afbeeldingen te slepen", "errorReadingFile": "Fout bij het lezen van bestand", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 6e31abecd7..ce9f3b0f54 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Wpisz wiadomość...", "typeTask": "Wpisz swoje zadanie tutaj...", "addContext": "@ aby dodać kontekst, / dla poleceń", + "historyHint": "! dla historii użytkownika", "dragFiles": "przytrzymaj shift, aby przeciągnąć pliki", "dragFilesImages": "przytrzymaj shift, aby przeciągnąć pliki/obrazy", "enhancePromptDescription": "Przycisk 'Ulepsz podpowiedź' pomaga ulepszyć Twoją prośbę, dostarczając dodatkowy kontekst, wyjaśnienia lub przeformułowania. Spróbuj wpisać prośbę tutaj i kliknij przycisk ponownie, aby zobaczyć, jak to działa.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index d82e6a007a..42d24ccfd0 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Digite uma mensagem...", "typeTask": "Digite sua tarefa aqui...", "addContext": "@ para adicionar contexto, / para comandos", + "historyHint": "! para o histórico do usuário", "dragFiles": "segure shift para arrastar arquivos", "dragFilesImages": "segure shift para arrastar arquivos/imagens", "enhancePromptDescription": "O botão 'Aprimorar prompt' ajuda a melhorar seu pedido fornecendo contexto adicional, esclarecimentos ou reformulações. Tente digitar um pedido aqui e clique no botão novamente para ver como funciona.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 2883a64fc6..cd9e59b475 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -133,6 +133,7 @@ "typeMessage": "Введите сообщение...", "typeTask": "Введите вашу задачу здесь...", "addContext": "@ для добавления контекста, / для команд", + "historyHint": "! для истории пользователя", "dragFiles": "удерживайте shift для перетаскивания файлов", "dragFilesImages": "удерживайте shift для перетаскивания файлов/изображений", "errorReadingFile": "Ошибка чтения файла", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 3ee2a99610..92932374cb 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Bir mesaj yazın...", "typeTask": "Görevinizi buraya yazın...", "addContext": "Bağlam eklemek için @, komutlar için /", + "historyHint": "kullanıcı geçmişi için !", "dragFiles": "dosyaları sürüklemek için shift tuşuna basılı tutun", "dragFilesImages": "dosyaları/resimleri sürüklemek için shift tuşuna basılı tutun", "enhancePromptDescription": "'İstemi geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifade sağlayarak isteğinizi iyileştirmeye yardımcı olur. Buraya bir istek yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 46f244d7bb..2a67e04d6c 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -124,6 +124,7 @@ "typeMessage": "Nhập tin nhắn...", "typeTask": "Nhập nhiệm vụ của bạn tại đây...", "addContext": "@ để thêm ngữ cảnh, / cho lệnh", + "historyHint": "! cho lịch sử người dùng", "dragFiles": "giữ shift để kéo tệp", "dragFilesImages": "giữ shift để kéo tệp/hình ảnh", "enhancePromptDescription": "Nút 'Nâng cao yêu cầu' giúp cải thiện yêu cầu của bạn bằng cách cung cấp ngữ cảnh bổ sung, làm rõ hoặc diễn đạt lại. Hãy thử nhập yêu cầu tại đây và nhấp vào nút một lần nữa để xem cách thức hoạt động.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index cf9186cdb4..a3bb9021c1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -124,6 +124,7 @@ "typeMessage": "输入消息...", "typeTask": "在此处输入您的任务...", "addContext": "@添加上下文,/输入命令", + "historyHint": "!查看历史", "dragFiles": "Shift+拖拽文件", "dragFilesImages": "Shift+拖拽文件/图片", "enhancePromptDescription": "'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 63c8e2aec1..71f45622d6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -160,6 +160,7 @@ "typeMessage": "輸入訊息...", "typeTask": "在這裡輸入工作...", "addContext": "輸入 @ 新增內容,/ 執行命令", + "historyHint": "! 查看歷史", "dragFiles": "按住 Shift 鍵拖曳檔案", "dragFilesImages": "按住 Shift 鍵拖曳檔案/圖片", "errorReadingFile": "讀取檔案時發生錯誤", diff --git a/webview-ui/src/utils/__tests__/context-mentions.spec.ts b/webview-ui/src/utils/__tests__/context-mentions.spec.ts index 48b03907c3..182867e7fa 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.spec.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.spec.ts @@ -586,3 +586,80 @@ describe("shouldShowContextMenu", () => { expect(shouldShowContextMenu("@/path/with space", 13)).toBe(false) // Cursor after unescaped space }) }) + +describe("prompt history trigger", () => { + it("should open the menu for a leading !", () => { + expect(shouldShowContextMenu("!", 1)).toBe(true) + expect(shouldShowContextMenu("!fix", 4)).toBe(true) + }) + + it("should open the menu for a leading full-width !", () => { + expect(shouldShowContextMenu("!", 1)).toBe(true) + expect(shouldShowContextMenu("!修复", 3)).toBe(true) + }) + + it("should not open the menu for ! outside the start of the input", () => { + expect(shouldShowContextMenu("Great!", 6)).toBe(false) + expect(shouldShowContextMenu("if (!x)", 7)).toBe(false) + expect(shouldShowContextMenu("![alt](url)", 11)).toBe(false) + expect(shouldShowContextMenu("多好!", 3)).toBe(false) + }) + + it("should close the menu once the message contains whitespace", () => { + expect(shouldShowContextMenu("!fix the bug", 12)).toBe(false) + expect(shouldShowContextMenu("! ", 2)).toBe(false) + }) +}) + +describe("getContextMenuOptions for prompt history", () => { + const history = ["Third prompt", "Second prompt", "First prompt"] + + it("should list prompts newest-first", () => { + const options = getContextMenuOptions("!", null, [], [], undefined, undefined, history) + expect(options.map((option) => option.type)).toEqual([ + ContextMenuOptionType.History, + ContextMenuOptionType.History, + ContextMenuOptionType.History, + ]) + expect(options.map((option) => option.value)).toEqual(["Third prompt", "Second prompt", "First prompt"]) + }) + + it("should filter prompts by the query", () => { + const options = getContextMenuOptions("!Third", null, [], [], undefined, undefined, history) + expect(options).toHaveLength(1) + expect(options[0]).toMatchObject({ type: ContextMenuOptionType.History, value: "Third prompt" }) + }) + + it("should keep newest-first order when fuzzy scores rank matches differently", () => { + // fzf scores the exact "fix" (older, index 1) above the scattered + // "finally, invest in x-rays" (newer, index 0). The picker must still + // list the newer prompt first. + const options = getContextMenuOptions("!fix", null, [], [], undefined, undefined, [ + "finally, invest in x-rays", + "fix", + ]) + expect(options.map((option) => option.value)).toEqual(["finally, invest in x-rays", "fix"]) + }) + + it("should drop duplicate prompts so the picker has no identical rows", () => { + const options = getContextMenuOptions("!", null, [], [], undefined, undefined, ["继续", "explain this", "继续"]) + expect(options.map((option) => option.value)).toEqual(["继续", "explain this"]) + }) + + it("should return NoResults when the history is empty", () => { + expect(getContextMenuOptions("!", null, [], [], undefined, undefined, [])).toEqual([ + { type: ContextMenuOptionType.NoResults }, + ]) + }) + + it("should return NoResults when nothing matches the query", () => { + expect(getContextMenuOptions("!zzzz", null, [], [], undefined, undefined, ["alpha", "beta"])).toEqual([ + { type: ContextMenuOptionType.NoResults }, + ]) + }) + + it("should not treat a mid-text exclamation mark as history", () => { + const options = getContextMenuOptions("Great!", null, [], [], undefined, undefined, history) + expect(options.every((option) => option.type !== ContextMenuOptionType.History)).toBe(true) + }) +}) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 22dba8864f..6cb8798352 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -108,6 +108,25 @@ export enum ContextMenuOptionType { Mode = "mode", // Add mode type Command = "command", // Add command type SectionHeader = "sectionHeader", // Add section header type + History = "history", // Recall a previously sent user prompt +} + +/** + * "!" (and its full-width form "!") at the very start of the input opens the + * prompt history picker. It must not contain whitespace, so the menu dismisses + * itself as soon as the user starts writing a real message. + * + * This is deliberately anchored to the start of the input: "!" is a common + * character in ordinary prose and code ("Great!", "if (!x)", "![alt](url)"), + * so triggering on any occurrence would hijack Enter for those messages. + */ +export function isHistoryTrigger(text: string): boolean { + // "![alt](url)" is a markdown image at the start of a message, not a picker. + if (text.startsWith("![")) { + return false + } + + return /^[!!]\S*$/.test(text) } export interface ContextMenuQueryItem { @@ -128,7 +147,36 @@ export function getContextMenuOptions( dynamicSearchResults: SearchResult[] = [], modes?: ModeConfig[], commands?: Command[], + history: string[] = [], ): ContextMenuQueryItem[] { + // Prompt history recall. Newest-first, de-duplicated so that repeated + // prompts (e.g. "continue") don't fill the picker with identical rows. + if (isHistoryTrigger(query)) { + const historyQuery = query.slice(1) + const prompts = Array.from(new Set(history.filter((prompt) => prompt.trim().length > 0))) + + if (prompts.length === 0) { + return [{ type: ContextMenuOptionType.NoResults }] + } + + // fzf ranks matches by score, which would put an older prompt above a + // newer one. The picker promises newest-first history, so keep the + // original (input) order of the prompts that matched. + const searchable = prompts.map((prompt, index) => ({ prompt, index })) + const fzf = new Fzf(searchable, { selector: (item) => item.prompt }) + const matching = historyQuery + ? fzf + .find(historyQuery) + .map((result) => result.item) + .sort((a, b) => a.index - b.index) + .map((item) => item.prompt) + : prompts + + return matching.length > 0 + ? matching.map((prompt) => ({ type: ContextMenuOptionType.History, value: prompt })) + : [{ type: ContextMenuOptionType.NoResults }] + } + // Handle slash commands for modes and commands // Only process as slash command if the query itself starts with "/" (meaning we're typing a slash command) if (query.startsWith("/")) { @@ -367,6 +415,11 @@ export function getContextMenuOptions( export function shouldShowContextMenu(text: string, position: number): boolean { const beforeCursor = text.slice(0, position) + // Check for prompt history recall ("!" / "!" at the start of the input) + if (isHistoryTrigger(text)) { + return true + } + // Check if we're in a slash command context (at the beginning and no space yet) if (text.startsWith("/") && !text.includes(" ") && position <= text.length) { return true