From 108152cb02db6cd49dc7814e2109402d12235328 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 7 Oct 2025 14:05:28 +0200 Subject: [PATCH] feat: add global focus shortcut for chat input Introduce global `a` and `i` keybinds that focus the chat input when no editable element is active. Move focus to the textarea with caret at the end and resize height to match content. Update escape handling to blur the input when interrupting or editing does not consume the shortcut, and document the new shortcuts in the keybinds reference. --- docs/keybinds.md | 1 + src/components/ChatInput.tsx | 55 +++++++++++++++++++++++++++++++----- src/utils/ui/keybinds.ts | 6 ++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/keybinds.md b/docs/keybinds.md index 8991fed6b0..49bb5c52a9 100644 --- a/docs/keybinds.md +++ b/docs/keybinds.md @@ -24,6 +24,7 @@ When documentation shows `Ctrl`, it means: | Action | Shortcut | | ---------------------- | ------------- | +| Focus chat input | `a` or `i` | | Send message | `Enter` | | New line in message | `Shift+Enter` | | Jump to bottom of chat | `Shift+G` | diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index d97fe97572..5d585b30cf 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -18,7 +18,7 @@ import { type SlashSuggestion, } from "@/utils/slashCommands/suggestions"; import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; -import { matchesKeybind, formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds"; import { defaultModel } from "@/utils/ai/models"; import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; import { useModelLRU } from "@/hooks/useModelLRU"; @@ -332,6 +332,47 @@ export const ChatInput: React.FC = ({ const [mode, setMode] = useMode(); const { recentModels } = useModelLRU(); + const focusMessageInput = useCallback(() => { + const element = inputRef.current; + if (!element || element.disabled) { + return; + } + + element.focus(); + + requestAnimationFrame(() => { + const cursor = element.value.length; + element.selectionStart = cursor; + element.selectionEnd = cursor; + element.style.height = "auto"; + element.style.height = Math.min(element.scrollHeight, 200) + "px"; + }); + }, []); + + useEffect(() => { + const handleGlobalKeyDown = (event: KeyboardEvent) => { + if (isEditableElement(event.target)) { + return; + } + + if (matchesKeybind(event, KEYBINDS.FOCUS_INPUT_I)) { + event.preventDefault(); + focusMessageInput(); + return; + } + + if (matchesKeybind(event, KEYBINDS.FOCUS_INPUT_A)) { + event.preventDefault(); + focusMessageInput(); + } + }; + + window.addEventListener("keydown", handleGlobalKeyDown); + return () => { + window.removeEventListener("keydown", handleGlobalKeyDown); + }; + }, [focusMessageInput]); + // When entering editing mode, populate input with message content useEffect(() => { if (editingMessage) { @@ -593,19 +634,19 @@ export const ChatInput: React.FC = ({ // Handle cancel/escape if (matchesKeybind(e, KEYBINDS.CANCEL)) { + const isFocused = document.activeElement === inputRef.current; e.preventDefault(); // Priority 1: Cancel editing if in edit mode if (editingMessage && onCancelEdit) { onCancelEdit(); - return; + } else if (canInterrupt) { + // Priority 2: Interrupt streaming if active + void window.api.workspace.sendMessage(workspaceId, ""); } - // Priority 2: Interrupt streaming if active - if (canInterrupt) { - // Send empty message to trigger interrupt - void window.api.workspace.sendMessage(workspaceId, ""); - return; + if (isFocused) { + inputRef.current?.blur(); } return; diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index 62ffa020c6..05ba056b09 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -124,6 +124,12 @@ export const KEYBINDS = { /** Cancel current action / Close modal / Interrupt streaming */ CANCEL: { key: "Escape" }, + /** Focus chat input */ + FOCUS_INPUT_I: { key: "i" }, + + /** Focus chat input (alternate) */ + FOCUS_INPUT_A: { key: "a" }, + /** Create new workspace for current project */ NEW_WORKSPACE: { key: "n", ctrl: true },