Skip to content

fix(terminal): render the IME caret and give the candidate anchor one owner - #17170

Merged
nwparker merged 16 commits into
stablyai:mainfrom
nwparker:nwparker/fix-terminal-ime-overlay
Aug 30, 2026
Merged

nwparker merged 16 commits into
stablyai:mainfrom
nwparker:nwparker/fix-terminal-ime-overlay

Conversation

@nwparker

@nwparker nwparker commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

While an IME character is being assembled, Orca now keeps that preedit, its caret, the text underneath it, and the operating-system candidate anchor in one coherent xterm composition surface — even at the final terminal cell.

What changed

  • Paint a themed solid caret inside xterm's existing composition overlay.
  • Preserve ordinary committed text covered by a mid-line opaque preedit.
  • Restore a preedit when an IME resumes with compositionupdate but no new compositionstart.
  • End-align an over-wide final-cell preedit and caret inside the terminal, and pull the OS candidate anchor in with it.
  • Make Orca's terminal-ime-candidate-anchor.ts apply the same clamp, so the two writers of textarea.style.left agree instead of reverting each other.
  • Reset transient children, flex alignment, and timers through the existing composition cleanup lifecycle.
  • Register the deterministic unit and rendered Electron oracles in config/reliability-gates.jsonc.
  • Regenerate the canonical xterm source patch, bundled patch, and lockfile together.

The Codex/Claude dim-placeholder masking that was previously in this PR is now #17377, stacked on this one. It is a different domain at a different layer, and its only coupling here is the .xterm-composition-remainder class this PR adds. That split took this PR from 18 files / +1,065 to 10 files / +701, excluding the generated bundle and lockfile.

Root cause

The opaque xterm preedit view covered the renderer cursor but did not paint its own caret. Its mid-line box also covered committed grid cells while rendering only the preedit, so text under the box disappeared.

At the final column, flex end-alignment moves an over-wide preedit left so its caret stays visible. Native IMEs read xterm's hidden textarea rect to place the candidate window, so that rect has to move with it.

Two writers, and why the obvious fix is wrong

textarea.style.left genuinely has two writers: xterm's patched CompositionHelper, and Orca's terminal-ime-candidate-anchor.ts. The anchor module listens on terminal.element, so within a single composition event it fires after xterm's textarea listener. An earlier revision of this PR clamped only in the patch, and the anchor module reverted it.

The obvious correction — move the clamp into the anchor module and drop the patch hunk — was tried, and it is wrong. The rendered e2e caught it:

Error: the IME candidate anchor overflows the terminal at the right edge
Expected: <= 1557.5
Received:    1561.28125

CoreBrowserTerminal.ts:444 is why:

this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));

xterm re-asserts the textarea position on every render, not only on composition events. Renders come from PTY output and repaints that carry no composition event at all, so the anchor module — which only listens to compositionstart/compositionupdate — structurally cannot be the last writer. Whether the anchor survived depended on whether a render happened to land after the last composition event, which is exactly the flake that showed up: two passing runs, then 1561.28125, the fully unclamped value.

So the fix is not to pick a winner. It is to remove the disagreement:

  • The patch clamps, because it is the writer on the render path and it already holds cursorLeft, maxWidth, and the preedit bounds at the moment it writes.
  • The anchor module applies the same clamp, so its composition-event write no longer reverts the correction in the window before the next render.

Every path that writes textarea.style.left now produces the same in-screen position, so no ordering can produce a wrong one. Both halves are load-bearing and both are mutation-tested below.

Behaviour unchanged from main

_getRowRemainderText keeps main's expression. An earlier revision rewrote translateToString(true, x, line.length) to translateToString(false, x, getTrimmedLength()); upstream does if (trimRight) endCol = Math.min(endCol, this.getTrimmedLength()), so the two are the same call. Verified as well as read: 1,200 differential comparisons across 15 row shapes (empty, padded, wide CJK, mixed) at every start column, all equal. The rewrite was churn on code already on main, so it is reverted rather than declared.

On the IThemeService constructor parameter

