Skip to content

Commit 7ea8d93

Browse files
authored
🤖 fix: change cancel edit keybind from Ctrl+Q to Escape (#860)
Ctrl+Q conflicts with system quit shortcut on Linux/Windows. ## New behavior - **Non-vim mode**: Escape cancels edit - **Vim mode**: Escape goes to normal mode, second Escape cancels edit ## Changes - Added `escapeInNormalMode` action to vim state machine - VimTextArea now exposes `onEscapeInNormalMode` callback - UI hints update dynamically based on vim mode (shows 'Esc×2 to cancel' when vim enabled) _Generated with `mux`_
1 parent 76d8779 commit 7ea8d93

File tree

6 files changed

+73
-16
lines changed

6 files changed

+73
-16
lines changed

docs/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
108108
- Let types drive design: prefer discriminated unions for state, minimize runtime checks, and simplify when types feel unwieldy.
109109
- Use `using` declarations (or equivalent disposables) for processes, file handles, etc., to ensure cleanup even on errors.
110110
- Centralize magic constants under `src/constants/`; share them instead of duplicating values across layers.
111+
- Never repeat constant values (like keybinds) in comments—they become stale when the constant changes.
111112

112113
## Component State & Storage
113114

scripts/bump_tag.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ if [[ -z "$CURRENT_VERSION" || "$CURRENT_VERSION" == "null" ]]; then
1818
fi
1919

2020
# Parse semver components
21-
IFS='.' read -r MAJOR MINOR_V PATCH <<< "$CURRENT_VERSION"
21+
IFS='.' read -r MAJOR MINOR_V PATCH <<<"$CURRENT_VERSION"
2222

2323
# Calculate new version
2424
if [[ "$MINOR" == "true" ]]; then
@@ -30,7 +30,7 @@ fi
3030
echo "Bumping version: $CURRENT_VERSION -> $NEW_VERSION"
3131

3232
# Update package.json
33-
jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp
33+
jq --arg v "$NEW_VERSION" '.version = $v' package.json >package.json.tmp
3434
mv package.json.tmp package.json
3535

3636
# Commit and tag

src/browser/components/ChatInput/index.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
148148
}, []);
149149
const inputRef = useRef<HTMLTextAreaElement>(null);
150150
const modelSelectorRef = useRef<ModelSelectorRef>(null);
151+
152+
// Draft state combines text input and image attachments
153+
// Use these helpers to avoid accidentally losing images when modifying text
154+
interface DraftState {
155+
text: string;
156+
images: ImageAttachment[];
157+
}
158+
const getDraft = useCallback(
159+
(): DraftState => ({ text: input, images: imageAttachments }),
160+
[input, imageAttachments]
161+
);
162+
const setDraft = useCallback(
163+
(draft: DraftState) => {
164+
setInput(draft.text);
165+
setImageAttachments(draft.images);
166+
},
167+
[setInput]
168+
);
169+
const preEditDraftRef = useRef<DraftState>({ text: "", images: [] });
151170
const [mode, setMode] = useMode();
152171
const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU();
153172
const commandListId = useId();
@@ -346,10 +365,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
346365
};
347366
}, [focusMessageInput]);
348367

349-
// When entering editing mode, populate input with message content
368+
// When entering editing mode, save current draft and populate with message content
350369
useEffect(() => {
351370
if (editingMessage) {
352-
setInput(editingMessage.content);
371+
preEditDraftRef.current = getDraft();
372+
setDraft({ text: editingMessage.content, images: [] });
353373
// Auto-resize textarea and focus
354374
setTimeout(() => {
355375
if (inputRef.current) {
@@ -360,7 +380,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
360380
}
361381
}, 0);
362382
}
363-
}, [editingMessage, setInput]);
383+
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when editingMessage changes
384+
}, [editingMessage]);
364385

365386
// Watch input for slash commands
366387
useEffect(() => {
@@ -826,6 +847,15 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
826847
}
827848
};
828849

