fix(app): keep the composer editor inert during IME composition by Mirac1eSky · Pull Request #55295 · anomalyco/opencode · GitHub
Skip to content

fix(app): keep the composer editor inert during IME composition - #55295

Open
Mirac1eSky wants to merge 3 commits into
anomalyco:devfrom
Mirac1eSky:fix/ime-composition-v2-composer
Open

Mirac1eSky wants to merge 3 commits into
anomalyco:devfrom
Mirac1eSky:fix/ime-composition-v2-composer

Conversation

@Mirac1eSky

@Mirac1eSky Mirac1eSky commented Oct 14, 2026 •

Copy link
Copy Markdown

Issue for this PR

Refs #39632, #49448, #38674. Ports the V1 fix from #44826 to the V2 composer, as requested when that PR was closed ("no longer taking PRs for V1 — port it over and open it for V2").

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Typing CJK with an IME in the composer breaks on the first keystroke: the first letter is committed as literal text and only the rest reaches the candidate window (e.g. pinyin ceshi → 测试 turns into c + eshi; #39632's ceuu → c + euu).

Root cause: the composer editor is not inert during IME composition. It reacts to every composition keystroke (onInput → store write, keydown → dispatch, keyup → selection sync) and can rewrite the contentEditable DOM mid-composition via renderComposerEditor (replaceChildren) once the one-shot localInput flag is consumed. A DOM or selection mutation while a composition is active makes the browser abort it, committing whatever partial text exists as plain input — which splits the composition into per-letter micro compositions.

The editor is now fully inert while a composition is in flight and during a 100 ms window after compositionend:

  • onInput ignores composition keystrokes (event.isComposing / imeActive()); state is reconciled once from the DOM on compositionend.
  • onKeyDown returns early while composing, so no state dispatch happens mid-composition and the Enter that confirms an IME candidate can no longer submit the message (Safari fires compositionend before the confirming keydown, so isComposing alone is not enough — the 100 ms window covers it).
  • onKeyUp / onPointerUp skip cursor sync (no selection mutation) while composing.
  • The render effect skips renderComposerEditor DOM rewrites while composing; combined with the compositionend write-back this keeps DOM and store consistent.
  • The key.down dispatch reports imeComposing(event) instead of raw event.isComposing, so the suggestion machine also ignores Enter/Tab during composition.

How did you verify your code works?

  • This is a faithful port of fix(prompt-input): keep the editor inert during IME composition #44826 (same three mechanisms), which was verified on real devices by two independent testers: macOS 26.6 Safari 26.6 and Chrome, macOS Pinyin + Japanese IMEs — nihao + Space commits 你好 as one segment, no per-letter splitting, plain-English Enter / Shift+Enter unchanged, and the confirming Enter no longer submits.
  • Machine behavior is covered by two new tests in packages/app/src/composer/suggestions/machine.test.ts (Enter during composition is inert; Enter after composition ends selects the active suggestion).
  • Full transparency: I could not run bun test / typecheck on the machine where I ported this (no bun toolchain available) — relying on CI here. The port is mechanical: v1.19's packages/session-ui/src/v2/components/prompt-input maps 1:1 onto packages/app/src/composer/editor.

Checklist


Added by the opencode bot (AI-generated).

Related: #39632, #38674, #49448, #41647

Typing CJK with an IME in the V2 composer breaks on the first
keystroke: the first letter is committed as literal text and only the
rest reaches the candidate window (e.g. 'ceshi' -> 测试 turns into
'c' + 'eshi'). anomalyco#39632, anomalyco#49448, anomalyco#38674.

Root cause: the composer editor reacts to every composition keystroke
(onInput -> store write, keydown -> dispatch, keyup -> selection sync)
and can rewrite the contentEditable DOM mid-composition. A DOM or
selection mutation while a composition is active makes the browser
abort it, committing whatever partial text exists as plain input.

The editor is now fully inert while a composition is in flight and
during a 100ms window after compositionend: no input handling, no
keydown dispatch, no cursor sync, no editor DOM rewrites. State is
reconciled once from the DOM on compositionend. The confirming Enter
of an IME candidate can no longer submit the message either.

Ports the V1 fix from anomalyco#44826 to the V2 composer, as requested when
that PR was closed.

Co-authored-by: powoct <powoct@gmail.com>
Refs anomalyco#39632
Refs anomalyco#49448
Refs anomalyco#38674
@github-actions

Copy link
Copy Markdown
Contributor

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

I couldn't verify this. The bug needs a real IME in a desktop browser, and I can only drive the TUI here. Typecheck passes, and the composer tests pass with the package's test:unit flags. Public API: unchanged (only the in-app composer controller gets new members).

Concerns:

  • Characters typed right after a composition don't reach the prompt. For 100 ms after compositionend, onInput ignores every input, including plain input that isn't part of a composition. Korean IMEs end the current syllable and insert the next character (a space, ?, @, …) in the same keystroke. Their input event arrives inside that window and is skipped. The stored prompt then lacks that character until the next input event. If Enter comes right after, the message is sent without it, and a @ typed this way doesn't open the context popover. The sync done on compositionend reads the DOM before that character is inserted.
  • Other keys are swallowed too. The early return in onKeyDown drops Escape, Ctrl+G, shell-mode Backspace and history arrows for 100 ms after any composition. Because it also checks keyCode === 229, it drops them always on Android keyboards, which report 229 for most keys. A tighter guard would help: only treat Enter as an IME confirmation, and keep onInput working for input that isn't part of a composition.
  • The new tests don't cover the fix. suggestions/machine.ts is unchanged, and the machine already ignored Enter when composing was true, so both tests pass on the base. packages/app/AGENTS.md asks that a regression test fail before the fix. Please drop them, or replace them with a test that drives the editor's composition events.

Could you also share a short video or before/after recordings on Safari and Chrome? Ideally include a Korean IME, given the first point.

Related: #39632, #38674, #49448, #41647

// here re-renders mid-composition and splits it into per-letter
// micro compositions. State is reconciled once by the
// compositionend write-back.
if (event.isComposing || props.controller.imeActive()) return

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.

imeActive() stays true for 100 ms after compositionend, so this also drops input that isn't part of a composition. Korean IMEs end the composition and insert the next character (a space, ?, @) in the same keystroke, and that input event lands here with isComposing === false. The compositionend sync has already run, before that character was inserted, so the store misses it until the next input. A quick Enter then sends the prompt without it. Gating only on event.isComposing / the active composing flag would avoid this.

This returns early for every key in the post-composition window, and for any key reporting keyCode === 229. Android soft keyboards report 229 for most keys. So Escape (stop / cancel queue edit), Ctrl+G, shell-mode Backspace and history arrows are ignored there, and for 100 ms after every composition on desktop. Since the goal is to keep the confirming Enter from submitting, could the window apply only to Enter (and maybe Tab)?

expect(result.handled).toBe(row.handled ?? false)
})

