Skip to content

Commit a8c2ad5

Browse files
authored
🤖 fix: vim-aware interrupt keybinds (Ctrl+C in vim, Esc otherwise) (#562)
Changed interrupt stream keybind strategy to be vim-mode aware: - **Vim mode enabled**: Ctrl+C (always interrupts, regardless of text selection) - **Vim mode disabled**: Esc (intuitive cancel/stop key) This provides the most natural behavior for each mode while avoiding conflicts with standard operations. ## Vim Mode Behavior In vim mode, **Ctrl+C always interrupts streams** - consistent with vim philosophy: - Ctrl+C is for canceling/interrupting (like in terminal) - Standard Ctrl+C copy is **not available** in vim mode - Use vim yank commands (`y`, `yy`, `yiw`, etc.) for copying instead - Provides consistent interrupt behavior whether text is selected or not ## Non-Vim Mode Behavior In non-vim mode: - **Esc** interrupts streams (intuitive cancel key) - **Ctrl+C** works normally for copy operations - Simple and predictable behavior ## Changes - Split `INTERRUPT_STREAM` into `INTERRUPT_STREAM_VIM` and `INTERRUPT_STREAM_NORMAL` - Updated `useAIViewKeybinds` to accept `vimEnabled` and select appropriate keybind - Removed text selection checking in vim mode - Ctrl+C always interrupts - Updated all UI components to display the correct keybind hint - Updated documentation in `docs/vim-mode.md` _Generated with `cmux`_
1 parent 2c5a41f commit a8c2ad5

File tree

6 files changed

+54
-38
lines changed

6 files changed

+54
-38
lines changed

‎docs/vim-mode.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,16 @@ ESC is used for:
146146

147147
1. Exiting Vim normal mode (highest priority)
148148
2. NOT used for canceling edits (use **Ctrl-Q** instead)
149-
3. NOT used for interrupting streams (use **Ctrl-C** instead)
149+
3. NOT used for interrupting streams in Vim mode (use **Ctrl-C**)
150+
4. In non-Vim mode, **Esc** interrupts streams
151+
152+
### Ctrl+C Key (Vim Mode)
153+
154+
In Vim mode, **Ctrl+C always interrupts streams** (similar to terminal interrupt behavior). This means:
155+
156+
- Standard Ctrl+C copy is **not available** in Vim mode
157+
- Use **vim yank commands** (`y`, `yy`, `yiw`, etc.) to copy text instead
158+
- This provides consistent interrupt behavior whether text is selected or not
150159

151160
## Tips
152161

‎src/components/AIView.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
55
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
66
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
77
import { PinnedTodoList } from "./PinnedTodoList";
8-
import { getAutoRetryKey } from "@/constants/storage";
8+
import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage";
99
import { ChatInput, type ChatInputAPI } from "./ChatInput";
1010
import { RightSidebar, type TabType } from "./RightSidebar";
1111
import { useResizableSidebar } from "@/hooks/useResizableSidebar";
@@ -75,11 +75,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
7575
);
7676

7777
// Auto-retry state - minimal setter for keybinds and message sent handler
78-
// RetryBarrier manages its own state, but we need this for Ctrl+C keybind
78+
// RetryBarrier manages its own state, but we need this for interrupt keybind
7979
const [, setAutoRetry] = usePersistedState<boolean>(getAutoRetryKey(workspaceId), true, {
8080
listener: true,
8181
});
8282

83+
// Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise)
84+
const [vimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, { listener: true });
85+
8386
// Use auto-scroll hook for scroll management
8487
const {
8588
contentRef,
@@ -214,6 +217,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
214217
handleOpenTerminal,
215218
aggregator,
216219
setEditingMessage,
220+
vimEnabled,
217221
});
218222

219223
// Clear editing state if the message being edited no longer exists
@@ -259,7 +263,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
259263
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
260264

261265
// Note: We intentionally do NOT reset autoRetry when streams start.
262-
// If user pressed Ctrl+C, autoRetry stays false until they manually retry.
266+
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
263267
// This makes state transitions explicit and predictable.
264268

265269
// Merge consecutive identical stream errors
@@ -416,8 +420,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
416420
}
417421
cancelText={
418422
isCompacting
419-
? `${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
420-
: `hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`
423+
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
424+
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
421425
}
422426
tokenCount={
423427
activeStreamMessageId

‎src/components/ChatInput.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
754754
}
755755

756756
// Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal)
757-
// Edit canceling is Ctrl+Q, stream interruption is Ctrl+C
757+
// Edit canceling is Ctrl+Q, stream interruption is Ctrl+C (vim) or Esc (normal)
758758

759759
// Don't handle keys if command suggestions are visible
760760
if (
@@ -778,13 +778,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
778778
return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`;
779779
}
780780
if (isCompacting) {
781-
return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`;
781+
const interruptKeybind = vimEnabled
782+
? KEYBINDS.INTERRUPT_STREAM_VIM
783+
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
784+
return `Compacting... (${formatKeybind(interruptKeybind)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`;
782785
}
783786

784787
// Build hints for normal input
785788
const hints: string[] = [];
786789
if (canInterrupt) {
787-
hints.push(`${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to interrupt`);
790+
const interruptKeybind = vimEnabled
791+
? KEYBINDS.INTERRUPT_STREAM_VIM
792+
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
793+
hints.push(`${formatKeybind(interruptKeybind)} to interrupt`);
788794
}
789795
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`);
790796
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);

‎src/components/Messages/UserMessage.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
77
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
88
import type { KebabMenuItem } from "@/components/KebabMenu";
99
import { copyToClipboard } from "@/utils/clipboard";
10+
import { usePersistedState } from "@/hooks/usePersistedState";
11+
import { VIM_ENABLED_KEY } from "@/constants/storage";
1012

1113
interface UserMessageProps {
1214
message: DisplayedMessage & { type: "user" };
@@ -24,6 +26,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
2426
clipboardWriteText = copyToClipboard,
2527
}) => {
2628
const content = message.content;
29+
const [vimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, { listener: true });
2730

2831
console.assert(
2932
typeof clipboardWriteText === "function",
@@ -58,7 +61,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
5861
onClick: handleEdit,
5962
disabled: isCompacting,
6063
tooltip: isCompacting
61-
? `Cannot edit while compacting (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`
64+
? `Cannot edit while compacting (${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel)`
6265
: undefined,
6366
},
6467
]

‎src/hooks/useAIViewKeybinds.ts‎

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,20 @@ interface UseAIViewKeybindsParams {
2323
handleOpenTerminal: () => void;
2424
aggregator: StreamingMessageAggregator; // For compaction detection
2525
setEditingMessage: (editing: { id: string; content: string } | undefined) => void;
26+
vimEnabled: boolean; // For vim-aware interrupt keybind
2627
}
2728

2829
/**
2930
* Manages keyboard shortcuts for AIView:
30-
* - Escape: Interrupt stream
31+
* - Esc (non-vim) or Ctrl+C (vim): Interrupt stream (always, regardless of selection)
3132
* - Ctrl+I: Focus chat input
3233
* - Ctrl+Shift+T: Toggle thinking level
3334
* - Ctrl+G: Jump to bottom
3435
* - Ctrl+T: Open terminal
35-
* - Ctrl+C (during compaction): Cancel compaction, restore command (uses localStorage)
36+
* - Ctrl+C (during compaction in vim mode): Cancel compaction, restore command
3637
* - Ctrl+A (during compaction): Accept early with [truncated]
38+
*
39+
* Note: In vim mode, Ctrl+C always interrupts streams. Use vim yank (y) commands for copying.
3740
*/
3841
export function useAIViewKeybinds({
3942
workspaceId,
@@ -48,13 +51,19 @@ export function useAIViewKeybinds({
4851
handleOpenTerminal,
4952
aggregator,
5053
setEditingMessage,
54+
vimEnabled,
5155
}: UseAIViewKeybindsParams): void {
5256
useEffect(() => {
5357
const handleKeyDown = (e: KeyboardEvent) => {
54-
// Ctrl+C during compaction: cancel and restore command to input
58+
// Check vim-aware interrupt keybind
59+
const interruptKeybind = vimEnabled
60+
? KEYBINDS.INTERRUPT_STREAM_VIM
61+
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
62+
63+
// Interrupt stream: Ctrl+C in vim mode, Esc in normal mode
5564
// (different from Ctrl+A which accepts early with [truncated])
56-
// Only intercept if actively compacting (otherwise allow browser default for copy)
57-
if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) {
65+
// Only intercept if actively compacting (otherwise allow browser default for copy in vim mode)
66+
if (matchesKeybind(e, interruptKeybind)) {
5867
if (canInterrupt && isCompactingStream(aggregator)) {
5968
// Ctrl+C during compaction: restore original state and enter edit mode
6069
// Stores cancellation marker in localStorage (persists across reloads)
@@ -67,33 +76,14 @@ export function useAIViewKeybinds({
6776
}
6877

6978
// Normal stream interrupt (non-compaction)
70-
// Allow interrupt in editable elements if there's no text selection
71-
// This way Ctrl+C works for both copy (when text is selected) and interrupt (when not)
72-
const inEditableElement = isEditableElement(e.target);
73-
let hasSelection = false;
74-
75-
if (inEditableElement) {
76-
// For input/textarea elements, check selectionStart/selectionEnd
77-
// (window.getSelection() doesn't work for form elements)
78-
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
79-
hasSelection =
80-
typeof target.selectionStart === "number" &&
81-
typeof target.selectionEnd === "number" &&
82-
target.selectionStart !== target.selectionEnd;
83-
} else {
84-
// For contentEditable and other elements, use window.getSelection()
85-
hasSelection = (window.getSelection()?.toString().length ?? 0) > 0;
86-
}
87-
88-
if ((canInterrupt || showRetryBarrier) && (!inEditableElement || !hasSelection)) {
79+
// Vim mode: Ctrl+C always interrupts (vim uses yank for copy, not Ctrl+C)
80+
// Non-vim mode: Esc always interrupts
81+
if (canInterrupt || showRetryBarrier) {
8982
e.preventDefault();
9083
setAutoRetry(false); // User explicitly stopped - don't auto-retry
9184
void window.api.workspace.interruptStream(workspaceId);
9285
return;
9386
}
94-
95-
// Let browser handle Ctrl+C (copy) when there's a selection
96-
return;
9787
}
9888

9989
// Ctrl+A during compaction: accept early with [truncated] sentinel
@@ -184,5 +174,6 @@ export function useAIViewKeybinds({
184174
chatInputAPI,
185175
aggregator,
186176
setEditingMessage,
177+
vimEnabled,
187178
]);
188179
}

‎src/utils/ui/keybinds.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ export const KEYBINDS = {
201201
CANCEL_EDIT: { key: "q", ctrl: true, macCtrlBehavior: "control" },
202202

203203
/** Interrupt active stream (destructive - stops AI generation) */
204-
INTERRUPT_STREAM: { key: "c", ctrl: true, macCtrlBehavior: "control" },
204+
// Vim mode: Ctrl+C (familiar from terminal interrupt)
205+
// Non-Vim mode: Esc (intuitive cancel/stop key)
206+
INTERRUPT_STREAM_VIM: { key: "c", ctrl: true, macCtrlBehavior: "control" },
207+
INTERRUPT_STREAM_NORMAL: { key: "Escape" },
205208

206209
/** Accept partial compaction early (adds [truncated] sentinel) */
207210
ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" },

0 commit comments

Comments
 (0)