code-yeongyu / code-yeongyu/web-terminal

IME: committed-but-unechoed text is invisible for a full PTY round trip — flicker, overlap, and stale stand-ins

Open
#3 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
22
Forks
5
PR merge metrics
No merged PRs in 30d

Description

### Summary

Even with composition routed to the caret-tracked textarea (#2), every committed CJK syllable is invisible for a full PTY round trip: `compositionend` sends the text, but nothing draws it until the server echo comes back. Locally that gap is ~1 ms and invisible; over a cloudflared tunnel it is 150–300 ms per syllable, which surfaces as a family of artifacts users notice one by one:

- the previous syllable flickers off and back on when the next one starts
- the next syllable is painted on top of the previous one (the cursor has not moved yet)
- with a naive fix, stale stand-in glyphs linger in the wrong colour for seconds

### Environment

- web-terminal `3a73f62`, Bun `1.4.0-canary.1`
- macOS system Korean IME; reproduced in Firefox 153 and Chromium 151 via a cloudflared quick tunnel
- All measurements below reproduced locally with a 250 ms delayed-echo PTY (`python3` re-emitting stdin after `sleep`)

### Two designs that look right and are not

The obvious data model is one string of committed-but-unechoed text, retired when the buffer shows it or a timeout passes. Both variants of that model fail:

Literal Hangul used in the accumulate-mode mismatch:

```
accumulated (no space): 많이좋아…
painted (with space): 많이 좋아…
```

**Accumulate (`pending += data`)** — the string can only match or expire as a whole. A space typed between words never appears in composition events (it is sent as a plain key), so the accumulated string can never match the painted string. One unmatched syllable keeps the whole run alive, and every new commit refreshes the shared expiry, so the stale stand-in survives for as long as the user keeps typing — measured 5 s of wrong-colour text in a real session.

**Replace (`pending = data`)** — the previous syllable is dropped the moment the next one commits. If its echo has not arrived yet (fast typing, slow link), the character the user just typed vanishes for a round trip.

### Design that works

A queue of independent entries, one per committed syllable **and one per plain printable key typed inside the composition flow**:

```ts
type PendingSyllable = { text: string; cell: CursorPosition; at: number }
// expected cell = cursor + columns already covered by the queue
```

- Each entry retires on its own, when the terminal buffer actually shows its text at its expected cell (cursor position is not a usable signal — a full-screen TUI moves the cursor while repainting), or when its own expiry passes. No shared fate, no refreshed timers.
- Expected cells account for CJK double-width and for spaces/punctuation between syllables; plain keys are only tracked near composition activity so non-echoing apps (vim) never see a stand-in.
- `Enter`/`Backspace` invalidate every expected cell and clear the queue.
- The stand-in's foreground colour is sampled from the cell of the most recently echoed entry on the same row, so once the first syllable lands, later stand-ins are pixel-identical to what the app will paint and the handoff is invisible.
- The composing syllable itself renders the way native Ghostty does: terminal background + underline, in the theme foreground.
- Baseline: glyphs are shifted to ghostty-web's `metrics.baseline`, but the background boxes stay pinned to the cell — shifting the box opens a ~1 px sliver at the cell bottom through which the app's own underline cursor bleeds.

### Verification (250 ms delayed echo, Firefox 153 + Chromium 151)

Word-boundary fixture (literal Hangul):

```
typed: 한[space]글
stand-in: 한 글
```

| scenario | result |
|---|---|
| second syllable commits before first echo | both stay visible; replace-mode showed only the second |
| word boundary (Hangul syllable, space, Hangul syllable) typed faster than the echo | stand-in shows the two syllables with a space with correct cells; accumulate-mode lingered indefinitely |
| burst of 6 syllables with echo disabled | ≤1 syllable pending at a time, queue empty ≤700 ms after typing stops |
| app paints text in its own colour | second and later stand-ins sampled to that exact colour |
| space typed outside composition flow | not tracked (vim-safety) |

A working implementation of all of the above exists and is submitted as a PR; happy to split or reshape it if you prefer a different boundary.

Contributor guide

No contributing guide indexed for this repository

Research direction

The issue describes the expected queue behavior and verification scenarios, but does not name implementation files or tests. Start by locating the composition-event handling and terminal rendering paths, then run the delayed-echo PTY scenarios described in the issue. Done means committed text remains visible until echoed or individually expires, with correct spacing, cell placement, color, and queue clearing as specified.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, typescript, wasm
Domain
frontend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.