diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 7212eb698..27b564864 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -53,6 +53,7 @@ import { isEditableElement, } from "@/browser/utils/ui/keybinds"; import { ModelSelector, type ModelSelectorRef } from "../ModelSelector"; +import { useChatPromptHistory } from "@/browser/hooks/useChatPromptHistory"; import { useModelLRU } from "@/browser/hooks/useModelLRU"; import { SendHorizontal, X } from "lucide-react"; import { VimTextArea } from "../VimTextArea"; @@ -155,6 +156,10 @@ export const ChatInput: React.FC = (props) => { const handleToastDismiss = useCallback(() => { setToast(null); }, []); + + const { addPrompt: addChatPrompt, getBestCompletion: getChatPromptCompletion } = + useChatPromptHistory(); + const [cursorAtEnd, setCursorAtEnd] = useState(true); const inputRef = useRef(null); const modelSelectorRef = useRef(null); @@ -168,6 +173,27 @@ export const ChatInput: React.FC = (props) => { (): DraftState => ({ text: input, images: imageAttachments }), [input, imageAttachments] ); + + const updateCursorAtEnd = useCallback(() => { + const el = inputRef.current; + if (!el) { + return; + } + setCursorAtEnd(el.selectionStart === el.value.length && el.selectionEnd === el.value.length); + }, []); + + const handleTextChange = useCallback( + (next: string) => { + setInput(next); + // Cursor position changes don't always correspond to input changes. + // We update our cursor-at-end heuristic opportunistically here. + const el = inputRef.current; + if (el) { + setCursorAtEnd(el.selectionStart === next.length && el.selectionEnd === next.length); + } + }, + [setInput] + ); const setDraft = useCallback( (draft: DraftState) => { setInput(draft.text); @@ -651,6 +677,7 @@ export const ChatInput: React.FC = (props) => { creationImageParts.length > 0 ? creationImageParts : undefined ); if (ok) { + addChatPrompt(messageText); setInput(""); setImageAttachments([]); // Height is managed by VimTextArea's useLayoutEffect - clear inline style @@ -1166,6 +1193,7 @@ export const ChatInput: React.FC = (props) => { // Restore draft on error so user can try again setDraft(preSendDraft); } else { + addChatPrompt(messageText); // Track telemetry for successful message send telemetry.messageSent( props.workspaceId, @@ -1304,7 +1332,29 @@ export const ChatInput: React.FC = (props) => { } }; - // Build placeholder text based on current state + // Derived input hints (autocomplete + placeholder) + + const completionSuggestion = useMemo(() => { + if (voiceInput.state !== "idle") { + return null; + } + if (showCommandSuggestions) { + return null; + } + + const prefix = input.trimStart(); + if (prefix.length < 2) { + return null; + } + if (prefix.startsWith("/")) { + return null; + } + if (!cursorAtEnd) { + return null; + } + + return getChatPromptCompletion(prefix); + }, [input, cursorAtEnd, showCommandSuggestions, voiceInput.state, getChatPromptCompletion]); const placeholder = (() => { // Creation variant has simple placeholder if (variant === "creation") { @@ -1478,7 +1528,19 @@ export const ChatInput: React.FC = (props) => { value={input} isEditing={!!editingMessage} mode={mode} - onChange={setInput} + completion={completionSuggestion} + onAcceptCompletion={(fullText) => { + setInput(fullText); + setCursorAtEnd(true); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(fullText.length, fullText.length); + }, 0); + }} + onSelect={updateCursorAtEnd} + onKeyUp={updateCursorAtEnd} + onMouseUp={updateCursorAtEnd} + onChange={handleTextChange} onKeyDown={handleKeyDown} onPaste={handlePaste} onDragOver={handleDragOver} @@ -1513,6 +1575,15 @@ export const ChatInput: React.FC = (props) => { )} + {completionSuggestion && ( +
+ Tab to complete: {completionSuggestion} +
+ )} + {/* Image attachments */} diff --git a/src/browser/components/VimTextArea.tsx b/src/browser/components/VimTextArea.tsx index 63f7467e5..e03ccb0e2 100644 --- a/src/browser/components/VimTextArea.tsx +++ b/src/browser/components/VimTextArea.tsx @@ -34,6 +34,12 @@ export interface VimTextAreaProps extends Omit< isEditing?: boolean; suppressKeys?: string[]; // keys for which Vim should not interfere (e.g. ["Tab","ArrowUp","ArrowDown","Escape"]) when popovers are open trailingAction?: React.ReactNode; + /** + * Optional full-text completion suggestion. + * When present, Tab can accept it (unless suppressed via suppressKeys). + */ + completion?: string | null; + onAcceptCompletion?: (fullText: string) => void; /** Called when Escape is pressed in normal mode (vim) - useful for cancel edit */ onEscapeInNormalMode?: () => void; } @@ -47,6 +53,8 @@ export const VimTextArea = React.forwardRef { + test("normalizePromptText collapses whitespace and trims but preserves casing", () => { + expect(normalizePromptText(" Rebase on main ")).toBe("Rebase on main"); + }); + + test("normalizePromptKey lowercases and collapses whitespace", () => { + expect(normalizePromptKey(" Rebase on main ")).toBe("rebase on main"); + }); + + test("shouldStorePrompt rejects empty, slash commands, multi-line, and long text", () => { + expect(shouldStorePrompt(" ")).toBe(false); + expect(shouldStorePrompt("/model gpt-4")).toBe(false); + expect(shouldStorePrompt("hello\nworld")).toBe(false); + expect(shouldStorePrompt("x".repeat(1000))).toBe(false); + expect(shouldStorePrompt("rebase on main")).toBe(true); + }); + + test("scorePrompt prefers recency, but frequency can win", () => { + const now = Date.now(); + + const recentLowFreq: StoredPrompt = { + text: "rebase on main", + key: "rebase on main", + useCount: 1, + lastUsedAt: now - 1 * 60 * 60 * 1000, + }; + + const staleHighFreq: StoredPrompt = { + text: "revert this", + key: "revert this", + useCount: 5, + lastUsedAt: now - 100 * 60 * 60 * 1000, + }; + + // Tuned so recency should edge out in this case. + expect(scorePrompt(recentLowFreq, now)).toBeGreaterThan(scorePrompt(staleHighFreq, now)); + + const somewhatStaleVeryHighFreq: StoredPrompt = { + text: "rebase on main", + key: "rebase on main", + useCount: 100, + lastUsedAt: now - 10 * 60 * 60 * 1000, + }; + + expect(scorePrompt(somewhatStaleVeryHighFreq, now)).toBeGreaterThan( + scorePrompt(recentLowFreq, now) + ); + }); + + test("getBestCompletionFromPrompts matches prefix case-insensitively and excludes exact match", () => { + const now = 1_700_000_000_000; + + const prompts: StoredPrompt[] = [ + { text: "rebase on main", key: "rebase on main", useCount: 3, lastUsedAt: now - 1000 }, + { + text: "reset --hard", + key: "reset --hard", + useCount: 10, + lastUsedAt: now - 10 * 60 * 60 * 1000, + }, + ]; + + expect(getBestCompletionFromPrompts(prompts, "re", now)).toBe("rebase on main"); + expect(getBestCompletionFromPrompts(prompts, "RE", now)).toBe("rebase on main"); + expect(getBestCompletionFromPrompts(prompts, "rebase on main", now)).toBe(null); + }); +}); diff --git a/src/browser/hooks/useChatPromptHistory.ts b/src/browser/hooks/useChatPromptHistory.ts new file mode 100644 index 000000000..1cad3547c --- /dev/null +++ b/src/browser/hooks/useChatPromptHistory.ts @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { usePersistedState } from "./usePersistedState"; +import { CHAT_PROMPT_HISTORY_KEY } from "@/common/constants/storage"; + +export interface StoredPrompt { + text: string; + /** Normalized key used for dedupe */ + key: string; + /** Number of times the prompt has been used */ + useCount: number; + /** Epoch ms */ + lastUsedAt: number; +} + +const MAX_PROMPTS = 100; +const MAX_TEXT_LENGTH = 400; + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, " "); +} + +export function normalizePromptKey(text: string): string { + return collapseWhitespace(text).trim().toLowerCase(); +} + +export function normalizePromptText(text: string): string { + // Keep original casing but normalize whitespace + trim. + return collapseWhitespace(text).trim(); +} + +export function shouldStorePrompt(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + if (trimmed.length > MAX_TEXT_LENGTH) return false; + // Slash commands have their own suggestion UX. + if (trimmed.startsWith("/")) return false; + // MVP: avoid storing multi-line prompts (typically unique and noisy). + if (trimmed.includes("\n")) return false; + return true; +} + +export function scorePrompt(prompt: StoredPrompt, nowMs: number): number { + const ageHours = Math.max(0, (nowMs - prompt.lastUsedAt) / (1000 * 60 * 60)); + const recencyScore = 1 / (1 + ageHours); + const freqScore = Math.log(1 + prompt.useCount); + return 0.7 * recencyScore + 0.3 * freqScore; +} + +export function getBestCompletionFromPrompts( + prompts: StoredPrompt[], + rawPrefix: string, + nowMs: number +): string | null { + const prefix = rawPrefix.trimStart(); + if (!prefix) return null; + + const prefixLower = prefix.toLowerCase(); + + const candidates = prompts + .filter((p) => { + const candidateLower = p.text.toLowerCase(); + return candidateLower.startsWith(prefixLower) && candidateLower !== prefixLower; + }) + .map((p) => ({ + prompt: p, + score: scorePrompt(p, nowMs), + })) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.prompt.lastUsedAt !== a.prompt.lastUsedAt) + return b.prompt.lastUsedAt - a.prompt.lastUsedAt; + if (b.prompt.useCount !== a.prompt.useCount) return b.prompt.useCount - a.prompt.useCount; + return a.prompt.text.length - b.prompt.text.length; + }); + + return candidates[0]?.prompt.text ?? null; +} + +/** + * Persisted prompt history for non-AI chat autocomplete. + * + * Similar spirit to `useModelLRU`, but stores text prompts and scores them by + * a blend of recency + frequency. + */ +export function useChatPromptHistory() { + const [prompts, setPrompts] = usePersistedState(CHAT_PROMPT_HISTORY_KEY, [], { + listener: true, + }); + + const addPrompt = useCallback( + (rawText: string) => { + if (!shouldStorePrompt(rawText)) { + return; + } + + const text = normalizePromptText(rawText); + const key = normalizePromptKey(text); + if (!key) return; + + const nowMs = Date.now(); + + setPrompts((prev) => { + const prevSafe = Array.isArray(prev) ? prev : []; + + const without = prevSafe.filter((p) => p && typeof p.key === "string" && p.key !== key); + const existing = prevSafe.find((p) => p && typeof p.key === "string" && p.key === key); + + const updated: StoredPrompt = { + text, + key, + useCount: (existing?.useCount ?? 0) + 1, + lastUsedAt: nowMs, + }; + + const merged = [updated, ...without].filter(Boolean); + + // Bound size by evicting lowest-score prompts first. + const sortedByScore = merged + .map((p) => ({ p, score: scorePrompt(p, nowMs) })) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return b.p.lastUsedAt - a.p.lastUsedAt; + }) + .map(({ p }) => p); + + return sortedByScore.slice(0, MAX_PROMPTS); + }); + }, + [setPrompts] + ); + + const getBestCompletion = useCallback( + (rawPrefix: string) => { + return getBestCompletionFromPrompts(prompts ?? [], rawPrefix, Date.now()); + }, + [prompts] + ); + + const clear = useCallback(() => { + setPrompts([]); + }, [setPrompts]); + + return useMemo( + () => ({ + prompts, + addPrompt, + getBestCompletion, + clear, + }), + [prompts, addPrompt, getBestCompletion, clear] + ); +} diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index beb43ba65..f79967606 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -157,6 +157,12 @@ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel"; */ export const VIM_ENABLED_KEY = "vimEnabled"; +/** + * Local prompt history for non-AI chat autocomplete (global) + * Format: "chatPromptHistory:v1" + */ +export const CHAT_PROMPT_HISTORY_KEY = "chatPromptHistory:v1"; + /** * Git status indicator display mode (global) * Stores: "line-delta" | "divergence"