From 9fb05f355d823e25921e015295ee858e61a35ca8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:18:15 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20change=20interrupt=20?= =?UTF-8?q?stream=20keybind=20to=20Ctrl+D=20on=20Linux/Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+C conflicts with the standard copy shortcut on Linux and Windows. Changed interrupt stream keybind to Ctrl+D on these platforms while keeping Ctrl+C on macOS (where Cmd+C is used for copy). _Generated with `cmux`_ --- docs/vim-mode.md | 2 +- src/components/AIView.tsx | 4 ++-- src/components/ChatInput.tsx | 2 +- src/utils/ui/keybinds.ts | 5 ++++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 6161f66d9..5ce3f0f21 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -146,7 +146,7 @@ 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 (use **Ctrl-D** on Linux/Windows or **Ctrl-C** on macOS) ## Tips diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 13991ffeb..3e402aac2 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -75,7 +75,7 @@ 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, }); @@ -259,7 +259,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 diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 015123fc4..4a21d80d9 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+D (Linux/Win) or Ctrl+C (macOS) // Don't handle keys if command suggestions are visible if ( diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index b9ad50c2e..12ebd25e6 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" }, + // Ctrl+D on Linux/Windows (doesn't conflict with copy), Ctrl+C on macOS + INTERRUPT_STREAM: (isMac() + ? { key: "c", ctrl: true, macCtrlBehavior: "control" } + : { key: "d", ctrl: true }) satisfies Keybind, /** Accept partial compaction early (adds [truncated] sentinel) */ ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" }, From 890ef153836f36d33c79507c22aed5e777996677 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:31:37 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20vim-aware=20interrupt?= =?UTF-8?q?=20keybinds=20(Ctrl+C=20in=20vim,=20Esc=20otherwise)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed interrupt stream keybind strategy to be vim-mode aware: - Vim mode enabled: Ctrl+C (familiar from terminal interrupt) - Vim mode disabled: Esc (intuitive cancel/stop key) This avoids conflicts with copy operations while providing the most natural keybind for each mode. In vim mode, Ctrl+C still allows copy when text is selected. In non-vim mode, Esc is free for interrupt since it's not needed for mode transitions. _Generated with `cmux`_ --- docs/vim-mode.md | 3 +- src/components/AIView.tsx | 10 +++- src/components/ChatInput.tsx | 12 +++- src/components/Messages/UserMessage.tsx | 5 +- src/hooks/useAIViewKeybinds.ts | 79 +++++++++++++++---------- src/utils/ui/keybinds.ts | 8 +-- 6 files changed, 75 insertions(+), 42 deletions(-) diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 5ce3f0f21..bbfb2d5e9 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -146,7 +146,8 @@ 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-D** on Linux/Windows or **Ctrl-C** on macOS) +3. NOT used for interrupting streams in Vim mode (use **Ctrl-C**) +4. In non-Vim mode, **Esc** interrupts streams ## Tips diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 3e402aac2..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"; @@ -80,6 +80,9 @@ const AIViewInner: React.FC = ({ 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 @@ -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 4a21d80d9..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+D (Linux/Win) or Ctrl+C (macOS) + // 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..edbc8aaad 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -23,16 +23,17 @@ 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 * - 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] */ export function useAIViewKeybinds({ @@ -48,13 +49,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 +74,44 @@ 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)) { - e.preventDefault(); - setAutoRetry(false); // User explicitly stopped - don't auto-retry - void window.api.workspace.interruptStream(workspaceId); + // In vim mode with Ctrl+C: Allow copy when text is selected, interrupt otherwise + // In non-vim mode with Esc: Always interrupt (Esc doesn't have default copy behavior) + if (vimEnabled) { + // Vim mode: Check for text selection to allow Ctrl+C copy + 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)) { + 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; + } else { + // Non-vim mode: Esc always interrupts + if (canInterrupt || showRetryBarrier) { + e.preventDefault(); + setAutoRetry(false); + 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 +202,6 @@ export function useAIViewKeybinds({ chatInputAPI, aggregator, setEditingMessage, + vimEnabled, ]); } diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index 12ebd25e6..5282c5b58 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -201,10 +201,10 @@ export const KEYBINDS = { CANCEL_EDIT: { key: "q", ctrl: true, macCtrlBehavior: "control" }, /** Interrupt active stream (destructive - stops AI generation) */ - // Ctrl+D on Linux/Windows (doesn't conflict with copy), Ctrl+C on macOS - INTERRUPT_STREAM: (isMac() - ? { key: "c", ctrl: true, macCtrlBehavior: "control" } - : { key: "d", ctrl: true }) satisfies Keybind, + // 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" }, From 8762f109c1dc701b3d245079dc55aba20015c9ae Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:45:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20Make=20Ctrl+C=20always=20int?= =?UTF-8?q?errupt=20in=20vim=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In vim mode, Ctrl+C now always interrupts streams regardless of text selection. This is consistent with vim philosophy where: - Ctrl+C is for canceling/interrupting operations - Yank commands (y, yy, yiw, etc.) are used for copying This simplifies the behavior and makes it more predictable. Users in vim mode will use vim's native copy commands instead of Ctrl+C. _Generated with `cmux`_ --- docs/vim-mode.md | 8 ++++++ src/hooks/useAIViewKeybinds.ts | 46 +++++++--------------------------- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/docs/vim-mode.md b/docs/vim-mode.md index bbfb2d5e9..1dccffbae 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -149,6 +149,14 @@ ESC is used for: 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 1. **Learn operators + motions**: Instead of memorizing every command, learn the operators (d, c, y) and motions (w, b, $, 0). They combine naturally. diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index edbc8aaad..f78e86525 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -28,13 +28,15 @@ interface UseAIViewKeybindsParams { /** * Manages keyboard shortcuts for AIView: - * - Esc (non-vim) or Ctrl+C (vim): 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 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, @@ -74,43 +76,13 @@ export function useAIViewKeybinds({ } // Normal stream interrupt (non-compaction) - // In vim mode with Ctrl+C: Allow copy when text is selected, interrupt otherwise - // In non-vim mode with Esc: Always interrupt (Esc doesn't have default copy behavior) - if (vimEnabled) { - // Vim mode: Check for text selection to allow Ctrl+C copy - 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)) { - 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 + // 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; - } else { - // Non-vim mode: Esc always interrupts - if (canInterrupt || showRetryBarrier) { - e.preventDefault(); - setAutoRetry(false); - void window.api.workspace.interruptStream(workspaceId); - return; - } } }