Reviewed as requested, because a constructor-signature hunk is the highest-conflict shape for an upstream xterm bump. Keeping it, for three reasons:

  1. It is the only path to resolved colors. rawOptions.theme is the unresolved input and is undefined for any key the embedder did not set; colors.cursor in particular carries xterm's own default resolution.
  2. color.ensureContrastRatio operates on xterm's IColor objects. Deriving the caret color in the renderer instead means reimplementing xterm's parse, default resolution, and contrast math — and getting it wrong whenever theme.cursor is unset.
  3. The six-argument overload next to it is not stylistic and cannot be dropped. Upstream's own src/browser/input/CompositionHelper.test.ts constructs the class directly, and .npmignore strips src/**/*.test.ts from the published tarball, so the shipped patch has no hunk that could update that call. Removing the overload fails the upstream build with TS2554 and the generator refuses to emit. This was found by running the regeneration, not by reading; the comment in the patch now records it.

The theme service is therefore optional, and every color read keeps the stock fallback that six-argument path needs. One redundant rung was dropped: the view foreground no longer falls back through rawOptions.theme?.foreground.

Painting the caret inline after the preedit span does need the patch: _renderCompositionView calls replaceChildren() on every update.

Exact-head verification

Published head: 0bb0b58ba7b.

  • pnpm tc: passed.
  • src/renderer/src/components/terminal-pane/ and src/renderer/src/lib/pane-manager/: 478 files / 4,730 tests passed, 1 skipped.
  • Rendered Electron e2e, --workers=1, three consecutive runs: 4/4, 4/4, 4/4.
  • pnpm run check:code-quality:changed: 0 findings.
  • node config/scripts/regenerate-xterm-patches.mjs --check: passed. Bundled patch is 7,388,516 bytes. (The previous body said 7,392,151, which was never the size of any patch this branch produced.)
  • pnpm install --frozen-lockfile: passed; lockfile hash 0139c879….
  • pnpm run check:reliability-gates: 99 gates valid.

No regression to other IMEs

Run, not asserted: terminal-ime-xterm-composition-cancel, terminal-ime-xterm-composition-deduplication, terminal-ime-xterm-consumed-key-commit, terminal-ime-hangul-terminating-digit, terminal-ime-composition-tracker, terminal-ios-hangul-preedit-coexistence, ime-composition-keyboard-event, terminal-complex-script, terminal-ime-xterm-transaction-events — 153 tests passed. These cover Chinese Pinyin (Sogou candidate selectors), Japanese romaji-to-kana, and Hangul/Hanja commit paths. All of them are inside the 478-file sweep above as well.

Mutation results

Every line this PR adds or changes was broken, the suite re-run, and the line restored. The xterm harness mutates the upstream source, runs npm run package, and copies lib/ into node_modules; the unmutated build of that harness is byte-identical to the shipped bundles, so the rig is faithful.

Mutation Result
Patch-side clamp removed (the "move it all to the anchor module" revision) killed — the render test
Patch clamp Math.min(0, ...) dropped killed — 1 failed
Anchor-module clamp removed killed — 4 failed
Anchor clamp Math.max(0, ...) floor dropped killed — 1 failed
Anchor clamp Number.isFinite width guard dropped killed — 6 failed
Anchor clamp min to max killed — 4 failed
Anchor clamp applied to the composition view too killed — 3 failed
dispose() cleanup removed killed — 1 failed
Resume-without-compositionstart removed killed — 1 failed
Caret not appended to the view killed — 11 failed
justifyContent: flex-end dropped killed — 3 failed
Empty-data reset in _renderCompositionView removed killed — 1 failed
Remainder display: none dropped killed — 1 failed
Caret contrast check dropped killed — 1 failed
Row remainder always empty killed — 9 failed
Render-path caret restyle dropped killed — 1 failed
classList.toggle('active', ...) to unconditional add killed — 1 failed
Overlay foreground forced to stock #FFF killed — 2 failed
Opaque-background alpha strip removed killed — 1 failed

Two surviving mutations from the earlier review — placeholder: !text && placeholder and handleBlur not clearing ownership — are both in the code that moved to #17377, and are killed there by two new tests.

The tests that would have caught this

Two were missing, and each one corresponds to a real defect found during this round:

  1. terminal-ime-xterm-midline-preedit-tail.test.ts now installs installTerminalImeCandidateAnchor alongside the patched terminal, so both writers of textarea.style.left run in one rig. Its absence is what let the original two-owner bug through: the old assertion checked 624px while never installing the other owner.
  2. keeps the candidate anchor inside the screen across a render, not just a composition event drives a repaint under an open composition with no composition event behind it. This is the arm that fails on the "move it to the anchor module" revision, and nothing in the previous suite exercised the onRender path.

