-
Notifications
You must be signed in to change notification settings - Fork 46.7k
fix(tui): restrict fast-echo bypass to ASCII so Vietnamese/CJK/IME input renders correctly #26011
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js' | ||
|
|
||
| // The fast-echo path bypasses Ink and writes characters directly to stdout | ||
| // for the common case of typing plain English at the end of the line. These | ||
| // tests pin the shape preconditions that make that bypass safe. | ||
| // | ||
| // Regression intent: any non-ASCII text — Vietnamese precomposed letters | ||
| // (single grapheme, single byte width but produced via IME composition), | ||
| // combining marks (zero width), CJK (double width), emoji (variable | ||
| // width), or anything that could be produced by an in-flight IME | ||
| // composition — must NOT take the bypass. Closes: | ||
| // - "TUI is experiencing font errors when using Unicode to type Vietnamese" | ||
| // - #5221 TUI input box renders incorrectly for CJK / East-Asian wide | ||
| // - #7443 CLI TUI renders and deletes Chinese characters incorrectly | ||
| // - #17602 / #17603 Chinese text scattering / ghosting | ||
|
|
||
| describe('canFastAppendShape', () => { | ||
| const COLS = 40 | ||
|
|
||
| it('accepts plain ASCII appended at end of single-line input', () => { | ||
| expect(canFastAppendShape('hello', 5, 'x', COLS, 5)).toBe(true) | ||
| expect(canFastAppendShape('hello', 5, ' world', COLS, 5)).toBe(true) | ||
| }) | ||
|
|
||
| it('rejects when cursor is not at end of line', () => { | ||
| expect(canFastAppendShape('hello', 3, 'x', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects when current is empty (placeholder render path needed)', () => { | ||
| expect(canFastAppendShape('', 0, 'x', COLS, 0)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects when current contains a newline (multi-line layout)', () => { | ||
| expect(canFastAppendShape('hi\nthere', 8, 'x', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects when appending would hit the wrap column', () => { | ||
| // Reaching cols on append must trigger a wrap, which the bypass | ||
| // cannot draw. Stay strictly below cols. | ||
| expect(canFastAppendShape('hello', 5, 'x', 6, 5)).toBe(false) | ||
| }) | ||
|
|
||
| // -- Regression coverage: Vietnamese / combining marks / IME -- | ||
|
|
||
| it('rejects Vietnamese precomposed letter ề (U+1EC1) — IME composition path', () => { | ||
| // 'ề' is one grapheme, length 1, width 1, but Vietnamese Telex/IME | ||
| // produces it via a multi-key composition. Fast-echo would commit the | ||
| // intermediate state to stdout and desync once the final commit | ||
| // arrives. | ||
| expect(canFastAppendShape('hello', 5, 'ề', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects Vietnamese tone marks ă, ơ, ư (Latin-Extended-A/B)', () => { | ||
| for (const ch of ['ă', 'ắ', 'ơ', 'ờ', 'ư', 'ự']) { | ||
| expect(canFastAppendShape('hello', 5, ch, COLS, 5)).toBe(false) | ||
| } | ||
| }) | ||
|
|
||
| it('rejects NFD combining marks (U+0300 grave, U+0301 acute, U+0302 circumflex)', () => { | ||
| // Decomposed Vietnamese: 'e' + combining circumflex + combining grave | ||
| // = 'ề'. Each combining mark is zero-width but length 1; without the | ||
| // ASCII guard the second/third keypress would be fast-echoed and | ||
| // desync the cell column. | ||
| expect(canFastAppendShape('hello', 5, '\u0300', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, '\u0301', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, '\u0302', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects CJK (East-Asian wide) characters', () => { | ||
| expect(canFastAppendShape('hello', 5, '你', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, '日本', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects emoji', () => { | ||
| expect(canFastAppendShape('hello', 5, '🙂', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects ANSI-bearing or control text', () => { | ||
| expect(canFastAppendShape('hello', 5, '\x1b[31m', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, '\t', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, '\x7f', COLS, 5)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects NBSP and Latin-1 letters that would change the line shape', () => { | ||
| expect(canFastAppendShape('hello', 5, '\u00a0', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, 'é', COLS, 5)).toBe(false) | ||
| expect(canFastAppendShape('hello', 5, 'ñ', COLS, 5)).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('canFastBackspaceShape', () => { | ||
| it('accepts deleting the last ASCII char', () => { | ||
| expect(canFastBackspaceShape('hello', 5)).toBe(true) | ||
| }) | ||
|
|
||
| it('rejects when cursor is not at end', () => { | ||
| expect(canFastBackspaceShape('hello', 3)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects when there is nothing to delete', () => { | ||
| expect(canFastBackspaceShape('', 0)).toBe(false) | ||
| expect(canFastBackspaceShape('hello', 0)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects when value contains a newline', () => { | ||
| expect(canFastBackspaceShape('hi\nthere', 8)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects deleting Vietnamese precomposed letter ề', () => { | ||
| // The "\b \b" shortcut clears one terminal cell; that's fine for a | ||
| // 1-cell ASCII char but if the previous grapheme is a Vietnamese | ||
| // letter that the IME may still be holding open, we want Ink to | ||
| // re-render so composition state stays consistent. | ||
| expect(canFastBackspaceShape('helloề', 'helloề'.length)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects deleting a CJK character (2 cells)', () => { | ||
| expect(canFastBackspaceShape('hi你', 'hi你'.length)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects deleting a NFD-composed grapheme with combining marks', () => { | ||
| // 'e' + U+0302 (circumflex) + U+0300 (grave) — final grapheme is one | ||
| // cluster but the previous-grapheme slice is multi-codepoint. Width | ||
| // is 1 but the bypass would be unsafe because the rendered cell | ||
| // already contained the combined glyph. | ||
| const s = 'hello' + 'e\u0302\u0300' | ||
| expect(canFastBackspaceShape(s, s.length)).toBe(false) | ||
| }) | ||
|
|
||
| it('rejects deleting an emoji', () => { | ||
| expect(canFastBackspaceShape('hi🙂', 'hi🙂'.length)).toBe(false) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.