From a0c0bfb88ccb160efda1c872a7c98f8641537b3a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 19:34:52 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20change=20cancel=20edi?= =?UTF-8?q?t=20keybind=20from=20Ctrl+Q=20to=20Escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 UI hints update dynamically based on vim mode: - Shows 'Esc to cancel' in non-vim mode - Shows 'Esc×2 to cancel' in vim mode _Generated with mux_ --- scripts/bump_tag.sh | 4 ++-- src/browser/components/ChatInput/index.tsx | 25 +++++++++++++++++----- src/browser/components/VimTextArea.tsx | 23 ++++++++++++++++++-- src/browser/utils/ui/keybinds.ts | 2 +- src/browser/utils/vim.ts | 6 +++--- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/scripts/bump_tag.sh b/scripts/bump_tag.sh index 069cf9fddd..db16e557a0 100755 --- a/scripts/bump_tag.sh +++ b/scripts/bump_tag.sh @@ -18,7 +18,7 @@ if [[ -z "$CURRENT_VERSION" || "$CURRENT_VERSION" == "null" ]]; then fi # Parse semver components -IFS='.' read -r MAJOR MINOR_V PATCH <<< "$CURRENT_VERSION" +IFS='.' read -r MAJOR MINOR_V PATCH <<<"$CURRENT_VERSION" # Calculate new version if [[ "$MINOR" == "true" ]]; then @@ -30,7 +30,7 @@ fi echo "Bumping version: $CURRENT_VERSION -> $NEW_VERSION" # Update package.json -jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp +jq --arg v "$NEW_VERSION" '.version = $v' package.json >package.json.tmp mv package.json.tmp package.json # Commit and tag diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index c2f6eb5d61..1c1f650cc4 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -826,6 +826,14 @@ export const ChatInput: React.FC = (props) => { } }; + // Handler for Escape in vim normal mode - cancels edit if editing + const handleEscapeInNormalMode = () => { + if (variant === "workspace" && editingMessage && props.onCancelEdit) { + props.onCancelEdit(); + inputRef.current?.blur(); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { // Handle cancel for creation variant if (variant === "creation" && matchesKeybind(e, KEYBINDS.CANCEL) && props.onCancel) { @@ -870,9 +878,11 @@ export const ChatInput: React.FC = (props) => { return; } - // Handle cancel edit (Ctrl+Q) - workspace only + // Handle cancel edit (Escape) - workspace only + // In vim mode, escape first goes to normal mode; escapeInNormalMode callback handles cancel + // In non-vim mode, escape directly cancels edit if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { - if (variant === "workspace" && editingMessage && props.onCancelEdit) { + if (variant === "workspace" && editingMessage && props.onCancelEdit && !vimEnabled) { e.preventDefault(); props.onCancelEdit(); const isFocused = document.activeElement === inputRef.current; @@ -897,7 +907,7 @@ export const ChatInput: React.FC = (props) => { } // Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal) - // Edit canceling is Ctrl+Q, stream interruption is Ctrl+C (vim) or Esc (normal) + // Edit canceling is Esc (non-vim) or Esc twice (vim), stream interruption is Ctrl+C (vim) or Esc (normal) // Don't handle keys if command suggestions are visible if ( @@ -924,7 +934,10 @@ export const ChatInput: React.FC = (props) => { // Workspace variant placeholders if (editingMessage) { - return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; + const cancelHint = vimEnabled + ? "Esc×2 to cancel" + : `${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel`; + return `Edit your message... (${cancelHint}, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (isCompacting) { const interruptKeybind = vimEnabled @@ -1040,6 +1053,7 @@ export const ChatInput: React.FC = (props) => { onPaste={handlePaste} onDragOver={handleDragOver} onDrop={handleDrop} + onEscapeInNormalMode={handleEscapeInNormalMode} suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} placeholder={placeholder} disabled={!editingMessage && (disabled || isSending)} @@ -1074,7 +1088,8 @@ export const ChatInput: React.FC = (props) => { {/* Editing indicator - workspace only */} {variant === "workspace" && editingMessage && (
- Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel) + Editing message ({vimEnabled ? "Esc×2" : formatKeybind(KEYBINDS.CANCEL_EDIT)} to + cancel)
)} diff --git a/src/browser/components/VimTextArea.tsx b/src/browser/components/VimTextArea.tsx index 1abcfa0c5a..227a2244c3 100644 --- a/src/browser/components/VimTextArea.tsx +++ b/src/browser/components/VimTextArea.tsx @@ -32,12 +32,27 @@ export interface VimTextAreaProps isEditing?: boolean; suppressKeys?: string[]; // keys for which Vim should not interfere (e.g. ["Tab","ArrowUp","ArrowDown","Escape"]) when popovers are open trailingAction?: React.ReactNode; + /** Called when Escape is pressed in normal mode (vim) - useful for cancel edit */ + onEscapeInNormalMode?: () => void; } type VimMode = vim.VimMode; export const VimTextArea = React.forwardRef( - ({ value, onChange, mode, isEditing, suppressKeys, onKeyDown, trailingAction, ...rest }, ref) => { + ( + { + value, + onChange, + mode, + isEditing, + suppressKeys, + onKeyDown, + trailingAction, + onEscapeInNormalMode, + ...rest + }, + ref + ) => { const textareaRef = useRef(null); // Expose DOM ref to parent useEffect(() => { @@ -129,7 +144,7 @@ export const VimTextArea = React.forwardRef Date: Tue, 2 Dec 2025 19:40:17 -0600 Subject: [PATCH 2/3] fix: use formatKeybind constant, remove keybinds from comments - Use formatKeybind(KEYBINDS.CANCEL_EDIT) consistently instead of hardcoded 'Esc' - Remove comment that repeated specific keybind values (brittle) - Add AGENTS.md note about not repeating constants in comments --- docs/AGENTS.md | 1 + src/browser/components/ChatInput/index.tsx | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 76fc25e88a..9d95eddfe6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -108,6 +108,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior. - Let types drive design: prefer discriminated unions for state, minimize runtime checks, and simplify when types feel unwieldy. - Use `using` declarations (or equivalent disposables) for processes, file handles, etc., to ensure cleanup even on errors. - Centralize magic constants under `src/constants/`; share them instead of duplicating values across layers. +- Never repeat constant values (like keybinds) in comments—they become stale when the constant changes. ## Component State & Storage diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 1c1f650cc4..317e30f53b 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -907,7 +907,6 @@ export const ChatInput: React.FC = (props) => { } // Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal) - // Edit canceling is Esc (non-vim) or Esc twice (vim), stream interruption is Ctrl+C (vim) or Esc (normal) // Don't handle keys if command suggestions are visible if ( @@ -935,7 +934,7 @@ export const ChatInput: React.FC = (props) => { // Workspace variant placeholders if (editingMessage) { const cancelHint = vimEnabled - ? "Esc×2 to cancel" + ? `${formatKeybind(KEYBINDS.CANCEL_EDIT)}×2 to cancel` : `${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel`; return `Edit your message... (${cancelHint}, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } @@ -1088,8 +1087,8 @@ export const ChatInput: React.FC = (props) => { {/* Editing indicator - workspace only */} {variant === "workspace" && editingMessage && (
- Editing message ({vimEnabled ? "Esc×2" : formatKeybind(KEYBINDS.CANCEL_EDIT)} to - cancel) + Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} + {vimEnabled ? "×2" : ""} to cancel)
)} From 43327ee618565fc9947be1e6cb0f390172f30eea Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 19:44:27 -0600 Subject: [PATCH 3/3] fix: restore previous input and images when canceling edit When entering edit mode, save current input text and image attachments. On cancel, restore both instead of leaving the edited message content. --- src/browser/components/ChatInput/index.tsx | 29 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 317e30f53b..d31db998ef 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -148,6 +148,25 @@ export const ChatInput: React.FC = (props) => { }, []); const inputRef = useRef(null); const modelSelectorRef = useRef(null); + + // Draft state combines text input and image attachments + // Use these helpers to avoid accidentally losing images when modifying text + interface DraftState { + text: string; + images: ImageAttachment[]; + } + const getDraft = useCallback( + (): DraftState => ({ text: input, images: imageAttachments }), + [input, imageAttachments] + ); + const setDraft = useCallback( + (draft: DraftState) => { + setInput(draft.text); + setImageAttachments(draft.images); + }, + [setInput] + ); + const preEditDraftRef = useRef({ text: "", images: [] }); const [mode, setMode] = useMode(); const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU(); const commandListId = useId(); @@ -346,10 +365,11 @@ export const ChatInput: React.FC = (props) => { }; }, [focusMessageInput]); - // When entering editing mode, populate input with message content + // When entering editing mode, save current draft and populate with message content useEffect(() => { if (editingMessage) { - setInput(editingMessage.content); + preEditDraftRef.current = getDraft(); + setDraft({ text: editingMessage.content, images: [] }); // Auto-resize textarea and focus setTimeout(() => { if (inputRef.current) { @@ -360,7 +380,8 @@ export const ChatInput: React.FC = (props) => { } }, 0); } - }, [editingMessage, setInput]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when editingMessage changes + }, [editingMessage]); // Watch input for slash commands useEffect(() => { @@ -829,6 +850,7 @@ export const ChatInput: React.FC = (props) => { // Handler for Escape in vim normal mode - cancels edit if editing const handleEscapeInNormalMode = () => { if (variant === "workspace" && editingMessage && props.onCancelEdit) { + setDraft(preEditDraftRef.current); props.onCancelEdit(); inputRef.current?.blur(); } @@ -884,6 +906,7 @@ export const ChatInput: React.FC = (props) => { if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { if (variant === "workspace" && editingMessage && props.onCancelEdit && !vimEnabled) { e.preventDefault(); + setDraft(preEditDraftRef.current); props.onCancelEdit(); const isFocused = document.activeElement === inputRef.current; if (isFocused) {