tests/e2e/terminal-ime-pane-arena.ts's initial-frame poll is raised from 15s to 30s. At default worker counts on a loaded machine that poll, not any assertion, is what times out first; it was observed failing two specs under concurrent load and passing all four serialized.

What runs where

Stated plainly, because the automated evidence does not reach the operating system:

  • The renderer-unit slice is deterministic and runs on every PR.
  • The rendered Electron/CDP arm drives composition through CDP, not a real IME.
  • The PR gate has no macOS runner, and no runner anywhere on it exercises a real OS IME. The textarea rect is a deterministic lower-layer oracle for native candidate placement; no automated result here claims to observe the OS candidate window. The gate records that as a standing gap.
  • The rendered e2e results above were run locally on macOS.

Linked issues

Fixes #16949

Fixes #17104

Shared acceptance checklist: #17172. Follow-up: #17377.

AI disclosure

AI-assisted implementation, test construction, and review were used. All claims above are tied to deterministic tests, rendered inspection, or explicitly named residual gaps.

@greptile-apps

greptile-apps Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

Greptile Summary

The PR consolidates IME preedit rendering, caret geometry, and candidate-anchor positioning in the terminal composition surface.

  • Adds a themed composition caret and reproduces committed row-tail text beneath opaque mid-line preedits.
  • Handles resumed composition updates and final-cell overflow geometry.
  • Updates candidate-anchor ownership, deterministic tests, Electron rendering probes, reliability gates, generated xterm patches, and the lockfile.

Confidence Score: 4/5

The PR does not yet appear safe to merge because covered dim terminal text is still rendered at ordinary foreground intensity during active IME composition.

The composition remainder is serialized to plain text and painted with one foreground color, so the previously reported loss of SGR-2 styling remains in the current xterm patch.

Files Needing Attention: config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch and src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts

Important Files Changed

Filename Overview
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch Adds the IME composition surface and geometry behavior, but the existing loss of dim styling in rendered row remainders remains outstanding.
src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts Consolidates deferred candidate-anchor clamping with lifecycle cleanup and no additional eligible issue identified.
src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts Covers composition-tail visibility and geometry extensively, though it does not establish preservation of per-cell dim styling.
config/reliability-gates.jsonc Registers the new deterministic and rendered IME verification paths and documents native candidate-window coverage gaps.
pnpm-lock.yaml Updates the patched xterm hash consistently across dependent package snapshots.

Reviews (13): Last reviewed commit: "fix(terminal): keep both writers of the ..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The patch reworks xterm.js IME handling around transaction state and deferred reconciliation. CompositionHelper now handles resumed compositions, IME-consumed commits, lifecycle events, cancellation, and disposal. The composition overlay renders separate preedit, caret, and remainder elements with theme-aware styling and textarea positioning. Composer placeholder masking now uses semantic cursor context and session events. Unit and end-to-end tests cover resumed commits, caret geometry, repaint behavior, row occlusion, placeholder masking, and Korean preedit handling.

Merge Risk: 🟡 Moderate · up to 364d6

The PR improves IME caret rendering, text preservation, placeholder masking, and candidate-anchor alignment, but it is not yet merge-ready because unresolved edge cases could prevent the terminal from building or leave committed text or placeholder masking in an incorrect state after interrupted or resumed composition.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Most changes support the linked IME objectives, tests, or reliability coverage. However, the SortedList.delete deferred-deletion retry is unrelated to the linked IME issues and is not justified by t… Remove the unrelated SortedList.delete change from this pull request, or add a linked issue and explicit rationale showing why it is required for the IME fixes.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 25 functions across 15 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description provides detailed scope, rationale, linked issues, testing evidence, limitations, and AI disclosure. However, it does not include the required Visual Proof section with before-and-afte… Add the required Visual Proof section with before-and-after screenshots or a video. Add the missing template sections, including Review, Agent skill upstream boundary, Notes, and Checklist, or explicitly mark them as not applicable.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #16949 by adding a visible themed IME caret and maintaining correct preedit and anchor geometry. They satisfy #17104 by masking the all-dim Codex placeholder while preserving ordin…
Title check ✅ Passed The title is concise and directly describes the central IME changes: rendering the composition caret and coordinating candidate-anchor positioning.
Full details: Linked Issues check

