Skip to content

fix(terminal): render the row tail the IME preedit overlay covers - #15014

Merged
nwparker merged 3 commits into
mainfrom
nwparker/preedit-tail-occlusion
Aug 17, 2026
Merged

nwparker merged 3 commits into
mainfrom
nwparker/preedit-tail-occlusion

Conversation

@nwparker

@nwparker nwparker commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​516 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​516
Prod 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​179 $\color{#cf222e}{\Huge{\mathbf{−}}}$​53 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​126

Fixes #12545. This is @tonite31's #13834, ported to current main and credited via Co-authored-by — not a rewrite. Supersedes #12546.

The root cause is not the overlay

I went in assuming Orca's floating, opaque preedit box was the problem and that mature terminals render preedit inline in the grid. Half right, wrong conclusion. Reading two reference implementations directly:

  • One draws marked text in grid-cell coordinates — fills chars × cellWidth with the default background, forced default fg, underline, over the already-painted row. Its GPU path goes further and substitutes the marked characters into the frame's cell array before rasterisation.
  • The other has no overlay at all: preedit codepoints are added as ordinary text cells on the terminal background, and the row rebuild skips every terminal cell in the preedit range.

Both render inline. Both still occlude the cells underneath — one of them even blanks an extra trailing cell, apparently unintentionally.

So the overlay is how Orca occludes, not why. The real root cause is that preedit is treated as an overwrite rather than an insertion; a native text field reflows the rest of the line around marked text. Migrating to inline grid rendering would have cost a renderer rewrite and still shipped this bug. Rendering the covered tail, shifted right, is the correct fix at any layer — which is what both PRs were aiming at.

Why #13834 over #12546

#12546 is careful work, but it re-derives geometry that xterm already owns.

#13834 (this) #12546
Production lines 70 ~330
Geometry reuses xterm's — the view is already placed, sized, clipped and font-matched at the cursor cell re-derives cell grid, font, clip, resize re-measure, rAF coalescing
Tail start inline flow after the preedit span — correct by construction cursorX + measureTerminalStringColumns(preedit), which holds only if the preedit box's rendered width equals columns × cellWidth. #12546 documents that a DOM text node doesn't advance on the cell grid, and fixes it for its own tail with per-cell spans — but it cannot for xterm's preedit box, which it does not control.
Reach every terminal automatically installed per construction site
Also fixes hard-coded #000/#FFF, candidate-window anchoring neither

The theming half is what makes the preedit read as inserted text instead of a black box, and it's the second thing the issue asked for.

One addition over #13834

_opaqueViewBackground(). Orca's terminalBackgroundOpacity composes theme.background into rgba(...). Carried through unchanged, the tail mask would be see-through and re-expose the very cell it stands in for. Alpha is dropped for the mask only.

Also _resetCompositionView() at the two existing clear sites plus _finalizeComposition, so no stale tail survives a composition.

Proof it discriminates — real geometry, not a class check

Restored the patch and lockfile to origin/main, reinstalled, and ran. The e2e invariant is "an opaque overlay must render every committed cell its bounding rect covers", computed from the real rect against the real cell grid:

the preedit overlay must render every committed cell it covers —
{"rowTailFromCursor":"하세요","hiddenByOverlay":"하","overlayText":"가",
 "cursorColumn":4,"coveredColumns":[4,5],"cellWidth":8,
 "overlayRect":{"left":317,"right":329.28125,"width":12.28125}}

Expected: covers "하세요" / renders "가하세요"
Received: covers "하"     / renders "가"

A 12.28px overlay on an 8px grid covers columns 4–5, which hold 하, and renders only 가. Unit side: 7 of 8 fail on main, including the #12545 repro and the theming case. The 8th is the end-of-row no-op, which passes both ways by design.

Two process notes worth keeping: the first version of the spec failed on main at the overlay-text gate rather than the geometry, so it was restructured to make geometry the discriminator; and a real flake was found and fixed — polling for 10s let the live shell repaint the row, so it now samples once behind a fix-blind readiness gate.

Verification

  • npm run typecheck clean; 369 files / 3610 tests pass in the terminal pane, including all 20 terminal-ime-* suites and the adversarial timer-bounding one
  • New e2e: 2 passed. Existing IME e2e re-run: terminal-korean-preedit-visibility 4 passed; CJK / shift-enter-commit / composing-chord-order / exact-byte 15 passed, 8 platform-skipped
  • rm -rf node_modules && pnpm install --frozen-lockfile applies the patch to both bundles; the pnpm patch round-trip was verified byte-identical before any edit
  • oxlint + oxfmt clean, no max-lines disable

The translateToString trap, kept from #13834

_getRowRemainderText() passes an explicit end column. That is load-bearing: the cacheable form arms BufferLineStringCache's self-renewing idle-clear timer, and the adversarial spec drains captured timers synchronously → unbounded loop. That was @tonite31's finding and it is preserved.

Not verified

…rlay

Closes #12545.

Composing mid-line hid the character at the cursor for the whole composition.
The preedit overlay is an opaque box anchored to the cursor cell, and nothing
reaches the pty while composing, so those cells still held their characters —
the box simply covered them.

`CompositionHelper` now draws the rest of the row after the preedit inside the
view, so the composition reads as inserted text pushing the tail right. Four
details come with it:

- The view is start-anchored while it carries a tail, so the preedit stays put
  and the pushed tail clips at the right edge; alone, `rtl` still keeps a long
  preedit's end in view.
- It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any
  alpha dropped — the view masks the cells it draws over, so a see-through
  background would re-expose the very characters the tail stands in for.
- The helper textarea syncs to the preedit's own bounds, so IME candidate
  dialogs anchor to the composing text rather than past the rendered tail.
- A TUI can repaint the row under an open composition, so
  `updateCompositionElements` — which already runs on every render — re-reads
  the remainder and re-renders on change. A string compare adds no layout read.

The tail is read with an explicit end column: the cacheable form of
`translateToString` arms the line string cache's self-renewing idle-clear timer,
and the composition path must own no timers.

Geometry is not the cause. Two mature reference terminal implementations compose
marked text into the grid rather than into a floating box, and both still blank
the cells under it — one of them literally substitutes the marked characters
into the row's character array before rasterizing. Moving off the overlay would
not have fixed this report; rendering the covered tail is what does.

The e2e arm asserts the invariant an opaque overlay owes the grid: it must
render every committed cell its bounding rect covers. That is measured from the
real rect against the real cell grid, so it fails on the unfixed build with
`covers "하" / renders "가"`.

Known limitation: the rendered tail is plain-styled while composing (theme
foreground on theme background, no per-cell colors); colors return on commit.
This is inherent to the overlay, and drawing the preedit into the cell renderer
instead would be a far larger change.

Co-authored-by: rayim <rayim@fxy.global>
@coderabbitai

coderabbitai Bot commented Aug 17, 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: cf63facb-8622-4fe4-a40e-bbb60d8007ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2e9d0f8 and 4ac494b.

⛔ 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
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Added happy-dom tests for xterm mid-line IME preedit rendering, repaint updates, overlay styling, streamed text, and composition cleanup. Added Playwright helpers that sample terminal row content, cursor state, overlay geometry, covered columns, and rendered preedit text. Added Korean end-to-end scenarios for mid-line occlusion and end-of-row compositions using CDP input and failure-safe cleanup.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description explains the fix, issue, rationale, and testing, but it omits the required Visual Proof section and most template checklist and review fields. Add the required Visual Proof or N/A explanation, complete the template sections and checklists, and document AI disclosure and review status.
Linked Issues check ❓ Inconclusive The tests target issue #12545, but the provided review summary does not show the production changes needed to verify the complete fix. Provide the production-file summaries or diff so tail rendering, theme handling, composition resets, and candidate-anchor behavior can be verified.
✅ Passed checks (2 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The summarized unit tests and end-to-end tests directly support the IME mid-line occlusion fix and show no unrelated changes.
Title check ✅ Passed The title clearly and concisely describes the main change: rendering the row tail covered by the IME preedit overlay.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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

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

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

Reduce the module comments to the test purpose and invariant.

  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts#L2-L17: Keep a short statement of the mid-line tail invariant.
  • tests/e2e/terminal-ime-midline-occlusion-probe.ts#L3-L14: Keep a short statement of the geometry sampling invariant.
  • tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts#L1-L19: Keep a short statement of the end-to-end occlusion invariant.

As per coding guidelines, “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ae43871-e13c-468b-b99f-2770ca5eaf90

📥 Commits

Reviewing files that changed from the base of the PR and between 0ac2e77 and 46a6d30.

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

Included review availability: Your plan includes up to 10 reviews per rolling hour; 6 remain after this review.

Comment on lines +228 to +241
it('leaves no tail behind for the next composition after one ends', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')

const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: '가' })
rig.terminal.textarea!.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()

expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.textContent).toBe('')

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Start a second composition after cleanup.

