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
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions scripts/bump_tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 45 additions & 8 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}, []);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(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<DraftState>({ text: "", images: [] });
const [mode, setMode] = useMode();
const { recentModels, addModel, defaultModel, setDefaultModel } = useModelLRU();
const commandListId = useId();
Expand Down Expand Up @@ -346,10 +365,11 @@ export const ChatInput: React.FC<ChatInputProps> = (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) {
Expand All @@ -360,7 +380,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}
}, 0);
}
}, [editingMessage, setInput]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run when editingMessage changes
}, [editingMessage]);

// Watch input for slash commands
useEffect(() => {
Expand Down Expand Up @@ -826,6 +847,15 @@ export const ChatInput: React.FC<ChatInputProps> = (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();
}
};

const handleKeyDown = (e: React.KeyboardEvent) => {
// Handle cancel for creation variant
if (variant === "creation" && matchesKeybind(e, KEYBINDS.CANCEL) && props.onCancel) {
Expand Down Expand Up @@ -870,10 +900,13 @@ export const ChatInput: React.FC<ChatInputProps> = (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();
setDraft(preEditDraftRef.current);
props.onCancelEdit();
const isFocused = document.activeElement === inputRef.current;
if (isFocused) {
Expand All @@ -897,7 +930,6 @@ export const ChatInput: React.FC<ChatInputProps> = (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)

// Don't handle keys if command suggestions are visible
if (
Expand All @@ -924,7 +956,10 @@ export const ChatInput: React.FC<ChatInputProps> = (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
? `${formatKeybind(KEYBINDS.CANCEL_EDIT)}×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
Expand Down Expand Up @@ -1040,6 +1075,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
onPaste={handlePaste}
onDragOver={handleDragOver}
onDrop={handleDrop}
onEscapeInNormalMode={handleEscapeInNormalMode}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
placeholder={placeholder}
disabled={!editingMessage && (disabled || isSending)}
Expand Down Expand Up @@ -1074,7 +1110,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
{/* Editing indicator - workspace only */}
{variant === "workspace" && editingMessage && (
<div className="text-edit-mode text-[11px] font-medium">
Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel)
Editing message ({formatKeybind(KEYBINDS.CANCEL_EDIT)}
{vimEnabled ? "×2" : ""} to cancel)
</div>
)}

Expand Down
23 changes: 21 additions & 2 deletions src/browser/components/VimTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLTextAreaElement, VimTextAreaProps>(
({ value, onChange, mode, isEditing, suppressKeys, onKeyDown, trailingAction, ...rest }, ref) => {
(
{
value,
onChange,
mode,
isEditing,
suppressKeys,
onKeyDown,
trailingAction,
onEscapeInNormalMode,
...rest
},
ref
) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// Expose DOM ref to parent
useEffect(() => {
Expand Down Expand Up @@ -129,7 +144,7 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp

e.preventDefault();

// Handle side effects (undo/redo)
// Handle side effects (undo/redo/escapeInNormalMode)
if (result.action === "undo") {
document.execCommand("undo");
return;
Expand All @@ -138,6 +153,10 @@ export const VimTextArea = React.forwardRef<HTMLTextAreaElement, VimTextAreaProp
document.execCommand("redo");
return;
}
if (result.action === "escapeInNormalMode") {
onEscapeInNormalMode?.();
return;
}

// Apply new state to React
const newState = result.newState;
Expand Down
2 changes: 1 addition & 1 deletion src/browser/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export const KEYBINDS = {
CANCEL: { key: "Escape" },

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

/** Interrupt active stream (destructive - stops AI generation) */
// Vim mode: Ctrl+C (familiar from terminal interrupt)
Expand Down
6 changes: 3 additions & 3 deletions src/browser/utils/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface VimState {
pendingOp: null | { op: "d" | "y" | "c"; at: number; args?: string[] };
}

export type VimAction = "undo" | "redo";
export type VimAction = "undo" | "redo" | "escapeInNormalMode";

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

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

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