850+
// Handler for Escape in vim normal mode - cancels edit if editing
851+
const handleEscapeInNormalMode = () => {
852+
if (variant === "workspace" && editingMessage && props.onCancelEdit) {
853+
setDraft(preEditDraftRef.current);
854+
props.onCancelEdit();
855+
inputRef.current?.blur();
856+
}
857+
};
858+
829859
const handleKeyDown = (e: React.KeyboardEvent) => {
830860
// Handle cancel for creation variant
831861
if (variant === "creation" && matchesKeybind(e, KEYBINDS.CANCEL) && props.onCancel) {
@@ -870,10 +900,13 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
870900
return;
871901
}
872902

873-
// Handle cancel edit (Ctrl+Q) - workspace only
903+
// Handle cancel edit (Escape) - workspace only
904+
// In vim mode, escape first goes to normal mode; escapeInNormalMode callback handles cancel
905+
// In non-vim mode, escape directly cancels edit
874906
if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) {
875-
if (variant === "workspace" && editingMessage && props.onCancelEdit) {
907+
if (variant === "workspace" && editingMessage && props.onCancelEdit && !vimEnabled) {
876908
e.preventDefault();
909+
setDraft(preEditDraftRef.current);
877910
props.onCancelEdit();
878911
const isFocused = document.activeElement === inputRef.current;
879912
if (isFocused) {
@@ -897,7 +930,6 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
897930
}
898931

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

902934
// Don't handle keys if command suggestions are visible
903935
if (
@@ -924,7 +956,10 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
924956

925957
// Workspace variant placeholders
926958
if (editingMessage) {
927-
return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`;
959+
const cancelHint = vimEnabled
960+
? `${formatKeybind(KEYBINDS.CANCEL_EDIT)}×2 to cancel`
961+
: `${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel`;
962+
return `Edit your message... (${cancelHint}, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`;
928963
}
929964
if (isCompacting) {
930965
const interruptKeybind = vimEnabled
@@ -1040,6 +1075,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10401075
onPaste={handlePaste}
10411076
onDragOver={handleDragOver}
10421077
onDrop={handleDrop}
1078+
onEscapeInNormalMode={handleEscapeInNormalMode}
10431079
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
10441080
placeholder={placeholder}
10451081
disabled={!editingMessage && (disabled || isSending)}
@@ -1074,7 +1110,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10741110
{/* Editing indicator - workspace only */}
10751111
{variant === "workspace" && editingMessage && (
10761112
<div className="text-edit-mode text-[11px] font-medium">
1077-
Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel)
1113+
Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)}
1114+
{vimEnabled ? "×2" : ""} to cancel)
10781115
</div>
10791116
)}
10801117

src/browser/components/VimTextArea.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,27 @@ export interface VimTextAreaProps
3232
isEditing?: boolean;
3333
suppressKeys?: string[]; // keys for which Vim should not interfere (e.g. ["Tab","ArrowUp","ArrowDown","Escape"]) when popovers are open
3434
trailingAction?: React.ReactNode;
35+
/** Called when Escape is pressed in normal mode (vim) - useful for cancel edit */
36+
onEscapeInNormalMode?: () => void;
3537
}
3638

3739
type VimMode = vim.VimMode;
3840

3941
export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProps>(
40-
({ value, onChange, mode, isEditing, suppressKeys, onKeyDown, trailingAction, ...rest }, ref) => {
42+
(
43+
{
44+
value,
45+
onChange,
46+
mode,
47+
isEditing,
48+
suppressKeys,
49+
onKeyDown,
50+
trailingAction,
51+
onEscapeInNormalMode,
52+
...rest
53+
},
54+
ref
55+
) => {
4156
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
4257
// Expose DOM ref to parent
4358
useEffect(() => {
@@ -129,7 +144,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
129144

130145
e.preventDefault();
131146

132-
// Handle side effects (undo/redo)
147+
// Handle side effects (undo/redo/escapeInNormalMode)
133148
if (result.action === "undo") {
134149
document.execCommand("undo");
135150
return;
@@ -138,6 +153,10 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
138153
document.execCommand("redo");
139154
return;
140155
}
156+
if (result.action === "escapeInNormalMode") {
157+
onEscapeInNormalMode?.();
158+
return;
159+
}
141160

142161
// Apply new state to React
143162
const newState = result.newState;

src/browser/utils/ui/keybinds.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export const KEYBINDS = {
198198
CANCEL: { key: "Escape" },
199199

200200
/** Cancel editing message (exit edit mode) */
201-
CANCEL_EDIT: { key: "q", ctrl: true, macCtrlBehavior: "control" },
201+
CANCEL_EDIT: { key: "Escape" },
202202

203203
/** Interrupt active stream (destructive - stops AI generation) */
204204
// Vim mode: Ctrl+C (familiar from terminal interrupt)

src/browser/utils/vim.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface VimState {
1919
pendingOp: null | { op: "d" | "y" | "c"; at: number; args?: string[] };
2020
}
2121

22-
export type VimAction = "undo" | "redo";
22+
export type VimAction = "undo" | "redo" | "escapeInNormalMode";
2323

2424
export type VimKeyResult =
2525
| { handled: false } // Browser should handle this key
@@ -457,9 +457,9 @@ function handleNormalModeKey(state: VimState, key: string, modifiers: KeyModifie
457457
const opResult = tryHandleOperator(state, key, now);
458458
if (opResult) return opResult;
459459

460-
// Stay in normal mode for ESC
460+
// Escape in normal mode - signal to parent (e.g., to cancel edit mode)
461461
if (key === "Escape" || (key === "[" && modifiers.ctrl)) {
462-
return { handled: true, newState: state };
462+
return { handled: true, newState: state, action: "escapeInNormalMode" };
463463
}
464464

465465
// Swallow all other single-character keys in normal mode (don't type letters)

0 commit comments

Comments
 (0)