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
This commit is contained in:
Ahmed Kamal 2025-12-25 05:09:47 +02:00 committed by GitHub
parent 65cbc22d7c
commit 0427445242
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 86 deletions

View file

@ -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 = "";
}

View file

@ -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 {

View file

@ -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[] = [];