Skip to content
Closed
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
7 changes: 7 additions & 0 deletions src/subcommands/chat/react/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { InputPlaceholder } from "./InputPlaceholder.js";
import {
deleteAfterCursor,
deleteBeforeCursor,
deleteWordBeforeCursor,
insertTextAtCursor,
moveCursorLeft,
moveCursorRight,
Expand Down Expand Up @@ -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;
Expand Down
248 changes: 248 additions & 0 deletions src/subcommands/chat/react/inputReducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
deleteAfterCursor,
deleteBeforeCursor,
deleteWordBeforeCursor,
insertPasteAtCursor,
insertSuggestionAtCursor,
insertTextAtCursor,
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions src/subcommands/chat/react/inputReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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--;
Comment on lines +374 to +376

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat punctuation as word boundaries in Ctrl+W

The new Ctrl+W reducer only checks \s to find word boundaries, so punctuation like /, ,, or - is treated as part of the word and gets deleted. That contradicts the function’s own docstring (“treating spaces and punctuation as word boundaries”) and typical terminal behavior where Ctrl+W stops at punctuation (e.g., /usr/local/bin should leave /usr/local/). As written, Ctrl+W will erase entire paths or flags containing punctuation, which is likely unexpected for users relying on terminal-like word deletion.

Useful? React with 👍 / 👎.

}

// 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
Expand Down