code-yeongyu / code-yeongyu/web-terminal

IME composition goes to ghostty's contenteditable container, so CJK input is drawn twice and anchored to the terminal edge

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

Description

### Summary

IME composition never reaches the hidden textarea that `attachImePreedit` carefully parks on the terminal cursor, because nothing on desktop ever focuses that textarea — ghostty keeps focus on its contenteditable container. The browser therefore anchors the composition to the container's caret, which sits at the container origin, and the composing syllable is painted at the top-left of the terminal (Firefox) or the bottom (Chrome) while the in-house `.term-preedit` overlay simultaneously draws the same syllable correctly at the caret. Every CJK user sees the character twice, in two different places, for the whole session.

### Environment

- web-terminal `3a73f62`, Bun `1.4.0-canary.1`
- macOS, system Korean input (3-set); reproduced in Firefox 153 and Chromium 151
- Served over a cloudflared quick tunnel

### Reproduction

1. Open web-terminal on macOS and click into the terminal.
2. Switch to a Korean (or any composing) input source and start typing a syllable.
3. Before committing, look at the top-left of the terminal region.

The composing syllable appears there as a separate line, and again at the caret.

### Evidence

Focus never lands on the textarea that the preedit code positions:

```
after load: MAIN[contenteditable]
after clicking .terminal: MAIN[contenteditable]
forcing textarea.focus(): TEXTAREA(ghostty) -> typing still reaches the PTY, focus stays
```

So `attachImePreedit`'s placement work applies to an element the IME is not composing into. Moving it or resizing it cannot affect the composition window.

### Root cause and two contributing defects

**1. Focus target.** `ime-preedit.ts` and `main.ts` both document the textarea as the correct focus target ("ghostty focuses its contenteditable container, whose prevented `beforeinput` silently drops IME text"), and the touch path calls `terminal.textarea?.focus()` on tap. There is no equivalent on desktop, so the container keeps focus.

**2. The caret box is positioned one event too late.** `place()` returns early while the overlay text is empty:

```ts
const place = (): void => {
const cell = cursorCell(terminal)
if (cell === undefined || overlay.textContent === "") {
overlay.hidden = true
return // textarea position is never reached
}
...
textarea.style.left = ...
```

At `compositionstart` there is no composition text yet, so the box is still at the origin at the exact moment the UA freezes the composition window against it.

**3. A 1x1 box is a degenerate caret rect.** macOS reads `firstRectForCharacterRange` from the focused element's box. ghostty pins the textarea to `1px × 1px`, which gives the IME no line to hang the window from.

### Suggested fix

Redirect focus from the container to the textarea:

```ts
const redirectFocusToTextarea = (event: FocusEvent): void => {
if (event.target !== container) return
terminal.textarea?.focus()
}
container.addEventListener("focusin", redirectFocusToTextarea)
```

Split the caret-box placement out of `place()` so it runs at `compositionstart` (and on cursor move) rather than only once composition text exists, and size the box to the cell:

```ts
const positionCaretBox = (): void => {
const cell = cursorCell(terminal)
const textarea = terminal.textarea
if (cell === undefined || textarea === undefined) return
textarea.style.left = `${clamp(cell.x)}px`
textarea.style.top = `${clamp(cell.y)}px`
textarea.style.width = `${cell.width}px`
textarea.style.height = `${cell.height}px`
textarea.style.fontSize = `${terminal.options.fontSize}px`
textarea.style.pointerEvents = "none"
}
```

All three are needed. Fixes 2 and 3 alone changed nothing on macOS, because the composition was still going to the container.

### Verification

| check | Firefox 153 | Chromium 151 |
|---|---|---|
| focus after load / after clicking the terminal | TEXTAREA | TEXTAREA |
| typing without clicking first | pass | pass |
| typing after clicking | pass | pass |
| composition target is the textarea | yes | yes |
| caret box aligned to the cursor cell | 203 = 203 | 203 = 203 |
| password field, `Space` on a focused button | pass | pass |

Confirmed on macOS with the real input source: the duplicate at the terminal edge is gone and only the caret-side syllable remains. `tsgo --noEmit` clean, `biome check` clean, `bun test` 102/102.

Happy to send a PR if the approach looks right.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reading ime-preedit.ts and main.ts, then trace how the desktop contenteditable container receives focus and how attachImePreedit positions the textarea. Ensure focus, caret-box placement, and cell-sized geometry cover composition start and cursor movement. Verify with the reported Firefox and Chromium checks, then run tsgo --noEmit, biome check, and bun test.

Written by the indexing model from the issue text.

Assessment

Tech stack
bun, typescript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.