diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index ed1301ea07..4b139d1a30 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -20,6 +20,7 @@ export enum Command { KILL_LINE_RIGHT = 'killLineRight', KILL_LINE_LEFT = 'killLineLeft', CLEAR_INPUT = 'clearInput', + DELETE_WORD_BACKWARD = 'deleteWordBackward', // Screen control CLEAR_SCREEN = 'clearScreen', @@ -89,46 +90,38 @@ export type KeyBindingConfig = { export const defaultKeyBindings: KeyBindingConfig = { // Basic bindings [Command.RETURN]: [{ key: 'return' }], - // Original: key.name === 'escape' [Command.ESCAPE]: [{ key: 'escape' }], // Cursor movement - // Original: key.ctrl && key.name === 'a' [Command.HOME]: [{ key: 'a', ctrl: true }], - // Original: key.ctrl && key.name === 'e' [Command.END]: [{ key: 'e', ctrl: true }], // Text deletion - // Original: key.ctrl && key.name === 'k' [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], - // Original: key.ctrl && key.name === 'u' [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - // Original: key.ctrl && key.name === 'c' [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + // Added command (meta/alt/option) for mac compatibility + [Command.DELETE_WORD_BACKWARD]: [ + { key: 'backspace', ctrl: true }, + { key: 'backspace', command: true }, + ], // Screen control - // Original: key.ctrl && key.name === 'l' [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], // History navigation - // Original: key.ctrl && key.name === 'p' [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }], - // Original: key.ctrl && key.name === 'n' [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }], - // Original: key.name === 'up' [Command.NAVIGATION_UP]: [{ key: 'up' }], - // Original: key.name === 'down' [Command.NAVIGATION_DOWN]: [{ key: 'down' }], // Auto-completion - // Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl) [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }], // Completion navigation (arrow or Ctrl+P/N) [Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }], [Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }], // Text input - // Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste // Must also exclude shift to allow shift+enter for newline [Command.SUBMIT]: [ { @@ -139,7 +132,6 @@ export const defaultKeyBindings: KeyBindingConfig = { shift: false, }, ], - // Original: key.name === 'return' && (key.ctrl || key.meta || key.paste) // Split into multiple data-driven bindings // Now also includes shift+enter for multi-line input [Command.NEWLINE]: [ @@ -151,34 +143,23 @@ export const defaultKeyBindings: KeyBindingConfig = { ], // External tools - // Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18') [Command.OPEN_EXTERNAL_EDITOR]: [ { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - // Original: key.ctrl && key.name === 'v' [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], // App level bindings - // Original: key.ctrl && key.name === 'o' [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], - // Original: key.ctrl && key.name === 't' [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], - // Original: key.ctrl && key.name === 'g' [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - // Original: key.ctrl && (key.name === 'c' || key.name === 'C') [Command.QUIT]: [{ key: 'c', ctrl: true }], - // Original: key.ctrl && (key.name === 'd' || key.name === 'D') [Command.EXIT]: [{ key: 'd', ctrl: true }], - // Original: key.ctrl && key.name === 's' [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], // Shell commands - // Original: key.ctrl && key.name === 'r' [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], - // Original: key.name === 'return' && !key.ctrl // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], - // Original: key.name === 'tab' [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], }; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f348346e11..ca1f9f5498 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -507,6 +507,11 @@ export const InputPrompt: React.FC = ({ return; } + if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) { + buffer.deleteWordLeft(); + return; + } + // External editor if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { buffer.openInExternalEditor(); diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 9caf269aac..94d4ffb55b 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -172,6 +172,117 @@ describe('textBufferReducer', () => { expect(state.undoStack[0].cursorCol).toBe(5); }); }); + + describe('delete_word_left action', () => { + it('should delete a simple word', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['hello world'], + cursorRow: 0, + cursorCol: 11, + }; + const action: TextBufferAction = { type: 'delete_word_left' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['hello ']); + expect(state.cursorCol).toBe(6); + }); + + it('should delete a path segment', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['path/to/file'], + cursorRow: 0, + cursorCol: 12, + }; + const action: TextBufferAction = { type: 'delete_word_left' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['path/to/']); + expect(state.cursorCol).toBe(8); + }); + + it('should delete variable_name parts', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['variable_name'], + cursorRow: 0, + cursorCol: 13, + }; + const action: TextBufferAction = { type: 'delete_word_left' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['variable_']); + expect(state.cursorCol).toBe(9); + }); + + it('should act like backspace at the beginning of a line', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['hello', 'world'], + cursorRow: 1, + cursorCol: 0, + }; + const action: TextBufferAction = { type: 'delete_word_left' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['helloworld']); + expect(state.cursorRow).toBe(0); + expect(state.cursorCol).toBe(5); + }); + }); + + describe('delete_word_right action', () => { + it('should delete a simple word', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['hello world'], + cursorRow: 0, + cursorCol: 0, + }; + const action: TextBufferAction = { type: 'delete_word_right' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['world']); + expect(state.cursorCol).toBe(0); + }); + + it('should delete a path segment', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['path/to/file'], + cursorRow: 0, + cursorCol: 0, + }; + const action: TextBufferAction = { type: 'delete_word_right' }; + let state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['/to/file']); + state = textBufferReducer(state, action); + expect(state.lines).toEqual(['to/file']); + }); + + it('should delete variable_name parts', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['variable_name'], + cursorRow: 0, + cursorCol: 0, + }; + const action: TextBufferAction = { type: 'delete_word_right' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['_name']); + expect(state.cursorCol).toBe(0); + }); + + it('should act like delete at the end of a line', () => { + const stateWithText: TextBufferState = { + ...initialState, + lines: ['hello', 'world'], + cursorRow: 0, + cursorCol: 5, + }; + const action: TextBufferAction = { type: 'delete_word_right' }; + const state = textBufferReducer(stateWithText, action); + expect(state.lines).toEqual(['helloworld']); + expect(state.cursorRow).toBe(0); + expect(state.cursorCol).toBe(5); + }); + }); }); // Helper to get the state from the hook diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 0fef248a05..bee0a2424d 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1229,47 +1229,38 @@ export function textBufferReducer( case 'delete_word_left': { const { cursorRow, cursorCol } = state; if (cursorCol === 0 && cursorRow === 0) return state; - if (cursorCol === 0) { + + const nextState = pushUndoLocal(state); + const newLines = [...nextState.lines]; + let newCursorRow = cursorRow; + let newCursorCol = cursorCol; + + if (newCursorCol > 0) { + const lineContent = currentLine(newCursorRow); + const prevWordStart = findPrevWordStartInLine( + lineContent, + newCursorCol, + ); + const start = prevWordStart === null ? 0 : prevWordStart; + newLines[newCursorRow] = + cpSlice(lineContent, 0, start) + cpSlice(lineContent, newCursorCol); + newCursorCol = start; + } else { // Act as a backspace - const nextState = pushUndoLocal(state); const prevLineContent = currentLine(cursorRow - 1); const currentLineContentVal = currentLine(cursorRow); const newCol = cpLen(prevLineContent); - const newLines = [...nextState.lines]; newLines[cursorRow - 1] = prevLineContent + currentLineContentVal; newLines.splice(cursorRow, 1); - return { - ...nextState, - lines: newLines, - cursorRow: cursorRow - 1, - cursorCol: newCol, - preferredCol: null, - }; + newCursorRow--; + newCursorCol = newCol; } - const nextState = pushUndoLocal(state); - const lineContent = currentLine(cursorRow); - const arr = toCodePoints(lineContent); - let start = cursorCol; - let onlySpaces = true; - for (let i = 0; i < start; i++) { - if (isWordChar(arr[i])) { - onlySpaces = false; - break; - } - } - if (onlySpaces && start > 0) { - start--; - } else { - while (start > 0 && !isWordChar(arr[start - 1])) start--; - while (start > 0 && isWordChar(arr[start - 1])) start--; - } - const newLines = [...nextState.lines]; - newLines[cursorRow] = - cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol); + return { ...nextState, lines: newLines, - cursorCol: start, + cursorRow: newCursorRow, + cursorCol: newCursorCol, preferredCol: null, }; } @@ -1277,26 +1268,32 @@ export function textBufferReducer( case 'delete_word_right': { const { cursorRow, cursorCol, lines } = state; const lineContent = currentLine(cursorRow); - const arr = toCodePoints(lineContent); - if (cursorCol >= arr.length && cursorRow === lines.length - 1) + const lineLen = cpLen(lineContent); + + if (cursorCol >= lineLen && cursorRow === lines.length - 1) { return state; - if (cursorCol >= arr.length) { - // Act as a delete - const nextState = pushUndoLocal(state); + } + + const nextState = pushUndoLocal(state); + const newLines = [...nextState.lines]; + + if (cursorCol >= lineLen) { + // Act as a delete, joining with the next line const nextLineContent = currentLine(cursorRow + 1); - const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; newLines.splice(cursorRow + 1, 1); - return { ...nextState, lines: newLines, preferredCol: null }; + } else { + const nextWordStart = findNextWordStartInLine(lineContent, cursorCol); + const end = nextWordStart === null ? lineLen : nextWordStart; + newLines[cursorRow] = + cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end); } - const nextState = pushUndoLocal(state); - let end = cursorCol; - while (end < arr.length && !isWordChar(arr[end])) end++; - while (end < arr.length && isWordChar(arr[end])) end++; - const newLines = [...nextState.lines]; - newLines[cursorRow] = - cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end); - return { ...nextState, lines: newLines, preferredCol: null }; + + return { + ...nextState, + lines: newLines, + preferredCol: null, + }; } case 'kill_line_right': { @@ -1902,6 +1899,7 @@ export function useTextBuffer({ moveToOffset, deleteWordLeft, deleteWordRight, + killLineRight, killLineLeft, handleInput, @@ -2011,6 +2009,7 @@ export interface TextBuffer { * follows the caret and the next contiguous run of word characters. */ deleteWordRight: () => void; + /** * Deletes text from the cursor to the end of the current line. */ diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 6ae489d3d3..662a74c0fa 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -346,6 +346,25 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('should recognize Ctrl+Backspace in kitty protocol', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Modifier 5 is Ctrl + act(() => { + stdin.sendKittySequence(`\x1b[127;5u`); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'backspace', + kittyProtocol: true, + ctrl: true, + }), + ); + }); }); describe('paste mode', () => { diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 8db1a8975b..e08cc2e039 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -29,6 +29,8 @@ describe('keyMatchers', () => { [Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k', [Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u', [Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c', + [Command.DELETE_WORD_BACKWARD]: (key: Key) => + (key.ctrl || key.meta) && key.name === 'backspace', [Command.CLEAR_SCREEN]: (key: Key) => key.ctrl && key.name === 'l', [Command.HISTORY_UP]: (key: Key) => key.ctrl && key.name === 'p', [Command.HISTORY_DOWN]: (key: Key) => key.ctrl && key.name === 'n', @@ -113,6 +115,14 @@ describe('keyMatchers', () => { positive: [createKey('c', { ctrl: true })], negative: [createKey('c'), createKey('k', { ctrl: true })], }, + { + command: Command.DELETE_WORD_BACKWARD, + positive: [ + createKey('backspace', { ctrl: true }), + createKey('backspace', { meta: true }), + ], + negative: [createKey('backspace'), createKey('delete', { ctrl: true })], + }, // Screen control {