Lines 233-241 only verify that compositionend clears the current overlay. A retained tail can reappear in a later end-of-row composition while this test still passes. Move the cursor to the row end, start a second composition, and assert that it has no child tail.

Proposed test extension
     expect(rig.compositionView.classList.contains('active')).toBe(false)
     expect(rig.compositionView.children).toHaveLength(0)
     expect(rig.compositionView.textContent).toBe('')
+
+    await rig.write('\x1b[6C')
+    rig.compose('나')
+    expect(rig.compositionView.children).toHaveLength(0)
+    expect(stripMarks(rig.compositionView.textContent)).toBe('나')

The PR objective requires composition state reset at finalize paths.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('leaves no tail behind for the next composition after one ends', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: '가' })
rig.terminal.textarea!.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.textContent).toBe('')
it('leaves no tail behind for the next composition after one ends', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: '가' })
rig.terminal.textarea!.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.textContent).toBe('')
await rig.write('\x1b[6C')
rig.compose('나')
expect(rig.compositionView.children).toHaveLength(0)
expect(stripMarks(rig.compositionView.textContent)).toBe('나')

CI covered four columns where this machine covers two — 34.4px over an 8.43px
grid against 12.3px over an 8px grid — so pinning the covered text verbatim
pinned the font metrics rather than the behaviour. Assert instead that every
committed cell the overlay covers appears in what it draws, which is the actual
invariant and holds at any cell width.