Explanation

The changes satisfy #16949 by adding a visible themed IME caret and maintaining correct preedit and anchor geometry. They satisfy #17104 by masking the all-dim Codex placeholder while preserving ordinary committed and mixed-style text beneath the preedit.

Full details: Out of Scope Changes check

Explanation

Most changes support the linked IME objectives, tests, or reliability coverage. However, the SortedList.delete deferred-deletion retry is unrelated to the linked IME issues and is not justified by the stated objectives.

Full details: Description check

Explanation

The description provides detailed scope, rationale, linked issues, testing evidence, limitations, and AI disclosure. However, it does not include the required Visual Proof section with before-and-after UI evidence, and it omits several template sections such as Review and Checklist.

  • Fix all pre-merge checks with AI

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.

Caution

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

⚠️ Outside diff range comments (2)
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch (2)

335-335: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Restore lifecycle state for update-only composition resumes.

When a later compositionupdate arrives after the previous pendingComposition finalizer completes, _isComposing and _isAwaitingCompositionEnd are both false. This handler only toggles active. The next compositionend returns false immediately, so the resumed preedit is not accepted or finalized as a composition transaction. Restore the composition lifecycle state before accepting an update-only resume.


1159-1160: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Declare the search index inside _deleteAtKey.

_deleteAtKey uses i without a lexical declaration. TypeScript reports Cannot find name 'i'. Add let i = this._search(key) inside _deleteAtKey.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 70ad02c1-aca8-4109-9167-42e5d548ab59

📥 Commits

Reviewing files that changed from the base of the PR and between 322c636 and e9f4a53.

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

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

@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)
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch (1)

576-576: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Emit the pending session-end event before deferred reconciliation.

_endPendingCompositionSession is never invoked. Each deferred transaction therefore emits xterm-composition-session-end only when _sendPendingComposition settles it. Listeners never receive dataPendingReconciliation: true.

Call _endPendingCompositionSession(pending) after storing pending and before scheduling its finalizer. Preserve the intended cancellation behavior when the final input dispatch occurs.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d117d634-3969-4d0b-ae5e-e48acc358ba5

📥 Commits

Reviewing files that changed from the base of the PR and between e9f4a53 and d1e21ff.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
  • config/reliability-gates.jsonc
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts
  • tests/e2e/terminal-ime-midline-occlusion-probe.ts
  • tests/e2e/terminal-ime-pane-arena.ts
  • tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts
  • tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

@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)
config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch (1)

1124-1124: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Declare i in _deleteAtKey.

The extracted method uses i without a local binding. TypeScript will report an unresolved identifier. Use const i = this._search(key).


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5afe97d4-982e-484d-bae0-e2302f005e51

📥 Commits

Reviewing files that changed from the base of the PR and between d1e21ff and 269b9a8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (14)
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
  • config/reliability-gates.jsonc
  • src/main/daemon/headless-emulator.ts
  • src/main/daemon/terminal-cursor-line-context.test.ts
  • src/renderer/src/assets/terminal.css
  • src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
  • src/shared/terminal-composer-draft.test.ts
  • src/shared/terminal-composer-draft.ts
  • src/shared/terminal-cursor-line-context.ts
  • tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.

@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: 29b52aff-cdf7-4bc1-90e4-abbdae576142

📥 Commits

Reviewing files that changed from the base of the PR and between 269b9a8 and 364d6c6.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts

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

@nwparker

Copy link
Copy Markdown
Contributor Author

Fresh CI on head 27f49c2a00ef has 23 passing, 4 skipped, and 8 failures. The seven unit-shard failures and aggregate verify are the same #17330 happy-dom 20.11.8 / xterm OffscreenCanvas test-environment regression, reproduced on #17330; the IME overlay suites and rendered/native smoke lanes are green. #17336 contains the focused shared Vitest compatibility fix; no symptom-level xterm patch is being added.

@nwparker

Copy link
Copy Markdown
Contributor Author

Holding this — one blocking production bug, plus a scope split

Blocking: the candidate-anchor slice has two owners and loses the race

src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts already owns textarea.style.left/top. It writes both at :82-83 and is installed for every pane at pane-lifecycle.ts:99. Its own doc comment explains why it exists — xterm rewrites the textarea's position from its own compositionupdate handler — and it carries a deferred re-apply specifically to beat xterm's setTimeout(0).

