diff --git a/src/subcommands/chat/react/ChatInput.tsx b/src/subcommands/chat/react/ChatInput.tsx index 7c532e5f..27aea659 100644 --- a/src/subcommands/chat/react/ChatInput.tsx +++ b/src/subcommands/chat/react/ChatInput.tsx @@ -5,13 +5,24 @@ import { InputPlaceholder } from "./InputPlaceholder.js"; import { deleteAfterCursor, deleteBeforeCursor, + deleteToLineEnd, + deleteToLineStart, + deleteWordBackward, + deleteWordForward, insertTextAtCursor, moveCursorLeft, moveCursorRight, + moveCursorToLineEnd, + moveCursorToLineStart, + moveCursorWordLeft, + moveCursorWordRight, } from "./inputReducer.js"; import { renderInputWithCursor } from "./inputRenderer.js"; import { type ChatUserInputState } from "./types.js"; +const isWindows = process.platform === "win32"; +const isMac = process.platform === "darwin"; + interface ChatInputProps { inputState: ChatUserInputState; isPredicting: boolean; @@ -90,10 +101,123 @@ 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) { + // 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 + if (inputCharacter === "a") { + setUserInputState(previousState => moveCursorToLineStart(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; + } + + if (inputCharacter === "b") { + setUserInputState(previousState => moveCursorLeft(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 (isMac === false) { + if (key.leftArrow === true) { + setUserInputState(previousState => moveCursorWordLeft(previousState)); + return; + } + if (key.rightArrow === true) { + setUserInputState(previousState => moveCursorWordRight(previousState)); + return; + } + } + } + + 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; + } + + 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)); + 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; + } + + if (key.backspace === true) { + setUserInputState(previousState => deleteWordBackward(previousState)); + return; + } + + if (isWindows) { + if (key.delete === true) { + setUserInputState(previousState => deleteWordForward(previousState)); + return; + } + } + } + if (areSuggestionsVisible) { if (key.upArrow === true) { onSuggestionsUp(); @@ -116,7 +240,6 @@ export const ChatInput = ({ return; } } - if (key.delete === true) { setUserInputState(previousState => deleteAfterCursor(previousState)); return; diff --git a/src/subcommands/chat/react/inputReducer.test.ts b/src/subcommands/chat/react/inputReducer.test.ts index 83ae8cc5..42421a4d 100644 --- a/src/subcommands/chat/react/inputReducer.test.ts +++ b/src/subcommands/chat/react/inputReducer.test.ts @@ -1,11 +1,19 @@ import { deleteAfterCursor, deleteBeforeCursor, + deleteToLineEnd, + deleteToLineStart, + deleteWordBackward, + deleteWordForward, insertPasteAtCursor, insertSuggestionAtCursor, insertTextAtCursor, moveCursorLeft, moveCursorRight, + moveCursorToLineEnd, + moveCursorToLineStart, + moveCursorWordLeft, + moveCursorWordRight, } from "./inputReducer.js"; import { type ChatInputSegment, type ChatUserInputState } from "./types.js"; @@ -1027,6 +1035,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", () => { @@ -1251,63 +1273,1359 @@ describe("chatInputStateReducers", () => { }); }); - describe("insertTextAtCursor edge cases", () => { - it("creates new text segment before largePaste when it is the first segment", () => { + describe("moveCursorWordLeft", () => { + it("moves to start of previous word within a text segment", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "x".repeat(1000) }], + [{ 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("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("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" }], 0, + "hello world".length, ); - const result = insertTextAtCursor({ - state: initialState, - text: "prefix", - }); + const result = moveCursorWordLeft(initialState); - expect(result.segments.length).toBe(2); - expect(result.segments[0]).toEqual({ type: "text", content: "prefix" }); - expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); expect(result.cursorOnSegmentIndex).toBe(0); - expect(result.cursorInSegmentOffset).toBe(6); + 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("treats largePaste before trailing placeholder as a word when moving left", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(1000) }, + { type: "text", content: "" }, + ], + 2, + 0, + ); + + const result = moveCursorWordLeft(initialState); + + 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("insertPasteAtCursor edge cases", () => { - it("inserts large paste before current largePaste when cursor is at start", () => { + describe("moveCursorWordRight", () => { + it("moves to end of current word within a text segment", () => { const initialState = createChatUserInputState( - [{ type: "largePaste", content: "x".repeat(1000) }], + [{ type: "text", content: "hello world" }], 0, 0, ); - const result = insertPasteAtCursor({ - state: initialState, - content: "y".repeat(1000), - largePasteThreshold: 500, - }); + const result = moveCursorWordRight(initialState); - expect(result.segments.length).toBe(2); - expect(result.segments[0]).toEqual({ type: "largePaste", content: "y".repeat(1000) }); - expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); }); - it("inserts small paste as text before current largePaste when cursor is at start", () => { + 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: "largePaste", content: "x".repeat(1000) }], + [{ 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("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" }], 0, + "hello".length, + ); + + const result = moveCursorWordRight(initialState); + + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe("hello".length); + }); + + it("treats largePaste as a word when moving right from preceding text", () => { + const initialState = createChatUserInputState( + [ + { type: "text", content: "before" }, + { type: "largePaste", content: "x".repeat(1000) }, + { type: "text", content: "" }, + ], 0, + "before".length, ); - const result = insertPasteAtCursor({ - state: initialState, - content: "small", - largePasteThreshold: 500, - }); + const result = moveCursorWordRight(initialState); - expect(result.segments.length).toBe(2); - expect(result.segments[0]).toEqual({ type: "text", content: "small" }); - expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); + expect(result.cursorOnSegmentIndex).toBe(1); + 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"; + 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); + }); + + 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", () => { + 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(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", () => { + 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( + [{ type: "largePaste", content: "x".repeat(1000) }], + 0, + 0, + ); + + const result = insertTextAtCursor({ + state: initialState, + text: "prefix", + }); + + expect(result.segments.length).toBe(2); + expect(result.segments[0]).toEqual({ type: "text", content: "prefix" }); + expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); + expect(result.cursorOnSegmentIndex).toBe(0); + expect(result.cursorInSegmentOffset).toBe(6); + }); + }); + + 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("deletes previous largePaste when cursor is at start of trailing placeholder", () => { + const initialState = createChatUserInputState( + [ + { 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 previous text when cursor is on largePaste", () => { + 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([ + { type: "largePaste", content: "large content" }, + { type: "text", content: "" }, + ]); + 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("deletes next largePaste when cursor is at end of preceding text", () => { + const initialState = createChatUserInputState( + [ + { 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([{ type: "text", content: "before" }]); + 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", () => { + it("inserts large paste before current largePaste when cursor is at start", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "x".repeat(1000) }], + 0, + 0, + ); + + const result = insertPasteAtCursor({ + state: initialState, + content: "y".repeat(1000), + largePasteThreshold: 500, + }); + + expect(result.segments.length).toBe(2); + expect(result.segments[0]).toEqual({ type: "largePaste", content: "y".repeat(1000) }); + expect(result.segments[1]).toEqual({ type: "largePaste", content: "x".repeat(1000) }); + expect(result.cursorOnSegmentIndex).toBe(0); + }); + + it("inserts small paste as text before current largePaste when cursor is at start", () => { + const initialState = createChatUserInputState( + [{ type: "largePaste", content: "x".repeat(1000) }], + 0, + 0, + ); + + const result = insertPasteAtCursor({ + state: initialState, + content: "small", + largePasteThreshold: 500, + }); + + expect(result.segments.length).toBe(2); + expect(result.segments[0]).toEqual({ type: "text", content: "small" }); + 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); + }); + }); + + 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); + }); + + 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); + }); + + 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", () => { + 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 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" }], + 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); + }); + + 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); + }); + + 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 16b7413d..20b85a5e 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,310 @@ function produceSanitizedState( }); } +function findLineStartPosition(state: ChatUserInputState): CursorPosition { + // Handle empty state + if (state.segments.length === 0) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const cursorSegmentIndex = state.cursorOnSegmentIndex; + const cursorOffset = state.cursorInSegmentOffset; + + // Validate cursor position + if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + 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, + offset: lastNewlineInCurrentSegment + 1, + }; + } + } + + // 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, + offset: lastNewlineInSegment + 1, + }; + } + } + + // No newline found - line starts at beginning of input + return { + segmentIndex: 0, + offset: 0, + }; +} + +function findLineEndPosition(state: ChatUserInputState): CursorPosition { + // Handle empty state + if (state.segments.length === 0) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + const cursorSegmentIndex = state.cursorOnSegmentIndex; + const cursorOffset = state.cursorInSegmentOffset; + + // Validate cursor position + if (cursorSegmentIndex < 0 || cursorSegmentIndex >= state.segments.length) { + return { + segmentIndex: 0, + offset: 0, + }; + } + + 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; + + return { + segmentIndex: cursorSegmentIndex, + offset: newlineAbsoluteIndex, + }; + } + } + + // Search forward through following segments for newline + for ( + let segmentIndex = cursorSegmentIndex + 1; + segmentIndex < state.segments.length; + segmentIndex += 1 + ) { + 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, + offset: newlineIndex, + }; + } + } + + // No newline found - line ends at end of last text segment + const lastSegmentIndex = state.segments.length - 1; + const lastSegment = state.segments[lastSegmentIndex]; + + if (lastSegment !== undefined && lastSegment.type === "text") { + return { + segmentIndex: lastSegmentIndex, + offset: lastSegment.content.length, + }; + } + + // 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) { + const segment = state.segments[segmentIndex]; + + if (segment === undefined || segment.type !== "text") { + continue; + } + + return { + segmentIndex, + offset: segment.content.length, + }; + } + + // No text segments found - default to beginning + return { + segmentIndex: 0, + offset: 0, + }; +} + +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 + return /[\s"'`,;|&<>()[\]{}\-\u2013\u2014]/.test(character); +} + +/** + * 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; + } + + const segmentLength = content.length; + + if (segmentLength === 0) { + return 0; + } + + let scanIndex = cursorOffset; + + if (scanIndex > segmentLength) { + scanIndex = segmentLength; + } + + // Scan backwards over any word separator characters + while (scanIndex > 0) { + const previousCharacter = content.charAt(scanIndex - 1); + + // 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 previous character is not a word separator, keep moving left + if (isWordSeparatorCharacter(previousCharacter) === false) { + scanIndex -= 1; + } else { + // Found a separator character, stop scanning + break; + } + } + + 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; + + if (segmentLength === 0) { + return 0; + } + + let scanIndex = cursorOffset; + + if (scanIndex < 0) { + scanIndex = 0; + } + + if (scanIndex >= segmentLength) { + 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 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 character is not a word separator, keep moving right + if (isWordSeparatorCharacter(character) === false) { + scanIndex += 1; + } else { + // Found a separator character, stop scanning + break; + } + } + + 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 @@ -436,6 +745,480 @@ 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); + + draft.cursorOnSegmentIndex = lineStartPosition.segmentIndex; + draft.cursorInSegmentOffset = lineStartPosition.offset; + }); +} + +/** + * 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); + + draft.cursorOnSegmentIndex = lineEndPosition.segmentIndex; + draft.cursorInSegmentOffset = lineEndPosition.offset; + }); +} + +/** + * 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; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (currentSegment === undefined) { + return; + } + + if (currentSegment.type === "text") { + 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; + } + } + + // At the start of the current segment (text or largePaste) - move to previous segment + const previousSegmentIndex = currentSegmentIndex - 1; + const previousSegment = draft.segments[previousSegmentIndex]; + + if (previousSegment === undefined) { + 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, + previousContent.length, + ); + + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; + }); +} + +/** + * 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; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (currentSegment === undefined) { + return; + } + + if (currentSegment.type === "text") { + const segmentContent = currentSegment.content; + 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; + // 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 + } + } + + // At the end of the current segment (text or largePaste) - move to next segment + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; + + if (nextSegment === undefined) { + 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 = + currentSegment.type === "largePaste" + ? findNextWordStartInSegment(nextContent, 0) + : findNextWordBoundaryInSegment(nextContent, 0); + + draft.cursorOnSegmentIndex = nextSegmentIndex; + draft.cursorInSegmentOffset = newCursorOffset; + }); +} + +/** + * 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; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (currentSegment === undefined) { + return; + } + + if (currentSegment.type === "text") { + 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]; + + if (previousSegment === undefined) { + return; + } + + // Previous segment is largePaste - delete the entire segment + if (previousSegment.type === "largePaste") { + draft.segments.splice(previousSegmentIndex, 1); + draft.cursorOnSegmentIndex = previousSegmentIndex; + draft.cursorInSegmentOffset = 0; + return; + } + + // Previous segment is text - delete last word from it + const previousContent = previousSegment.content; + const previousLength = previousContent.length; + + // Find previous word boundary in previous text segment + const newCursorOffset = findPreviousWordBoundaryInSegment(previousContent, previousLength); + + // 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; + const currentSegment = draft.segments[currentSegmentIndex]; + + if (currentSegment === undefined) { + return; + } + + const currentSegmentType = currentSegment.type; + switch (currentSegmentType) { + case "text": { + const segmentContent = currentSegment.content; + const cursorOffset = draft.cursorInSegmentOffset; + const segmentLength = segmentContent.length; + + // 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; + } + + // Delete content from cursorOffset to newCursorOffset + currentSegment.content = + segmentContent.slice(0, cursorOffset) + segmentContent.slice(newCursorOffset); + return; + } + + // At end of text segment - delete next segment + const nextSegmentIndex = currentSegmentIndex + 1; + const nextSegment = draft.segments[nextSegmentIndex]; + + if (nextSegment === undefined) { + return; + } + + // If next segment is largePaste, delete it + if (nextSegment.type === "largePaste") { + draft.segments.splice(nextSegmentIndex, 1); + return; + } + + // Next segment is text - delete first word from it + const nextContent = nextSegment.content; + const nextWordBoundary = findNextWordBoundaryInSegment(nextContent, 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); + + // 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; + draft.cursorInSegmentOffset = 0; + break; + } + default: { + const exhaustiveCheck: never = currentSegmentType; + throw new Error(`Unhandled segment type: ${exhaustiveCheck}`); + } + } + }); +} + +/** + * 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 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; + } + + // Truncate line start segment at line start position + if (lineStartSegment !== undefined && lineStartSegment.type === "text") { + 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; + } + } 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; + 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) + 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 = + lineStartSegment !== undefined && lineStartSegment.type === "text" + ? lineStartPosition.offset + : 0; + }); +} + +/** + * 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; + } + } else if (currentOffset === 0) { + // 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; + } + + // 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