Still fails against main: covers "하" / renders "가".
The composition view is white-space: nowrap, which collapses runs of spaces
exactly like normal — it only suppresses wrapping. So a committed tail carrying
padding drew its trailing glyph cells left of where the grid has them: measured
in Chromium with xterm's own rule, twenty spaces plus a border rendered two
cells wide instead of twenty-one.

The visible case is Orca's most common IME context — composing inside an agent
TUI input box, where the row is a prompt, padding, then a real border glyph the
trim cannot drop. A stray border appeared a cell after the preedit while the
real one stayed put.

xterm sets white-space: pre on its grid rows for this reason; the view was only
nowrap-safe while it held preedit text alone.

The existing fixtures are all space-free, and the e2e invariant is that the
overlay renders everything it covers — collapsing makes it cover less, so both
stayed green. Pinned with a padded-row fixture.
@nwparker
nwparker merged commit 453237c into main Aug 17, 2026
48 checks passed
@nwparker
nwparker deleted the nwparker/preedit-tail-occlusion branch August 17, 2026 07:18
nwparker added a commit that referenced this pull request Aug 18, 2026
…e cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in #15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs #12729
nwparker added a commit that referenced this pull request Aug 18, 2026
…e cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in #15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs #12729
nwparker added a commit that referenced this pull request Aug 18, 2026
…e cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in #15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs #12729
nwparker added a commit that referenced this pull request Aug 18, 2026
…e cursor (#15242)

* test(terminal): pin that the CJK block is the preedit overlay, not the cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in #15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs #12729

* test(terminal): make the cursor precedence assertion real and measure the overlay

Review of the first pass found one assertion that could not fail. It set
options.cursorStyle and then read decPrivateModes.cursorStyle, which are
separate fields with separate storage, so it pinned that writing one does
not clobber the other. Deleting the precedence expression from both
renderers left it green.

It now asserts the rendered cursor class: the option style renders, a
DECSCUSR overrides it, and the reset hands control back. That fails if
the precedence is removed.

The overlay's rendered width is the one measurement in the report that
argues against our explanation, and no test here could reach it, because
the unit environment performs no layout. Adds an end-of-row browser arm
beside the existing mid-line one, asserting a single composing Hangul
syllable spans about two cells. That settles whether the block the
reporter measured at one cell can be this overlay.

Also scopes two DOM queries to the test container rather than the
document, and attaches the render listener before writing so a missed
render fails instead of hanging to timeout.

Records in the file header what it does not establish: composing the
opacity into the theme is not the same as it reaching the screen, since
the webgl renderer drops the cursor colour's alpha for a block cursor.

Refs #12729
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 21, 2026
…ablyai#15014)

* fix(terminal): render the covered row tail inside the IME preedit overlay

Closes stablyai#12545.

Composing mid-line hid the character at the cursor for the whole composition.
The preedit overlay is an opaque box anchored to the cursor cell, and nothing
reaches the pty while composing, so those cells still held their characters —
the box simply covered them.

`CompositionHelper` now draws the rest of the row after the preedit inside the
view, so the composition reads as inserted text pushing the tail right. Four
details come with it:

- The view is start-anchored while it carries a tail, so the preedit stays put
  and the pushed tail clips at the right edge; alone, `rtl` still keeps a long
  preedit's end in view.
- It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any
  alpha dropped — the view masks the cells it draws over, so a see-through
  background would re-expose the very characters the tail stands in for.
- The helper textarea syncs to the preedit's own bounds, so IME candidate
  dialogs anchor to the composing text rather than past the rendered tail.
- A TUI can repaint the row under an open composition, so
  `updateCompositionElements` — which already runs on every render — re-reads
  the remainder and re-renders on change. A string compare adds no layout read.

The tail is read with an explicit end column: the cacheable form of
`translateToString` arms the line string cache's self-renewing idle-clear timer,
and the composition path must own no timers.

Geometry is not the cause. Two mature reference terminal implementations compose
marked text into the grid rather than into a floating box, and both still blank
the cells under it — one of them literally substitutes the marked characters
into the row's character array before rasterizing. Moving off the overlay would
not have fixed this report; rendering the covered tail is what does.

The e2e arm asserts the invariant an opaque overlay owes the grid: it must
render every committed cell its bounding rect covers. That is measured from the
real rect against the real cell grid, so it fails on the unfixed build with
`covers "하" / renders "가"`.

Known limitation: the rendered tail is plain-styled while composing (theme
foreground on theme background, no per-cell colors); colors return on commit.
This is inherent to the overlay, and drawing the preedit into the cell renderer
instead would be a far larger change.

Co-authored-by: rayim <rayim@fxy.global>

* test(e2e): assert the occlusion invariant, not the runner's cell width

CI covered four columns where this machine covers two — 34.4px over an 8.43px
grid against 12.3px over an 8px grid — so pinning the covered text verbatim
pinned the font metrics rather than the behaviour. Assert instead that every
committed cell the overlay covers appears in what it draws, which is the actual
invariant and holds at any cell width.

Still fails against main: covers "하" / renders "가".

* fix(terminal): keep the rendered tail's spacing on the grid

The composition view is white-space: nowrap, which collapses runs of spaces
exactly like normal — it only suppresses wrapping. So a committed tail carrying
padding drew its trailing glyph cells left of where the grid has them: measured
in Chromium with xterm's own rule, twenty spaces plus a border rendered two
cells wide instead of twenty-one.

The visible case is Orca's most common IME context — composing inside an agent
TUI input box, where the row is a prompt, padding, then a real border glyph the
trim cannot drop. A stray border appeared a cell after the preedit while the
real one stayed put.

xterm sets white-space: pre on its grid rows for this reason; the view was only
nowrap-safe while it held preedit text alone.

The existing fixtures are all space-free, and the e2e invariant is that the
overlay renders everything it covers — collapsing makes it cover less, so both
stayed green. Pinned with a padded-row fixture.

---------

Co-authored-by: rayim <rayim@fxy.global>
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 21, 2026
…e cursor (stablyai#15242)

* test(terminal): pin that the CJK block is the preedit overlay, not the cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in stablyai#15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs stablyai#12729

* test(terminal): make the cursor precedence assertion real and measure the overlay

Review of the first pass found one assertion that could not fail. It set
options.cursorStyle and then read decPrivateModes.cursorStyle, which are
separate fields with separate storage, so it pinned that writing one does
not clobber the other. Deleting the precedence expression from both
renderers left it green.

It now asserts the rendered cursor class: the option style renders, a
DECSCUSR overrides it, and the reset hands control back. That fails if
the precedence is removed.

The overlay's rendered width is the one measurement in the report that
argues against our explanation, and no test here could reach it, because
the unit environment performs no layout. Adds an end-of-row browser arm
beside the existing mid-line one, asserting a single composing Hangul
syllable spans about two cells. That settles whether the block the
reporter measured at one cell can be this overlay.

Also scopes two DOM queries to the test container rather than the
document, and attaches the render listener before writing so a missed
render fails instead of hanging to timeout.

Records in the file header what it does not establish: composing the
opacity into the theme is not the same as it reaching the screen, since
the webgl renderer drops the cursor colour's alpha for a block cursor.

Refs stablyai#12729
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
…e cursor (stablyai#15242)

* test(terminal): pin that the CJK block is the preedit overlay, not the cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in stablyai#15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs stablyai#12729

* test(terminal): make the cursor precedence assertion real and measure the overlay

Review of the first pass found one assertion that could not fail. It set
options.cursorStyle and then read decPrivateModes.cursorStyle, which are
separate fields with separate storage, so it pinned that writing one does
not clobber the other. Deleting the precedence expression from both
renderers left it green.

It now asserts the rendered cursor class: the option style renders, a
DECSCUSR overrides it, and the reset hands control back. That fails if
the precedence is removed.

The overlay's rendered width is the one measurement in the report that
argues against our explanation, and no test here could reach it, because
the unit environment performs no layout. Adds an end-of-row browser arm
beside the existing mid-line one, asserting a single composing Hangul
syllable spans about two cells. That settles whether the block the
reporter measured at one cell can be this overlay.

Also scopes two DOM queries to the test container rather than the
document, and attaches the render listener before writing so a missed
render fails instead of hanging to timeout.

Records in the file header what it does not establish: composing the
opacity into the theme is not the same as it reaching the screen, since
the webgl renderer drops the cursor colour's alpha for a block cursor.

Refs stablyai#12729
@AmethystLiang

Copy link
Copy Markdown
Contributor

thanks for reporting. looking into it

enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…ablyai#15014)

* fix(terminal): render the covered row tail inside the IME preedit overlay

Closes stablyai#12545.

Composing mid-line hid the character at the cursor for the whole composition.
The preedit overlay is an opaque box anchored to the cursor cell, and nothing
reaches the pty while composing, so those cells still held their characters —
the box simply covered them.

`CompositionHelper` now draws the rest of the row after the preedit inside the
view, so the composition reads as inserted text pushing the tail right. Four
details come with it:

- The view is start-anchored while it carries a tail, so the preedit stays put
  and the pushed tail clips at the right edge; alone, `rtl` still keeps a long
  preedit's end in view.
- It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any
  alpha dropped — the view masks the cells it draws over, so a see-through
  background would re-expose the very characters the tail stands in for.
- The helper textarea syncs to the preedit's own bounds, so IME candidate
  dialogs anchor to the composing text rather than past the rendered tail.
- A TUI can repaint the row under an open composition, so
  `updateCompositionElements` — which already runs on every render — re-reads
  the remainder and re-renders on change. A string compare adds no layout read.

The tail is read with an explicit end column: the cacheable form of
`translateToString` arms the line string cache's self-renewing idle-clear timer,
and the composition path must own no timers.

Geometry is not the cause. Two mature reference terminal implementations compose
marked text into the grid rather than into a floating box, and both still blank
the cells under it — one of them literally substitutes the marked characters
into the row's character array before rasterizing. Moving off the overlay would
not have fixed this report; rendering the covered tail is what does.

The e2e arm asserts the invariant an opaque overlay owes the grid: it must
render every committed cell its bounding rect covers. That is measured from the
real rect against the real cell grid, so it fails on the unfixed build with
`covers "하" / renders "가"`.

Known limitation: the rendered tail is plain-styled while composing (theme
foreground on theme background, no per-cell colors); colors return on commit.
This is inherent to the overlay, and drawing the preedit into the cell renderer
instead would be a far larger change.

Co-authored-by: rayim <rayim@fxy.global>

* test(e2e): assert the occlusion invariant, not the runner's cell width

CI covered four columns where this machine covers two — 34.4px over an 8.43px
grid against 12.3px over an 8px grid — so pinning the covered text verbatim
pinned the font metrics rather than the behaviour. Assert instead that every
committed cell the overlay covers appears in what it draws, which is the actual
invariant and holds at any cell width.

Still fails against main: covers "하" / renders "가".

* fix(terminal): keep the rendered tail's spacing on the grid

The composition view is white-space: nowrap, which collapses runs of spaces
exactly like normal — it only suppresses wrapping. So a committed tail carrying
padding drew its trailing glyph cells left of where the grid has them: measured
in Chromium with xterm's own rule, twenty spaces plus a border rendered two
cells wide instead of twenty-one.

The visible case is Orca's most common IME context — composing inside an agent
TUI input box, where the row is a prompt, padding, then a real border glyph the
trim cannot drop. A stray border appeared a cell after the preedit while the
real one stayed put.

xterm sets white-space: pre on its grid rows for this reason; the view was only
nowrap-safe while it held preedit text alone.

The existing fixtures are all space-free, and the e2e invariant is that the
overlay renders everything it covers — collapsing makes it cover less, so both
stayed green. Pinned with a padded-row fixture.

---------

Co-authored-by: rayim <rayim@fxy.global>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…e cursor (stablyai#15242)

* test(terminal): pin that the CJK block is the preedit overlay, not the cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in stablyai#15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs stablyai#12729

* test(terminal): make the cursor precedence assertion real and measure the overlay

Review of the first pass found one assertion that could not fail. It set
options.cursorStyle and then read decPrivateModes.cursorStyle, which are
separate fields with separate storage, so it pinned that writing one does
not clobber the other. Deleting the precedence expression from both
renderers left it green.

It now asserts the rendered cursor class: the option style renders, a
DECSCUSR overrides it, and the reset hands control back. That fails if
the precedence is removed.

The overlay's rendered width is the one measurement in the report that
argues against our explanation, and no test here could reach it, because
the unit environment performs no layout. Adds an end-of-row browser arm
beside the existing mid-line one, asserting a single composing Hangul
syllable spans about two cells. That settles whether the block the
reporter measured at one cell can be this overlay.

Also scopes two DOM queries to the test container rather than the
document, and attaches the render listener before writing so a missed
render fails instead of hanging to timeout.

Records in the file header what it does not establish: composing the
opacity into the theme is not the same as it reaching the screen, since
the webgl renderer drops the cursor colour's alpha for a block cursor.

Refs stablyai#12729
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)

2 participants