From b8dd9be3d06d6ae46b82f7877c2cdd88152390c6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 19 Dec 2025 20:39:45 +0100 Subject: [PATCH] fix(tui): handle emoji deletion and cursor movement correctly Backspace, Delete, and arrow keys now use Intl.Segmenter to operate on grapheme clusters instead of individual UTF-16 code units. This fixes deletion of emojis and other multi-codepoint characters. fixes #240 --- packages/coding-agent/CHANGELOG.md | 2 ++ packages/tui/src/components/editor.ts | 37 ++++++++++++++++++++------- packages/tui/src/components/input.ts | 35 ++++++++++++++++++------- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e1e52239..1ba912da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,8 @@ - **Kitty keyboard protocol on Linux**: Fixed keyboard input not working in Ghostty on Linux when Num Lock is enabled. The Kitty protocol includes Caps Lock and Num Lock state in modifier values, which broke key detection. Now correctly masks out lock key bits when matching keyboard shortcuts. ([#243](https://github.com/badlogic/pi-mono/issues/243)) +- **Emoji deletion and cursor movement**: Backspace, Delete, and arrow keys now correctly handle multi-codepoint characters like emojis. Previously, deleting an emoji would leave partial bytes, corrupting the editor state. ([#240](https://github.com/badlogic/pi-mono/issues/240)) + ## [0.24.0] - 2025-12-19 ### Added diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index d699cb60..8ef933c7 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -793,14 +793,20 @@ export class Editor implements Component { this.historyIndex = -1; // Exit history browsing mode if (this.state.cursorCol > 0) { - // Delete character in current line + // Delete grapheme before cursor (handles emojis, combining characters, etc.) const line = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = line.slice(0, this.state.cursorCol); - const before = line.slice(0, this.state.cursorCol - 1); + // Find the last grapheme in the text before cursor + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + + const before = line.slice(0, this.state.cursorCol - graphemeLength); const after = line.slice(this.state.cursorCol); this.state.lines[this.state.cursorLine] = before + after; - this.state.cursorCol--; + this.state.cursorCol -= graphemeLength; } else if (this.state.cursorLine > 0) { // Merge with previous line const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -943,9 +949,16 @@ export class Editor implements Component { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { - // Delete character at cursor position (forward delete) + // Delete grapheme at cursor position (handles emojis, combining characters, etc.) + const afterCursor = currentLine.slice(this.state.cursorCol); + + // Find the first grapheme at cursor + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + const before = currentLine.slice(0, this.state.cursorCol); - const after = currentLine.slice(this.state.cursorCol + 1); + const after = currentLine.slice(this.state.cursorCol + graphemeLength); this.state.lines[this.state.cursorLine] = before + after; } else if (this.state.cursorLine < this.state.lines.length - 1) { // At end of line - merge with next line @@ -1087,18 +1100,24 @@ export class Editor implements Component { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (deltaCol > 0) { - // Moving right + // Moving right - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol < currentLine.length) { - this.state.cursorCol++; + const afterCursor = currentLine.slice(this.state.cursorCol); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1; } else if (this.state.cursorLine < this.state.lines.length - 1) { // Wrap to start of next logical line this.state.cursorLine++; this.state.cursorCol = 0; } } else { - // Moving left + // Moving left - move by one grapheme (handles emojis, combining characters, etc.) if (this.state.cursorCol > 0) { - this.state.cursorCol--; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1; } else if (this.state.cursorLine > 0) { // Wrap to end of previous logical line this.state.cursorLine--; diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 31ad3e11..e5b05ab7 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -2,6 +2,9 @@ import { isAltBackspace, isArrowLeft, isArrowRight, isCtrlA, isCtrlE, isCtrlK, i import type { Component } from "../tui.js"; import { visibleWidth } from "../utils.js"; +// Grapheme segmenter for proper Unicode iteration (handles emojis, etc.) +const segmenter = new Intl.Segmenter(); + /** * Input component - single-line text input with horizontal scrolling */ @@ -70,34 +73,48 @@ export class Input implements Component { } if (data === "\x7f" || data === "\x08") { - // Backspace + // Backspace - delete grapheme before cursor (handles emojis, etc.) if (this.cursor > 0) { - this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); - this.cursor--; + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); + this.cursor -= graphemeLength; } return; } if (isArrowLeft(data)) { - // Left arrow + // Left arrow - move by one grapheme (handles emojis, etc.) if (this.cursor > 0) { - this.cursor--; + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; } return; } if (isArrowRight(data)) { - // Right arrow + // Right arrow - move by one grapheme (handles emojis, etc.) if (this.cursor < this.value.length) { - this.cursor++; + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; } return; } if (data === "\x1b[3~") { - // Delete + // Delete - delete grapheme at cursor (handles emojis, etc.) if (this.cursor < this.value.length) { - this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); } return; }