Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -932,6 +962,8 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
showIndexingCard={() => {
setIndexingCardVisible(true);
}}
vimNavigationEnabled={settings.vimNavigation.enabled}
focusMessages={focusMessages}
/>
</>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/components/chat-components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,6 +108,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
onRemoveSelectedText,
showProgressCard,
showIndexingCard,
focusMessages,
vimNavigationEnabled = false,
editMode = false,
onEditSave,
onEditCancel,
Expand Down Expand Up @@ -791,6 +796,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
isCopilotPlus={isCopilotPlus}
currentActiveFile={currentActiveNote}
currentChain={currentChain}
vimNavigationEnabled={vimNavigationEnabled}
focusMessages={focusMessages}
/>
</div>

Expand Down
61 changes: 54 additions & 7 deletions src/components/chat-components/ChatMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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(
Expand All @@ -36,6 +42,11 @@ const ChatMessages = memo(
onDelete,
onReplaceChat,
showHelperComponents = true,
messagesRef,
vimNavigationEnabled = false,
onKeyDown,
onBlur,
onClick,
}: ChatMessagesProps) => {
const [loadingDots, setLoadingDots] = useState("");

Expand All @@ -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) {
Expand All @@ -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}
Comment thread
Emt-lin marked this conversation as resolved.
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" />
)}
Expand All @@ -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;

Expand Down
9 changes: 9 additions & 0 deletions src/components/chat-components/LexicalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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> = ({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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} />
Expand Down
49 changes: 49 additions & 0 deletions src/components/chat-components/plugins/VimEscapePlugin.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading