Skip to content
Merged
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
11 changes: 10 additions & 1 deletion docs/vim-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 9 additions & 5 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -75,11 +75,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
);

// 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<boolean>(getAutoRetryKey(workspaceId), true, {
listener: true,
});

// Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise)
const [vimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, { listener: true });

// Use auto-scroll hook for scroll management
const {
contentRef,
Expand Down Expand Up @@ -214,6 +217,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
handleOpenTerminal,
aggregator,
setEditingMessage,
vimEnabled,
});

// Clear editing state if the message being edited no longer exists
Expand Down Expand Up @@ -259,7 +263,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
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
Expand Down Expand Up @@ -416,8 +420,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
}
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
Expand Down
12 changes: 9 additions & 3 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}

// 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 (
Expand All @@ -778,13 +778,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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`);
Expand Down
5 changes: 4 additions & 1 deletion src/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -24,6 +26,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
clipboardWriteText = copyToClipboard,
}) => {
const content = message.content;
const [vimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, { listener: true });

console.assert(
typeof clipboardWriteText === "function",
Expand Down Expand Up @@ -58,7 +61,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
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,
},
]
Expand Down
45 changes: 18 additions & 27 deletions src/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -184,5 +174,6 @@ export function useAIViewKeybinds({
chatInputAPI,
aggregator,
setEditingMessage,
vimEnabled,
]);
}
5 changes: 4 additions & 1 deletion src/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down