charmbracelet / charmbracelet/x

vt: keep grapheme clusters intact across Write calls

Open
#935 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
314
Forks
94
Avg merge
3d 22h
Merged PRs (30d)
2

Description

## Problem

The final screen changes depending on how the same text is divided between calls to `Emulator.Write`.

That is unsafe for terminal output because readers do not receive meaningful text boundaries. A pipe, socket, PTY, or tmux control client may split one visible character across two reads.

## Minimal reproduction

Using `github.com/charmbracelet/x/vt` at `3755ebad01b1`:

```go
whole := vt.NewEmulator(20, 2)
_, _ = whole.Write([]byte("❤️ vs ❤"))

split := vt.NewEmulator(20, 2)
_, _ = split.Write([]byte("❤"))
_, _ = split.Write([]byte("\ufe0f vs ❤"))
```

The two emulators received identical bytes in the same order. Only the `Write` boundaries differ.

## Expected

Both emulators should produce the same cells, widths, and cursor position. The first `❤️` should occupy one grapheme cell.

## Actual

The split form commits `❤` before the variation selector arrives. The selector is then handled separately, the following cells shift, and the cursor ends in a different column.

A related case happens without a split:

```go
_, _ = emu.Write([]byte("e\u0301")) // NFD form of é
```

The combining accent does not remain attached to the ASCII `e`.

## Why this matters

This can affect ordinary terminal traffic even when the caller writes complete strings, because transport layers are free to split those bytes differently. Emoji, combining accents, variation selectors, and ZWJ emoji are the likely inputs.

A useful regression test would feed the same byte sequence whole and at every possible split point, then require identical cells, widths, and cursor position. The caller cannot provide a reliable “end of character” signal because a `Write` boundary is not that signal.

Contributor guide

Open the contributing guide

Research direction

Start at the vt Emulator.Write entry point and trace how grapheme clusters, combining accents, and variation selectors are assigned to cells across calls. Reproduce the whole-versus-split examples, then add coverage that feeds the same bytes at every split point and verifies identical cells, widths, and cursor position.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.