From bceae355e32982a472116f7038a03991f5c795f5 Mon Sep 17 00:00:00 2001 From: Neil Mehta Date: Wed, 7 Jan 2026 16:16:20 -0500 Subject: [PATCH] Add ctrl+w support to lms chat --- src/subcommands/chat/react/ChatInput.tsx | 7 + .../chat/react/inputReducer.test.ts | 248 ++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 51 ++++ 3 files changed, 306 insertions(+) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 7c532e5f..4146451f 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -5,6 +5,7 @@ import { InputPlaceholder } from "./InputPlaceholder.js"; import { deleteAfterCursor, deleteBeforeCursor, + deleteWordBeforeCursor, insertTextAtCursor, moveCursorLeft, moveCursorRight, @@ -127,6 +128,12 @@ export const ChatInput = ({ return; } + // Ctrl+W to delete word before cursor + if (key.ctrl === true && inputCharacter === "w") { + setUserInputState(previousState => deleteWordBeforeCursor(previousState)); + return; + } + if (key.leftArrow === true && areSuggestionsVisible === false) { setUserInputState(previousState => moveCursorLeft(previousState)); return; diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 83ae8cc5..747bca91 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1,6 +1,7 @@ import { deleteAfterCursor, deleteBeforeCursor, + deleteWordBeforeCursor, insertPasteAtCursor, insertSuggestionAtCursor, insertTextAtCursor, @@ -269,6 +270,253 @@ describe("chatInputStateReducers", () => { expect(afterThirdBackspace.cursorInSegmentOffset).toBe(3); }); }); + + describe("deleteWordBeforeCursor", () => { + it("deletes a single word before cursor", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello world" }], 0, 11); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello " }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("deletes word and trailing spaces when cursor is after spaces", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello world" }], 0, 8); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "world" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes word and trailing spaces when cursor is in middle of spaces", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello world" }], 0, 7); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: " world" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes entire text when single word at cursor", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 5); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("treats punctuation as part of word (no boundaries within word)", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello,world" }], + 0, + 11, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when cursor is at start of text", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello world" }], 0, 0); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello world" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when cursor is at very start (segment 0, offset 0)", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "hello" }, + { type: "largePaste", content: "pasted" }, + ], + 0, + 0, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([ + { type: "text", content: "hello" }, + { type: "largePaste", content: "pasted" }, + ]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when cursor is on largePaste segment", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "pasted" }, + { type: "text", content: "after" }, + ], + 1, + 0, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([ + { type: "text", content: "before" }, + { type: "largePaste", content: "pasted" }, + { type: "text", content: "after" }, + ]); + expect(result.cursorOnSegmentIndex).toBe(1); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes word in middle of text segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "one two three" }], + 0, + 7, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "one three" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(4); + }); + + it("deletes multiple words when called multiple times", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "one two three four" }], + 0, + 18, + ); + + const afterFirst = deleteWordBeforeCursor(initialState); + expect(afterFirst.segments).toEqual([{ type: "text", content: "one two three " }]); + expect(afterFirst.cursorInSegmentOffset).toBe(14); + + const afterSecond = deleteWordBeforeCursor(afterFirst); + expect(afterSecond.segments).toEqual([{ type: "text", content: "one two " }]); + expect(afterSecond.cursorInSegmentOffset).toBe(8); + + const afterThird = deleteWordBeforeCursor(afterSecond); + expect(afterThird.segments).toEqual([{ type: "text", content: "one " }]); + expect(afterThird.cursorInSegmentOffset).toBe(4); + + const afterFourth = deleteWordBeforeCursor(afterThird); + expect(afterFourth.segments).toEqual([{ type: "text", content: "" }]); + expect(afterFourth.cursorInSegmentOffset).toBe(0); + }); + + it("handles tab characters as whitespace", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello\tworld" }], 0, 11); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello\t" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("handles newline characters as whitespace", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello\nworld" }], + 0, + 11, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello\n" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("deletes word with special characters", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "git commit -m" }], + 0, + 13, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "git commit " }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(11); + }); + + it("handles cursor in middle of word", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 8, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello rld" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("deletes entire path when no spaces present", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "/usr/local/bin" }], + 0, + 14, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("handles empty text segment", () => { + const initialState = createChatUserInputState([{ type: "text", content: "" }], 0, 0); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("handles only whitespace before cursor", () => { + const initialState = createChatUserInputState([{ type: "text", content: " hello" }], 0, 3); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("mimics terminal Ctrl+W with mixed content", () => { + // Simulate typing "ls -la /home/user " and pressing Ctrl+W + const initialState = createChatUserInputState( + [{ type: "text", content: "ls -la /home/user " }], + 0, + 18, + ); + + const result = deleteWordBeforeCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "ls -la " }]); + expect(result.cursorInSegmentOffset).toBe(7); + }); + }); + describe("deleteAfterCursor", () => { it("deletes character at cursor position within text segment", () => { const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 2); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 16b7413d..3880d1ed 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -335,6 +335,57 @@ export function deleteAfterCursor(state: ChatUserInputState): ChatUserInputState }); } +/** + * Deletes the word before the cursor (Ctrl+W behavior). + * Mimics standard terminal Ctrl+W: deletes backwards to the start of the word, + * treating spaces and punctuation as word boundaries. + * - Skips any trailing whitespace before the cursor + * - Deletes the word characters back to whitespace or start of line + * - Only operates within the current text segment + */ +export function deleteWordBeforeCursor(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + if (currentSegment === undefined) { + return; + } + + // At the very start - nothing to delete + if (draft.cursorOnSegmentIndex === 0 && draft.cursorInSegmentOffset === 0) { + return; + } + + // Can't delete within largePaste segments + if (currentSegment.type === "largePaste") { + return; + } + + const cursorPosition = draft.cursorInSegmentOffset; + const textBeforeCursor = currentSegment.content.slice(0, cursorPosition); + + // Find the start position of the word to delete + // First, skip trailing whitespace to find the end of the word + let wordEnd = cursorPosition; + while (wordEnd > 0 && /\s/.test(textBeforeCursor[wordEnd - 1])) { + wordEnd--; + } + + // Then, find the start of the word (non-whitespace characters) + let wordStart = wordEnd; + while (wordStart > 0 && !/\s/.test(textBeforeCursor[wordStart - 1])) { + wordStart--; + } + + // Delete from word start to cursor position (includes the word and trailing whitespace) + if (wordStart < cursorPosition) { + currentSegment.content = + currentSegment.content.slice(0, wordStart) + + currentSegment.content.slice(cursorPosition); + draft.cursorInSegmentOffset = wordStart; + } + }); +} + /** * Moves the cursor one position to the left. * - Within text: moves one character left