diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index 3791a71175..0fbd8ef748 100644 Binary files a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png and b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png differ diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 90be332890..d7df34eb2f 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 61419487cc..ec1a25b1b4 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, @@ -231,14 +232,21 @@ 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) + // 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(() => { @@ -326,6 +334,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) @@ -421,8 +450,14 @@ export const ChatTextArea = forwardRef( (event: React.KeyboardEvent) => { if (showContextMenu) { if (event.key === "Escape") { + // 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. + suppressedHistoryRef.current = normalizedInputValue + setShowContextMenu(false) + setSearchQuery("") setSelectedType(null) - setSelectedMenuIndex(3) // File by default + setSelectedMenuIndex(-1) return } @@ -437,6 +472,7 @@ export const ChatTextArea = forwardRef( fileSearchResults, allModes, commands, + promptHistory, ) const optionsLength = options.length @@ -467,7 +503,6 @@ export const ChatTextArea = forwardRef( return } if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) { - event.preventDefault() const selectedOption = getContextMenuOptions( searchQuery, selectedType, @@ -475,6 +510,7 @@ export const ChatTextArea = forwardRef( fileSearchResults, allModes, commands, + promptHistory, )[selectedMenuIndex] if ( selectedOption && @@ -482,9 +518,14 @@ export const ChatTextArea = forwardRef( selectedOption.type !== ContextMenuOptionType.NoResults && selectedOption.type !== ContextMenuOptionType.SectionHeader ) { + event.preventDefault() handleMentionSelect(selectedOption.type, selectedOption.value) + return } - return + + // Nothing selectable (e.g. "No results"). Do not swallow the + // key: fall through so Enter still sends the message instead + // of leaving the composer unresponsive. } } @@ -576,6 +617,7 @@ export const ChatTextArea = forwardRef( handleHistoryNavigation, resetHistoryNavigation, commands, + promptHistory, enterBehavior, ], ) @@ -602,10 +644,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) @@ -655,7 +718,14 @@ export const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + promptHistory, + ], ) useEffect(() => { @@ -935,7 +1005,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( @@ -1016,6 +1087,7 @@ export const ChatTextArea = forwardRef( loading={searchLoading} dynamicSearchResults={fileSearchResults} commands={commands} + history={promptHistory} /> )} @@ -1076,8 +1148,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..019d0d6235 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,23 @@ 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 +257,8 @@ const ContextMenu: React.FC = ({ return "link" case ContextMenuOptionType.Git: return "git-commit" + case ContextMenuOptionType.History: + return "history" case ContextMenuOptionType.NoResults: return "info" default: diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 5cced6a92f..d61e0a10f5 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -634,6 +634,90 @@ describe("ChatTextArea", () => { 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") + + 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 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/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index d934d1105b..7529ee76ee 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -132,6 +132,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 1ac60a097e..847b81a6e4 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -132,6 +132,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 51aee7a438..af58fdfce6 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -168,6 +168,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 fbe66d6eb3..7ded7ac04d 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -132,6 +132,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 a8f5766c85..742a258c21 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -132,6 +132,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 c6dbe9978d..69bb565579 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -132,6 +132,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 c59be94c72..0432774197 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -171,6 +171,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 bb809a41b6..415c0afe33 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -132,6 +132,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 18d7d0a7a8..2ad8c3fd8d 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -132,6 +132,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 95971b5e31..4797f68683 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -124,6 +124,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 b8d2689327..b03ad81a97 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -141,6 +141,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 1f8271f16e..578251defb 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -132,6 +132,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 231cf54d70..51e86e92ee 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -132,6 +132,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 0bcb4176a3..1a07966463 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -141,6 +141,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 40c7d37b45..61d63b8107 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -132,6 +132,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 ef0bd5b59a..bdf942b4cb 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -132,6 +132,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 b3e8f35297..ed73f3a2d3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -132,6 +132,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 11a90a9178..e5982e7971 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -168,6 +168,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