feat: Add Vim-style keyboard navigation for chat

Add optional Vim-style keyboard navigation for the chat messages area:
- Hold j/k to continuously scroll messages (RAF-based smooth scrolling)
- Press i to focus the input editor
- Press Escape in input to return focus to messages
- Click non-interactive areas to focus messages container
- Configurable key mappings via settings UI (map <key> <action> format)

Includes settings validation (single-char, unique, case-insensitive keys),
accessibility labels on focusable regions, text selection preservation,
and proper cleanup of global listeners and animation frames.
This commit is contained in:
wyh 2026-03-15 17:04:59 +08:00
parent 31ad9b825c
commit 9054f00a1f
13 changed files with 1087 additions and 13 deletions

View file

@ -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<ChatProps & { chatInput: ReturnType<typeof useChatI
plugin.chatSelectionHighlightController.persistFromPointerDown();
}, [plugin]);
// Vim-style keyboard navigation (settings already sanitized with defaults)
const {
messagesRef,
focusMessages,
handleMessagesKeyDown,
handleMessagesBlur,
handleMessagesClick,
} = useVimNavigation({
enabled: settings.vimNavigation.enabled,
scrollUpKey: settings.vimNavigation.scrollUpKey,
scrollDownKey: settings.vimNavigation.scrollDownKey,
focusInputKey: settings.vimNavigation.focusInputKey,
focusInput: chatInput.focusInput,
});
// Safe setter utilities - automatically wrap state setters to prevent updates after unmount
const safeSet = useMemo<{
setCurrentAiMessage: (value: string) => void;
@ -651,6 +667,15 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
useEffect(() => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
return () => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
onDelete={handleDelete}
onReplaceChat={setInputMessage}
showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN}
messagesRef={messagesRef}
vimNavigationEnabled={settings.vimNavigation.enabled}
onKeyDown={handleMessagesKeyDown}
onBlur={handleMessagesBlur}
onClick={handleMessagesClick}
/>
{shouldShowProgressCard() ? (
<div className="tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-xl">
@ -932,6 +962,8 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
showIndexingCard={() => {
setIndexingCardVisible(true);
}}
vimNavigationEnabled={settings.vimNavigation.enabled}
focusMessages={focusMessages}
/>
</>
)}

View file

@ -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<ChatInputProps> = ({
onRemoveSelectedText,
showProgressCard,
showIndexingCard,
focusMessages,
vimNavigationEnabled = false,
editMode = false,
onEditSave,
onEditCancel,
@ -791,6 +796,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
isCopilotPlus={isCopilotPlus}
currentActiveFile={currentActiveNote}
currentChain={currentChain}
vimNavigationEnabled={vimNavigationEnabled}
focusMessages={focusMessages}
/>
</div>

View file

@ -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<HTMLDivElement | null>;
/** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */
vimNavigationEnabled?: boolean;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
onBlur?: React.FocusEventHandler<HTMLDivElement>;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
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 (
<div className="tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto">
<div
ref={messagesRef}
tabIndex={vimNavigationEnabled ? 0 : undefined}
role={vimNavigationEnabled ? "region" : undefined}
aria-label={vimNavigationEnabled ? "Chat messages" : undefined}
onKeyDown={vimNavigationEnabled ? onKeyDown : undefined}
onBlur={vimNavigationEnabled ? onBlur : undefined}
onClick={vimNavigationEnabled ? onClick : undefined}
data-testid="chat-messages"
className="copilot-messages-focusable tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto"
>
{showHelperComponents && settings.showRelevantNotes && (
<RelevantNotes defaultOpen={true} key="relevant-notes-before-chat" />
)}
@ -81,13 +123,18 @@ const ChatMessages = memo(
<RelevantNotes className="tw-mb-4" defaultOpen={false} key="relevant-notes-in-chat" />
)}
<div
ref={scrollContainerCallbackRef}
ref={combinedScrollContainerRef}
tabIndex={vimNavigationEnabled ? 0 : undefined}
role={vimNavigationEnabled ? "region" : undefined}
aria-label={vimNavigationEnabled ? "Chat messages" : undefined}
onKeyDown={vimNavigationEnabled ? onKeyDown : undefined}
onBlur={vimNavigationEnabled ? onBlur : undefined}
onClick={vimNavigationEnabled ? onClick : undefined}
data-testid="chat-messages"
className="tw-relative tw-flex tw-w-full tw-flex-1 tw-select-text tw-flex-col tw-items-start tw-justify-start tw-overflow-y-auto tw-scroll-smooth tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)]"
className="copilot-messages-focusable tw-relative tw-flex tw-w-full tw-flex-1 tw-select-text tw-flex-col tw-items-start tw-justify-start tw-overflow-y-auto tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)]"
>
{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;

View file

@ -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<LexicalEditorProps> = ({
@ -94,6 +98,8 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
isCopilotPlus = false,
currentActiveFile = null,
currentChain,
focusMessages,
vimNavigationEnabled = false,
}) => {
const [focusFn, setFocusFn] = React.useState<(() => void) | null>(null);
const [editorInstance, setEditorInstance] = React.useState<LexicalEditorType | null>(null);
@ -182,6 +188,9 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
<OnChangePlugin onChange={handleEditorChange} />
<HistoryPlugin />
<KeyboardPlugin onSubmit={onSubmit} sendShortcut={settings.defaultSendShortcut} />
{focusMessages && (
<VimEscapePlugin enabled={vimNavigationEnabled} focusMessages={focusMessages} />
)}
<ValueSyncPlugin value={value} />
<FocusPlugin onFocus={handleFocusRegistration} onEditorReady={handleEditorReady} />
<NotePillSyncPlugin onNotesChange={onNotesChange} onNotesRemoved={onNotesRemoved} />

View file

@ -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;
}

View file

@ -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 = {

View file

@ -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<HTMLDivElement | null>;
focusMessages: () => void;
handleMessagesKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
handleMessagesBlur: React.FocusEventHandler<HTMLDivElement>;
handleMessagesClick: React.MouseEventHandler<HTMLDivElement>;
}
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<HTMLDivElement | null>(null);
const isUnmountedRef = useRef(false);
const rafIdRef = useRef<number | null>(null);
const lastRafTimestampRef = useRef<number | null>(null);
const directionRef = useRef<ScrollDirection>(null);
const pressedScrollKeysRef = useRef<Set<string>>(new Set());
// Store config values in refs to avoid stale closures
const enabledRef = useRef<boolean>(config.enabled);
const keysRef = useRef<NormalizedVimKeys>(
normalizeVimKeys(config.scrollUpKey, config.scrollDownKey, config.focusInputKey)
);
const scrollSpeedRef = useRef<number>(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<HTMLDivElement> = 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<HTMLDivElement> = 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<HTMLDivElement> = 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,
};
}

View file

@ -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<CopilotSettings>) {
* @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<VimNavigationSettings>;
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;
}

View file

@ -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";

View file

@ -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<ChainType, string> = {
@ -24,6 +26,86 @@ const ChainType2Label: Record<ChainType, string> = {
[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<string | null>(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 (
<div className="tw-ml-4 tw-border-l tw-border-border tw-pl-4">
<SettingItem
type="custom"
title="Key Mappings"
description={
<div className="tw-space-y-1">
<div>Format: map &lt;key&gt; &lt;action&gt; (one per line)</div>
<div className="tw-text-xs tw-text-muted">
Actions: scrollUp, scrollDown, focusInput
</div>
</div>
}
>
<div className="tw-flex tw-flex-col tw-gap-2">
<Textarea
value={mappingText}
onChange={(e) => handleMappingChange(e.target.value)}
className="tw-min-h-[80px] tw-w-full tw-font-mono tw-text-sm sm:tw-w-[240px]"
rows={3}
/>
{error && <div className="tw-text-xs tw-text-error">{error}</div>}
<Button variant="ghost" size="sm" onClick={handleReset} className="tw-w-fit">
Reset to defaults
</Button>
</div>
</SettingItem>
</div>
);
};
export const BasicSettings: React.FC = () => {
const settings = useSettingsValue();
const { setSelectedTab } = useTab();
@ -267,6 +349,41 @@ export const BasicSettings: React.FC = () => {
]}
/>
{/* Vim Navigation */}
<SettingItem
type="switch"
title="Vim-style Navigation"
description={
<div className="tw-flex tw-items-center tw-gap-1.5">
<span className="tw-leading-none">
Navigate chat messages using keyboard shortcuts
</span>
<HelpTooltip
content={
<div className="tw-flex tw-max-w-96 tw-flex-col tw-gap-2 tw-py-4">
<div className="tw-text-sm tw-font-medium tw-text-accent">
Vim-style keyboard navigation
</div>
<div className="tw-text-xs tw-text-muted">
When enabled, the messages area becomes focusable. Use configured keys to
scroll messages, focus input, and Escape to return to messages from input.
</div>
</div>
}
/>
</div>
}
checked={settings.vimNavigation.enabled}
onCheckedChange={(checked) => {
updateSetting("vimNavigation", {
...settings.vimNavigation,
enabled: checked,
});
}}
/>
{settings.vimNavigation.enabled && <VimKeyMappings />}
<SettingItem
type="switch"
title="Auto-Add Active Content to Context"

View file

@ -86,6 +86,17 @@ If your plugin does not need CSS, delete this file.
width: 70%;
}
/* Vim navigation: hide default outline, show for keyboard navigation */
.copilot-messages-focusable:focus {
outline: none;
}
.copilot-messages-focusable:focus-visible {
outline: 2px solid var(--interactive-accent);
outline-offset: -2px;
border-radius: var(--radius-s);
}
.chat-context-menu {
display: flex;
align-items: center;

View file

@ -0,0 +1,178 @@
/**
* Tests for Vim-style keyboard navigation utilities
*
* Verifies that navigation key mappings are correctly built and parsed.
* Format: one mapping per line, e.g., "map k scrollUp"
*/
import { buildNavMappingText, parseNavMappings } from "./vimKeyboardNavigation";
describe("vimKeyboardNavigation", () => {
describe("buildNavMappingText", () => {
it("should build mapping text from settings", () => {
const settings = {
enabled: true,
scrollUpKey: "k",
scrollDownKey: "j",
focusInputKey: "i",
};
const result = buildNavMappingText(settings);
expect(result).toBe("map k scrollUp\nmap j scrollDown\nmap i focusInput");
});
it("should handle custom keys", () => {
const settings = {
enabled: true,
scrollUpKey: "w",
scrollDownKey: "s",
focusInputKey: "e",
};
const result = buildNavMappingText(settings);
expect(result).toBe("map w scrollUp\nmap s scrollDown\nmap e focusInput");
});
});
describe("parseNavMappings", () => {
it("should parse valid mapping text", () => {
const input = "map k scrollUp\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBeUndefined();
expect(result.settings).toEqual({
scrollUp: "k",
scrollDown: "j",
focusInput: "i",
});
});
it("should handle empty lines", () => {
const input = "map k scrollUp\n\nmap j scrollDown\n\nmap i focusInput\n";
const result = parseNavMappings(input);
expect(result.error).toBeUndefined();
expect(result.settings).toEqual({
scrollUp: "k",
scrollDown: "j",
focusInput: "i",
});
});
it("should handle different key order", () => {
const input = "map i focusInput\nmap k scrollUp\nmap j scrollDown";
const result = parseNavMappings(input);
expect(result.error).toBeUndefined();
expect(result.settings).toEqual({
scrollUp: "k",
scrollDown: "j",
focusInput: "i",
});
});
describe("error cases", () => {
it("should error on invalid format - missing map keyword", () => {
const input = "k scrollUp\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe('Each line must follow "map <key> <action>"');
expect(result.settings).toBeUndefined();
});
it("should error on invalid format - too few parts", () => {
const input = "map k\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe('Each line must follow "map <key> <action>"');
});
it("should error on invalid format - too many parts", () => {
const input = "map k scrollUp extra\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe('Each line must follow "map <key> <action>"');
});
it("should error on unknown action", () => {
const input = "map k unknownAction\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe("Unknown action: unknownAction");
});
it("should error on multi-character key", () => {
const input = "map kk scrollUp\nmap j scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe("Key must be a single character for scrollUp");
});
it("should error on duplicate keys (same case)", () => {
const input = "map k scrollUp\nmap k scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe("Navigation keys must be unique");
});
it("should error on duplicate keys (different case)", () => {
const input = "map k scrollUp\nmap K scrollDown\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe("Navigation keys must be unique");
});
it("should error on duplicate action mapping", () => {
const input = "map k scrollUp\nmap j scrollUp\nmap i focusInput";
const result = parseNavMappings(input);
expect(result.error).toBe("Duplicate mapping for scrollUp");
});
it("should error on missing action - single", () => {
const input = "map k scrollUp\nmap j scrollDown";
const result = parseNavMappings(input);
expect(result.error).toBe("Missing mapping for focusInput");
});
it("should error on missing action - multiple", () => {
const input = "map k scrollUp";
const result = parseNavMappings(input);
expect(result.error).toBe("Missing mapping for scrollDown, focusInput");
});
it("should error on empty input", () => {
const input = "";
const result = parseNavMappings(input);
expect(result.error).toBe("Missing mapping for scrollUp, scrollDown, focusInput");
});
it("should error on whitespace-only input", () => {
const input = " \n \n ";
const result = parseNavMappings(input);
expect(result.error).toBe("Missing mapping for scrollUp, scrollDown, focusInput");
});
});
});
});

View file

@ -0,0 +1,71 @@
import type { VimNavigationSettings } from "@/constants";
const NAV_ACTIONS = ["scrollUp", "scrollDown", "focusInput"] as const;
type NavAction = (typeof NAV_ACTIONS)[number];
/**
* Builds a vim-style mapping text from settings.
* Example output:
* map k scrollUp
* map j scrollDown
* map i focusInput
*/
export const buildNavMappingText = (settings: VimNavigationSettings): string => {
return [
`map ${settings.scrollUpKey} scrollUp`,
`map ${settings.scrollDownKey} scrollDown`,
`map ${settings.focusInputKey} focusInput`,
].join("\n");
};
/**
* Parses vim-style mapping text into settings.
* Returns either parsed settings or an error message.
*/
export const parseNavMappings = (
value: string
): { settings?: Record<NavAction, string>; error?: string } => {
const parsed: Partial<Record<NavAction, string>> = {};
const usedKeys = new Map<string, string>();
const lines = value.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const parts = line.split(/\s+/);
if (parts.length !== 3 || parts[0] !== "map") {
return { error: 'Each line must follow "map <key> <action>"' };
}
const key = parts[1];
const action = parts[2] as NavAction;
if (!NAV_ACTIONS.includes(action)) {
return { error: `Unknown action: ${parts[2]}` };
}
if (key.length !== 1) {
return { error: `Key must be a single character for ${action}` };
}
const normalizedKey = key.toLowerCase();
if (usedKeys.has(normalizedKey)) {
return { error: "Navigation keys must be unique" };
}
if (parsed[action]) {
return { error: `Duplicate mapping for ${action}` };
}
usedKeys.set(normalizedKey, action);
parsed[action] = key;
}
const missing = NAV_ACTIONS.filter((action) => !parsed[action]);
if (missing.length > 0) {
return { error: `Missing mapping for ${missing.join(", ")}` };
}
return { settings: parsed as Record<NavAction, string> };
};