mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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:
parent
65cbc22d7c
commit
0427445242
4 changed files with 250 additions and 86 deletions
|
|
@ -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 = "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue