Skip to content

fix(terminal): render the covered row tail inside the IME preedit overlay - #13834

Closed
tonite31 wants to merge 2 commits into
stablyai:mainfrom
tonite31:fix/12545-ime-preedit-occlusion
Closed

tonite31 wants to merge 2 commits into
stablyai:mainfrom
tonite31:fix/12545-ime-preedit-occlusion

Conversation

@tonite31

Copy link
Copy Markdown
Contributor

Closes #12545.

Summary

A mid-line IME composition visually swallows the character after the cursor: the preedit overlay (.composition-view) is an opaque box anchored to the cursor cell, and since nothing reaches the PTY while composing, the covered cells still hold their characters — the box just hides them until the syllable commits. Composing 가 with the cursor before 하 in 안녕하세요 blanks 하 for the whole composition. Long-standing overlay behavior in the vendored terminal library, not a regression.

The implementation follows the fix direction proposed in the issue: while a composition is open and the cursor sits mid-line, the rest of the row's committed text is rendered after the preedit inside the overlay, so the composition reads as inserted text pushing the tail right — what a native marked-text implementation (Terminal.app) shows.

What changed

All in the vendored config/patches/@xterm__xterm@6.1.0-beta.287.patch (CompositionHelper), plus a new test suite:

  • _renderCompositionView: when a row tail exists, the view holds an underlined preedit span (the way native marked text renders) followed by a tail span; with no tail, the previous single-text rtl form is preserved byte-for-byte.
  • With a tail the view is start-anchored (ltr) so the preedit stays visible and the pushed tail clips at the terminal edge; alone, rtl still keeps a long preedit's end in view.
  • The overlay is themed from options.theme instead of the stock #000/#FFF, so the tail reads as ordinary terminal text and light themes keep contrast.
  • The helper textarea now syncs to the preedit's own bounds, so IME candidate dialogs keep anchoring to the composing text rather than past the rendered tail.
  • A TUI can repaint the row while a composition is open (spinners, streamed output), so updateCompositionElements — which already runs on every render — re-reads the row remainder and re-renders the view when it changed. A string compare keeps the per-frame cost trivial, and no layout read is added (the Korean/CJK IME input lag in terminal panes — installTerminalImeCandidateAnchor forces layout on every compositionupdate #12211 concern).
  • One subtlety: the tail is read with an explicit end column because the cacheable form of translateToString arms the line string cache's self-renewing idle-clear timer, and the composition path must not own stray timers — the adversarial timer-bounding suite runs every captured callback synchronously and turns that self-renewal into an unbounded scheduling loop.
  • The bundle hunks are the same edits applied to the shipped minified lib/xterm.js / lib/xterm.mjs; the sourcemaps are carried through unchanged (same convention as Show a preedit the IME resumes without a compositionstart #13284).

Screenshots

Before: the character right of the cursor disappears for the whole composition — reproduced on a pre-patch build with macOS Korean 2-Set exactly as the issue describes.

After (real macOS Korean 2-Set typing on the patched dev build — 가 composing, underlined, with the tail visible behind it):

Plain zsh prompt:

mid-line composition at a zsh prompt with the tail visible

Claude Code composer:

mid-line composition in the Claude Code TUI with the tail visible

Testing

  • New suite terminal-ime-xterm-midline-preedit-tail.test.ts (7 cases): the issue's exact repro (안녕하세요 + CUB 6 + composing 가 shows 하세요 after the preedit), tail tracking across preedit growth, end-of-row single-text behavior preserved, theming, tail refresh under a TUI repaint, tail appearing when output lands mid-composition, and overlay hide on compositionend.
  • pnpm vitest run over every terminal-ime-* suite: 21 files / 284 tests pass — including the ESM+CJS adversarial composition-ownership suite and the timer-bounding case that catches the string-cache trap above.
  • pnpm typecheck and pnpm lint pass.
  • Manual: macOS 26.5 (arm64), built-in Korean 2-Set — mid-line composition shows the pushed tail at a plain zsh prompt and inside the Claude Code composer; commit and cancel both restore the row; end-of-row composition unchanged.

AI review report

  • Cross-platform: renderer-only change inside the vendored terminal library; no platform branches. Key routing and PTY writes are untouched — onData output is byte-identical, so Windows/WSL/Linux IME flows see only the overlay difference.
  • SSH/remote/local: display-only; nothing crosses the wire.
  • Agents/integrations: verified against a plain shell and the Claude Code TUI; applies to any agent TUI equally since the tail is read from the terminal buffer, not from any agent state.
  • Performance: no added layout reads; one buffer-line read plus a string compare per render frame, only while a composition is open; the explicit-end-column read stays off the line string cache so composition owns no timers.
  • Known limitation: the rendered tail is plain-styled (theme foreground on theme background) while composing; per-cell colors return on commit. This is inherent to the DOM-overlay approach — the alternative (drawing the preedit into the cell renderer itself) is a far larger change to the WebGL pipeline.
  • Security: none — no input parsing, no privilege boundary, no new data flow.

Notes

…rlay

A mid-line composition visually swallows the character after the cursor:
the preedit overlay (.composition-view) is an opaque box anchored to the
cursor cell, and nothing reaches the PTY while composing, so the covered
cells still hold their characters — the box just hides them until the
syllable commits. Composing 가 with the cursor before 하 in 안녕하세요
blanks 하 for the whole composition (stablyai#12545). Long-standing overlay
behavior in the vendored terminal library, not a regression; Terminal.app's
native marked-text rendering handles the same case correctly.

The fix renders the rest of the row's committed text after the preedit
inside the overlay, so the composition reads as inserted text pushing the
tail right. The preedit is underlined the way native marked text is, the
view is start-anchored (ltr) when a tail is present so the preedit stays
visible while the tail clips at the terminal edge, and the overlay is
themed from options.theme instead of the stock #000/#FFF so the tail reads
as ordinary terminal text. The end-of-row composition keeps the previous
single-text rtl behavior byte-for-byte, and the textarea now syncs to the
preedit's own bounds so IME candidate dialogs keep anchoring to the
composing text rather than past the rendered tail.

A TUI can repaint the row while the composition is open (spinners,
streamed output), so updateCompositionElements — which already runs on
every render — re-reads the row remainder and re-renders the view when it
changed; a string compare keeps the per-frame cost trivial and adds no
layout read.

One subtlety: the tail is read with an explicit end column because the
cacheable form of translateToString arms the line string cache's
self-renewing idle-clear timer, and the composition path must not own
stray timers — the adversarial timer-bounding test runs every captured
callback synchronously and turns that self-renewal into an unbounded
scheduling loop.

The bundle hunks are the same edits applied to the shipped minified
output; the sourcemaps are carried through unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bd08df9-2ff5-41fb-b5d1-4624fd71bc34

📥 Commits

Reviewing files that changed from the base of the PR and between d7e37bd and 7d844da.

📒 Files selected for processing (1)
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts

📝 Walkthrough

Walkthrough

Added a happy-dom/Vitest test suite for xterm IME composition rendering. The tests cover mid-line committed row tails, changing composition text, end-of-row behavior, terminal theme colors, row repaint refreshes, streamed output refreshes, and deactivation after compositionend. The fixture mocks canvas measurement, sets terminal geometry, drives composition and terminal writes, and disposes terminals after each test.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: rendering the covered row tail inside the IME preedit overlay.
Description check ✅ Passed The description covers the required sections, implementation, testing, review, security, platform compatibility, and known limitations.
Linked Issues check ✅ Passed The changes address issue #12545 by keeping mid-line row content visible during Korean IME composition and applying theme-aware overlay rendering.
Out of Scope Changes check ✅ Passed The patch, shipped bundle updates, and regression tests are directly related to the linked issue and stated pull request objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts (1)

2-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Condense the file header comment.

Keep only the non-obvious test constraint. The test names and assertions already describe the IME behavior.

As per coding guidelines, “Comments must be concise, limited to non-obvious information, and preferably one line.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 48bb66b2-63c8-4eaf-8bc7-1fcc11b68cf3

📥 Commits

Reviewing files that changed from the base of the PR and between a0a654c and d7e37bd.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts

Review found two shortcuts in the test rig: the growth test restarted a
composition per preedit value instead of streaming compositionupdate events
through one active composition, and the repaint tests called the private
_compositionHelper.updateCompositionElements directly instead of exercising
the production terminal.onRender hook.

Split the compose helper into start/update, and replace the private calls
with a write helper that awaits the repaint the write schedules — arming the
render listener only after the parse callback, because a repaint scheduled by
an earlier write can fire first and still show the old row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nwparker

Copy link
Copy Markdown
Contributor

Closing the branch — your fix is merged, as #15014 (453237c), with you as Co-authored-by.

It needed a port rather than a rebase: the base was 383 commits behind, main had since changed the same CompositionHelper hunks (_deferPreeditResync, the classList.toggle('active', …) change), and the minified bundle hunks were built from a stale bundle. The approach, the method names and your unit suite came across close to verbatim.

Two things added on top:

  • _opaqueViewBackground() — Orca's terminalBackgroundOpacity composes theme.background into rgba(...), so carried through as-is the tail mask would have been see-through and re-exposed the very cell it stands in for.
  • white-space: pre on the remainder span. A readiness review caught that the view is nowrap, which collapses runs of spaces — measured, twenty spaces plus a border rendered two cells wide instead of twenty-one. The visible case is composing inside an agent TUI input box, where the padded row's border drew a cell after the preedit. Every fixture in both PRs was space-free, so nothing caught it.

Your translateToString finding is preserved and called out in the merged code: the explicit end column is load-bearing, because the cacheable form arms BufferLineStringCache's self-renewing idle-clear timer and the adversarial spec drains captured timers synchronously into an unbounded loop. That's a trap someone would have walked back into.

Thanks — this was the right design and it's now what ships.

@nwparker nwparker closed this Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Korean IME preedit overlay hides the character right after the cursor when composing mid-line (macOS)

4 participants