refactor(terminal): return IME composition ownership to xterm (#13128)

* fix(terminal): return IME composition ownership to xterm

* fix(mobile): derive terminal input from native replacement ranges

* test(mobile): record iOS Japanese IME traces

* fix(mobile): preserve native IME replacement ranges

* fix(xterm): flush queued application input after IME commit

* test(terminal): pin Korean intermediate commit

* test: pin Windows IME shortcut ownership

* test: replay IBus number candidate commit

* fix: preserve native macOS input-method punctuation

* refactor(terminal): remove stale mac focus override

* fix(mobile): preserve soft keyboard deletion ranges

* fix: keep IME-owned palette chords in renderer

* fix: stop carried IME shortcuts at renderer owner

* fix: preserve carried IME shortcut dispatch

* fix: narrow main-owned shortcut actions

* test(mobile): pin Japanese IME replacement traces

* test(terminal): retain paired native IME trace

* fix(chat): preserve browser IME composition ownership

* fix(chat): retain macOS IME confirm gesture

* fix(chat): expire unmatched IME confirm carry

* fix(chat): isolate IME confirmation expiry

* fix(chat): retain active IME confirmation

* refactor(terminal): remove dead composition handler

* feat(ime): add shared Enter-ownership seams for CJK composition

The confirming Enter of a CJK composition arrives as two keydowns and the
orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13
before keyup, macOS delivers keyup first. A guard reading only isComposing or
keyCode 229 misses the redispatch, so surfaces submitted on a confirm.

Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared
ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering
18 CommandInput surfaces at one site.

A chorded Enter arms the carry but is never swallowed — the reverse would eat a
user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by
ime-enter-gesture-ownership-contract.test.ts.

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

* refactor(terminal): consolidate native input listeners and parked-screen owner

Extracts the shared native-input listener installer and renames the parked-screen
detector for what it actually does, replacing per-call-site duplication. The
listener installer keeps a forgetOptionKeyLocationOnBlur flag so per-window
semantics are preserved rather than flattened.

Net deletion; no behaviour change intended.

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

* test(terminal): pin recorded IME shapes as regression tests

Nine regression tests built from hashed affected-platform captures, each with a
paired ordinary negative and a discriminating mutation verified to take the file
from all-passing to exactly one failure.

Covers the Windows MS-Korean Shift family (#12179, #11878, #12151, #11946,
#12152) and the Korean TUI line-break rows (STA-3237, STA-3222, STA-3129).

STA-3237 pins the empirical 3-Shift / 2-active-composition / 2-newline ratio the
device run established — the third Shift produces nothing because Space has
already committed. That ratio is not derivable from a static capture.

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

* fix(ime): guard Enter-commit surfaces against CJK confirm

Applies the Enter-ownership guards across the surfaces whose Enter commits
something: publishes, clones, pairs, installs, posts, or persists.

Tiered deliberately rather than uniformly. Irreversible and remote-effect sites
take the carry token, which also blocks the unmarked redispatch. Locally
reversible sites take the oracle check with a one-line comment naming the
residual, because a spurious commit there costs one undo.

Three numeric fields are left unguarded with the reason in-code: Chromium blanks
number inputs at compositionstart, so a confirm-Enter only ever reaches an
empty-draft reset. Measured with a CDP probe rather than assumed — a guard that
cannot fire is noise.

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

* test(ime): teeth-check the Enter guards on every guarded surface

One suite per guarded surface, each verified by deleting the guard and
confirming the test fails. A green guard test without that check is unverified,
not verified.

Two shapes pass vacuously in happy-dom and are avoided here: native implicit
form submission never fires, and blur() is inert on an unfocused element. Both
made "the commit did not happen" assertions pass with the guard removed, so the
suites assert the guard's contract directly instead.

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

* fix(mobile): keep iOS Korean commits whole through the live-input path

iOS Korean reports isComposing: false on every event, so it bypasses the
composition guard entirely. The strict owner rejected UIKit's transformed
post-change field and sent only the leading jamo — the reported symptom.

Prefers the authoritative same-event field text over the predicted text when the
supplied operation cannot produce it. Generic: no Korean special-case, no locale
classifier, no normalization. Adds the RN-target-keyed submit carry alongside it.

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

* test(e2e): make IME capture harnesses fail loudly instead of silently

Four instruments recorded silence as success, so a void run scored as a clean
one:

- readTerminalImeBoundaryTrace returned an empty trace when the probe never
  installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
  three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
  failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
  silently downgrading a PTY assertion

Input sources now resolve by enumeration and name the near-matches on failure.

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

* test(terminal): cover Cangjie cancellation and fix a cross-namespace assertion

Adds #11951's recorded Cangjie cancel shape to the existing cancellation suite,
which covered Pinyin and Sogou but not Cangjie. One keystroke then Backspace
arriving as deleteContentBackward with data: null, so the stale preedit is the
only thing a fallback could replay.

Verified against the historical pre-6cd944c62b3 bundle: the positive fails with
['尸'] where [] is expected, while the ordinary negative stays green.

Also fixes the Vietnamese spec, which asserted a TIS-space input-source ID
against getKeyboardInputSourceId(). Those two Orca APIs report the same source
in different namespaces — TIS nests it under VietnameseIM, the app API does not.
The resolver stays as an installation precondition; the assertion matches the
leaf.

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

* test(e2e): add a real-IME macOS arm for the Korean chord commit

The existing korean-ime-terminal-shift-enter-commit spec synthesizes composition
over CDP: Input.imeSetComposition sets the preedit directly and Input.insertText
performs the commit. Asserting the IME produced events you injected yourself is
circular, so that spec cannot certify real-IME behaviour.

This arm selects 2-Set Korean via TIS, reads it back live, and injects through
System Events key codes, so the OS owns the preedit, the commit instant, and
isComposing. PTY byte expectations are preserved verbatim.

Covers 2 of the original 4 cases by design. The other two are the Windows/Linux
redispatch-before-keyup ordering, which macOS cannot produce and which cannot be
selected -- the OS decides it. Reintroducing synthesis to "restore coverage"
would reintroduce the circularity.

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

* test(e2e): assert the macOS chord arm at the PTY boundary, not the renderer

The byte expectations were transcribed from korean-ime-terminal-shift-enter-commit
:364/:383, which assert against onData -- a renderer boundary where the terminator
is CR. This spec reads the PTY child, where the tty has already converted CR to LF.

Names both forms per row rather than swapping the constant, so the conversion reads
as evidence that the capture reached past the renderer, as #11936 and #11951 record.
Ctrl+Enter's CSI-u sequence is unaffected and is identical at both boundaries.

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

* test(e2e): measure composer-to-onData latency and stop dropping IME keystrokes

Two defects in the echo latency probe.

It hooked onWriteParsed and onRender but never onData, so it measured
key->parse->render echo rather than the composer-vs-onData delta the latency rows
need. Adds a third hook feeding its own sample set.

And `event.key.length !== 1` silently dropped IME keystrokes: Pinyin and Cangjie
keydowns arrive as key:'Process' (length 7). Replayed over the captured corpus,
the old filter accepted 580 of 4137 Chinese IME keydowns -- it was discarding 80%
of them. The new filter matches the shape the owner itself branches on.

Attribution charges each onData to the latest keydown rather than a FIFO head,
because composing jamo emit no onData at all and a queue would credit a whole
composition to its first keystroke. The consumer now asserts sample count before
any percentile, so a zero-sample run cannot render as a flawless distribution.

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

* test(terminal): pin the WSL shifted-jamo newline shape for #11919

In Korean 2-set, Shift types ordinary letters -- the double consonants and the
compound vowels. Each such keystroke reaches Chromium as key='Process',
keyCode=229, shiftKey=true.

The v1.4.163 classifier matched exactly that pattern with no code guard, so it
called those keystrokes Enter, rewrote them to a synthetic Shift+Enter, and
injected a newline into the middle of the word -- with no Enter key pressed.
That is why the reporters said "no modifier key pressed": they had not chorded
Shift+Enter, but they had pressed Shift, to type the double consonant.

Asserts the row's own recorded capture: 40 immediate keydowns, exactly 3 of them
Shift-carrying inside a single syllable, and an onData stream with one newline
per Enter press and none mid-word. Two ordinary negatives keep it from being a
blanket mute -- the same session's non-IME keydowns still reach shortcut policy,
and an ordinary Shift+Enter still resolves through the real policy.

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

* test(terminal): pin the composition commit lag that made Korean type one behind

macOS Korean 2-Set commits syllable N only when the first jamo of N+1 arrives, so
compositionend and compositionstart land in the same task. A composition-start
handler cancelled the pending finalizer that was the only path to triggerDataEvent
and ended the session without emitting bytes, so every committed syllable reached
onData exactly one syllable late and the backlog cleared only at a Space or Enter.

Types continuously with no Enter and no Space -- either would flush the backlog and
hide it -- and samples onData at every syllable boundary. Paired with a
length-matched ASCII arm that stays green throughout, so the positive is a fact
about composition rather than about timing in general.

Bisected to a single call site across five builds: pristine, 1.4.155 and 1.4.162
pass, 1.4.163 fails, removing the one call repairs it, restoring it fails
identically. That window is exactly the reporter's "started immediately after
updating".

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

* test(mobile): cover the send-queue abort that silently drops queued keystrokes

One failed send in use-terminal-live-input-commit aborts every keystroke
queued behind it, with the error swallowed by .catch(() => false). The
existing test resolves(true) on every send, so the failure branch was
uncovered.

Four arms: the abort itself, an ordinary negative on the healthy path, a
throwing sender, and a liveness control proving the queue recovers once
the chain settles. Deleting the abort takes 4 passed to 3 failed, with the
ordinary negative correctly surviving.

Scope is stated in the docblock: this is a transport send-queue abort,
reachable only via a real disconnect or RPC error. REQUEST_TIMEOUT_MS is
30s, so latency alone cannot reach the branch — consistent with #7094's
symptom class, not proven to be its cause.

* test(terminal): pin that daemon snapshot/restore cannot disturb a composition

Two independent reporters attributed broken Korean composition to the
always-on PTY daemon repainting terminal state over the preedit. The
attribution is wrong on ancestry — the daemon shipped three months before
the version both call good — but the boundary was never actually tested.

Runs the real applyMainBufferSnapshot choreography against a live
composition, including the full 2J/3J/H wipe plus the resize and
alt-screen branches. textarea.value, selectionStart/End,
compositionView.textContent and .active all survive byte-identical, and
interleaving a restore between every jamo of 문제 still commits 문제 at
onData. Also pins that the uncommitted preedit is absent from the captured
snapshot: it lives in the textarea, never the buffer, so a restore has
nothing stale to echo back.

Injecting one textarea.value = '' into the restore fails exactly the three
restore-boundary tests.

* test(terminal): pin that Cmd tears down a composition where Ctrl and Shift do not

xterm's composition keydown exempts only keyCode 16/17/18 (Shift/Ctrl/Alt)
plus 20/229. macOS Meta — 91/93/224 — is absent, so a Cmd press mid-composition
takes _finalizeComposition(false): the overlay goes dark and never recovers,
because compositionstart is not re-fired. The user composes the rest of the
word blind. Linux and Windows users press Ctrl and are exempt.

xterm already has a Meta-aware modifier predicate in wasModifierKeyOnlyEvent,
so this is an internal inconsistency rather than a deliberate choice.

Owns no reported row and is version-neutral: 5/5 on both 1.4.162 and 1.4.163.
The branch is unexercised in all 328 recorded traces, so this is a hazard pin,
not a regression guard. Only the teardown is asserted; the likely duplicated
commit needs a compositionend the IME kept alive across the Cmd, which no
capture contains.

Deleting the exemption fails exactly the three paired negatives; adding Meta
to it fails exactly the two Cmd arms.

* test(native-chat): characterize preedit loss when a question card replaces the composer

An AskUserQuestion card fully replaces the composer by design, but the
in-flight composition goes with it: the composer unmounts before
compositionend reaches it, so the preedit is never committed to the draft.
The committed text survives only because the draft is cached and restored
via defaultValue. Node identity changes, value 'abc' is preserved, the 가
is gone.

Drives the real NativeChatView -> SessionGate -> InteractiveCard ->
questionActive swap -> Composer -> ComposerField, flipped by writing the
same store field an AskUserQuestion hook event writes. Flipping
questionActive to false fails exactly this test and nothing else across
639 native-chat tests, so the path was entirely unguarded.

CHARACTERIZATION TEST: it asserts the loss. Fixing the defect — committing
the preedit before the swap, or keeping the composer mounted — will make
this file fail. Update the expectations to the new contract rather than
working around them.

Owns no reported row. #12118/STA-3219 flicker is keyed to token counters,
which provably do not remount, and a question card arrives once per
question.

* test(terminal): pin the duplicated commit when Meta interrupts a composition

_finalizeComposition(false) sends textarea.value.substring(start, end) but
cannot clear the IME-owned textarea, so a later compositionend re-sends the
same range. Meta reaches that path because CompositionHelper exempts only
Shift/Ctrl/Alt; xterm's own wasModifierKeyOnlyEvent covers Meta four ways,
so the omission is an internal inconsistency rather than a choice.

Companion to the modifier-exemption guard, which deliberately pins only the
overlay teardown. This pins the data consequence.

HAZARD PIN: owns no reported row. The trigger is unverified on hardware —
no capture in the corpus contains a Meta-during-composition gesture, and
whether macOS keeps the composition alive across it is unmeasured. The
duplication follows from the code given that sequence; whether users reach
the sequence is the open half.

An earlier premise that Space (keyCode 32) reaches this path was refuted by
a corpus scan: 0 of 731 evidence files carry a keyCode-32 Space while
composing, against 171 at 229, and 229 returns early.

* test(terminal): characterize the syllable lost when the textarea blurs mid-composition

CoreBrowserTerminal._handleTextAreaBlur clears the helper textarea
unconditionally — "Text can safely be removed on blur" — while
CompositionHelper._finalizeComposition reads the committed text back out of
that same value from a deferred timeout. By the time it runs the value is
empty, the substring is '', and triggerDataEvent never sees the syllable.
xterm checks composition state in _syncTextArea and omits the same check
here.

Six cases. Blurring mid-composition loses the syllable in every ordering,
including compositionend-before-blur, which is Chromium's real order — so
it is not an ordering artifact. A bare textarea.blur() with no Orca code
loses it too, which places the owner upstream: Orca's unguarded release on
outside pointerdown is one trigger, not the cause. Committing 한 then
blurring mid-가 yields ['한'] where ['한','가'] is correct: one syllable
gone, surrounding text intact.

Teeth checked by inverting — adding an Orca-side composition guard flips
exactly the three cases that route through the release path and leaves the
bare-blur and no-blur cases green, which is the scope split: a fix in
regular-terminal-focus-ownership alone would not close this.

HAZARD PIN, but unlike the others this one has a real production injector —
clicking outside the terminal mid-composition. Owns no reported row. The
shape matches #9738's report; the injector does not, and a shape match with
a mismatched injector is not an owner.

* test(terminal): say which arm the STA-3237 fixture came from

The recorded keydowns are wave 4's A-shift-unmarked-only — the arm that
emits no PTY bytes. Nothing in the file said so, so two readers concluded
the row's events fail the owner's predicate and that STA-3237 and STA-3222
were different defects. They share an owner; the arm that fires is
Process/229+Shift, absent from this bubble-phase trace because the owner
claims it in the capture phase.

Also corrects "code-blind": the v1.4.163 policy emits \x1b\r only for a
shift-only key:'Enter', and a jamo keydown reaches that branch solely via
the isTerminalImeProcessEnter rewrite. The mock is deliberately wider so
the ownership guard stays under test if that rewrite moves.

Comments only — no assertion, fixture value, or mock behaviour changed.

* test(e2e): track the input-source selector the macOS specs shell out to

Five tracked macOS IME specs ran `swift .tmp/select-input-source.swift`, a
file that is gitignored and existed only on one machine. Anyone else
checking out the repo — or the same machine after .tmp is cleaned — could
not run them, and they are the capture drivers for the macOS rows that are
blocked waiting for exactly those runs.

Moves it to tests/e2e/ beside its callers. The chord spec now resolves it
from __dirname rather than reaching two levels up into .tmp.

* test(terminal): pin the CJK repaint decision against the reporter's own output

#12164 comment 1 and #5921 report agent output with double-width glyphs
rendering duplicated character-by-character while ASCII in the same line
stays clean. No IME, no composition, no keystroke — the user never types
the CJK.

Segmenting all three verbatim samples into maximal same-risk-class runs
gives 33 runs and zero violations of "this run is corrupted iff the
production detector flags it": 17 wide runs all corrupted, 16 narrow runs
all byte-identical. The paired negative is co-located in the same line
rather than in a separate run — the reporter supplied it without knowing.

Doubling is asserted as present, not uniform: 자바스크립트 and 시스템 each
leave a jamo undoubled, which is a repaint-region boundary artifact rather
than a per-character transform.

The discriminating arm is in the test rather than a source mutation:
be3f30e2f8 (#6890) elects a repaint for all 17 corrupted runs when the
agent types nothing, and reverting its disjunct elects none. Both
predicates agree once the user has recently typed, which is the pre-#6890
condition.

Samples inlined with per-sample SHA-256 because .tmp is gitignored and
cannot back a landed test.

* test(terminal): pin macOS period substitution landing after the composition

#11504's reporter published a DOM trace showing insertText ". " arriving
149ms after compositionend, when two spaces are typed with a CJK input
source and NSAutomaticPeriodSubstitutionEnabled is on. This replays that
trace against a real Terminal and asserts what reaches onData — bytes to
the PTY, not anything visual.

The owner is stock upstream CoreBrowserTerminal._inputEvent, not an Orca
module, confirmed at the resolved install and in the shipped bundle Vite
loads rather than in the TypeScript source.

Three mutations against that install, predictions written before the runs,
each failing exactly the arms predicted: dropping the composed/keyDownSeen
guard fails two, dropping Orca's intercept fails the one arm where the
payload arrives before the send window drains, and flipping || to && —
the candidate-fix shape — fails the arm that pins the defect itself.

CHARACTERIZATION: arm 1 asserts the broken behaviour and will fail the
moment #11504 is fixed. Update it to the new contract rather than working
around it.

composed is absent from every recorded bundle, so composed: true is the
spec-required value rather than a captured one; the test asserts it before
dispatching so a harness that dropped the field fails loudly.

* test(terminal): replay the recorded Windows Shift sessions through the IME guard

STA-3179 reports a Shift release sending Enter; #12171 reports delayed
Hangul plus doubled newlines. Both replay their own recorded Windows
MS-Korean keydowns through resolveTerminalKeyboardShortcutAction with the
shortcut policy mocked, so the assertions are about which events reach the
policy and what reaches terminal input.

STA-3179's held-Shift gesture yields exactly one newline, from the unmarked
Enter alone; its release arms nothing for the next composition, asserted
after a precondition check that the release really is keyups with shiftKey
already dropped; and an ordinary Shift press-and-release still routes every
keydown, which is the paired non-IME negative.

Teeth, verified by mutation: bypassing the isImeOwnedKeyboardEvent guard in
keyboard-handlers takes STA-3179 from 3 passed to 2 failed / 1 passed — the
survivor being the ordinary-session negative, which is correct, since a
non-IME session should not depend on that guard — and #12171 from 2 passed
to 2 failed. Source restored byte-identical.

Recorded shapes are inlined and the bundles cited in comments; nothing is
imported from .tmp, which is gitignored.

* test(native-chat): correct 61977d4517 — the preedit survives the question card

61977d4517 claimed a composed syllable vanishes silently when an
AskUserQuestion card replaces the composer, and characterized that loss.
The claim was false. Its premise was an artifact of the harness: the test
simulated a preedit with a silent textarea.value assignment and no input
event, which no IME does.

Real composition fires input with insertCompositionText on every keystroke
— the shape this repo already records in its own observed-event capture —
and React's change handler returns on input/change with no composition
gate, so onChange runs for each frame. The draft cache is written
synchronously inside the updater, so the preedit is already committed
before the card can arrive. Driven that way, it survives.

Renamed to match the contract that actually holds, and extended: Hangul
jamo-per-frame, Japanese kana accumulation followed by per-segment
conversion asserting the candidate the user was looking at survives, and a
pin on the mechanism itself — the draft cache holds the preedit while the
card is up.

Teeth: there is no fix to revert, so the mutation is the plausible wrong
one — gating onChange on isComposing(). That takes 4 passed to 3 failed,
with the English negative correctly surviving, since it has no composition
to gate.

Two consequences remain, recorded rather than fixed: the OS aborts the
composition when the field disappears, so a lone jamo returns as a
compatibility jamo the user cannot compose onto, and the remounted
composer is unfocused because the card owned focus.

* docs(native-chat): name the corrected commit and the degraded-jamo consequence

Records in the file itself that 61977d4517 is pushed and wrong, quoting
the two claims that are false, so a reader who finds it in git log reaches
the correction from the file that replaced it.

Also states the residual as a consequence rather than a curiosity: a lone
leading jamo returns as a standalone compatibility jamo (U+3131), which is
not a composable state — the user cannot resume the syllable, only delete
and retype. Preserved, but degraded into something unusable. That is the
note to find if a reporter ever describes exactly that.

The invariant these tests pin is not "the composer commits on unmount" but
"composition input events must reach React" — which is what a future IME
change would break, and is not visible from the swap site at all.

* test(terminal): replay the recorded macOS Telex commit boundaries

#6905 reports Vietnamese composed characters breaking in the terminal.
Replays the retained macOS built-in Simple Telex capture — recorded
selection and value set before each dispatch, since that is what the
commit range reads — and asserts what reaches onData: the first commit
alone, then through the real Enter, then the ASCII tail of the same run.
A code-point count would catch NFD normalisation.

ENGINE CAVEAT, stated first in the docblock: this is macOS built-in Simple
Telex, Telex only. The reporter's three named engines cannot run on the
platform they declared, and which macOS Vietnamese engine they used is
unconfirmed. This file certifies no engine, and does not imply VNI.

The owner is upstream's — CompositionHelper._finalizeComposition's
waitForPropagation branch — so the arms are copies under .tmp aliased by a
scratch config, with node_modules verified unchanged by shasum after every
run. Collapsing the range end onto its start fails all three; collapsing
the start to zero re-emits the first word into the second commit, which is
the reporter's "duplicated" direction. Different failure sets, so the
mutants are distinguishable rather than merely detectable, and the ASCII
assertion passes under both.

Falsifiability here is by mutation, not by a defective build: #6905 does
not reproduce at HEAD, so this has never been watched going red on a real
reproduction.

* docs(terminal): lead the #6905 test with its engine caveat

Comment-only. Moves the caveat above the source line so a reader meets what
the file does NOT establish before what it does — the capture is macOS
built-in Simple Telex, the reporter's named engines cannot run on the
platform they declared, and which engine they used is what gates this row.

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

* docs(terminal): record that the swallow eats a keystroke after Japanese conversion

This pin framed the swallowed insertText around Cmd interrupting a
composition. A differential through Japanese multi-segment conversion shows
it is broader: type a segment, convert, then press `a`, and the `a` is
lost. No modifier, no exotic gesture. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

Also records why it cannot simply be fixed. The suppression de-duplicates
IMEs that deliver their commit a task after compositionend, which a sibling
test pins; this swallow is that dedup's false positive, and the two events
differ only in payload, so no flag-timing change separates them. Both a
smaller redesign and a content-aware variant were built and measured — the
first duplicates on IBus, the second costs a reported row's test and is
blocked while the patch cannot be regenerated.

The Japanese arrays behind this are authored, not observed: no Japanese DOM
composition trace exists in the corpus.

* docs(terminal): a Japanese capture does exist — correcting dbeecb11be

That commit said no Japanese DOM composition trace exists in the corpus.
False. One does, filed under the Linux bundles rather than the bundle named
for Japanese: 30 DOM events, two にほんご->日本語 conversions, with full
selection state per event. It is retained byte-identically in three further
bundles — one capture copied four times, not four observations, checked by
hash rather than by counting files.

The claim came from checking the bundle named for Japanese, finding nothing,
and generalising to the corpus without querying the rest of it.

Replaying it emits 日本語日本語 under both sequencing extremes on all four
arms, matching its own recorded onData. So "repeated conversion is
undisturbed" is now captured rather than authored. It carries no
post-compositionend insertText, so it cannot speak to the swallow: the
a-after-conversion figure stays authored and unobserved.

Also rewords the paragraph opener. It claimed to broaden a Cmd framing, but
hazard 2 was never Cmd-framed — the lines above already say Cmd does not
reach it. The real gap was that hazard 2 named no trigger at all, which
reads as exotic when it is ordinary.

* build(xterm): land the patch regeneration harness

The five dependency patches under config/patches/ shipped with no tracked
way to regenerate any of them. The xterm one is the hard case: it is derived
from an upstream build, so no fix could be made without rebuilding, and the
tooling to rebuild lived only in one machine's scratch directory. That
blocked a measured fix for a live keystroke-loss bug, and the EditContext
reduction an OSS survey identified as the only real one available.

Adds the regenerator, the upstream pin, the hand-written source patch the
bundle hunks derive from, tests, docs, and a PR job that verifies the
shipped patches still match the pinned build. The job caches the shallow
clone keyed on the manifest, so a cold run is minutes and a warm one under
one. Round-trip verified: regenerating from a clean checkout reproduces the
shipped patch byte-for-byte.

Marks the emitted patch -diff -text. pnpm hashes it byte-for-byte, so a
CRLF checkout would break install on Windows, and its minified bundle lines
make a diff nobody can read — review the source patch instead.

Also rejects unknown flags. --check was the fallback for any unrecognised
argument, so a typo, or --help, silently triggered a full upstream build
instead of what the caller asked for.

* fix(xterm): stop swallowing a keystroke typed after an IME commit

Type a Japanese segment, convert it, then press a key one macrotask later
and that key was lost. No modifier, nothing exotic — every user who keeps
typing straight after converting. Korean surfaced it first only because
2-Set composes on nearly every keystroke.

handleCompositionInput discarded the payload unconditionally in the window
after the deferred send: _isSendingComposition stays true for one macrotask
after the timer cleared _pendingCompositionStart, and the branch substituted
'' for whatever arrived. The suppression is not itself wrong — it
de-duplicates IMEs that deliver their commit an event-loop turn late, which
terminal-stock-composition.test.ts pins. It just could not tell a duplicate
from new input, because the two events are identical apart from payload.

Now it compares against _sentComposition, the text the deferred send
actually emitted, and discards only a match. A flag-timing redesign was
measured first and rejected: it fixed this and duplicated on IBus, because
no timing change can separate events that differ only in content.

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and the lockfile hash are derived, not hand-written.

The commit-overlap pin's swallow arm now asserts the repaired contract —
the value its own comment already named as correct and as what stock
beta.287 emits. #11504's arm at :184 flips too; it never covered that
report, as its own prior note recorded, and the reporter's +149ms arm is
untouched and still asserting the defect. Provenance hashes in three test
docblocks are updated, since regenerating changes the patch hash and with it
the resolved install directory.

* docs(terminal): re-measure the #6905 mutation citations against the new bundle

Regenerating the patch moved the resolved install, so this docblock's
patch_hash, line count, two line numbers and three mutation outcomes all
described a bundle that no longer exists. The deferred branch is one the
fix writes into, so the outcomes could not be re-pointed on reasoning.

Line numbers read off both files by diffing anchors rather than derived by
arithmetic: 201 to 205, 159 to 163. Outcomes re-run through the retained
rig, which re-resolves through the module loader and re-derives each arm
from a unique minified anchor: pristine 3 passed, m1 3 failed, m2 2 failed,
m3 3 passed — identical to the old bundle. Guard controls in both
directions exit 1, so the counts are falsifiable.

Comment-only; the assertions and expectations are unchanged.

* fix(xterm): size the preedit overlay to the cells its text will occupy

updateCompositionElements computed the overlay's left edge from the grid
but never its width, so the preedit rendered at the font's natural advance
while the committed text takes two cells per wide glyph. Measured in
Chromium 150: 가나다라 drew 48.45px as a preedit and 69.20px once committed
— the same characters, same font, 30% narrower, and drifting further with
each syllable. Every macOS mono font carrying Hangul measured 0.49–0.72 of
two cells; never 1.0.

Deriving the width from wcwidth and the cell measure moves Korean, Japanese
and Chinese to 1.000 and leaves ASCII at 1.000, which it already was:

  한        12.125 -> 17.297   (17.30 expected)
  가나다라   48.453 -> 69.188   (69.20)
  안녕하세요 60.563 -> 86.500   (86.50)
  日本語     42.000 -> 51.906   (51.90)
  abcdefgh  69.234 -> 69.203   (69.20, unchanged)

Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and lockfile hash are derived rather than hand-written.

The unit test asserts the arithmetic, which is what CI can run. The pixel
consequence was measured on macOS with SF Mono in an Electron harness, not
on the Windows font stack STA-3232 reports from — so this demonstrates the
mechanism and does not stand as that row's platform evidence.

* test(e2e): pin the macOS Korean preedit as visible only while composing

#11914 reports the composing text invisible until Space. Its c3 was recorded
as unobtainable, and the reason on file was wrong: the boundary IS
assertable, but not in happy-dom, which reports display:block in BOTH the
active and inactive states and zeros for every rect. A test there passes
with the defect present.

Captured on real hardware instead: hidden and 0x0 before, .active with
display:block, a 15.84x16 rect and checkVisibility() true while composing
그, hidden again after. 39 DOM events, 2 composition starts, onData
["한","그","\r"].

Two mechanism findings are carried in the setup because both are invisible
in the result and fatal if removed. The input source must be selected AFTER
the app takes focus — focusing resets it to ABC. And the IME must be warmed
until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m)
with no composition at all, which is indistinguishable from an IME that is
not installed. Two runs were voided on exactly that signature before the
warm-up was found.

The has229 and compositionStarts assertions exist to make such a run fail
loudly rather than pass as a clean negative.

Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The
final spec form has not itself been executed — the machine became
unavailable — so it carries the probe's measured values as literals rather
than a run of its own.

* docs(e2e): correct 19a8d133db — the Korean preedit spec has been executed

That commit said the landed form had never run and carried the probe's
values as literals. It has now run on real hardware: 1 passed, 9.1s, rc=0,
with the capture and log sealed under a verified hash manifest.

The teeth check was also run rather than reasoned about, and it changes
which assertion matters. Forcing the active overlay to max-width:0 with
overflow:hidden — invisible on screen — leaves the active class, the
textContent, display:block AND checkVisibility() all passing. Only
during.rect.width fails. checkVisibility() is not sufficient against this
defect; the bounding rect is the single load-bearing assertion, which the
docblock already said and this run confirms.

An earlier teeth attempt injected the CSS mid-run and tripped the
hasActiveClass poll instead, failing at the wrong assertion. It is
inconclusive and excluded from the seal rather than counted.

* test(terminal): add #12171's ordinary-English arm from a real Windows capture

c4 was recorded as unmet and the ledger sourced its control to
evidence/windows-current/, which holds 12 captures and not one English one.
The arm here comes from windows-9803-final instead — same probe, same host
geometry, same injector, en-US with no IME, replayed keydown for keydown.

Two limits are stated in the file rather than left for a reader to find. It
is a different bundle and a different run about 3.6 hours later, so it is
not a same-run arm. And it is #9803's range-active MUTANT arm: ordinary
English stays byte-exact even with that saved-range mutation live, which is
why it reads as a negative rather than as a baseline.

Bundle cited by directory with its file SHA-256; MANIFEST.sha256 verifies
21/21, rc=0. Nothing imported from .tmp.

* docs(terminal): correct #12164's grounds — the cited comments say no such thing

The rejection of #12164 from this file's family was recorded as resting on its
comment 1 (output doubling) and comment 2 (filed against 1.4.163). Checked
against the API: the issue has exactly two comments, neither of which says
that, and the string 1.4.163 appears nowhere in the thread.

The conclusion survives on better grounds. The issue BODY's repro is "Run any
CLI agent (Codex, AGY, Claude, etc.) that outputs Korean text into the Orca
terminal" — untyped output, no keystrokes, no composition — so excluding
CompositionHelper is right, and the input-path hunt was looking in the wrong
place. The body is also LLM-authored (it still contains a literal
"## 5. GitHub Submission Draft (Ready to Post)") and its Root Cause section
blames a CJK IME preedit buffer its own repro never engages, so it should not
be read as observation.

Comment-only; suite unchanged at 5/5.

* test(native-chat): make composition frames carry isComposing, not just inputType

This suite's comment claimed "Gating onChange on `isComposing` breaks here."
It did not. composeFrame() fired `input` with `inputType` but never set
`isComposing`, so a gate on `isComposing` passed all four tests untouched —
the suite asserted a discriminator it did not exercise.

Composition frames now carry both, so neither gate is exempt. Verified by
pointing the mutant at it: with an `isComposing` gate on the composer's
onChange, this suite now fails 3 of 4 (it passed 4 of 4 before), and the
ordinary-English arm correctly survives, since a composition gate should not
touch it. Production code is unchanged and stays gate-free; the mutation was
applied, measured, and reverted.

Found while excluding NativeChatView's question-card remount as the owner of
#12118 / STA-3219: the remount is real, but the preedit survives it precisely
because this write path has no composition gate.

* fix(mobile): ship the patched xterm build, matching desktop

mobile pinned @xterm/xterm 6.1.0-beta.285 while the patch is keyed to
6.1.0-beta.287, so mobile shipped stock xterm and neither IME defect fix
reached it: the swallowed keystroke after an IME commit (9506039de7) and
the preedit sized to the font rather than the grid (e04e0c88da).

Bumps the three xterm packages to the desktop versions and adds the patch
to mobile's own pnpm.patchedDependencies. No copy of the patch: pnpm
accepts the parent-relative path and records it in the lockfile against
hash 8d63166272e9040a…, byte-identical to what desktop resolves, so the
two stay in step by construction rather than by a drift check.

The workspace separation is untouched — root pnpm-workspace.yaml still
declares `packages: []` and mobile keeps its own lockfile, which is what
keeps the root's patches from failing as ERR_PNPM_UNUSED_PATCH.

Verified in the generated webview bundle rather than at the install:
alignPreeditToGrid 0->2, sentComposition 0->3, pendingInput 0->11, and the
stock-only _handleAnyTextareaChanges 2->0 and dataAlreadySent 4->0. pnpm
applies patches during linking before postinstall regenerates the bundle,
confirmed by a revert/reinstall/re-apply cycle in both directions.

Mobile suite 2971 passed, 3 skipped — identical before and after. Bundle
+1,514 B (+0.24%). Lockfile churn is xterm-only; --frozen-lockfile passes.

mobile/src/ime/ime-submit-carry.ts is NOT made redundant and is untouched:
it handles iOS firing onSubmitEditing on a React Native native TextInput
after unmarking a composition, which is outside the WebView entirely.

Known divergence left alone: desktop also patches @xterm/addon-webgl and
mobile now runs that version unpatched. That patch is glyph/texture-atlas
rendering with nothing IME-related, so it affects neither fix.

* test(terminal): pin the preedit overlay against already-committed cells

STA-3132 (arm A), STA-3170 and STA-3232 report a Korean preedit painted on
top of text already on screen. Builds v1.4.163-v1.4.166 cancel the pending
finalizer in compositionstart, so a committed syllable reaches onData one
syllable late and buffer.x is stale — the overlay lands on the cell the
flushed syllable is about to occupy.

Replays a recorded hardware trace rather than an authored one: the ordered
DOM event stream captured on Windows + MS Korean (wave5-r2 evidence, 64
events), echoing onData back as PTY output.

The load-bearing assertion is deliberately not the obvious one. Comparing
overlay style.left against cursorX is tautological — left is computed from
buffer.x. This counts committed syllables from the compositionend events
the IME fired, so the two sides are independently derived.

Discriminated by a historical re-add across seven real bundles, since the
owner is deletion-shaped: pristine beta287, v1.4.155 and v1.4.162 pass;
v1.4.163 fails; v1.4.163 with that single call removed passes; the byte
identical baseline restored fails again; head passes. Every failing arm
fails only this case — the ordinary negative stays green in all seven.

The negative asserts its own category rather than claiming it: zero
composition events, zero isComposing, zero keyCode 229, exactly 16 events,
paired against the Korean arm's 4 starts / 3 ends / 11 updates / 64 events.

Scope: cell indices, not pixels. happy-dom has no layout, so the recorded
8x16 cell metrics are supplied to the render service. This makes no claim
about pixels visually overlapping; that is affected-OS confirmation and
stays open. Covers the overlap arm only — STA-3132's auto-line-break arm
and STA-3232's half-line-capacity and a11y arms are untouched.

* test(e2e): matrix macOS period substitution against the OS preference

#11504 reports macOS inserting ". " after a Hangul Space commit. This
sweeps six arms across both states of NSAutomaticPeriodSubstitutionEnabled,
reading the preference live per run rather than asserting a literal.

Two results worth having on record.

The reporter's stated trigger did not reproduce. Their words are "There is
no second press at all. One space is enough", but korean-single-space emits
zero insertText with the preference on or off. So does word-space-word-space.

Their timing does reproduce, with different content. korean-double-space and
korean-longer-word-double-space emit a delayed insertText at +122.5-122.7ms
after compositionend — squarely the reported +149ms — but the payload is a
space, never ". ". Consistent with the double-space rule seeing two slots
under ABC and only one under Korean, where the IME commit consumes the first.

The substitution itself is real and preference-bound: latin-double-space
gives "ab . " with the preference on and "ab  " with it off, on one build
with the preference as the sole variable, reproduced across two runs.

That also refutes a claim in PR #11506, which states the substitution "is
enforced outside the renderer and never reproduces in dev builds, so changes
here must be verified against a packaged app". It reproduced in the dev build
twice and did not reproduce on the signed packaged app. That claim should not
be used as a verification gate.

Gated @headful behind ORCA_E2E_NATIVE_MACOS_PERIOD, same shape as the Korean
preedit spec, so it does not run in ordinary CI. Evidence is onData and DOM
only — the PTY-child reader aborted and no packaged-app arm was stable.

* test(terminal): actually enforce the recorded jamo progression

The preedit assertion compared sample.overlayText against sample.overlayText
— the same expression on both sides. A lane proved it by mutation: corrupting
seven of the eight recorded preedit values left the suite fully green. So the
docblock's ㄱ→가→간→나→낟→다→달→라, which the matrix also cites as this row's
recorded shape, was cited and unenforced.

The first attempt at a fix was insufficient and is worth recording. Threading
stroke.preedit through to the expectation still passed on a corrupted fixture,
because that value both drives the rig and was the expectation — corrupting it
moved both sides together. Same tautology, one level down.

The expectation is now an independent literal. Verified by mutation rather
than by reading: corrupting two recorded values fails one arm; restoring them
passes 3/3.

overlayCell was never affected — it is compared against a count derived from
the compositionend events, not from the buffer, and remains the load-bearing
assertion for the overlap.

* fix(e2e): select the selectable input source, not the first match

TISCreateInputSourceList can return several entries for one input source
id. A third-party IME publishes a non-selectable parent alongside the
selectable mode, and taking sources.first can return the parent — after
which TISSelectInputSource fails with paramErr (-50) while the caller
reports success from the enable step.

Found with Qingg (com.aodaren.inputmethod.Qingg), which exposes exactly
that pair under one id. Its mode id equals the bundle id, so filtering by
name would not have helped; selectability is the discriminator.

Now filters on kTISPropertyInputSourceIsSelectCapable and falls back to
the old behaviour when nothing advertises it, so single-entry sources are
unaffected. Also enables every entry for the id rather than only the one
being selected: selecting a mode whose parent is still disabled fails the
same way.

Compile-checked, and selecting com.apple.keylayout.ABC still exits 0.

Unrelated to the enable path: on macOS 26.5.2 third-party IMEs are gated
behind a consent sheet in System Settings. TISEnableInputSource returns
noErr immediately regardless, and the enable only lands if that sheet is
answered while the requesting process is still alive.

* test(terminal): discriminate #12171 against the real shortcut policy

The prior candidate mutation for this row was correctly refused: its suite
mocked shortcut policy so Process/229 became actionable, while the real
resolveTerminalShortcutAction has no Process branch — so the kill measured
the mock. This does not mock it.

Replays a capture of this row's own gesture (d, l, Shift+T, e, k, Space,
Enter under MS Korean, committing 있다) taken on Orca 1.4.164, through the
real useTerminalKeyboardShortcuts hook, capturing bytes at terminal.input.
The earlier capture could not discriminate at all because it recorded no
shiftKey; this one records it on 10 of 10 keydowns with code populated.

One physical Shift+T produces two shifted Process/229 keydowns. Under the
pre-#12265 classifier each synthesizes {key:'Enter', shiftKey:true}, which
the real policy resolves to sendInput '\x1b\r' — twice, giving 1b0d1b0d,
the two escapes the known-bad ed96881b0d1b0d contains.

Mutation is the retained pre-12265-process-shift.patch applied to HEAD, not
an authored one: patch -p1 applies clean and diffs identical to the mutant
copy. Arms are copies; shared source hashes the same before and after.

The English arm stays clean under both modules, so the mutation
discriminates by language rather than by harness — and a real Shift+Enter
through the same rig yields exactly ['\x1b\r'] in every arm, so a silent
pristine result means the code is quiet rather than the harness dead.

Scope: 1b0d1b0d is measured at the renderer boundary. The capture recorded
no PTY bytes — window.api.pty is frozen on shipped builds and the onData
channel needs a build-time flag — so this shows the renderer producing the
two escapes that payload contains, not a re-observation of the payload.

* docs(native-chat): narrow this file's disclaimer to what is now true

It said "THIS OWNS NO REPORTED ROW". Half of that is stale: the remount site
is now the attributed owner of #12118 and STA-3219. On real Windows TSF the
questionActive swap aborts a live composition — the old node gets only a
blur and no compositionend, the text returns as committed, and the next jamo
yields 아ㄴ rather than 안.

The other half holds. This file pins the opposite property, that the text
survives, which is the half those reporters already agree with. Mutation
shows the gap rather than asserting it: deleting the unmount entirely leaves
three of four tests green, because every substantive assertion is
after.value === … and a composer that never unmounts keeps its value.

Also records why the abort cannot be asserted here. The DOM exposes no
observable separating committed text from a live preedit — value is the same
string either way, there is no EditContext, and the only composing-ness
state is a per-instance ref discarded with the node. A test pinning "no
compositionend fires" would be an anti-guard: red the day it is fixed.

The cadence objection is kept, since it is now the open question rather than
the reason for exclusion.

* refactor(terminal): drop the unread isComposing field from XtermBypassEvent

Added by #6396 for terminal IME candidate handling that this branch has since
removed. No production or test code reads it, and the policy is safe without it:
during composition `key` is 'Process', so the non-ASCII printable checks that
would care never match.

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

* fix(native-chat): keep the composer mounted through an in-flight IME composition

A question card replaced the composer outright
(`{questionActive ? null : <NativeChatComposer/>}`). Unmounting the field
mid-composition aborts the composition in the OS: the node is detached before
`compositionend` can fire, the preedit returns as committed text, and a resumed
Hangul syllable degrades — 아 then ㄴ yields `아ㄴ`, never `안`.

Confirmed in rasterised pixels on Windows with a real MS Korean IME, at both
v1.4.171 and the reporter-era v1.4.164 (the swap block is byte-identical
across them): the preedit underline present before the swap, the composer
visibly absent during it, and the same glyph back afterwards WITHOUT the
underline — committed, not composing.

The swap is now deferred while a composition is in flight, which is what
editors that survive IME do: ProseMirror gates DOM work on `view.composing`,
CodeMirror protects the composing subtree from redraws. Hiding instead of
unmounting does not work — `display:none` and `visibility:hidden` both blur the
focused element and abort the composition the same way.

The hold releases on `compositionend`, which browsers also fire on blur, so
clicking into the card's own answer input yields the input region immediately;
with nothing composing the card still replaces the composer at once, so no
stray "Send a message" appears beside a question.

The existing characterization test flips to a regression guard: it pinned the
node being destroyed, which was the defect. Node identity is the load-bearing
assertion — value-only checks are trivially satisfied by a composer that never
unmounts and cannot tell a held composition from a destroyed one.

The typing-redirect handler moves to its own hook. That is not cosmetic: both
touched files sat at the 400-line cap, and `max-lines` suppressions are
forbidden, so the room had to come from a real extraction.

* fix(macos): opt Orca out of AppKit automatic period substitution

macOS "Add period with double-space" (`NSAutomaticPeriodSubstitutionEnabled`,
on by default) is applied by AppKit's text input system. Native terminals never
join that system; Chromium text fields do, so xterm's helper textarea inherits
it and a double space arrives as `". "` — a period nobody typed, handed straight
to the PTY (#11504).

Chromium answers AppKit for quote and dash substitution and defaults both off,
but declares no period accessor at all, so AppKit applies that one without
asking. This user default is the only lever: there is no per-field or
per-webContents opt-out to prefer over it. Writing the key into Orca's own
defaults domain overrides the global value for this app alone and leaves the
user's system-wide setting untouched. It necessarily covers every Orca text
field, not only terminals — AppKit offers no narrower scope, and that tradeoff
is deliberate rather than accidental.

Measured on the reporter's own build v1.4.161: with the preference ON, typing
a,b,space,space yields `onData ["a","b"," ",". "]`; with it OFF the same arm
yields two spaces.

Note the issue's causal model is wrong and this fix does not follow it. It
claims the substitution only fires with a CJK input source and never with ABC.
The measurement is the inverse — every Korean arm is clean and the ABC arm is
the one that fires — so the fix is not conditioned on input source.

NOT YET VERIFIED ON HARDWARE. The unit tests inject the writer, so they prove
the call is made on darwin and skipped elsewhere; they do not prove AppKit
honours an app-domain override for this key. That check is outstanding.

* fix(xterm): keep a live composition across a lone Cmd press on macOS

CompositionHelper.keydown exempted keyCodes 16/17/18 from tearing a composition
down, which covers Shift/Ctrl/Alt but not macOS Meta — 91/93 in Chromium, 224 in
Firefox. A lone Cmd press mid-composition therefore reached
_finalizeComposition(false), which dropped the preedit overlay's `active` class
and committed the live syllable early. macOS keeps the marked text alive across
that press, so no later compositionstart re-arms the overlay and the rest of the
word composes invisibly.

Measured on hardware (m4air, macOS 26.5.2, Apple M4, 2-Set Korean) with the Cmd
posted as a CGEventType.flagsChanged, which is what a physical modifier emits.
AppleScript `key code 55` posts nothing a browser can see — a bare `key code 56`
for Shift is equally silent — which is why no capture in the corpus ever reached
this branch. Three arms, same build otherwise: overlay live throughout with the
exemption, dark and prematurely committed without it, live again with it
restored. Evidence under
.tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/.

The fix cannot widen past a lone modifier: only a standalone press reports these
keyCodes, and a Cmd chord during composition is reported by Chromium as 229,
which was already exempt. Cmd+A still ends the composition, via the IME's own
compositionend. Ghostty draws the same line, returning early from flagsChanged
under hasMarkedText() for every modifier including Super.

Orca's terminal pane was never affected — shouldSuppressTerminalModifierKeyboardEvent
drops a standalone Meta keydown before xterm sees it, and deleting only 'Meta'
from that set is what flipped the hardware arm to broken. The popout preview
terminal and mobile's webview install no such guard and did reach the teardown.

terminal-ime-xterm-composition-commit-overlap.test.ts asked its fixer to update
the two Cmd arms to the values it named as correct; both now emit a single ['한'].

* test(native-chat): drop two byte-identical duplicate cases

`4632b86919d` copy-pasted two cases twice into the same describe block:
`retains carry across a same-frame non-Enter keyup before redispatch` and
`expires carry before a deliberate Enter after the next frame`. Each pair is
byte-identical — same title, same body — so the copies asserted nothing the
originals did not.

This is what has been failing `static analysis` on this branch since 2026-08-06:
`oxlint vitest(no-identical-title)` reports both under `--deny-warnings`, and
`verify` fails solely because it requires static analysis to pass. Every other
gate in `verify` was already green, including typecheck, xterm patch sync, the
full test shard set, and both package jobs.

12 cases still pass in the file.

* test(e2e): skip the WebGL arm when no WebGL renderer exists

The #12164 probe runs two arms, webgl and dom, and closes by asserting the
active renderer is the requested one. That assertion is right for the dom arm —
it is what proves the pane actually left WebGL, without which the arm is
meaningless — but headless CI has no GPU, xterm falls back to DOM silently, and
the webgl arm then fails.

The failure reads as a Korean rendering defect and is not one, so the webgl arm
now skips with the active renderer named. The dom arm keeps the assertion
unchanged.

This is the third of three checks that have been red on this branch since
2026-08-06. `static analysis` and `verify` were fixed in c51c6b5837e; the CI log
shows this job as 1 failed / 1 passed, the pass being the dom arm.

* chore(lint): drop five unused no-console disable directives

`check-changed-code-quality` reports unused eslint-disable directives as errors,
and these five sat above diagnostic `console.log` calls in IME test and spec
files where `no-console` is not enabled — so each suppressed nothing.

This is the second of the two static-analysis steps. `c51c6b5837e` fixed
"Enforce focused code-quality plugins" (duplicate test titles); this fixes
"Enforce changed-code quality". Both had been red on this branch since
2026-08-06, and I mistook the first for the whole job.

The diagnostic logs themselves are kept — they are what a failing IME arm prints
for a reader to inspect.

* test(e2e): cover #12164 under fractional device scale factor

Fractional display scaling was #12164's last unexplored branch, and the reason
is worth recording: earlier attempts were BLOCKED, correctly, because they
proposed mutating the Windows display scale on a remote physical machine with no
console recovery. `--force-device-scale-factor` reaches the same renderer state
per process, so nothing outside the Electron instance changes and there is
nothing to restore.

The hypothesis was specific: `프프로로젝젝트트` is what a half-pixel cell boundary
could produce on a 2-column glyph, and nothing else in the suite varies dpr.

Measured at 1.25 and 1.5, both under WebGL: ink extents 25/21/16 with identical
ink groups, matching the scale-1 run. No doubling.

The arm self-certifies before asserting — if the flag does not take, the test
fails rather than silently measuring at dpr 1. That matters here: the sibling
spec's WebGL arm went two days reporting a missing GPU as a Korean rendering
defect precisely because a silent fallback looked like a result.

* fix(terminal): match Mod+letter shortcuts by physical key, not IME-rewritten key

With a CJK input source active, macOS and Windows report the physical key through
`code` but rewrite `key` to the layout's character: Korean 2-Set turns Cmd+C into
`{ key: "ㅊ", code: "KeyC", metaKey: true }`. Every `key.toLowerCase() === 'c'`
match misses it, so the shortcut is not recognised and xterm encodes the chord as
PTY input instead — issue #13033 reports `ESC[12618;9u` and a terminal that jumps
to the bottom, because user input scrolls the viewport.

This is the same key-vs-code confusion that owned #12171, where a `Shift+T`
typing ㅆ was read as Enter for want of a `code` guard, so the fix is the same
shape: trust `code` when it is present, fall back to `key` and then the legacy
`keyCode` when it is not (Chromium omits `code` on synthetic and some keypress
events, and `keyCode` keeps its US value even when `key` is rewritten).

Applied to the four terminal-side sites, including the dashboard pop-out, which
#13033 called out specifically as having its own key handler:
  pty-connection.ts            Cmd/Ctrl+C copy guard
  keyboard-handlers.ts         Cmd+G search navigation
  agent-interrupt-inference.ts interrupt inference
  preview-terminal-key-handler.ts  pop-out paste

Nine further `key.toLowerCase()` letter matches exist outside the terminal
(TaskPage, editor, GitHub composer, browser markup). They have the same defect
and are deliberately left for a separate change rather than widening this one.

An existing case, `matchSearchNavigate > returns null for wrong key`, overrode
only `key` and left `code: 'KeyG'`, so it began passing for the wrong reason. It
now overrides both — which is what "wrong key" means once matching is physical —
and a companion case pins the Korean-rewritten chord still matching.

#13033 was closed NOT_PLANNED; the reporter's event shapes drive the new test.

* fix(renderer): match every Mod+letter shortcut by physical key, not IME-rewritten key

Completes the previous commit. A CJK input source rewrites `event.key` while
`event.code` keeps the physical key, so `key.toLowerCase() === 'z'` and friends
silently stop matching — the shortcut is not recognised and the keystroke falls
through to whatever handles unclaimed input.

The helper moves to `@/lib/ime-latin-shortcut-key` first: it now serves the
editor, GitHub composer and browser markup, and importing terminal-pane
internals into those would be the wrong direction. `lib/` already hosts
`ime-composition-keyboard-event` for the same reason.

Nine remaining sites, all previously unreachable under Korean/Japanese/Chinese/
Vietnamese input:
  TaskPage, ActivityPrototypePage, ProjectViewWrapper   Cmd+F search
  useMarkupKeyboardShortcuts                            Cmd+Z undo
  GitHubMarkdownComposer, RichMarkdownLinkBubble,
    rich-markdown-link-shortcut                         Cmd+K link
  native-chat-shortcut                                  Cmd+J
  rich-markdown-key-handler                             Cmd+Shift+X

Six of the nine test `!== 'letter'` as early-return guards and three test
`=== 'letter'`; the negation is applied per site, since a blind substitution
would have inverted six of them.

Full suite: 4249 files pass. Three files fail locally and none is caused by this
change — the branch touches no file under `src/main/` or `src/relay/`, all four
failures reproduce on an unmodified tree or pass in isolation (the worktree
poller passes 21/21 alone, so it is full-suite parallelism), and all 16 CI test
shards are green.

* docs(ime): scope the IME composition rules to the terminal-pane directory

#11893 proposed adding these to the root `AGENTS.md`, which every agent loads on
every task regardless of what it is doing. They only bind keyboard handling, the
composer and the terminal input path, so they belong next to that code —
`tests/e2e/AGENTS.md` already establishes the nested convention here.

Kept from #11893: range-derived commits, guarding above the key dispatch,
the `attachCustomKeyEventHandler` / `CompositionHelper` interaction, no
normalization at commit, and the recorded-trace evidence bar.

Added from defects found since it was written:
- match shortcuts on `event.code`, not `event.key` (#12171, #13033)
- `keyCode === 229` means an IME owns the press
- do not unmount a field mid-composition, and hiding is not a fix because
  `display:none` blurs and aborts it too (#12118, STA-3219, #11332)

The evidence bar now also names the mutation check, since a test that survives
deleting the code it guards is guarding nothing — a failure this effort hit more
than once.

* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated pane, porting #12462

Found while scoping the rebase onto `main`: #12462 landed on 2026-08-06 and
fixes a real defect this branch does not carry. Ctrl+Enter emitted
`\x1b[13;5u` unconditionally, so a pane that never negotiated the kitty
keyboard protocol — local Windows ConPTY, plain shell — printed the escape
verbatim into the prompt.

This branch deletes `terminal-ime-deferred-newline.ts`, which is one of the
files #12462 touched, so a rebase resolving those conflicts by taking our side
wholesale would silently reintroduce the defect. Porting it forward now means
the fix survives the rebase however the conflicts are resolved.

Mirrors the Shift+Enter guard already here: local ConPTY falls back to the
legacy CR every emulator sends, and a negotiated pane keeps the chord, so the
fallback is scoped to panes that cannot receive CSI-u rather than to Windows.

NARROWER THAN #12462 BY ONE CONDITION, deliberately. `main` also allows CSI-u
via `hasCtrlEnterCsiUAuthority()` (trusted consumer evidence, #12329); that
helper and its plumbing do not exist on this branch. Omitting it is the
conservative direction — an authorised pane gets `\r` instead of the chord,
rather than an unnegotiated pane printing an escape — but it should be restored
when the two histories are reconciled.

Test covers both directions and is mutation-checked: forcing the gate open
fails it, so it cannot pass by construction.

* fix: reconcile two more fixtures main moved while the stack waited

Both caught by CI, not locally, and the reason the local run missed one is
worth recording:

1. `browser-toolbar-profile-dialogs.ime-enter.test.tsx` did not pass
   `useNativeUserAgent` / `onUseNativeUserAgentChange`, which `main` added to
   `BrowserToolbarProfileDialogsProps`.

   Local `pnpm typecheck` reported 0 errors on the same commit CI failed. The
   cause was a stale `config/*.tsbuildinfo` — tsc reused an incremental cache
   from before the merge. Deleting it reproduced CI's error exactly. Any
   "typecheck clean" during this merge should be treated as unverified unless
   the cache was cleared first.

2. Localization keys for `SshDisconnectedDialog` were absent from `en.json`:
   the merge took this branch's component alongside `main`'s catalog.
   Regenerated with `pnpm run sync:localization-catalog` rather than hand-added.

* fix(mobile): regenerate the lockfile the merge resolved by taking one side

CI's `verify` failed with `ERR_PNPM_OUTDATED_LOCKFILE` on `mermaid (lockfile:
11.16.0, manifest: 11.16.1)`. The mismatch was in `mobile/`, not the root — the
root lockfile was consistent throughout, which is why inspecting it (and even
GitHub's merge ref) found nothing wrong.

Cause: during the merge I resolved `mobile/pnpm-lock.yaml` by taking this
branch's side wholesale rather than merging it, so it kept `mermaid 11.16.0`
while `mobile/package.json` came from `main` at `11.16.1`. Taking one side of a
lockfile is only safe when the corresponding manifest comes from the same side.

Regenerated with `pnpm install --lockfile-only`; `--frozen-lockfile` now passes
in `mobile/`. Verified the xterm patch entry survives intact — same hash
`4f1b42d268f3964d…` and the parent-relative path into `config/patches/`, which
is the desktop/mobile coupling that would silently break the mobile build.

Two earlier diagnoses of this failure were wrong and are worth recording: it was
not the root lockfile, and it was not a stale merge ref (a rerun reproduced it
exactly).

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-07 22:58:40 -07:00
committed by GitHub
co-authored by Orca
parent cf16eac7f6
commit 17b3dff3c4
332 changed files with 20566 additions and 12659 deletions
+5
View File
@@ -12,3 +12,8 @@
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
# pnpm hashes this generated patch byte-for-byte, so a CRLF checkout breaks the install;
# its minified bundle lines also make a diff nobody can read. Review the hand-written
# source patch under xterm-src/ instead.
/config/patches/@xterm__xterm@*.patch -diff -text
/config/patches/xterm-src/*.patch text eol=lf
+33 -1
View File
@@ -173,6 +173,34 @@ jobs:
done
exit "$status"
xterm_patch_sync:
name: xterm patch sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v4
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
- name: Verify xterm patches match the pinned upstream build
env:
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
@@ -415,7 +443,8 @@ jobs:
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
# Native specs require their dedicated platform harnesses.
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' | grep -Ev -- '-native\.spec\.ts$' || true)"
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
@@ -444,6 +473,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- managed_hook_node18
@@ -466,6 +496,7 @@ jobs:
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
@@ -477,6 +508,7 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$MANAGED_HOOK_NODE18" \
+71 -9
View File
@@ -9,10 +9,20 @@ permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
linux:
name: Linux ${{ matrix.label }} terminal IME
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
- label: X11
os: ubuntu-22.04
display_server: x11
- label: Wayland
os: ubuntu-24.04
display_server: wayland
steps:
- name: Checkout
@@ -27,10 +37,17 @@ jobs:
build-essential
dbus-x11
dconf-gsettings-backend
fcitx5
fcitx5-chinese-addons
fcitx5-frontend-gtk3
fcitx5-hangul
ibus
ibus-hangul
ibus-libpinyin
libglib2.0-bin
python3
sway
wtype
xdotool
xfwm4
xvfb
@@ -56,7 +73,57 @@ jobs:
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run native IBus exact-byte tests
if: matrix.display_server == 'x11'
env:
ORCA_E2E_NATIVE_IME: ibus
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native IBus evidence
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run native Fcitx5 exact-byte tests
if: matrix.display_server == 'x11'
env:
ORCA_E2E_NATIVE_IME: fcitx5
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native Fcitx5 evidence
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-fcitx5-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run native Fcitx5 Wayland exact-byte tests
if: matrix.display_server == 'wayland'
env:
ORCA_E2E_NATIVE_DISPLAY_SERVER: wayland
ORCA_E2E_NATIVE_IME: fcitx5
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload native Fcitx5 Wayland evidence
if: ${{ always() && matrix.display_server == 'wayland' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-native-fcitx5-wayland-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
- name: Run deterministic terminal IME boundary tests
if: matrix.display_server == 'x11'
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
@@ -64,13 +131,8 @@ jobs:
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
if: ${{ always() && matrix.display_server == 'x11' }}
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
+1
View File
@@ -102,6 +102,7 @@ docs/**
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/windows-setup-shell.md
!docs/reference/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
File diff suppressed because one or more lines are too long
@@ -0,0 +1,363 @@
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..c16096341dadfd215a8086e13f7a0551025c0e77 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -735,6 +735,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
paste(data, this.textarea!, this.coreService, this.optionsService);
}
+ public override input(data: string, wasUserInput: boolean = true): void {
+ if (!wasUserInput || !this._compositionHelper?.handleCompositionInput(data, false)) super.input(data, wasUserInput);
+ }
+
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
this._customKeyEventHandler = customKeyEventHandler;
}
@@ -1029,6 +1033,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
// support reading out character input which can doubling up input characters
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
+ if (ev.data && ev.inputType === 'insertText' && this._compositionHelper?.handleCompositionInput(ev.data, true)) return true;
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
if (this._keyPressHandled) {
return false;
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
index 7b346459521dd77f9de25d43fba3c36a6f8459b2..4837a7c1a3eb36ec6f6223cbcec0f2dd6cc4e235 100644
--- a/src/browser/TestUtils.test.ts
+++ b/src/browser/TestUtils.test.ts
@@ -342,6 +342,9 @@ export class MockViewport implements IViewport {
}
export class MockCompositionHelper implements ICompositionHelper {
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ throw new Error('Method not implemented.');
+ }
public get isComposing(): boolean {
return false;
}
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81070941ed 100644
--- a/src/browser/Types.ts
+++ b/src/browser/Types.ts
@@ -39,6 +39,7 @@ export type LineData = CharData[];
export interface ICompositionHelper {
readonly isComposing: boolean;
+ handleCompositionInput(data: string, nativeCommit: boolean): boolean;
compositionstart(): void;
compositionupdate(ev: CompositionEvent): void;
compositionend(): void;
diff --git a/src/browser/input/CompositionHelper.test.ts b/src/browser/input/CompositionHelper.test.ts
index 5a1e6c38c9799f7f57d5df6d4a122a7beb0cf04f..2d78e414177804261acbbe2f53d71b5c8709ceb6 100644
--- a/src/browser/input/CompositionHelper.test.ts
+++ b/src/browser/input/CompositionHelper.test.ts
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { CompositionHelper } from './CompositionHelper';
import { MockRenderService } from '../TestUtils.test';
-import { MockCoreService, MockBufferService, MockOptionsService } from '../../common/TestUtils.test';
+import { MockCoreService, MockBufferService, MockOptionsService, MockUnicodeService } from '../../common/TestUtils.test';
describe('CompositionHelper', () => {
let compositionHelper: CompositionHelper;
@@ -42,7 +42,7 @@ describe('CompositionHelper', () => {
};
handledText = '';
const bufferService = new MockBufferService(10, 5);
- compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService());
+ compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService(), new MockUnicodeService());
});
describe('Input', () => {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..ea77d070322479e75201970d63fc41336d64dc93 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -4,8 +4,7 @@
*/
import { IRenderService } from '../services/Services';
-import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
-import { C0 } from '../../common/data/EscapeSequences';
+import { IBufferService, ICoreService, IOptionsService, IUnicodeService } from '../../common/services/Services';
interface IPosition {
start: number;
@@ -42,15 +41,12 @@ export class CompositionHelper {
*/
private _isSendingComposition: boolean;
- /**
- * Data already sent due to keydown event.
- */
- private _dataAlreadySent: string;
+ private _pendingCompositionStart?: number;
+ private _pendingInput = '';
+ private _sentComposition = '';
- /**
- * The pending textarea change timer, if any.
- */
- private _textareaChangeTimer?: number;
+ /** Text and cell width the current letter-spacing was measured for. */
+ private _gridAdvanceKey = '';
constructor(
private readonly _textarea: HTMLTextAreaElement,
@@ -58,13 +54,13 @@ export class CompositionHelper {
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICoreService private readonly _coreService: ICoreService,
- @IRenderService private readonly _renderService: IRenderService
+ @IRenderService private readonly _renderService: IRenderService,
+ @IUnicodeService private readonly _unicodeService: IUnicodeService
) {
this._isComposing = false;
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
- this._dataAlreadySent = '';
}
/**
@@ -80,10 +76,27 @@ export class CompositionHelper {
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionView.textContent = '';
- this._dataAlreadySent = '';
this._compositionView.classList.add('active');
}
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ if (nativeCommit) {
+ if (!this._isSendingComposition) return false;
+ // Once the deferred send has run, an IME that delivers its commit a task late (IBus) repeats
+ // text we already sent; anything else is new input and must not be discarded with it.
+ const alreadySent = this._pendingCompositionStart === undefined && data === this._sentComposition;
+ const input = (alreadySent ? '' : data) + this._pendingInput;
+ this._pendingCompositionStart = undefined;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ this._isSendingComposition = false;
+ return true;
+ }
+ if (!this._isComposing && !this._isSendingComposition) return false;
+ this._pendingInput += data;
+ return true;
+ }
+
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
@@ -119,8 +132,10 @@ export class CompositionHelper {
// Continue composing if the keyCode is the "composition character"
return false;
}
- if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
- // Continue composing if the keyCode is a modifier key
+ if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18 || ev.keyCode === 91 || ev.keyCode === 93 || ev.keyCode === 224) {
+ // Continue composing if the keyCode is a modifier key. 91/93 are Chromium's left and
+ // right Meta, 224 is Firefox's. Only a lone modifier press reports these, and on macOS
+ // finalizing there hides the preedit for the rest of a word the IME is still composing.
return false;
}
// Finish composition immediately. This is mainly here for the case where enter is
@@ -129,9 +144,6 @@ export class CompositionHelper {
}
if (ev.keyCode === 229) {
- // If the "composition character" is used but gets to this point it means a non-composition
- // character (eg. numbers and punctuation) was pressed when the IME was active.
- this._handleAnyTextareaChanges();
return false;
}
@@ -153,7 +165,11 @@ export class CompositionHelper {
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
+ const start = this._pendingCompositionStart ?? this._compositionPosition.start;
+ const end = Math.max(start, this._textarea.selectionEnd ?? this._compositionPosition.end);
+ this._pendingCompositionStart = undefined;
+ const input = this._textarea.value.substring(start, end) + this._pendingInput;
+ this._pendingInput = '';
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
@@ -163,6 +179,7 @@ export class CompositionHelper {
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
+ this._pendingCompositionStart ??= currentCompositionPosition.start;
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -175,12 +192,10 @@ export class CompositionHelper {
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
- if (this._isSendingComposition) {
- this._isSendingComposition = false;
+ if (this._isSendingComposition && this._pendingCompositionStart !== undefined) {
+ currentCompositionPosition.start = this._pendingCompositionStart;
+ this._pendingCompositionStart = undefined;
let input;
- // Add length of data already sent due to keydown event,
- // otherwise input characters can be duplicated. (Issue #3191)
- currentCompositionPosition.start += this._dataAlreadySent.length;
if (this._isComposing) {
// Use the start position of the new composition to get the string
// if a new composition has started.
@@ -195,47 +210,22 @@ export class CompositionHelper {
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
- if (input.length > 0) {
- this._coreService.triggerDataEvent(input, true);
- }
+ this._sentComposition = input;
+ input += this._pendingInput;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ setTimeout(() => {
+ if (this._pendingCompositionStart === undefined) {
+ if (this._pendingInput.length > 0) this._coreService.triggerDataEvent(this._pendingInput, true);
+ this._pendingInput = '';
+ this._isSendingComposition = false;
+ }
+ }, 0);
}
}, 0);
}
}
- /**
- * Apply any changes made to the textarea after the current event chain is allowed to complete.
- * This should be called when not currently composing but a keydown event with the "composition
- * character" (229) is triggered, in order to allow non-composition text to be entered when an
- * IME is active.
- */
- private _handleAnyTextareaChanges(): void {
- if (this._textareaChangeTimer) {
- return;
- }
- const oldValue = this._textarea.value;
- this._textareaChangeTimer = window.setTimeout(() => {
- this._textareaChangeTimer = undefined;
- // Ignore if a composition has started since the timeout
- if (!this._isComposing) {
- const newValue = this._textarea.value;
-
- const diff = newValue.replace(oldValue, '');
-
- this._dataAlreadySent = diff;
-
- if (newValue.length > oldValue.length) {
- this._coreService.triggerDataEvent(diff, true);
- } else if (newValue.length < oldValue.length) {
- this._coreService.triggerDataEvent(`${C0.DEL}`, true);
- } else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
- this._coreService.triggerDataEvent(newValue, true);
- }
-
- }
- }, 0);
- }
-
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -260,6 +250,7 @@ export class CompositionHelper {
this._compositionView.style.lineHeight = cellHeight + 'px';
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
+ this._alignPreeditToGrid(this._renderService.dimensions.css.cell.width);
// Limit the composition view width to the space between the cursor and
// the terminal's right edge, preventing it from overflowing the terminal.
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
@@ -281,4 +272,39 @@ export class CompositionHelper {
setTimeout(() => this.updateCompositionElements(true), 0);
}
}
+
+ /**
+ * The overlay is laid out as plain text, so its extent is whatever advance the font
+ * gives the preedit. For Hangul and CJK that is well under the cells the same text
+ * occupies once committed (measured on macOS/SF Mono: 0.70 for Hangul, 0.81 for CJK,
+ * against 1.00 for Latin), and the shortfall accumulates across the composition.
+ * Spread it as letter-spacing, which is how DomRendererRowFactory lands committed
+ * glyphs on the cell grid, so the preedit covers the cells it is about to become.
+ */
+ private _alignPreeditToGrid(cellWidth: number): void {
+ const text = this._compositionView.textContent ?? '';
+ // Runs on every render frame while composing; the measurement below forces layout.
+ const key = `${cellWidth}${text}`;
+ if (key === this._gridAdvanceKey) {
+ return;
+ }
+ this._gridAdvanceKey = key;
+ // Letter-spacing lands after each character that advances, so the LTR marks
+ // wrapping the preedit are not among the gaps the shortfall is divided over.
+ let advancing = 0;
+ for (const character of text) {
+ if (this._unicodeService.wcwidth(character.codePointAt(0)!) > 0) {
+ advancing++;
+ }
+ }
+ this._compositionView.style.letterSpacing = '';
+ if (advancing === 0) {
+ return;
+ }
+ // Measured with maxWidth cleared: a long preedit's natural advance exceeds it.
+ this._compositionView.style.maxWidth = '';
+ const naturalWidth = this._compositionView.getBoundingClientRect().width;
+ const gridWidth = this._unicodeService.getStringCellWidth(text) * cellWidth;
+ this._compositionView.style.letterSpacing = `${(gridWidth - naturalWidth) / advancing}px`;
+ }
}
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
return false;
diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts
index bedfa6ba1fdd6fb24646f52167e4881dc03498be..5d87be2d5cc52cb4504fd383243c2de72290bd04 100644
--- a/src/common/TestUtils.test.ts
+++ b/src/common/TestUtils.test.ts
@@ -225,8 +225,10 @@ export class MockUnicodeService implements IUnicodeService {
}
return UnicodeService.createPropertyValue(0, width, shouldJoin);
}
+ // Reuses the real traversal against this mock's own provider; CompositionHelper
+ // sizes the preedit overlay with it, so throwing here would fail its tests.
public getStringCellWidth(s: string): number {
- throw new Error('Method not implemented.');
+ return UnicodeService.prototype.getStringCellWidth.call(this, s);
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
"policy": "delete",
"why": "The shipped .js and .mjs move under the patch, so a retained map would need to move with it: excluding just the map hunks ships offsets that no longer line up, which is the defect the addon patches still have. Deleting the maps is the honest form of that saving — the patch drops from 7.3MB to 1.5MB, the bundles stay byte-identical, and nothing in this repo consumes the maps at build or run time."
},
"toolchain": {
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
"esbuild": "0.28.1",
"webpack": "5.107.0",
"terser": "5.47.1",
"@typescript/native-preview": "7.0.0-dev.20260521.1"
},
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
+39 -130
View File
@@ -6631,186 +6631,95 @@
},
{
"id": "terminal-input.ime-and-synthetic-forwarding",
"title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes",
"title": "Native terminal composition commits once through stock xterm",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-input",
"layer": "renderer-unit-platform-soak",
"surfaces": [
"IME",
"native text forwarding",
"synthetic input",
"paste",
"keyboard bypass",
"JIS yen"
],
"surfaces": ["IME", "paste", "keyboard bypass", "JIS yen"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": [],
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coveredProviders": ["local", "ssh", "remote-runtime"],
"coverageNotes": "Stock xterm owns preedit and commit derivation. Renderer tests cover same-task consecutive commits, commit-before-Enter, ordinary input, action refusal, and PTY-generation isolation. Native platform and rendering certification remains required.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
"https://github.com/stablyai/orca/pull/6513",
"https://github.com/stablyai/orca/pull/6999"
"https://github.com/stablyai/orca/pull/11293",
"https://github.com/stablyai/orca/pull/12062"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"invariant": "The stock terminal composition owner emits committed text exactly once; Orca refuses IME-owned application actions and never reroutes terminal data to a replacement PTY generation.",
"oracle": "Real xterm onData bytes are asserted for consecutive composition, ordinary ASCII, and physical Enter. Marked and ordinary shortcut shapes are paired, and a replaced PTY transport receives no stale input.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/lib/ime-composition-keyboard-event.test.ts",
"pnpm run test:e2e:terminal-ime-native"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
"src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts",
"src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts",
"src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/lib/ime-composition-keyboard-event.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"file": "src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts",
"assertions": [
"native text commits route once to the intended PTY",
"composition/preedit bookkeeping does not leak premature text"
"a commit is delivered before a same-task next composition",
"ordinary typing and physical Enter are unchanged",
"the commit precedes physical Enter"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts",
"assertions": [
"synthetic and native input-source paths are classified for covered fixtures"
"a marked real key shape cannot trigger a copy action",
"the ordinary copy shortcut is unchanged"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"empty Sogou-style compositionupdate keeps composition active",
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"assertions": [
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"assertions": [
"an orphaned plain-letter keyup arms exactly the next bare digit guard",
"ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available",
"physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
"candidate Space/digit selectors are suppressed only while candidate guards are active"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"assertions": [
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
]
},
{
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend",
"Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap"
"ordinary input reaches the bound transport",
"a replaced transport generation receives no stale input"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-11",
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-stock-composition.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.test.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/lib/ime-composition-keyboard-event.test.ts",
"result": "passed",
"durationSeconds": 0.619,
"summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"result": "passed",
"durationSeconds": 0.747,
"summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 30.3,
"summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.7,
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 72.0,
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
"durationSeconds": 20.43,
"summary": "Five files and 569 tests passed against the stock xterm composition path and PTY generation gate."
}
],
"runtimeBudget": {
"p95Seconds": 90,
"scope": "renderer unit plus focused Electron/CDP IME repro"
"scope": "renderer unit plus native Linux IME E2E"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
"evidence": "The new stock-owner slice has one local run; native soak history is still required."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
"evidence": "Removing the action guard triggers the marked action while the ordinary negative remains valid; bypassing the PTY-generation check sends stale input. Native platform mutations remain required."
},
"performanceBudget": {
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added."
"evidence": "The change deletes per-pane trackers, timers, event buses, candidate registries, and native-text forwarding from the input hot path."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
"Mark true OS IME automation gaps explicitly by platform.",
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
"Complete the native OS and input-method matrix.",
"Add Windows fractional-scale rendering evidence.",
"Retain paired ordinary input and mutation checks."
],
"knownGaps": [
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
"Windows Korean rendering, macOS Korean/Pinyin/Cangjie/Vietnamese, Wayland/Fcitx, and iOS/Android still require native evidence.",
"The retained Linux E2E must be rerun against this stock-owner head."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
"demotionRule": "Cannot promote while any required goalpost environment lacks native evidence."
},
{
"id": "terminal-input.windows-conpty-keyboard-reset",
@@ -57,6 +57,7 @@ describe('PR E2E gate contract', () => {
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
expect(filterStep.run).toContain("'-native\\.spec\\.ts$'")
expect(filterStep.run).not.toContain('tests/playwright\\.')
})
@@ -148,7 +148,12 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
@@ -194,6 +199,7 @@ describe('PR workflow parallelism', () => {
'root_directory_guard',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'managed_hook_node18',
+727
View File
@@ -0,0 +1,727 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The emitted patch can only ever name files the registry publishes, and
* upstream's `.npmignore` strips `src/**\/*.test.ts`. Deriving the source patch
* from the emitted patch therefore deletes any hunk against those files on the
* next `--write` — including the `MockCompositionHelper` implementation the
* patched `ICompositionHelper` requires to type-check. The two derivations must
* still agree everywhere they can both speak, or the source patch and the
* shipped patch have drifted.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText, publishedPaths) {
const published = selectPatchEntries(checkoutSource, (file) => publishedPaths.has(file))
const emitted = sourceHunks(patchText)
if (published === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a published source file.',
` from checkout: [${splitPatchEntries(published)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(published, emitted)}`,
'',
'This is a generator bug, not a patch problem: the same content diffed two',
'ways must produce the same bytes.'
].join('\n')
)
}
/** The derived half of a patch: build output, never edited by hand. */
export function generatedHunks(patchText, generatedPaths) {
return selectPatchEntries(patchText, (file) =>
generatedPaths.some((prefix) => file.startsWith(prefix))
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
`export const XTERM_VERSION = '${version}';`
)
if (stamped === source && !source.includes(`'${version}'`)) {
throw new Error('Version stamp file does not declare XTERM_VERSION')
}
return stamped
}
/**
* The published tarball names the commit it was built from, so a version bump
* that forgets the manifest fails here instead of producing a patch against the
* wrong tree.
*/
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
if (publishedPackageJson.version !== packageEntry.version) {
throw new Error(
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
)
}
if (publishedPackageJson.commit !== upstreamCommit) {
throw new Error(
[
`${packageEntry.name}@${packageEntry.version} was published from commit`,
` ${publishedPackageJson.commit ?? '(absent)'}`,
`but ${MANIFEST_RELATIVE_PATH} pins`,
` ${upstreamCommit}`,
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
].join('\n')
)
}
}
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
export function assertBuildStepsAllowed(manifest) {
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
for (const packageEntry of manifest.packages) {
for (const step of packageEntry.build) {
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
if (script !== undefined && forbidden.has(script)) {
throw new Error(
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
)
}
}
}
}
export const SOURCEMAP_POLICIES = new Set(['include', 'delete'])
/** A typo would fall through to `include` and re-inflate the patch by 5.8 MB. */
export function assertSourcemapPolicy(manifest) {
const policy = manifest.sourcemaps?.policy
if (!SOURCEMAP_POLICIES.has(policy)) {
throw new Error(
`sourcemaps.policy must be one of ${[...SOURCEMAP_POLICIES].join(', ')}, got ${JSON.stringify(policy)}`
)
}
return policy
}
/**
* pnpm keys the patched package directory and the lockfile entry by the
* sha256 of the patch file itself, so a regenerated patch that leaves
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
* author's.
*/
export function patchHash(patchText) {
return createHash('sha256').update(patchText, 'utf8').digest('hex')
}
function lockfilePatchHashPattern(packageKey) {
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
}
export function readLockfilePatchHash(lockfileText, packageKey) {
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
if (!match) {
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
}
return match[2]
}
function lockfileResolutionHashPattern(packageKey) {
const separator = packageKey.lastIndexOf('@')
const name = escapeRegExp(packageKey.slice(0, separator))
const version = escapeRegExp(packageKey.slice(separator + 1))
// Two spellings: `name@version(patch_hash=…)` in dependency keys, and a bare
// `: version(patch_hash=…)` under `version:` and in resolved dependency maps.
return new RegExp(`(?:${name}@|: )${version}\\(patch_hash=([0-9a-f]{64})\\)`, 'g')
}
/**
* pnpm repeats the hash inside every resolution key that depends on the patched
* package, not just in `patchedDependencies`. Updating one and not the other leaves
* a lockfile that installs on a warm store and drifts on a cold one, which is CI.
*/
export function readLockfileResolutionHashes(lockfileText, packageKey) {
return Array.from(
lockfileText.matchAll(lockfileResolutionHashPattern(packageKey)),
(match) => match[1]
)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
readLockfileResolutionHashes(lockfileText, packageKey).some((value) => value !== hash)
)
}
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
readLockfilePatchHash(lockfileText, packageKey)
return lockfileText
.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
.replace(lockfileResolutionHashPattern(packageKey), (match, current) =>
match.replace(`patch_hash=${current}`, `patch_hash=${hash}`)
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'inherit'],
...options
})
}
function listFilesRelative(root, base = root) {
const files = []
for (const entry of readdirSync(base, { withFileTypes: true })) {
const absolute = path.join(base, entry.name)
if (entry.isDirectory()) {
files.push(...listFilesRelative(root, absolute))
} else if (entry.isFile()) {
files.push(path.relative(root, absolute))
}
}
return files.sort()
}
function sameBytes(left, right) {
return (
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
)
}
function fetchPristinePackage(packageEntry, workDir) {
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
rmSync(target, { recursive: true, force: true })
mkdirSync(target, { recursive: true })
const spec = `${packageEntry.name}@${packageEntry.version}`
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
cwd: workDir
})
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
run('tar', ['xzf', tarball, '-C', target])
return path.join(target, 'package')
}
function hasCommit(root, commit) {
try {
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
} catch {
return false
}
}
function ensureUpstreamCheckout(manifest, workDir) {
const root = path.join(workDir, 'upstream')
const { repository, commit } = manifest.upstream
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(root, { recursive: true })
run('git', ['init', '--quiet'], { cwd: root })
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
}
if (!hasCommit(root, commit)) {
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
}
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
return root
}
function ensureDependencies(upstreamRoot, manifest) {
const lockfile = path.join(upstreamRoot, 'package-lock.json')
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
return
}
run('npm', ['ci'], {
cwd: upstreamRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
})
assertToolchain(upstreamRoot, manifest)
writeFileSync(stamp, want)
}
function assertToolchain(upstreamRoot, manifest) {
const expected = manifest.toolchain
for (const [name, version] of Object.entries(expected)) {
if (name === 'why') {
continue
}
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
if (!existsSync(installed)) {
throw new Error(
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
)
}
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
if (actual !== version) {
throw new Error(
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
)
}
}
}
/**
* The published src/ must equal the pinned commit's src/ apart from the version
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
* commit and every hunk below would be nonsense.
*/
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
const stampFile = packageEntry.versionStampFile
const sourceRoot = path.join(pristineDir, 'src')
const drifted = listFilesRelative(sourceRoot)
.map((relative) => path.join('src', relative))
.filter((relative) => relative !== stampFile)
.filter(
(relative) =>
!sameBytes(
path.join(pristineDir, relative),
path.join(upstreamRoot, packageEntry.packageDir, relative)
)
)
if (drifted.length > 0) {
throw new Error(
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
)
}
}
function buildPackage(upstreamRoot, packageEntry, manifest) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
}
}
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
const drifted = listFilesRelative(pristineDir)
.filter((relative) =>
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
)
.filter(
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
)
if (drifted.length > 0) {
throw new Error(
[
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
...drifted.map((relative) => ` ${relative}`),
'',
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
].join('\n')
)
}
}
function toPosix(value) {
return value.split(path.sep).join('/')
}
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
rmSync(destination, { recursive: true, force: true })
cpSync(pristineDir, destination, { recursive: true })
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const relative of listFilesRelative(pristineDir)) {
// package.json carries the registry's version/commit stamp, which the build
// tree has no way to reproduce and which we never want to patch.
if (relative === 'package.json') {
continue
}
const built = path.join(packageRoot, relative)
if (!existsSync(built)) {
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
}
copyFileSync(built, path.join(destination, relative))
}
}
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
maxBuffer: 512 * 1024 * 1024,
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
// `git diff --no-index` exits 1 whenever it finds differences.
if (error.status !== 1 || error.stderr?.length > 0) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
// Why: dropping only the map hunks would ship maps whose offsets no longer line
// up with the patched bundle. Deleting the maps outright is the honest form of
// the same size saving, and the diff carries it as a file-deletion stanza.
function deleteGeneratedSourcemaps(patchedDir, packageEntry) {
for (const relative of listFilesRelative(patchedDir)) {
const posix = toPosix(relative)
if (!packageEntry.generatedPaths.some((prefix) => posix.startsWith(prefix))) {
continue
}
const absolute = path.join(patchedDir, relative)
if (posix.endsWith('.map')) {
rmSync(absolute)
continue
}
// The reference outlives the file it points at, so it goes with it.
const text = readFileSync(absolute, 'utf8')
const stripped = text.replace(/\n\/\/# sourceMappingURL=[^\n]*\n?$/, '')
if (stripped !== text) {
writeFileSync(absolute, stripped)
}
}
}
/** The source of truth for the hand-written half: what the checkout itself holds. */
function diffCheckoutSource(packageRoot) {
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
cwd: packageRoot,
env: pnpmDiffEnvironment(),
maxBuffer: 64 * 1024 * 1024
})
}
function publishedSourcePaths(pristineDir) {
return new Set(
listFilesRelative(path.join(pristineDir, 'src')).map((relative) =>
toPosix(path.join('src', relative))
)
)
}
function regeneratePackage(packageEntry, manifest, context) {
const { workDir, repoRoot } = context
const pristineDir = fetchPristinePackage(packageEntry, workDir)
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
ensureDependencies(upstreamRoot, manifest)
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
buildPackage(upstreamRoot, packageEntry, manifest)
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
if (assertSourcemapPolicy(manifest) === 'delete') {
deleteGeneratedSourcemaps(patchedDir, packageEntry)
}
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch, publishedSourcePaths(pristineDir))
return { patch, source }
}
export function regenerateXtermPatches({
mode,
repoRoot = DEFAULT_REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
log = console.info
} = {}) {
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
assertBuildStepsAllowed(manifest)
assertSourcemapPolicy(manifest)
mkdirSync(workDir, { recursive: true })
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
let lockfile = readFileSync(lockfilePath, 'utf8')
let lockfileChanged = false
const failures = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
const { patch: regenerated, source: canonicalSource } = regeneratePackage(
packageEntry,
manifest,
{ workDir, repoRoot }
)
const patchPath = path.join(repoRoot, packageEntry.patch)
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
const packageKey = `${packageEntry.name}@${packageEntry.version}`
const hash = patchHash(regenerated)
if (mode === 'write') {
writeFileSync(patchPath, regenerated)
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
}
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
failures.push(
[
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
` patchedDependencies: ${readLockfilePatchHash(lockfile, packageKey)}`,
` resolution keys: ${stale.length > 0 ? stale.join(', ') : 'in sync'}`,
` patch: ${hash}`,
'',
'pnpm keys the patched package by the sha256 of the patch file, so',
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
].join('\n')
)
}
const committed = readFileSync(patchPath, 'utf8')
if (committed !== regenerated) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.patch,
committed,
regenerated
})
)
continue
}
const committedSource = readFileSync(sourcePatchPath, 'utf8')
if (committedSource !== canonicalSource) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.sourcePatch,
committed: committedSource,
regenerated: canonicalSource
})
)
continue
}
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
}
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
}
const USAGE =
'Usage: regenerate-xterm-patches.mjs [--check | --write] [--work-dir=<path>]\n' +
' --check (default) verifies the shipped patches match the pinned upstream build;\n' +
' --write regenerates them from config/patches/xterm-src/. Build outside this repo:\n' +
' tsc otherwise walks up into our node_modules. See\n' +
' docs/reference/xterm-patch-regeneration.md.'
function main(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.info(USAGE)
return
}
// --check is the default, so an unrecognised flag would otherwise silently run a full
// upstream build instead of whatever the caller meant.
const known = (v) =>
!v.startsWith('-') || ['--write', '--check'].includes(v) || v.startsWith('--work-dir=')
const unknown = argv.filter((value) => !known(value))
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown.join(', ')}\n${USAGE}`)
}
const write = argv.includes('--write')
const check = argv.includes('--check') || !write
if (write && argv.includes('--check')) {
throw new Error('Pass either --write or --check, not both')
}
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
regenerateXtermPatches({
mode: write ? 'write' : 'check',
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
})
if (check) {
console.info('xterm patches are in sync with the pinned upstream build.')
}
}
// realpathSync so a symlinked checkout path still registers as a direct run.
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
@@ -0,0 +1,478 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
generatedHunks,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
selectPatchEntries,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
const temporaryDirectories = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
async function createDirectory() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
temporaryDirectories.push(directory)
return directory
}
async function writeTree(root, files) {
for (const [relative, contents] of Object.entries(files)) {
const target = path.join(root, relative)
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, contents)
}
}
/** The three exported diff pieces, composed the way the generator composes them. */
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
if (error.status !== 1) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
const PRISTINE = {
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
'src/Other.ts': 'export const other = 0\n',
'lib/widget.js': 'function widget(){return 1}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
'package.json': '{\n "name": "@scope/widget"\n}\n'
}
const PATCHED = {
...PRISTINE,
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
'lib/widget.js': 'function widget(){return 2}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
}
describe('pnpm diff format', () => {
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
expect(PNPM_DIFF_FLAGS).toEqual([
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
])
})
it('blanks the config-bearing environment variables', () => {
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
expect(environment).toMatchObject({
PATH: '/usr/bin',
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
})
})
it('strips both scratch folder prefixes from headers and index lines', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(patch).not.toContain(root)
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
expect(patch).toContain('--- a/src/Widget.ts')
expect(patch).toContain('+++ b/src/Widget.ts')
})
it('drops a trailing no-newline marker and .DS_Store entries', () => {
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
)
const withJunk = [
'diff --git a/.DS_Store b/.DS_Store\n',
'index 000..111\n',
'Binary files differ\n',
'diff --git a/lib/x.js b/lib/x.js\n',
'@@ -1 +1 @@\n-a\n+b\n'
].join('')
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
)
})
})
describe('patch entry splitting', () => {
it('separates hand-edited source hunks from generated bundle hunks', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map',
'src/Widget.ts'
])
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
'src/Widget.ts'
])
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map'
])
})
it('rejects renames rather than emitting a header it cannot round-trip', () => {
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
/renames are not supported/
)
})
it('concatenating the two halves reproduces the whole patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
})
})
describe('round-trip stability', () => {
it('re-diffing an applied patch yields the identical patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
const patchFile = path.join(root, 'round-trip.patch')
await writeFile(patchFile, patch)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PATCHED['lib/widget.js']
)
expect(diffFolders(folderA, replay)).toBe(patch)
})
it('applying only the source half leaves the bundle untouched', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patchFile = path.join(root, 'src.patch')
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
PATCHED['src/Widget.ts']
)
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PRISTINE['lib/widget.js']
)
})
})
// The source patch is derived from the upstream checkout, not from the emitted
// patch, so a hunk against an unpublished file survives `--write` instead of
// deleting itself on the next run.
describe('unpublished source files', () => {
const publishedEntry = [
'diff --git a/src/browser/Types.ts b/src/browser/Types.ts',
'index 1111111..2222222 100644',
'--- a/src/browser/Types.ts',
'+++ b/src/browser/Types.ts',
'@@ -1 +1,2 @@',
' interface ICompositionHelper {',
'+ handleCompositionInput(data: string): boolean;',
''
].join('\n')
const unpublishedEntry = [
'diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts',
'index 3333333..4444444 100644',
'--- a/src/browser/TestUtils.test.ts',
'+++ b/src/browser/TestUtils.test.ts',
'@@ -1 +1,2 @@',
' class MockCompositionHelper {',
'+ public handleCompositionInput(): boolean { return false; }',
''
].join('\n')
const published = new Set(['src/browser/Types.ts'])
it('accepts a source diff that carries an extra unpublished file', () => {
expect(() =>
assertSourceDerivationsAgree(publishedEntry + unpublishedEntry, publishedEntry, published)
).not.toThrow()
})
it('fails when the two derivations disagree on a published file', () => {
expect(() =>
assertSourceDerivationsAgree(
publishedEntry.replace('boolean;', 'void;'),
publishedEntry,
published
)
).toThrow(/disagree on a published source file/)
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
})
describe('manifest guards', () => {
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
it('accepts a tarball that names the pinned commit', () => {
const published = { version: '6.1.0-beta.287', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
})
it('fails when a version bump moved the upstream commit', () => {
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
/was published from commit[\s\S]*Update upstream\.commit/
)
})
it('fails when the registry serves a different version than the manifest pins', () => {
const published = { version: '6.1.0-beta.288', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
})
it('fails when the tarball carries no commit stamp at all', () => {
expect(() =>
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
).toThrow(/\(absent\)/)
})
it('refuses a build step that would de-minify the bundle', () => {
const manifest = {
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
packages: [
{
name: '@xterm/xterm',
build: [
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
]
}
]
}
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
})
it('refuses a sourcemap policy it does not implement', () => {
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'delete' } })).toBe('delete')
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'include' } })).toBe('include')
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'exclude' } })).toThrow(
/must be one of include, delete, got "exclude"/
)
expect(() => assertSourcemapPolicy({})).toThrow(/got undefined/)
})
it('stamps the published version into the version source', () => {
const source = "export const XTERM_VERSION = '6.0.0';\n"
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
)
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
})
})
describe('lockfile coupling', () => {
const lockfile = [
'patchedDependencies:',
" '@xterm/xterm@6.1.0-beta.287':",
` hash: ${'0'.repeat(64)}`,
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
' node-pty@1.1.0:',
` hash: ${'1'.repeat(64)}`,
' path: config/patches/node-pty@1.1.0.patch',
'snapshots:',
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
` node-pty@1.1.0(patch_hash=${'1'.repeat(64)}):`,
''
].join('\n')
it('hashes the patch the way pnpm keys the store directory', () => {
expect(patchHash('diff --git a/x b/x\n')).toBe(
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
)
})
it('reads quoted and unquoted package keys', () => {
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
})
it('rewrites only the targeted entry', () => {
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
})
// pnpm repeats the hash in every resolution key. Rewriting only patchedDependencies
// installs fine on a warm store and drifts on a cold one, so it fails in CI only.
it('rewrites the resolution keys as well as patchedDependencies', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
expect(readLockfileResolutionHashes(lockfile, key)).toEqual(['0'.repeat(64), '0'.repeat(64)])
const updated = updateLockfilePatchHash(lockfile, key, 'a'.repeat(64))
expect(readLockfileResolutionHashes(updated, key)).toEqual(['a'.repeat(64), 'a'.repeat(64)])
expect(readLockfileResolutionHashes(updated, 'node-pty@1.1.0')).toEqual(['1'.repeat(64)])
expect(updated).not.toContain('0'.repeat(64))
})
it('reports a lockfile stale in its resolution keys alone', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
expect(lockfilePatchHashIsStale(lockfile, key, '0'.repeat(64))).toBe(false)
})
it('fails loudly when the package is not patched at all', () => {
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
/no patchedDependencies entry/
)
})
})
describe('check-mode reporting', () => {
it('points at the source patch instead of the bundle', () => {
const message = formatCheckFailure({
name: '@xterm/xterm',
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
})
expect(message).toContain('Do not edit them')
expect(message).toContain('--write')
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
expect(message).toContain('files [lib/x.js]')
})
it('locates the first differing character', () => {
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
})
})
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const key = `${packageEntry.name}@${packageEntry.version}`
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
}
})
it('keeps the source patch equal to the full patch on every published file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
expect(selectPatchEntries(source, (file) => published.has(file))).toBe(sourceHunks(patch))
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
}
})
// Why: the source patch is deliberately a superset. Anything extra must be a
// file upstream's .npmignore strips, because a hunk against a published file
// that never reached the shipped patch would be a hunk that is not installed.
it('only exceeds the full patch on files the registry does not publish', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
const unpublished = splitPatchEntries(source)
.map((entry) => entry.path)
.filter((file) => !published.has(file))
for (const file of unpublished) {
expect(file, `${file} is not an unpublished test file`).toMatch(/\.test\.ts$/)
}
}
})
it('pins a full upstream commit and a buildable package entry', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
expect(manifest.packages.length).toBeGreaterThan(0)
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
})
})
@@ -1,306 +0,0 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,488 @@
import { spawn, spawnSync } from 'node:child_process'
import {
closeSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
writeFileSync
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
const inputFramework = process.env.ORCA_E2E_NATIVE_IME ?? 'ibus'
const displayServer = process.env.ORCA_E2E_NATIVE_DISPLAY_SERVER ?? 'x11'
const isWayland = displayServer === 'wayland'
if (!['ibus', 'fcitx5'].includes(inputFramework)) {
throw new Error(`Unsupported native IME framework: ${inputFramework}`)
}
if (!['wayland', 'x11'].includes(displayServer)) {
throw new Error(`Unsupported native display server: ${displayServer}`)
}
if (isWayland && inputFramework !== 'fcitx5') {
throw new Error('Native Wayland coverage currently requires Fcitx5')
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
function configureFcitxProfile(evidenceDir) {
const fcitxConfigDir = path.join(evidenceDir, 'config', 'fcitx5')
mkdirSync(fcitxConfigDir, { recursive: true })
writeFileSync(
path.join(fcitxConfigDir, 'profile'),
`[Groups/0]
Name=Default
Default Layout=us
DefaultIM=hangul
[Groups/0/Items/0]
Name=keyboard-us
Layout=
[Groups/0/Items/1]
Name=hangul
Layout=
[Groups/0/Items/2]
Name=pinyin
Layout=
[GroupOrder]
0=Default
`
)
}
async function waitForIbusEngine(ibusProcess, engine) {
const busDeadline = Date.now() + 15_000
while (Date.now() < busDeadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' })
if (result.status === 0) {
break
}
await delay(100)
}
spawnSync('ibus', ['engine', engine], { stdio: 'pipe' })
const engineDeadline = Date.now() + 15_000
while (Date.now() < engineDeadline) {
const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' })
if (result.status === 0 && result.stdout.trim() === engine) {
return
}
await delay(100)
}
throw new Error(`Timed out while selecting the IBus ${engine} engine`)
}
async function waitForFcitx(fcitxProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (fcitxProcess.exitCode !== null) {
throw new Error(`fcitx5 exited early with code ${fcitxProcess.exitCode}`)
}
const owner = spawnSync(
'gdbus',
[
'call',
'--session',
'--dest',
'org.freedesktop.DBus',
'--object-path',
'/org/freedesktop/DBus',
'--method',
'org.freedesktop.DBus.NameHasOwner',
'org.fcitx.Fcitx5'
],
{ encoding: 'utf8' }
)
if (owner.status === 0 && owner.stdout.includes('true')) {
for (const engine of ['hangul', 'pinyin']) {
const addon = spawnSync('fcitx5-remote', ['-m', engine], { encoding: 'utf8' })
if (addon.status !== 0 || addon.stdout.trim().length === 0) {
throw new Error(`Fcitx5 input method is unavailable: ${engine}`)
}
}
return
}
await delay(100)
}
throw new Error('Timed out while starting Fcitx5')
}
async function waitForWaylandCompositor(compositorProcess) {
const runtimeDir = process.env.XDG_RUNTIME_DIR
const display = process.env.WAYLAND_DISPLAY
if (!runtimeDir || !display) {
throw new Error('Native Wayland coverage requires XDG_RUNTIME_DIR and WAYLAND_DISPLAY')
}
const socketPath = path.isAbsolute(display) ? display : path.join(runtimeDir, display)
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (compositorProcess.exitCode !== null) {
throw new Error(`sway exited early with code ${compositorProcess.exitCode}`)
}
if (existsSync(socketPath)) {
return
}
await delay(100)
}
throw new Error(`Timed out while waiting for Wayland socket: ${socketPath}`)
}
async function runInsideSession(evidenceDir) {
const inputMethodLogPath = path.join(evidenceDir, `${inputFramework}-daemon.log`)
const inputMethodLogFd = openSync(inputMethodLogPath, 'w')
const windowManagerName = isWayland ? 'sway' : 'xfwm4'
const displayEvidenceSuffix = isWayland ? '-wayland' : ''
const windowManagerLogPath = path.join(evidenceDir, `${windowManagerName}.log`)
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
displayServer,
inputFramework,
inputMethodDaemonPid: null,
inputMethodGroupBeforeCleanup: [],
inputMethodGroupAfterCleanup: [],
playwrightPid: null,
swaySocket: null,
waylandDisplay: process.env.WAYLAND_DISPLAY ?? null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let inputMethodProcess
let windowManagerProcess
let testExitCode = 1
try {
if (inputFramework === 'ibus') {
configureHangulEngine()
} else {
configureFcitxProfile(evidenceDir)
}
windowManagerProcess = spawn(
windowManagerName,
isWayland ? ['-c', '/dev/null'] : ['--compositor=off'],
{
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
}
)
if (!windowManagerProcess.pid) {
throw new Error(`${windowManagerName} did not return a PID`)
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started ${windowManagerName} PID ${windowManagerProcess.pid}`)
if (isWayland) {
process.env.SWAYSOCK = path.join(
process.env.XDG_RUNTIME_DIR,
`sway-ipc.${process.getuid()}.${windowManagerProcess.pid}.sock`
)
evidence.swaySocket = process.env.SWAYSOCK
await waitForWaylandCompositor(windowManagerProcess)
}
const inputMethodCommand = inputFramework === 'ibus' ? 'ibus-daemon' : 'fcitx5'
const inputMethodArgs =
inputFramework === 'ibus'
? ['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']
: isWayland
? []
: ['--disable=wayland']
inputMethodProcess = spawn(inputMethodCommand, inputMethodArgs, {
detached: true,
env: process.env,
stdio: ['ignore', inputMethodLogFd, inputMethodLogFd]
})
if (!inputMethodProcess.pid) {
throw new Error(`${inputMethodCommand} did not return a PID`)
}
evidence.inputMethodDaemonPid = inputMethodProcess.pid
console.error(`[terminal-ime] started ${inputMethodCommand} PID ${inputMethodProcess.pid}`)
if (inputFramework === 'ibus') {
await waitForIbusEngine(inputMethodProcess, 'hangul')
await waitForIbusEngine(inputMethodProcess, 'libpinyin')
await waitForIbusEngine(inputMethodProcess, 'hangul')
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
} else {
await waitForFcitx(inputMethodProcess)
console.error(`[terminal-ime] Fcitx5 version: ${commandOutput('fcitx5', ['--version'])}`)
}
evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid)
console.error(
`[terminal-ime] owned ${inputFramework} group: ${evidence.inputMethodGroupBeforeCleanup.join('; ')}`
)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-linux-ime-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IME: inputFramework
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (inputMethodProcess?.pid) {
evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid)
evidence.inputMethodGroupAfterCleanup = await stopOwnedProcessGroup(inputMethodProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(inputMethodLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
inputMethodLogPath,
path.join(projectDir, 'test-results', `terminal-${inputFramework}-native-daemon.log`)
)
copyFileSync(
windowManagerLogPath,
path.join(
projectDir,
'test-results',
`terminal-${inputFramework}-native${displayEvidenceSuffix}-${windowManagerName}.log`
)
)
writeFileSync(
path.join(
projectDir,
'test-results',
`terminal-${inputFramework}-native${displayEvidenceSuffix}-processes.json`
),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.inputMethodGroupAfterCleanup.length > 0) {
throw new Error(
`Owned ${inputFramework} processes survived cleanup: ${evidence.inputMethodGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native Linux IME E2E runner requires Linux')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionCommand = isWayland ? 'dbus-run-session' : 'xvfb-run'
const sessionArgs = isWayland
? ['--', process.execPath, scriptPath, insideSessionFlag, evidenceDir]
: [
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
]
const {
DISPLAY: _display,
GTK_IM_MODULE: _gtkImModule,
QT_IM_MODULE: _qtImModule,
XMODIFIERS: _xModifiers,
...waylandBaseEnv
} = process.env
void _display
void _gtkImModule
void _qtImModule
void _xModifiers
const sessionProcess = spawn(sessionCommand, sessionArgs, {
cwd: projectDir,
detached: true,
env: {
...(isWayland ? waylandBaseEnv : process.env),
...(isWayland
? {
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
WAYLAND_DISPLAY: 'wayland-1',
WLR_BACKENDS: 'headless',
WLR_HEADLESS_OUTPUTS: '1',
WLR_LIBINPUT_NO_DEVICES: '1',
XDG_SESSION_TYPE: 'wayland'
}
: {
GTK_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus',
...(inputFramework === 'ibus' ? { IBUS_ENABLE_SYNC_MODE: '1' } : {}),
QT_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus',
XMODIFIERS: inputFramework === 'fcitx5' ? '@im=fcitx' : '@im=ibus'
}),
LANG: process.env.LANG || 'C.UTF-8',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir
},
stdio: 'inherit'
})
if (!sessionProcess.pid) {
throw new Error(`${sessionCommand} did not return a PID`)
}
console.error(
`[terminal-ime] started isolated ${displayServer} session PID ${sessionProcess.pid}`
)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(
`Owned ${displayServer} session processes survived cleanup: ${remaining.join('; ')}`
)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -9,6 +9,7 @@ describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
const linuxJob = workflow.jobs.linux
it('runs only on schedule or manual dispatch', () => {
expect(workflow.on.pull_request).toBeUndefined()
@@ -16,14 +17,18 @@ describe('terminal IME e2e workflow', () => {
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
it('installs native IBus and Fcitx5 engines with X11 input tools', () => {
const runs = linuxJob.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('ibus-libpinyin')
expect(installRun).toContain('fcitx5-chinese-addons')
expect(installRun).toContain('fcitx5-frontend-gtk3')
expect(installRun).toContain('fcitx5-hangul')
expect(installRun).toContain('sway')
expect(installRun).toContain('wtype')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
@@ -32,36 +37,61 @@ describe('terminal IME e2e workflow', () => {
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
it('pins X11 to Ubuntu 22.04 and Wayland to a current wlroots stack', () => {
expect(linuxJob.strategy.matrix.include).toEqual([
{ label: 'X11', os: 'ubuntu-22.04', display_server: 'x11' },
{ label: 'Wayland', os: 'ubuntu-24.04', display_server: 'wayland' }
])
})
it('keeps IBus lifecycle scoped to owned processes', () => {
it('runs both native framework suites before deterministic boundaries', () => {
const steps = linuxJob.steps
const deterministicIndex = steps.findIndex((step) =>
step.run?.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndexes = steps
.map((step, index) => (step.run?.includes('test:e2e:terminal-ime-native') ? index : -1))
.filter((index) => index >= 0)
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndexes).toHaveLength(3)
expect(nativeIndexes.every((index) => deterministicIndex > index)).toBe(true)
expect(steps.some((step) => step.with?.name === 'terminal-ime-native-evidence')).toBe(true)
expect(steps.some((step) => step.with?.name === 'terminal-ime-native-fcitx5-evidence')).toBe(
true
)
expect(
steps.some((step) => step.with?.name === 'terminal-ime-native-fcitx5-wayland-evidence')
).toBe(true)
})
it('keeps native input framework lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
join(projectDir, 'config/scripts/run-terminal-linux-ime-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("isWayland ? ['-c', '/dev/null'] : ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("await waitForIbusEngine(inputMethodProcess, 'libpinyin')")
expect(runner).toContain("inputFramework === 'ibus' ? 'ibus-daemon' : 'fcitx5'")
expect(runner).toContain("['--disable=wayland']")
expect(runner).toContain("WLR_BACKENDS: 'headless'")
expect(runner).toContain("WLR_LIBINPUT_NO_DEVICES: '1'")
expect(runner).toContain("for (const engine of ['hangul', 'pinyin'])")
expect(runner).toContain("'org.freedesktop.DBus.NameHasOwner'")
expect(runner).toContain("'org.fcitx.Fcitx5'")
expect(runner).not.toContain("['--check']")
expect(runner.match(/spawnSync\('ibus', \['engine', engine\]/g)).toHaveLength(1)
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
/'test:e2e:headful',[\s\S]*'--workers=1',[\s\S]*'--',[\s\S]*'tests\/e2e\/terminal-linux-ime-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
@@ -70,10 +100,11 @@ describe('terminal IME e2e workflow', () => {
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
join(projectDir, 'tests/e2e/terminal-linux-ime-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(9)
expect(nativeSpec).toContain('{ timeout: 20_000 }')
})
})
@@ -251,6 +251,7 @@ async function runValidation(mode) {
app.evaluate(({ app: electronApp }) => ({
disableGpuSandbox: electronApp.commandLine.hasSwitch('disable-gpu-sandbox'),
disableGpu: electronApp.commandLine.hasSwitch('disable-gpu'),
enableWaylandIme: electronApp.commandLine.hasSwitch('enable-wayland-ime'),
ozonePlatform: electronApp.commandLine.getSwitchValue('ozone-platform'),
enableFeatures: electronApp.commandLine.getSwitchValue('enable-features')
})),
@@ -277,6 +278,9 @@ async function runValidation(mode) {
if (mode === 'verify-fix' && commandLineSwitches.disableGpu) {
throw new Error('Expected hardware acceleration to remain enabled, but --disable-gpu is set.')
}
if (mode === 'verify-fix' && !commandLineSwitches.enableWaylandIme) {
throw new Error('Expected --enable-wayland-ime on Linux Wayland, but it was absent.')
}
logPhase('window.first')
page = await runWithTimeout('first renderer window', () => app.firstWindow(), timeoutMs)
+229
View File
@@ -0,0 +1,229 @@
# xterm Patch Regeneration
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ITerminal` surface those events widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
generated, and this document exists so nobody edits it by hand.
The source patch carries a fifth file, `src/browser/TestUtils.test.ts`, which
the shipped patch does not and cannot; see
[The Source Patch Is a Superset](#the-source-patch-is-a-superset).
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
3. Every source change must land together with the regenerated bundle hunks and
the `pnpm-lock.yaml` hash bump, in one commit.
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
comment. A version bump that leaves it stale fails the generator, it does not
silently patch the wrong tree.
5. Sourcemaps are deleted, not patched and not silently omitted. The patch moves
the bundle, so a retained map would have to move with it; dropping only the
map hunks ships offsets that point at the wrong code. Deletion is the honest
form of that saving, and `sourcemaps.policy` in the manifest controls it.
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
# 3. Reinstall so node_modules picks up the new patch hash.
pnpm install
# 4. Confirm the tree is self-consistent.
node config/scripts/regenerate-xterm-patches.mjs --check
```
Editing a patch file by hand is awkward for anything larger than a one-liner.
For a substantial change, work in the generator's own checkout instead — after
any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
each published tarball names the commit that built it. The generator asserts
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
against the checkout file by file. Only `src/common/Version.ts` may differ,
because `publish.js` rewrites the version immediately before packaging; the
generator applies the same stamp.
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
commit would still produce a plausible-looking 7 MB patch.
## The Source Patch Is a Superset
Patching `ICompositionHelper` widens an interface, so every implementor has to
follow — including `MockCompositionHelper` in upstream's
`src/browser/TestUtils.test.ts`. Without that hunk the patched checkout does not
type-check and `npm run package` never reaches webpack, so the generator cannot
build the patched bundles at all.
Upstream's `.npmignore` strips `*.test.ts`, so that file is not in the published
tarball. The shipped patch is a diff against the published tarball, and it
therefore *cannot* name the file — correctly, since pnpm has nothing there to
patch.
That is why the source patch is derived from the upstream checkout
(`git diff -- src/`) and not from the emitted patch. Deriving it from the
emitted patch is the trap: `--write` would filter the hunk out through the
published file set and delete it, so the fix that makes the build work would
erase itself on the first run that used it.
The two derivations are still cross-checked. `assertSourceDerivationsAgree`
requires them to be byte-identical on every file the tarball publishes, so the
carve-out stays confined to files upstream does not ship rather than becoming a
place where the source patch and the shipped patch can quietly disagree. The
checkout diff uses pnpm's own formatting flags minus `--no-index`, which is what
makes that byte comparison meaningful.
## Build Order
Upstream's publish path is `npm ci` → stamp `Version.ts``npm run package`.
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
50% larger than the published one, which is easy to miss inside a 7 MB diff.
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
The generator also builds the *unmodified* commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
## The Lockfile Moves With the Patch
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
the sha256 of the patch file itself. A regenerated patch without the lockfile
bump fails `pnpm install --frozen-lockfile` on every machine except the
author's. `--write` makes that edit; `--check` fails if it is missing.
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
without a network or a build, so the ordinary test job catches lockfile drift
in milliseconds even though the full rebuild runs in its own CI lane.
## Toolchain Pin
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
at the pinned commit, and the generator fails if `npm ci` produces something
else. The entry that matters is `@typescript/native-preview`
(`tsgo`), which upstream pins to a **dated development build**
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
and npm does not prune old releases, but it is the one dependency of this scheme
that is not a stable release.
If that version ever becomes unresolvable the generator fails with a toolchain
error naming it. Recovery is to move the pin to the next upstream commit whose
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Version Bumps
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
2. Rename both patch files to the new version and update `patch`,
`sourcePatch`, and `version` in `xterm-upstream.json`.
3. Update `upstream.commit` to the `commit` field of the new tarball's
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
rerun. The bundle hunks need no attention at any point.
## Why Not Vendor a Fork
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
the published package, so every upstream beta becomes a merge rather than a
version bump, and Orca inherits responsibility for building and publishing a
package it does not own. The patch is four small source hunks against a commit
that reproduces byte for byte; a fork is a much larger standing cost for the
same result.
## Why Not Handle Composition at Runtime
`CompositionHelper` hooks four private call sites upstream of `onData`, and
`SortedList` has no public surface at all. There is no supported extension point
that reaches either, so a runtime shim would mean reaching into `_core`
internals that upstream renames freely between betas. The patch is the smaller
risk.
## CI Contract
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
twice, and byte-compares the result against the committed patch. A warm run is
about eight seconds of work around the clone and install.
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
commit and build-order assertions, and lockfile coupling — with no network and
no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` now avoids for `@xterm/xterm`
and which folding them into this manifest would also fix. Both addons build from
the same pinned commit and reproduce
byte for byte, so they can be folded into this manifest as additional `packages`
entries; that change needs e2e sign-off because, unlike `@xterm/xterm`, it will
not be a byte-for-byte no-op.
+14 -4
View File
@@ -115,6 +115,10 @@ import {
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import {
TerminalLiveInputField,
type TerminalLiveInputFieldHandle
} from '../../../../src/terminal/terminal-live-input-field'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
clearTerminalLiveInputFocusTimer,
@@ -127,7 +131,9 @@ import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { imeGuardedSubmitProps } from '../../../../src/ime/ime-submit-carry'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { useTerminalLiveInputPreedit } from '../../../../src/terminal/use-terminal-live-input-preedit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
buildTerminalSendParams,
@@ -1030,7 +1036,7 @@ export default function SessionScreen() {
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const liveInputRef = useRef<TerminalLiveInputFieldHandle>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
@@ -1091,9 +1097,12 @@ export default function SessionScreen() {
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
platform: Platform.OS,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const { isComposing: liveInputComposing, handleLiveInputChangeWithPreedit } =
useTerminalLiveInputPreedit(handleLiveInputChange)
const { canCompose, canSend } = resolveMobileTerminalInputGate({
connState,
activeHandle,
@@ -4987,6 +4996,7 @@ export default function SessionScreen() {
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
composingText={liveInputComposing ? liveInputCapture : ''}
/>
</Pressable>
<MobileTerminalInputActions
@@ -5004,11 +5014,11 @@ export default function SessionScreen() {
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<TextInput
<TerminalLiveInputField
ref={liveInputRef}
style={styles.liveInputCapture}
value={liveInputCapture}
onChangeText={handleLiveInputChange}
onChange={handleLiveInputChangeWithPreedit}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
@@ -5057,7 +5067,7 @@ export default function SessionScreen() {
returnKeyType="send"
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
{...imeGuardedSubmitProps(Platform.OS, () => void handleSend())}
/>
<MobileTerminalInputActions
canSend={canSend}
+2 -1
View File
@@ -32,6 +32,7 @@ import {
Send,
X
} from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../../../src/ime/ime-submit-carry'
import type { RpcClient } from '../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
@@ -10835,7 +10836,7 @@ export default function MobileTasksScreen() {
autoCorrect={false}
secureTextEntry
editable={linearConnectState !== 'connecting'}
onSubmitEditing={() => void connectLinearAccount()}
{...imeGuardedSubmitProps(Platform.OS, () => void connectLinearAccount())}
/>
{linearConnectState === 'error' && linearConnectError ? (
<Text style={styles.detailError}>{linearConnectError}</Text>
+8 -3
View File
@@ -20,10 +20,11 @@
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@orca/react-native-terminal-input": "file:./packages/react-native-terminal-input",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"@xterm/addon-unicode11": "0.10.0-beta.287",
"@xterm/addon-webgl": "0.20.0-beta.286",
"@xterm/xterm": "6.1.0-beta.287",
"buffer": "^6.0.3",
"expo": "^55.0.27",
"expo-build-properties": "^55.0.13",
@@ -85,6 +86,10 @@
"pnpm": {
"overrides": {
"xcode>uuid": "11.1.1"
},
"patchedDependencies": {
"react-native@0.83.9": "patches/react-native@0.83.9.patch",
"@xterm/xterm@6.1.0-beta.287": "../config/patches/@xterm__xterm@6.1.0-beta.287.patch"
}
}
}
@@ -0,0 +1,16 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace 'com.orca.terminalinput'
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
}
}
dependencies {
implementation 'com.facebook.react:react-android'
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,250 @@
package com.orca.terminalinput
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.view.ViewGroup
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.ViewManager
import com.facebook.react.uimanager.events.Event
import com.facebook.react.views.textinput.ReactEditText
import com.facebook.react.views.textinput.ReactTextInputManager
class TerminalInputPackage : ReactPackage {
override fun createViewManagers(
reactContext: ReactApplicationContext
): List<ViewManager<*, *>> = listOf(TerminalInputManager())
}
private class TerminalInputManager : ReactTextInputManager() {
override fun getName(): String = "OrcaTerminalInput"
override fun createViewInstance(context: ThemedReactContext): ReactEditText {
val editText = TerminalReactEditText(context)
editText.inputType = editText.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv()
editText.returnKeyType = "done"
editText.layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
return editText
}
override fun addEventEmitters(reactContext: ThemedReactContext, editText: ReactEditText) {
super.addEventEmitters(reactContext, editText)
val terminalEditText = editText as TerminalReactEditText
terminalEditText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {
terminalEditText.dispatchTextChange(text, start, before, count)
}
override fun afterTextChanged(text: Editable) = Unit
}
)
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> {
val events = super.getExportedCustomDirectEventTypeConstants().orEmpty().toMutableMap()
events[TerminalInputEvent.NAME] = mapOf("registrationName" to "onTerminalInput")
return events
}
}
private class TerminalReactEditText(
private val reactContext: ThemedReactContext,
) : ReactEditText(reactContext) {
private var mutationIsComposing: Boolean? = null
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val connection = super.onCreateInputConnection(outAttrs) ?: return null
return TerminalInputConnection(connection, this)
}
fun dispatchTextChange(text: CharSequence, start: Int, before: Int, count: Int) {
if (before == 0 && count == 0) return
if (mutationIsComposing != null) return
val end = start + count
if (start < 0 || end < start || end > text.length) return
dispatch(text.toString(), text.subSequence(start, end).toString(), start, start + before)
}
fun mutateInput(
isComposing: Boolean,
replacementText: String,
range: Pair<Int, Int>?,
mutation: () -> Boolean,
): Boolean {
mutationIsComposing = isComposing
return try {
val consumed = mutation()
if (consumed && range != null) {
text?.toString()?.let { dispatch(it, replacementText, range.first, range.second) }
}
consumed
} finally {
mutationIsComposing = null
}
}
fun replacementRange(): Pair<Int, Int>? {
composingRange()?.let { return it }
val start = selectionStart
val end = selectionEnd
return if (start >= 0 && end >= 0) Pair(minOf(start, end), maxOf(start, end)) else null
}
fun composingRange(): Pair<Int, Int>? {
val currentText = text ?: return null
val start = BaseInputConnection.getComposingSpanStart(currentText)
val end = BaseInputConnection.getComposingSpanEnd(currentText)
return if (start >= 0 && end >= start) Pair(start, end) else null
}
fun deletionReplacement(
beforeLength: Int,
afterLength: Int,
inCodePoints: Boolean,
): Triple<Int, Int, String>? {
if (beforeLength < 0 || afterLength < 0) return null
val currentText = text?.toString() ?: return null
val orderedSelectionStart = minOf(selectionStart, selectionEnd)
val orderedSelectionEnd = maxOf(selectionStart, selectionEnd)
if (orderedSelectionStart < 0 || orderedSelectionEnd > currentText.length) return null
val composing = composingRange()
val retainedStart = minOf(orderedSelectionStart, composing?.first ?: orderedSelectionStart)
val retainedEnd = maxOf(orderedSelectionEnd, composing?.second ?: orderedSelectionEnd)
val beforeStart =
if (inCodePoints) {
val available = Character.codePointCount(currentText, 0, retainedStart)
Character.offsetByCodePoints(currentText, retainedStart, -minOf(beforeLength, available))
} else {
maxOf(0, retainedStart - beforeLength)
}
val afterEnd =
if (inCodePoints) {
val available = Character.codePointCount(currentText, retainedEnd, currentText.length)
Character.offsetByCodePoints(currentText, retainedEnd, minOf(afterLength, available))
} else {
minOf(currentText.length, retainedEnd + afterLength)
}
return Triple(
beforeStart,
afterEnd,
currentText.substring(retainedStart, retainedEnd),
)
}
private fun dispatch(text: String, replacementText: String, start: Int, end: Int) {
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
dispatcher.dispatchEvent(
TerminalInputEvent(
UIManagerHelper.getSurfaceId(this),
id,
text,
mutationIsComposing ?: false,
replacementText,
start,
end,
)
)
}
}
private class TerminalInputConnection(
target: InputConnection,
private val editText: TerminalReactEditText,
) : InputConnectionWrapper(target, false) {
override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean {
val range = editText.replacementRange()
val consumed = editText.mutateInput(true, text.toString(), range) {
super.setComposingText(text, newCursorPosition)
}
// Let React Native deliver its synthetic Backspace while the IME still owns it.
if (text.isEmpty() && consumed) {
editText.post {
if (editText.composingRange() == null) editText.mutateInput(false, "", range) { true }
}
}
return consumed
}
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
return editText.mutateInput(false, text.toString(), editText.replacementRange()) {
super.commitText(text, newCursorPosition)
}
}
override fun finishComposingText(): Boolean {
val range = editText.composingRange() ?: return super.finishComposingText()
val replacementText = editText.text?.subSequence(range.first, range.second)?.toString()
?: return super.finishComposingText()
return editText.mutateInput(false, replacementText, range) { super.finishComposingText() }
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
val replacement = editText.deletionReplacement(beforeLength, afterLength, false)
return editText.mutateInput(
editText.composingRange() != null,
replacement?.third ?: "",
replacement?.let { Pair(it.first, it.second) },
) {
super.deleteSurroundingText(beforeLength, afterLength)
}
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
val replacement = editText.deletionReplacement(beforeLength, afterLength, true)
return editText.mutateInput(
editText.composingRange() != null,
replacement?.third ?: "",
replacement?.let { Pair(it.first, it.second) },
) {
super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
}
}
private class TerminalInputEvent(
surfaceId: Int,
viewId: Int,
private val text: String,
private val isComposing: Boolean,
private val replacementText: String,
private val replacementStart: Int,
private val replacementEnd: Int,
) : Event<TerminalInputEvent>(surfaceId, viewId) {
override fun getEventName(): String = NAME
override fun canCoalesce(): Boolean = false
override fun getEventData(): WritableMap =
Arguments.createMap().apply {
putString("text", text)
putBoolean("isComposing", isComposing)
putString("replacementText", replacementText)
putMap(
"replacementRange",
Arguments.createMap().apply {
putInt("start", replacementStart)
putInt("end", replacementEnd)
},
)
}
companion object {
const val NAME = "topTerminalInput"
}
}
@@ -0,0 +1,8 @@
{
"name": "@orca/react-native-terminal-input",
"version": "0.0.1",
"private": true,
"peerDependencies": {
"react-native": "*"
}
}
@@ -0,0 +1,12 @@
module.exports = {
dependency: {
platforms: {
android: {
sourceDir: './android',
packageImportPath: 'import com.orca.terminalinput.TerminalInputPackage;',
packageInstance: 'new TerminalInputPackage()'
},
ios: null
}
}
}
+120
View File
@@ -0,0 +1,120 @@
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
index d02e003a439e410ddab597b2c036256c80db0395..304b4aa81e6257d5854a41757bcf24915eb774d4 100644
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
@@ -43,6 +43,9 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
UIView<RCTBackedTextInputViewProtocol> *_backedTextInputView;
NSUInteger _mostRecentEventCount;
NSAttributedString *_lastStringStateWasUpdatedWith;
+ NSString *_pendingReplacementText;
+ NSRange _pendingReplacementRange;
+ BOOL _hasPendingReplacement;
/*
* UIKit uses either UITextField or UITextView as its UIKit element for <TextInput>. UITextField is for single line
@@ -439,6 +442,10 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
- (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
{
+ _pendingReplacementText = text;
+ _pendingReplacementRange = range;
+ _hasPendingReplacement = YES;
+
const auto &props = static_cast<const TextInputProps &>(*_props);
if (!_backedTextInputView.textWasPasted) {
@@ -495,6 +502,10 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
const auto &textInputEventEmitter = static_cast<const TextInputEventEmitter &>(*_eventEmitter);
textInputEventEmitter.onChange([self _textInputMetrics]);
}
+ if (_backedTextInputView.markedTextRange == nil) {
+ _pendingReplacementText = nil;
+ _hasPendingReplacement = NO;
+ }
}
- (void)textInputDidChangeSelection
@@ -516,6 +527,11 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
if (_eventEmitter) {
static_cast<const TextInputEventEmitter &>(*_eventEmitter).onSelectionChange([self _textInputMetrics]);
+ if (_hasPendingReplacement && _backedTextInputView.markedTextRange == nil) {
+ static_cast<const TextInputEventEmitter &>(*_eventEmitter).onChange([self _textInputMetrics]);
+ _pendingReplacementText = nil;
+ _hasPendingReplacement = NO;
+ }
}
}
@@ -710,6 +726,15 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
.contentSize = RCTSizeFromCGSize(_backedTextInputView.contentSize),
.layoutMeasurement = RCTSizeFromCGSize(_backedTextInputView.bounds.size),
.zoomScale = _backedTextInputView.zoomScale,
+ .isComposing = _backedTextInputView.markedTextRange != nil,
+ .replacementText = _hasPendingReplacement
+ ? std::optional<std::string>(RCTStringFromNSString(_pendingReplacementText))
+ : std::nullopt,
+ .replacementRange = _hasPendingReplacement
+ ? std::optional<AttributedString::Range>(
+ {.location = static_cast<int>(_pendingReplacementRange.location),
+ .length = static_cast<int>(_pendingReplacementRange.length)})
+ : std::nullopt,
};
}
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
index a9bc219f8bf729111e907423ca61b991fd712b46..0f6ec4299134f13d5a06467ce540e8ac350be03a 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
@@ -24,6 +24,28 @@ static jsi::Value textInputMetricsPayload(
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
+ if (textInputMetrics.isComposing.has_value()) {
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
+ }
+
+ if (textInputMetrics.replacementText.has_value() &&
+ textInputMetrics.replacementRange.has_value()) {
+ payload.setProperty(
+ runtime,
+ "replacementText",
+ jsi::String::createFromUtf8(
+ runtime, *textInputMetrics.replacementText));
+ auto replacementRange = jsi::Object(runtime);
+ replacementRange.setProperty(
+ runtime, "start", textInputMetrics.replacementRange->location);
+ replacementRange.setProperty(
+ runtime,
+ "end",
+ textInputMetrics.replacementRange->location +
+ textInputMetrics.replacementRange->length);
+ payload.setProperty(runtime, "replacementRange", replacementRange);
+ }
+
if (includeSelectionState) {
auto selection = jsi::Object(runtime);
selection.setProperty(
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..f2ad25d9f5d6260c063c8790a682b5622c2e11fa 100644
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
@@ -7,6 +7,8 @@
#pragma once
+#include <optional>
+
#include <react/renderer/attributedstring/AttributedString.h>
#include <react/renderer/components/view/ViewEventEmitter.h>
@@ -28,6 +30,9 @@ class TextInputEventEmitter : public ViewEventEmitter {
Size layoutMeasurement;
Float zoomScale;
Tag target;
+ std::optional<bool> isComposing;
+ std::optional<std::string> replacementText;
+ std::optional<AttributedString::Range> replacementRange;
};
struct KeyPressMetrics {
+353 -386
View File
File diff suppressed because it is too large Load Diff
@@ -113,13 +113,19 @@ export function handleMockTerminalRequest(
return true
}
case 'terminal.send':
case 'terminal.send': {
// Input-routing repros (#8818) assert on the exact bytes reaching the host.
const text = String(request.params?.text ?? '')
console.log(
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}`
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(text)}`
)
respond(
success(request.id, {
send: { handle: 'term-1', accepted: true, bytesWritten: Buffer.byteLength(text) }
})
)
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
return true
}
case 'terminal.unsubscribe':
clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1'))
+2 -1
View File
@@ -19,6 +19,7 @@ import {
type PanResponderGestureState
} from 'react-native'
import { ArrowUp, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../ime/ime-submit-carry'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import type {
@@ -1283,7 +1284,7 @@ export function MobileBrowserPane({
autoCapitalize="none"
autoCorrect={false}
editable={!controlsDisabled}
onSubmitEditing={() => void sendKeyboardText()}
{...imeGuardedSubmitProps(Platform.OS, () => void sendKeyboardText())}
/>
<Pressable
style={[styles.sendButton, (controlsDisabled || !keyboardValue) && styles.disabled]}
+161
View File
@@ -0,0 +1,161 @@
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { imeGuardedSubmitProps, noteImeCompositionChange } from './ime-submit-carry'
// Captured on a physical iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana keyboard
// (lane-ios/metro.log, IME7427_NATIVE_EVENT eventCount 5-10). Five marked events, then the
// confirmation unmarks the same text — and iOS fires onSubmitEditing right after it.
const RECORDED_IOS_DEVICE_FLICK_VOWELS: readonly (boolean | undefined)[] = [
true,
true,
true,
true,
true,
false
]
// The paired ASCII control from the same capture arm: never marked.
const ORDINARY_ASCII: readonly (boolean | undefined)[] = [false, false, false]
// iOS Korean 2-set never marks — all 12 events in the retained #11235 trace are isComposing false.
const RECORDED_IOS_KOREAN: readonly (boolean | undefined)[] = Array.from(
{ length: 12 },
() => false
)
const frames: Array<() => void> = []
function flushFrame(): void {
for (const callback of frames.splice(0)) {
callback()
}
}
// `target` is React Native's view tag: every textInputMetrics payload carries it, on onChange and
// onSubmitEditing alike, so it identifies which field emitted an event. The device capture recorded
// target 3760 throughout.
function createField(
platform: string,
target = 3760
): {
readonly submits: number[]
readonly type: (trace: readonly (boolean | undefined)[]) => void
readonly pressReturn: () => void
} {
const submits: number[] = []
const props = imeGuardedSubmitProps(platform, () => submits.push(submits.length))
return {
submits,
type: (trace) => {
for (const isComposing of trace) {
props.onChange({ nativeEvent: { isComposing, target } })
}
},
pressReturn: () => props.onSubmitEditing({ nativeEvent: { target } })
}
}
beforeEach(() => {
frames.length = 0
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
frames.push(callback)
return frames.length
})
noteImeCompositionChange('android', true)
noteImeCompositionChange('android', false)
})
describe('ime submit carry', () => {
it('drops the submit iOS fires for the recorded device confirmation', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
expect(field.submits).toEqual([])
})
it('submits a deliberate Return taken one frame after that confirmation', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
flushFrame()
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('submits the paired ASCII control on its genuine Return', () => {
const field = createField('ios')
field.type(ORDINARY_ASCII)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('keeps the unmarking iOS Korean keyboard submitting on its confirming Return', () => {
const field = createField('ios')
field.type(RECORDED_IOS_KOREAN)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('owns only the first submit, so a repeated Return still reaches the action', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('releases the carry when the confirmation is followed by another keystroke', () => {
const field = createField('ios')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.type([false])
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('stays inert on Android, whose editor-action behaviour has no device trace', () => {
const field = createField('android')
field.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
field.pressReturn()
expect(field.submits).toEqual([0])
})
it('lets a second field submit inside the frame a first field armed', () => {
const composing = createField('ios', 3760)
const other = createField('ios', 4210)
composing.type(RECORDED_IOS_DEVICE_FLICK_VOWELS)
other.pressReturn()
expect(other.submits).toEqual([0])
})
it('never arms without a marked event, so unpatched hosts are unchanged', () => {
const field = createField('ios')
field.type([undefined, undefined])
field.pressReturn()
expect(field.submits).toEqual([0])
})
})
function sourceOf(relativePath: string): string {
return readFileSync(new URL(`../../${relativePath}`, import.meta.url), 'utf8')
}
describe('irreversible mobile Return surfaces route through the carry', () => {
it.each([
['app/h/[hostId]/session/[worktreeId].tsx', '() => void handleSend()'],
['app/h/[hostId]/tasks.tsx', '() => void connectLinearAccount()'],
['src/browser/MobileBrowserPane.tsx', '() => void sendKeyboardText()'],
['src/source-control/MobileSourceControlContent.tsx', 'primaryAction.onPress']
])('%s guards %s', (relativePath, action) => {
const source = sourceOf(relativePath)
expect(source).toContain(`{...imeGuardedSubmitProps(Platform.OS, ${action})}`)
expect(source).not.toContain(`onSubmitEditing={${action}}`)
})
it('routes the terminal live input submit through the same carry', () => {
const commit = sourceOf('src/terminal/use-terminal-live-input-commit.ts')
expect(commit).toContain('imeOwnsSubmit((event?.nativeEvent as { target?: number }')
expect(commit).toContain(
'noteImeCompositionChange(platform, nativeEvent.isComposing, nativeEvent.target)'
)
expect(sourceOf('app/h/[hostId]/session/[worktreeId].tsx')).toContain('platform: Platform.OS')
})
})
+79
View File
@@ -0,0 +1,79 @@
// `nativeEvent: object` so React Native's own TextInputChangeEvent stays assignable: the patch
// that adds `isComposing` to the iOS metrics is not reflected in React Native's shipped types.
type ImeChangeEvent = { readonly nativeEvent: object }
export type ImeGuardedSubmitProps = {
readonly onChange: (event: ImeChangeEvent) => void
readonly onSubmitEditing: (event?: ImeChangeEvent) => void
}
// The carry models the in-flight IME gesture, so it is module state rather than per-field — but it
// records WHICH field armed it (React Native's view tag, on every textInputMetrics payload), so a
// confirmation in one field can never swallow a different field's Return.
const carry: { composing: boolean; pending: object | null; target: number | undefined } = {
composing: false,
pending: null,
target: undefined
}
/**
* Why: iOS confirms a marked composition by unmarking the text and *then* firing
* `onSubmitEditing` for that same key press — React Native's
* `textInputShouldSubmitOnReturn` never inspects `markedTextRange`. An
* "is composing" boolean cannot see it, because composition has already ended by
* the time submit arrives (the mobile analogue of the renderer's Mode B). So the
* confirming change hands ownership of the *next* submit to the IME.
*
* Ownership expires on the next frame, never on an event count: a user who
* deliberately presses Return after confirming produces no intervening change
* event, so anything counted would eat that Return. Android is excluded until a
* device trace exists — its marking IMEs may deliver a wanted Return in the same
* turn, and `isComposing` is absent there outside the terminal input anyway.
*/
export function noteImeCompositionChange(
platform: string,
isComposing: boolean | undefined,
target?: number
): void {
const composing = isComposing === true
const confirmed = carry.composing && !composing
carry.composing = composing
if (!confirmed || platform !== 'ios') {
carry.pending = null
return
}
const token = {}
carry.pending = token
carry.target = target
requestAnimationFrame(() => {
if (carry.pending === token) {
carry.pending = null
}
})
}
/** True when this submit belongs to an IME confirmation gesture rather than the user. */
export function imeOwnsSubmit(target?: number): boolean {
if (!carry.pending || carry.target !== target) {
return false
}
carry.pending = null
return true
}
type ImeChangeMetrics = { isComposing?: boolean; target?: number }
/** Spread onto any single-line `TextInput` whose Return commits something. */
export function imeGuardedSubmitProps(platform: string, submit: () => void): ImeGuardedSubmitProps {
return {
onChange: (event) => {
const metrics = event.nativeEvent as ImeChangeMetrics
noteImeCompositionChange(platform, metrics.isComposing, metrics.target)
},
onSubmitEditing: (event) => {
if (!imeOwnsSubmit((event?.nativeEvent as ImeChangeMetrics | undefined)?.target)) {
submit()
}
}
}
}
@@ -29,6 +29,12 @@ function listedTerminalWorktreeIds(worktree?: string): string[] {
}
describe('mock server terminal fixture routing', () => {
it('returns the production terminal.send acknowledgement contract', () => {
expect(sendMockRequest('terminal.send', { terminal: 'term-1', text: 'abc' }).result).toEqual({
send: { handle: 'term-1', accepted: true, bytesWritten: 3 }
})
})
it('follows worktree creation and activation', () => {
const worktreeResponse = sendMockRequest('worktree.ps')
const initialWorktreeId = (
@@ -11,13 +11,31 @@ type MobileTerminalLiveInputStatusProps = {
readonly dictation: DictationStatus
readonly isAttaching: boolean
readonly liveInputText: string
// Why: marking IMEs (Japanese kana, pinyin) withhold bytes until commit, so the
// terminal echo shows nothing mid-composition and this dock is the only preview.
// Empties on commit by design — the terminal echo takes over from there.
readonly composingText?: string
}
export function MobileTerminalLiveInputStatus({
dictation,
isAttaching,
liveInputText
liveInputText,
composingText = ''
}: MobileTerminalLiveInputStatusProps) {
if (composingText.length > 0) {
return (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
Composing
</Text>
<Text style={styles.composing} numberOfLines={1} ellipsizeMode="head">
{composingText}
</Text>
</View>
)
}
const title = dictation.isRecording
? 'Listening'
: dictation.isProcessing
@@ -61,5 +79,13 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
// Underlined uncommitted text is the platform convention for marked text on both
// iOS and Android; brighter than `detail` because it is live content, not chrome.
composing: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
textDecorationLine: 'underline'
}
})
@@ -0,0 +1,182 @@
import { createElement, type RefObject } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-sender'
import {
useTerminalLiveInputCommit,
type TerminalLiveInputChangeEvent
} from '../terminal/use-terminal-live-input-commit'
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
type RecordedChange = {
readonly text: string
readonly isComposing: boolean
readonly replacementText: string
readonly start: number
readonly end: number
}
// Captured from a PHYSICAL iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana
// keyboard, flicking い/う/え/お off the あ key. eventCount 5..10 of a gapless 1..23
// stream, sealed at swarm-scratch/lane-ios/metro.log.
const DEVICE_FLICK_TRACE: readonly RecordedChange[] = [
{ text: 'い', isComposing: true, replacementText: 'い', start: 0, end: 0 },
{ text: 'いう', isComposing: true, replacementText: 'う', start: 1, end: 1 },
{ text: 'いうえ', isComposing: true, replacementText: 'え', start: 2, end: 2 },
{ text: 'いうえお', isComposing: true, replacementText: 'お', start: 3, end: 3 },
{ text: 'いうえお', isComposing: true, replacementText: 'いうえお', start: 0, end: 4 },
{ text: 'いうえお', isComposing: false, replacementText: 'いうえお', start: 0, end: 4 }
]
const ORDINARY_ABC_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', isComposing: false, replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', isComposing: false, replacementText: 'c', start: 2, end: 2 }
]
// iOS Korean 2-set never marks: every event reports isComposing:false, so it
// commits continuously and must never enter the preview path.
const IOS_KOREAN_2SET_TRACE: readonly RecordedChange[] = [
{ text: 'ㅇ', isComposing: false, replacementText: 'ㅇ', start: 0, end: 0 },
{ text: '아', isComposing: false, replacementText: 'ㅏ', start: 1, end: 1 },
{ text: '안', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 }
]
type TraceRun = {
readonly previews: string[]
readonly sent: string[]
}
/**
* Mirrors the production wiring in app/h/[hostId]/session/[worktreeId].tsx. The
* commit hook owns every send decision; a sibling flag mirrors the native
* marked-text bit into state so the dock can render a preview. Passing
* `withPreviewWiring: false` reproduces the pre-fix behavior for comparison.
*/
function runTrace(trace: readonly RecordedChange[], withPreviewWiring = true): TraceRun {
const activeHandle = 'terminal-a'
const sent: string[] = []
const previews: string[] = []
let capture = ''
let composing = false
const liveInputTerminalHandles = new Set([activeHandle])
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return true
}
}
let onChange: ((event: TerminalLiveInputChangeEvent) => void) | null = null
function Harness(): null {
const { handleLiveInputChange } = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef: { current: activeHandle },
activeSessionTabType: 'terminal',
activeSessionTabTypeRef: { current: 'terminal' },
connected: true,
liveInputRef: { current: { setNativeProps: vi.fn() } },
liveInputTerminalHandles,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => {
capture = text
}
})
onChange = (event) => {
if (withPreviewWiring) {
composing = event.nativeEvent.isComposing === true
}
handleLiveInputChange(event)
}
return null
}
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
act(() => {
create(createElement(Harness))
})
for (const change of trace) {
act(() => {
onChange?.({
nativeEvent: {
text: change.text,
isComposing: change.isComposing,
replacementText: change.replacementText,
replacementRange: { start: change.start, end: change.end }
}
})
})
previews.push(renderPreview(composing ? capture : ''))
}
} finally {
consoleError.mockRestore()
}
return { previews, sent }
}
/** Renders the real dock component and returns the visible composing text. */
function renderPreview(composingText: string): string {
let tree: ReturnType<typeof create> | null = null
act(() => {
tree = create(
createElement(MobileTerminalLiveInputStatus, {
dictation: { isStarting: false, isRecording: false, isProcessing: false },
isAttaching: false,
composingText
})
)
})
const texts = tree!.root.findAllByType('Text')
const isComposingView = texts.some((t) => t.props.children === 'Composing')
return isComposingView ? String(texts[1]?.props.children ?? '') : ''
}
describe('mobile terminal live-input preedit preview', () => {
it('renders the composing text for the recorded physical-device flick trace', async () => {
const { previews, sent } = runTrace(DEVICE_FLICK_TRACE)
// THE DISPLAY DEFECT: these four states were invisible before the fix.
expect(previews.slice(0, 4)).toEqual(['い', 'いう', 'いうえ', 'いうえお'])
// The commit event clears the preview; the terminal echo takes over.
expect(previews.at(-1)).toBe('')
// Byte behavior unchanged: nothing reaches the PTY until the unmarked commit.
await vi.waitFor(() => expect(sent).toEqual(['いうえお']))
})
it('sends nothing while composing, for the same recorded trace', async () => {
const marked = DEVICE_FLICK_TRACE.slice(0, 5)
const { sent } = runTrace(marked)
// Five marked events, zero bytes. This is the byte-level form of the
// reporter's observation that nothing appeared in the terminal until commit.
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('shows no preview for ordinary ASCII and leaves its bytes untouched', async () => {
const withFix = runTrace(ORDINARY_ABC_TRACE)
const withoutFix = runTrace(ORDINARY_ABC_TRACE, false)
expect(withFix.previews).toEqual(['', '', ''])
await vi.waitFor(() => expect(withFix.sent).toEqual(withoutFix.sent))
await vi.waitFor(() => expect(withFix.sent.join('')).toBe('abc'))
})
it('shows no preview for iOS Korean 2-set, which never marks', async () => {
const withFix = runTrace(IOS_KOREAN_2SET_TRACE)
const withoutFix = runTrace(IOS_KOREAN_2SET_TRACE, false)
// The exemption: unmarked input never enters the preview path, so this
// surface behaves exactly as it did before the fix.
expect(withFix.previews).toEqual(['', '', ''])
await vi.waitFor(() => expect(withFix.sent).toEqual(withoutFix.sent))
})
})
@@ -1,5 +1,6 @@
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
SectionList,
@@ -8,6 +9,7 @@ import {
View
} from 'react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { imeGuardedSubmitProps } from '../ime/ime-submit-carry'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
@@ -212,7 +214,7 @@ export function MobileSourceControlContent({ state }: Props) {
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
onSubmitEditing={primaryAction.onPress}
{...imeGuardedSubmitProps(Platform.OS, primaryAction.onPress)}
/>
)}
{shouldShowGenerateButton ? (
@@ -85,7 +85,7 @@ describe('session route offline-compose wiring', () => {
it('keeps the buffered command box editable offline while the live capture stays send-gated', () => {
const bufferedInput = routeSlice(
'ref={commandInputRef}',
'onSubmitEditing={() => void handleSend()}'
'{...imeGuardedSubmitProps(Platform.OS, () => void handleSend())}'
)
expect(bufferedInput).toContain('editable={canCompose}')
@@ -22,4 +22,12 @@ describe('terminal iOS dictation write-back', () => {
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
})
it('leaves buffered autocorrection native and remounts Android when it changes', () => {
expect(sessionRouteSource).toContain('onChangeText={setInput}')
expect(sessionRouteSource).toContain('autoCorrect={autocompleteEnabled}')
expect(sessionRouteSource).toContain('spellCheck={autocompleteEnabled}')
expect(sessionRouteSource).toContain("? 'cmd-input-ac-on'")
expect(sessionRouteSource).toContain(": 'cmd-input-ac-off'")
})
})
@@ -1,4 +1,4 @@
import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryInput = {
readonly bytes: string
@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
describe('terminal live control send order', () => {
it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return false
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(sendControlBytes).not.toHaveBeenCalled()
expect(events).toEqual(['flush'])
})
it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return true
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(true)
expect(events).toEqual(['flush', 'control'])
})
it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
// Given
const events: string[] = []
const flushPendingText = vi.fn(async () => {
events.push('flush')
return true
})
const sendControlBytes = vi.fn(async () => {
events.push('control')
return false
})
// When
const result = await sendTerminalLiveControlAfterPendingFlush(
flushPendingText,
sendControlBytes
)
// Then
expect(result).toBe(false)
expect(events).toEqual(['flush', 'control'])
})
})
@@ -1,12 +0,0 @@
export type TerminalLiveAsyncSendStep = () => Promise<boolean>
export async function sendTerminalLiveControlAfterPendingFlush(
flushPendingText: TerminalLiveAsyncSendStep,
sendControlBytes: TerminalLiveAsyncSendStep
): Promise<boolean> {
const flushed = await flushPendingText()
if (!flushed) {
return false
}
return sendControlBytes()
}
@@ -1,178 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
isTerminalLiveHangulCodePoint,
type TerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
function runMirrorSequence(
fieldStates: readonly string[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const fieldText of fieldStates) {
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live hangul mirror', () => {
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한', '글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가', '나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
expect(commit.nextSentText).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then: one DEL erases the stale syllable; the new one is held again
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
})
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
// Given: '한글' typed, '한' streamed, '글' held
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
expect(beforeSpace.sentText).toBe('한')
expect(beforeSpace.heldText).toBe('글')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
expect(step.heldText).toBe('')
expect(step.nextSentText).toBe('한글 ')
})
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
// Given / When
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
expect(chinese.heldText).toBe('')
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
expect(vietnamese.heldText).toBe('')
})
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
})
})
@@ -1,61 +0,0 @@
// Why: paused composition should still reach the PTY quickly; corrections make
// a premature commit safe, so this can be short without leaking jamo forever.
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
// Why: React Native exposes no composition events, but Hangul composition only
// mutates the trailing syllable. Holding just that code point keeps the PTY
// echo live while preedit jamo never leak; DEL corrections repair any commit
// that later turns out to be premature.
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const lastCodePoint = fieldCodePoints.at(-1)
const holdLast =
!options.commitHeld &&
lastCodePoint !== undefined &&
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
const sentCodePoints = Array.from(sentText)
let commonPrefixLength = 0
while (
commonPrefixLength < sentCodePoints.length &&
commonPrefixLength < targetCodePoints.length &&
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
) {
commonPrefixLength += 1
}
return {
eraseCount: sentCodePoints.length - commonPrefixLength,
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}
@@ -0,0 +1,53 @@
import { forwardRef, useImperativeHandle, useRef } from 'react'
import {
findNodeHandle,
Platform,
TextInput,
UIManager,
requireNativeComponent,
type TextInputProps
} from 'react-native'
import type { TerminalLiveInputChangeEvent } from './use-terminal-live-input-commit'
type TerminalLiveInputFieldProps = Omit<TextInputProps, 'onChange'> & {
readonly onChange: (event: TerminalLiveInputChangeEvent) => void
}
type AndroidTerminalInputProps = Omit<TerminalLiveInputFieldProps, 'onChange'> & {
readonly onTerminalInput: TerminalLiveInputFieldProps['onChange']
}
const AndroidTerminalInput =
Platform.OS === 'android'
? requireNativeComponent<AndroidTerminalInputProps>('OrcaTerminalInput')
: null
export type TerminalLiveInputFieldHandle = Pick<TextInput, 'blur' | 'focus' | 'setNativeProps'>
function runInputCommand(input: TextInput | null, command: 'blur' | 'focus'): void {
if (!AndroidTerminalInput) {
input?.[command]()
return
}
const tag = findNodeHandle(input)
if (tag !== null) {
UIManager.dispatchViewManagerCommand(tag, command, [])
}
}
export const TerminalLiveInputField = forwardRef<
TerminalLiveInputFieldHandle,
TerminalLiveInputFieldProps
>(function TerminalLiveInputField({ onChange, ...props }, ref) {
const inputRef = useRef<TextInput>(null)
useImperativeHandle(ref, () => ({
blur: () => runInputCommand(inputRef.current, 'blur'),
focus: () => runInputCommand(inputRef.current, 'focus'),
setNativeProps: (nativeProps) => inputRef.current?.setNativeProps(nativeProps)
}))
if (AndroidTerminalInput) {
return <AndroidTerminalInput {...props} ref={inputRef as never} onTerminalInput={onChange} />
}
return <TextInput {...props} ref={inputRef} onChange={onChange as TextInputProps['onChange']} />
})
@@ -1,168 +0,0 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
const state = createTerminalLivePendingFlushState()
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
})
it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFlush(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['control'])
})
it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
// Given
const events: string[] = []
let resolveFlush: (value: boolean) => void = () => {}
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFlush(false)
// Then
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})
describe('terminal live mirror send queue', () => {
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
let resolveFirstSend: (value: boolean) => void = () => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
resolveFirstSend = resolve
})
}
return true
}
// When
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
await Promise.resolve()
// Then
expect(payloads).toEqual(['a'])
resolveFirstSend(true)
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
expect(payloads).toEqual(['a', 'bc'])
})
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
order.push('first')
return false
})
// When
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
order.push('second')
return true
})
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(order).toEqual(['first', 'second'])
})
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state = createTerminalLivePendingFlushState()
// When
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
// Then
expect(state.current).toBeNull()
})
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
// Given
const state = createTerminalLivePendingFlushState()
let resolveSend: (value: boolean) => void = () => {}
const sender = async (): Promise<boolean> =>
new Promise((resolve) => {
resolveSend = resolve
})
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
// When
cancelTerminalLivePendingFlush(state)
// Then
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
expect(state.current).toBeNull()
resolveSend(true)
})
})
@@ -1,108 +0,0 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
type TerminalLivePendingRequest = {
readonly resolve: (sent: boolean) => void
}
type TerminalLivePendingBatch = {
readonly handle: string
payload: string
readonly requests: TerminalLivePendingRequest[]
readonly sender: TerminalLiveMirrorSender
}
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
activeRequests: TerminalLivePendingRequest[]
generation: number
pendingBatches: TerminalLivePendingBatch[]
}
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
return {
current: null,
activeRequests: [],
generation: 0,
pendingBatches: []
}
}
export function waitForTerminalLivePendingFlush(
state: TerminalLivePendingFlushState
): Promise<boolean> {
return state.current ?? Promise.resolve(true)
}
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
state.generation += 1
const requests = [
...state.activeRequests,
...state.pendingBatches.flatMap((batch) => batch.requests)
]
state.activeRequests = []
state.pendingBatches = []
state.current = null
requests.forEach(({ resolve }) => resolve(false))
}
async function drainTerminalLiveMirrorSends(
state: TerminalLivePendingFlushState,
generation: number
): Promise<boolean> {
let allSent = true
while (state.generation === generation) {
const batch = state.pendingBatches.shift()
if (!batch) {
state.current = null
return allSent
}
state.activeRequests = batch.requests
const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
if (state.generation !== generation) {
return false
}
state.activeRequests = []
batch.requests.forEach(({ resolve }) => resolve(sent))
allSent &&= sent
}
return false
}
// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise<boolean>((resolve) => {
resolveRequest = resolve
})
const pendingTail = state.pendingBatches.at(-1)
if (pendingTail?.handle === handle && pendingTail.sender === sender) {
pendingTail.payload += payload
pendingTail.requests.push({ resolve: resolveRequest })
} else {
state.pendingBatches.push({
handle,
payload,
requests: [{ resolve: resolveRequest }],
sender
})
}
if (!state.current) {
const generation = state.generation
const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
state.current = drain
void drain.then(() => {
if (state.current === drain) {
state.current = null
}
})
}
return request
}
@@ -1,118 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
deriveTerminalLiveCommit,
getTerminalLiveSpecialKeyDecision
} from './terminal-live-text-commit'
describe('terminal live special key decision', () => {
it('Given an unmapped key Then ignores it', () => {
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
kind: 'ignore'
})
})
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
// Held syllable present
describe('terminal live native replacement commits', () => {
it('commits a recorded Pinyin candidate without replaying its preedit', () => {
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
).toEqual({ kind: 'local-edit' })
// Only mirrored text present — native edit fires onChangeText and the diff erases
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
).toEqual({ kind: 'local-edit' })
})
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Backspace',
heldText: '',
sentText: ''
})
expect(decision.kind).toBe('send-now')
})
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Tab',
heldText: '글',
sentText: '한'
})
expect(decision.kind).toBe('commit-held-then-send')
})
it('Given a control key with no held syllable Then sends immediately', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'ArrowUp',
heldText: '',
sentText: 'ls'
})
expect(decision.kind).toBe('send-now')
})
})
describe('terminal live accessory bytes decision', () => {
it('Given a local-edit accessory key with field text Then edits locally', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '한',
sentText: ''
deriveTerminalLiveCommit('', {
text: '中',
replacementText: '中',
replacementRange: { start: 0, end: 5 }
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
).toEqual({ committedText: '', payload: '' })
})
it('keeps ordinary append input unchanged', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: 'abc'
deriveTerminalLiveCommit('a', {
text: 'ab',
replacementText: 'b',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
).toEqual({ committedText: 'ab', payload: 'b' })
})
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '한',
sentText: ''
})
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
})
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '',
sentText: 'abc'
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
})
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: ''
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
})
})
describe('terminal live accessory local edit text', () => {
it('Given backspace Then drops the last code point of the field text', () => {
it('replaces the full field when UIKit transforms the proposed input', () => {
expect(
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
).toBe('')
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '' })).toBe(
''
)
deriveTerminalLiveCommit('ㅇ', {
text: '',
replacementText: '',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: '아', payload: '\x7f아' })
})
it('Given forward delete Then keeps the field text unchanged', () => {
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
'한글'
)
it('emits nothing when a proposed transform leaves the field unchanged', () => {
expect(
deriveTerminalLiveCommit('a', {
text: 'a',
replacementText: '´',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: 'a', payload: '' })
})
it('derives deletion from the native range and counts emoji as one terminal character', () => {
expect(
deriveTerminalLiveCommit('a😀', {
text: 'a',
replacementText: '',
replacementRange: { start: 1, end: 3 }
})
).toEqual({ committedText: 'a', payload: '\x7f' })
})
it('uses the authoritative field text for a collapsed transformed deletion', () => {
expect(
deriveTerminalLiveCommit('a', {
text: '',
replacementText: '',
replacementRange: { start: 1, end: 1 }
})
).toEqual({ committedText: '', payload: '\x7f' })
expect(
deriveTerminalLiveCommit('😀', {
text: '',
replacementText: '',
replacementRange: { start: 2, end: 2 }
})
).toEqual({ committedText: '', payload: '\x7f' })
})
it('emits nothing for a cancelled preedit or ambiguous non-suffix replacement', () => {
expect(
deriveTerminalLiveCommit('', {
text: '',
replacementText: '',
replacementRange: { start: 0, end: 5 }
})
).toEqual({ committedText: '', payload: '' })
expect(
deriveTerminalLiveCommit('abc', {
text: 'aXc',
replacementText: 'X',
replacementRange: { start: 1, end: 2 }
})
).toBeNull()
})
})
describe('terminal live special keys', () => {
it('lets the native replacement own Backspace while the field has committed text', () => {
expect(getTerminalLiveSpecialKeyDecision('Backspace', true)).toEqual({ kind: 'ignore' })
expect(getTerminalLiveSpecialKeyDecision('Backspace', false)).toEqual({
kind: 'send',
bytes: '\x7f'
})
})
})
@@ -1,83 +1,69 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
const TERMINAL_DEL_BYTE = '\x7f'
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly heldText: string
readonly sentText: string
export type TerminalLiveReplacement = {
readonly text: string
readonly replacementText: string
readonly replacementRange: {
readonly start: number
readonly end: number
}
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly heldText: string
readonly sentText: string
export type TerminalLiveCommit = {
readonly committedText: string
readonly payload: string
}
export function getTerminalLiveSpecialKeyDecision({
key,
heldText,
sentText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
function splitsSurrogatePair(text: string, offset: number): boolean {
if (offset <= 0 || offset >= text.length) {
return false
}
const before = text.charCodeAt(offset - 1)
const after = text.charCodeAt(offset)
return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff
}
export function deriveTerminalLiveCommit(
committedText: string,
change: TerminalLiveReplacement
): TerminalLiveCommit | null {
const { start, end } = change.replacementRange
if (
!Number.isInteger(start) ||
!Number.isInteger(end) ||
start < 0 ||
start > committedText.length ||
end < committedText.length ||
end < start ||
splitsSurrogatePair(committedText, start)
) {
return null
}
if (change.text === committedText) {
return { committedText, payload: '' }
}
const retainedText = committedText.slice(0, start)
const predictedText = retainedText + change.replacementText
const operationMatchesText = change.text === predictedText
const replacementStart = operationMatchesText ? start : 0
const eraseCount = Array.from(committedText.slice(replacementStart)).length
return {
committedText: change.text,
payload: TERMINAL_DEL_BYTE.repeat(eraseCount) + change.text.slice(replacementStart)
}
}
export function getTerminalLiveSpecialKeyDecision(
key: string,
hasCommittedText: boolean
): { readonly kind: 'ignore' } | { readonly kind: 'send'; readonly bytes: string } {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
if (bytes === null || ((key === 'Backspace' || key === 'Delete') && hasCommittedText)) {
return { kind: 'ignore' }
}
// Why: native field edits fire onChangeText and the mirror diff emits the
// matching PTY erase; sending raw DEL here as well would double-erase.
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit' }
}
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
heldText,
sentText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit', localEdit }
}
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
}
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
fieldText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly fieldText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the field text.
return fieldText
}
return Array.from(fieldText).slice(0, -1).join('')
return { kind: 'send', bytes }
}
@@ -11,4 +11,8 @@ describe('normalizeTerminalTextInput', () => {
it('keeps ASCII hyphens unchanged', () => {
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
})
it.each(['the ', 'teh '])('passes through recorded Android buffered input %j', (text) => {
expect(normalizeTerminalTextInput(text)).toBe(text)
})
})
@@ -1,230 +0,0 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
getTerminalLiveAccessoryInactiveInputCommitResult,
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
readonly resolve: (value: boolean) => void
}
function createDeferredBoolean(): DeferredBoolean {
let resolvePromise: (value: boolean) => void = () => {
throw new Error('deferred promise was resolved before initialization')
}
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
type AccessoryInputCommitHarnessOptions = {
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
readonly sendResult?: boolean
readonly flushResult?: boolean
readonly waitResult?: boolean
}
type AccessoryInputCommitHarness = {
readonly commit: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly sent: readonly string[]
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
readonly unmount: () => void
}
function createAccessoryInputCommitHarness({
heldText = '',
sentText = '',
pendingHandle = null,
sendResult = true,
flushResult = true,
waitResult = true
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
const setLiveInputCapture = vi.fn((_text: string) => {})
let commit: AccessoryInputCommitHarness['commit'] | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
commit = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!commit || !renderer) {
throw new Error('terminal live accessory input hook did not render')
}
return {
commit,
sent,
applyLiveInputMirror,
flushPendingLiveInputText,
waitForPendingLiveInputFlush,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live accessory inactive input commit result', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
let settled = false
// When
const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
() => deferredFlush.promise
)
void resultPromise.then(() => {
settled = true
})
await Promise.resolve()
// Then
expect(settled).toBe(false)
deferredFlush.resolve(true)
await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
})
it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
// Given
const waitForPendingLiveInputFlush = async (): Promise<boolean> => false
// When
const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush
)
// Then
expect(result).toEqual({ kind: 'suppress-raw' })
})
})
describe('terminal live accessory input commit hook', () => {
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'handled' })
})
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(result).toEqual({ kind: 'allow-raw' })
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -1,113 +0,0 @@
import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
}: TerminalLiveAccessoryInputCommitOptions): (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult> {
return useCallback(
async (input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight mirror send
// so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
fieldText: sentText + heldText
})
// Why: accessory buttons do not emit native TextInput edits, so the
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
applyLiveInputMirror(activeHandle, editedText)
return { kind: 'handled' }
}
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
default:
decision satisfies never
return { kind: 'handled' }
}
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
]
)
}
@@ -0,0 +1,197 @@
/**
* #7094 — mobile terminal keystrokes vanish when typed fast ("only `l` instead of `ls`").
*
* `queueSend` chains every keystroke behind the previous send, and a send that resolves false or
* throws aborts the whole queue behind it — the bytes are never attempted and the error is
* swallowed. Only keystrokes queued while a doomed send is still in flight are lost, which is why
* the loss is speed-dependent.
*
* SCOPE LIMIT, load-bearing: this covers a transport send-queue abort, which requires a real
* disconnect or RPC error. `REQUEST_TIMEOUT_MS = 30_000` (`transport/rpc-client.ts`) means latency
* alone cannot reach this branch — a flapping relay produces such errors, a steady-but-slow one does
* not. So this is consistent with the reporter's symptom class, NOT proven to be its cause; their
* word "latency" may be their own inference.
*/
import { createElement, type RefObject } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type Handlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
type OrdinaryKeystroke = {
readonly text: string
readonly replacementText: string
readonly start: number
readonly end: number
}
// Ordinary ASCII, no composition — the reporter typed `ls`, not an IME sequence.
const ORDINARY_ABC_TRACE: readonly OrdinaryKeystroke[] = [
{ text: 'a', replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', replacementText: 'c', start: 2, end: 2 }
]
const LATER_KEYSTROKE: OrdinaryKeystroke = {
text: 'abcd',
replacementText: 'd',
start: 3,
end: 3
}
function createLiveInputHarness(sender: TerminalLiveInputSender): Handlers {
const activeHandle = 'terminal-a'
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const liveInputTerminalHandles = new Set([activeHandle])
let handlers: Handlers | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
connected: true,
liveInputRef: { current: { setNativeProps: vi.fn() } },
liveInputTerminalHandles,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
platform: 'android',
sendLiveTerminalInputRef: { current: sender },
setLiveInputCapture: () => {}
})
return null
}
const originalConsoleError = console.error
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
originalConsoleError(...args)
}
})
try {
act(() => {
create(createElement(Harness))
})
} finally {
consoleError.mockRestore()
}
if (!handlers) {
throw new Error('terminal live input hook did not render')
}
return handlers
}
function type(handlers: Handlers, keystroke: OrdinaryKeystroke): void {
handlers.handleLiveInputChange({
nativeEvent: {
text: keystroke.text,
isComposing: false,
replacementText: keystroke.replacementText,
replacementRange: { start: keystroke.start, end: keystroke.end }
}
})
}
/** Holds each send open so keystrokes queue behind it, exactly as fast typing does. */
function createHeldSender(): {
readonly attempted: string[]
readonly settle: (delivered: boolean) => void
readonly sender: TerminalLiveInputSender
} {
const attempted: string[] = []
const waiting: Array<(delivered: boolean) => void> = []
return {
attempted,
settle: (delivered) => {
const resolve = waiting.shift()
if (!resolve) {
throw new Error('settle() called with no send in flight')
}
resolve(delivered)
},
sender: async (_handle, bytes) =>
new Promise<boolean>((resolve) => {
attempted.push(bytes)
waiting.push(resolve)
})
}
}
// The abort resolves through promise microtasks only — no timers, so draining ticks is exact.
async function flushSendChain(): Promise<void> {
for (let tick = 0; tick < 10; tick += 1) {
await Promise.resolve()
}
}
describe('#7094 mobile live input drops keystrokes queued behind a failed send', () => {
it('never attempts the keystrokes queued behind a send that fails', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
expect(attempted).toEqual(['a'])
settle(false)
await flushSendChain()
// `b` and `c` are discarded without ever reaching the transport, and nothing reports it.
expect(attempted).toEqual(['a'])
})
it('still delivers every keystroke in order when the sends succeed', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
expect(attempted).toEqual(['a'])
settle(true)
await flushSendChain()
expect(attempted).toEqual(['a', 'b'])
settle(true)
await flushSendChain()
expect(attempted).toEqual(['a', 'b', 'c'])
})
it('swallows a throwing send and drops the keystrokes queued behind it', async () => {
const attempted: string[] = []
const sender: TerminalLiveInputSender = async (_handle, bytes) => {
attempted.push(bytes)
throw new Error('relay dropped the frame')
}
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
await flushSendChain()
expect(attempted).toEqual(['a'])
})
it('loses only the keystrokes queued during the failure, so slow typing survives it', async () => {
const { attempted, settle, sender } = createHeldSender()
const handlers = createLiveInputHarness(sender)
for (const keystroke of ORDINARY_ABC_TRACE) {
type(handlers, keystroke)
}
settle(false)
await flushSendChain()
expect(attempted).toEqual(['a'])
// Typed after the aborted chain settled: the path is alive, so the drop above is a real
// abort rather than a dead queue.
type(handlers, LATER_KEYSTROKE)
expect(attempted).toEqual(['a', 'd'])
})
})
@@ -1,392 +1,578 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { noteImeCompositionChange } from '../ime/ime-submit-carry'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly setConnected: (next: boolean) => void
readonly setSendResult: (next: boolean) => void
readonly unmount: () => void
const frames: Array<() => void> = []
function flushFrame(): void {
const pending = frames.splice(0)
for (const callback of pending) {
callback()
}
}
type TerminalLiveInputCommitHarnessOptions = {
readonly sendResult?: boolean
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
beforeEach(() => {
frames.length = 0
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
frames.push(callback)
return frames.length
})
return () => consoleErrorSpy.mockRestore()
// Why: the IME submit carry is module state, so a prior test's marked event would otherwise
// make this test's first unmarked event look like a confirmation. Android never arms it.
noteImeCompositionChange('android', true)
noteImeCompositionChange('android', false)
})
type Handlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
type RecordedChange = {
readonly text: string
readonly isComposing: boolean
readonly replacementText: string
readonly start: number
readonly end: number
}
function createTerminalLiveInputCommitHarness({
sendResult = true
}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
const RECORDED_IOS_KANA_TRACE: readonly RecordedChange[] = [
{ text: 'あ', isComposing: true, replacementText: 'あ', start: 0, end: 0 },
{ text: 'あ', isComposing: true, replacementText: 'あ', start: 0, end: 1 },
{ text: 'あ', isComposing: false, replacementText: 'あ', start: 0, end: 1 },
{ text: 'あき', isComposing: true, replacementText: 'き', start: 1, end: 1 },
{ text: 'あき', isComposing: true, replacementText: 'き', start: 1, end: 2 },
{ text: 'あき', isComposing: false, replacementText: 'き', start: 1, end: 2 },
{ text: 'あきか', isComposing: true, replacementText: 'か', start: 2, end: 2 },
{ text: 'あきかな', isComposing: true, replacementText: 'な', start: 3, end: 3 },
{ text: 'あきカナ', isComposing: true, replacementText: 'カナ', start: 2, end: 4 },
{ text: 'あきカナ', isComposing: false, replacementText: 'カナ', start: 2, end: 4 },
{ text: 'あきカナさ', isComposing: true, replacementText: 'さ', start: 4, end: 4 },
{ text: 'あきカナ', isComposing: false, replacementText: '', start: 4, end: 5 }
]
const RECORDED_IOS_7427_TRACE: readonly RecordedChange[] = [
{ text: 'つ', isComposing: true, replacementText: 'つ', start: 0, end: 0 },
{ text: 'っ', isComposing: true, replacementText: '゛', start: 1, end: 1 },
{ text: 'っ', isComposing: true, replacementText: 'っ', start: 0, end: 1 },
{ text: 'っ', isComposing: false, replacementText: 'っ', start: 0, end: 1 },
{ text: 'っか', isComposing: true, replacementText: 'か', start: 1, end: 1 },
{ text: 'っが', isComposing: true, replacementText: '゛', start: 2, end: 2 },
{ text: 'っが', isComposing: true, replacementText: 'が', start: 1, end: 2 },
{ text: 'っが', isComposing: false, replacementText: 'が', start: 1, end: 2 },
{ text: 'っがは', isComposing: true, replacementText: 'は', start: 2, end: 2 },
{ text: 'っがば', isComposing: true, replacementText: '゛', start: 3, end: 3 },
{ text: 'っがぱ', isComposing: true, replacementText: '゛', start: 3, end: 3 },
{ text: 'っがぱ', isComposing: true, replacementText: 'ぱ', start: 2, end: 3 },
{ text: 'っがぱ', isComposing: false, replacementText: 'ぱ', start: 2, end: 3 },
{ text: 'っがぱs', isComposing: true, replacementText: 's', start: 3, end: 3 },
{ text: 'っがぱさ', isComposing: true, replacementText: 'a', start: 4, end: 4 },
{ text: 'っがぱさ', isComposing: true, replacementText: 'さ', start: 3, end: 4 },
{ text: 'っがぱさ', isComposing: false, replacementText: 'さ', start: 3, end: 4 },
{ text: 'っがぱさk', isComposing: true, replacementText: 'k', start: 4, end: 4 },
{ text: 'っがぱさか', isComposing: true, replacementText: 'a', start: 5, end: 5 },
{ text: 'っがぱさかn', isComposing: true, replacementText: 'n', start: 5, end: 5 },
{ text: 'っがぱさかんj', isComposing: true, replacementText: 'j', start: 6, end: 6 },
{ text: 'っがぱさかんじ', isComposing: true, replacementText: 'i', start: 7, end: 7 },
{ text: 'っがぱさ漢字', isComposing: true, replacementText: '漢字', start: 4, end: 7 },
{ text: 'っがぱさ漢字', isComposing: false, replacementText: '漢字', start: 4, end: 7 },
{ text: 'っがぱさ漢字k', isComposing: true, replacementText: 'k', start: 6, end: 6 },
{ text: 'っがぱさ漢字か', isComposing: true, replacementText: 'a', start: 7, end: 7 },
{ text: 'っがぱさ漢字かn', isComposing: true, replacementText: 'n', start: 7, end: 7 },
{ text: 'っがぱさ漢字かな', isComposing: true, replacementText: 'a', start: 8, end: 8 },
{ text: 'っがぱさ漢字かな', isComposing: true, replacementText: 'かな', start: 6, end: 8 },
{ text: 'っがぱさ漢字かな', isComposing: false, replacementText: 'かな', start: 6, end: 8 }
]
// Captured on a physical iPhone 13 Pro Max, iOS 26.5.2, system Japanese Kana keyboard
// (lane-ios/metro.log, IME7427_NATIVE_EVENT eventCount 5-10). The same capture arm recorded
// 13 PTY bytes: the 12 expected UTF-8 bytes for いうえお plus a trailing 0d.
const RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE: readonly RecordedChange[] = [
{ text: 'い', isComposing: true, replacementText: 'い', start: 0, end: 0 },
{ text: 'いう', isComposing: true, replacementText: 'う', start: 1, end: 1 },
{ text: 'いうえ', isComposing: true, replacementText: 'え', start: 2, end: 2 },
{ text: 'いうえお', isComposing: true, replacementText: 'お', start: 3, end: 3 },
{ text: 'いうえお', isComposing: true, replacementText: 'いうえお', start: 0, end: 4 },
{ text: 'いうえお', isComposing: false, replacementText: 'いうえお', start: 0, end: 4 }
]
const RECORDED_ANDROID_FCITX_HANGUL_TRACE: readonly RecordedChange[] = [
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 0 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: '한', isComposing: true, replacementText: '한', start: 0, end: 1 },
{ text: '한', isComposing: false, replacementText: '한', start: 0, end: 1 },
{ text: '한ㄱ', isComposing: true, replacementText: 'ㄱ', start: 1, end: 1 },
{ text: '한그', isComposing: true, replacementText: '그', start: 1, end: 2 },
{ text: '한글', isComposing: true, replacementText: '글', start: 1, end: 2 },
{ text: '한글', isComposing: false, replacementText: '글', start: 1, end: 2 }
]
const RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE: readonly RecordedChange[] = [
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 0 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: '한', isComposing: true, replacementText: '한', start: 0, end: 1 },
{ text: '하', isComposing: true, replacementText: '하', start: 0, end: 1 },
{ text: 'ㅎ', isComposing: true, replacementText: 'ㅎ', start: 0, end: 1 },
{ text: '', isComposing: true, replacementText: '', start: 0, end: 1 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const RECORDED_ANDROID_FCITX_ANTHY_TRACE: readonly RecordedChange[] = [
{ text: 's', isComposing: true, replacementText: 's', start: 0, end: 0 },
{ text: 'さ', isComposing: true, replacementText: 'さ', start: 0, end: 1 },
{ text: 'さ', isComposing: false, replacementText: 'さ', start: 0, end: 1 }
]
const RECORDED_ANDROID_FCITX_ANTHY_CANCELLATION_TRACE: readonly RecordedChange[] = [
{ text: 's', isComposing: true, replacementText: 's', start: 0, end: 0 },
{ text: 'さ', isComposing: true, replacementText: 'さ', start: 0, end: 1 },
{ text: '', isComposing: true, replacementText: '', start: 0, end: 1 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const ORDINARY_ABC_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: 'ab', isComposing: false, replacementText: 'b', start: 1, end: 1 },
{ text: 'abc', isComposing: false, replacementText: 'c', start: 2, end: 2 }
]
const RECORDED_IOS_KOREAN_TRANSFORM_TRACE: readonly RecordedChange[] = [
{ text: 'ㅇ', isComposing: false, replacementText: 'ㅇ', start: 0, end: 0 },
{ text: '아', isComposing: false, replacementText: 'ㅏ', start: 1, end: 1 },
{ text: '안', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 },
{ text: '안ㄴ', isComposing: false, replacementText: 'ㄴ', start: 1, end: 1 },
{ text: '안녀', isComposing: false, replacementText: 'ㅕ', start: 2, end: 2 },
{ text: '안녕', isComposing: false, replacementText: 'ㅇ', start: 2, end: 2 },
{ text: '안녕ㅎ', isComposing: false, replacementText: 'ㅎ', start: 2, end: 2 },
{ text: '안녕하', isComposing: false, replacementText: 'ㅏ', start: 3, end: 3 },
{ text: '안녕핫', isComposing: false, replacementText: 'ㅅ', start: 3, end: 3 },
{ text: '안녕하세', isComposing: false, replacementText: 'ㅔ', start: 3, end: 3 },
{ text: '안녕하셍', isComposing: false, replacementText: 'ㅇ', start: 4, end: 4 },
{ text: '안녕하세요', isComposing: false, replacementText: 'ㅛ', start: 4, end: 4 }
]
const RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE: readonly RecordedChange[] = [
{ text: 'a', isComposing: false, replacementText: 'a', start: 0, end: 0 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
const IOS_ROMAJI_RECORDED_PREFIX = 'あきカナたあbcabc'
const RECORDED_IOS_ROMAJI_TRACE: readonly RecordedChange[] = [
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}k`,
isComposing: true,
replacementText: 'k',
start: 11,
end: 11
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}`,
isComposing: true,
replacementText: 'a',
start: 12,
end: 12
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かn`,
isComposing: true,
replacementText: 'n',
start: 12,
end: 12
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: true,
replacementText: 'a',
start: 13,
end: 13
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: true,
replacementText: 'かな',
start: 11,
end: 13
},
{
text: `${IOS_ROMAJI_RECORDED_PREFIX}かな`,
isComposing: false,
replacementText: 'かな',
start: 11,
end: 13
}
]
function createHarness(
send?: TerminalLiveInputSender,
platform = 'ios'
): {
readonly captures: string[]
readonly handlers: Handlers
readonly sent: string[]
} {
const activeHandle = 'terminal-a'
const captures: string[] = []
const sent: string[] = []
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
current: new Set([activeHandle])
}
const sent: string[] = []
let currentSendResult = sendResult
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return currentSendResult
}
current:
send ??
(async (_handle, bytes) => {
sent.push(bytes)
return true
})
}
// Refs never re-render; only these variables re-run the hook's clear effects.
let currentActiveSessionTabType: string | undefined = 'terminal'
let currentConnected = true
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
const liveInputRef = {
current: { setNativeProps: vi.fn() }
}
let handlers: Handlers | null = null
function Harness(): null {
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabType: 'terminal',
activeSessionTabTypeRef,
connected: currentConnected,
connected: true,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
liveInputTerminalHandlesRef: { current: liveInputTerminalHandles },
platform,
sendLiveTerminalInputRef,
setLiveInputCapture
setLiveInputCapture: (text) => captures.push(text)
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
const originalConsoleError = console.error
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
originalConsoleError(...args)
}
})
try {
act(() => {
renderer = create(createElement(Harness))
create(createElement(Harness))
})
} finally {
restoreConsoleError()
consoleError.mockRestore()
}
if (!handlers || !renderer) {
if (!handlers) {
throw new Error('terminal live input hook did not render')
}
return { captures, handlers, sent }
}
return {
captures,
handlers,
sent,
setActiveSessionTabType: (next: string | undefined): void => {
currentActiveSessionTabType = next
// Ref and prop derive from the same activeSessionTab in the real route, so
// they go null together during tab-list lag — keep the harness coupled.
activeSessionTabTypeRef.current = next ?? null
act(() => {
renderer?.update(createElement(Harness))
})
},
setConnected: (next: boolean): void => {
currentConnected = next
act(() => {
renderer?.update(createElement(Harness))
})
},
setSendResult: (next: boolean): void => {
currentSendResult = next
},
unmount: () => {
act(() => renderer?.unmount())
function change(handlers: Handlers, event: RecordedChange): void {
handlers.handleLiveInputChange({
nativeEvent: {
text: event.text,
isComposing: event.isComposing,
replacementText: event.replacementText,
replacementRange: { start: event.start, end: event.end }
}
})
}
function replay(handlers: Handlers, trace: readonly RecordedChange[]): void {
for (const event of trace) {
change(handlers, event)
}
}
describe('terminal live input commit hook', () => {
afterEach(() => {
vi.useRealTimers()
})
it('replays the recorded Android Fcitx Hangul commit and Enter trace', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
handlers.handleLiveInputChange(fieldText)
await vi.advanceTimersByTimeAsync(50)
}
// Then: only the stable prefix went out; the trailing syllable is held
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.waitFor(() => expect(sent).toEqual(['하']))
// When
handlers.handleLiveInputChange('한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
await Promise.resolve()
await Promise.resolve()
// Then: the held commit went out but was not accepted, so no \r follows
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('a')
handlers.handleLiveInputChange('ab')
// Then
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
handlers.handleLiveInputChange('a')
// Then: writing "a--" back into the controlled value would kill an active
// iOS dictation/IME session, so the capture must keep what iOS produced
expect(captures).toEqual(['a'])
await vi.waitFor(() => expect(sent).toEqual(['a--']))
})
it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS dictation replaces its hypothesis as recognition refines
handlers.handleLiveInputChange('high')
handlers.handleLiveInputChange('hi there')
// Then: captures only echo the field; the mirror repairs the PTY with DELs
expect(captures).toEqual(['high', 'hi there'])
await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('한 ')
// Then
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(true)
expect(sent).toEqual(['한'])
})
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
// Then
expect(flushed).toBe(false)
expect(sent).toEqual(['한'])
})
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
})
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
unmount()
await vi.advanceTimersByTimeAsync(1_000)
// Then
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE.slice(0, 3))
expect(sent).toEqual([])
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE[3])
await vi.waitFor(() => expect(sent).toEqual(['한']))
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE.slice(4, 7))
expect(sent).toEqual(['한'])
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_TRACE[7])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['한', '글', '\r']))
})
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
it('replays Fcitx Hangul cancellation without leaving terminal input gated', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
// When
replay(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.slice(0, -2))
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.at(-2)!)
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
// Then
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
})
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the mobile tab list momentarily yields no active tab object
setActiveSessionTabType(undefined)
handlers.handleLiveInputSubmit()
// Then: an unknown tab type is not "left the terminal", so pending still flushes
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the active tab actually becomes a non-terminal (chat) tab
setActiveSessionTabType('chat')
handlers.handleLiveInputSubmit()
// Then: pending was dropped, so submit sends only the carriage return
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => {
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
const { captures, handlers, sent, setConnected, setSendResult } =
createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('XYZZY')
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
// When: the outage is finally detected, then the link recovers
setConnected(false)
setSendResult(true)
setConnected(true)
// Then: the capture was wiped, and fresh typing sends verbatim bytes — not
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
expect(captures.at(-1)).toBe('')
const sentBeforeRecovery = sent.length
handlers.handleLiveInputChange('echo CLEANLINE')
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
})
it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
sendResult: false
})
handlers.handleLiveInputChange('한')
// When
setConnected(false)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then: the outage cleared the held text before the timer could send it
change(handlers, RECORDED_ANDROID_FCITX_HANGUL_CANCELLATION_TRACE.at(-1)!)
expect(sent).toEqual([])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'allow-raw'
})
change(handlers, ORDINARY_ABC_TRACE[0])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['a', '\r']))
})
it('replays the recorded Android Fcitx Anthy trace with an English control', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
replay(handlers, RECORDED_ANDROID_FCITX_ANTHY_TRACE.slice(0, -1))
expect(sent).toEqual([])
change(handlers, RECORDED_ANDROID_FCITX_ANTHY_TRACE.at(-1)!)
await vi.waitFor(() => expect(sent).toEqual(['さ']))
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'allow-raw'
})
replay(handlers, RECORDED_ANDROID_FCITX_ANTHY_CANCELLATION_TRACE)
expect(sent).toEqual(['さ'])
change(handlers, ORDINARY_ABC_TRACE[0])
await vi.waitFor(() => expect(sent).toEqual(['さ', 'a']))
})
it('keeps the recorded Pinyin preedit native and sends only its candidate commit', async () => {
const { captures, handlers, sent } = createHarness()
const preedit = [
{ text: 'z', replacementText: 'z', start: 0 },
{ text: 'zh', replacementText: 'h', start: 1 },
{ text: 'zho', replacementText: 'o', start: 2 },
{ text: 'zhon', replacementText: 'n', start: 3 },
{ text: 'zhong', replacementText: 'g', start: 4 }
]
for (const event of preedit) {
change(handlers, {
...event,
isComposing: true,
end: event.start
})
}
expect(sent).toEqual([])
change(handlers, {
text: '中',
isComposing: true,
replacementText: '中',
start: 0,
end: 5
})
expect(sent).toEqual([])
change(handlers, {
text: '中',
isComposing: false,
replacementText: '中',
start: 0,
end: 5
})
await vi.waitFor(() => expect(sent).toEqual(['中']))
expect(captures).toEqual(['z', 'zh', 'zho', 'zhon', 'zhong', '中', '中'])
})
it('replays the recorded Gboard Backspace replacement exactly once', async () => {
const { handlers, sent } = createHarness(undefined, 'android')
change(handlers, RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE[0])
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
change(handlers, RECORDED_ANDROID_GBOARD_BACKSPACE_TRACE[1])
await vi.waitFor(() => expect(sent).toEqual(['a', '\x7f']))
})
it('preserves rapid input order while transport sends are delayed', async () => {
const started: string[] = []
const delivered: string[] = []
const release: Array<() => void> = []
const { handlers } = createHarness(
async (_handle, bytes) =>
new Promise<boolean>((resolve) => {
started.push(bytes)
release.push(() => {
delivered.push(bytes)
resolve(true)
})
})
)
replay(handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(started).toEqual(['a']))
release.shift()!()
await vi.waitFor(() => expect(started).toEqual(['a', 'b']))
release.shift()!()
await vi.waitFor(() => expect(started).toEqual(['a', 'b', 'c']))
release.shift()!()
await vi.waitFor(() => expect(delivered).toEqual(['a', 'b', 'c']))
})
it('replays the recorded iOS Kana tap, flick, candidate, and cancellation trace', async () => {
const { handlers, sent } = createHarness()
change(handlers, RECORDED_IOS_KANA_TRACE[0])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
replay(handlers, RECORDED_IOS_KANA_TRACE.slice(1))
await vi.waitFor(() => expect(sent).toEqual(['あ', 'き', 'カナ']))
})
it('replays the recorded iOS Japanese Romaji candidate trace', async () => {
const { handlers, sent } = createHarness()
change(handlers, {
text: IOS_ROMAJI_RECORDED_PREFIX,
isComposing: false,
replacementText: IOS_ROMAJI_RECORDED_PREFIX,
start: 0,
end: 0
})
await vi.waitFor(() => expect(sent).toEqual([IOS_ROMAJI_RECORDED_PREFIX]))
sent.length = 0
replay(handlers, RECORDED_IOS_ROMAJI_TRACE)
await vi.waitFor(() => expect(sent).toEqual(['かな']))
})
it('replays the recorded iOS #7427 transforms, confirmation, and English control', async () => {
const japanese = createHarness()
replay(japanese.handlers, RECORDED_IOS_7427_TRACE)
await vi.waitFor(() => expect(japanese.sent).toEqual(['っ', 'が', 'ぱ', 'さ', '漢字', 'かな']))
expect(japanese.sent).not.toContain('\r')
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(english.sent).toEqual(['a', 'b', 'c']))
})
it('replays iOS Korean post-change transforms without normalizing text', async () => {
const korean = createHarness()
replay(korean.handlers, RECORDED_IOS_KOREAN_TRANSFORM_TRACE)
korean.handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(korean.sent.at(-1)).toBe('\r'))
const terminalText = korean.sent
.join('')
.split('')
.reduce((text, character) =>
character === '\x7f' ? Array.from(text).slice(0, -1).join('') : text + character
)
expect(terminalText).toBe('안녕하세요\r')
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
await vi.waitFor(() => expect(english.sent).toEqual(['a', 'b', 'c']))
})
it('emits nothing for the recorded Pinyin cancellation trace', () => {
const { handlers, sent } = createHarness()
const changes = [
{ text: 'z', isComposing: true, replacementText: 'z', start: 0, end: 0 },
{ text: 'zh', isComposing: true, replacementText: 'h', start: 1, end: 1 },
{ text: 'z', isComposing: true, replacementText: '', start: 1, end: 2 },
{ text: '', isComposing: false, replacementText: '', start: 0, end: 1 }
]
for (const event of changes) {
change(handlers, event)
}
expect(sent).toEqual([])
})
it('suppresses submit and accessory controls until native composition ends', async () => {
const { handlers, sent } = createHarness()
change(handlers, {
text: 'zhong',
isComposing: true,
replacementText: 'zhong',
start: 0,
end: 0
})
handlers.handleLiveInputSubmit()
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\t' })).resolves.toEqual({
kind: 'suppress-raw'
})
expect(sent).toEqual([])
})
it('blocks sends when native replacement evidence is absent', async () => {
const { handlers, sent } = createHarness()
handlers.handleLiveInputChange({
nativeEvent: {
text: 'mutable snapshot'
} as never
})
handlers.handleLiveInputSubmit()
expect(sent).toEqual([])
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
})
it('blocks an incomplete command until native evidence reconciles', async () => {
const { handlers, sent } = createHarness()
change(handlers, ORDINARY_ABC_TRACE[0])
change(handlers, {
text: 'ab',
isComposing: false,
replacementText: 'b',
start: -1,
end: 1
})
handlers.handleLiveInputSubmit()
await expect(handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })).resolves.toEqual({
kind: 'suppress-raw'
})
expect(sent).toEqual(['a'])
change(handlers, ORDINARY_ABC_TRACE[1])
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['a', 'b', '\r']))
})
it('drops the iOS device confirmation submit and keeps the ASCII control armed', async () => {
const japanese = createHarness()
replay(japanese.handlers, RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE)
japanese.handlers.handleLiveInputSubmit()
// Why: assert the drained queue, not a transient one — a suppressed '\r' and a '\r' that has
// merely not landed yet are indistinguishable while sends are still in flight.
await japanese.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
expect(japanese.sent).toEqual(['いうえお'])
expect(Buffer.from(japanese.sent.join(''), 'utf8').toString('hex')).toBe(
'e38184e38186e38188e3818a'
)
const english = createHarness()
replay(english.handlers, ORDINARY_ABC_TRACE)
english.handlers.handleLiveInputSubmit()
await english.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
expect(english.sent).toEqual(['a', 'b', 'c', '\r'])
expect(Buffer.from(english.sent.join(''), 'utf8').toString('hex')).toBe('6162630d')
})
it('sends a deliberate Return taken one frame after the iOS device confirmation', async () => {
const { handlers, sent } = createHarness()
replay(handlers, RECORDED_IOS_DEVICE_FLICK_VOWELS_TRACE)
await vi.waitFor(() => expect(sent).toEqual(['いうえお']))
flushFrame()
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent).toEqual(['いうえお', '\r']))
})
it('keeps the unmarking iOS Korean keyboard submitting on the confirming Return', async () => {
const { handlers, sent } = createHarness()
replay(handlers, RECORDED_IOS_KOREAN_TRANSFORM_TRACE)
handlers.handleLiveInputSubmit()
await vi.waitFor(() => expect(sent.at(-1)).toBe('\r'))
})
})
@@ -1,15 +1,27 @@
import { useCallback, useEffect, type RefObject } from 'react'
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import { imeOwnsSubmit, noteImeCompositionChange } from '../ime/ime-submit-carry'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
deriveTerminalLiveCommit,
getTerminalLiveSpecialKeyDecision,
type TerminalLiveReplacement
} from './terminal-live-text-commit'
export type TerminalLiveInputChangeEvent = {
readonly nativeEvent: {
readonly text: string
readonly isComposing?: boolean
readonly replacementText?: string
readonly replacementRange?: TerminalLiveReplacement['replacementRange']
readonly target?: number
}
}
// `nativeEvent: object` so React Native's own TextInputSubmitEditingEvent stays assignable; the
// view tag it carries at runtime is not declared on React Native's shipped event data types.
export type TerminalLiveInputSubmitEvent = { readonly nativeEvent?: object }
type TerminalLiveInputKeyPressEvent = {
readonly nativeEvent: {
@@ -23,22 +35,27 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputRef: RefObject<Pick<TextInput, 'setNativeProps'> | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly platform: string
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'allow-raw' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (text: string) => void
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
readonly handleLiveInputSubmit: (event?: TerminalLiveInputSubmitEvent) => void
}
export function useTerminalLiveInputCommit<TTabType extends string>({
@@ -50,44 +67,55 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
platform,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const committedTextRef = useRef('')
const isComposingRef = useRef(false)
const pendingSendRef = useRef<Promise<boolean> | null>(null)
const waitForPendingSend = useCallback(
(): Promise<boolean> => pendingSendRef.current ?? Promise.resolve(true),
[]
)
const queueSend = useCallback(
(handle: string, bytes: string): Promise<boolean> => {
const previousSend = pendingSendRef.current
const send = (async () => {
if (previousSend && !(await previousSend)) {
return false
}
return sendLiveTerminalInputRef.current(handle, bytes)
})().catch(() => false)
pendingSendRef.current = send
void send.then(() => {
if (pendingSendRef.current === send) {
pendingSendRef.current = null
}
})
return send
},
[sendLiveTerminalInputRef]
)
const clearPendingLiveInputCommit = useCallback(() => {
committedTextRef.current = ''
isComposingRef.current = false
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
useEffect(() => {
// Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send.
if (!connected) {
clearPendingLiveInputCommit()
}
}, [connected, clearPendingLiveInputCommit])
}, [clearPendingLiveInputCommit, connected])
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
return
}
// Why: a lagging mobile tab list briefly yields no active tab object; a
// null/undefined type is "unknown", not "left the terminal" — flush guards
// still block sends if the tab truly changed.
if (
!activeHandle ||
pendingHandle !== activeHandle ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
@@ -95,111 +123,119 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
}
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
// Why: external bytes (dictation/paste) land after the field's echo on the
// PTY; the field session must fully end or later diffs would erase them.
if (pendingHandle === handle) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
)
const handleLiveInputChange = useCallback(
(text: string) => {
({ nativeEvent }: TerminalLiveInputChangeEvent) => {
noteImeCompositionChange(platform, nativeEvent.isComposing, nativeEvent.target)
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
}
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(text)
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
setLiveInputCapture(nativeEvent.text)
if (nativeEvent.isComposing === true) {
isComposingRef.current = true
return
}
if (
nativeEvent.isComposing !== false ||
typeof nativeEvent.replacementText !== 'string' ||
!nativeEvent.replacementRange
) {
isComposingRef.current = true
return
}
const commit = deriveTerminalLiveCommit(committedTextRef.current, {
text: nativeEvent.text,
replacementText: nativeEvent.replacementText,
replacementRange: nativeEvent.replacementRange
})
if (!commit) {
isComposingRef.current = true
return
}
isComposingRef.current = false
committedTextRef.current = commit.committedText
if (commit.payload.length > 0) {
void queueSend(activeHandle, commit.payload)
}
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
platform,
queueSend,
setLiveInputCapture
]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
({ nativeEvent: { key } }: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || isComposingRef.current || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
const decision = getTerminalLiveSpecialKeyDecision(key, committedTextRef.current.length > 0)
if (decision.kind === 'send') {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
default:
decision satisfies never
void queueSend(activeHandle, decision.bytes)
}
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, queueSend]
)
const handleLiveInputAccessoryBytes = useCallback(
async (_input: TerminalLiveAccessoryInput): Promise<TerminalLiveAccessoryInputCommitResult> => {
if (!activeHandle) {
return { kind: 'allow-raw' }
}
if (!liveInputTerminalHandles.has(activeHandle)) {
return (await waitForPendingSend()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
}
if (isComposingRef.current) {
return { kind: 'suppress-raw' }
}
clearPendingLiveInputCommit()
return (await waitForPendingSend()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, waitForPendingSend]
)
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
if (
isComposingRef.current ||
handle !== activeHandleRef.current ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
return false
}
clearPendingLiveInputCommit()
return waitForPendingSend()
},
[
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
liveInputTerminalHandlesRef,
waitForPendingSend
]
)
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
const handleLiveInputSubmit = useCallback(
(event?: TerminalLiveInputSubmitEvent) => {
if (imeOwnsSubmit((event?.nativeEvent as { target?: number } | undefined)?.target)) {
return
}
if (!activeHandle || isComposingRef.current || !liveInputTerminalHandles.has(activeHandle)) {
return
}
clearPendingLiveInputCommit()
void queueSend(activeHandle, '\r')
},
[activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles, queueSend]
)
return {
clearPendingLiveInputCommit,
@@ -0,0 +1,27 @@
import { useCallback, useState } from 'react'
import type { TerminalLiveInputChangeEvent } from './use-terminal-live-input-commit'
/**
* Marking IMEs (Japanese kana, pinyin) withhold bytes until commit, so the terminal
* echo shows nothing mid-composition and the input dock is the only place a preview
* can appear. The commit hook tracks composition in a ref, which cannot drive a
* render, so mirror the native marked-text bit into state alongside it.
*
* Byte behavior is untouched: the commit hook still owns every send decision.
*/
export function useTerminalLiveInputPreedit(
handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
): {
readonly isComposing: boolean
readonly handleLiveInputChangeWithPreedit: (event: TerminalLiveInputChangeEvent) => void
} {
const [isComposing, setIsComposing] = useState(false)
const handleLiveInputChangeWithPreedit = useCallback(
(event: TerminalLiveInputChangeEvent) => {
setIsComposing(event.nativeEvent.isComposing === true)
handleLiveInputChange(event)
},
[handleLiveInputChange]
)
return { isComposing, handleLiveInputChangeWithPreedit }
}
@@ -1,192 +0,0 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
export function useTerminalLivePendingInputFlush<TTabType extends string>({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
>(async () => false)
const clearHeldCommitTimer = useCallback(() => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
}, [])
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
const clearPendingLiveInputCommit = useCallback(() => {
resetMirrorState()
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
handle !== activeHandleRef.current ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
// Why: a stale handle must not keep local mirror state alive — the next
// active terminal would inherit wrong erase counts. A null tab type is
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
resetMirrorState()
return false
}
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
commitHeld
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
clearHeldCommitTimer()
if (step.heldText.length > 0) {
heldCommitTimerRef.current = setTimeout(() => {
heldCommitTimerRef.current = null
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
void runMirrorStepRef.current(handle, heldField, true)
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
}
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(
pendingLiveInputFlushRef.current,
handle,
payload,
sendQueuedMirrorPayload
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
runMirrorStepRef.current = runMirrorStep
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string): void => {
void runMirrorStep(handle, fieldText, false)
},
[runMirrorStep]
)
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const handle = pendingLiveInputHandleRef.current
if (!handle) {
return waitForPendingLiveInputFlush()
}
if (expectedHandle !== null && handle !== expectedHandle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
const heldText = heldLiveInputTextRef.current
const result =
heldText.length > 0
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
: await waitForPendingLiveInputFlush()
// Why: an explicit flush ends the field's editing session; the echoed PTY
// text stays, so local mirror state must restart from empty.
clearPendingLiveInputCommit()
return result
},
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
)
useEffect(() => {
return () => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])
return {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
}
}
+1 -1
View File
@@ -103,7 +103,7 @@
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-linux-ime-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
+24 -24
View File
@@ -18,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8
hash: 4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -45,7 +45,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -208,25 +208,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
version: 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9591,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=037272642db6a9bf5242c02acacf8fc41f4c6e91268d8a47ea083c98fb2535b8)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)': {}
abbrev@4.0.0: {}
+3
View File
@@ -4,6 +4,7 @@ import { isAbsolute, join } from 'node:path'
import os from 'node:os'
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerMonitor, type Tray } from 'electron'
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
import { disableMacAutomaticPeriodSubstitution } from './macos-automatic-period-substitution'
import { electronApp, is } from '@electron-toolkit/utils'
import {
Store,
@@ -2059,6 +2060,8 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: {
void app.whenReady().then(async () => {
logStartupMilestone('app-ready')
// Why: before any window exists, so the first terminal never inherits the substitution (#11504).
disableMacAutomaticPeriodSubstitution()
installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() })
const hangDetection = consumeHangDetectionMarker(
hangDetectionMarkerPath(getCanonicalUserDataPath())
@@ -0,0 +1,69 @@
import { systemPreferences } from 'electron'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
AUTOMATIC_PERIOD_SUBSTITUTION_KEY,
disableMacAutomaticPeriodSubstitution
} from './macos-automatic-period-substitution'
vi.mock('electron', () => ({ systemPreferences: { setUserDefault: vi.fn() } }))
// #11504: on m4air (macOS 26.5.2, packaged build, global preference on) typing `a b <space>
// <space>` in a terminal put `["a","b"," ",". "]` on the PTY. With this override written to
// Orca's own defaults domain the same keystrokes put `["a","b"," "," "]` there — no period.
// Sealed at .tmp/ime-handoff/swarm-scratch/wave27-11504fix/.
describe('disableMacAutomaticPeriodSubstitution', () => {
beforeEach(() => {
vi.mocked(systemPreferences.setUserDefault).mockReset()
})
it('writes the app-domain override on macOS', () => {
const setUserDefault = vi.fn()
expect(disableMacAutomaticPeriodSubstitution({ platform: 'darwin', setUserDefault })).toBe(true)
expect(setUserDefault).toHaveBeenCalledWith(AUTOMATIC_PERIOD_SUBSTITUTION_KEY, 'boolean', false)
})
// Why: startup calls this with no options, so Electron delegation is the path that actually ships.
it('writes through systemPreferences when no writer is injected', () => {
expect(disableMacAutomaticPeriodSubstitution({ platform: 'darwin' })).toBe(true)
expect(systemPreferences.setUserDefault).toHaveBeenCalledWith(
AUTOMATIC_PERIOD_SUBSTITUTION_KEY,
'boolean',
false
)
})
// The other direction of #11504: suppress the substitution the OS invents, and nothing else.
// Quote and dash substitution stay as the user set them, and a period the user types is never
// routed through here at all — it reaches the PTY as an ordinary keystroke
// (terminal-stock-composition.issue-11504-macos-period-substitution.test.ts pins that arm).
it('disables period substitution only', () => {
const setUserDefault = vi.fn()
disableMacAutomaticPeriodSubstitution({ platform: 'darwin', setUserDefault })
expect(setUserDefault.mock.calls).toEqual([
[AUTOMATIC_PERIOD_SUBSTITUTION_KEY, 'boolean', false]
])
})
it.each(['win32', 'linux'] as const)('does not touch defaults on %s', (platform) => {
const setUserDefault = vi.fn()
expect(disableMacAutomaticPeriodSubstitution({ platform, setUserDefault })).toBe(false)
expect(setUserDefault).not.toHaveBeenCalled()
})
it('survives a failing preferences write', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const setUserDefault = vi.fn(() => {
throw new Error('defaults unavailable')
})
expect(disableMacAutomaticPeriodSubstitution({ platform: 'darwin', setUserDefault })).toBe(
false
)
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
@@ -0,0 +1,43 @@
import { systemPreferences } from 'electron'
/**
* macOS "Add period with double-space" (`NSAutomaticPeriodSubstitutionEnabled`, on by default)
* is applied by AppKit's text input system. Native terminals never join that system; Chromium
* text fields do, so xterm's helper textarea inherits it and a double space arrives as `". "` —
* a period nobody typed, handed straight to the PTY (#11504).
*
* Chromium answers AppKit for quote and dash substitution and defaults both off (`boolForKey:`
* on an unset `WebAutomatic*` key), which is why those never leak. It declares no period accessor
* at all — "period" does not appear in render_widget_host_view_cocoa.mm — so AppKit applies that
* one without asking, and this user default is the only lever. There is no per-field or
* per-webContents opt-out to prefer over it.
*
* Writing the key into Orca's own defaults domain overrides the global value for this app alone
* and leaves the user's system-wide setting untouched. It necessarily covers every Orca text
* field, not only terminals — AppKit offers no narrower scope.
*/
export const AUTOMATIC_PERIOD_SUBSTITUTION_KEY = 'NSAutomaticPeriodSubstitutionEnabled'
export type DisableMacAutomaticPeriodSubstitutionOptions = {
platform?: NodeJS.Platform
setUserDefault?: (key: string, type: 'boolean', value: boolean) => void
}
/** Returns whether the app-domain override was written. No-op off macOS. */
export function disableMacAutomaticPeriodSubstitution({
platform = process.platform,
setUserDefault = (key, type, value) => systemPreferences.setUserDefault(key, type, value)
}: DisableMacAutomaticPeriodSubstitutionOptions = {}): boolean {
if (platform !== 'darwin') {
return false
}
try {
setUserDefault(AUTOMATIC_PERIOD_SUBSTITUTION_KEY, 'boolean', false)
return true
} catch (error) {
// Why: a preferences write is never worth failing startup over.
console.warn('Failed to disable macOS automatic period substitution', error)
return false
}
}
@@ -503,6 +503,7 @@ describe('enableMainProcessGpuFeatures', () => {
}
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-wayland-ime')
expect(app.disableHardwareAcceleration).not.toHaveBeenCalled()
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith(
'enable-features',
@@ -528,6 +529,7 @@ describe('enableMainProcessGpuFeatures', () => {
enableMainProcessGpuFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-wayland-ime')
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith(
'enable-features',
expect.stringContaining('EarlyEstablishGpuChannel')
@@ -565,6 +567,7 @@ describe('enableMainProcessGpuFeatures', () => {
}
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-wayland-ime')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'enable-features',
'EarlyEstablishGpuChannel,EstablishGpuChannelAsync'
@@ -594,6 +597,7 @@ describe('enableMainProcessGpuFeatures', () => {
enableMainProcessGpuFeatures()
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu-sandbox')
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-wayland-ime')
}
} finally {
if (originalWaylandDisplay === undefined) {
@@ -637,6 +641,31 @@ describe('enableMainProcessGpuFeatures', () => {
)
})
it('keeps native Wayland IME enabled for Linux E2E runs', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
const originalWaylandDisplay = process.env.WAYLAND_DISPLAY
try {
setPlatform('linux')
process.env.ORCA_E2E_USER_DATA_DIR = '/tmp/orca-e2e'
process.env.WAYLAND_DISPLAY = 'wayland-1'
vi.mocked(app.commandLine.appendSwitch).mockClear()
enableMainProcessGpuFeatures()
} finally {
if (originalWaylandDisplay === undefined) {
delete process.env.WAYLAND_DISPLAY
} else {
process.env.WAYLAND_DISPLAY = originalWaylandDisplay
}
}
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-wayland-ime')
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu')
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-gpu-sandbox')
})
it('preserves existing enable-features switches', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
+17 -11
View File
@@ -254,6 +254,23 @@ export function installDevParentSignalQuit(isDev: boolean): void {
}
export function enableMainProcessGpuFeatures(): void {
const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase()
const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase()
const isLinuxX11Override =
ozonePlatform === 'x11' || (ozonePlatform === '' && ozonePlatformHint === 'x11')
const isLinuxWaylandSession =
process.platform === 'linux' &&
!isLinuxX11Override &&
(Boolean(process.env.WAYLAND_DISPLAY) ||
process.env.XDG_SESSION_TYPE === 'wayland' ||
ozonePlatformHint === 'wayland' ||
ozonePlatform === 'wayland')
if (isLinuxWaylandSession) {
// Why: Chromium otherwise leaves Wayland text-input-v3 disconnected from native IME frameworks.
app.commandLine.appendSwitch('enable-wayland-ime')
}
if (process.platform === 'linux' && getMainE2EConfig().userDataDir) {
// Why: Ubuntu/Xvfb runners fail Electron startup with "GPU process isn't usable"; E2E needs no GPU, so use the software path.
app.disableHardwareAcceleration()
@@ -272,17 +289,6 @@ export function enableMainProcessGpuFeatures(): void {
// 128 raises the ceiling for real layouts while staying bounded so context leaks still surface.
app.commandLine.appendSwitch('max-active-webgl-contexts', '128')
const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase()
const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase()
const isLinuxX11Override =
ozonePlatform === 'x11' || (ozonePlatform === '' && ozonePlatformHint === 'x11')
const isLinuxWaylandSession =
process.platform === 'linux' &&
!isLinuxX11Override &&
(Boolean(process.env.WAYLAND_DISPLAY) ||
process.env.XDG_SESSION_TYPE === 'wayland' ||
ozonePlatformHint === 'wayland' ||
ozonePlatform === 'wayland')
if (isLinuxWaylandSession) {
// Why: #5319 — Wayland loses the eager GPU channel; drop the GPU sandbox so Chromium opens it lazily.
app.commandLine.appendSwitch('disable-gpu-sandbox')
+10 -11
View File
@@ -1362,7 +1362,7 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenCalledWith('ui:dictationKeyDown')
})
it('forwards ctrl/cmd+j to the worktree palette toggle event', () => {
it('leaves worktree palette shortcuts to the renderer', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
@@ -1419,12 +1419,10 @@ describe('createMainWindow', () => {
]) {
const preventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault } as never, input as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(preventDefault).not.toHaveBeenCalled()
}
expect(webContents.send).toHaveBeenCalledTimes(2)
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:toggleWorktreePalette')
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
expect(webContents.send).not.toHaveBeenCalledWith('ui:toggleWorktreePalette')
})
it('suppresses auto-repeat quick-command menu toggles from before-input-event', () => {
@@ -1632,7 +1630,7 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenCalledWith('ui:openQuickOpen')
})
it('notifies before Orca-first captures a risky terminal-focused shortcut', () => {
it('leaves terminal-focused worktree palette capture to the renderer', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
@@ -1690,11 +1688,12 @@ describe('createMainWindow', () => {
} as never
)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', {
actionId: 'worktree.palette'
})
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
expect(preventDefault).not.toHaveBeenCalled()
expect(webContents.send).not.toHaveBeenCalledWith(
'ui:terminalShortcutCaptured',
expect.anything()
)
expect(webContents.send).not.toHaveBeenCalledWith('ui:toggleWorktreePalette')
})
it('notifies before Orca-first captures a terminal-focused double-tap shortcut', () => {
+6 -4
View File
@@ -670,7 +670,9 @@ export function createMainWindow(
const doubleTapDetector = new ModifierDoubleTapDetector()
// Why: one mapping of action → IPC/side effect, shared by the keydown and double-tap paths so they can't drift.
const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): void => {
const sendResolvedWindowShortcutAction = (
action: Exclude<WindowShortcutAction, { type: 'toggleWorktreePalette' }>
): void => {
switch (action.type) {
// The renderer's DictationController re-checks enabled/sttModel and ignores hold mode, so this path needs no voice guards.
case 'dictationKeyDown':
@@ -692,9 +694,6 @@ export function createMainWindow(
case 'toggleRightSidebar':
mainWindow.webContents.send('ui:toggleRightSidebar')
return
case 'toggleWorktreePalette':
mainWindow.webContents.send('ui:toggleWorktreePalette')
return
case 'toggleFloatingTerminal':
mainWindow.webContents.send('ui:toggleFloatingTerminal')
return
@@ -739,6 +738,9 @@ export function createMainWindow(
}
): boolean => {
const { focusedShortcutContext, isAutoRepeat } = options
if (action.type === 'toggleWorktreePalette') {
return false
}
if (
floatingTerminalInputFocused &&
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
+37 -1
View File
@@ -25,6 +25,11 @@ import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/const
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import {
isImeOwnedKeyboardEvent,
markImeOwnedShortcutEvent,
resolveImeModifierGesture
} from '@/lib/ime-composition-keyboard-event'
import {
isPairedWebClientWindow,
shouldRenderDesktopWindowChrome
@@ -1571,6 +1576,7 @@ function App(): React.JSX.Element {
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
let imeOwnedModifierGesture = false
const createRegisteredCommandHandlers = (
input?: ShortcutDispatchInput,
@@ -1865,6 +1871,18 @@ function App(): React.JSX.Element {
return
}
if (matchShortcut('worktree.palette')) {
input.preventDefault()
notifyTerminalCapture('worktree.palette')
const store = useAppStore.getState()
if (store.activeModal === 'worktree-palette') {
store.closeModal()
} else {
store.openModal('worktree-palette')
}
return
}
// Skip editable surfaces so TipTap's Cmd+B bold works; this renderer-side fallback covers the blur→press IPC race (docs/markdown-cmd-b-bold-design.md).
if (isEditableTarget(input.target)) {
return
@@ -1931,6 +1949,15 @@ function App(): React.JSX.Element {
}
const onKeyDown = (e: KeyboardEvent): void => {
const gesture = resolveImeModifierGesture(imeOwnedModifierGesture, e)
imeOwnedModifierGesture = gesture.active
if (gesture.owned || isImeOwnedKeyboardEvent(e)) {
if (gesture.carried) {
markImeOwnedShortcutEvent(e)
}
doubleTapDetector.reset()
return
}
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyDown',
@@ -1971,6 +1998,12 @@ function App(): React.JSX.Element {
}
const onKeyUp = (e: KeyboardEvent): void => {
const gesture = resolveImeModifierGesture(imeOwnedModifierGesture, e)
imeOwnedModifierGesture = gesture.active
if (gesture.owned || isImeOwnedKeyboardEvent(e)) {
doubleTapDetector.reset()
return
}
doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyUp',
@@ -1986,7 +2019,10 @@ function App(): React.JSX.Element {
}
// Why: a window blur mid-gesture must not leave the detector armed.
const onBlur = (): void => doubleTapDetector.reset()
const onBlur = (): void => {
imeOwnedModifierGesture = false
doubleTapDetector.reset()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('monaco-editor', () => ({}))
vi.mock('@/lib/monaco-setup', () => ({ monaco: {} }))
vi.mock('@monaco-editor/react', () => ({ default: () => null, DiffEditor: () => null }))
import { PRReviewersPanel } from './GitHubItemDialog'
import type { GitHubWorkItem } from '../../../shared/types'
const requestPRReviewers = vi.fn(async () => ({ ok: true as const, reviewRequests: [] }))
beforeEach(() => {
requestPRReviewers.mockClear()
;(window as unknown as { api: unknown }).api = {
gh: {
requestPRReviewers,
listAssignableUsers: vi.fn(async () => [])
}
}
})
afterEach(cleanup)
function dispatchKey(el: HTMLElement, type: 'keydown' | 'keyup', init: KeyboardEventInit): void {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => {
el.dispatchEvent(event)
})
}
const item = {
id: 'pr-1',
repoId: 'repo-1',
number: 7,
title: 'PR',
itemType: 'PULL_REQUEST',
reviewRequests: []
} as unknown as GitHubWorkItem
async function openReviewerInput(): Promise<HTMLInputElement> {
render(
<PRReviewersPanel item={item} loading={false} repoPath="/repo" onReviewersRequested={vi.fn()} />
)
const trigger = document.querySelector('button[aria-label="Reviewer"]') as HTMLButtonElement
fireEvent.click(trigger)
return await waitFor(() => {
const input = document.querySelector<HTMLInputElement>(
'input[placeholder="Type or choose a user"]'
)
if (!input) {
throw new Error('reviewer input not open')
}
return input
})
}
// Why: this input requests a PR review remotely, which cannot be undone from the client, so the
// confirming Enter of a CJK composition must not reach the request path.
describe('GitHubItemDialog reviewer IME Enter ownership', () => {
it('does not request a review on the recorded Korean Enter redispatch', async () => {
const input = await openReviewerInput()
fireEvent.change(input, { target: { value: '테스' } })
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', { key: 'Process', keyCode: 229, isComposing: true })
fireEvent.compositionEnd(input, { data: '가' })
dispatchKey(input, 'keydown', { key: 'Enter', keyCode: 13, isComposing: false })
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
expect(requestPRReviewers).not.toHaveBeenCalled()
})
// Discriminates in the other direction: the guard must not swallow a real review request.
it('requests a review on an ordinary Enter', async () => {
const input = await openReviewerInput()
fireEvent.change(input, { target: { value: 'octocat' } })
dispatchKey(input, 'keydown', { key: 'Enter', keyCode: 13, isComposing: false })
await waitFor(() => expect(requestPRReviewers).toHaveBeenCalled())
})
})
@@ -70,6 +70,7 @@ import {
} from '@/components/ui/dropdown-menu'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator'
import {
@@ -350,7 +351,8 @@ function WorkItemStateBadge({
)
}
function PRReviewersPanel({
// Exported for IME Enter guard tests; not used outside this module in production.
export function PRReviewersPanel({
item,
loading,
repoPath,
@@ -397,6 +399,7 @@ function PRReviewersPanel({
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const reviewerPanelMountedRef = useRef(true)
const reviewerImeEnter = useImeEnterGestureOwnership()
const cancelReviewerInputFocusFrame = useCallback((): void => {
if (reviewerInputFocusFrameRef.current !== null) {
@@ -889,6 +892,10 @@ function PRReviewersPanel({
ref={reviewerInputRef}
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
onCompositionStart={() => reviewerImeEnter.setComposing(true)}
onCompositionEnd={() => reviewerImeEnter.setComposing(false)}
onKeyUp={reviewerImeEnter.onKeyUp}
onBlur={reviewerImeEnter.reset}
disabled={submitting || !canRequestReview}
placeholder={translate(
'auto.components.GitHubItemDialog.bb42774171',
@@ -899,6 +906,9 @@ function PRReviewersPanel({
aria-haspopup="listbox"
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
onKeyDown={(event) => {
if (reviewerImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex(
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { JiraIssueTitleInput } from './JiraIssueWorkspace'
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderTitle(onSubmit: (value: string) => void): HTMLInputElement {
const view = render(
<JiraIssueTitleInput value="테스" onChange={() => {}} onSubmit={onSubmit} disabled={false} />
)
return view.getByRole('textbox') as HTMLInputElement
}
function dispatchRecordedGesture(input: HTMLInputElement): boolean {
act(() => input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
act(() => input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })))
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
afterEach(cleanup)
describe('JiraIssueTitleInput IME Enter ownership', () => {
it('does not update Jira on the recorded Korean Enter redispatch', () => {
const onSubmit = vi.fn()
const input = renderTitle(onSubmit)
expect(dispatchRecordedGesture(input)).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it('updates Jira exactly once on ordinary Enter', () => {
const onSubmit = vi.fn()
const input = renderTitle(onSubmit)
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit).toHaveBeenCalledWith('테스')
})
})
@@ -49,6 +49,7 @@ import type {
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
type JiraIssueWorkspaceProps = {
issue: JiraIssue | null
@@ -101,6 +102,41 @@ async function copyTextToClipboard(text: string, label: string): Promise<void> {
}
}
export function JiraIssueTitleInput({
value,
onChange,
onSubmit,
disabled
}: {
value: string
onChange: (value: string) => void
onSubmit: (value: string) => void
disabled: boolean
}): React.JSX.Element {
const imeEnter = useImeEnterGestureOwnership()
return (
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={imeEnter.reset}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(event) => {
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
onSubmit(event.currentTarget.value)
}
}}
disabled={disabled}
className="h-8 text-xs"
/>
)
}
export default function JiraIssueWorkspace({
issue,
onUse,
@@ -592,16 +628,11 @@ export default function JiraIssueWorkspace({
{translate('auto.components.JiraIssueWorkspace.444865b4a8', 'Title')}
</label>
<div className="flex gap-2">
<Input
<JiraIssueTitleInput
value={titleDraft}
onChange={(event) => setTitleDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSaveTitle()
}
}}
className="h-8 text-xs"
onChange={setTitleDraft}
onSubmit={() => handleSaveTitle()}
disabled={pendingField === 'title'}
/>
<Button
size="sm"
@@ -0,0 +1,123 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/runtime/runtime-linear-client', () => ({
linearUpdateIssue: vi.fn(async () => ({ ok: true }))
}))
import { LinearIssueTextEditor } from './LinearIssueTextEditor'
import { useAppStore } from '@/store'
import type { LinearIssue } from '../../../shared/types'
const initialState = useAppStore.getInitialState()
let root: Root | null = null
let container: HTMLDivElement | null = null
function dispatchKey(el: HTMLElement, init: KeyboardEventInit): void {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => {
el.dispatchEvent(event)
})
}
function setValue(el: HTMLTextAreaElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set as (
this: HTMLTextAreaElement,
v: string
) => void
act(() => {
setter.call(el, value)
el.dispatchEvent(new Event('input', { bubbles: true }))
})
}
async function renderFocusedTitle(): Promise<HTMLTextAreaElement> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<LinearIssueTextEditor
issue={
{
id: 'iss-1',
identifier: 'ORC-1',
title: 'old',
description: ''
} as unknown as LinearIssue
}
onIssueChange={() => {}}
fields="title"
/>
)
})
const textarea = container.querySelector('textarea')
if (!textarea) {
throw new Error('title textarea not found')
}
// The title commits by blurring, and blur is inert on a never-focused element here.
act(() => textarea.focus())
return textarea
}
describe('LinearIssueTextEditor title IME Enter ownership', () => {
beforeEach(() => useAppStore.setState(initialState, true))
afterEach(() => {
act(() => root?.unmount())
container?.remove()
root = null
container = null
})
// Regression: the carry token owned any Enter carrying a modifier, so a user holding
// Ctrl/Cmd through the composition confirm lost the title commit outright.
// Ctrl, not Cmd: happy-dom's UA has no 'Mac', so getShortcutPlatform() reports linux.
it('still commits when the modifier is held through the confirm redispatch', async () => {
const textarea = await renderFocusedTitle()
expect(document.activeElement).toBe(textarea)
setValue(textarea, '테스트')
act(() => textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(textarea, { key: 'Enter', keyCode: 13, isComposing: true, ctrlKey: true })
act(() => textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })))
dispatchKey(textarea, { key: 'Enter', keyCode: 13, isComposing: false, ctrlKey: true })
expect(document.activeElement).not.toBe(textarea)
})
// Mode B: the bare Enter the IME redispatches after the confirm must stay swallowed.
it('does not commit on the bare redispatch after a confirm', async () => {
const textarea = await renderFocusedTitle()
setValue(textarea, '테스트')
act(() => textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(textarea, { key: 'Process', keyCode: 229, isComposing: true })
act(() => textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })))
dispatchKey(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
expect(document.activeElement).toBe(textarea)
})
// A chord pressed mid-composition is the IME's confirm, never a commit.
it('does not commit on a chord pressed during composition', async () => {
const textarea = await renderFocusedTitle()
setValue(textarea, '테스트')
act(() => textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(textarea, { key: 'Enter', keyCode: 13, isComposing: true, ctrlKey: true })
expect(document.activeElement).toBe(textarea)
})
it('commits on an ordinary Enter', async () => {
const textarea = await renderFocusedTitle()
setValue(textarea, 'plain title')
dispatchKey(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
expect(document.activeElement).not.toBe(textarea)
})
})
@@ -7,6 +7,7 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import type { LinearIssue } from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
@@ -42,6 +43,7 @@ export function LinearIssueTextEditor({
const [savingField, setSavingField] = useState<LinearIssueTextField | null>(null)
const lastIssueIdRef = useRef(issue.id)
const mountedRef = useMountedRef()
const titleImeEnter = useImeEnterGestureOwnership()
const resolvedDraftState = resolveLinearIssueTextDraftState(draftState, issue)
const issueChanged = draftState.issueId !== issue.id
if (resolvedDraftState !== draftState) {
@@ -172,6 +174,9 @@ export function LinearIssueTextEditor({
const handleTitleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (titleImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
@@ -179,7 +184,7 @@ export function LinearIssueTextEditor({
}
handleDescriptionKeyDown(event)
},
[handleDescriptionKeyDown]
[handleDescriptionKeyDown, titleImeEnter]
)
const titleClass =
@@ -193,8 +198,14 @@ export function LinearIssueTextEditor({
<textarea
value={titleDraft}
onChange={(event) => updateTitleDraft(event.target.value)}
onBlur={() => void saveField('title')}
onCompositionStart={() => titleImeEnter.setComposing(true)}
onCompositionEnd={() => titleImeEnter.setComposing(false)}
onBlur={() => {
titleImeEnter.reset()
void saveField('title')
}}
onKeyDown={handleTitleKeyDown}
onKeyUp={titleImeEnter.onKeyUp}
disabled={savingField === 'title'}
rows={1}
aria-label={translate(
@@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LinearSubIssueTitleInput } from './LinearIssueWorkspace'
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function dispatchRecordedGesture(input: HTMLInputElement): boolean {
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
function renderInput(onSubmit: () => void): HTMLInputElement {
const view = render(
<LinearSubIssueTitleInput value="테스" onChange={() => {}} onSubmit={onSubmit} />
)
return view.getByRole('textbox') as HTMLInputElement
}
afterEach(cleanup)
describe('LinearSubIssueTitleInput IME Enter ownership', () => {
it('does not create a sub-issue on the recorded Korean Enter redispatch', () => {
const onSubmit = vi.fn()
const input = renderInput(onSubmit)
expect(dispatchRecordedGesture(input)).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it('creates one sub-issue on an ordinary Enter', () => {
const onSubmit = vi.fn()
const input = renderInput(onSubmit)
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmit).toHaveBeenCalledOnce()
})
})
@@ -51,6 +51,7 @@ import {
} from '@/lib/linear-issue-workspace-attachment'
import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open'
import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
@@ -127,6 +128,39 @@ function LinearIssueAvatar({
)
}
export function LinearSubIssueTitleInput({
value,
onChange,
onSubmit
}: {
value: string
onChange: (value: string) => void
onSubmit: () => void
}): React.JSX.Element {
const imeEnter = useImeEnterGestureOwnership()
return (
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyDown={(event) => {
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
onSubmit()
}
}}
onKeyUp={imeEnter.onKeyUp}
onBlur={imeEnter.reset}
placeholder={translate('auto.components.LinearIssueWorkspace.c182e02de5', 'Sub-issue title')}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)
}
function LinearIssueSubIssueButton({
issue,
onOpenIssue,
@@ -300,20 +334,10 @@ function LinearIssueSubIssueButton({
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start">
<div className="space-y-3">
<input
<LinearSubIssueTitleInput
value={title}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleCreate()
}
}}
placeholder={translate(
'auto.components.LinearIssueWorkspace.c182e02de5',
'Sub-issue title'
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
onChange={setTitle}
onSubmit={() => void handleCreate()}
/>
<div className="flex justify-end">
<Button
@@ -32,6 +32,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
@@ -168,6 +169,8 @@ export function LinearIssueEditSection({
labels: localLabels
} = editState
const [estimateInput, setEstimateInput] = useState(() => formatLinearEstimateInput(localEstimate))
const propertiesEstimateImeEnter = useImeEnterGestureOwnership()
const chipsEstimateImeEnter = useImeEnterGestureOwnership()
const teamId = issue.team?.id || null
const states = useTeamStates(teamId, providerSettings, issue.workspaceId)
@@ -638,12 +641,19 @@ export function LinearIssueEditSection({
<Input
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onCompositionStart={() => propertiesEstimateImeEnter.setComposing(true)}
onCompositionEnd={() => propertiesEstimateImeEnter.setComposing(false)}
onKeyDown={(event) => {
if (propertiesEstimateImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
}
}}
onKeyUp={propertiesEstimateImeEnter.onKeyUp}
onBlur={propertiesEstimateImeEnter.reset}
inputMode="numeric"
placeholder={translate(
'auto.components.LinearItemDrawer.fbb90300e2',
@@ -884,12 +894,19 @@ export function LinearIssueEditSection({
<Input
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onCompositionStart={() => chipsEstimateImeEnter.setComposing(true)}
onCompositionEnd={() => chipsEstimateImeEnter.setComposing(false)}
onKeyDown={(event) => {
if (chipsEstimateImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
}
}}
onKeyUp={chipsEstimateImeEnter.onKeyUp}
onBlur={chipsEstimateImeEnter.reset}
inputMode="numeric"
placeholder={translate(
'auto.components.LinearItemDrawer.fbb90300e2',
@@ -19,6 +19,7 @@ import {
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
import type {
GitHubWorkItem,
TuiAgent,
@@ -273,6 +274,12 @@ function QuickTabBody({
return
}
// Closes the gap isScreenSubmitShortcut leaves: it reads only isComposing, so an
// IME that reports keyCode 229 without it would still submit mid-composition.
if (isImeOwnedKeyboardEvent(event)) {
return
}
// Why: workspace creation is screen-local submit behavior, not a
// user-configurable app command.
if (!isScreenSubmitShortcut(event)) {
@@ -63,6 +63,7 @@ import {
} from '@/components/ui/dropdown-menu'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { setWithLRU } from '@/lib/scroll-cache'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
@@ -445,6 +446,7 @@ function PRReviewersPanel({
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const reviewerPanelMountedRef = useRef(true)
const reviewerImeEnter = useImeEnterGestureOwnership()
const cancelReviewerInputFocusFrame = useCallback((): void => {
if (reviewerInputFocusFrameRef.current !== null) {
@@ -935,6 +937,10 @@ function PRReviewersPanel({
ref={reviewerInputRef}
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
onCompositionStart={() => reviewerImeEnter.setComposing(true)}
onCompositionEnd={() => reviewerImeEnter.setComposing(false)}
onKeyUp={reviewerImeEnter.onKeyUp}
onBlur={reviewerImeEnter.reset}
disabled={submitting || !canRequestReview}
placeholder={translate(
'auto.components.PullRequestPage.3bde131f49',
@@ -945,6 +951,9 @@ function PRReviewersPanel({
aria-haspopup="listbox"
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
onKeyDown={(event) => {
if (reviewerImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex(
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('monaco-editor', () => ({}))
vi.mock('@/lib/monaco-setup', () => ({ monaco: {} }))
vi.mock('@monaco-editor/react', () => ({
default: () => null,
DiffEditor: () => null
}))
import { TaskCreationTitleInput } from './TaskPage'
type Surface = {
name: string
placeholder: string
variant?: 'default' | 'plain'
}
const surfaces: Surface[] = [
{ name: 'GitHub issue', placeholder: 'GitHub issue title' },
{ name: 'Linear project', placeholder: 'Linear project name', variant: 'plain' },
{ name: 'Linear issue', placeholder: 'Linear issue title', variant: 'plain' },
{ name: 'Jira issue', placeholder: 'Jira issue title' }
]
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderSurface(surface: Surface, onSubmit: (value: string) => void): HTMLInputElement {
const view = render(
<TaskCreationTitleInput
value="테스"
onChange={() => {}}
onSubmit={onSubmit}
placeholder={surface.placeholder}
disabled={false}
variant={surface.variant}
/>
)
return view.getByPlaceholderText(surface.placeholder) as HTMLInputElement
}
function dispatchRecordedGesture(input: HTMLInputElement): boolean {
act(() => input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
act(() => input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })))
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
afterEach(cleanup)
describe('TaskPage creation title IME Enter ownership', () => {
for (const surface of surfaces) {
it(`${surface.name} does not create on the recorded Korean Enter redispatch`, () => {
const onSubmit = vi.fn()
const input = renderSurface(surface, onSubmit)
expect(dispatchRecordedGesture(input)).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it(`${surface.name} creates exactly once on ordinary Enter`, () => {
const onSubmit = vi.fn()
const input = renderSurface(surface, onSubmit)
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit).toHaveBeenCalledWith('테스')
})
}
it('keeps Jira and Linear issue ownership isolated', () => {
const onJiraSubmit = vi.fn()
const onLinearSubmit = vi.fn()
const view = render(
<>
<TaskCreationTitleInput
value="지라"
onChange={() => {}}
onSubmit={onJiraSubmit}
placeholder="Jira isolation title"
disabled={false}
/>
<TaskCreationTitleInput
value="리니어"
onChange={() => {}}
onSubmit={onLinearSubmit}
placeholder="Linear isolation title"
disabled={false}
variant="plain"
/>
</>
)
const jira = view.getByPlaceholderText('Jira isolation title') as HTMLInputElement
const linear = view.getByPlaceholderText('Linear isolation title') as HTMLInputElement
act(() => jira.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchKey(jira, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
act(() => jira.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })))
dispatchKey(linear, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onJiraSubmit).not.toHaveBeenCalled()
expect(onLinearSubmit).toHaveBeenCalledOnce()
expect(onLinearSubmit).toHaveBeenCalledWith('리니어')
})
})
+83 -39
View File
@@ -379,6 +379,7 @@ import {
clampLinearIssueListLimit
} from '../../../shared/linear-issue-read-limits'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import {
@@ -419,6 +420,7 @@ import {
} from '../../../shared/task-providers'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
import { isLatinShortcutKey } from '@/lib/ime-latin-shortcut-key'
import {
getGitHubModeButtons,
getGitHubTaskKindPresets,
@@ -470,6 +472,53 @@ const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = 'group-hover/github-task-row:bg-acce
const GITHUB_TASK_HEADER_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--muted)_25%,var(--background))]'
type TaskCreationTitleInputProps = {
value: string
onChange: (value: string) => void
onSubmit: (value: string) => void
placeholder: string
disabled: boolean
variant?: 'default' | 'plain'
className?: string
}
export function TaskCreationTitleInput({
value,
onChange,
onSubmit,
placeholder,
disabled,
variant = 'default',
className
}: TaskCreationTitleInputProps): React.JSX.Element {
const imeEnter = useImeEnterGestureOwnership()
const imeProps = {
onBlur: imeEnter.reset,
onCompositionStart: () => imeEnter.setComposing(true),
onCompositionEnd: () => imeEnter.setComposing(false),
onKeyUp: imeEnter.onKeyUp,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
onSubmit(event.currentTarget.value)
}
}
}
const props = {
autoFocus: true,
value,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.value),
placeholder,
disabled,
className,
...imeProps
}
return variant === 'plain' ? <input {...props} /> : <Input {...props} />
}
function getGitHubWorkItemWorkspaceSeed(item: GitHubWorkItem): string {
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
}
@@ -2165,6 +2214,7 @@ function PRReviewCell({
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerTriggerRef = useRef<HTMLButtonElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const reviewerImeEnter = useImeEnterGestureOwnership()
const cancelReviewerInputFocusFrame = useCallback((): void => {
if (reviewerInputFocusFrameRef.current === null) {
@@ -2617,12 +2667,19 @@ function PRReviewCell({
ref={setReviewerInputNode}
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
onCompositionStart={() => reviewerImeEnter.setComposing(true)}
onCompositionEnd={() => reviewerImeEnter.setComposing(false)}
onKeyUp={reviewerImeEnter.onKeyUp}
onBlur={reviewerImeEnter.reset}
placeholder={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')}
disabled={!repo || submitting}
className="h-8 rounded-md bg-background px-2 text-[13px]"
aria-label={translate('auto.components.TaskPage.0b9b04f4b5', 'Type or choose a user')}
aria-autocomplete="list"
onKeyDown={(event) => {
if (reviewerImeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length)
@@ -7218,7 +7275,11 @@ export default function TaskPage(): React.JSX.Element {
// React SyntheticEvent does not expose isComposing; use nativeEvent.
if (
shouldSuppressEnterSubmit(
{ isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey },
{
isComposing: event.nativeEvent.isComposing,
keyCode: event.keyCode,
shiftKey: event.shiftKey
},
false
)
) {
@@ -7248,7 +7309,7 @@ export default function TaskPage(): React.JSX.Element {
const onKeyDown = (event: KeyboardEvent): void => {
const isMac = navigator.userAgent.includes('Mac')
const modifierPressed = isMac ? event.metaKey : event.ctrlKey
if (!modifierPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f') {
if (!modifierPressed || event.altKey || event.shiftKey || !isLatinShortcutKey(event, 'f')) {
return
}
@@ -9544,6 +9605,7 @@ export default function TaskPage(): React.JSX.Element {
shouldSuppressEnterSubmit(
{
isComposing: e.nativeEvent.isComposing,
keyCode: e.keyCode,
shiftKey: e.shiftKey
},
false
@@ -9745,7 +9807,11 @@ export default function TaskPage(): React.JSX.Element {
if (e.key === 'Enter') {
if (
shouldSuppressEnterSubmit(
{ isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey },
{
isComposing: e.nativeEvent.isComposing,
keyCode: e.keyCode,
shiftKey: e.shiftKey
},
false
)
) {
@@ -12093,16 +12159,10 @@ export default function TaskPage(): React.JSX.Element {
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.16cba35bee', 'Title')}
</label>
<Input
autoFocus
<TaskCreationTitleInput
value={newIssueTitle}
onChange={(e) => setNewIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewIssue()
}
}}
onChange={setNewIssueTitle}
onSubmit={() => void handleCreateNewIssue()}
placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')}
disabled={newIssueSubmitting}
/>
@@ -12267,18 +12327,13 @@ export default function TaskPage(): React.JSX.Element {
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5 scrollbar-sleek">
<input
autoFocus
<TaskCreationTitleInput
value={newLinearProjectName}
onChange={(event) => setNewLinearProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void handleCreateNewLinearProject()
}
}}
onChange={setNewLinearProjectName}
onSubmit={() => void handleCreateNewLinearProject()}
placeholder={translate('auto.components.TaskPage.ecbcc83140', 'Project name')}
disabled={newLinearProjectSubmitting}
variant="plain"
className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0"
/>
@@ -12714,18 +12769,13 @@ export default function TaskPage(): React.JSX.Element {
{/* Form Content */}
<div className="flex flex-col px-6 py-4 gap-3">
{/* Title */}
<input
autoFocus
<TaskCreationTitleInput
value={newLinearIssueTitle}
onChange={(e) => setNewLinearIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewLinearIssue()
}
}}
onChange={setNewLinearIssueTitle}
onSubmit={() => void handleCreateNewLinearIssue()}
placeholder={translate('auto.components.TaskPage.d9151fd4e9', 'Issue title')}
disabled={newLinearIssueSubmitting}
variant="plain"
className="text-lg font-semibold bg-transparent border-none outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 p-0 placeholder:text-muted-foreground/40 text-foreground w-full"
/>
@@ -13314,16 +13364,10 @@ export default function TaskPage(): React.JSX.Element {
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.TaskPage.16cba35bee', 'Title')}
</label>
<Input
autoFocus
<TaskCreationTitleInput
value={newJiraIssueTitle}
onChange={(e) => setNewJiraIssueTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void handleCreateNewJiraIssue()
}
}}
onChange={setNewJiraIssueTitle}
onSubmit={() => void handleCreateNewJiraIssue()}
placeholder={translate('auto.components.TaskPage.578f730c16', 'Short summary')}
disabled={newJiraIssueSubmitting}
/>
@@ -82,6 +82,7 @@ import {
resolveActivityThreadStatusPreview
} from '@/lib/activity-thread-display'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { isLatinShortcutKey } from '@/lib/ime-latin-shortcut-key'
type ThreadReadFilter = 'all' | 'unread'
type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent'
@@ -1103,7 +1104,7 @@ export function isActivityFilterFocusShortcut(
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
isMac = navigator.userAgent.includes('Mac')
): boolean {
if (event.key.toLowerCase() !== 'f' || event.shiftKey || event.altKey) {
if (!isLatinShortcutKey(event, 'f') || event.shiftKey || event.altKey) {
return false
}
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
@@ -41,6 +41,39 @@ vi.mock('@/components/ui/command', () => ({
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
function dispatchAddressBarKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function dispatchRecordedAddressBarGesture(input: HTMLInputElement): boolean {
act(() => input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })))
dispatchAddressBarKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
act(() =>
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '가' }))
)
const prevented = dispatchAddressBarKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchAddressBarKey(input, 'keyup', { key: 'Process', code: 'Enter', keyCode: 229 })
dispatchAddressBarKey(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
return prevented
}
function historyEntry(overrides: Partial<BrowserHistoryEntry>): BrowserHistoryEntry {
return {
url: 'http://localhost:3000/review-one',
@@ -248,4 +281,39 @@ describe('BrowserAddressBar autocomplete preview', () => {
expect(onNavigate).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
it('does not navigate on the recorded Korean Enter redispatch', async () => {
const onSubmit = vi.fn()
await act(async () => {
root.render(
<AddressBarHarness initialValue="한글" onNavigate={() => {}} onSubmit={onSubmit} />
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
act(() => input.focus())
expect(dispatchRecordedAddressBarGesture(input)).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it('navigates exactly once on an ordinary Enter', async () => {
const onSubmit = vi.fn()
await act(async () => {
root.render(
<AddressBarHarness initialValue="example.com" onNavigate={() => {}} onSubmit={onSubmit} />
)
})
const input = container.querySelector<HTMLInputElement>('input[data-orca-browser-address-bar]')!
act(() => input.focus())
expect(
dispatchAddressBarKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
).toBe(true)
expect(onSubmit).toHaveBeenCalledOnce()
})
})
@@ -4,6 +4,7 @@ import { Globe } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { useAppStore } from '@/store'
import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url'
import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions'
@@ -41,6 +42,7 @@ export default function BrowserAddressBar({
const blurCloseTimerRef = useRef<number | null>(null)
const closingResetTimerRef = useRef<number | null>(null)
const slotRef = useRef<HTMLDivElement | null>(null)
const imeEnter = useImeEnterGestureOwnership()
const [inlineWidth, setInlineWidth] = useState<number | null>(null)
// Why: the slot keeps its flex width even while the bar overlays the toolbar,
@@ -186,6 +188,7 @@ export default function BrowserAddressBar({
}, [inputRef])
const handleBlur = useCallback(() => {
imeEnter.reset()
// Why: delay close so that clicking a suggestion item registers before
// the popover unmounts. Without this, onSelect never fires because the
// mousedown on PopoverContent triggers input blur first.
@@ -208,7 +211,7 @@ export default function BrowserAddressBar({
restoreTypedQuery()
setOpen(false)
}, 200)
}, [inputRef, restoreTypedQuery])
}, [imeEnter, inputRef, restoreTypedQuery])
const handleSelect = useCallback(
(url: string) => {
@@ -229,6 +232,11 @@ export default function BrowserAddressBar({
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (imeEnter.ownsKeyDown(event)) {
event.preventDefault()
event.stopPropagation()
return
}
if (event.key === 'Escape') {
cancelSuggestionPreview()
return
@@ -280,6 +288,7 @@ export default function BrowserAddressBar({
}
},
[
imeEnter,
open,
suggestions,
selectedValue,
@@ -409,7 +418,10 @@ export default function BrowserAddressBar({
value={value}
onFocus={handleFocus}
onBlur={handleBlur}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyDown={handleKeyDown}
onKeyUp={imeEnter.onKeyUp}
data-orca-browser-address-bar="true"
className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
spellCheck={false}
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
dispatchOrdinaryImplicitSubmit,
dispatchRecordedImeImplicitSubmit
} from '../ime-enter-guarded-form.test-events'
import { BrowserToolbarProfileDialogs } from './browser-toolbar-profile-dialogs'
function renderDialogs(onCreateProfile: () => void): HTMLInputElement {
render(
<BrowserToolbarProfileDialogs
pendingSwitchProfileId={undefined}
onPendingSwitchChange={() => {}}
onConfirmSwitch={() => {}}
newProfileDialogOpen
onNewProfileDialogOpenChange={() => {}}
newProfileName="한국 프로필"
onNewProfileNameChange={() => {}}
isCreatingProfile={false}
useNativeUserAgent={false}
onUseNativeUserAgentChange={() => {}}
onCreateProfile={onCreateProfile}
onCancelNewProfile={() => {}}
/>
)
return screen.getByPlaceholderText('Profile name') as HTMLInputElement
}
afterEach(cleanup)
describe('BrowserToolbarProfileDialogs IME implicit submit', () => {
it('does not create a browser profile on the recorded Korean Enter redispatch', () => {
const onCreateProfile = vi.fn()
const input = renderDialogs(onCreateProfile)
expect(dispatchRecordedImeImplicitSubmit(input)).toBe(true)
expect(onCreateProfile).not.toHaveBeenCalled()
})
it('creates a browser profile exactly once on an ordinary Enter', () => {
const onCreateProfile = vi.fn()
const input = renderDialogs(onCreateProfile)
expect(dispatchOrdinaryImplicitSubmit(input)).toBe(false)
expect(onCreateProfile).toHaveBeenCalledOnce()
})
})
@@ -1,3 +1,4 @@
import { ImeEnterGuardedForm } from '@/components/ime-enter-guarded-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@@ -86,7 +87,7 @@ export function BrowserToolbarProfileDialogs({
)}
</DialogTitle>
</DialogHeader>
<form
<ImeEnterGuardedForm
onSubmit={(e) => {
e.preventDefault()
onCreateProfile()
@@ -129,7 +130,7 @@ export function BrowserToolbarProfileDialogs({
)}
</Button>
</DialogFooter>
</form>
</ImeEnterGuardedForm>
</DialogContent>
</Dialog>
</>
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const editor = vi.hoisted(() => ({
commitPendingText: vi.fn(),
cancelPendingText: vi.fn()
}))
vi.mock('./useMarkupEditor', async () => {
const { createRef } = await import('react')
return {
useMarkupEditor: () => ({
rootRef: createRef<HTMLDivElement>(),
canvasRef: createRef<HTMLCanvasElement>(),
textInputRef: createRef<HTMLInputElement>(),
tool: 'text',
color: '#ffffff',
width: 2,
fontSize: 16,
pendingText: { x: 10, y: 20, initial: '테스' },
shapes: [],
canUndo: false,
canRedo: false,
setTool: vi.fn(),
setColor: vi.fn(),
setWidth: vi.fn(),
setFontSize: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
clear: vi.fn(),
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
commitPendingText: editor.commitPendingText,
cancelPendingText: editor.cancelPendingText
})
}
})
import { MarkupOverlay } from './MarkupOverlay'
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderInput(): HTMLInputElement {
const view = render(
<MarkupOverlay
baseImage={{ dataUrl: 'data:image/png;base64,', width: 100, height: 100 }}
busy={false}
onComplete={() => {}}
onCancel={() => {}}
/>
)
return view.getByRole('textbox', { name: 'Annotation text' }) as HTMLInputElement
}
function dispatchRecordedGesture(input: HTMLInputElement): boolean {
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
afterEach(() => {
cleanup()
editor.commitPendingText.mockClear()
editor.cancelPendingText.mockClear()
})
describe('MarkupOverlay IME Enter ownership', () => {
it('does not commit on the recorded Korean Enter redispatch', () => {
const input = renderInput()
expect(dispatchRecordedGesture(input)).toBe(true)
expect(editor.commitPendingText).not.toHaveBeenCalled()
})
it('commits exactly once on ordinary Enter', () => {
const input = renderInput()
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(editor.commitPendingText).toHaveBeenCalledOnce()
expect(editor.commitPendingText).toHaveBeenCalledWith('테스')
})
})
@@ -3,6 +3,7 @@ import { Check, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { MarkupToolbar } from './MarkupToolbar'
import type { MarkupBaseImage } from './markup-base-image'
import type { MarkupShape } from './markup-drawing-model'
@@ -23,6 +24,7 @@ export function MarkupOverlay({
onCancel
}: MarkupOverlayProps): React.JSX.Element {
const baseImgRef = useRef<HTMLImageElement | null>(null)
const imeEnter = useImeEnterGestureOwnership()
const [baseLoaded, setBaseLoaded] = useState(false)
const editor = useMarkupEditor(busy, onCancel)
const { pendingText } = editor
@@ -76,14 +78,21 @@ export function MarkupOverlay({
defaultValue={pendingText.initial}
aria-label={translate('auto.components.browser-pane.markup.textInput', 'Annotation text')}
onPointerDown={(event) => event.stopPropagation()}
onBlur={(event) => editor.commitPendingText(event.target.value)}
onBlur={(event) => {
imeEnter.reset()
editor.commitPendingText(event.target.value)
}}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(event) => {
// Why: keep keystrokes local — without this the browser pane's global
// key handlers can swallow typing before it reaches the input.
event.stopPropagation()
// Why: during IME composition (e.g. Japanese conversion), Enter
// confirms the candidate — it must NOT also commit the annotation.
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
editor.commitPendingText(event.currentTarget.value)
} else if (event.key === 'Escape') {
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { isLatinShortcutKey } from '@/lib/ime-latin-shortcut-key'
export type PendingText = { x: number; y: number; initial: string }
@@ -37,7 +38,7 @@ export function useMarkupKeyboardShortcuts(params: MarkupKeyboardParams): void {
return
}
const mod = isMac ? event.metaKey : event.ctrlKey
if (mod && event.key.toLowerCase() === 'z') {
if (mod && isLatinShortcutKey(event, 'z')) {
event.preventDefault()
if (event.shiftKey) {
redo()
@@ -38,17 +38,6 @@ const storeState = vi.hoisted(() => ({
keybindings: {} as Record<string, string[]>
}))
const imeHarness = vi.hoisted(() => ({
forwarders: [] as {
claimKeyEvent: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
sendInput: (data: string) => void
}[],
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
claimResult: false,
inputSourceTrackerRequests: 0
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
@@ -113,30 +102,6 @@ vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({
vi.mock('@/lib/shortcut-platform', () => ({
getShortcutPlatform: () => platformState.value
}))
vi.mock('@/components/terminal-pane/terminal-ime-native-text-forwarder', () => ({
installTerminalImeNativeTextForwarder: (args: { sendInput: (data: string) => void }) => {
const forwarder = {
claimKeyEvent: vi.fn(() => imeHarness.claimResult),
dispose: vi.fn(),
sendInput: args.sendInput
}
imeHarness.forwarders.push(forwarder)
return forwarder
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-composition-tracker', () => ({
installTerminalImeCompositionTracker: () => {
const tracker = { isActive: () => false, dispose: vi.fn() }
imeHarness.trackers.push(tracker)
return tracker
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-source', () => ({
getMacNativeTextInputSourceTracker: () => {
imeHarness.inputSourceTrackerRequests++
return { getFeatures: () => ({}) }
}
}))
vi.mock('@/store', () => {
const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState)
useAppStore.getState = (): typeof storeState => storeState
@@ -162,10 +127,6 @@ describe('AgentTerminalPreview', () => {
terminalHarness.userInputListener = null
platformState.value = 'linux'
storeState.keybindings = {}
imeHarness.forwarders.length = 0
imeHarness.trackers.length = 0
imeHarness.claimResult = false
imeHarness.inputSourceTrackerRequests = 0
emitData = null
emitAppMenuPaste = null
connect.mockResolvedValue({
@@ -227,78 +188,6 @@ describe('AgentTerminalPreview', () => {
expect(ack).toHaveBeenCalledWith('pty-1', 4)
})
it('installs the macOS IME native-text forwarder and lets its claims bypass chord handling', async () => {
platformState.value = 'darwin'
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull())
expect(imeHarness.forwarders).toHaveLength(1)
expect(imeHarness.trackers).toHaveLength(1)
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
imeHarness.forwarders[0]!.sendInput('。')
expect(terminal.input).toHaveBeenCalledOnce()
expect(input).toHaveBeenCalledOnce()
expect(input).toHaveBeenCalledWith('pty-1', '。')
// A claimed native-text key bypasses xterm AND the clipboard chords.
imeHarness.claimResult = true
terminal.selectionText = 'selected text'
const handled = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(handled).toBe(false)
expect(writeClipboardText).not.toHaveBeenCalled()
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
// Unclaimed events still reach the chord handling.
imeHarness.claimResult = false
const copied = terminal.customKeyHandler!(
new KeyboardEvent('keydown', { key: 'C', code: 'KeyC', metaKey: true, shiftKey: true })
)
expect(copied).toBe(false)
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text')
expect(writeClipboardText).not.toHaveBeenCalled()
expect(imeHarness.inputSourceTrackerRequests).toBe(1)
})
it('does not install the IME native-text forwarder off macOS', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
await waitFor(() => expect(terminalHarness.instances[0]!.customKeyHandler).not.toBeNull())
expect(imeHarness.forwarders).toHaveLength(0)
expect(imeHarness.trackers).toHaveLength(0)
})
it('disposes the IME bridge on unmount', async () => {
platformState.value = 'darwin'
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
view.unmount()
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledTimes(1)
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledTimes(1)
})
it('disposes the IME bridge once when the PTY disappears', async () => {
platformState.value = 'darwin'
connect.mockResolvedValueOnce({
snapshot: { data: '', cols: 80, rows: 24, seq: 1 },
replay: []
})
connect.mockResolvedValueOnce({ snapshot: null, replay: [] })
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1))
act(() => emitData?.({ type: 'resync', ptyId: 'pty-1' }))
await waitFor(() => expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce())
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
view.unmount()
expect(imeHarness.forwarders[0]!.dispose).toHaveBeenCalledOnce()
expect(imeHarness.trackers[0]!.dispose).toHaveBeenCalledOnce()
})
it('copies the terminal selection on the copy chord and blocks xterm handling', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
@@ -14,7 +14,6 @@ import {
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility'
import { createPreviewClipboardPaster } from './preview-terminal-paste'
import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import { translate } from '@/i18n/i18n'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
@@ -102,7 +101,6 @@ export function AgentTerminalPreview({
let terminal: Terminal | null = null
let offData: (() => void) | null = null
let userInputDisposable: { dispose: () => void } | null = null
let imeBridge: PreviewImeBridge | null = null
let disposeKeyHandler: (() => void) | null = null
let disposeTerminalCompatibility: (() => void) | null = null
// Why: mirrors the pane's tracker — the policy needs the flags the TUI
@@ -202,24 +200,12 @@ export function AgentTerminalPreview({
isDisposed: () => disposed
})
const disposeImeNativeTextBridge = (): void => {
imeBridge?.dispose()
imeBridge = null
}
const installImeNativeTextBridge = (): void => {
if (terminal) {
imeBridge = installPreviewImeBridge(terminal)
}
}
const installKeyHandler = (): void => {
if (!terminal) {
return
}
disposeKeyHandler = installPreviewTerminalKeyHandler({
terminal,
claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
pasteClipboardText: (activeElement, source) =>
void pasteClipboardText(activeElement, source),
// Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
@@ -294,7 +280,6 @@ export function AgentTerminalPreview({
terminalRef.current = terminal
installTerminalCompatibility()
installInputRouting()
installImeNativeTextBridge()
installKeyHandler()
} else if (replaceExisting) {
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
@@ -364,7 +349,6 @@ export function AgentTerminalPreview({
offData = null
userInputDisposable?.dispose()
userInputDisposable = null
disposeImeNativeTextBridge()
disposeTerminalCompatibility?.()
disposeTerminalCompatibility = null
disposeKeyHandler?.()
@@ -416,7 +400,6 @@ export function AgentTerminalPreview({
offAppMenuPaste()
offData?.()
userInputDisposable?.dispose()
disposeImeNativeTextBridge()
disposeTerminalCompatibility?.()
disposeKeyHandler?.()
void window.api.terminalPreview.unsubscribe(ptyId)
@@ -5,7 +5,6 @@ import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal
import { installWindowsCtrlAltChordRepair } from '@/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification'
import { attachTerminalMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel'
import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
import { installTerminalImeCandidateAnchor } from '@/lib/pane-manager/terminal-ime-candidate-anchor'
import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-tui-wheel-reports'
import { installPreviewTerminalLinks } from './preview-terminal-links'
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
@@ -13,7 +12,7 @@ import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
/**
* Brings the preview's emulator up to a pane's: Orca's Unicode 11 width shim,
* Windows Ctrl+Alt chord classification, clickable links, ligatures, the TUI
* wheel multiplier, lazy Arabic shaping, and the IME candidate anchor.
* wheel multiplier, and lazy Arabic shaping.
*
* Returns a disposer for everything bound to the terminal's DOM element, which
* must run before that terminal is disposed or replaced.
@@ -39,13 +38,7 @@ export function installPreviewTerminalCompatibility(
// until RTL text arrives. Never shaping-active: the preview is DOM-rendered
// and has no WebGL glyph atlas to compensate for.
const disposeArabicShapingJoiner = configureLazyArabicShapingJoiner(terminal, () => false)
const imeAnchorHandler = installTerminalImeCandidateAnchor(terminal)
return () => {
if (imeAnchorHandler && terminal.element) {
terminal.element.removeEventListener('compositionstart', imeAnchorHandler)
terminal.element.removeEventListener('compositionupdate', imeAnchorHandler)
}
disposeArabicShapingJoiner()
}
}
@@ -1,40 +0,0 @@
import type { Terminal } from '@xterm/xterm'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { installTerminalImeCompositionTracker } from '@/components/terminal-pane/terminal-ime-composition-tracker'
import { installTerminalImeNativeTextForwarder } from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
export type PreviewImeBridge = {
/** True when the forwarder owns this keydown, so xterm must not encode it. */
claimKeyEvent: (event: KeyboardEvent) => boolean
dispose: () => void
}
/**
* Native-text bridge for the preview terminal.
*
* Why: xterm's kitty encoder can encode+cancel a printable keydown before
* Chromium commits IME/native text, silently dropping the glyph. Mirrors
* TerminalPane's forwarder, macOS-only like the pane's install.
*/
export function installPreviewImeBridge(terminal: Terminal): PreviewImeBridge | null {
if (getShortcutPlatform() !== 'darwin') {
return null
}
// Why: prewarm the async input-source lookup before the first native-text key needs classification.
const inputSourceTracker = getMacNativeTextInputSourceTracker()
const compositionTracker = installTerminalImeCompositionTracker(terminal.element)
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: terminal.element,
isComposing: () => compositionTracker?.isActive() ?? false,
sendInput: (data) => terminal.input(data),
getInputSourceFeatures: () => inputSourceTracker.getFeatures()
})
return {
claimKeyEvent: (event) => forwarder?.claimKeyEvent(event) ?? false,
dispose: () => {
forwarder?.dispose()
compositionTracker?.dispose()
}
}
}
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Terminal } from '@xterm/xterm'
import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler'
const shortcutPlatform = vi.hoisted(() => ({ value: 'linux' as 'darwin' | 'linux' }))
vi.mock('@/lib/shortcut-platform', () => ({ getShortcutPlatform: () => shortcutPlatform.value }))
vi.mock('@/store', () => ({
useAppStore: { getState: () => ({ keybindings: undefined }) }
}))
describe('preview terminal IME action ownership', () => {
let handler: ((event: KeyboardEvent) => boolean) | null
let writeTerminalClipboardText: ReturnType<typeof vi.fn>
beforeEach(() => {
handler = null
shortcutPlatform.value = 'linux'
writeTerminalClipboardText = vi.fn(async () => undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { writeTerminalClipboardText } }
})
})
function install(kittyKeyboardActive = false): void {
const terminal = {
attachCustomKeyEventHandler: (next: (event: KeyboardEvent) => boolean) => {
handler = next
},
getSelection: () => 'selected'
} as unknown as Terminal
installPreviewTerminalKeyHandler({
terminal,
pasteClipboardText: vi.fn(),
sendInput: vi.fn(),
getShortcutContext: () => ({
clientPlatform: 'linux',
macOptionAsAlt: 'false',
keybindings: undefined,
terminalInput: null,
kittyKeyboardActive: () => kittyKeyboardActive,
terminalShortcutPolicy: 'orca-first'
})
})
}
function copyEvent(isComposing: boolean): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
bubbles: true,
ctrlKey: true,
isComposing,
key: 'C',
shiftKey: true
})
Object.defineProperty(event, 'keyCode', { value: 13 })
return event
}
it('refuses the marked real key shape before the copy action', () => {
install()
expect(handler?.(copyEvent(true))).toBe(true)
expect(writeTerminalClipboardText).not.toHaveBeenCalled()
})
it('leaves the ordinary copy shortcut unchanged', () => {
install()
expect(handler?.(copyEvent(false))).toBe(false)
expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected')
})
it('lets macOS own the recorded unmarked initial jamo', () => {
shortcutPlatform.value = 'darwin'
install()
const event = new KeyboardEvent('keydown', { key: 'ㄱ' })
Object.defineProperties(event, {
code: { value: 'KeyR' },
keyCode: { value: 82 }
})
expect(handler?.(event)).toBe(false)
})
it('leaves ordinary unmodified Latin input with xterm', () => {
shortcutPlatform.value = 'darwin'
install()
const event = new KeyboardEvent('keydown', { code: 'KeyR', key: 'r' })
Object.defineProperty(event, 'keyCode', { value: 82 })
expect(handler?.(event)).toBe(true)
})
it('leaves physical Backslash to native macOS keypress', () => {
shortcutPlatform.value = 'darwin'
install()
expect(handler?.(new KeyboardEvent('keydown', { code: 'Backslash', key: '\\' }))).toBe(false)
})
it('keeps physical Backslash in xterm while kitty reporting is active', () => {
shortcutPlatform.value = 'darwin'
install(true)
expect(handler?.(new KeyboardEvent('keydown', { code: 'Backslash', key: '\\' }))).toBe(true)
})
})
@@ -4,15 +4,19 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { useAppStore } from '@/store'
import { prefetchLayoutBaseCharacters } from '@/lib/keyboard-layout/layout-base-character'
import { createTerminalNativeOnlyShortcutTracker } from '@/components/terminal-pane/terminal-native-only-shortcut'
import { installTerminalNativeInputListeners } from '@/components/terminal-pane/terminal-native-input-listeners'
import {
resolvePreviewShortcutAction,
type PreviewShortcutContext
} from './preview-terminal-shortcuts'
import { isImeOwnedKeyboardEvent } from '@/lib/ime-composition-keyboard-event'
import { shouldBypassXtermForMacNativeText } from '@/components/terminal-pane/xterm-bypass-policy'
import { isLatinShortcutKey } from '@/lib/ime-latin-shortcut-key'
/**
* Installs the preview terminal's ONE custom key handler (xterm allows a single
* attachCustomKeyEventHandler) covering copy/paste chords, the IME native-text
* bypass, and the full pane shortcut policy. Plain Mod+V is left to the
* attachCustomKeyEventHandler) covering copy/paste chords and the full pane
* shortcut policy. Plain Mod+V is left to the
* Edit-menu accelerator, which reaches this window as ui:appMenuPaste matching
* it here too would paste twice.
*
@@ -21,7 +25,6 @@ import {
*/
export function installPreviewTerminalKeyHandler(args: {
terminal: Terminal
claimImeKeyEvent: (event: KeyboardEvent) => boolean
pasteClipboardText: (activeElement: Element | null, source: 'keyboard') => void
sendInput: (data: string) => void
/** Everything but optionKeyLocation, which this installer tracks itself. */
@@ -37,57 +40,33 @@ export function installPreviewTerminalKeyHandler(args: {
return false
}
// Why: a character key's KeyboardEvent.location reports its own position, so
// left-vs-right Option must be recorded from the modifier's own keydown.
let optionKeyLocation = 0
const onModifierDown = (event: KeyboardEvent): void => {
if (event.key === 'Alt') {
optionKeyLocation = event.location
}
}
const onModifierUp = (event: KeyboardEvent): void => {
if (event.key === 'Alt') {
optionKeyLocation = 0
}
}
const onWindowBlur = (): void => {
optionKeyLocation = 0
nativeOnlyShortcutTracker.clear()
}
const onNativeOnlyShortcutCompanion = (event: KeyboardEvent): void => {
if (!nativeOnlyShortcutTracker.consumeCompanion(event)) {
return
}
if (event.type === 'keypress') {
event.preventDefault()
}
event.stopImmediatePropagation()
}
const onNativeOnlyBeforeInput = (event: Event): void => {
if (
!(event instanceof InputEvent) ||
!nativeOnlyShortcutTracker.shouldSuppressBeforeInput(event)
) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
}
const disposeNativeInputListeners = installTerminalNativeInputListeners(
window,
nativeOnlyShortcutTracker,
(location) => {
optionKeyLocation = location
},
// Why: the preview dialog has dropped the tracked side on blur since #11015.
{ forgetOptionKeyLocationOnBlur: true }
)
if (platform === 'darwin') {
// Why: kitty Option-chord encoding resolves base keys through the async
// KeyboardLayoutMap; prefetch so the map is cached before the first chord.
prefetchLayoutBaseCharacters()
}
window.addEventListener('keydown', onModifierDown, true)
window.addEventListener('keyup', onModifierUp, true)
window.addEventListener('keypress', onNativeOnlyShortcutCompanion, true)
window.addEventListener('keyup', onNativeOnlyShortcutCompanion, true)
window.addEventListener('beforeinput', onNativeOnlyBeforeInput, true)
window.addEventListener('blur', onWindowBlur)
terminal.attachCustomKeyEventHandler((event) => {
if (args.claimImeKeyEvent(event)) {
// Why: bypass xterm's kitty encoder for native-text keydowns so the committed glyph survives via the input event.
if (isImeOwnedKeyboardEvent(event)) {
return true
}
if (
shouldBypassXtermForMacNativeText(
event,
platform === 'darwin',
args.getShortcutContext().kittyKeyboardActive()
)
) {
return false
}
if (event.type !== 'keydown') {
@@ -116,7 +95,7 @@ export function installPreviewTerminalKeyHandler(args: {
(platform === 'darwin' ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === 'v'
isLatinShortcutKey(event, 'v')
if (
!isMenuPasteChord &&
keybindingMatchesAction('terminal.paste', event, platform, keybindings)
@@ -171,12 +150,5 @@ export function installPreviewTerminalKeyHandler(args: {
}
})
return () => {
window.removeEventListener('keydown', onModifierDown, true)
window.removeEventListener('keyup', onModifierUp, true)
window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, true)
window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, true)
window.removeEventListener('beforeinput', onNativeOnlyBeforeInput, true)
window.removeEventListener('blur', onWindowBlur)
}
return disposeNativeInputListeners
}
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DiffCommentCard } from './DiffCommentCard'
function dispatchKey(
input: HTMLTextAreaElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderEditor(onSubmitEdit: (body: string) => Promise<boolean>): HTMLTextAreaElement {
const view = render(
<DiffCommentCard lineNumber={1} body="original" onSubmitEdit={onSubmitEdit} />
)
fireEvent.click(view.getByRole('button', { name: 'Edit note' }))
const input = view.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: '테스' } })
return input
}
afterEach(cleanup)
describe('DiffCommentCard IME Enter ownership', () => {
it('does not publish on the recorded Korean Enter redispatch', () => {
const onSubmitEdit = vi.fn(async () => true)
const input = renderEditor(onSubmitEdit)
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
expect(prevented).toBe(true)
expect(onSubmitEdit).not.toHaveBeenCalled()
})
it('publishes exactly once on ordinary Enter', () => {
const onSubmitEdit = vi.fn(async () => true)
const input = renderEditor(onSubmitEdit)
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmitEdit).toHaveBeenCalledOnce()
expect(onSubmitEdit).toHaveBeenCalledWith('테스')
})
})
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
// Why: the saved-note card lives inside a Monaco view zone's DOM node.
// useDiffCommentDecorator creates a React root per zone and renders this
@@ -52,6 +53,7 @@ export function DiffCommentCard({
headerActions
}: Props): React.JSX.Element {
const [editing, setEditing] = useState(false)
const imeEnter = useImeEnterGestureOwnership()
const [draft, setDraft] = useState(body)
const [submitting, setSubmitting] = useState(false)
const mountedRef = useMountedRef()
@@ -286,13 +288,20 @@ export function DiffCommentCard({
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
onContentResizeRef.current?.()
}}
onBlur={imeEnter.reset}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(e) => {
if (imeEnter.ownsKeyDown(e)) {
return
}
if (e.key === 'Escape') {
e.preventDefault()
handleCancel()
return
}
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!canSubmit) {
return
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DiffCommentPopover } from './DiffCommentPopover'
function dispatchKey(
input: HTMLTextAreaElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderPopover(onSubmit: (body: string) => Promise<void>) {
const view = render(
<DiffCommentPopover lineNumber={1} top={0} onCancel={() => {}} onSubmit={onSubmit} />
)
return view.getByRole('textbox') as HTMLTextAreaElement
}
afterEach(cleanup)
describe('DiffCommentPopover IME Enter ownership', () => {
it('does not publish on the recorded Korean Enter redispatch', () => {
const onSubmit = vi.fn(async () => {})
const input = renderPopover(onSubmit)
fireEvent.change(input, { target: { value: '테스' } })
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
expect(prevented).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it('publishes exactly once on ordinary Enter', () => {
const onSubmit = vi.fn(async () => {})
const input = renderPopover(onSubmit)
fireEvent.change(input, { target: { value: 'ordinary comment' } })
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit).toHaveBeenCalledWith('ordinary comment')
})
})
describe('DiffCommentPopover Shift+Enter newline', () => {
// Regression: the carry matched any Enter/13 with no shiftKey check, so after a
// composition the Shift+Enter NEWLINE gesture was owned and preventDefault()ed —
// in a multi-line comment box, which is where newlines matter most.
it('never owns Shift+Enter, during composition or on the redispatch', () => {
const onSubmit = vi.fn(async () => {})
const input = renderPopover(onSubmit)
fireEvent.change(input, { target: { value: '테스' } })
fireEvent.compositionStart(input)
const marked = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: true,
shiftKey: true
})
fireEvent.compositionEnd(input, { data: '가' })
const redispatch = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false,
shiftKey: true
})
expect(marked).toBe(false)
expect(redispatch).toBe(false)
expect(onSubmit).not.toHaveBeenCalled()
})
})
@@ -9,6 +9,7 @@ import {
} from '@/lib/comment-body-submit-state'
import { translate } from '@/i18n/i18n'
import { installOpenDraftAddReviewNoteGuard } from '../editor/editor-shortcuts'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { resolveDiffCommentPopoverTop } from './diff-comment-popover-position'
// Why: a DOM sibling overlay rather than a Monaco content widget, so it can own a React auto-resizing textarea.
@@ -46,6 +47,7 @@ export function DiffCommentPopover({
onSubmit
}: Props): React.JSX.Element {
const [body, setBody] = useState('')
const imeEnter = useImeEnterGestureOwnership()
// Why: mirror the draft into a ref so the mousedown listener reads it fresh without re-registering each keystroke.
const bodyRef = useRef(body)
bodyRef.current = body
@@ -207,14 +209,20 @@ export function DiffCommentPopover({
setBody(e.target.value)
autoResize(e.currentTarget)
}}
onBlur={imeEnter.reset}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(e) => {
if (imeEnter.ownsKeyDown(e)) {
return
}
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
return
}
// Why: Shift+Enter inserts a newline; skip isComposing so IME composition Enter doesn't submit a half-typed CJK note.
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (submitting) {
return
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
const commitRename = vi.fn()
const cancelRename = vi.fn()
vi.mock('./editor-header-file-rename', () => ({
useEditorHeaderFileRename: () => ({
canRename: false,
currentFileName: '메모.md',
isRenaming: true,
renameInputRef: () => {},
openRenameInput: () => {},
commitRename,
cancelRename
})
}))
const { EditorPanelHeaderPath } = await import('./EditorPanelHeaderPath')
const activeFile = {
worktreeId: 'wt-1',
filePath: '/work/repo/메모.md',
mode: 'edit'
} as unknown as OpenFile
function dispatchKey(
input: HTMLInputElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
/** macOS 2-Set Korean confirm gesture: the confirming Enter is redispatched unmarked, after keyup. */
function dispatchRecordedGesture(input: HTMLInputElement): boolean {
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true })
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
function renderHeader(): HTMLInputElement {
render(
<EditorPanelHeaderPath
activeFile={activeFile}
copiedPathVisible={false}
canShowMarkdownPreview={false}
onCopyPath={() => {}}
onOpenMarkdownPreview={() => {}}
onOpenContainingFolder={() => {}}
/>
)
return screen.getByRole('textbox') as HTMLInputElement
}
beforeEach(() => {
commitRename.mockClear()
cancelRename.mockClear()
})
afterEach(cleanup)
describe('EditorPanelHeaderPath IME Enter ownership', () => {
it('does not rename the file on the recorded Korean confirm gesture', () => {
const input = renderHeader()
expect(dispatchRecordedGesture(input)).toBe(true)
expect(commitRename).not.toHaveBeenCalled()
})
it('renames exactly once on an ordinary Enter', () => {
const input = renderHeader()
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(commitRename).toHaveBeenCalledOnce()
})
it('still commits on blur', () => {
const input = renderHeader()
fireEvent.blur(input)
expect(commitRename).toHaveBeenCalledOnce()
})
})
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import type { OpenFile } from '@/store/slices/editor'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
@@ -59,6 +60,7 @@ export function EditorPanelHeaderPath({
commitRename,
cancelRename
} = useEditorHeaderFileRename(activeFile)
const imeEnter = useImeEnterGestureOwnership()
useEffect(() => {
const closeMenu = (): void => setPathMenuOpen(false)
@@ -95,7 +97,16 @@ export function EditorPanelHeaderPath({
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(event) => {
// Why: this Enter renames a file on disk; a conversion-confirm
// Enter (and the unmarked Enter/13 redispatched after
// compositionend) must not reach commitRename.
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
@@ -106,7 +117,10 @@ export function EditorPanelHeaderPath({
cancelRename()
}
}}
onBlur={commitRename}
onBlur={() => {
imeEnter.reset()
commitRename()
}}
/>
) : (
<button
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarkdownAnnotationComposer } from './MarkdownPreview'
function dispatchKey(
input: HTMLTextAreaElement,
type: 'keydown' | 'keyup',
init: KeyboardEventInit
): boolean {
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
act(() => input.dispatchEvent(event))
return event.defaultPrevented
}
function renderComposer(onSubmit: (body: string) => Promise<boolean>): HTMLTextAreaElement {
const view = render(
<MarkdownAnnotationComposer lineNumber={1} onCancel={() => {}} onSubmit={onSubmit} />
)
const input = view.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: '테스' } })
return input
}
function dispatchRecordedGesture(input: HTMLTextAreaElement): boolean {
fireEvent.compositionStart(input)
dispatchKey(input, 'keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
isComposing: true
})
fireEvent.compositionEnd(input, { data: '가' })
const prevented = dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
dispatchKey(input, 'keyup', { key: 'Process', keyCode: 229 })
dispatchKey(input, 'keyup', { key: 'Enter', keyCode: 13 })
return prevented
}
afterEach(cleanup)
describe('MarkdownAnnotationComposer IME Enter ownership', () => {
it('does not publish on the recorded Korean Enter redispatch', () => {
const onSubmit = vi.fn(async () => true)
const input = renderComposer(onSubmit)
expect(dispatchRecordedGesture(input)).toBe(true)
expect(onSubmit).not.toHaveBeenCalled()
})
it('publishes exactly once on ordinary Enter', () => {
const onSubmit = vi.fn(async () => true)
const input = renderComposer(onSubmit)
dispatchKey(input, 'keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: false
})
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit).toHaveBeenCalledWith('테스')
})
})
@@ -98,6 +98,7 @@ import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { dirname } from '@/lib/path'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import { translate } from '@/i18n/i18n'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
const EMPTY_MARKDOWN_DOCUMENTS: MarkdownDocument[] = []
@@ -2041,7 +2042,7 @@ function MarkdownSingleNoteSendMenu({
)
}
function MarkdownAnnotationComposer({
export function MarkdownAnnotationComposer({
onCancel,
onSubmit
}: {
@@ -2052,6 +2053,7 @@ function MarkdownAnnotationComposer({
}): React.JSX.Element {
const [body, setBody] = useState('')
const [submitting, setSubmitting] = useState(false)
const imeEnter = useImeEnterGestureOwnership()
const mountedRef = useMountedRef()
const composerRef = useRef<HTMLDivElement | null>(null)
@@ -2114,13 +2116,20 @@ function MarkdownAnnotationComposer({
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
}}
onBlur={imeEnter.reset}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
onKeyUp={imeEnter.onKeyUp}
onKeyDown={(event) => {
if (imeEnter.ownsKeyDown(event)) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing && !event.shiftKey) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void submit()
}

Some files were not shown because too many files have changed in this diff Show More