diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index ad30e953f..e08fd62f5 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -14,8 +14,10 @@ import { import { resetSessionSystemPromptSettings } from "@/system-prompts"; import { ChainType } from "@/chainFactory"; import { useProjectContextStatus } from "@/hooks/useProjectContextStatus"; -import { logInfo, logError } from "@/logger"; +import { useVimNavigation } from "@/hooks/useVimNavigation"; +import { logError, logInfo } from "@/logger"; import type { WebTabContext } from "@/types/message"; +import { ChatMessage } from "@/types/message"; import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls"; import ChatInput from "@/components/chat-components/ChatInput"; @@ -45,7 +47,6 @@ import { useIsPlusUser } from "@/plusUtils"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { ChatUIState } from "@/state/ChatUIState"; import { FileParserManager } from "@/tools/FileParserManager"; -import { ChatMessage } from "@/types/message"; import { err2String, isPlusChain } from "@/utils"; import { arrayBufferToBase64 } from "@/utils/base64"; import { Notice, TFile } from "obsidian"; @@ -142,6 +143,21 @@ const ChatInternal: React.FC void; @@ -651,6 +667,15 @@ const ChatInternal: React.FC { const handleChatVisibility = () => { + // Only check for Vim navigation mode when it's enabled + if (settings.vimNavigation.enabled) { + // Don't steal focus if user is in Vim navigation mode (focus on messages area) + const activeElement = document.activeElement; + const messagesContainer = messagesRef.current; + if (messagesContainer && activeElement && messagesContainer.contains(activeElement)) { + return; + } + } chatInput.focusInput(); }; eventTarget?.addEventListener(EVENT_NAMES.CHAT_IS_VISIBLE, handleChatVisibility); @@ -659,7 +684,7 @@ const ChatInternal: React.FC { eventTarget?.removeEventListener(EVENT_NAMES.CHAT_IS_VISIBLE, handleChatVisibility); }; - }, [eventTarget, chatInput]); + }, [eventTarget, chatInput, messagesRef, settings.vimNavigation.enabled]); const handleDelete = useCallback( async (messageIndex: number) => { @@ -854,6 +879,11 @@ const ChatInternal: React.FC {shouldShowProgressCard() ? (
@@ -932,6 +962,8 @@ const ChatInternal: React.FC { setIndexingCardVisible(true); }} + vimNavigationEnabled={settings.vimNavigation.enabled} + focusMessages={focusMessages} /> )} diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index 7d3d7fe41..32e7c38e7 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -64,6 +64,9 @@ interface ChatInputProps { onRemoveSelectedText?: (id: string) => void; showProgressCard: () => void; showIndexingCard?: () => void; + focusMessages?: () => void; + /** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */ + vimNavigationEnabled?: boolean; // Edit mode props editMode?: boolean; @@ -105,6 +108,8 @@ const ChatInput: React.FC = ({ onRemoveSelectedText, showProgressCard, showIndexingCard, + focusMessages, + vimNavigationEnabled = false, editMode = false, onEditSave, onEditCancel, @@ -791,6 +796,8 @@ const ChatInput: React.FC = ({ isCopilotPlus={isCopilotPlus} currentActiveFile={currentActiveNote} currentChain={currentChain} + vimNavigationEnabled={vimNavigationEnabled} + focusMessages={focusMessages} />
diff --git a/src/components/chat-components/ChatMessages.tsx b/src/components/chat-components/ChatMessages.tsx index e45c20fe1..b849f3f46 100644 --- a/src/components/chat-components/ChatMessages.tsx +++ b/src/components/chat-components/ChatMessages.tsx @@ -6,7 +6,7 @@ import { useChatScrolling } from "@/hooks/useChatScrolling"; import { useSettingsValue } from "@/settings/model"; import { ChatMessage } from "@/types/message"; import { App } from "obsidian"; -import React, { memo, useEffect, useState } from "react"; +import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; interface ChatMessagesProps { chatHistory: ChatMessage[]; @@ -21,6 +21,12 @@ interface ChatMessagesProps { onDelete: (messageIndex: number) => void; onReplaceChat: (prompt: string) => void; showHelperComponents: boolean; + messagesRef?: React.MutableRefObject; + /** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */ + vimNavigationEnabled?: boolean; + onKeyDown?: React.KeyboardEventHandler; + onBlur?: React.FocusEventHandler; + onClick?: React.MouseEventHandler; } const ChatMessages = memo( @@ -36,6 +42,11 @@ const ChatMessages = memo( onDelete, onReplaceChat, showHelperComponents = true, + messagesRef, + vimNavigationEnabled = false, + onKeyDown, + onBlur, + onClick, }: ChatMessagesProps) => { const [loadingDots, setLoadingDots] = useState(""); @@ -46,6 +57,17 @@ const ChatMessages = memo( chatHistory, }); + // Combine scroll container ref with external messagesRef for vim navigation + const combinedScrollContainerRef = useCallback( + (node: HTMLDivElement | null) => { + scrollContainerCallbackRef(node); + if (messagesRef) { + messagesRef.current = node; + } + }, + [scrollContainerCallbackRef, messagesRef] + ); + useEffect(() => { let intervalId: NodeJS.Timeout; if (loading) { @@ -58,9 +80,29 @@ const ChatMessages = memo( return () => clearInterval(intervalId); }, [loading]); - if (!chatHistory.filter((message) => message.isVisible).length && !currentAiMessage) { + // Find last visible message index with single reverse scan (O(n) with early exit) + const { lastVisibleMessageIndex, hasVisibleMessages } = useMemo(() => { + for (let i = chatHistory.length - 1; i >= 0; i--) { + if (chatHistory[i].isVisible) { + return { lastVisibleMessageIndex: i, hasVisibleMessages: true }; + } + } + return { lastVisibleMessageIndex: -1, hasVisibleMessages: false }; + }, [chatHistory]); + + if (!hasVisibleMessages && !currentAiMessage) { return ( -
+
{showHelperComponents && settings.showRelevantNotes && ( )} @@ -81,13 +123,18 @@ const ChatMessages = memo( )}
{chatHistory.map((message, index) => { - const visibleMessages = chatHistory.filter((m) => m.isVisible); - const isLastMessage = index === visibleMessages.length - 1; + const isLastMessage = index === lastVisibleMessageIndex; // Only apply min-height to AI messages that are last const shouldApplyMinHeight = isLastMessage && message.sender !== USER_SENDER; diff --git a/src/components/chat-components/LexicalEditor.tsx b/src/components/chat-components/LexicalEditor.tsx index 4f6a14f6f..2b3b8797b 100644 --- a/src/components/chat-components/LexicalEditor.tsx +++ b/src/components/chat-components/LexicalEditor.tsx @@ -21,6 +21,7 @@ import { WebTabPillNode } from "./pills/WebTabPillNode"; import { ActiveWebTabPillNode } from "./pills/ActiveWebTabPillNode"; import { PillDeletionPlugin } from "./plugins/PillDeletionPlugin"; import { KeyboardPlugin } from "./plugins/KeyboardPlugin"; +import { VimEscapePlugin } from "./plugins/VimEscapePlugin"; import { ValueSyncPlugin } from "./plugins/ValueSyncPlugin"; import { FocusPlugin } from "./plugins/FocusPlugin"; import { NotePillSyncPlugin } from "./plugins/NotePillSyncPlugin"; @@ -65,6 +66,9 @@ interface LexicalEditorProps { isCopilotPlus?: boolean; currentActiveFile?: TFile | null; currentChain?: ChainType; + focusMessages?: () => void; + /** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */ + vimNavigationEnabled?: boolean; } const LexicalEditor: React.FC = ({ @@ -94,6 +98,8 @@ const LexicalEditor: React.FC = ({ isCopilotPlus = false, currentActiveFile = null, currentChain, + focusMessages, + vimNavigationEnabled = false, }) => { const [focusFn, setFocusFn] = React.useState<(() => void) | null>(null); const [editorInstance, setEditorInstance] = React.useState(null); @@ -182,6 +188,9 @@ const LexicalEditor: React.FC = ({ + {focusMessages && ( + + )} diff --git a/src/components/chat-components/plugins/VimEscapePlugin.tsx b/src/components/chat-components/plugins/VimEscapePlugin.tsx new file mode 100644 index 000000000..83b50a03d --- /dev/null +++ b/src/components/chat-components/plugins/VimEscapePlugin.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { COMMAND_PRIORITY_LOW, KEY_ESCAPE_COMMAND } from "lexical"; + +/** + * Props for the VimEscapePlugin component. + */ +export interface VimEscapePluginProps { + enabled: boolean; + focusMessages: () => void; +} + +/** + * Lexical plugin that maps Escape (in the input editor) to focusing the messages area. + * Uses COMMAND_PRIORITY_LOW so other plugins (typeahead, menus, etc.) can handle Escape first. + */ +export function VimEscapePlugin({ enabled, focusMessages }: VimEscapePluginProps) { + const [editor] = useLexicalComposerContext(); + + React.useEffect(() => { + // Skip command registration when Vim navigation is disabled + if (!enabled) { + return; + } + + return editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event: KeyboardEvent) => { + // Ignore Escape during IME composition (CJK input, etc.) + if (event.isComposing) { + return false; + } + + // Only preventDefault, not stopPropagation, to allow document-level Escape handlers + // (e.g., edit mode cancel) to still receive the event if needed. + event.preventDefault(); + + // Blur the editor first, then focus messages area. + // This prevents Lexical from reclaiming focus after we switch. + editor.blur(); + focusMessages(); + return true; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor, enabled, focusMessages]); + + return null; +} diff --git a/src/constants.ts b/src/constants.ts index eaed95c7a..936b47a54 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,30 @@ import { v4 as uuidv4 } from "uuid"; import { ChainType } from "./chainFactory"; import { PromptSortStrategy } from "./types"; +/** + * Settings for Vim-style keyboard navigation in the chat UI. + */ +export interface VimNavigationSettings { + /** Enable/disable Vim navigation */ + enabled: boolean; + /** Key used to scroll up in the messages area */ + scrollUpKey: string; + /** Key used to scroll down in the messages area */ + scrollDownKey: string; + /** Key used to focus the input from the messages area */ + focusInputKey: string; +} + +/** + * Default Vim navigation settings. + */ +export const DEFAULT_VIM_NAVIGATION: VimNavigationSettings = { + enabled: false, + scrollUpKey: "k", + scrollDownKey: "j", + focusInputKey: "i", +}; + export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1"; export const BREVILABS_MODELS_BASE_URL = "https://models.brevilabs.com/v1"; export const CHAT_VIEWTYPE = "copilot-chat-view"; @@ -985,6 +1009,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = { defaultSystemPromptTitle: "", autoCompactThreshold: 128000, convertedDocOutputFolder: DEFAULT_CONVERTED_DOC_OUTPUT_FOLDER, + vimNavigation: DEFAULT_VIM_NAVIGATION, }; export const EVENT_NAMES = { diff --git a/src/hooks/useVimNavigation.ts b/src/hooks/useVimNavigation.ts new file mode 100644 index 000000000..d28c1a03b --- /dev/null +++ b/src/hooks/useVimNavigation.ts @@ -0,0 +1,480 @@ +import React, { useCallback, useEffect, useRef } from "react"; + +/** + * Configuration for Vim-style navigation in the chat messages area. + */ +export interface VimNavigationConfig { + enabled: boolean; + scrollUpKey: string; // 'k' + scrollDownKey: string; // 'j' + focusInputKey: string; // 'i' + scrollSpeed?: number; // px per second, default 960 + focusInput: () => void; +} + +/** + * Default scroll speed in pixels per second. + * 960px/s provides smooth scrolling across all refresh rates. + */ +const DEFAULT_SCROLL_SPEED_PX_PER_SECOND = 960; + +/** + * Maximum scroll speed in pixels per second to prevent excessive scrolling. + */ +const MAX_SCROLL_SPEED_PX_PER_SECOND = 10_000; + +/** + * Validates and clamps scroll speed to a safe, finite range. + */ +function sanitizeScrollSpeedPxPerSecond(scrollSpeed?: number): number { + if (typeof scrollSpeed !== "number" || !Number.isFinite(scrollSpeed) || scrollSpeed <= 0) { + return DEFAULT_SCROLL_SPEED_PX_PER_SECOND; + } + + return Math.min(scrollSpeed, MAX_SCROLL_SPEED_PX_PER_SECOND); +} + +/** + * Return type for the useVimNavigation hook. + */ +export interface VimNavigationReturn { + messagesRef: React.MutableRefObject; + focusMessages: () => void; + handleMessagesKeyDown: React.KeyboardEventHandler; + handleMessagesBlur: React.FocusEventHandler; + handleMessagesClick: React.MouseEventHandler; +} + +type ScrollDirection = "up" | "down" | null; + +interface NormalizedVimKeys { + scrollUp: string; + scrollDown: string; + focusInput: string; +} + +interface StopScrollingOptions { + clearPressedKeys?: boolean; +} + +/** + * Normalizes a key string for case-insensitive comparisons. + * Only single-character keys are supported by the parser/sanitizer. + */ +function normalizeKey(key: string): string { + return (key ?? "").trim().toLowerCase(); +} + +/** + * Normalizes Vim navigation keys for case-insensitive matching. + * Key uniqueness is validated by the settings parser, so no deduplication here. + */ +function normalizeVimKeys( + scrollUpKey: string, + scrollDownKey: string, + focusInputKey: string +): NormalizedVimKeys { + return { + scrollUp: normalizeKey(scrollUpKey), + scrollDown: normalizeKey(scrollDownKey), + focusInput: normalizeKey(focusInputKey), + }; +} + +/** + * Checks whether a keyboard event has any modifier keys pressed (Ctrl/Meta/Alt). + * Note: Shift is intentionally excluded to allow case-insensitive key matching. + */ +function hasModifierKey(event: { + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; +}): boolean { + return event.ctrlKey || event.metaKey || event.altKey; +} + +/** + * CSS selector for interactive elements that should not trigger container focus on click. + * Uses closest() to properly handle nested elements (e.g., button > svg > path). + */ +const INTERACTIVE_SELECTOR = [ + "a", + "button", + "input", + "textarea", + "select", + "details", + "summary", + '[tabindex]:not([tabindex="-1"])', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + "[contenteditable=true]", +].join(","); + +/** + * Hook implementing Vim-style navigation for the chat messages area: + * - Hold j/k to continuously scroll (RAF loop) + * - Press i to focus the input + * - Global keyup stops scrolling + */ +export function useVimNavigation(config: VimNavigationConfig): VimNavigationReturn { + const messagesRef = useRef(null); + + const isUnmountedRef = useRef(false); + const rafIdRef = useRef(null); + const lastRafTimestampRef = useRef(null); + const directionRef = useRef(null); + const pressedScrollKeysRef = useRef>(new Set()); + + // Store config values in refs to avoid stale closures + const enabledRef = useRef(config.enabled); + const keysRef = useRef( + normalizeVimKeys(config.scrollUpKey, config.scrollDownKey, config.focusInputKey) + ); + const scrollSpeedRef = useRef(sanitizeScrollSpeedPxPerSecond(config.scrollSpeed)); + const focusInputRef = useRef<() => void>(config.focusInput); + + /** + * Stops the active scroll loop and optionally clears pressed-key state. + */ + const stopScrolling = useCallback((options?: StopScrollingOptions) => { + directionRef.current = null; + lastRafTimestampRef.current = null; + + if (options?.clearPressedKeys) { + pressedScrollKeysRef.current.clear(); + } + + if (rafIdRef.current !== null) { + window.cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }, []); + + const scrollLoopRef = useRef<(timestamp: number) => void>(() => {}); + + /** + * Ensures there is a single RAF scroll loop running (deduped). + */ + const ensureScrollLoop = useCallback(() => { + if (rafIdRef.current !== null) { + return; + } + rafIdRef.current = window.requestAnimationFrame((timestamp) => scrollLoopRef.current(timestamp)); + }, []); + + // The actual scroll loop implementation with time-based scrolling + scrollLoopRef.current = (timestamp: number) => { + rafIdRef.current = null; + + if (isUnmountedRef.current || !enabledRef.current) { + stopScrolling({ clearPressedKeys: true }); + return; + } + + const direction = directionRef.current; + if (!direction) { + return; + } + + const container = messagesRef.current; + if (!container || !container.isConnected) { + stopScrolling({ clearPressedKeys: true }); + return; + } + + const lastTimestamp = lastRafTimestampRef.current; + lastRafTimestampRef.current = timestamp; + + // First frame: use a small default delta to start scrolling immediately + // This avoids the perceived delay when first pressing the scroll key + const deltaTimeMs = lastTimestamp === null ? 16 : timestamp - lastTimestamp; + + // Guard against invalid deltaTime (e.g., negative or zero) + if (deltaTimeMs <= 0) { + rafIdRef.current = window.requestAnimationFrame((t) => scrollLoopRef.current(t)); + return; + } + + // Clamp deltaTime to prevent large jumps after long frame stalls (tab switch, GC, etc.) + const clampedDeltaTimeMs = Math.min(deltaTimeMs, 100); + + // Calculate scroll delta based on time elapsed (px/second) + const speedPxPerSecond = scrollSpeedRef.current; + // Guard against invalid speed (should not happen with sanitizeScrollSpeedPxPerSecond, but be safe) + if (!Number.isFinite(speedPxPerSecond) || speedPxPerSecond <= 0) { + stopScrolling({ clearPressedKeys: true }); + return; + } + const delta = (direction === "up" ? -1 : 1) * speedPxPerSecond * (clampedDeltaTimeMs / 1000); + + const previousScrollTop = container.scrollTop; + container.scrollTop = previousScrollTop + delta; + + // Check if we've reached the scroll boundary + if (container.scrollTop === previousScrollTop) { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const isAtBoundary = + (direction === "up" && previousScrollTop <= 0) || + (direction === "down" && previousScrollTop >= maxScrollTop); + + if (isAtBoundary) { + stopScrolling(); + return; + } + } + + if (isUnmountedRef.current || !enabledRef.current || !directionRef.current) { + return; + } + + rafIdRef.current = window.requestAnimationFrame((t) => scrollLoopRef.current(t)); + }; + + /** + * Focuses the messages container (for Escape-from-input behavior). + */ + const focusMessages = useCallback(() => { + const container = messagesRef.current; + if (!container || !container.isConnected) { + return; + } + + try { + container.focus({ preventScroll: true }); + } catch { + container.focus(); + } + }, []); + + /** + * Keydown handler for the messages container. + */ + const handleMessagesKeyDown: React.KeyboardEventHandler = useCallback( + (event) => { + if (!enabledRef.current) { + return; + } + + if (event.defaultPrevented) { + return; + } + + // Check isComposing on the native event (IME input) + if (event.nativeEvent.isComposing) { + return; + } + + if (hasModifierKey(event)) { + return; + } + + // Only handle events when the container itself has focus (not child elements) + if (event.target !== event.currentTarget) { + return; + } + + const { scrollUp, scrollDown, focusInput } = keysRef.current; + const key = normalizeKey(event.key); + + if (key === focusInput) { + event.preventDefault(); + event.stopPropagation(); + stopScrolling({ clearPressedKeys: true }); + focusInputRef.current(); + return; + } + + if (key === scrollUp || key === scrollDown) { + event.preventDefault(); + event.stopPropagation(); + + pressedScrollKeysRef.current.add(key); + directionRef.current = key === scrollUp ? "up" : "down"; + + ensureScrollLoop(); + } + }, + [ensureScrollLoop, stopScrolling] + ); + + /** + * Blur handler for the messages container. + * Stops scrolling when focus leaves the container (e.g., mouse click elsewhere). + */ + const handleMessagesBlur: React.FocusEventHandler = useCallback(() => { + stopScrolling({ clearPressedKeys: true }); + }, [stopScrolling]); + + /** + * Click handler for the messages container. + * Focuses the container when clicking on non-interactive elements, + * enabling Vim navigation without requiring Escape from input first. + */ + const handleMessagesClick: React.MouseEventHandler = useCallback( + (event) => { + if (!enabledRef.current) { + return; + } + + // Don't steal focus when user is selecting text (e.g., copy workflow) + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + return; + } + + // Don't interfere with clicks on interactive elements. + // Note: We must exclude the container itself (event.currentTarget) from this check + // because the container has tabindex=0 for keyboard navigation, which would match + // the [tabindex] selector and cause all clicks to be incorrectly ignored. + // Use Element (not HTMLElement) to also handle SVGElement clicks (e.g., button > svg > path). + if (event.target instanceof Element) { + const interactiveAncestor = event.target.closest(INTERACTIVE_SELECTOR); + if (interactiveAncestor && interactiveAncestor !== event.currentTarget) { + return; + } + } + + // Use the unified focusMessages function to maintain consistent behavior + focusMessages(); + }, + [focusMessages] + ); + + // Cleanup on unmount + useEffect(() => { + isUnmountedRef.current = false; + return () => { + isUnmountedRef.current = true; + stopScrolling({ clearPressedKeys: true }); + }; + }, [stopScrolling]); + + // Update refs when config changes + useEffect(() => { + // Detect if scroll keys changed - if so, stop scrolling to avoid keyup mismatch + const previousKeys = keysRef.current; + const nextKeys = normalizeVimKeys( + config.scrollUpKey, + config.scrollDownKey, + config.focusInputKey + ); + const scrollKeysChanged = + previousKeys.scrollUp !== nextKeys.scrollUp || previousKeys.scrollDown !== nextKeys.scrollDown; + + enabledRef.current = config.enabled; + keysRef.current = nextKeys; + scrollSpeedRef.current = sanitizeScrollSpeedPxPerSecond(config.scrollSpeed); + focusInputRef.current = config.focusInput; + + // Stop scrolling if disabled or if scroll keys changed (prevents keyup mismatch) + if (!config.enabled || scrollKeysChanged) { + stopScrolling({ clearPressedKeys: true }); + } + }, [ + config.enabled, + config.scrollUpKey, + config.scrollDownKey, + config.focusInputKey, + config.scrollSpeed, + config.focusInput, + stopScrolling, + ]); + + // Global keyup listener to stop scrolling when key is released + useEffect(() => { + if (!config.enabled) { + return; + } + + if (typeof document === "undefined") { + return; + } + + /** + * Global keyup handler that stops the RAF scroll loop when the scroll key is released. + */ + const handleKeyUp = (event: KeyboardEvent) => { + // Fast path: skip processing if no scroll keys are currently pressed + // This avoids unnecessary key normalization when user is typing elsewhere + if (pressedScrollKeysRef.current.size === 0) { + return; + } + + const { scrollUp, scrollDown } = keysRef.current; + const releasedKey = normalizeKey(event.key); + + if (releasedKey !== scrollUp && releasedKey !== scrollDown) { + return; + } + + pressedScrollKeysRef.current.delete(releasedKey); + + // If another scroll key is still pressed, continue in that direction + if (pressedScrollKeysRef.current.has(scrollUp)) { + directionRef.current = "up"; + ensureScrollLoop(); + return; + } + + if (pressedScrollKeysRef.current.has(scrollDown)) { + directionRef.current = "down"; + ensureScrollLoop(); + return; + } + + stopScrolling({ clearPressedKeys: true }); + }; + + // Use capture phase to ensure we receive keyup even if other handlers stop propagation + document.addEventListener("keyup", handleKeyUp, true); + + return () => { + document.removeEventListener("keyup", handleKeyUp, true); + }; + }, [config.enabled, ensureScrollLoop, stopScrolling]); + + // Stop scrolling if the app/window loses focus or becomes hidden + useEffect(() => { + if (!config.enabled) { + return; + } + + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + /** + * Stops scrolling when the window loses focus. + */ + const handleWindowBlur = () => { + stopScrolling({ clearPressedKeys: true }); + }; + + /** + * Stops scrolling when the document becomes hidden (e.g., app minimized or switched away). + */ + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible") { + stopScrolling({ clearPressedKeys: true }); + } + }; + + window.addEventListener("blur", handleWindowBlur); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.removeEventListener("blur", handleWindowBlur); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [config.enabled, stopScrolling]); + + return { + messagesRef, + focusMessages, + handleMessagesKeyDown, + handleMessagesBlur, + handleMessagesClick, + }; +} diff --git a/src/settings/model.ts b/src/settings/model.ts index 15a6531cf..9259b2fca 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -12,8 +12,10 @@ import { DEFAULT_OPEN_AREA, DEFAULT_QA_EXCLUSIONS_SETTING, DEFAULT_SETTINGS, + DEFAULT_VIM_NAVIGATION, EmbeddingModelProviders, SEND_SHORTCUT, + type VimNavigationSettings, } from "@/constants"; /** @@ -197,6 +199,8 @@ export interface CopilotSettings { autoCompactThreshold: number; /** Folder where converted document markdown files are saved */ convertedDocOutputFolder: string; + /** Vim-style keyboard navigation settings for the chat UI */ + vimNavigation: VimNavigationSettings; } export const settingsStore = createStore(); @@ -234,6 +238,48 @@ export function setSettings(settings: Partial) { * @param rawValue - Persisted QA exclusion setting value. * @returns Encoded QA exclusion patterns string. */ +/** + * Validates and sanitizes VimNavigationSettings. + * Ensures keys are single characters, non-empty, and unique (case-insensitive). + * Falls back to DEFAULT_VIM_NAVIGATION for any invalid configuration. + */ +function sanitizeVimNavigation(raw: unknown): VimNavigationSettings { + if (!raw || typeof raw !== "object") { + return { ...DEFAULT_VIM_NAVIGATION }; + } + + const input = raw as Partial; + const enabled = typeof input.enabled === "boolean" ? input.enabled : DEFAULT_VIM_NAVIGATION.enabled; + + const rawKeys = [input.scrollUpKey, input.scrollDownKey, input.focusInputKey]; + + // Validate and trim each key to a single character + const trimmedKeys: string[] = []; + for (const key of rawKeys) { + if (typeof key !== "string") { + return { ...DEFAULT_VIM_NAVIGATION, enabled }; + } + const trimmed = key.trim(); + if (trimmed.length !== 1) { + return { ...DEFAULT_VIM_NAVIGATION, enabled }; + } + trimmedKeys.push(trimmed); + } + + // Validate uniqueness (case-insensitive) on trimmed values + const normalized = new Set(trimmedKeys.map((k) => k.toLowerCase())); + if (normalized.size !== trimmedKeys.length) { + return { ...DEFAULT_VIM_NAVIGATION, enabled }; + } + + return { + enabled, + scrollUpKey: trimmedKeys[0], + scrollDownKey: trimmedKeys[1], + focusInputKey: trimmedKeys[2], + }; +} + export function sanitizeQaExclusions(rawValue: unknown): string { const rawValueString = typeof rawValue === "string" ? rawValue : DEFAULT_QA_EXCLUSIONS_SETTING; @@ -571,6 +617,8 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings { sanitizedSettings.qaExclusions = sanitizeQaExclusions(settingsToSanitize.qaExclusions); + sanitizedSettings.vimNavigation = sanitizeVimNavigation(settingsToSanitize.vimNavigation); + return sanitizedSettings; } diff --git a/src/settings/v2/components/AdvancedSettings.tsx b/src/settings/v2/components/AdvancedSettings.tsx index b238bb630..94ae086ce 100644 --- a/src/settings/v2/components/AdvancedSettings.tsx +++ b/src/settings/v2/components/AdvancedSettings.tsx @@ -3,8 +3,8 @@ import { SettingItem } from "@/components/ui/setting-item"; import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select"; import { logFileManager } from "@/logFileManager"; import { flushRecordedPromptPayloadToLog } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder"; -import { updateSetting, useSettingsValue } from "@/settings/model"; import { ArrowUpRight, Plus } from "lucide-react"; +import { updateSetting, useSettingsValue } from "@/settings/model"; import React from "react"; import { getPromptFilePath, SystemPromptAddModal } from "@/system-prompts"; import { useSystemPrompts } from "@/system-prompts/state"; diff --git a/src/settings/v2/components/BasicSettings.tsx b/src/settings/v2/components/BasicSettings.tsx index 627c8e4ed..80893854b 100644 --- a/src/settings/v2/components/BasicSettings.tsx +++ b/src/settings/v2/components/BasicSettings.tsx @@ -4,7 +4,8 @@ import { HelpTooltip } from "@/components/ui/help-tooltip"; import { Input } from "@/components/ui/input"; import { getModelDisplayWithIcons } from "@/components/ui/model-display"; import { SettingItem } from "@/components/ui/setting-item"; -import { DEFAULT_OPEN_AREA, PLUS_UTM_MEDIUMS, SEND_SHORTCUT } from "@/constants"; +import { Textarea } from "@/components/ui/textarea"; +import { DEFAULT_OPEN_AREA, DEFAULT_VIM_NAVIGATION, PLUS_UTM_MEDIUMS, SEND_SHORTCUT } from "@/constants"; import { useTab } from "@/contexts/TabContext"; import { cn } from "@/lib/utils"; import { createPlusPageUrl } from "@/plusUtils"; @@ -12,9 +13,10 @@ import { getModelKeyFromModel, updateSetting, useSettingsValue } from "@/setting import { PlusSettings } from "@/settings/v2/components/PlusSettings"; import { checkModelApiKey, formatDateTime } from "@/utils"; import { isSortStrategy } from "@/utils/recentUsageManager"; +import { buildNavMappingText, parseNavMappings } from "@/utils/vimKeyboardNavigation"; import { Key, Loader2 } from "lucide-react"; import { Notice } from "obsidian"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ApiKeyDialog } from "./ApiKeyDialog"; const ChainType2Label: Record = { @@ -24,6 +26,86 @@ const ChainType2Label: Record = { [ChainType.PROJECT_CHAIN]: "Projects (alpha)", }; +/** + * Vim key mappings textarea component. + * Displays when Vim navigation is enabled. + */ +const VimKeyMappings: React.FC = () => { + const settings = useSettingsValue(); + const [mappingText, setMappingText] = useState(() => buildNavMappingText(settings.vimNavigation)); + const [error, setError] = useState(null); + + // Sync mappingText when settings change externally + const { scrollUpKey, scrollDownKey, focusInputKey, enabled } = settings.vimNavigation; + useEffect(() => { + setMappingText(buildNavMappingText({ scrollUpKey, scrollDownKey, focusInputKey, enabled })); + }, [scrollUpKey, scrollDownKey, focusInputKey, enabled]); + + /** + * Validates and persists on every change. + * Saves immediately when valid to prevent data loss if settings view closes. + */ + const handleMappingChange = (value: string) => { + setMappingText(value); + + const result = parseNavMappings(value); + if (result.error) { + setError(result.error); + return; + } + + setError(null); + updateSetting("vimNavigation", { + ...settings.vimNavigation, + scrollUpKey: result.settings!.scrollUp, + scrollDownKey: result.settings!.scrollDown, + focusInputKey: result.settings!.focusInput, + }); + }; + + const handleReset = () => { + const defaultText = buildNavMappingText(DEFAULT_VIM_NAVIGATION); + setMappingText(defaultText); + setError(null); + updateSetting("vimNavigation", { + ...settings.vimNavigation, + scrollUpKey: DEFAULT_VIM_NAVIGATION.scrollUpKey, + scrollDownKey: DEFAULT_VIM_NAVIGATION.scrollDownKey, + focusInputKey: DEFAULT_VIM_NAVIGATION.focusInputKey, + }); + }; + + return ( +
+ +
Format: map <key> <action> (one per line)
+
+ Actions: scrollUp, scrollDown, focusInput +
+
+ } + > +
+