mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
31ad9b825c
commit
9054f00a1f
13 changed files with 1087 additions and 13 deletions
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
49
src/components/chat-components/plugins/VimEscapePlugin.tsx
Normal file
49
src/components/chat-components/plugins/VimEscapePlugin.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
480
src/hooks/useVimNavigation.ts
Normal file
480
src/hooks/useVimNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 <key> <action> (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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
178
src/utils/vimKeyboardNavigation.test.ts
Normal file
178
src/utils/vimKeyboardNavigation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
71
src/utils/vimKeyboardNavigation.ts
Normal file
71
src/utils/vimKeyboardNavigation.ts
Normal 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> };
|
||||
};
|
||||
Loading…
Reference in a new issue