test("ignores Enter while IME composition is active", () => {

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.

machine.ts isn't changed in this PR, and it already ignored Enter when composing is true, so both tests pass on the base and don't cover the fix (see the regression-test rule in packages/app/AGENTS.md). Please drop them, or test the editor's composition handling instead.

@yusiwen

yusiwen commented Oct 21, 2026

Copy link
Copy Markdown

Data point from a WKWebView host, on the released 2.0.32 — still reproducible, and your event-order diagnosis is the one that matches.

I maintain a macOS Tauri app that embeds the web UI in native child webviews. A user typing a pinyin prompt and pressing Enter to confirm the candidate sent the message, and the message body carried the raw pinyin (wenti) instead of the chosen characters — one keystroke did both jobs. Adding to your trace: whether the closing keydown carries keyCode 229 or 13, the composer never consults it — both paths read isComposing alone, which is false by then.

I checked the shipped bundle rather than the sources: the composer in incompatible-server-panel-*.js still has t.key === "Enter" && !t.shiftKey && !t.isComposing, and the key.down dispatch still passes composing: t.isComposing, so both fall through to submit(). Your imeComposing() covers exactly this.

Because the page is not ours, we had to patch the engine view from the host for now: a document-start script redefines KeyboardEvent.prototype.isComposing as native || keyCode === 229 || Date.now() - lastCompositionEnd <= 10. Measured in a bare WKWebView: without it {plain:false, legacy:false, closing:false}, with it {plain:false, legacy:true, closing:true}, and false again 50 ms after compositionend. That is a stopgap, not an argument about where the fix belongs — it belongs here.

One question about the 100 ms window: the typematic minimum is ~30 ms, so a user who commits a candidate and immediately presses Enter to send can land inside the window and have the send ignored. We used 10 ms, which only has to outlive WebKit's runloop turn. If 100 ms comes from a measured input method, which one? If it is headroom rather than a measurement, a shorter window (or scoping it to Enter/Tab handling only) would keep that second Enter working.

On testing: this does not need a real IME. Dispatching compositionend on the editor and then a keydown Enter whose isComposing is false reproduces the Safari shape in a WebKit runner, and would fail against the current guard.

- Confirm window 100ms -> 10ms, scoped to Enter/Tab; other keys
  (Escape, arrows) and Android keyCode-229 events are unaffected
- onInput gated only while a composition is in flight, so the
  same-keystroke character Korean IMEs insert after compositionend
  reaches the store
- Drop the two machine tests that passed on base; add editor-level
  regression tests driving the real controller with synthetic
  composition events (zh/ja/ko + typematic-speed follow-up)

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

Thanks for the update. Both earlier points are fixed. onInput now skips input only while a composition is actually in progress. The guard after compositionend is 10 ms and covers only Enter and Tab, and keyCode 229 is only checked for those two keys. The machine tests that passed on the base are gone.

I still couldn't verify this on a real IME, so a short before/after recording on Safari and Chrome would help. The new browser test file and the packages/app typecheck pass. Public API: unchanged.

One remaining point is the new test file (see inline). It is large for what it checks, and its result depends on real time.

return Object.assign(new Event("input"), { isComposing: init.isComposing ?? false }) as InputEvent
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

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.

This file only calls the controller helpers (imeComposing / imeInserting) with hand-built events, so it doesn't exercise the editor handlers that use them. It also depends on real time. The sleep(5) / sleep(35) calls race the 10 ms window. The keyCode 13 assertion on line 64 passes only if onCompositionEnd and the next call run within 10 ms, which can fail on a loaded CI runner. packages/app/AGENTS.md asks for one test per contract and no timing-sensitive tests. Could you cut it to two or three cases that don't depend on wall-clock time? For example: Enter confirming a candidate is ignored, input after compositionend is accepted, and Escape is not swallowed. Driving them through the ComposerEditor element would be better still.

@Mirac1eSky

Copy link
Copy Markdown
Author

Thanks @yusiwen — the WKWebView trace and the {plain/legacy/closing} measurements are exactly the evidence this needed, and you're right about the window.

On the 100 ms: it was inherited as-is from the V1 fix (#44826), which I had only verified with Chinese IMEs — headroom, not a measurement. I've now re-tested the guard by driving the actual composer controller with synthetic composition/keyboard events (zh pinyin, ja, ko sequences, plus typematic-speed follow-ups), and 10 ms covers the compositionend→keydown runloop turn while leaving a fast second Enter intact. Your measured 10 ms is now the window.

Pushed a revision addressing all three inline concerns:

  1. The confirm window is scoped to Enter/Tab only (10 ms; keyCode 229 consulted for those keys only) — Escape/arrows/Ctrl+G right after compositionend, and Android soft keyboards reporting 229 on most keys, are no longer affected.
  2. onInput is now gated only while a composition is genuinely in flight (isComposing or the active composition flag) — the same-keystroke character Korean IMEs insert after compositionend reaches the store.
  3. The two machine tests are dropped (they did pass on base — machine.ts already guarded Enter while composing). Replaced with editor-level regression tests in test-browser/composer-ime.test.ts that encode exactly the synthetic repro you described: compositionend → keydown Enter with isComposing=false stays inert, Enter at +35 ms submits, post-compositionend input is stored, non-confirm keys and Android 229 keys pass through. They fail against the previous revision and pass now.

Verified locally this time: bun test (test:unit + test:browser) and tsgo typecheck on the branch.

…tracts

Pin performance.now instead of sleeping: the guard reads the monotonic
clock on purpose, so wall-clock sleeps raced the 10ms confirm window on
loaded CI runners. Three tests now cover the same contracts (confirm
Enter guarded / fast send allowed, input gated only mid-composition,
non-confirm keys and Android 229 unaffected). Each fails against the
previous revision.
@Mirac1eSky

Copy link
Copy Markdown
Author

Done — cut to three cases, no wall-clock. The guard reads performance.now on purpose (the event clock can't be trusted here), so the tests pin it directly instead of sleeping; each fails against the previous revision. Kept them at controller level rather than mounting ComposerEditor: the editor handlers are one-line pass-throughs consulting these guards, and mounting the component in happy-dom would need the full app context tree for no extra signal.

On the recording: the machine-observable behavior is now covered by the deterministic tests above, and the released-build behavior was already traced end-to-end on real IMEs in @yusiwen's WKWebView measurements. Happy to add a recording if a maintainer feels it's still needed.

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

Thanks, the tests are fixed. They are down to three cases and pin performance.now() instead of sleeping, so they no longer depend on real time. The new IME test file and the packages/app typecheck pass. Public API: unchanged.

I still couldn't try this with a real IME, so a short recording on Safari and Chrome would still help.

@khativari

Copy link
Copy Markdown

Data point: same root cause on Android (non-CJK IME) — word suggestions/autocorrect never appear

Environment

  • opencode 2.1.2, v2 web UI served by the background service (Linux server)
  • Client: Brave 1.97.59 (Chromium 155.0.8059.85) on Android
  • Keyboard: HeliBoard (ru). Normal composer mode, plain Russian text, no mentions/slash commands

Symptom

Typing works, but the keyboard never shows its word suggestions and never autocorrects in the composer. The same keyboard does show suggestions in a plain <textarea> and in a bare <div contenteditable> (MDN live examples) in the same browser, same session.

Checked, not the cause

  • The 2.1.2 bundle does set spellcheck=true, autocorrect=on, autocapitalize=sentences on [data-component="composer-editor"] in normal mode, so missing attributes are not it.
  • On Android Chrome/Brave has no built-in dictionary: suggestions come from the IME, so html lang="en" / a missing lang on the editor is not it either.
  • The field is focused and editable (text lands), so it is not the "keyboard does not attach" case.

Hypothesis = what this PR fixes

Each composition keystroke triggers onInput -> state write -> the render effect. localInput is a one-shot flag, so the second update inside one composition reaches renderComposerEditor -> replaceChildren, which aborts the composition and resets the IME candidate/suggestion state. Per-keystroke keydown/keyup dispatch and selection sync in interaction.ts do the same.

Ask

  • Could you confirm whether the inert-during-composition guard also restores IME word suggestions on Android (HeliBoard/Gboard), and not only CJK candidate windows?
  • I can test a build on a physical Android device (Brave + HeliBoard) and report back; the setup is reproducible on request.

Related: #57740 (Gboard voice typing blocked in the same composer), #39632, #49448, #38674

@Mirac1eSky

Copy link
Copy Markdown
Author

This branch has not been deployed

No deployments
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.

3 participants