This PR adds a second writer inside the patch. Measured with the patched terminal and the real anchor module in the same rig, cursor at the final column, cols=80, 8px cells, 2-cell preedit:

after the composition events settle:  left = 632px   <- anchor module wins, PR offset discarded
patch wants:                                 624px
after the next xterm render:                 624px   <- PR value restored

xterm's handler is on the textarea; the anchor module's is on terminal.element, so it fires last on every compositionupdate and reverts the overflow offset. It oscillates for the whole composition, and the OS reads the rect at composition-event time — which is the exact premise the anchor module was built on.

Both tests miss it. terminal-ime-xterm-midline-preedit-tail.test.ts:264 asserts 624px but never installs installTerminalImeCandidateAnchor, so it tests the patch in isolation from production wiring. The e2e polls until the overlay settles, so a render lands last and it passes for the wrong reason.

The shipped behaviour is not what the tests assert. And per AGENTS.md's reuse rule, this belongs in the module that already owns the property — as a renderer-only change, with no patch hunk at all.

On the patch itself — the objection I came in with does not apply

For the record, since it shaped my initial read: main already carries a substantial CompositionHelper patch (23 hunks in the reviewable src patch), landed via #15198 with the xterm patch sync gate added in #15223 to police it. This PR edits an already-patched file rather than introducing a patch. #15456 was rejected for putting input handling inside xterm — provably relocatable, and it moved to the renderer in #15480. Editing a file Orca already owns a large patch to is a different question, and the general objection does not carry.

What survives scrutiny is narrower but genuine: painting the caret inline after the preedit span, so it participates in the same flex row as the end-alignment, is legitimately hard from outside — _renderCompositionView calls replaceChildren() on every update. That is a real argument for keeping the caret in the patch.

IThemeService is a convenience rather than a necessity — Orca owns the theme it hands to xterm, so colors.cursor plus a contrast check is derivable outside, and the patch's own rawOptions.theme?.foreground fallback is that external path. Since a constructor-signature hunk is the highest-conflict shape for an upstream bump, it is worth asking whether that specific cost is being paid for something avoidable.

Scope — smaller than it looks, but still three changes

Reviewable non-test surface is 389 added lines, not 1250: ~700 additions are tests, 66 are gate prose, and the rest is the generated bundle and lockfile hash. Worth stating, because the raw number overstates the review burden.

Still separable:

  • A — overlay geometry: caret, final-cell containment, remainder class, theme service.
  • B — resume without compositionstart: two lines plus the dispose() reset and its test. Independent; could land alone.
  • C — dim-placeholder masking: a new renderer module, its 253-line test, the composer-draft refactor, a src/main/daemon → src/shared move, headless-emulator.ts, and CSS. Different domain, different layers, different reasoning. Its only coupling to A is keying off the new .xterm-composition-remainder class.
  • D — undeclared: _getRowRemainderText changed from translateToString(true, x, line.length) to translateToString(false, x, getTrimmedLength()). That is a behaviour change to code already on main, and it appears in neither the title nor the bullets.

Two surviving mutations worth fixing

  • placeholder: !text && placeholder → placeholder survives. Nothing covers a composer that has typed text and matches the stock-placeholder heuristic, so dropping the !text guard hides a row containing real user text with everything still green. That is user-visible data hiding with no guard test.
  • handleBlur not clearing ownership survives. xterm's blur path fires session-end so this is belt-and-braces in practice — but it is untested belt-and-braces on the exact latched-ownership shape that already shipped as a P0 in this area.

Verified clean

Patch pipeline (regenerate-xterm-patches.mjs --check passes, bundle byte-identical, sync gate green); lockfile (all 24 lines are the patch hash, no dependency moved, --frozen-lockfile succeeds); no regressions across 10 adjacent IME suites / 109 tests, plus 6 gate suites and pnpm tc.

One correction: the body cites the bundle as 7,392,151 bytes; it is 7,387,365. A stale number presented as verification evidence.

Must-fix before merge

  1. Move the anchor offset into terminal-ime-candidate-anchor.ts and drop that patch hunk.
  2. Add a test that installs installTerminalImeCandidateAnchor alongside the patched terminal — its absence is what let this through.
  3. Cover the !text guard and the blur clear.
  4. Declare change D.
  5. Either test the xterm-side disposal reset or drop the disposal clause from the gate invariant, which currently claims more than the tests enforce.
  6. Correct the byte count.

