From 0427445242f47b9b2d9e82863826279934478496 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 25 Dec 2025 05:09:47 +0200 Subject: [PATCH] Fix Ctrl+W to use standard readline word deletion behavior (#306) - Skip trailing whitespace before deleting word (readline behavior) - Make word navigation grapheme-aware using Intl.Segmenter - Add Ctrl+Left/Right and Alt+Left/Right word navigation to Input - Accept full Unicode input while rejecting control characters (C0/C1/DEL) - Extract shared utilities to utils.ts (getSegmenter, isWhitespaceChar, isPunctuationChar) - Fix unsafe cast in Editor.forceFileAutocomplete with runtime type check - Add comprehensive tests for word deletion and navigation --- packages/tui/src/components/editor.ts | 112 ++++++++++++------------ packages/tui/src/components/input.ts | 120 ++++++++++++++++++++------ packages/tui/src/utils.ts | 26 +++++- packages/tui/test/editor.test.ts | 78 +++++++++++++++++ 4 files changed, 250 insertions(+), 86 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f92a1aa5..017cb1c4 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -26,11 +26,10 @@ import { isTab, } from "../keys.js"; import type { Component } from "../tui.js"; -import { visibleWidth } from "../utils.js"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; -// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) -const segmenter = new Intl.Segmenter(); +const segmenter = getSegmenter(); interface EditorState { lines: string[]; @@ -919,30 +918,10 @@ export class Editor implements Component { this.state.cursorCol = previousLine.length; } } else { - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - - const isWhitespace = (char: string): boolean => /\s/.test(char); - const isPunctuation = (char: string): boolean => { - // Treat obvious code punctuation as boundaries - return /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); - }; - - let deleteFrom = this.state.cursorCol; - const lastChar = textBeforeCursor[deleteFrom - 1] ?? ""; - - // If immediately on whitespace or punctuation, delete that single boundary char - if (isWhitespace(lastChar) || isPunctuation(lastChar)) { - deleteFrom -= 1; - } else { - // Otherwise, delete a run of non-boundary characters (the "word") - while (deleteFrom > 0) { - const ch = textBeforeCursor[deleteFrom - 1] ?? ""; - if (isWhitespace(ch) || isPunctuation(ch)) { - break; - } - deleteFrom -= 1; - } - } + const oldCursorCol = this.state.cursorCol; + this.moveWordBackwards(); + const deleteFrom = this.state.cursorCol; + this.state.cursorCol = oldCursorCol; this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); @@ -1139,10 +1118,6 @@ export class Editor implements Component { } } - private isWordBoundary(char: string): boolean { - return /\s/.test(char) || /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); - } - private moveWordBackwards(): void { const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1157,21 +1132,31 @@ export class Editor implements Component { } const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const graphemes = [...segmenter.segment(textBeforeCursor)]; let newCol = this.state.cursorCol; - const lastChar = textBeforeCursor[newCol - 1] ?? ""; - // If immediately on whitespace or punctuation, skip that single boundary char - if (this.isWordBoundary(lastChar)) { - newCol -= 1; + // Skip trailing whitespace + while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { + newCol -= graphemes.pop()?.segment.length || 0; } - // Now skip the "word" (non-boundary characters) - while (newCol > 0) { - const ch = textBeforeCursor[newCol - 1] ?? ""; - if (this.isWordBoundary(ch)) { - break; + if (graphemes.length > 0) { + const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; + if (isPunctuationChar(lastGrapheme)) { + // Skip punctuation run + while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { + newCol -= graphemes.pop()?.segment.length || 0; + } + } else { + // Skip word run + while ( + graphemes.length > 0 && + !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && + !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") + ) { + newCol -= graphemes.pop()?.segment.length || 0; + } } - newCol -= 1; } this.state.cursorCol = newCol; @@ -1189,24 +1174,33 @@ export class Editor implements Component { return; } - let newCol = this.state.cursorCol; - const charAtCursor = currentLine[newCol] ?? ""; + const textAfterCursor = currentLine.slice(this.state.cursorCol); + const segments = segmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); - // If on whitespace or punctuation, skip it - if (this.isWordBoundary(charAtCursor)) { - newCol += 1; + // Skip leading whitespace + while (!next.done && isWhitespaceChar(next.value.segment)) { + this.state.cursorCol += next.value.segment.length; + next = iterator.next(); } - // Skip the "word" (non-boundary characters) - while (newCol < currentLine.length) { - const ch = currentLine[newCol] ?? ""; - if (this.isWordBoundary(ch)) { - break; + if (!next.done) { + const firstGrapheme = next.value.segment; + if (isPunctuationChar(firstGrapheme)) { + // Skip punctuation run + while (!next.done && isPunctuationChar(next.value.segment)) { + this.state.cursorCol += next.value.segment.length; + next = iterator.next(); + } + } else { + // Skip word run + while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { + this.state.cursorCol += next.value.segment.length; + next = iterator.next(); + } } - newCol += 1; } - - this.state.cursorCol = newCol; } // Helper method to check if cursor is at start of message (for slash command detection) @@ -1274,9 +1268,11 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ private forceFileAutocomplete(): void { if (!this.autocompleteProvider) return; - // Check if provider has the force method - const provider = this.autocompleteProvider as any; - if (!provider.getForceFileSuggestions) { + // Check if provider supports force file suggestions via runtime check + const provider = this.autocompleteProvider as { + getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"]; + }; + if (typeof provider.getForceFileSuggestions !== "function") { this.tryTriggerAutocomplete(true); return; } @@ -1298,7 +1294,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ private cancelAutocomplete(): void { this.isAutocompleting = false; - this.autocompleteList = undefined as any; + this.autocompleteList = undefined; this.autocompletePrefix = ""; } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index e99ad8ec..37b663a3 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,21 +1,24 @@ import { isAltBackspace, + isAltLeft, + isAltRight, isArrowLeft, isArrowRight, isBackspace, isCtrlA, isCtrlE, isCtrlK, + isCtrlLeft, + isCtrlRight, isCtrlU, isCtrlW, isDelete, isEnter, } from "../keys.js"; import type { Component } from "../tui.js"; -import { visibleWidth } from "../utils.js"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; -// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) -const segmenter = new Intl.Segmenter(); +const segmenter = getSegmenter(); /** * Input component - single-line text input with horizontal scrolling @@ -168,10 +171,25 @@ export class Input implements Component { return; } - // Regular character input - if (data.length === 1 && data >= " " && data <= "~") { + if (isCtrlLeft(data) || isAltLeft(data)) { + this.moveWordBackwards(); + return; + } + + if (isCtrlRight(data) || isAltRight(data)) { + this.moveWordForwards(); + return; + } + + // Regular character input - accept printable characters including Unicode, + // but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F) + const hasControlChars = [...data].some((ch) => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars) { this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); - this.cursor++; + this.cursor += data.length; } } @@ -180,30 +198,80 @@ export class Input implements Component { return; } - const text = this.value.slice(0, this.cursor); - let deleteFrom = this.cursor; + const oldCursor = this.cursor; + this.moveWordBackwards(); + const deleteFrom = this.cursor; + this.cursor = oldCursor; - const isWhitespace = (char: string): boolean => /\s/.test(char); - const isPunctuation = (char: string): boolean => /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char); + this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); + this.cursor = deleteFrom; + } - const charBeforeCursor = text[deleteFrom - 1] ?? ""; - - // If immediately on whitespace or punctuation, delete that single boundary char - if (isWhitespace(charBeforeCursor) || isPunctuation(charBeforeCursor)) { - deleteFrom -= 1; - } else { - // Otherwise, delete a run of non-boundary characters (the "word") - while (deleteFrom > 0) { - const ch = text[deleteFrom - 1] ?? ""; - if (isWhitespace(ch) || isPunctuation(ch)) { - break; - } - deleteFrom -= 1; - } + private moveWordBackwards(): void { + if (this.cursor === 0) { + return; } - this.value = text.slice(0, deleteFrom) + this.value.slice(this.cursor); - this.cursor = deleteFrom; + const textBeforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(textBeforeCursor)]; + + // Skip trailing whitespace + while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { + this.cursor -= graphemes.pop()?.segment.length || 0; + } + + if (graphemes.length > 0) { + const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; + if (isPunctuationChar(lastGrapheme)) { + // Skip punctuation run + while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { + this.cursor -= graphemes.pop()?.segment.length || 0; + } + } else { + // Skip word run + while ( + graphemes.length > 0 && + !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && + !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") + ) { + this.cursor -= graphemes.pop()?.segment.length || 0; + } + } + } + } + + private moveWordForwards(): void { + if (this.cursor >= this.value.length) { + return; + } + + const textAfterCursor = this.value.slice(this.cursor); + const segments = segmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); + + // Skip leading whitespace + while (!next.done && isWhitespaceChar(next.value.segment)) { + this.cursor += next.value.segment.length; + next = iterator.next(); + } + + if (!next.done) { + const firstGrapheme = next.value.segment; + if (isPunctuationChar(firstGrapheme)) { + // Skip punctuation run + while (!next.done && isPunctuationChar(next.value.segment)) { + this.cursor += next.value.segment.length; + next = iterator.next(); + } + } else { + // Skip word run + while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { + this.cursor += next.value.segment.length; + next = iterator.next(); + } + } + } } private handlePaste(pastedText: string): void { diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 4614605a..4c311443 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -406,8 +406,30 @@ function wrapSingleLine(line: string, width: number): string[] { return wrapped.length > 0 ? wrapped : [""]; } -// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) -const segmenter = new Intl.Segmenter(); +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** + * Get the shared grapheme segmenter instance. + */ +export function getSegmenter(): Intl.Segmenter { + return segmenter; +} + +const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; + +/** + * Check if a character is whitespace. + */ +export function isWhitespaceChar(char: string): boolean { + return /\s/.test(char); +} + +/** + * Check if a character is punctuation. + */ +export function isPunctuationChar(char: string): boolean { + return PUNCTUATION_REGEX.test(char); +} function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] { const lines: string[] = []; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 716c2b4d..c77dab7b 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -396,6 +396,84 @@ describe("Editor component", () => { const text = editor.getText(); assert.strictEqual(text, "xab"); }); + + it("deletes words correctly with Ctrl+W and Alt+Backspace", () => { + const editor = new Editor(defaultEditorTheme); + + // Basic word deletion + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W + assert.strictEqual(editor.getText(), "foo bar "); + + // Trailing whitespace + editor.setText("foo bar "); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo "); + + // Punctuation run + editor.setText("foo bar..."); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo bar"); + + // Delete across multiple lines + editor.setText("line one\nline two"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "line one\nline "); + + // Delete empty line (merge) + editor.setText("line one\n"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "line one"); + + // Grapheme safety (emoji as a word) + editor.setText("foo 😀😀 bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo 😀😀 "); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo "); + + // Alt+Backspace + editor.setText("foo bar"); + editor.handleInput("\x1b\x7f"); // Alt+Backspace (legacy) + assert.strictEqual(editor.getText(), "foo "); + }); + + it("navigates words correctly with Ctrl+Left/Right", () => { + const editor = new Editor(defaultEditorTheme); + + editor.setText("foo bar... baz"); + // Cursor at end + + // Move left over baz + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); // after '...' + + // Move left over punctuation + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 'bar' + + // Move left over bar + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); // after 'foo ' + + // Move right over bar + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // at end of 'bar' + + // Move right over punctuation run + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); // after '...' + + // Move right skips space and lands after baz + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 14 }); // end of line + + // Test forward from start with leading whitespace + editor.setText(" foo bar"); + editor.handleInput("\x01"); // Ctrl+A to go to start + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo' + }); }); describe("Grapheme-aware text wrapping", () => {