diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 6161f66d9..1dccffbae 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -146,7 +146,16 @@ ESC is used for: 1. Exiting Vim normal mode (highest priority) 2. NOT used for canceling edits (use **Ctrl-Q** instead) -3. NOT used for interrupting streams (use **Ctrl-C** instead) +3. NOT used for interrupting streams in Vim mode (use **Ctrl-C**) +4. In non-Vim mode, **Esc** interrupts streams + +### Ctrl+C Key (Vim Mode) + +In Vim mode, **Ctrl+C always interrupts streams** (similar to terminal interrupt behavior). This means: + +- Standard Ctrl+C copy is **not available** in Vim mode +- Use **vim yank commands** (`y`, `yy`, `yiw`, etc.) to copy text instead +- This provides consistent interrupt behavior whether text is selected or not ## Tips diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 13991ffeb..927ea3f79 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -5,7 +5,7 @@ import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; -import { getAutoRetryKey } from "@/constants/storage"; +import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput"; import { RightSidebar, type TabType } from "./RightSidebar"; import { useResizableSidebar } from "@/hooks/useResizableSidebar"; @@ -75,11 +75,14 @@ const AIViewInner: React.FC = ({ ); // Auto-retry state - minimal setter for keybinds and message sent handler - // RetryBarrier manages its own state, but we need this for Ctrl+C keybind + // RetryBarrier manages its own state, but we need this for interrupt keybind const [, setAutoRetry] = usePersistedState(getAutoRetryKey(workspaceId), true, { listener: true, }); + // Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise) + const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); + // Use auto-scroll hook for scroll management const { contentRef, @@ -214,6 +217,7 @@ const AIViewInner: React.FC = ({ handleOpenTerminal, aggregator, setEditingMessage, + vimEnabled, }); // Clear editing state if the message being edited no longer exists @@ -259,7 +263,7 @@ const AIViewInner: React.FC = ({ const activeStreamMessageId = aggregator.getActiveStreamMessageId(); // Note: We intentionally do NOT reset autoRetry when streams start. - // If user pressed Ctrl+C, autoRetry stays false until they manually retry. + // If user pressed the interrupt key, autoRetry stays false until they manually retry. // This makes state transitions explicit and predictable. // Merge consecutive identical stream errors @@ -416,8 +420,8 @@ const AIViewInner: React.FC = ({ } cancelText={ isCompacting - ? `${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early` - : `hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel` + ? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early` + : `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel` } tokenCount={ activeStreamMessageId diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 015123fc4..778b8ea97 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -754,7 +754,7 @@ export const ChatInput: React.FC = ({ } // Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal) - // Edit canceling is Ctrl+Q, stream interruption is Ctrl+C + // Edit canceling is Ctrl+Q, stream interruption is Ctrl+C (vim) or Esc (normal) // Don't handle keys if command suggestions are visible if ( @@ -778,13 +778,19 @@ export const ChatInput: React.FC = ({ return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (isCompacting) { - return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`; + const interruptKeybind = vimEnabled + ? KEYBINDS.INTERRUPT_STREAM_VIM + : KEYBINDS.INTERRUPT_STREAM_NORMAL; + return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`; } // Build hints for normal input const hints: string[] = []; if (canInterrupt) { - hints.push(`${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to interrupt`); + const interruptKeybind = vimEnabled + ? KEYBINDS.INTERRUPT_STREAM_VIM + : KEYBINDS.INTERRUPT_STREAM_NORMAL; + hints.push(`${formatKeybind(interruptKeybind)} to interrupt`); } hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`); hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`); diff --git a/src/components/Messages/UserMessage.tsx b/src/components/Messages/UserMessage.tsx index c83b28005..5db7285d1 100644 --- a/src/components/Messages/UserMessage.tsx +++ b/src/components/Messages/UserMessage.tsx @@ -7,6 +7,8 @@ import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import type { KebabMenuItem } from "@/components/KebabMenu"; import { copyToClipboard } from "@/utils/clipboard"; +import { usePersistedState } from "@/hooks/usePersistedState"; +import { VIM_ENABLED_KEY } from "@/constants/storage"; interface UserMessageProps { message: DisplayedMessage & { type: "user" }; @@ -24,6 +26,7 @@ export const UserMessage: React.FC = ({ clipboardWriteText = copyToClipboard, }) => { const content = message.content; + const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); console.assert( typeof clipboardWriteText === "function", @@ -58,7 +61,7 @@ export const UserMessage: React.FC = ({ onClick: handleEdit, disabled: isCompacting, tooltip: isCompacting - ? `Cannot edit while compacting (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)` + ? `Cannot edit while compacting (${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel)` : undefined, }, ] diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index 1086af3a0..f78e86525 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -23,17 +23,20 @@ interface UseAIViewKeybindsParams { handleOpenTerminal: () => void; aggregator: StreamingMessageAggregator; // For compaction detection setEditingMessage: (editing: { id: string; content: string } | undefined) => void; + vimEnabled: boolean; // For vim-aware interrupt keybind } /** * Manages keyboard shortcuts for AIView: - * - Escape: Interrupt stream + * - Esc (non-vim) or Ctrl+C (vim): Interrupt stream (always, regardless of selection) * - Ctrl+I: Focus chat input * - Ctrl+Shift+T: Toggle thinking level * - Ctrl+G: Jump to bottom * - Ctrl+T: Open terminal - * - Ctrl+C (during compaction): Cancel compaction, restore command (uses localStorage) + * - Ctrl+C (during compaction in vim mode): Cancel compaction, restore command * - Ctrl+A (during compaction): Accept early with [truncated] + * + * Note: In vim mode, Ctrl+C always interrupts streams. Use vim yank (y) commands for copying. */ export function useAIViewKeybinds({ workspaceId, @@ -48,13 +51,19 @@ export function useAIViewKeybinds({ handleOpenTerminal, aggregator, setEditingMessage, + vimEnabled, }: UseAIViewKeybindsParams): void { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Ctrl+C during compaction: cancel and restore command to input + // Check vim-aware interrupt keybind + const interruptKeybind = vimEnabled + ? KEYBINDS.INTERRUPT_STREAM_VIM + : KEYBINDS.INTERRUPT_STREAM_NORMAL; + + // Interrupt stream: Ctrl+C in vim mode, Esc in normal mode // (different from Ctrl+A which accepts early with [truncated]) - // Only intercept if actively compacting (otherwise allow browser default for copy) - if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) { + // Only intercept if actively compacting (otherwise allow browser default for copy in vim mode) + if (matchesKeybind(e, interruptKeybind)) { if (canInterrupt && isCompactingStream(aggregator)) { // Ctrl+C during compaction: restore original state and enter edit mode // Stores cancellation marker in localStorage (persists across reloads) @@ -67,33 +76,14 @@ export function useAIViewKeybinds({ } // Normal stream interrupt (non-compaction) - // Allow interrupt in editable elements if there's no text selection - // This way Ctrl+C works for both copy (when text is selected) and interrupt (when not) - const inEditableElement = isEditableElement(e.target); - let hasSelection = false; - - if (inEditableElement) { - // For input/textarea elements, check selectionStart/selectionEnd - // (window.getSelection() doesn't work for form elements) - const target = e.target as HTMLInputElement | HTMLTextAreaElement; - hasSelection = - typeof target.selectionStart === "number" && - typeof target.selectionEnd === "number" && - target.selectionStart !== target.selectionEnd; - } else { - // For contentEditable and other elements, use window.getSelection() - hasSelection = (window.getSelection()?.toString().length ?? 0) > 0; - } - - if ((canInterrupt || showRetryBarrier) && (!inEditableElement || !hasSelection)) { + // Vim mode: Ctrl+C always interrupts (vim uses yank for copy, not Ctrl+C) + // Non-vim mode: Esc always interrupts + if (canInterrupt || showRetryBarrier) { e.preventDefault(); setAutoRetry(false); // User explicitly stopped - don't auto-retry void window.api.workspace.interruptStream(workspaceId); return; } - - // Let browser handle Ctrl+C (copy) when there's a selection - return; } // Ctrl+A during compaction: accept early with [truncated] sentinel @@ -184,5 +174,6 @@ export function useAIViewKeybinds({ chatInputAPI, aggregator, setEditingMessage, + vimEnabled, ]); } diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index b9ad50c2e..5282c5b58 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -201,7 +201,10 @@ export const KEYBINDS = { CANCEL_EDIT: { key: "q", ctrl: true, macCtrlBehavior: "control" }, /** Interrupt active stream (destructive - stops AI generation) */ - INTERRUPT_STREAM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, + // Vim mode: Ctrl+C (familiar from terminal interrupt) + // Non-Vim mode: Esc (intuitive cancel/stop key) + INTERRUPT_STREAM_VIM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, + INTERRUPT_STREAM_NORMAL: { key: "Escape" }, /** Accept partial compaction early (adds [truncated] sentinel) */ ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" },