Residual, not blocking: the synthesized-compositionstart line runs for every IME, and its only coverage is a Windows Korean trace. No Pinyin or Japanese trace exercises an update arriving after finalize.

nwparker added a commit that referenced this pull request Aug 30, 2026
Two of the four ledger rows describe contracts whose implementation is still
open in #17170, and the placeholder-mask bullet with them. A reference doc that
reads as current behaviour when it is really acceptance criteria is worse than
no doc, so they are marked rather than removed - they are still the criteria
that change has to meet.

Also records the gap this checklist exists to compensate for: no PR-gate job
runs a real input method. The unit gate is happy-dom, the e2e gate drives
synthetic CDP compositions, the one workflow with ibus-hangul is nightly and
gates nothing, and there is no macOS runner at all. Without that stated, the
"native evidence" table reads like something CI enforces.
@nwparker nwparker changed the title fix(terminal): render the IME caret and mask dim placeholders fix(terminal): render the IME caret and give the candidate anchor one owner Aug 30, 2026
Comment on lines +956 to +962
+ if (rowRemainder) {
+ remainder = doc.createElement('span');
+ remainder.className = 'xterm-composition-remainder';
+ // Why: the view is nowrap, which collapses runs of spaces, so committed padding would draw
+ // its trailing glyph cells to the left of where the grid has them.
+ remainder.style.whiteSpace = 'pre';
+ remainder.textContent = rowRemainder;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Dim styling is discarded

When IME composition covers legitimate SGR-2-dim terminal content, _getRowRemainderText reduces those cells to plain text and the opaque overlay paints the remainder with the ordinary theme foreground, causing dim prompts, status text, and TUI content to become full-intensity until composition ends.

Knowledge Base Used: Restore Terminal IME Composition Handling

@nwparker
nwparker force-pushed the nwparker/fix-terminal-ime-overlay branch from 435ae80 to dd8229a Compare August 30, 2026 08:37
…ment

`textarea.style.left` has two writers: xterm's patched CompositionHelper and
Orca's terminal-ime-candidate-anchor.ts. The anchor module listens on
terminal.element, so within a composition event it writes after xterm's textarea
listener and reverted the final-column clamp the patch had just applied.

Moving the clamp into the anchor module and dropping the patch hunk does not fix
it, and the rendered e2e caught that: CoreBrowserTerminal.ts:444 drives
updateCompositionElements from onRender as well, so xterm re-asserts the textarea
position on every repaint, with no composition event for that module to hear. The
anchor survived only when no render happened to follow — measured as a flake at the
final column, 1561.28px against a 1557px screen edge, the fully unclamped value.

So both writers now compute the same clamp. The patch keeps it, because it is the
writer on the render path and already holds cursorLeft, maxWidth and the preedit
bounds. The anchor module applies the same one, so its composition-event write no
longer reverts the correction in the window before the next render. Both halves are
individually necessary and both are mutation-tested.

Also restores _getRowRemainderText's expression from main: translateToString(true,
x, line.length) and translateToString(false, x, getTrimmedLength()) are the same
call, since upstream does endCol = min(endCol, getTrimmedLength()) under trimRight.

Adds the two missing tests — one installing both anchor writers in a single rig, one
driving a render under an open composition — plus disposal cleanup and clamp-bound
coverage, and moves the Codex/Claude placeholder mask to a follow-up PR.
@nwparker
nwparker force-pushed the nwparker/fix-terminal-ime-overlay branch from dd8229a to 0bb0b58 Compare August 30, 2026 08:44
@nwparker
nwparker merged commit 7f822a7 into stablyai:main Aug 30, 2026
30 checks passed
nwparker added a commit to nwparker/orca that referenced this pull request Aug 30, 2026
…edit

Split out of stablyai#17170, which now carries only the xterm composition-overlay work.

Codex and Claude draw an all-dim, full-row ghost placeholder. The opaque preedit
overlay reproduces the committed row tail it covers, so without this the ghost is
repeated to the right of the composing syllable instead of staying masked. The
binding keys off the `.xterm-composition-remainder` class that stablyai#17170 adds and
hides it through CSS while a composition owns a structurally verified placeholder
row — bold prompt glyph plus a dimmed model footer below a blank gap for Codex, a
frame line above the prompt for Claude. Arbitrary dim output, shell lookalikes,
and any row carrying typed text keep their tail visible.

