Skip to content

Commit 9054f00

Browse files
committed
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.
1 parent 31ad9b8 commit 9054f00

13 files changed

Lines changed: 1087 additions & 13 deletions

File tree

src/components/Chat.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
import { resetSessionSystemPromptSettings } from "@/system-prompts";
1515
import { ChainType } from "@/chainFactory";
1616
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
17-
import { logInfo, logError } from "@/logger";
17+
import { useVimNavigation } from "@/hooks/useVimNavigation";
18+
import { logError, logInfo } from "@/logger";
1819
import type { WebTabContext } from "@/types/message";
20+
import { ChatMessage } from "@/types/message";
1921

2022
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
2123
import ChatInput from "@/components/chat-components/ChatInput";
@@ -45,7 +47,6 @@ import { useIsPlusUser } from "@/plusUtils";
4547
import { updateSetting, useSettingsValue } from "@/settings/model";
4648
import { ChatUIState } from "@/state/ChatUIState";
4749
import { FileParserManager } from "@/tools/FileParserManager";
48-
import { ChatMessage } from "@/types/message";
4950
import { err2String, isPlusChain } from "@/utils";
5051
import { arrayBufferToBase64 } from "@/utils/base64";
5152
import { Notice, TFile } from "obsidian";
@@ -142,6 +143,21 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
142143
plugin.chatSelectionHighlightController.persistFromPointerDown();
143144
}, [plugin]);
144145

146+
// Vim-style keyboard navigation (settings already sanitized with defaults)
147+
const {
148+
messagesRef,
149+
focusMessages,
150+
handleMessagesKeyDown,
151+
handleMessagesBlur,
152+
handleMessagesClick,
153+
} = useVimNavigation({
154+
enabled: settings.vimNavigation.enabled,
155+
scrollUpKey: settings.vimNavigation.scrollUpKey,
156+
scrollDownKey: settings.vimNavigation.scrollDownKey,
157+
focusInputKey: settings.vimNavigation.focusInputKey,
158+
focusInput: chatInput.focusInput,
159+
});
160+
145161
// Safe setter utilities - automatically wrap state setters to prevent updates after unmount
146162
const safeSet = useMemo<{
147163
setCurrentAiMessage: (value: string) => void;
@@ -651,6 +667,15 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
651667

652668
useEffect(() => {
653669
const handleChatVisibility = () => {
670+
// Only check for Vim navigation mode when it's enabled
671+
if (settings.vimNavigation.enabled) {
672+
// Don't steal focus if user is in Vim navigation mode (focus on messages area)
673+
const activeElement = document.activeElement;
674+
const messagesContainer = messagesRef.current;
675+
if (messagesContainer && activeElement && messagesContainer.contains(activeElement)) {
676+
return;
677+
}
678+
}
654679
chatInput.focusInput();
655680
};
656681
eventTarget?.addEventListener(EVENT_NAMES.CHAT_IS_VISIBLE, handleChatVisibility);
@@ -659,7 +684,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
659684
return () => {
660685
eventTarget?.removeEventListener(EVENT_NAMES.CHAT_IS_VISIBLE, handleChatVisibility);
661686
};
662-
}, [eventTarget, chatInput]);
687+
}, [eventTarget, chatInput, messagesRef, settings.vimNavigation.enabled]);
663688

