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

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