From 51e7b2b011229bdd8c206516320c0c1abf57f0b2 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 18 Dec 2025 16:09:06 -0500 Subject: [PATCH 01/25] Stage I: Add CTRL A/E/F/B --- src/subcommands/chat/react/ChatInput.tsx | 24 +++ .../chat/react/inputReducer.test.ts | 124 ++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 162 ++++++++++++++++++ 3 files changed, 310 insertions(+) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 7c532e5f..10f6a370 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -8,6 +8,8 @@ import { insertTextAtCursor, moveCursorLeft, moveCursorRight, + moveCursorToLineEnd, + moveCursorToLineStart, } from "./inputReducer.js"; import { renderInputWithCursor } from "./inputRenderer.js"; import { type ChatUserInputState } from "./types.js"; @@ -94,6 +96,28 @@ export const ChatInput = ({ return; } + if (key.ctrl === true) { + if (inputCharacter === "a") { + setUserInputState(previousState => moveCursorToLineStart(previousState)); + return; + } + + if (inputCharacter === "e") { + setUserInputState(previousState => moveCursorToLineEnd(previousState)); + return; + } + + if (inputCharacter === "f") { + setUserInputState(previousState => moveCursorRight(previousState)); + return; + } + + if (inputCharacter === "b") { + setUserInputState(previousState => moveCursorLeft(previousState)); + return; + } + } + if (areSuggestionsVisible) { if (key.upArrow === true) { onSuggestionsUp(); diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 83ae8cc5..1171c15f 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -6,6 +6,8 @@ import { insertTextAtCursor, moveCursorLeft, moveCursorRight, + moveCursorToLineEnd, + moveCursorToLineStart, } from "./inputReducer.js"; import { type ChatInputSegment, type ChatUserInputState } from "./types.js"; @@ -1251,6 +1253,128 @@ describe("chatInputStateReducers", () => { }); }); + describe("moveCursorToLineStart", () => { + it("moves to start of buffer when there is no newline", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 5, + ); + + const result = moveCursorToLineStart(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("moves to character after last newline in current segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "first line\nsecond line" }], + 0, + "first line\nsecond line".length, + ); + + const result = moveCursorToLineStart(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("first line\n".length); + }); + + it("moves to character after newline in previous text segment when current has none", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "line one\n" }, + { type: "text", content: "line two" }, + ], + 1, + 4, + ); + + const result = moveCursorToLineStart(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("line one\n".length); + }); + + it("ignores newlines inside largePaste segments when searching for line start", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "prefix" }, + { type: "largePaste", content: "with\ninternal\nnewlines" }, + { type: "text", content: "suffix" }, + ], + 2, + 3, + ); + + const result = moveCursorToLineStart(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + + describe("moveCursorToLineEnd", () => { + it("moves to end of buffer when there is no newline", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 0, + ); + + const result = moveCursorToLineEnd(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello world".length); + }); + + it("moves to position before next newline in current segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "first line\nsecond line" }], + 0, + 0, + ); + + const result = moveCursorToLineEnd(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("first line".length); + }); + + it("moves to position before newline in subsequent text segment when current has none", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "prefix" }, + { type: "text", content: "line one\nline two" }, + ], + 0, + 3, + ); + + const result = moveCursorToLineEnd(initialState); + + expect(result.cursorOnSegmentIndex).toBe(1); + expect(result.cursorInSegmentOffset).toBe("line one".length); + }); + + it("skips largePaste segments when searching for next newline and moves to end of buffer when none found", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "start" }, + { type: "largePaste", content: "with\ninternal\nnewlines" }, + { type: "text", content: "tail" }, + ], + 0, + 2, + ); + + const result = moveCursorToLineEnd(initialState); + + expect(result.cursorOnSegmentIndex).toBe(2); + expect(result.cursorInSegmentOffset).toBe("tail".length); + }); + }); + describe("insertTextAtCursor edge cases", () => { it("creates new text segment before largePaste when it is the first segment", () => { const initialState = createChatUserInputState( diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 16b7413d..2f78ebcc 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -49,6 +49,11 @@ interface InsertSuggestionAtCursorOpts { suggestionText: string; } +interface CursorPosition { + segmentIndex: number; + offset: number; +} + type ChatUserInputStateMutator = (draft: ChatUserInputState) => void; /** @@ -65,6 +70,145 @@ function produceSanitizedState( }); } +function findLineStartPosition(state: ChatUserInputState): CursorPosition { + if (state.segments.length === 0) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const cursorSegmentIndex = state.cursorOnSegmentIndex; + const cursorOffset = state.cursorInSegmentOffset; + + if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const currentSegment = state.segments[cursorSegmentIndex]; + + if (currentSegment !== undefined && currentSegment.type === "text") { + const textBeforeCursor = currentSegment.content.slice(0, cursorOffset); + const lastNewlineInCurrentSegment = textBeforeCursor.lastIndexOf("\n"); + + if (lastNewlineInCurrentSegment !== -1) { + return { + segmentIndex: cursorSegmentIndex, + offset: lastNewlineInCurrentSegment + 1, + }; + } + } + + for (let segmentIndex = cursorSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { + const segment = state.segments[segmentIndex]; + + if (segment === undefined || segment.type !== "text") { + continue; + } + + const lastNewlineInSegment = segment.content.lastIndexOf("\n"); + + if (lastNewlineInSegment !== -1) { + return { + segmentIndex, + offset: lastNewlineInSegment + 1, + }; + } + } + + return { + segmentIndex: 0, + offset: 0, + }; +} + +function findLineEndPosition(state: ChatUserInputState): CursorPosition { + if (state.segments.length === 0) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const cursorSegmentIndex = state.cursorOnSegmentIndex; + const cursorOffset = state.cursorInSegmentOffset; + + if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const currentSegment = state.segments[cursorSegmentIndex]; + + if (currentSegment !== undefined && currentSegment.type === "text") { + const textAfterCursor = currentSegment.content.slice(cursorOffset); + const newlineRelativeIndex = textAfterCursor.indexOf("\n"); + + if (newlineRelativeIndex !== -1) { + const newlineAbsoluteIndex = cursorOffset + newlineRelativeIndex; + + return { + segmentIndex: cursorSegmentIndex, + offset: newlineAbsoluteIndex, + }; + } + } + + for ( + let segmentIndex = cursorSegmentIndex + 1; + segmentIndex < state.segments.length; + segmentIndex += 1 + ) { + const segment = state.segments[segmentIndex]; + + if (segment === undefined || segment.type !== "text") { + continue; + } + + const newlineIndex = segment.content.indexOf("\n"); + + if (newlineIndex !== -1) { + return { + segmentIndex, + offset: newlineIndex, + }; + } + } + + const lastSegmentIndex = state.segments.length - 1; + const lastSegment = state.segments[lastSegmentIndex]; + + if (lastSegment !== undefined && lastSegment.type === "text") { + return { + segmentIndex: lastSegmentIndex, + offset: lastSegment.content.length, + }; + } + + for (let segmentIndex = lastSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { + const segment = state.segments[segmentIndex]; + + if (segment === undefined || segment.type !== "text") { + continue; + } + + return { + segmentIndex, + offset: segment.content.length, + }; + } + + return { + segmentIndex: 0, + offset: 0, + }; +} + /** * Ensures the input state is valid by: * 1. Guaranteeing at least one segment exists @@ -436,6 +580,24 @@ export function moveCursorRight(state: ChatUserInputState): ChatUserInputState { }); } +export function moveCursorToLineStart(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const lineStartPosition = findLineStartPosition(draft); + + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; + draft.cursorInSegmentOffset = lineStartPosition.offset; + }); +} + +export function moveCursorToLineEnd(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const lineEndPosition = findLineEndPosition(draft); + + draft.cursorOnSegmentIndex = lineEndPosition.segmentIndex; + draft.cursorInSegmentOffset = lineEndPosition.offset; + }); +} + /** * Inserts text at the cursor position. * - In text segment: inserts text at cursor position From cd574354943094f5e90cf9c57b4675fad6584b5f Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 18 Dec 2025 16:21:46 -0500 Subject: [PATCH 02/25] Stage II: Word shortcuts --- src/subcommands/chat/react/ChatInput.tsx | 26 ++ .../chat/react/inputReducer.test.ts | 232 +++++++++++++++++- src/subcommands/chat/react/inputReducer.ts | 171 +++++++++++++ 3 files changed, 427 insertions(+), 2 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 10f6a370..286a7f5b 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -5,11 +5,15 @@ import { InputPlaceholder } from "./InputPlaceholder.js"; import { deleteAfterCursor, deleteBeforeCursor, + deleteWordBackward, + deleteWordForward, insertTextAtCursor, moveCursorLeft, moveCursorRight, moveCursorToLineEnd, moveCursorToLineStart, + moveCursorWordLeft, + moveCursorWordRight, } from "./inputReducer.js"; import { renderInputWithCursor } from "./inputRenderer.js"; import { type ChatUserInputState } from "./types.js"; @@ -116,6 +120,28 @@ export const ChatInput = ({ setUserInputState(previousState => moveCursorLeft(previousState)); return; } + + if (inputCharacter === "w") { + setUserInputState(previousState => deleteWordBackward(previousState)); + return; + } + } + + if (key.meta === true) { + if (inputCharacter === "f") { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; + } + + if (inputCharacter === "b") { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + + if (inputCharacter === "d") { + setUserInputState(previousState => deleteWordForward(previousState)); + return; + } } if (areSuggestionsVisible) { diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 1171c15f..a7ab183b 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1,6 +1,8 @@ import { deleteAfterCursor, deleteBeforeCursor, + deleteWordBackward, + deleteWordForward, insertPasteAtCursor, insertSuggestionAtCursor, insertTextAtCursor, @@ -8,6 +10,8 @@ import { moveCursorRight, moveCursorToLineEnd, moveCursorToLineStart, + moveCursorWordLeft, + moveCursorWordRight, } from "./inputReducer.js"; import { type ChatInputSegment, type ChatUserInputState } from "./types.js"; @@ -1253,6 +1257,114 @@ describe("chatInputStateReducers", () => { }); }); + describe("moveCursorWordLeft", () => { + it("moves to start of previous word within a text segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello world".length, + ); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello ".length); + }); + + it("skips whitespace then moves to start of previous word", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello world".length, + ); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello ".length); + }); + + it("does nothing when at start of segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 0, + ); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when current segment is largePaste", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "pasted content" }], + 0, + 0, + ); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + + describe("moveCursorWordRight", () => { + it("moves to end of current word within a text segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 0, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); + + it("skips whitespace then moves to end of next word", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello".length, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello world".length); + }); + + it("does nothing when at end of segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello" }], + 0, + "hello".length, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); + + it("does nothing when current segment is largePaste", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "pasted content" }], + 0, + 0, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + describe("moveCursorToLineStart", () => { it("moves to start of buffer when there is no newline", () => { const initialState = createChatUserInputState( @@ -1353,8 +1465,8 @@ describe("chatInputStateReducers", () => { const result = moveCursorToLineEnd(initialState); - expect(result.cursorOnSegmentIndex).toBe(1); - expect(result.cursorInSegmentOffset).toBe("line one".length); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("prefixline one".length); }); it("skips largePaste segments when searching for next newline and moves to end of buffer when none found", () => { @@ -1396,6 +1508,122 @@ describe("chatInputStateReducers", () => { }); }); + describe("deleteWordBackward", () => { + it("deletes previous word within a text segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello world".length, + ); + + const result = deleteWordBackward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello " }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello ".length); + }); + + it("deletes previous word when there is no trailing whitespace", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello world".length, + ); + + const result = deleteWordBackward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello " }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello ".length); + }); + + it("does nothing when at start of segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 0, + ); + + const result = deleteWordBackward(initialState); + + expect(result.segments).toEqual(initialState.segments); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when current segment is largePaste", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "pasted content" }], + 0, + 0, + ); + + const result = deleteWordBackward(initialState); + + expect(result.segments).toEqual(initialState.segments); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + + describe("deleteWordForward", () => { + it("deletes next word within a text segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + 0, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: " world" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes whitespace then next word", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello world" }], + 0, + "hello".length, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); + + it("does nothing when at end of segment", () => { + const initialState = createChatUserInputState( + [{ type: "text", content: "hello" }], + 0, + "hello".length, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual(initialState.segments); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); + + it("does nothing when current segment is largePaste", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "pasted content" }], + 0, + 0, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual(initialState.segments); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + describe("insertPasteAtCursor edge cases", () => { it("inserts large paste before current largePaste when cursor is at start", () => { const initialState = createChatUserInputState( diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 2f78ebcc..dba65b69 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -209,6 +209,90 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } +function isWhitespaceCharacter(character: string): boolean { + return /\s/.test(character); +} + +function findPreviousWordBoundaryInSegment(content: string, cursorOffset: number): number { + if (cursorOffset <= 0) { + return 0; + } + + const segmentLength = content.length; + + if (segmentLength === 0) { + return 0; + } + + let scanIndex = cursorOffset; + + if (scanIndex > segmentLength) { + scanIndex = segmentLength; + } + + while (scanIndex > 0) { + const previousCharacter = content.charAt(scanIndex - 1); + + if (isWhitespaceCharacter(previousCharacter) === true) { + scanIndex -= 1; + } else { + break; + } + } + + while (scanIndex > 0) { + const previousCharacter = content.charAt(scanIndex - 1); + + if (isWhitespaceCharacter(previousCharacter) === false) { + scanIndex -= 1; + } else { + break; + } + } + + return scanIndex; +} + +function findNextWordBoundaryInSegment(content: string, cursorOffset: number): number { + const segmentLength = content.length; + + if (segmentLength === 0) { + return 0; + } + + let scanIndex = cursorOffset; + + if (scanIndex < 0) { + scanIndex = 0; + } + + if (scanIndex >= segmentLength) { + return segmentLength; + } + + while (scanIndex < segmentLength) { + const character = content.charAt(scanIndex); + + if (isWhitespaceCharacter(character) === true) { + scanIndex += 1; + } else { + break; + } + } + + while (scanIndex < segmentLength) { + const character = content.charAt(scanIndex); + + if (isWhitespaceCharacter(character) === false) { + scanIndex += 1; + } else { + break; + } + } + + return scanIndex; +} + /** * Ensures the input state is valid by: * 1. Guaranteeing at least one segment exists @@ -598,6 +682,93 @@ export function moveCursorToLineEnd(state: ChatUserInputState): ChatUserInputSta }); } +export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + + if (currentSegment === undefined || currentSegment.type !== "text") { + return; + } + + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + + draft.cursorInSegmentOffset = newCursorOffset; + }); +} + +export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + + if (currentSegment === undefined || currentSegment.type !== "text") { + return; + } + + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + + draft.cursorInSegmentOffset = newCursorOffset; + }); +} + +export function deleteWordBackward(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + + if (currentSegment === undefined || currentSegment.type !== "text") { + return; + } + + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; + + if (segmentLength === 0 || cursorOffset <= 0) { + return; + } + + const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + + if (newCursorOffset === cursorOffset) { + return; + } + + currentSegment.content = + segmentContent.slice(0, newCursorOffset) + segmentContent.slice(cursorOffset); + draft.cursorInSegmentOffset = newCursorOffset; + }); +} + +export function deleteWordForward(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + + if (currentSegment === undefined || currentSegment.type !== "text") { + return; + } + + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; + + if (segmentLength === 0 || cursorOffset >= segmentLength) { + return; + } + + const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + + if (newCursorOffset === cursorOffset) { + return; + } + + currentSegment.content = + segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); + }); +} + /** * Inserts text at the cursor position. * - In text segment: inserts text at cursor position From 44bfd33b2b3350be4e107ea73439bd422f446196 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 18 Dec 2025 16:50:45 -0500 Subject: [PATCH 03/25] Stage III: Treat pastes as words --- .../chat/react/inputReducer.test.ts | 84 +++++- src/subcommands/chat/react/inputReducer.ts | 273 +++++++++++++++--- 2 files changed, 306 insertions(+), 51 deletions(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index a7ab183b..a6bf0a61 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1297,16 +1297,20 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe(0); }); - it("does nothing when current segment is largePaste", () => { + it("treats largePaste before trailing placeholder as a word when moving left", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "pasted content" }], - 0, + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(1000) }, + { type: "text", content: "" }, + ], + 2, 0, ); const result = moveCursorWordLeft(initialState); - expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorOnSegmentIndex).toBe(1); expect(result.cursorInSegmentOffset).toBe(0); }); }); @@ -1351,16 +1355,20 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("hello".length); }); - it("does nothing when current segment is largePaste", () => { + it("treats largePaste as a word when moving right from preceding text", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "pasted content" }], - 0, + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(1000) }, + { type: "text", content: "" }, + ], 0, + "before".length, ); const result = moveCursorWordRight(initialState); - expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorOnSegmentIndex).toBe(1); expect(result.cursorInSegmentOffset).toBe(0); }); }); @@ -1551,18 +1559,40 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe(0); }); - it("does nothing when current segment is largePaste", () => { + it("deletes previous largePaste when cursor is at start of trailing placeholder", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "pasted content" }], + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ], + 2, 0, + ); + + const result = deleteWordBackward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "before" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("before".length); + }); + + it("deletes current largePaste when cursor is on it", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ], + 1, 0, ); const result = deleteWordBackward(initialState); - expect(result.segments).toEqual(initialState.segments); + expect(result.segments).toEqual([{ type: "text", content: "before" }]); expect(result.cursorOnSegmentIndex).toBe(0); - expect(result.cursorInSegmentOffset).toBe(0); + expect(result.cursorInSegmentOffset).toBe("before".length); }); }); @@ -1609,18 +1639,40 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("hello".length); }); - it("does nothing when current segment is largePaste", () => { + it("deletes next largePaste when cursor is at end of preceding text", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "pasted content" }], + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ], 0, + "before".length, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "before" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("before".length); + }); + + it("deletes current largePaste when cursor is on it", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ], + 1, 0, ); const result = deleteWordForward(initialState); - expect(result.segments).toEqual(initialState.segments); + expect(result.segments).toEqual([{ type: "text", content: "before" }]); expect(result.cursorOnSegmentIndex).toBe(0); - expect(result.cursorInSegmentOffset).toBe(0); + expect(result.cursorInSegmentOffset).toBe("before".length); }); }); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index dba65b69..4501d628 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -684,88 +684,291 @@ export function moveCursorToLineEnd(state: ChatUserInputState): ChatUserInputSta export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { - const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (currentSegment === undefined) { + return; + } + + if (currentSegment.type === "text") { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + + if (cursorOffset > 0) { + const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + + draft.cursorInSegmentOffset = newCursorOffset; + return; + } + + const previousSegmentIndex = currentSegmentIndex - 1; + const previousSegment = draft.segments[previousSegmentIndex]; + + if (previousSegment === undefined) { + return; + } + + if (previousSegment.type === "largePaste") { + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } - if (currentSegment === undefined || currentSegment.type !== "text") { + const previousContent = previousSegment.content; + const newCursorOffset = findPreviousWordBoundaryInSegment( + previousContent, + previousContent.length, + ); + + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; return; } - const segmentContent = currentSegment.content; - const cursorOffset = draft.cursorInSegmentOffset; - const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + const previousSegmentIndex = currentSegmentIndex - 1; + const previousSegment = draft.segments[previousSegmentIndex]; + + if (previousSegment === undefined) { + return; + } + if (previousSegment.type === "largePaste") { + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + const previousContent = previousSegment.content; + const newCursorOffset = findPreviousWordBoundaryInSegment( + previousContent, + previousContent.length, + ); + + draft.cursorOnSegmentIndex = previousSegmentIndex; draft.cursorInSegmentOffset = newCursorOffset; }); } export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { - const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentSegment = draft.segments[currentSegmentIndex]; - if (currentSegment === undefined || currentSegment.type !== "text") { + if (currentSegment === undefined) { return; } - const segmentContent = currentSegment.content; - const cursorOffset = draft.cursorInSegmentOffset; - const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + if (currentSegment.type === "text") { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; + + if (cursorOffset < segmentLength) { + const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + + draft.cursorInSegmentOffset = newCursorOffset; + return; + } + + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; + + if (nextSegment === undefined) { + return; + } + + if (nextSegment.type === "largePaste") { + draft.cursorOnSegmentIndex = nextSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + const nextContent = nextSegment.content; + const newCursorOffset = findNextWordBoundaryInSegment(nextContent, 0); + + draft.cursorOnSegmentIndex = nextSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; + return; + } + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; + + if (nextSegment === undefined) { + return; + } + + if (nextSegment.type === "largePaste") { + draft.cursorOnSegmentIndex = nextSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + const nextContent = nextSegment.content; + const newCursorOffset = findNextWordBoundaryInSegment(nextContent, 0); + + draft.cursorOnSegmentIndex = nextSegmentIndex; draft.cursorInSegmentOffset = newCursorOffset; }); } export function deleteWordBackward(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { - const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentSegment = draft.segments[currentSegmentIndex]; - if (currentSegment === undefined || currentSegment.type !== "text") { + if (currentSegment === undefined) { return; } - const segmentContent = currentSegment.content; - const cursorOffset = draft.cursorInSegmentOffset; - const segmentLength = segmentContent.length; + if (currentSegment.type === "text") { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; - if (segmentLength === 0 || cursorOffset <= 0) { - return; - } + if (cursorOffset > 0) { + const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + + if (newCursorOffset === cursorOffset) { + return; + } + + currentSegment.content = + segmentContent.slice(0, newCursorOffset) + segmentContent.slice(cursorOffset); + draft.cursorInSegmentOffset = newCursorOffset; + return; + } + + const previousSegmentIndex = currentSegmentIndex - 1; + const previousSegment = draft.segments[previousSegmentIndex]; + + if (previousSegment === undefined) { + return; + } + + if (previousSegment.type === "largePaste") { + draft.segments.splice(previousSegmentIndex, 1); + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + const previousContent = previousSegment.content; + const previousLength = previousContent.length; - const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + if (previousLength === 0) { + draft.segments.splice(previousSegmentIndex, 1); + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + const newCursorOffset = findPreviousWordBoundaryInSegment(previousContent, previousLength); - if (newCursorOffset === cursorOffset) { + previousSegment.content = previousContent.slice(0, newCursorOffset); + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; return; } - currentSegment.content = - segmentContent.slice(0, newCursorOffset) + segmentContent.slice(cursorOffset); - draft.cursorInSegmentOffset = newCursorOffset; + const currentSegmentIndexForLargePaste = draft.cursorOnSegmentIndex; + + if (currentSegment.type === "largePaste") { + const segmentIndexToRemove = currentSegmentIndexForLargePaste; + + draft.segments.splice(segmentIndexToRemove, 1); + + const newCursorSegmentIndex = Math.max(0, segmentIndexToRemove - 1); + const newCursorSegment = draft.segments[newCursorSegmentIndex]; + + draft.cursorOnSegmentIndex = newCursorSegmentIndex; + if (newCursorSegment?.type === "text") { + draft.cursorInSegmentOffset = newCursorSegment.content.length; + } else { + draft.cursorInSegmentOffset = 0; + } + } }); } export function deleteWordForward(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { - const currentSegment = draft.segments[draft.cursorOnSegmentIndex]; + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentSegment = draft.segments[currentSegmentIndex]; - if (currentSegment === undefined || currentSegment.type !== "text") { + if (currentSegment === undefined) { return; } - const segmentContent = currentSegment.content; - const cursorOffset = draft.cursorInSegmentOffset; - const segmentLength = segmentContent.length; + if (currentSegment.type === "text") { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; - if (segmentLength === 0 || cursorOffset >= segmentLength) { - return; - } + if (cursorOffset < segmentLength) { + const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + + if (newCursorOffset === cursorOffset) { + return; + } + + currentSegment.content = + segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); + return; + } + + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; - const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + if (nextSegment === undefined) { + return; + } + + if (nextSegment.type === "largePaste") { + draft.segments.splice(nextSegmentIndex, 1); + return; + } + + const nextContent = nextSegment.content; + const deleteEndOffset = findNextWordBoundaryInSegment(nextContent, 0); + + if (deleteEndOffset === 0) { + return; + } - if (newCursorOffset === cursorOffset) { + nextSegment.content = nextContent.slice(deleteEndOffset); return; } - currentSegment.content = - segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); + if (currentSegment.type === "largePaste") { + const segmentIndexToRemove = currentSegmentIndex; + + draft.segments.splice(segmentIndexToRemove, 1); + + if (segmentIndexToRemove >= draft.segments.length) { + const newCursorSegmentIndex = draft.segments.length - 1; + if (newCursorSegmentIndex < 0) { + draft.cursorOnSegmentIndex = 0; + draft.cursorInSegmentOffset = 0; + return; + } + draft.cursorOnSegmentIndex = newCursorSegmentIndex; + const newCursorSegment = draft.segments[newCursorSegmentIndex]; + if (newCursorSegment?.type === "text") { + draft.cursorInSegmentOffset = newCursorSegment.content.length; + } else { + draft.cursorInSegmentOffset = 0; + } + } else { + draft.cursorOnSegmentIndex = segmentIndexToRemove; + const newCursorSegment = draft.segments[segmentIndexToRemove]; + if (newCursorSegment?.type === "text") { + draft.cursorInSegmentOffset = 0; + } else { + draft.cursorInSegmentOffset = 0; + } + } + } }); } From 6f85d990deeb671151b4603badfed8e9cea49780 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Fri, 19 Dec 2025 10:59:53 -0500 Subject: [PATCH 04/25] Add comments --- src/subcommands/chat/react/inputReducer.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 4501d628..70e64056 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -71,6 +71,7 @@ function produceSanitizedState( } function findLineStartPosition(state: ChatUserInputState): CursorPosition { + // Handle empty state if (state.segments.length === 0) { return { segmentIndex: 0, @@ -81,6 +82,7 @@ function findLineStartPosition(state: ChatUserInputState): CursorPosition { const cursorSegmentIndex = state.cursorOnSegmentIndex; const cursorOffset = state.cursorInSegmentOffset; + // Validate cursor position if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { return { segmentIndex: 0, @@ -90,10 +92,12 @@ function findLineStartPosition(state: ChatUserInputState): CursorPosition { const currentSegment = state.segments[cursorSegmentIndex]; + // Check current segment for newline before cursor if (currentSegment !== undefined && currentSegment.type === "text") { const textBeforeCursor = currentSegment.content.slice(0, cursorOffset); const lastNewlineInCurrentSegment = textBeforeCursor.lastIndexOf("\n"); + // Found newline in current segment - line starts after it if (lastNewlineInCurrentSegment !== -1) { return { segmentIndex: cursorSegmentIndex, @@ -102,15 +106,18 @@ function findLineStartPosition(state: ChatUserInputState): CursorPosition { } } + // Search backward through previous segments for newline for (let segmentIndex = cursorSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { const segment = state.segments[segmentIndex]; + // Skip non-text segments (e.g., largePaste) if (segment === undefined || segment.type !== "text") { continue; } const lastNewlineInSegment = segment.content.lastIndexOf("\n"); + // Found newline in previous segment - line starts after it if (lastNewlineInSegment !== -1) { return { segmentIndex, @@ -119,6 +126,7 @@ function findLineStartPosition(state: ChatUserInputState): CursorPosition { } } + // No newline found - line starts at beginning of input return { segmentIndex: 0, offset: 0, @@ -126,6 +134,7 @@ function findLineStartPosition(state: ChatUserInputState): CursorPosition { } function findLineEndPosition(state: ChatUserInputState): CursorPosition { + // Handle empty state if (state.segments.length === 0) { return { segmentIndex: 0, @@ -136,6 +145,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { const cursorSegmentIndex = state.cursorOnSegmentIndex; const cursorOffset = state.cursorInSegmentOffset; + // Validate cursor position if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { return { segmentIndex: 0, @@ -145,10 +155,12 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { const currentSegment = state.segments[cursorSegmentIndex]; + // Check current segment for newline after cursor if (currentSegment !== undefined && currentSegment.type === "text") { const textAfterCursor = currentSegment.content.slice(cursorOffset); const newlineRelativeIndex = textAfterCursor.indexOf("\n"); + // Found newline in current segment - line ends at it if (newlineRelativeIndex !== -1) { const newlineAbsoluteIndex = cursorOffset + newlineRelativeIndex; @@ -159,6 +171,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { } } + // Search forward through following segments for newline for ( let segmentIndex = cursorSegmentIndex + 1; segmentIndex < state.segments.length; @@ -166,12 +179,14 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { ) { const segment = state.segments[segmentIndex]; + // Skip non-text segments (e.g., largePaste) if (segment === undefined || segment.type !== "text") { continue; } const newlineIndex = segment.content.indexOf("\n"); + // Found newline in following segment - line ends at it if (newlineIndex !== -1) { return { segmentIndex, @@ -180,6 +195,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { } } + // No newline found - line ends at end of last text segment const lastSegmentIndex = state.segments.length - 1; const lastSegment = state.segments[lastSegmentIndex]; @@ -190,6 +206,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } + // Last segment is not text - search backward for last text segment for (let segmentIndex = lastSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { const segment = state.segments[segmentIndex]; @@ -203,6 +220,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } + // No text segments found - default to beginning return { segmentIndex: 0, offset: 0, From 0df5f87ec3e189b0bc3748f69aa5d88f5675df31 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Mon, 22 Dec 2025 17:18:55 -0500 Subject: [PATCH 05/25] Add more tests and cleanup code --- .../chat/react/inputReducer.test.ts | 154 ++++++++- src/subcommands/chat/react/inputReducer.ts | 308 +++++++++--------- 2 files changed, 312 insertions(+), 150 deletions(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index a6bf0a61..7238701a 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1033,6 +1033,20 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(2); expect(result.cursorInSegmentOffset).toBe(9); }); + + it("handles empty segments array by sanitizing before applying suggestion", () => { + const initialState = createChatUserInputState([], 0, 0); + + const result = insertSuggestionAtCursor({ + state: initialState, + suggestionText: "/model", + }); + + expect(result.segments.length).toBe(1); + expect(result.segments[0]).toEqual({ type: "text", content: "" }); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); }); describe("sanitizeChatUserInputState edge cases", () => { @@ -1271,6 +1285,46 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("hello ".length); }); + it("treats newline as whitespace when moving to previous word", () => { + const content = "hello\nworld"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello\n".length); + }); + + it("treats tab as whitespace when moving to previous word", () => { + const content = "hello\tworld"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello\t".length); + }); + + it("treats hyphen as a word separator when moving to previous word from end of segment", () => { + const content = "test-test"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("test-".length); + }); + + it("treats hyphen as a word separator when moving to previous word from after hyphen", () => { + const content = "test-test"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, "test-".length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + it("skips whitespace then moves to start of previous word", () => { const initialState = createChatUserInputState( [{ type: "text", content: "hello world" }], @@ -1313,6 +1367,22 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(1); expect(result.cursorInSegmentOffset).toBe(0); }); + + it("moves to start of previous segment last word when at start of current segment", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "first second" }, + { type: "text", content: "third" }, + ], + 1, + 0, + ); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("first ".length); + }); }); describe("moveCursorWordRight", () => { @@ -1329,6 +1399,46 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("hello".length); }); + it("treats newline as whitespace when moving to next word", () => { + const content = "hello\nworld"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, "hello".length); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(content.length); + }); + + it("treats tab as whitespace when moving to next word", () => { + const content = "hello\tworld"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, "hello".length); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(content.length); + }); + + it("treats hyphen as a word separator when moving to next word from start of segment", () => { + const content = "test-test more"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, 0); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("test".length); + }); + + it("treats hyphen as a word separator when moving to next word from hyphen", () => { + const content = "test-test more"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, "test-".length); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("test-test".length); + }); + it("skips whitespace then moves to end of next word", () => { const initialState = createChatUserInputState( [{ type: "text", content: "hello world" }], @@ -1371,6 +1481,24 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(1); expect(result.cursorInSegmentOffset).toBe(0); }); + + it("moves to end of next segment word when starting at end of current segment", () => { + const firstSegmentText = "hello"; + const secondSegmentText = " world"; + const initialState = createChatUserInputState( + [ + { type: "text", content: firstSegmentText }, + { type: "text", content: secondSegmentText }, + ], + 0, + firstSegmentText.length, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe((firstSegmentText + secondSegmentText).length); + }); }); describe("moveCursorToLineStart", () => { @@ -1577,7 +1705,7 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("before".length); }); - it("deletes current largePaste when cursor is on it", () => { + it("deletes previous text when cursor is on largePaste", () => { const initialState = createChatUserInputState( [ { type: "text", content: "before" }, @@ -1590,9 +1718,12 @@ describe("chatInputStateReducers", () => { const result = deleteWordBackward(initialState); - expect(result.segments).toEqual([{ type: "text", content: "before" }]); + expect(result.segments).toEqual([ + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ]); expect(result.cursorOnSegmentIndex).toBe(0); - expect(result.cursorInSegmentOffset).toBe("before".length); + expect(result.cursorInSegmentOffset).toBe(0); }); }); @@ -1674,6 +1805,23 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe("before".length); }); + + it("deletes first word from next text segment when cursor is at end of current text segment", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "hello" }, + { type: "text", content: " world" }, + ], + 0, + "hello".length, + ); + + const result = deleteWordForward(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); }); describe("insertPasteAtCursor edge cases", () => { diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 70e64056..bafe4f0f 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -199,6 +199,8 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { const lastSegmentIndex = state.segments.length - 1; const lastSegment = state.segments[lastSegmentIndex]; + // Can never be the case because we have our trailing placeholder rule + // but we handle it anyway as this runs before sanitation if (lastSegment !== undefined && lastSegment.type === "text") { return { segmentIndex: lastSegmentIndex, @@ -206,6 +208,8 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } + // Again, this can never be the case because of our trailing placeholder rule + // but we handle it anyway as this runs before sanitation // Last segment is not text - search backward for last text segment for (let segmentIndex = lastSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { const segment = state.segments[segmentIndex]; @@ -227,10 +231,26 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } -function isWhitespaceCharacter(character: string): boolean { - return /\s/.test(character); +function isWordSeparatorCharacter(character: string): boolean { + if (/\s/.test(character) === true) { + return true; + } + + if (character === "-") { + return true; + } + + return false; } +/** + * Finds the previous word boundary in a text segment given a cursor offset. + * The word boundary is defined as the position before the start of the word + * that precedes the cursor offset. + * @param content - The text content of the segment + * @param cursorOffset - The cursor position within the segment + * @returns The offset of the previous word boundary + */ function findPreviousWordBoundaryInSegment(content: string, cursorOffset: number): number { if (cursorOffset <= 0) { return 0; @@ -248,22 +268,28 @@ function findPreviousWordBoundaryInSegment(content: string, cursorOffset: number scanIndex = segmentLength; } + // Scan backwards over any word separator characters while (scanIndex > 0) { const previousCharacter = content.charAt(scanIndex - 1); - if (isWhitespaceCharacter(previousCharacter) === true) { + // If previous character is a word separator, keep moving left + if (isWordSeparatorCharacter(previousCharacter) === true) { scanIndex -= 1; } else { + // Found a non-separator character, stop scanning break; } } + // Now scan backwards over non-separator characters to find the start of the word while (scanIndex > 0) { const previousCharacter = content.charAt(scanIndex - 1); - if (isWhitespaceCharacter(previousCharacter) === false) { + // If previous character is not a word separator, keep moving left + if (isWordSeparatorCharacter(previousCharacter) === false) { scanIndex -= 1; } else { + // Found a separator character, stop scanning break; } } @@ -271,6 +297,14 @@ function findPreviousWordBoundaryInSegment(content: string, cursorOffset: number return scanIndex; } +/** + * Finds the next word boundary in a text segment given a cursor offset. + * The word boundary is defined as the position after the end of the word + * that follows the cursor offset. + * @param content - The text content of the segment + * @param cursorOffset - The cursor position within the segment + * @returns The offset of the next word boundary + */ function findNextWordBoundaryInSegment(content: string, cursorOffset: number): number { const segmentLength = content.length; @@ -288,22 +322,29 @@ function findNextWordBoundaryInSegment(content: string, cursorOffset: number): n return segmentLength; } + // Scan forwards over any word separator characters + // to find the start of the next word while (scanIndex < segmentLength) { const character = content.charAt(scanIndex); - if (isWhitespaceCharacter(character) === true) { + // If character is a word separator, keep moving right + if (isWordSeparatorCharacter(character) === true) { scanIndex += 1; } else { + // Found a non-separator character, stop scanning break; } } + // Now scan forwards over non-separator characters while (scanIndex < segmentLength) { const character = content.charAt(scanIndex); - if (isWhitespaceCharacter(character) === false) { + // If character is not a word separator, keep moving right + if (isWordSeparatorCharacter(character) === false) { scanIndex += 1; } else { + // Found a separator character, stop scanning break; } } @@ -682,6 +723,11 @@ export function moveCursorRight(state: ChatUserInputState): ChatUserInputState { }); } +/** + * Moves the cursor to the start of the current line. + * Handles multi-segment inputs and newlines. + * @returns Updated ChatUserInputState with cursor at line start + */ export function moveCursorToLineStart(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const lineStartPosition = findLineStartPosition(draft); @@ -691,6 +737,11 @@ export function moveCursorToLineStart(state: ChatUserInputState): ChatUserInputS }); } +/** + * Moves the cursor to the end of the current line. + * Handles multi-segment inputs and newlines. + * @returns Updated ChatUserInputState with cursor at line end + */ export function moveCursorToLineEnd(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const lineEndPosition = findLineEndPosition(draft); @@ -700,6 +751,13 @@ export function moveCursorToLineEnd(state: ChatUserInputState): ChatUserInputSta }); } +/** + * Moves the cursor one word to the left. + * For text segments, + * if inside text segment, finds the previous word boundary. + * if at start of text segment, moves to the end of the previous text segment. + * For largePaste segments, moves to the previous segment. + */ export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const currentSegmentIndex = draft.cursorOnSegmentIndex; @@ -713,37 +771,16 @@ export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputStat const segmentContent = currentSegment.content; const cursorOffset = draft.cursorInSegmentOffset; + // Inside text segment if (cursorOffset > 0) { + // Find previous word boundary within current segment and move cursor there const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); - draft.cursorInSegmentOffset = newCursorOffset; return; } - - const previousSegmentIndex = currentSegmentIndex - 1; - const previousSegment = draft.segments[previousSegmentIndex]; - - if (previousSegment === undefined) { - return; - } - - if (previousSegment.type === "largePaste") { - draft.cursorOnSegmentIndex = previousSegmentIndex; - draft.cursorInSegmentOffset = 0; - return; - } - - const previousContent = previousSegment.content; - const newCursorOffset = findPreviousWordBoundaryInSegment( - previousContent, - previousContent.length, - ); - - draft.cursorOnSegmentIndex = previousSegmentIndex; - draft.cursorInSegmentOffset = newCursorOffset; - return; } + // At the start of the current segment (text or largePaste) - move to previous segment const previousSegmentIndex = currentSegmentIndex - 1; const previousSegment = draft.segments[previousSegmentIndex]; @@ -751,12 +788,14 @@ export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputStat return; } + // If previous segment is largePaste, move cursor there if (previousSegment.type === "largePaste") { draft.cursorOnSegmentIndex = previousSegmentIndex; draft.cursorInSegmentOffset = 0; return; } + // The previous segment is text - move to its end word boundary const previousContent = previousSegment.content; const newCursorOffset = findPreviousWordBoundaryInSegment( previousContent, @@ -768,6 +807,13 @@ export function moveCursorWordLeft(state: ChatUserInputState): ChatUserInputStat }); } +/** + * Moves the cursor one word to the right. + * For text segments, + * if inside text segment, finds the next word boundary. + * if at end of text segment, moves to the start of the next text segment. + * For largePaste segments, moves to the next segment. + */ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const currentSegmentIndex = draft.cursorOnSegmentIndex; @@ -782,34 +828,16 @@ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputSta const cursorOffset = draft.cursorInSegmentOffset; const segmentLength = segmentContent.length; + // Inside text segment if (cursorOffset < segmentLength) { + // Find next word boundary within current segment and move cursor there const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); - draft.cursorInSegmentOffset = newCursorOffset; return; } - - const nextSegmentIndex = currentSegmentIndex + 1; - const nextSegment = draft.segments[nextSegmentIndex]; - - if (nextSegment === undefined) { - return; - } - - if (nextSegment.type === "largePaste") { - draft.cursorOnSegmentIndex = nextSegmentIndex; - draft.cursorInSegmentOffset = 0; - return; - } - - const nextContent = nextSegment.content; - const newCursorOffset = findNextWordBoundaryInSegment(nextContent, 0); - - draft.cursorOnSegmentIndex = nextSegmentIndex; - draft.cursorInSegmentOffset = newCursorOffset; - return; } + // At the end of the current segment (text or largePaste) - move to next segment const nextSegmentIndex = currentSegmentIndex + 1; const nextSegment = draft.segments[nextSegmentIndex]; @@ -817,12 +845,14 @@ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputSta return; } + // If next segment is largePaste, move cursor there if (nextSegment.type === "largePaste") { draft.cursorOnSegmentIndex = nextSegmentIndex; draft.cursorInSegmentOffset = 0; return; } + // The next segment is text - move to its start word boundary const nextContent = nextSegment.content; const newCursorOffset = findNextWordBoundaryInSegment(nextContent, 0); @@ -831,6 +861,13 @@ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputSta }); } +/** + * Deletes the word before the cursor. + * Handles all segment types and cursor positions: + * - On text segment: deletes word before cursor + * - On largePaste at offset 0: deletes the previous segment + * + */ export function deleteWordBackward(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const currentSegmentIndex = draft.cursorOnSegmentIndex; @@ -844,71 +881,59 @@ export function deleteWordBackward(state: ChatUserInputState): ChatUserInputStat const segmentContent = currentSegment.content; const cursorOffset = draft.cursorInSegmentOffset; + // Inside text segment if (cursorOffset > 0) { + // Find previous word boundary within current segment const newCursorOffset = findPreviousWordBoundaryInSegment(segmentContent, cursorOffset); + // We are at the start of a word - nothing to delete if (newCursorOffset === cursorOffset) { return; } + // Delete content from newCursorOffset to cursorOffset currentSegment.content = segmentContent.slice(0, newCursorOffset) + segmentContent.slice(cursorOffset); draft.cursorInSegmentOffset = newCursorOffset; return; } + } + // At the start of the current segment (text or largePaste) - delete from previous segment + const previousSegmentIndex = currentSegmentIndex - 1; + const previousSegment = draft.segments[previousSegmentIndex]; - const previousSegmentIndex = currentSegmentIndex - 1; - const previousSegment = draft.segments[previousSegmentIndex]; - - if (previousSegment === undefined) { - return; - } - - if (previousSegment.type === "largePaste") { - draft.segments.splice(previousSegmentIndex, 1); - draft.cursorOnSegmentIndex = previousSegmentIndex; - draft.cursorInSegmentOffset = 0; - return; - } - - const previousContent = previousSegment.content; - const previousLength = previousContent.length; - - if (previousLength === 0) { - draft.segments.splice(previousSegmentIndex, 1); - draft.cursorOnSegmentIndex = previousSegmentIndex; - draft.cursorInSegmentOffset = 0; - return; - } - - const newCursorOffset = findPreviousWordBoundaryInSegment(previousContent, previousLength); + if (previousSegment === undefined) { + return; + } - previousSegment.content = previousContent.slice(0, newCursorOffset); + // Previous segment is largePaste - delete the entire segment + if (previousSegment.type === "largePaste") { + draft.segments.splice(previousSegmentIndex, 1); draft.cursorOnSegmentIndex = previousSegmentIndex; - draft.cursorInSegmentOffset = newCursorOffset; + draft.cursorInSegmentOffset = 0; return; } - const currentSegmentIndexForLargePaste = draft.cursorOnSegmentIndex; - - if (currentSegment.type === "largePaste") { - const segmentIndexToRemove = currentSegmentIndexForLargePaste; - - draft.segments.splice(segmentIndexToRemove, 1); + // Previous segment is text - delete last word from it + const previousContent = previousSegment.content; + const previousLength = previousContent.length; - const newCursorSegmentIndex = Math.max(0, segmentIndexToRemove - 1); - const newCursorSegment = draft.segments[newCursorSegmentIndex]; + // Find previous word boundary in previous text segment + const newCursorOffset = findPreviousWordBoundaryInSegment(previousContent, previousLength); - draft.cursorOnSegmentIndex = newCursorSegmentIndex; - if (newCursorSegment?.type === "text") { - draft.cursorInSegmentOffset = newCursorSegment.content.length; - } else { - draft.cursorInSegmentOffset = 0; - } - } + // Delete content from newCursorOffset to end of previous segment + previousSegment.content = previousContent.slice(0, newCursorOffset); + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; }); } +/** + * Deletes the word after the cursor. + * Handles all segment types and cursor positions: + * - On text segment: deletes word after cursor or the next large paste + * - On largePaste at offset 0: deletes the paste segment + */ export function deleteWordForward(state: ChatUserInputState): ChatUserInputState { return produceSanitizedState(state, draft => { const currentSegmentIndex = draft.cursorOnSegmentIndex; @@ -918,73 +943,62 @@ export function deleteWordForward(state: ChatUserInputState): ChatUserInputState return; } - if (currentSegment.type === "text") { - const segmentContent = currentSegment.content; - const cursorOffset = draft.cursorInSegmentOffset; - const segmentLength = segmentContent.length; + const currentSegmentType = currentSegment.type; + switch (currentSegmentType) { + case "text": { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; - if (cursorOffset < segmentLength) { - const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + // Inside text segment + if (cursorOffset < segmentLength) { + // Find next word boundary within current segment and delete up to there + const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); + if (newCursorOffset === cursorOffset) { + return; + } - if (newCursorOffset === cursorOffset) { + // Delete content from cursorOffset to newCursorOffset + currentSegment.content = + segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); return; } - currentSegment.content = - segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); - return; - } - - const nextSegmentIndex = currentSegmentIndex + 1; - const nextSegment = draft.segments[nextSegmentIndex]; + // At end of text segment - delete next segment + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; - if (nextSegment === undefined) { - return; - } + if (nextSegment === undefined) { + return; + } - if (nextSegment.type === "largePaste") { - draft.segments.splice(nextSegmentIndex, 1); - return; - } + // If next segment is largePaste, delete it + if (nextSegment.type === "largePaste") { + draft.segments.splice(nextSegmentIndex, 1); + return; + } - const nextContent = nextSegment.content; - const deleteEndOffset = findNextWordBoundaryInSegment(nextContent, 0); + // Next segment is text - delete first word from it + const nextContent = nextSegment.content; + const nextWordBoundary = findNextWordBoundaryInSegment(nextContent, 0); - if (deleteEndOffset === 0) { + // Delete content from start to nextWordBoundary in next segment + nextSegment.content = nextContent.slice(nextWordBoundary); return; } + case "largePaste": { + const segmentIndexToRemove = currentSegmentIndex; + draft.segments.splice(segmentIndexToRemove, 1); - nextSegment.content = nextContent.slice(deleteEndOffset); - return; - } - - if (currentSegment.type === "largePaste") { - const segmentIndexToRemove = currentSegmentIndex; - - draft.segments.splice(segmentIndexToRemove, 1); - - if (segmentIndexToRemove >= draft.segments.length) { - const newCursorSegmentIndex = draft.segments.length - 1; - if (newCursorSegmentIndex < 0) { - draft.cursorOnSegmentIndex = 0; - draft.cursorInSegmentOffset = 0; - return; - } - draft.cursorOnSegmentIndex = newCursorSegmentIndex; - const newCursorSegment = draft.segments[newCursorSegmentIndex]; - if (newCursorSegment?.type === "text") { - draft.cursorInSegmentOffset = newCursorSegment.content.length; - } else { - draft.cursorInSegmentOffset = 0; - } - } else { + // We know for sure that there is at least one segment left after sanitation + // due to the trailing placeholder rule so segmentIndexToRemove will be valid draft.cursorOnSegmentIndex = segmentIndexToRemove; - const newCursorSegment = draft.segments[segmentIndexToRemove]; - if (newCursorSegment?.type === "text") { - draft.cursorInSegmentOffset = 0; - } else { - draft.cursorInSegmentOffset = 0; - } + draft.cursorInSegmentOffset = 0; + break; + } + default: { + const exhaustiveCheck: never = currentSegmentType; + throw new Error(`Unhandled segment type: ${exhaustiveCheck}`); } } }); From 5d4207ef2da61529ad3d1d9724f877ecd12cedd7 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 12:02:19 -0500 Subject: [PATCH 06/25] Stage IV: Delete after cursor --- src/subcommands/chat/react/ChatInput.tsx | 10 +- .../chat/react/inputReducer.test.ts | 295 ++++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 286a7f5b..6a19b33e 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -125,6 +125,11 @@ export const ChatInput = ({ setUserInputState(previousState => deleteWordBackward(previousState)); return; } + + if (inputCharacter === "d") { + setUserInputState(previousState => deleteAfterCursor(previousState)); + return; + } } if (key.meta === true) { @@ -137,11 +142,14 @@ export const ChatInput = ({ setUserInputState(previousState => moveCursorWordLeft(previousState)); return; } - if (inputCharacter === "d") { setUserInputState(previousState => deleteWordForward(previousState)); return; } + if (key.delete === true) { + setUserInputState(previousState => deleteWordBackward(previousState)); + return; + } } if (areSuggestionsVisible) { diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 7238701a..bc24c0e6 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1862,4 +1862,299 @@ describe("chatInputStateReducers", () => { expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); }); }); + describe("deleteAfterCursor", () => { + it("deletes character at cursor position within text segment", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 2); + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "helo" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(2); + }); + + it("does nothing at end of input when cursor is at end of last text segment", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 5); + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("merges next text segment when deleting at end of current text segment", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "hello" }, + { type: "text", content: "world" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "helloorld" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("deletes next largePaste segment when cursor is at end of text segment", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "hello" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("deletes largePaste segment when cursor is on it at offset 0", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "hello" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 0, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("does nothing when cursor is on largePaste with invalid offset greater than 0", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "hello" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([ + { type: "text", content: "hello" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "" }, + ]); + expect(result.cursorOnSegmentIndex).toBe(1); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes first character of text segment when cursor is at start", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 0); + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "ello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes last character of text segment when cursor is before it", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 4); + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hell" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(4); + }); + + it("deletes multiple characters in succession when called multiple times", () => { + const initialState = createChatUserInputState([{ type: "text", content: "hello" }], 0, 1); + + const afterFirst = deleteAfterCursor(initialState); + expect(afterFirst.segments).toEqual([{ type: "text", content: "hllo" }]); + expect(afterFirst.cursorInSegmentOffset).toBe(1); + + const afterSecond = deleteAfterCursor(afterFirst); + expect(afterSecond.segments).toEqual([{ type: "text", content: "hlo" }]); + expect(afterSecond.cursorInSegmentOffset).toBe(1); + + const afterThird = deleteAfterCursor(afterSecond); + expect(afterThird.segments).toEqual([{ type: "text", content: "ho" }]); + expect(afterThird.cursorInSegmentOffset).toBe(1); + }); + + it("deletes first character of next segment at segment boundary", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "abc" }, + { type: "text", content: "def" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 3, + }; + + const result = deleteAfterCursor(initialState); + + // Consecutive text segments are merged by sanitization + expect(result.segments).toEqual([{ type: "text", content: "abcef" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(3); + }); + + it("deletes largePaste between two text segments", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "after" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 6, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "beforeafter" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("deletes multiple largePaste segments in succession", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "start" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "largePaste", content: "y".repeat(300) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const afterFirst = deleteAfterCursor(initialState); + expect(afterFirst.segments).toEqual([ + { type: "text", content: "start" }, + { type: "largePaste", content: "y".repeat(300) }, + { type: "text", content: "" }, + ]); + + const afterSecond = deleteAfterCursor(afterFirst); + expect(afterSecond.segments).toEqual([{ type: "text", content: "start" }]); + }); + + it("handles empty text segment by deleting first char of next segment", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "" }, + { type: "text", content: "hello" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteAfterCursor(initialState); + + // Empty segment is removed by sanitization + expect(result.segments).toEqual([{ type: "text", content: "ello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes trailing placeholder after deleting largePaste when cursor is on largePaste", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "hello" }, + { type: "largePaste", content: "x".repeat(300) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 0, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "hello" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("handles single character text segment deletion at cursor position", () => { + const initialState = createChatUserInputState([{ type: "text", content: "x" }], 0, 0); + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when segments array is empty (edge case)", () => { + const initialState: ChatUserInputState = { + segments: [], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes newline character in text segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello\nworld" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "helloworld" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("deletes tab character in text segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello\tworld" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + expect(result.segments).toEqual([{ type: "text", content: "helloworld" }]); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("deletes unicode emoji character in text segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello🎉world" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteAfterCursor(initialState); + + // Note: emoji is multi-byte UTF-16, slice removes first code unit + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content.length).toBeLessThan("hello🎉world".length); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + }); }); From c29e8d82447bae41a8e62a610e9f67c9a248933c Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 12:08:45 -0500 Subject: [PATCH 07/25] Tweak delete for windows --- src/subcommands/chat/react/ChatInput.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 6a19b33e..288f56ca 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -176,7 +176,11 @@ export const ChatInput = ({ } if (key.delete === true) { - setUserInputState(previousState => deleteAfterCursor(previousState)); + if (process.platform === "win32") { + setUserInputState(previousState => deleteAfterCursor(previousState)); + } else { + setUserInputState(previousState => deleteBeforeCursor(previousState)); + } return; } From 6de80a2c6e438c79c6685e535ba99e7566f92921 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 12:39:25 -0500 Subject: [PATCH 08/25] Remove windows check --- src/subcommands/chat/react/ChatInput.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 288f56ca..8ec12221 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -174,13 +174,8 @@ export const ChatInput = ({ return; } } - if (key.delete === true) { - if (process.platform === "win32") { - setUserInputState(previousState => deleteAfterCursor(previousState)); - } else { - setUserInputState(previousState => deleteBeforeCursor(previousState)); - } + setUserInputState(previousState => deleteAfterCursor(previousState)); return; } From a4089e5f88033014bd8393550068023eaf3eada0 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 14:17:39 -0500 Subject: [PATCH 09/25] Stage V: Add CTRL K and CTRL U and add comments for how keys are perceived on macOS --- src/subcommands/chat/react/ChatInput.tsx | 93 ++++++-- .../chat/react/inputReducer.test.ts | 221 ++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 121 ++++++++++ 3 files changed, 412 insertions(+), 23 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 8ec12221..9ffc68ce 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -5,6 +5,8 @@ import { InputPlaceholder } from "./InputPlaceholder.js"; import { deleteAfterCursor, deleteBeforeCursor, + deleteToLineEnd, + deleteToLineStart, deleteWordBackward, deleteWordForward, insertTextAtCursor, @@ -18,6 +20,10 @@ import { import { renderInputWithCursor } from "./inputRenderer.js"; import { type ChatUserInputState } from "./types.js"; +// Note: key.meta represents Command (⌘) on macOS and Alt on Windows/Linux +const isWindows = process.platform === "win32"; +const isMac = process.platform === "darwin"; + interface ChatInputProps { inputState: ChatUserInputState; isPredicting: boolean; @@ -79,6 +85,7 @@ export const ChatInput = ({ isConfirmationActive === false; useInput((inputCharacter, key) => { + console.debug("Received input:", { inputCharacter, key }); if (skipUseInputRef.current === true) { return; } @@ -96,39 +103,77 @@ export const ChatInput = ({ return; } + if (key.ctrl === true && inputCharacter === "d") { + const isInputEmpty = inputState.segments.every(segment => segment.content.length === 0); + if (isInputEmpty) { + onExit(); + return; + } + } + if (disableUserInput) { return; } if (key.ctrl === true) { - if (inputCharacter === "a") { - setUserInputState(previousState => moveCursorToLineStart(previousState)); - return; - } + // Unix/Emacs-style shortcuts (not supported on Windows) + if (isWindows === false) { + // Also works as Cmd+LeftArrow on macOS + if (inputCharacter === "a") { + setUserInputState(previousState => moveCursorToLineStart(previousState)); + return; + } - if (inputCharacter === "e") { - setUserInputState(previousState => moveCursorToLineEnd(previousState)); - return; - } + // Also works as Cmd+RightArrow on macOS + if (inputCharacter === "e") { + setUserInputState(previousState => moveCursorToLineEnd(previousState)); + return; + } - if (inputCharacter === "f") { - setUserInputState(previousState => moveCursorRight(previousState)); - return; - } + // Also works as Option+RightArrow on macOS + if (inputCharacter === "f") { + setUserInputState(previousState => moveCursorRight(previousState)); + return; + } - if (inputCharacter === "b") { - setUserInputState(previousState => moveCursorLeft(previousState)); - return; - } + // Also works as Option+LeftArrow on macOS + if (inputCharacter === "b") { + setUserInputState(previousState => moveCursorLeft(previousState)); + return; + } - if (inputCharacter === "w") { - setUserInputState(previousState => deleteWordBackward(previousState)); - return; + if (inputCharacter === "w") { + setUserInputState(previousState => deleteWordBackward(previousState)); + return; + } + + if (inputCharacter === "d") { + setUserInputState(previousState => deleteAfterCursor(previousState)); + return; + } + + // This is usually Ctrl+Backspace or cmd+Backspace in mac + if (inputCharacter === "u") { + setUserInputState(previousState => deleteToLineStart(previousState)); + return; + } + + // This is usually Ctrl+Delete or cmd+Delete in mac + if (inputCharacter === "k") { + setUserInputState(previousState => deleteToLineEnd(previousState)); + return; + } } - if (inputCharacter === "d") { - setUserInputState(previousState => deleteAfterCursor(previousState)); - return; + if (isMac === false) { + if (key.leftArrow === true) { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + if (key.rightArrow === true) { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; + } } } @@ -142,11 +187,13 @@ export const ChatInput = ({ setUserInputState(previousState => moveCursorWordLeft(previousState)); return; } + // When we press Alt + Delete on Windows/Linux, inputCharacter is "d" and same for macOS if (inputCharacter === "d") { setUserInputState(previousState => deleteWordForward(previousState)); return; } - if (key.delete === true) { + + if (key.backspace === true) { setUserInputState(previousState => deleteWordBackward(previousState)); return; } diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index bc24c0e6..6aa3feef 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1,6 +1,8 @@ import { deleteAfterCursor, deleteBeforeCursor, + deleteToLineEnd, + deleteToLineStart, deleteWordBackward, deleteWordForward, insertPasteAtCursor, @@ -2157,4 +2159,223 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe(5); }); }); + + describe("deleteToLineStart", () => { + it("deletes from cursor to start of line within single segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello world" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 11, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(""); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes from cursor to character after newline in same segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "first line\nsecond line" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 22, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("first line\n"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(11); + }); + + it("does nothing when cursor is already at line start", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello\nworld" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 6, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("hello\nworld"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + + it("deletes across multiple text segments", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "first" }, + { type: "text", content: " second" }, + { type: "text", content: " third" }, + ], + cursorOnSegmentIndex: 2, + cursorInSegmentOffset: 3, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("ird"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("handles largePaste segments by removing them", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "start" }, + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "end" }, + ], + cursorOnSegmentIndex: 2, + cursorInSegmentOffset: 3, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(""); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("preserves content after cursor", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "delete this keep this" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 11, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(" keep this"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + }); + + describe("deleteToLineEnd", () => { + it("deletes from cursor to end of line within single segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello world" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(""); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes from cursor to newline in same segment", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "first line\nsecond line" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("\nsecond line"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("does nothing when cursor is already at line end", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "hello\nworld" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("hello\nworld"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + + it("deletes across multiple text segments", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "first" }, + { type: "text", content: " second" }, + { type: "text", content: " third" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 2, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("fi"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(2); + }); + + it("handles largePaste segments by removing them", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "start" }, + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "end" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 2, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("st"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(2); + }); + + it("preserves content before cursor", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "keep this delete this" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 9, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("keep this"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(9); + }); + + it("deletes to newline and preserves text after newline", () => { + const initialState: ChatUserInputState = { + segments: [{ type: "text", content: "line one\nline two" }], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 5, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("line \nline two"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(5); + }); + }); }); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index bafe4f0f..7007a403 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -1004,6 +1004,127 @@ export function deleteWordForward(state: ChatUserInputState): ChatUserInputState }); } +/** + * Deletes all content from the cursor to the start of the current line. + * This is equivalent to Ctrl+U in Emacs/Unix terminals. + * - Deletes from cursor back to line start (after last newline or start of input) + * - Preserves segments structure + * - Handles multi-segment inputs + */ +export function deleteToLineStart(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const lineStartPosition = findLineStartPosition(draft); + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentOffset = draft.cursorInSegmentOffset; + + // If already at line start, do nothing + if ( + lineStartPosition.segmentIndex === currentSegmentIndex && + lineStartPosition.offset === currentOffset + ) { + return; + } + + // Same segment - just delete content within segment + if (lineStartPosition.segmentIndex === currentSegmentIndex) { + const currentSegment = draft.segments[currentSegmentIndex]; + if (currentSegment !== undefined && currentSegment.type === "text") { + currentSegment.content = + currentSegment.content.slice(0, lineStartPosition.offset) + + currentSegment.content.slice(currentOffset); + draft.cursorInSegmentOffset = lineStartPosition.offset; + } + return; + } + + // Different segments - need to delete across segments + const lineStartSegment = draft.segments[lineStartPosition.segmentIndex]; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (lineStartSegment !== undefined && lineStartSegment.type === "text") { + // Keep content before line start + lineStartSegment.content = lineStartSegment.content.slice(0, lineStartPosition.offset); + } + + if (currentSegment !== undefined && currentSegment.type === "text") { + // Keep content after cursor + const contentAfterCursor = currentSegment.content.slice(currentOffset); + // Merge into line start segment if it's text + if (lineStartSegment !== undefined && lineStartSegment.type === "text") { + lineStartSegment.content += contentAfterCursor; + } + } + + // Remove segments between line start and cursor (inclusive of current if different) + const segmentsToRemove = currentSegmentIndex - lineStartPosition.segmentIndex; + if (segmentsToRemove > 0) { + draft.segments.splice(lineStartPosition.segmentIndex + 1, segmentsToRemove); + } + + // Position cursor at line start + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; + draft.cursorInSegmentOffset = lineStartPosition.offset; + }); +} + +/** + * Deletes all content from the cursor to the end of the current line. + * This is equivalent to Ctrl+K in Emacs/Unix terminals. + * - Deletes from cursor to line end (before next newline or end of input) + * - Preserves segments structure + * - Handles multi-segment inputs + */ +export function deleteToLineEnd(state: ChatUserInputState): ChatUserInputState { + return produceSanitizedState(state, draft => { + const lineEndPosition = findLineEndPosition(draft); + const currentSegmentIndex = draft.cursorOnSegmentIndex; + const currentOffset = draft.cursorInSegmentOffset; + + // If already at line end, do nothing + if ( + lineEndPosition.segmentIndex === currentSegmentIndex && + lineEndPosition.offset === currentOffset + ) { + return; + } + + // Same segment - just delete content within segment + if (lineEndPosition.segmentIndex === currentSegmentIndex) { + const currentSegment = draft.segments[currentSegmentIndex]; + if (currentSegment !== undefined && currentSegment.type === "text") { + currentSegment.content = + currentSegment.content.slice(0, currentOffset) + + currentSegment.content.slice(lineEndPosition.offset); + } + return; + } + + // Different segments - need to delete across segments + const currentSegment = draft.segments[currentSegmentIndex]; + const lineEndSegment = draft.segments[lineEndPosition.segmentIndex]; + + if (currentSegment !== undefined && currentSegment.type === "text") { + // Keep content before cursor and append content after line end + const contentBeforeCursor = currentSegment.content.slice(0, currentOffset); + if (lineEndSegment !== undefined && lineEndSegment.type === "text") { + const contentAfterLineEnd = lineEndSegment.content.slice(lineEndPosition.offset); + currentSegment.content = contentBeforeCursor + contentAfterLineEnd; + } else { + currentSegment.content = contentBeforeCursor; + } + } + + // Remove segments between cursor and line end (inclusive of line end if different) + const segmentsToRemove = lineEndPosition.segmentIndex - currentSegmentIndex; + if (segmentsToRemove > 0) { + draft.segments.splice(currentSegmentIndex + 1, segmentsToRemove); + } + + // Cursor stays at current position + draft.cursorInSegmentOffset = currentOffset; + }); +} + /** * Inserts text at the cursor position. * - In text segment: inserts text at cursor position From 0ba475d5d48d67e15a8a67cfbb5a794466876140 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 14:39:14 -0500 Subject: [PATCH 10/25] Cleanup and correct comments --- src/subcommands/chat/react/ChatInput.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 9ffc68ce..6538f2e8 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -85,7 +85,6 @@ export const ChatInput = ({ isConfirmationActive === false; useInput((inputCharacter, key) => { - console.debug("Received input:", { inputCharacter, key }); if (skipUseInputRef.current === true) { return; } @@ -116,6 +115,12 @@ export const ChatInput = ({ } if (key.ctrl === true) { + // Also works as Ctrl+Backspace + if (inputCharacter === "w") { + setUserInputState(previousState => deleteWordBackward(previousState)); + return; + } + // Unix/Emacs-style shortcuts (not supported on Windows) if (isWindows === false) { // Also works as Cmd+LeftArrow on macOS @@ -130,23 +135,16 @@ export const ChatInput = ({ return; } - // Also works as Option+RightArrow on macOS if (inputCharacter === "f") { setUserInputState(previousState => moveCursorRight(previousState)); return; } - // Also works as Option+LeftArrow on macOS if (inputCharacter === "b") { setUserInputState(previousState => moveCursorLeft(previousState)); return; } - if (inputCharacter === "w") { - setUserInputState(previousState => deleteWordBackward(previousState)); - return; - } - if (inputCharacter === "d") { setUserInputState(previousState => deleteAfterCursor(previousState)); return; @@ -178,11 +176,13 @@ export const ChatInput = ({ } if (key.meta === true) { + // Also works as Option+RightArrow on macOS if (inputCharacter === "f") { setUserInputState(previousState => moveCursorWordRight(previousState)); return; } + // Also works as Option+LeftArrow on macOS if (inputCharacter === "b") { setUserInputState(previousState => moveCursorWordLeft(previousState)); return; @@ -221,7 +221,7 @@ export const ChatInput = ({ return; } } - if (key.delete === true) { + if (key.delete === true && key.meta === false) { setUserInputState(previousState => deleteAfterCursor(previousState)); return; } From 04be9b68e311fadb7689d63ada559b0bde7f2530 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 14:44:23 -0500 Subject: [PATCH 11/25] Fix Alt+Delete for windows --- src/subcommands/chat/react/ChatInput.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 6538f2e8..a51f0b82 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -197,6 +197,9 @@ export const ChatInput = ({ setUserInputState(previousState => deleteWordBackward(previousState)); return; } + if (key.delete === true) { + setUserInputState(previousState => deleteWordForward(previousState)); + } } if (areSuggestionsVisible) { @@ -221,7 +224,7 @@ export const ChatInput = ({ return; } } - if (key.delete === true && key.meta === false) { + if (key.delete === true) { setUserInputState(previousState => deleteAfterCursor(previousState)); return; } From 8371e13dbc2226e155f335b3d24888a1647fa9be Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 14:49:36 -0500 Subject: [PATCH 12/25] Update comment --- src/subcommands/chat/react/ChatInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index a51f0b82..ddbcb390 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -187,7 +187,7 @@ export const ChatInput = ({ setUserInputState(previousState => moveCursorWordLeft(previousState)); return; } - // When we press Alt + Delete on Windows/Linux, inputCharacter is "d" and same for macOS + // When we press option+fn+delete on macOS, it sends meta+d if (inputCharacter === "d") { setUserInputState(previousState => deleteWordForward(previousState)); return; From 7d8d3bc7e89cf6b25101824b6757e348320930d4 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 15:04:04 -0500 Subject: [PATCH 13/25] Only allow Alt+delete in windows --- src/subcommands/chat/react/ChatInput.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index ddbcb390..d63696f1 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -197,8 +197,11 @@ export const ChatInput = ({ setUserInputState(previousState => deleteWordBackward(previousState)); return; } - if (key.delete === true) { - setUserInputState(previousState => deleteWordForward(previousState)); + + if (isWindows) { + if (key.delete === true) { + setUserInputState(previousState => deleteWordForward(previousState)); + } } } From 53dd3e77627e89035fe9494c7c1a357a3fd2b7a9 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 15:12:13 -0500 Subject: [PATCH 14/25] Linux fix for alt arrows --- src/subcommands/chat/react/ChatInput.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index d63696f1..8276d849 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -187,6 +187,18 @@ export const ChatInput = ({ setUserInputState(previousState => moveCursorWordLeft(previousState)); return; } + + // For linux, we need to specifically check for Alt+Arrows + if (isMac === false) { + if (key.leftArrow === true) { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + if (key.rightArrow === true) { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; + } + } // When we press option+fn+delete on macOS, it sends meta+d if (inputCharacter === "d") { setUserInputState(previousState => deleteWordForward(previousState)); From 9b4e38470ba08f8dfed4992bdc057ab98e5d022e Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 15:20:37 -0500 Subject: [PATCH 15/25] Change comments --- src/subcommands/chat/react/ChatInput.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 8276d849..c069554e 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -188,18 +188,17 @@ export const ChatInput = ({ return; } - // For linux, we need to specifically check for Alt+Arrows - if (isMac === false) { - if (key.leftArrow === true) { - setUserInputState(previousState => moveCursorWordLeft(previousState)); - return; - } - if (key.rightArrow === true) { - setUserInputState(previousState => moveCursorWordRight(previousState)); - return; - } + // For linux, we need to specifically check for Alt+Arrows so we have these here + if (key.leftArrow === true) { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + if (key.rightArrow === true) { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; } // When we press option+fn+delete on macOS, it sends meta+d + // and for linux, alt+d is the alternative to alt+delete for word delete forward if (inputCharacter === "d") { setUserInputState(previousState => deleteWordForward(previousState)); return; From 12a79c5782d1aeb5255aadf33f308a4835a7f5b3 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 15:55:14 -0500 Subject: [PATCH 16/25] Add return --- src/subcommands/chat/react/ChatInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index c069554e..23be1264 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -212,6 +212,7 @@ export const ChatInput = ({ if (isWindows) { if (key.delete === true) { setUserInputState(previousState => deleteWordForward(previousState)); + return; } } } From 306113c993b79555aa6b9f8b197df0d56bd3c075 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 16:12:09 -0500 Subject: [PATCH 17/25] Fix bugs for ctrl+U --- .../chat/react/inputReducer.test.ts | 59 +++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 49 +++++++++++---- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 6aa3feef..c2e9f55c 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -2260,6 +2260,47 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe(0); }); + + it("preserves text after cursor when line starts with largePaste", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "should deleteshould keep" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 13, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("should keep"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("deletes largePaste when cursor is at paste start with text on both sides", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "after" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments.length).toBe(2); + expect(result.segments[0].type).toBe("largePaste"); + expect(result.segments[0].content).toBe("x".repeat(100)); + expect(result.segments[1].type).toBe("text"); + expect(result.segments[1].content).toBe("after"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); }); describe("deleteToLineEnd", () => { @@ -2362,7 +2403,25 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe(9); }); + it("deletes trailing largePaste when cursor is in text before it", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "keep this" }, + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 9, + }; + + const result = deleteToLineEnd(initialState); + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("keep this"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(9); + }); it("deletes to newline and preserves text after newline", () => { const initialState: ChatUserInputState = { segments: [{ type: "text", content: "line one\nline two" }], diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 7007a403..910a2623 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -1041,18 +1041,44 @@ export function deleteToLineStart(state: ChatUserInputState): ChatUserInputState const lineStartSegment = draft.segments[lineStartPosition.segmentIndex]; const currentSegment = draft.segments[currentSegmentIndex]; - if (lineStartSegment !== undefined && lineStartSegment.type === "text") { - // Keep content before line start - lineStartSegment.content = lineStartSegment.content.slice(0, lineStartPosition.offset); - } + // If cursor is at start of current segment (offset 0), preserve current segment + if (currentOffset === 0) { + // Delete segments between line start and current (exclusive of current) + const segmentsToRemove = currentSegmentIndex - lineStartPosition.segmentIndex - 1; + if (segmentsToRemove > 0) { + draft.segments.splice(lineStartPosition.segmentIndex + 1, segmentsToRemove); + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex + 1; + } - if (currentSegment !== undefined && currentSegment.type === "text") { - // Keep content after cursor - const contentAfterCursor = currentSegment.content.slice(currentOffset); - // Merge into line start segment if it's text + // Truncate line start segment at line start position if (lineStartSegment !== undefined && lineStartSegment.type === "text") { - lineStartSegment.content += contentAfterCursor; + lineStartSegment.content = lineStartSegment.content.slice(0, lineStartPosition.offset); + if (lineStartSegment.content.length === 0) { + // Remove empty line start segment + draft.segments.splice(lineStartPosition.segmentIndex, 1); + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; + } } + + draft.cursorInSegmentOffset = 0; + return; + } + + let contentAfterCursor = ""; + if (currentSegment !== undefined && currentSegment.type === "text") { + contentAfterCursor = currentSegment.content.slice(currentOffset); + } + + if (lineStartSegment !== undefined && lineStartSegment.type === "text") { + // Keep content before line start and merge content after cursor + lineStartSegment.content = + lineStartSegment.content.slice(0, lineStartPosition.offset) + contentAfterCursor; + } else if (contentAfterCursor.length > 0) { + // Line starts with non-text segment, create new text segment for content after cursor + draft.segments[lineStartPosition.segmentIndex] = { + type: "text", + content: contentAfterCursor, + }; } // Remove segments between line start and cursor (inclusive of current if different) @@ -1063,7 +1089,10 @@ export function deleteToLineStart(state: ChatUserInputState): ChatUserInputState // Position cursor at line start draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; - draft.cursorInSegmentOffset = lineStartPosition.offset; + draft.cursorInSegmentOffset = + lineStartSegment !== undefined && lineStartSegment.type === "text" + ? lineStartPosition.offset + : 0; }); } From 0ee0a7f7cec8d6b9f95844ff444f9c764e7b387b Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 16:29:45 -0500 Subject: [PATCH 18/25] Fix more bugs --- src/subcommands/chat/react/ChatInput.tsx | 18 +++++---- .../chat/react/inputReducer.test.ts | 38 +++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 11 ++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 23be1264..b7e16162 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -188,14 +188,16 @@ export const ChatInput = ({ return; } - // For linux, we need to specifically check for Alt+Arrows so we have these here - if (key.leftArrow === true) { - setUserInputState(previousState => moveCursorWordLeft(previousState)); - return; - } - if (key.rightArrow === true) { - setUserInputState(previousState => moveCursorWordRight(previousState)); - return; + if (isMac === false) { + // For linux, we need to specifically check for Alt+Arrows so we have these here + if (key.leftArrow === true) { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + if (key.rightArrow === true) { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; + } } // When we press option+fn+delete on macOS, it sends meta+d // and for linux, alt+d is the alternative to alt+delete for word delete forward diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index c2e9f55c..d5ecfbe6 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -2301,6 +2301,25 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe(0); }); + + it("deletes largePaste when cursor is at trailing placeholder after paste", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineStart(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(""); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); }); describe("deleteToLineEnd", () => { @@ -2436,5 +2455,24 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe(5); }); + + it("deletes largePaste and following text when cursor is at offset 0 of largePaste", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "following text" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe(""); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); }); }); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 910a2623..2a011212 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -1058,6 +1058,10 @@ export function deleteToLineStart(state: ChatUserInputState): ChatUserInputState draft.segments.splice(lineStartPosition.segmentIndex, 1); draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; } + } else if (lineStartSegment !== undefined) { + // Line start segment is non-text (e.g., largePaste) - remove it + draft.segments.splice(lineStartPosition.segmentIndex, 1); + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; } draft.cursorInSegmentOffset = 0; @@ -1141,6 +1145,13 @@ export function deleteToLineEnd(state: ChatUserInputState): ChatUserInputState { } else { currentSegment.content = contentBeforeCursor; } + } else if (currentOffset === 0) { + // At start of non-text segment - remove it along with everything to line end + draft.segments.splice( + currentSegmentIndex, + lineEndPosition.segmentIndex - currentSegmentIndex + 1, + ); + return; } // Remove segments between cursor and line end (inclusive of line end if different) From 2b993659abc2eed8aef762c55b276604443ff172 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Tue, 23 Dec 2025 16:55:25 -0500 Subject: [PATCH 19/25] Input rendering on new line fix --- src/subcommands/chat/react/inputRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/inputRenderer.tsx b/src/subcommands/chat/react/inputRenderer.tsx index de4660ae..32ae7694 100644 --- a/src/subcommands/chat/react/inputRenderer.tsx +++ b/src/subcommands/chat/react/inputRenderer.tsx @@ -23,6 +23,7 @@ export function renderInputWithCursor({ if (fullText.length === 0 && cursorPosition === 0) { return <>{chalk.inverse(" ")}; } + return ( Date: Wed, 24 Dec 2025 12:41:48 -0500 Subject: [PATCH 20/25] Fix bugs --- src/subcommands/chat/react/inputReducer.test.ts | 17 +++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index d5ecfbe6..54df65d3 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1501,6 +1501,23 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe((firstSegmentText + secondSegmentText).length); }); + + it("jumps to end of largePaste in one move when cursor is mid-word before largePaste", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "ddf" }, + { type: "largePaste", content: "x".repeat(1495) }, + { type: "text", content: "Helh" }, + ], + 0, + "dd".length, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(1); + expect(result.cursorInSegmentOffset).toBe(0); + }); }); describe("moveCursorToLineStart", () => { diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 2a011212..85776a22 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -833,7 +833,11 @@ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputSta // Find next word boundary within current segment and move cursor there const newCursorOffset = findNextWordBoundaryInSegment(segmentContent, cursorOffset); draft.cursorInSegmentOffset = newCursorOffset; - return; + // If we reached the end of the segment, continue to jump to next segment + if (newCursorOffset < segmentLength) { + return; + } + // Fall through to jump to next segment } } From 5dc9de1e5f22166274e7c9b90105756a6bd0ee61 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Mon, 29 Dec 2025 14:20:17 -0500 Subject: [PATCH 21/25] Remove unnecessary diff --- src/subcommands/chat/react/inputRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/inputRenderer.tsx b/src/subcommands/chat/react/inputRenderer.tsx index 32ae7694..de4660ae 100644 --- a/src/subcommands/chat/react/inputRenderer.tsx +++ b/src/subcommands/chat/react/inputRenderer.tsx @@ -23,7 +23,6 @@ export function renderInputWithCursor({ if (fullText.length === 0 && cursorPosition === 0) { return <>{chalk.inverse(" ")}; } - return ( Date: Mon, 12 Jan 2026 11:20:55 -0500 Subject: [PATCH 22/25] More word separators --- .../chat/react/inputReducer.test.ts | 80 +++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 11 +-- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 54df65d3..6c7ae149 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1327,6 +1327,46 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe(0); }); + it("treats en dash as a word separator when moving left", () => { + const content = "foo–bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo–".length); + }); + + it("treats em dash as a word separator when moving left", () => { + const content = "foo—bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo—".length); + }); + + it("treats quotes as word separators when moving left", () => { + const content = 'foo"bar'; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe('foo"'.length); + }); + + it("treats pipe as a word separator when moving left", () => { + const content = "foo|bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, content.length); + + const result = moveCursorWordLeft(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo|".length); + }); + it("skips whitespace then moves to start of previous word", () => { const initialState = createChatUserInputState( [{ type: "text", content: "hello world" }], @@ -1454,6 +1494,46 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe("hello world".length); }); + it("treats en dash as a word separator when moving right", () => { + const content = "foo–bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, 0); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo".length); + }); + + it("treats em dash as a word separator when moving right", () => { + const content = "foo—bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, 0); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo".length); + }); + + it("treats quotes as word separators when moving right", () => { + const content = 'foo"bar'; + const initialState = createChatUserInputState([{ type: "text", content }], 0, 0); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo".length); + }); + + it("treats pipe as a word separator when moving right", () => { + const content = "foo|bar"; + const initialState = createChatUserInputState([{ type: "text", content }], 0, 0); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("foo".length); + }); + it("does nothing when at end of segment", () => { const initialState = createChatUserInputState( [{ type: "text", content: "hello" }], diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 85776a22..d3df2d00 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -232,15 +232,8 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { } function isWordSeparatorCharacter(character: string): boolean { - if (/\s/.test(character) === true) { - return true; - } - - if (character === "-") { - return true; - } - - return false; + // Treat whitespace and common shell separators (including ASCII hyphen and Unicode en/em dashes) as word breaks; keep path/flag chars intact + return /[\s"'`,;|&<>()[\]{}\-\u2013\u2014]/.test(character); } /** From 06ece86ba7ab7c491c0a82edd78c4aa790c43e58 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Mon, 12 Jan 2026 12:18:26 -0500 Subject: [PATCH 23/25] Tweak Meta + F behavior --- .../chat/react/inputReducer.test.ts | 17 +++++++++ src/subcommands/chat/react/inputReducer.ts | 35 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 6c7ae149..512132ba 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1564,6 +1564,23 @@ describe("chatInputStateReducers", () => { expect(result.cursorInSegmentOffset).toBe(0); }); + it("lands at start of the next word when skipping over a largePaste", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(200) }, + { type: "text", content: "\nMy tail" }, + ], + 1, + 0, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(2); + expect(result.cursorInSegmentOffset).toBe(1); // start of "My" + }); + it("moves to end of next segment word when starting at end of current segment", () => { const firstSegmentText = "hello"; const secondSegmentText = " world"; diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index d3df2d00..a92d97b7 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -345,6 +345,36 @@ function findNextWordBoundaryInSegment(content: string, cursorOffset: number): n return scanIndex; } +function findNextWordStartInSegment(content: string, cursorOffset: number): number { + const segmentLength = content.length; + + if (segmentLength === 0) { + return 0; + } + + let scanIndex = cursorOffset; + + if (scanIndex < 0) { + scanIndex = 0; + } + + if (scanIndex >= segmentLength) { + return segmentLength; + } + + // Skip over separators to land at the first non-separator (start of the next word) + while (scanIndex < segmentLength) { + const character = content.charAt(scanIndex); + if (isWordSeparatorCharacter(character) === true) { + scanIndex += 1; + continue; + } + break; + } + + return scanIndex; +} + /** * Ensures the input state is valid by: * 1. Guaranteeing at least one segment exists @@ -851,7 +881,10 @@ export function moveCursorWordRight(state: ChatUserInputState): ChatUserInputSta // The next segment is text - move to its start word boundary const nextContent = nextSegment.content; - const newCursorOffset = findNextWordBoundaryInSegment(nextContent, 0); + const newCursorOffset = + currentSegment.type === "largePaste" + ? findNextWordStartInSegment(nextContent, 0) + : findNextWordBoundaryInSegment(nextContent, 0); draft.cursorOnSegmentIndex = nextSegmentIndex; draft.cursorInSegmentOffset = newCursorOffset; From 62b0ec62cfd4a699921957f5390e819560959f46 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 22 Jan 2026 15:07:45 -0500 Subject: [PATCH 24/25] Address comments --- src/subcommands/chat/react/ChatInput.tsx | 3 +-- src/subcommands/chat/react/inputReducer.ts | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index b7e16162..27aea659 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -20,7 +20,6 @@ import { import { renderInputWithCursor } from "./inputRenderer.js"; import { type ChatUserInputState } from "./types.js"; -// Note: key.meta represents Command (⌘) on macOS and Alt on Windows/Linux const isWindows = process.platform === "win32"; const isMac = process.platform === "darwin"; @@ -188,7 +187,7 @@ export const ChatInput = ({ return; } - if (isMac === false) { + if (isMac === false && isWindows === false) { // For linux, we need to specifically check for Alt+Arrows so we have these here if (key.leftArrow === true) { setUserInputState(previousState => moveCursorWordLeft(previousState)); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index a92d97b7..2c220874 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -199,8 +199,6 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { const lastSegmentIndex = state.segments.length - 1; const lastSegment = state.segments[lastSegmentIndex]; - // Can never be the case because we have our trailing placeholder rule - // but we handle it anyway as this runs before sanitation if (lastSegment !== undefined && lastSegment.type === "text") { return { segmentIndex: lastSegmentIndex, @@ -208,7 +206,7 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { }; } - // Again, this can never be the case because of our trailing placeholder rule + // A last segment being non-text should not be the case because of our trailing placeholder rule // but we handle it anyway as this runs before sanitation // Last segment is not text - search backward for last text segment for (let segmentIndex = lastSegmentIndex - 1; segmentIndex >= 0; segmentIndex -= 1) { @@ -232,7 +230,8 @@ function findLineEndPosition(state: ChatUserInputState): CursorPosition { } function isWordSeparatorCharacter(character: string): boolean { - // Treat whitespace and common shell separators (including ASCII hyphen and Unicode en/em dashes) as word breaks; keep path/flag chars intact + // Treat whitespace and common shell separators (including ASCII hyphen and Unicode en/em dashes) + // as word breaks; keep path/flag chars intact return /[\s"'`,;|&<>()[\]{}\-\u2013\u2014]/.test(character); } From 774b6470868cc6391774b102670bd63db1ee3074 Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Thu, 22 Jan 2026 16:11:55 -0500 Subject: [PATCH 25/25] Handle paste and text delete after word case --- .../chat/react/inputReducer.test.ts | 39 +++++++++++++++++++ src/subcommands/chat/react/inputReducer.ts | 35 ++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 512132ba..42421a4d 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -2588,5 +2588,44 @@ describe("chatInputStateReducers", () => { expect(result.cursorOnSegmentIndex).toBe(0); expect(result.cursorInSegmentOffset).toBe(0); }); + + it("preserves text after newline when cursor is at offset 0 of largePaste", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "abc\ndef" }, + ], + cursorOnSegmentIndex: 0, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("\ndef"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(0); + }); + + it("keeps cursor logical position when deletion from largePaste reaches end-of-input", () => { + const initialState: ChatUserInputState = { + segments: [ + { type: "text", content: "keep" }, + { type: "largePaste", content: "x".repeat(100) }, + { type: "text", content: "" }, + ], + cursorOnSegmentIndex: 1, + cursorInSegmentOffset: 0, + }; + + const result = deleteToLineEnd(initialState); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].type).toBe("text"); + expect(result.segments[0].content).toBe("keep"); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(4); + }); }); }); diff --git a/src/subcommands/chat/react/inputReducer.ts b/src/subcommands/chat/react/inputReducer.ts index 2c220874..20b85a5e 100644 --- a/src/subcommands/chat/react/inputReducer.ts +++ b/src/subcommands/chat/react/inputReducer.ts @@ -1175,11 +1175,36 @@ export function deleteToLineEnd(state: ChatUserInputState): ChatUserInputState { currentSegment.content = contentBeforeCursor; } } else if (currentOffset === 0) { - // At start of non-text segment - remove it along with everything to line end - draft.segments.splice( - currentSegmentIndex, - lineEndPosition.segmentIndex - currentSegmentIndex + 1, - ); + // At start of non-text segment - delete from here to line end, but preserve any + // remaining text after the line end (e.g., after a newline) in the line-end segment. + const preservedTextAfterLineEnd = + lineEndSegment !== undefined && lineEndSegment.type === "text" + ? lineEndSegment.content.slice(lineEndPosition.offset) + : ""; + + const segmentsToRemove = lineEndPosition.segmentIndex - currentSegmentIndex + 1; + if (segmentsToRemove > 0) { + draft.segments.splice(currentSegmentIndex, segmentsToRemove); + } + + if (preservedTextAfterLineEnd.length > 0) { + draft.segments.splice(currentSegmentIndex, 0, { + type: "text", + content: preservedTextAfterLineEnd, + }); + draft.cursorOnSegmentIndex = currentSegmentIndex; + draft.cursorInSegmentOffset = 0; + } else if (currentSegmentIndex < draft.segments.length) { + draft.cursorOnSegmentIndex = currentSegmentIndex; + draft.cursorInSegmentOffset = 0; + } else { + // Cursor is now at end-of-input. + const lastSegmentIndex = draft.segments.length - 1; + draft.cursorOnSegmentIndex = Math.max(0, lastSegmentIndex); + const lastSegment = draft.segments[draft.cursorOnSegmentIndex]; + draft.cursorInSegmentOffset = + lastSegment !== undefined && lastSegment.type === "text" ? lastSegment.content.length : 0; + } return; }