Skip to content

fix(terminal): keep text after the cursor visible during IME composition - #12546

Closed
p1318k wants to merge 4 commits into
stablyai:mainfrom
p1318k:fix/ime-composition-tail-occlusion
Closed

p1318k wants to merge 4 commits into
stablyai:mainfrom
p1318k:fix/ime-composition-tail-occlusion

Conversation

@p1318k

@p1318k p1318k commented Aug 4, 2026 •

Copy link
Copy Markdown

Fixes #12545.

Summary

Composing Korean in the middle of a line hid the character sitting at the cursor for the whole
composition. xterm paints the preedit into .composition-view, which upstream styles opaque
(background: #000; position: absolute; z-index: 1) and anchors to the cursor cell. Nothing reaches
the pty while composing, so that cell still holds its original character — the box simply covers it.

macOS Terminal.app renders the preedit as inserted text and keeps the following character visible;
iTerm2 and stock xterm hide it.

installTerminalImeCompositionTail repaints the row remainder just past the preedit while a
composition is open, so the composition reads as an insertion pushing the tail right.

The overlay is its own element rather than a child of .composition-view, which xterm rewrites on
every compositionupdate, clips with overflow: hidden, and flips to direction: rtl. Four details
matter:

  • Cell-grid layout and placement. The tail is built cell by cell off the buffer with
    width = columns × cellWidth, and positioned from the cell grid rather than from
    .composition-view. A plain text node advances on the font's own metrics, which runs tighter than
    the terminal grid and looked visibly cramped. xterm also only assigns the preedit box its geometry
    on compositionupdate, so reading that element would place a terminal's first composition in the
    top-left corner. The grid is re-measured on resize.
  • Start column. The tail begins on the cell the preedit ends on, measured through the
    emulator's own width tables (new measureTerminalStringColumns in
    shared/terminal-unicode-provider.ts). Measuring the preedit box instead inherits the bidi marks
    xterm wraps it in and leaves a ragged gap.
  • Commit echo. Every Hangul syllable commits before the next one starts composing, so its echo
    lands mid-composition. The tail re-reads the row (coalesced to one repaint per frame) instead of
    being dropped — dropping it hid the tail from the second syllable onwards.
  • Scroll safety. Row reads use baseY + cursorY, since cursorY is viewport-relative while
    getLine indexes the whole buffer. The tail hides entirely while the viewport is scrolled away
    from the cursor row, and re-reads on every compositionupdate so a viewport that moves
    mid-composition cannot leave a stale snapshot on screen.

Masking needs an opaque backdrop. A translucent terminalBackgroundOpacity theme is layered over
the first opaque surface behind it rather than swapped for one, so the mask matches the row it
replaces instead of dropping a black block onto a light theme.

Screenshots

Captured from the packaged app driven through Playwright (electron-headful): type 가나다라마,
walk the cursor back three syllables, then open a composition on 바. Both frames are the same live
state — the "before" frame simply has the tail element suppressed, which is exactly the old
behavior.

Before — 다 is hidden under the preedit box for the whole composition:

before-tail-hidden-crop
p1318k@… orca-e2e-repo % 가 나 [바] 라 마
                                 ↑ 다 is gone

After — 다 is repainted just past the preedit, reading as an insertion pushing it right:

after-tail-visible-crop
p1318k@… orca-e2e-repo % 가 나 [바] 다 라 마
                                 ↑ 다 restored

The captured DOM state for the "after" frame: tail textContent="다라마", display=block,
left=440px, against the preedit box at left=424px — the tail resumes exactly two cells (one
double-width syllable) past where the preedit starts.

Testing

  • pnpm lint
  • pnpm typecheck
  • pnpm test — 45,482 passing. 9 failures in 3 files (check-root-directory-entries.test.mjs,
    codex-fetcher-pty-settle.test.ts, agent-exec-handler.test.ts) reproduce identically on
    origin/main with none of this branch applied, so they are pre-existing and unrelated. The
    root-directory guard ones are a macOS-only artifact: the test shells out to bash, and the
    guard script uses declare -A, which macOS's bash 3.2 rejects.
  • pnpm build
  • Added or updated high-quality tests that would catch regressions

terminal-ime-composition-tail.test.ts runs 20 cases against the real emulator: mid-line repaint,
end-of-row no-op, scrolled-buffer row read, viewport-scrolled-away hide, mid-composition viewport
move, cell-grid advance, preedit-end start column, first-composition placement, resize re-measure,
commit-echo re-read, composition-end cleanup, opaque / translucent / alpha-hex backdrops, and
listener + element teardown.