664689
const handleDelete = useCallback(
665690
async (messageIndex: number) => {
@@ -854,6 +879,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
854879
onDelete={handleDelete}
855880
onReplaceChat={setInputMessage}
856881
showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN}
882+
messagesRef={messagesRef}
883+
vimNavigationEnabled={settings.vimNavigation.enabled}
884+
onKeyDown={handleMessagesKeyDown}
885+
onBlur={handleMessagesBlur}
886+
onClick={handleMessagesClick}
857887
/>
858888
{shouldShowProgressCard() ? (
859889
<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
932962
showIndexingCard={() => {
933963
setIndexingCardVisible(true);
934964
}}
965+
vimNavigationEnabled={settings.vimNavigation.enabled}
966+
focusMessages={focusMessages}
935967
/>
936968
</>
937969
)}

src/components/chat-components/ChatInput.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ interface ChatInputProps {
6464
onRemoveSelectedText?: (id: string) => void;
6565
showProgressCard: () => void;
6666
showIndexingCard?: () => void;
67+
focusMessages?: () => void;
68+
/** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */
69+
vimNavigationEnabled?: boolean;
6770

6871
// Edit mode props
6972
editMode?: boolean;
@@ -105,6 +108,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
105108
onRemoveSelectedText,
106109
showProgressCard,
107110
showIndexingCard,
111+
focusMessages,
112+
vimNavigationEnabled = false,
108113
editMode = false,
109114
onEditSave,
110115
onEditCancel,
@@ -791,6 +796,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
791796
isCopilotPlus={isCopilotPlus}
792797
currentActiveFile={currentActiveNote}
793798
currentChain={currentChain}
799+
vimNavigationEnabled={vimNavigationEnabled}
800+
focusMessages={focusMessages}
794801
/>
795802
</div>
796803

src/components/chat-components/ChatMessages.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useChatScrolling } from "@/hooks/useChatScrolling";
66
import { useSettingsValue } from "@/settings/model";
77
import { ChatMessage } from "@/types/message";
88
import { App } from "obsidian";
9-
import React, { memo, useEffect, useState } from "react";
9+
import React, { memo, useCallback, useEffect, useMemo, useState } from "react";
1010

1111
interface ChatMessagesProps {
1212
chatHistory: ChatMessage[];
@@ -21,6 +21,12 @@ interface ChatMessagesProps {
2121
onDelete: (messageIndex: number) => void;
2222
onReplaceChat: (prompt: string) => void;
2323
showHelperComponents: boolean;
24+
messagesRef?: React.MutableRefObject<HTMLDivElement | null>;
25+
/** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */
26+
vimNavigationEnabled?: boolean;
27+
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
28+
onBlur?: React.FocusEventHandler<HTMLDivElement>;
29+
onClick?: React.MouseEventHandler<HTMLDivElement>;
2430
}
2531

2632
const ChatMessages = memo(
@@ -36,6 +42,11 @@ const ChatMessages = memo(
3642
onDelete,
3743
onReplaceChat,
3844
showHelperComponents = true,
45+
messagesRef,
46+
vimNavigationEnabled = false,
47+
onKeyDown,
48+
onBlur,
49+
onClick,
3950
}: ChatMessagesProps) => {
4051
const [loadingDots, setLoadingDots] = useState("");
4152

@@ -46,6 +57,17 @@ const ChatMessages = memo(
4657
chatHistory,
4758
});
4859

60+
// Combine scroll container ref with external messagesRef for vim navigation
61+
const combinedScrollContainerRef = useCallback(
62+
(node: HTMLDivElement | null) => {
63+
scrollContainerCallbackRef(node);
64+
if (messagesRef) {
65+
messagesRef.current = node;
66+
}
67+
},
68+
[scrollContainerCallbackRef, messagesRef]
69+
);
70+
4971
useEffect(() => {
5072
let intervalId: NodeJS.Timeout;
5173
if (loading) {
@@ -58,9 +80,29 @@ const ChatMessages = memo(
5880
return () => clearInterval(intervalId);
5981
}, [loading]);
6082

61-
if (!chatHistory.filter((message) => message.isVisible).length && !currentAiMessage) {
83+
// Find last visible message index with single reverse scan (O(n) with early exit)
84+
const { lastVisibleMessageIndex, hasVisibleMessages } = useMemo(() => {
85+
for (let i = chatHistory.length - 1; i >= 0; i--) {
86+
if (chatHistory[i].isVisible) {
87+
return { lastVisibleMessageIndex: i, hasVisibleMessages: true };
88+
}
89+
}
90+
return { lastVisibleMessageIndex: -1, hasVisibleMessages: false };
91+
}, [chatHistory]);
92+
93+
if (!hasVisibleMessages && !currentAiMessage) {
6294
return (
63-
<div className="tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto">
95+
<div
96+
ref={messagesRef}
97+
tabIndex={vimNavigationEnabled ? 0 : undefined}
98+
role={vimNavigationEnabled ? "region" : undefined}
99+
aria-label={vimNavigationEnabled ? "Chat messages" : undefined}
100+
onKeyDown={vimNavigationEnabled ? onKeyDown : undefined}
101+
onBlur={vimNavigationEnabled ? onBlur : undefined}
102+
onClick={vimNavigationEnabled ? onClick : undefined}
103+
data-testid="chat-messages"
104+
className="copilot-messages-focusable tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto"
105+
>
64106
{showHelperComponents && settings.showRelevantNotes && (
65107
<RelevantNotes defaultOpen={true} key="relevant-notes-before-chat" />
66108
)}
@@ -81,13 +123,18 @@ const ChatMessages = memo(
81123
<RelevantNotes className="tw-mb-4" defaultOpen={false} key="relevant-notes-in-chat" />
82124
)}
83125
<div
84-
ref={scrollContainerCallbackRef}
126+
ref={combinedScrollContainerRef}
127+
tabIndex={vimNavigationEnabled ? 0 : undefined}
128+
role={vimNavigationEnabled ? "region" : undefined}
129+
aria-label={vimNavigationEnabled ? "Chat messages" : undefined}
130+
onKeyDown={vimNavigationEnabled ? onKeyDown : undefined}
131+
onBlur={vimNavigationEnabled ? onBlur : undefined}
132+
onClick={vimNavigationEnabled ? onClick : undefined}
85133
data-testid="chat-messages"
86-
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)]"
134+
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)]"
87135
>
88136
{chatHistory.map((message, index) => {
89-
const visibleMessages = chatHistory.filter((m) => m.isVisible);
90-
const isLastMessage = index === visibleMessages.length - 1;
137+
const isLastMessage = index === lastVisibleMessageIndex;
91138
// Only apply min-height to AI messages that are last
92139
const shouldApplyMinHeight = isLastMessage && message.sender !== USER_SENDER;
93140

src/components/chat-components/LexicalEditor.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { WebTabPillNode } from "./pills/WebTabPillNode";
2121
import { ActiveWebTabPillNode } from "./pills/ActiveWebTabPillNode";
2222
import { PillDeletionPlugin } from "./plugins/PillDeletionPlugin";
2323
import { KeyboardPlugin } from "./plugins/KeyboardPlugin";
24+
import { VimEscapePlugin } from "./plugins/VimEscapePlugin";
2425
import { ValueSyncPlugin } from "./plugins/ValueSyncPlugin";
2526
import { FocusPlugin } from "./plugins/FocusPlugin";
2627
import { NotePillSyncPlugin } from "./plugins/NotePillSyncPlugin";
@@ -65,6 +66,9 @@ interface LexicalEditorProps {
6566
isCopilotPlus?: boolean;
6667
currentActiveFile?: TFile | null;
6768
currentChain?: ChainType;
69+
focusMessages?: () => void;
70+
/** Whether Vim navigation is enabled (passed from parent to avoid redundant settings reads) */
71+
vimNavigationEnabled?: boolean;
6872
}
6973

7074
const LexicalEditor: React.FC<LexicalEditorProps> = ({
@@ -94,6 +98,8 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
9498
isCopilotPlus = false,
9599
currentActiveFile = null,
96100
currentChain,
101+
focusMessages,
102+
vimNavigationEnabled = false,
97103
}) => {
98104
const [focusFn, setFocusFn] = React.useState<(() => void) | null>(null);
99105
const [editorInstance, setEditorInstance] = React.useState<LexicalEditorType | null>(null);
@@ -182,6 +188,9 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
182188
<OnChangePlugin onChange={handleEditorChange} />
183189
<HistoryPlugin />
184190
<KeyboardPlugin onSubmit={onSubmit} sendShortcut={settings.defaultSendShortcut} />
191+
{focusMessages && (
192+
<VimEscapePlugin enabled={vimNavigationEnabled} focusMessages={focusMessages} />
193+
)}
185194
<ValueSyncPlugin value={value} />
186195
<FocusPlugin onFocus={handleFocusRegistration} onEditorReady={handleEditorReady} />
187196
<NotePillSyncPlugin onNotesChange={onNotesChange} onNotesRemoved={onNotesRemoved} />
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from "react";
2+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3+
import { COMMAND_PRIORITY_LOW, KEY_ESCAPE_COMMAND } from "lexical";
4+
5+
/**
6+
* Props for the VimEscapePlugin component.
7+
*/
8+
export interface VimEscapePluginProps {
9+
enabled: boolean;
10+
focusMessages: () => void;
11+
}
12+
13+
/**
14+
* Lexical plugin that maps Escape (in the input editor) to focusing the messages area.
15+
* Uses COMMAND_PRIORITY_LOW so other plugins (typeahead, menus, etc.) can handle Escape first.
16+
*/
17+
export function VimEscapePlugin({ enabled, focusMessages }: VimEscapePluginProps) {
18+
const [editor] = useLexicalComposerContext();
19+
20+
React.useEffect(() => {
21+
// Skip command registration when Vim navigation is disabled
22+
if (!enabled) {
23+
return;
24+
}
25+
26+
return editor.registerCommand(
27+
KEY_ESCAPE_COMMAND,
28+
(event: KeyboardEvent) => {
29+
// Ignore Escape during IME composition (CJK input, etc.)
30+
if (event.isComposing) {
31+
return false;
32+
}
33+
34+
// Only preventDefault, not stopPropagation, to allow document-level Escape handlers
35+
// (e.g., edit mode cancel) to still receive the event if needed.
36+
event.preventDefault();
37+
38+
// Blur the editor first, then focus messages area.
39+
// This prevents Lexical from reclaiming focus after we switch.
40+
editor.blur();
41+
focusMessages();
42+
return true;
43+
},
44+
COMMAND_PRIORITY_LOW
45+
);
46+
}, [editor, enabled, focusMessages]);
47+
48+
return null;
49+
}

src/constants.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ import { v4 as uuidv4 } from "uuid";
44
import { ChainType } from "./chainFactory";
55
import { PromptSortStrategy } from "./types";
66

7+
/**
8+
* Settings for Vim-style keyboard navigation in the chat UI.
9+
*/
10+
export interface VimNavigationSettings {
11+
/** Enable/disable Vim navigation */
12+
enabled: boolean;
13+
/** Key used to scroll up in the messages area */
14+
scrollUpKey: string;
15+
/** Key used to scroll down in the messages area */
16+
scrollDownKey: string;
17+
/** Key used to focus the input from the messages area */
18+
focusInputKey: string;
19+
}
20+
21+
/**
22+
* Default Vim navigation settings.
23+
*/
24+
export const DEFAULT_VIM_NAVIGATION: VimNavigationSettings = {
25+
enabled: false,
26+
scrollUpKey: "k",
27+
scrollDownKey: "j",
28+
focusInputKey: "i",
29+
};
30+
731
export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1";
832
export const BREVILABS_MODELS_BASE_URL = "https://models.brevilabs.com/v1";
933
export const CHAT_VIEWTYPE = "copilot-chat-view";
@@ -985,6 +1009,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
9851009
defaultSystemPromptTitle: "",
9861010
autoCompactThreshold: 128000,
9871011
convertedDocOutputFolder: DEFAULT_CONVERTED_DOC_OUTPUT_FOLDER,
1012+
vimNavigation: DEFAULT_VIM_NAVIGATION,
9881013
};
9891014

9901015
export const EVENT_NAMES = {

0 commit comments

Comments
 (0)