Skip to content

Show a preedit the IME resumes without a compositionstart - #13284

Merged
nwparker merged 4 commits into
mainfrom
nwparker/ime-resumed-preedit-visibility
Aug 9, 2026
Merged

nwparker merged 4 commits into
mainfrom
nwparker/ime-resumed-preedit-visibility

Conversation

@nwparker

@nwparker nwparker commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Typing 2-Set Korean into a terminal pane shows committed syllables but not the in-progress jamo. The user composes each syllable blind and only sees it once it commits.

This is a pre-existing limitation, not a regression

The added test fails identically against the bundle this branch starts from — i.e. against the restored patch that #13282 reverted to, with none of the reverted composition work present. Reverting the fix on this branch and reinstalling reproduces the same two failures. This hole predates the composition-ownership change and survived its revert.

Mechanism

The .active class is what the stylesheet keys display: block off. In the vendored terminal library's composition helper:

  • compositionstart sets _isComposing = true and adds .active.
  • _finalizeComposition clears _isComposing and removes .active.
  • compositionupdate writes textContent and calls updateCompositionElements(), which early-returned on !this._isComposing.

Some IMEs — observed in a recorded Windows/WSL Korean capture — resume a composition with a bare compositionupdate and no second compositionstart. By that point compositionend has already hidden the overlay. The resumed preedit is therefore written into an element that is still display: none, and updateCompositionElements declines to lay it out. The commit path is untouched, which is exactly why the committed text lands and the composing glyph does not.

The fix

Two edits in the vendored helper:

  • In compositionupdate, re-add .active when the update carries data.
  • In updateCompositionElements, guard on the shown overlay (classList.contains('active')) rather than _isComposing.

_isComposing is deliberately not set. No commit bookkeeping changes — _pendingCompositionStart, _compositionPosition, the deferred send, the transaction id — and onData is byte-identical. No timers, no event.key.

The two guards are equivalent on every pre-existing path, because the only writers set and clear them together: compositionstart sets both, _finalizeComposition clears both. The new resumed-update path is the only place they diverge.

Discrimination was proved, not argued

Not by reasoning about the guards — by an actual revert-and-restore cycle:

  1. git checkout origin/main -- config/patches/@xterm__xterm@6.1.0-beta.287.patch pnpm-lock.yaml, pnpm install, confirmed the bundle no longer carries the guard, ran the test:
× shows a preedit the IME resumes with an update and no second compositionstart
  AssertionError: expected { preedit: '네', shown: false } to deeply equal { preedit: '네', shown: true }
× shows a preedit the IME resumes after the Enter that committed the last syllable
  AssertionError: expected { preedit: '제', shown: false } to deeply equal { preedit: '제', shown: true }

Tests  2 failed | 2 passed (4)
  1. Restored, pnpm install, confirmed the guard is back in the installed bundle, re-ran: Tests 4 passed (4).

The suite has 4 arms. Two fail without the fix; two are controls that pass either way — a composition reopened with a compositionstart must keep working, and the overlay must still hide when a composition ends and nothing resumes it. Those controls are what stop the suite from degenerating into "always visible".

The test asserts the displayed overlay (the .active class plus its text). Asserting onData here would pass on the broken build, because the commit path is not what breaks.

Note on the patch file

The src-patch toolchain and its regeneration script were removed by #13282, so config/patches/@xterm__xterm@6.1.0-beta.287.patch is once again the only artifact. It was regenerated with pnpm patch / pnpm patch-commit: the same two edits were applied to the hand-authored source and to the shipped minified lib/xterm.js and lib/xterm.mjs, verified as unique single-site replacements in each. The sourcemaps are carried through unchanged, so their offsets are stale by the length of the two insertions — the mapped source is still correct, only slightly shifted for code after the insertion point. The lockfile patch hash changes in all resolution keys, as expected.