Every regression test was verified to fail against the pre-fix code before being kept.

Manually verified on macOS with the Korean 2-Set IME in the integrated terminal.

AI Review Report

Reviewed with Claude Code across the risks below. CodeRabbit also reviewed the branch; all five of
its findings are fixed and confirmed resolved.

  • Cross-platform (macOS, Linux, Windows). The change is renderer-only: it reads the xterm buffer
    and writes to a DOM element. No path handling, no shell invocation, no Electron main-process code,
    no keyboard shortcut or accelerator handling, and no metaKey/ctrlKey branching — so none of
    the usual platform divergences are touched. It hangs off the DOM composition* events, which
    behave the same on all three platforms. Manual verification is macOS-only (see Notes).
  • SSH / remote / local. The tail operates on the emulator, not the transport: it reads
    terminal.buffer.active in the renderer and writes nothing to the pty. Local, remote and SSH
    panes take the identical path, and no round trip is added to composition handling. Installed in
    both pane lifecycle (pane-lifecycle.ts) and the dashboard popout preview
    (preview-terminal-compatibility.ts), with a disposer stored on the pane so disposePane() tears
    it down.
  • Agent / integration / git-provider. Not touched — this is terminal rendering only.
  • Performance. Per compositionupdate: one buffer line read plus up to cols span elements.
    Geometry comes from the cell grid measured at compositionstart and on resize, so no layout
    read was added per compositionupdate (keeping clear of Korean/CJK IME input lag in terminal panes — installTerminalImeCandidateAnchor forces layout on every compositionupdate #12211). Output-driven repaints from
    onWriteParsed are coalesced to one per animation frame. Cleanup covers the three composition
    listeners, the onWriteParsed and onResize disposables, a pending requestAnimationFrame, and
    the element itself.
  • UI quality. The tail sits below the preedit box (z-index: 0 against xterm's 1) so a wide
    glyph never clips it, stops where the row's content stops rather than at the right margin, and
    clamps to the screen width. Backdrop resolution walks to the first opaque ancestor and layers a
    translucent theme over it, which was checked against both light and dark themes.

Flagged and changed as a result: getLine(cursorY) ignoring scrollback (repainted a stale row once
output scrolled), translucent CSS colors passing as opaque (rgb(… / 50%), hsla(…)), fully
opaque alpha hex being rejected (#ffff, #ffffffff), and compositionupdate not resnapshotting
(left a stale tail after a viewport move). Each has a regression test.

Security Audit

No input execution, path handling, IPC, auth, secrets, network access, or dependency changes.

  • Injection. Repainted text is written with textContent, never innerHTML, so terminal output
    cannot inject markup.
  • CSS value interpolation. The one interpolated value is the theme background, which reaches
    style.backgroundImage inside linear-gradient(…). It can only get there after
    isOpaqueColor(), whose functional-color pattern is anchored and forbids nested parentheses, so
    the value cannot terminate the linear-gradient() early. style.backgroundColor drops invalid
    colors on its own. No injection path found. Malformed hex is now rejected outright rather than
    treated as opaque, since CSS would ignore it and leave the mask see-through.
  • Data exposure. The overlay renders characters already present in the local terminal buffer,
    into the same window, for the duration of the composition. Nothing is persisted or transmitted.

No follow-up needed.

Notes

  • Per-cell colors are not carried into the overlay; the covered span renders in the theme's
    foreground/background for the duration of the composition.
  • Not manually verified on Linux or Windows. The behavior is platform-neutral by construction
    (see above), but the IME stacks are not — terminal-ime-e2e.yml covers Linux X11 with
    ibus-hangul and would be the place to confirm it in CI.
  • No CI has run on this PR: only track-community-pr executed, on the first commit. The remaining
    workflows appear to be waiting on maintainer approval for a fork PR.

xterm paints the preedit into an opaque .composition-view anchored to the
cursor cell, so composing mid-line hid the character sitting there until the
composition committed. Nothing reaches the pty while composing, so those cells
still held the old text — repaint that row remainder just past the preedit so
the composition reads as insertion pushing it right, matching macOS
Terminal.app.

The tail is its own element: xterm rewrites .composition-view's textContent on
every compositionupdate, clips it, and flips it to direction: rtl. It is laid
out cell by cell off the buffer so glyph advances cannot drift off the terminal
grid, starts on the cell the preedit ends on rather than on the measured box
(whose bidi marks left a ragged gap), and re-reads the row when a committed
syllable echoes mid-composition — every Hangul syllable commits before the next
one starts, so dropping the tail there hid it from the second syllable onwards.

Masking needs an opaque backdrop, so a see-through terminalBackgroundOpacity
theme falls back to the first opaque ancestor.

Fixes stablyai#12545

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an IME composition tail overlay for terminal content hidden by the preedit box. The overlay resolves an opaque backdrop, measures cell widths through the terminal Unicode service, renders cell-aligned text, updates during composition and terminal output, and cleans up listeners and DOM state. Pane and preview terminal lifecycles now install and dispose the overlay. Tests cover rendering, positioning, redraws, colors, composition completion, and cleanup.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation directly addresses issue #12545 by keeping the character after the cursor visible during IME composition.
Out of Scope Changes check ✅ Passed The changes are limited to IME tail rendering, lifecycle cleanup, Unicode width measurement, and regression tests required by issue #12545.
Title check ✅ Passed The title clearly and concisely describes the main fix: keeping text after the cursor visible during terminal IME composition.
Description check ✅ Passed The description includes all required sections, explains the user-visible change, documents testing and review, and identifies platform and security considerations.

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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3360033d-562e-4090-981d-ad04fa306687

📥 Commits

Reviewing files that changed from the base of the PR and between 4734428 and c8451a2.

📒 Files selected for processing (7)
  • src/renderer/src/components/dashboard-popout/preview-terminal-compatibility.ts
  • src/renderer/src/lib/pane-manager/pane-dom-creation.ts
  • src/renderer/src/lib/pane-manager/pane-lifecycle.ts
  • src/renderer/src/lib/pane-manager/pane-manager-types.ts
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.test.ts
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts
  • src/shared/terminal-unicode-provider.ts

cursorY is viewport-relative but getLine indexes the whole buffer, so once
output pushed baseY past zero the tail repainted a stale scrollback line over
the cursor row. Read baseY + cursorY, from one helper both call sites share.

isOpaqueColor only recognised alpha that ended in a bare number, so
rgb(20 20 20 / 50%) and hsla(..., 0.5) passed as opaque and the mask let the
covered cells show through. Parse the alpha argument itself — legacy comma and
slash forms, percentage or number — across the rgb/hsl/hwb/lab families.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ae7763ad-ef60-43af-bf98-425c01b66043

📥 Commits

Reviewing files that changed from the base of the PR and between c8451a2 and 3ccb509.

📒 Files selected for processing (2)
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.test.ts
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts

…drop

isOpaqueColor rejected #ffff and #ffffffff, which are fully opaque, so an alpha
hex theme background fell through to the surface behind it. Read the alpha
nibble or byte instead of the string length, and drop malformed hex, which CSS
would ignore and leave the mask see-through.

A translucent theme background is now layered over the first opaque surface
behind it rather than swapped for one, so the mask matches the row it replaces
instead of dropping a black block onto a light theme.

Geometry comes off the cell grid rather than off .composition-view: xterm only
assigns that element's position from compositionupdate, so the first
composition of a terminal placed the tail in the top-left corner. The grid is
re-measured on resize, and the tail stays hidden while the viewport is scrolled
away from the cursor row, where there is nothing on screen to mask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts (1)

270-273: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Resnapshot the row on compositionupdate.

If the viewport moves after compositionstart, rowTail remains populated. handleCompositionUpdate calls paint(), and renderTailCells() then uses that stale value when readCursorLine() returns undefined. The tail becomes visible over an unrelated viewport row.

  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts#L270-L273: Call resnapshotAndPaint() after updating preedit.
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.test.ts#L218-L236: Keep composition active, move the viewport, dispatch compositionupdate, and assert that the tail remains hidden.
Proposed fix
 const handleCompositionUpdate = (event: Event): void => {
   preedit = (event as CompositionEvent).data ?? ''
-  paint()
+  resnapshotAndPaint()
 }

Based on PR objectives, hide the tail when the cursor row is outside the visible viewport.

🧹 Nitpick comments (1)
src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts (1)

63-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the implementation comments.

Keep only the non-obvious constraint at each site.

  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts#L63-L69: State the translucent-background layering rule in one concise comment.
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts#L202-L207: State why cell-grid geometry is required in one concise comment.

As per coding guidelines, comments must be concise and preferably one line.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cded5999-b67e-4ef1-bf3c-53a77ab5c169

📥 Commits

Reviewing files that changed from the base of the PR and between 3ccb509 and 1813894.

📒 Files selected for processing (2)
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.test.ts
  • src/renderer/src/lib/pane-manager/terminal-ime-composition-tail.ts

compositionupdate only repainted, so a viewport that moved after
compositionstart left rowTail populated and renderTailCells fell back to that
stale snapshot — the tail stayed on screen over an unrelated row. Re-read the
row instead, which hides it when the cursor row is no longer visible.

Also trim the two block comments added with the cell-grid geometry down to the
constraint each one exists to record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@p1318k

p1318k commented Aug 4, 2026

Copy link
Copy Markdown
Author

@coderabbitai Both findings from the outside-diff section of the last review are addressed in 13f7cfb.

270-273 — Resnapshot the row on compositionupdate (Minor, Functional Correctness). Confirmed as a real defect. paint() guards on rowTail being non-empty, so a viewport that moved after compositionstart left the snapshot populated and renderTailCells() took its !line fallback — repainting the stale row over whatever the viewport had scrolled to. handleCompositionUpdate now calls resnapshotAndPaint(), which re-reads through readCursorLine() and hides the tail when the cursor row is off screen.

Regression test: hides when the viewport leaves the cursor row mid-composition — it starts a composition, moves the viewport to the scrollback origin without ending it, dispatches compositionupdate, and asserts the tail is hidden and emptied. Reverting the one-line change makes it fail with expected 'block' to be 'none'.

63-69 / 202-207 — Shorten the implementation comments (Trivial). Agreed, both were over the one-line-if-possible guideline in AGENTS.md. Each is now two lines carrying only the constraint it exists to record.

Verified: 640 tests in src/renderer/src/lib/pane-manager pass, tsc and oxlint/oxfmt clean.

One process note for anyone following along: these two landed in the review body rather than inline because GitHub only accepts inline comments on lines present in the PR diff, and the previous commit did not touch them. Reading pulls/{n}/comments alone misses them; pulls/{n}/reviews[].body is where they surface.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@p1318k Confirmed. The resnapshot on compositionupdate prevents stale row content from being repainted after a viewport change. The regression test covers the reported failure mode.

The shortened comments also address the style finding. Thank you for the detailed verification.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@p1318k

p1318k commented Aug 6, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@p1318k

p1318k commented Aug 10, 2026

Copy link
Copy Markdown
Author

@OrcaWin Could you take a look at this PR when you get a chance? Thanks!

@nwparker

Copy link
Copy Markdown
Contributor

Closing — #12545 is fixed on main by #15014 (453237c), which took @tonite31's #13834 approach.

Your PR is careful work and it was a genuinely close call, so it's worth saying exactly what decided it rather than leaving it as a preference.

Both PRs aimed at the right thing. I went in assuming the floating overlay was itself the root cause — that mature terminals render preedit inline in the grid and we should too. That turned out to be wrong: two reference implementations both render inline and occlude the cells underneath identically. The real root cause is preedit being treated as an overwrite rather than an insertion, so rendering the covered tail is the correct fix at any layer. You and tonite31 both had that right.

What decided it was geometry ownership. #13834 renders the tail inside xterm's own composition view, which is already placed, sized, clipped and font-matched at the cursor cell — so the tail's start is correct by construction. Yours re-derives the cell grid, font, clip and resize handling in the renderer, and computes the tail start as cursorX + measureTerminalStringColumns(preedit). That's only right if the preedit box's rendered width equals columns × cellWidth — and your own PR documents that a DOM text node doesn't advance on the cell grid, which is why you use per-cell spans for your tail. The problem is you can't do that for xterm's preedit box, which you don't control. It came out at ~330 production lines against 70.

Your instinct about DOM text not advancing on the grid was also the sharpest thing either PR noticed, and it's directly why the merged version needed white-space: pre on the tail — a readiness review caught that nowrap was collapsing padding runs, so a TUI border drew two cells wide instead of twenty-one. Your reasoning generalised even though your implementation didn't land.

Thanks for it, and sorry it took this long to get a decision.

@nwparker nwparker closed this Aug 17, 2026
@p1318k
p1318k deleted the fix/ime-composition-tail-occlusion branch August 18, 2026 04:29
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