readTerminalCursorLineContext moves from src/main/daemon to src/shared because the
renderer now needs the same reader the daemon uses; the move is import-only.

Depends on stablyai#17170.
nwparker added a commit that referenced this pull request Aug 30, 2026
…edit (#17377)

Split out of #17170, which now carries only the xterm composition-overlay work.

Codex and Claude draw an all-dim, full-row ghost placeholder. The opaque preedit
overlay reproduces the committed row tail it covers, so without this the ghost is
repeated to the right of the composing syllable instead of staying masked. The
binding keys off the `.xterm-composition-remainder` class that #17170 adds and
hides it through CSS while a composition owns a structurally verified placeholder
row — bold prompt glyph plus a dimmed model footer below a blank gap for Codex, a
frame line above the prompt for Claude. Arbitrary dim output, shell lookalikes,
and any row carrying typed text keep their tail visible.

readTerminalCursorLineContext moves from src/main/daemon to src/shared because the
renderer now needs the same reader the daemon uses; the move is import-only.

Depends on #17170.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
… owner (stablyai#17170)

* fix(terminal): render IME caret without placeholder overlap

* fix(terminal): preserve dim mid-line composition tails

* fix(terminal): keep IME caret visible at row edge

* fix(terminal): harden IME overlay lifecycle and layout

* test(terminal): type final-cell layout mock

* fix(terminal): keep final-cell IME anchor on-screen

* fix(terminal): bind IME masking to composer ownership

* fix(terminal): bound IME placeholder session ownership

* fix(terminal): track latest IME placeholder session

* test(terminal): share IME session event fixture

* fix(terminal): keep both writers of the IME candidate anchor in agreement

`textarea.style.left` has two writers: xterm's patched CompositionHelper and
Orca's terminal-ime-candidate-anchor.ts. The anchor module listens on
terminal.element, so within a composition event it writes after xterm's textarea
listener and reverted the final-column clamp the patch had just applied.

Moving the clamp into the anchor module and dropping the patch hunk does not fix
it, and the rendered e2e caught that: CoreBrowserTerminal.ts:444 drives
updateCompositionElements from onRender as well, so xterm re-asserts the textarea
position on every repaint, with no composition event for that module to hear. The
anchor survived only when no render happened to follow — measured as a flake at the
final column, 1561.28px against a 1557px screen edge, the fully unclamped value.

So both writers now compute the same clamp. The patch keeps it, because it is the
writer on the render path and already holds cursorLeft, maxWidth and the preedit
bounds. The anchor module applies the same one, so its composition-event write no
longer reverts the correction in the window before the next render. Both halves are
individually necessary and both are mutation-tested.

Also restores _getRowRemainderText's expression from main: translateToString(true,
x, line.length) and translateToString(false, x, getTrimmedLength()) are the same
call, since upstream does endCol = min(endCol, getTrimmedLength()) under trimRight.

Adds the two missing tests — one installing both anchor writers in a single rig, one
driving a render under an open composition — plus disposal cleanup and clamp-bound
coverage, and moves the Codex/Claude placeholder mask to a follow-up PR.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…edit (stablyai#17377)

Split out of stablyai#17170, which now carries only the xterm composition-overlay work.

Codex and Claude draw an all-dim, full-row ghost placeholder. The opaque preedit
overlay reproduces the committed row tail it covers, so without this the ghost is
repeated to the right of the composing syllable instead of staying masked. The
binding keys off the `.xterm-composition-remainder` class that stablyai#17170 adds and
hides it through CSS while a composition owns a structurally verified placeholder
row — bold prompt glyph plus a dimmed model footer below a blank gap for Codex, a
frame line above the prompt for Claude. Arbitrary dim output, shell lookalikes,
and any row carrying typed text keep their tail visible.

readTerminalCursorLineContext moves from src/main/daemon to src/shared because the
renderer now needs the same reader the daemon uses; the move is import-only.

Depends on stablyai#17170.
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 repeats the Codex placeholder on first input [Bug]: No cursor is visible while a CJK syllable composes in a terminal pane

1 participant