Verification

  • npx vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/ src/renderer/src/lib/pane-manager/ — 312 files, 3907 passed, 1 skipped.
  • rm -f config/*.tsbuildinfo && pnpm run typecheck — clean on a fresh buildinfo.
  • npx oxlint on the changed file — exit 0.
  • node config/scripts/check-react-doctor-changed.mjs — exit 0, 0 issues.

Made with Orca 🐋

@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added Vitest and happy-dom regression tests for xterm IME preedit visibility. The tests replay synthetic and captured composition events, including resumptions without a second compositionstart and resumptions after Enter. They verify preedit text, the active overlay class, the standard compositionstart flow, and hiding after composition ends without resuming. A Windows/WSL Korean IME trace test verifies 37 composition updates remain visible.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, mechanism, fix, tests, and verification, but it omits the required Screenshots, AI Review Report, Security Audit, and Notes sections. Add the required sections, state “No visual change,” document cross-platform AI review coverage, summarize the security audit, and record platform-specific risks or follow-up work.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: showing preedit text when an IME resumes without compositionstart.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b85ba4a-b886-46cc-9723-4109489c3d77

📥 Commits

Reviewing files that changed from the base of the PR and between 17cfc96 and b25a913.

⛔ 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-resumed-preedit-visibility.test.ts

Comment on lines +183 to +217
it('shows a preedit the IME resumes with an update and no second compositionstart', async () => {
const rig = openTerminal()

await rig.replay(RESUMED_WITHOUT_START)

expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '네', shown: true })
})

it('shows a preedit the IME resumes after the Enter that committed the last syllable', async () => {
const rig = openTerminal()

await rig.replay(RESUMED_ACROSS_ENTER)

expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '제', shown: true })
})

it('still shows a preedit the IME reopens with a compositionstart', async () => {
const rig = openTerminal()

await rig.replay(REOPENED_WITH_START)

expect(displayedPreedit(rig.compositionView)).toEqual({ preedit: '글', shown: true })
})

it('hides the overlay once the composition ends and nothing resumes it', async () => {
const rig = openTerminal()

await rig.replay([
{ type: 'compositionstart', data: '' },
{ type: 'compositionupdate', data: '한' },
{ type: 'compositionend', data: '한' }
])

expect(displayedPreedit(rig.compositionView).shown).toBe(false)
})

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add the required ordinary-input control and mutation check.

REOPENED_WITH_START is still an IME sequence. It is not a non-IME negative control.

Add an ordinary input sequence that proves normal input is unchanged. Add a mutation check that disables the .active restoration. The resumed-preedit test must then fail while the ordinary-input control still passes.

As per coding guidelines, “Pair the IME regression test with a genuine negative control proving ordinary non-IME input is unchanged” and “Include a mutation check.”

Source: Coding guidelines

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

🧹 Nitpick comments (1)
src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts (1)

2-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the file header comment.

Keep one concise comment that states why this trace exists. The current block duplicates details that the test name and fixture already show.

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

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 354672da-006a-4e55-9520-70846c112fb4

📥 Commits

Reviewing files that changed from the base of the PR and between b25a913 and 82fc178.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/__fixtures__/windows-wsl-hangul-resumed-preedit-trace.json
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts

@nwparker
nwparker force-pushed the nwparker/ime-resumed-preedit-visibility branch from 82fc178 to c1d9425 Compare August 9, 2026 03:15

@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-windows-resumed-preedit-trace.test.ts (1)

28-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting the replay harness into a shared module.

nextEventLoop, buildEvent, and the canvas stub at Lines 62-64 duplicate the harness in terminal-ime-xterm-resumed-preedit-visibility.test.ts. Both files belong to the same cohort, and the stack lists this layer as dependent on the harness layer. A drift in one copy will silently weaken one of the two tests.

Move the shared pieces into a dedicated module, for example terminal-ime-replay-harness.ts, and import it from both tests. Do not name the module helpers, utils, or shared.

As per coding guidelines: "Do not use vague names such as helpers, utils, common, misc, or shared-stuff for files, folders, or modules; use concrete domain names instead."

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 731f9e76-3141-4e5d-a716-02a430ebc6a2

📥 Commits

Reviewing files that changed from the base of the PR and between 82fc178 and cd73ded.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/__fixtures__/windows-wsl-hangul-resumed-preedit-trace.json
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-windows-resumed-preedit-trace.test.ts

Comment on lines +67 to +70
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Drain pending composition timers before you dispose the terminal.

afterEach is synchronous, and terminal.dispose() runs inline at Line 112 with no preceding await. xterm re-arms updateCompositionElements on a setTimeout(0) while a composition is open. A callback that fires after dispose touches detached DOM and can surface as an unhandled error, which makes the run flaky.

The companion test terminal-ime-xterm-resumed-preedit-visibility.test.ts already guards this case. It awaits nextEventLoop() twice before dispose. Apply the same guard here.

🛡️ Proposed fix: move dispose into an async afterEach
+const openTerminals: Terminal[] = []
+
 describe('Windows/WSL Korean — recorded composition updates keep the preedit visible', () => {
   beforeEach(() => {
     vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
       measureText: () => ({ width: 10 })
     } as unknown as CanvasRenderingContext2D)
   })
 
-  afterEach(() => {
+  afterEach(async () => {
+    // updateCompositionElements re-arms on a timer; let the pending one run before dispose.
+    await nextEventLoop()
+    await nextEventLoop()
+    while (openTerminals.length > 0) {
+      openTerminals.pop()?.dispose()
+    }
     vi.restoreAllMocks()
     document.body.replaceChildren()
   })

Then register the terminal after terminal.open(container) and drop the inline terminal.dispose() at Line 112:

     const terminal = new Terminal()
     terminal.open(container)
+    openTerminals.push(terminal)
-    terminal.dispose()
📝 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
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
afterEach(async () => {
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
await nextEventLoop()
await nextEventLoop()
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})

nwparker and others added 4 commits August 9, 2026 01:09
Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so
the user composes each syllable blind. Long-standing hole in the vendored
terminal library, not a regression: the same test fails identically against the
bundle this branch starts from.

The `.active` class that CSS keys `display: block` off is added only in
`compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on
Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no
second `compositionstart`, by which point `compositionend` has already hidden the
overlay, so the resumed preedit is written into a hidden element and never
positioned. `updateCompositionElements` also early-returned on `!_isComposing`,
so it would not lay the overlay out either.

Re-show the overlay on an update that carries data, and key the layout guard on
the shown overlay instead. `_isComposing` is deliberately left alone, so no
commit bookkeeping changes and `onData` stays byte-identical. The two guards are
equivalent on every pre-existing path: `compositionstart` sets both,
`_finalizeComposition` clears both.

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

The synthetic test pins the shape; this replays events a real Microsoft Korean
IME emitted on Windows/WSL. The capture holds three compositionupdates that
resume a composition with no second compositionstart — the exact ordering that
wrote the preedit into a hidden overlay.

Without the fix all three report shown:false; with it all three are visible.
Fixture derived from the sealed 11919-windows-wsl-current capture, which is
read-only and unmodified.

Co-authored-by: Orca <help@stably.ai>
… artifact

The capture logs each event twice — a dispatch record and a batched next-frame
re-log. Deriving from both replayed every event twice, which made three
compositionupdates appear to land after a session had ended. Filtered to
dispatch records the capture holds zero resumes and 11 balanced sessions, so
the previous toHaveLength(3) was pinning an artifact of the derivation.

Re-scoped to what the capture does prove: the preedit stays visible across all
37 real updates. Verified by reverting the patch that this passes either way,
so it is coverage and the synthetic test remains the discriminator. Both facts
are now stated in the file.

Co-authored-by: Orca <help@stably.ai>
The previous commit accidentally reverted it: checking main's patch and lockfile
into the worktree to test whether a test discriminates also stages them, so the
commit that followed swept them up.

Co-authored-by: Orca <help@stably.ai>
@nwparker
nwparker force-pushed the nwparker/ime-resumed-preedit-visibility branch from cd73ded to 5c07f0b Compare August 9, 2026 08:17
@nwparker
nwparker merged commit a47b9d1 into main Aug 9, 2026
44 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…3284)

* fix(terminal): show a preedit the IME resumes without a compositionstart

Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so
the user composes each syllable blind. Long-standing hole in the vendored
terminal library, not a regression: the same test fails identically against the
bundle this branch starts from.

The `.active` class that CSS keys `display: block` off is added only in
`compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on
Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no
second `compositionstart`, by which point `compositionend` has already hidden the
overlay, so the resumed preedit is written into a hidden element and never
positioned. `updateCompositionElements` also early-returned on `!_isComposing`,
so it would not lay the overlay out either.

Re-show the overlay on an update that carries data, and key the layout guard on
the shown overlay instead. `_isComposing` is deliberately left alone, so no
commit bookkeeping changes and `onData` stays byte-identical. The two guards are
equivalent on every pre-existing path: `compositionstart` sets both,
`_finalizeComposition` clears both.

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

* test(terminal): prove the resumed-preedit fix against a recorded Windows capture

The synthetic test pins the shape; this replays events a real Microsoft Korean
IME emitted on Windows/WSL. The capture holds three compositionupdates that
resume a composition with no second compositionstart — the exact ordering that
wrote the preedit into a hidden overlay.

Without the fix all three report shown:false; with it all three are visible.
Fixture derived from the sealed 11919-windows-wsl-current capture, which is
read-only and unmodified.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): stop the recorded Hangul fixture pinning a derivation artifact

The capture logs each event twice — a dispatch record and a batched next-frame
re-log. Deriving from both replayed every event twice, which made three
compositionupdates appear to land after a session had ended. Filtered to
dispatch records the capture holds zero resumes and 11 balanced sessions, so
the previous toHaveLength(3) was pinning an artifact of the derivation.

Re-scoped to what the capture does prove: the preedit stays visible across all
37 real updates. Verified by reverting the patch that this passes either way,
so it is coverage and the synthetic test remains the discriminator. Both facts
are now stated in the file.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): restore the preedit visibility patch onto its own branch

The previous commit accidentally reverted it: checking main's patch and lockfile
into the worktree to test whether a test discriminates also stages them, so the
commit that followed swept them up.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
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.

1 participant