fix(terminal): bind IME masking to composer ownership

This commit is contained in:
Neil
2026-08-29 19:36:20 -07:00
parent d1e21fff51
commit 269b9a8416
15 changed files with 527 additions and 239 deletions
File diff suppressed because one or more lines are too long
@@ -88,10 +88,10 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc05393bc52a5 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..91c722fd7da0055f14da3e69f4ceb282cb062f40 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -3,8 +3,10 @@
@@ -3,8 +3,9 @@
* @license MIT
*/
@@ -99,19 +99,13 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+import { IRenderService, IThemeService } from '../services/Services';
import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
+import { color } from '../../common/Color';
+import { BgFlags } from '../../common/buffer/Constants';
import { C0 } from '../../common/data/EscapeSequences';
interface IPosition {
@@ -12,6 +14,32 @@ interface IPosition {
@@ -12,6 +13,27 @@ interface IPosition {
end: number;
}
+interface IRowRemainder {
+ text: string;
+ maskAsGhost: boolean;
+}
+
+interface IPendingComposition {
+ transactionId: number;
+ finalizerTimer?: ReturnType<typeof setTimeout>;
@@ -136,7 +130,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
/**
* Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
* events, displaying the in-progress composition to the UI and forwarding the final composition
@@ -24,6 +52,15 @@ export class CompositionHelper {
@@ -24,6 +46,15 @@ export class CompositionHelper {
*/
private _isComposing: boolean;
public get isComposing(): boolean { return this._isComposing; }
@@ -152,7 +146,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
/**
* The position within the input textarea's value of the current composition.
@@ -36,52 +73,140 @@ export class CompositionHelper {
@@ -36,52 +67,140 @@ export class CompositionHelper {
*/
private _compositionSuffix: string;
@@ -303,7 +297,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
}
/**
@@ -89,22 +214,93 @@ export class CompositionHelper {
@@ -89,22 +208,93 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
@@ -406,7 +400,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
}
/**
@@ -113,7 +309,19 @@ export class CompositionHelper {
@@ -113,7 +303,19 @@ export class CompositionHelper {
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
@@ -427,7 +421,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
if (ev.keyCode === 20 || ev.keyCode === 229) {
// 20 is CapsLock, 229 is Enter
// Continue composing if the keyCode is the "composition character"
@@ -128,6 +336,10 @@ export class CompositionHelper {
@@ -128,6 +330,10 @@ export class CompositionHelper {
this._finalizeComposition(false);
}
@@ -438,7 +432,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
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.
@@ -138,6 +350,74 @@ export class CompositionHelper {
@@ -138,6 +344,74 @@ export class CompositionHelper {
return true;
}
@@ -513,7 +507,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
@@ -146,23 +426,52 @@ export class CompositionHelper {
@@ -146,23 +420,52 @@ export class CompositionHelper {
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
@@ -577,7 +571,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
// 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
@@ -172,37 +481,310 @@ export class CompositionHelper {
@@ -172,35 +475,308 @@ export class CompositionHelper {
// - The last compositionupdate event's data property does not always accurately describe
// the character, a counter example being Korean where an ending consonsant can move to
// the following character if the following input is a vowel.
@@ -815,9 +809,9 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ private _dispatchCompositionSessionEvent(event: CustomEvent): void {
+ if (typeof this._textarea.dispatchEvent === 'function') {
+ this._textarea.dispatchEvent(event);
}
}
+ }
+ }
+
+ private _dispatchCompositionTransactionSettled(): void {
+ this._dispatchCompositionSessionEvent(new CustomEvent(
+ 'xterm-composition-transaction-settled',
@@ -907,15 +901,13 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ private _cancelDeferredTimer(timer?: ReturnType<typeof setTimeout>): void {
+ if (timer === undefined) {
+ return;
+ }
}
+ clearTimeout(timer);
+ this._compositionTimers.delete(timer);
+ }
+
}
/**
* 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
@@ -222,6 +804,9 @@ export class CompositionHelper {
@@ -222,6 +798,9 @@ export class CompositionHelper {
const diff = newValue.replace(oldValue, '');
@@ -925,7 +917,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +821,120 @@ export class CompositionHelper {
@@ -236,6 +815,104 @@ export class CompositionHelper {
}, 0);
}
@@ -935,7 +927,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ * box hiding the character under the cursor. Nothing reaches the pty while composing, so those
+ * cells still hold their characters; only what the overlay shows changes.
+ */
+ private _renderCompositionView(data: string, rowRemainder = this._getRowRemainder()): void {
+ private _renderCompositionView(data: string, rowRemainder = this._getRowRemainderText()): void {
+ if (!data) {
+ this._resetCompositionView();
+ return;
@@ -955,15 +947,13 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ caret.setAttribute('aria-hidden', 'true');
+ const children = [preedit, caret];
+ let remainder: HTMLElement | undefined;
+ if (rowRemainder.text) {
+ if (rowRemainder) {
+ remainder = doc.createElement('span');
+ remainder.className = 'xterm-composition-remainder';
+ // Why: the view is nowrap, which collapses runs of spaces, so committed padding would draw
+ // its trailing glyph cells to the left of where the grid has them.
+ remainder.style.whiteSpace = 'pre';
+ // Preserve the ghost text's width so the opaque view masks it without repeating it.
+ remainder.style.visibility = rowRemainder.maskAsGhost ? 'hidden' : '';
+ remainder.textContent = rowRemainder.text;
+ remainder.textContent = rowRemainder;
+ children.push(remainder);
+ }
+ this._compositionView.replaceChildren(...children);
@@ -973,35 +963,21 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ this._styleCompositionCaret();
+ }
+
+ /** The row tail and whether its full-row dim geometry identifies application ghost text. */
+ private _getRowRemainder(): IRowRemainder {
+ /** The committed row text from the cursor rightwards — what a mid-line preedit would cover. */
+ private _getRowRemainderText(): string {
+ const buffer = this._bufferService.buffer;
+ if (!buffer.isCursorInViewport) {
+ return { text: '', maskAsGhost: false };
+ return '';
+ }
+ const line = buffer.lines.get(buffer.ybase + buffer.y);
+ if (!line) {
+ return { text: '', maskAsGhost: false };
+ return '';
+ }
+ const start = Math.min(buffer.x, this._bufferService.cols - 1);
+ // The explicit end column keeps this off the line string cache, whose self-renewing
+ // idle-clear timer the composition path must not arm.
+ const end = line.getTrimmedLength();
+ const text = line.translateToString(false, start, end);
+ if (!text || start !== 0) {
+ return { text, maskAsGhost: false };
+ }
+ let hasVisibleCell = false;
+ for (let x = start; x < end; x++) {
+ if (!line.hasContent(x) || !line.hasWidth(x)) {
+ continue;
+ }
+ hasVisibleCell = true;
+ if (!(line.getBg(x) & BgFlags.DIM)) {
+ return { text, maskAsGhost: false };
+ }
+ }
+ return { text, maskAsGhost: hasVisibleCell };
+ return line.translateToString(false, start, end);
+ }
+
+ private _styleCompositionCaret(): void {
@@ -1046,7 +1022,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +942,24 @@ export class CompositionHelper {
@@ -243,10 +920,23 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1059,12 +1035,11 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
+ // A TUI can repaint the row under an open composition (spinners, streamed output), and this
+ // already runs on every render — so keep the rendered tail current with the buffer. A string
+ // compare adds no layout read.
+ const rowRemainder = this._getRowRemainder();
+ const remainderMasked = this._compositionRemainder?.style.visibility === 'hidden';
+ if (this._compositionViewData && (
+ rowRemainder.text !== (this._compositionRemainder?.textContent ?? '') ||
+ rowRemainder.maskAsGhost !== remainderMasked
+ )) {
+ const rowRemainder = this._getRowRemainderText();
+ if (
+ this._compositionViewData &&
+ rowRemainder !== (this._compositionRemainder?.textContent ?? '')
+ ) {
+ this._renderCompositionView(this._compositionViewData, rowRemainder);
+ }
+ this._styleCompositionCaret();
@@ -1072,7 +1047,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..b91682d6c17d81e9ad527b22dfcbc053
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,20 +978,38 @@ export class CompositionHelper {
@@ -265,20 +955,38 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
+18 -7
View File
@@ -11105,12 +11105,12 @@
"https://github.com/stablyai/orca/issues/12729",
"https://github.com/stablyai/orca/pull/17170"
],
"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. A live preedit must stay visible across an IME-resumed transaction, preserve every committed non-dim cell hidden by its opaque overlay, keep the themed insertion caret and native candidate anchor inside the terminal at the final column, and clean up without stale overlay children. Layout-native Option text must preserve negotiated CSI-u alternate/associated fields and lock modifiers, distinguish repeat from press, and resolve releases with the press's primary identity plus the actual keyup modifiers without exposing a partial or stale layout refresh. An in-focus macOS input-source change must invalidate both renderer authorities before the next key can use the old source. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.",
"oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, resumed Windows event traces reactivate the visible preedit, an opaque mid-line overlay reproduces the committed row tail it covers, and final-column layout keeps both caret and textarea candidate anchor within the available screen width. Rendered Electron/CDP geometry independently proves the overlay reproduces every committed cell it hides and the final-column caret stays on-screen. 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. A main/preload/renderer notification chain proves every live window invalidates immediately, the old native read settles before a coalesced refresh signal, and older refreshes are generation-fenced. 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, native candidate-window placement, and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"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. A live preedit must stay visible across an IME-resumed transaction, preserve every committed row-tail cell hidden by its opaque overlay, mask only a structurally verified Codex or Claude placeholder, keep the themed insertion caret and native candidate anchor inside the terminal at the final column, and clean up without stale overlay children or ownership classes. Layout-native Option text must preserve negotiated CSI-u alternate/associated fields and lock modifiers, distinguish repeat from press, and resolve releases with the press's primary identity plus the actual keyup modifiers without exposing a partial or stale layout refresh. An in-focus macOS input-source change must invalidate both renderer authorities before the next key can use the old source. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.",
"oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, resumed Windows event traces reactivate the visible preedit, an opaque mid-line overlay reproduces the committed row tail it covers, arbitrary DIM output remains visible, and final-column layout keeps both caret and textarea candidate anchor within the available screen width. The renderer ownership binding reuses the shared terminal-composer classifier, recognizes prompt/frame/footer-backed Codex and Claude placeholders, rejects shell lookalikes, reclassifies live repaints only during composition, and clears exact session and DOM state at end/disposal. Rendered Electron/CDP geometry independently proves the overlay reproduces every committed cell it hides, masks a real Codex placeholder at column 2, and keeps the final-column caret on-screen. 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. A main/preload/renderer notification chain proves every live window invalidates immediately, the old native read settles before a coalesced refresh signal, and older refreshes are generation-fenced. 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, native candidate-window placement, and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"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-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.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 exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts src/shared/terminal-composer-draft.test.ts src/main/daemon/terminal-cursor-line-context.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts src/main/ipc/macos-keyboard-layout-snapshot.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/preload/app-restart-checkpoint-routing.test.ts src/renderer/src/web/web-preload-api-ui.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
@@ -11129,6 +11129,9 @@
"src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-xterm-trailing-preedit-occlusion.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts",
"src/shared/terminal-composer-draft.test.ts",
"src/main/daemon/terminal-cursor-line-context.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",
"src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts",
@@ -11208,11 +11211,19 @@
"file": "src/renderer/src/components/terminal-pane/terminal-ime-xterm-midline-preedit-tail.test.ts",
"assertions": [
"a mid-line preedit reproduces committed cells hidden by its opaque overlay and refreshes that tail after terminal renders",
"fully dimmed text stays masked instead of being repeated after the preedit",
"arbitrary fully dimmed output remains visible even at column zero",
"the final-column caret stays visible and the textarea candidate anchor shifts left with an over-wide preedit",
"cancel, empty resumed updates, and disposal remove transient overlay state"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts",
"assertions": [
"Codex prompt/footer and Claude prompt/frame placeholders acquire semantic masking only during composition",
"ordinary shell lookalikes remain visible",
"active-composition repaints reclassify ownership and composition end/disposal remove the class and listeners"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-xterm-resumed-preedit-visibility.test.ts",
"assertions": [
@@ -11408,8 +11419,8 @@
{
"file": "tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts",
"assertions": [
"the rendered opaque overlay reproduces every non-dim committed cell its measured rectangle hides",
"a fully dimmed row stays masked rather than repeated and an end-of-row composition adds no tail"
"the rendered opaque overlay reproduces every committed row-tail cell its measured rectangle hides",
"a real Codex placeholder with bold prompt, blank footer gap, model footer, and cursor at column 2 stays masked while an end-of-row composition adds no tail"
]
}
],
@@ -11519,7 +11530,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Terminal input hot-path audit clean through 2026-08-29: candidate guards retain O(1) checks and bounded per-pane state. While composition is active, overlay reconciliation scans only the current row, bounded by terminal columns, retains at most three current DOM children, reuses one preedit-bounds layout read for visual and native-candidate geometry, and cancels its single deferred timer on replacement and disposal. Layout changes add one process-lifetime native listener, two singleton renderer subscriptions, one O(live windows) broadcast per OS event, and generation-fenced refreshes whose native helper read is deduplicated in flight. The paths are event-driven and add no polling, per-key IPC, SSH/remote transport work, unbounded queues, or retained terminal data."
"evidence": "Terminal input hot-path audit clean through 2026-08-29: candidate guards retain O(1) checks and bounded per-pane state. Xterm overlay reconciliation scans only the current row, bounded by terminal columns, retains at most three current DOM children, reuses one preedit-bounds layout read for visual and native-candidate geometry, and cancels its single deferred timer on replacement and disposal. Orca's semantic placeholder binding reads the bounded visible cursor context synchronously at composition start and on xterm render only while a composition session is active; it retains only exact active session ids and one disposable render listener. Layout changes add one process-lifetime native listener, two singleton renderer subscriptions, one O(live windows) broadcast per OS event, and generation-fenced refreshes whose native helper read is deduplicated in flight. The paths are event-driven and add no polling, per-key IPC, SSH/remote transport work, unbounded queues, or retained terminal data."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
+24 -24
View File
@@ -113,7 +113,7 @@ patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.287': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
'@xterm/addon-serialize@0.15.0-beta.287': af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
'@xterm/addon-webgl@0.20.0-beta.286': 2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91
'@xterm/xterm@6.1.0-beta.287': a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426
'@xterm/xterm@6.1.0-beta.287': b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
@@ -138,7 +138,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -313,25 +313,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=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@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=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@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=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))
version: 0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
version: 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9718,39 +9718,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
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=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=2c301a06ad9caa635d746147dcb2b1c69aa026904f65e1183564f08a0e601b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=a08f655f2f2ee2201070fb9b004b2b58a93138b42d0e82c9bf6a0614d1f6a426)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=b1d19ab8b4845ed9e9dce542babc8aef1040addf5afadd2c240399f3f0589b76)': {}
abbrev@4.0.0: {}
+1 -1
View File
@@ -24,7 +24,7 @@ import { installDeviceAttributesResponder } from './startup-device-attributes-re
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
import { readTerminalCursorLineContext } from '../../shared/terminal-cursor-line-context'
export type HeadlessEmulatorOptions = {
cols: number
@@ -2,7 +2,7 @@ import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft'
import { HeadlessEmulator } from './headless-emulator'
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
import { readTerminalCursorLineContext } from '../../shared/terminal-cursor-line-context'
function writeSync(terminal: Terminal, data: string): void {
const core = (terminal as unknown as { _core: { writeSync(data: string): void } })._core
+4
View File
@@ -67,6 +67,10 @@
caret-color: transparent;
}
.xterm.orca-ime-composer-placeholder .xterm-composition-remainder {
visibility: hidden;
}
/* Divider: the element is a wide transparent hit area; the visible line is
drawn by ::after so that setting `background` on the element never hides it. */
.pane-divider.is-vertical,
@@ -0,0 +1,197 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
installTerminalImeComposerPlaceholderMask,
TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS
} from './terminal-ime-composer-placeholder-mask'
const CODEX_PLACEHOLDER = 'Ask Codex to do anything'
const openTerminals: Terminal[] = []
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
type Rig = {
compose: (preedit?: string) => void
compositionView: HTMLElement
disposeMask: () => void
element: HTMLElement
endComposition: (commit?: string) => Promise<void>
textarea: HTMLTextAreaElement
terminal: Terminal
write: (data: string) => Promise<void>
writeAwaitingRender: (data: string) => Promise<void>
}
function openTerminal(): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 12 })
terminal.open(container)
openTerminals.push(terminal)
const element = terminal.element
const textarea = terminal.textarea
const compositionView = container.querySelector<HTMLElement>('.composition-view')
if (!element || !textarea || !compositionView) {
throw new Error('xterm did not create its terminal composition elements')
}
const mask = installTerminalImeComposerPlaceholderMask(terminal)
const write = (data: string): Promise<void> =>
new Promise((resolve) => terminal.write(data, resolve))
const writeAwaitingRender = async (data: string): Promise<void> => {
await write(data)
await new Promise<void>((resolve) => {
const rendered = terminal.onRender(() => {
rendered.dispose()
resolve()
})
})
}
const compose = (preedit = '아'): void => {
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
textarea.value = preedit
const update = new CompositionEvent('compositionupdate', { bubbles: true })
Object.defineProperty(update, 'data', { value: preedit })
textarea.dispatchEvent(update)
}
const endComposition = async (commit = '아'): Promise<void> => {
textarea.value = commit
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: commit })
textarea.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()
}
return {
compose,
compositionView,
disposeMask: () => mask.dispose(),
element,
endComposition,
terminal,
textarea,
write,
writeAwaitingRender
}
}
function codexPlaceholderFrame(): string {
return [
'\x1b[2J\x1b[H\x1b[1m›\x1b[22m \x1b7',
`\x1b[2m${CODEX_PLACEHOLDER}\x1b[22m`,
'\r\n\r\n\x1b[2mgpt-5.6 · ~/repo\x1b[22m\x1b8'
].join('')
}
describe('terminal IME composer placeholder mask', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(async () => {
await nextEventLoop()
await nextEventLoop()
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it.each([
['Codex', codexPlaceholderFrame()],
[
'Claude',
`\x1b[2J\x1b[H${'─'.repeat(24)}\r\n❯ \x1b7\x1b[2mTry “fix the failing test”\x1b[22m\x1b8`
]
])('owns a structurally verified %s placeholder during composition', async (_agent, frame) => {
const rig = openTerminal()
await rig.write(frame)
expect(rig.terminal.buffer.active.cursorX).toBe(2)
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
expect(rig.compositionView.querySelector('.xterm-composition-remainder')).not.toBeNull()
})
it.each([
[
'Codex lookalike without a footer',
`\x1b[1m›\x1b[22m \x1b7\x1b[2m${CODEX_PLACEHOLDER}\x1b[22m\x1b8`
],
[
'Claude lookalike without a frame',
'\x1b[2J\x1b[H❯ \x1b7\x1b[2mTry “fix the failing test”\x1b[22m\x1b8'
],
['arbitrary all-DIM output', '\x1b[2J\x1b[H\x1b[2mWaiting for input\x1b[22m\x1b[17D']
])('leaves an ordinary shell %s visible', async (_case, frame) => {
const rig = openTerminal()
await rig.write(frame)
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
})
it('leaves non-placeholder Codex draft text untouched', async () => {
const rig = openTerminal()
await rig.write(
[
'\x1b[2J\x1b[H\x1b[1m›\x1b[22m review\x1b7 this',
'\r\n\r\n\x1b[2mgpt-5.6 · ~/repo\x1b[22m\x1b8'
].join('')
)
expect(rig.terminal.buffer.active.cursorX).toBe(8)
expect(rig.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('› review this')
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
expect(rig.compositionView.querySelector('.xterm-composition-remainder')?.textContent).toBe(
' this'
)
})
it('reclassifies a repaint only while composition is active', async () => {
const rig = openTerminal()
await rig.write(codexPlaceholderFrame())
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
const ordinaryDimOutput = 'Background task'
await rig.writeAwaitingRender(
`\x1b[K\x1b[2m${ordinaryDimOutput}\x1b[22m\x1b[${ordinaryDimOutput.length}D`
)
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
await rig.writeAwaitingRender(
`\x1b[K\x1b[2m${CODEX_PLACEHOLDER}\x1b[22m\x1b[${CODEX_PLACEHOLDER.length}D`
)
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
})
it('clears ownership on composition end, blur, and disposal', async () => {
const rig = openTerminal()
await rig.write(codexPlaceholderFrame())
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
await rig.endComposition()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
rig.textarea.dispatchEvent(new FocusEvent('blur'))
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
rig.compose()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true)
rig.disposeMask()
expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false)
})
})
@@ -0,0 +1,72 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import { hasTerminalComposerPlaceholder } from '../../../../shared/terminal-composer-draft'
import { readTerminalCursorLineContext } from '../../../../shared/terminal-cursor-line-context'
import {
XTERM_COMPOSITION_SESSION_END_EVENT,
XTERM_COMPOSITION_SESSION_START_EVENT
} from './terminal-ime-composition-route'
export const TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS = 'orca-ime-composer-placeholder'
function compositionSessionId(event: Event): number | null {
if (!(event instanceof CustomEvent)) {
return null
}
const id = (event.detail as { id?: unknown } | null)?.id
return Number.isSafeInteger(id) && Number(id) > 0 ? Number(id) : null
}
export function installTerminalImeComposerPlaceholderMask(terminal: Terminal): IDisposable {
const element = terminal.element
if (!element) {
return { dispose: () => undefined }
}
const activeSessions = new Set<number>()
const syncPlaceholderOwnership = (): void => {
const ownsPlaceholder =
activeSessions.size > 0 &&
hasTerminalComposerPlaceholder(readTerminalCursorLineContext(terminal, terminal.rows))
element.classList.toggle(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS, ownsPlaceholder)
}
const handleSessionStart = (event: Event): void => {
const id = compositionSessionId(event)
if (id === null) {
return
}
activeSessions.add(id)
syncPlaceholderOwnership()
}
const handleSessionEnd = (event: Event): void => {
const id = compositionSessionId(event)
if (id === null) {
return
}
activeSessions.delete(id)
syncPlaceholderOwnership()
}
const handleBlur = (): void => {
activeSessions.clear()
syncPlaceholderOwnership()
}
element.addEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, handleSessionStart)
element.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, handleSessionEnd)
element.addEventListener('blur', handleBlur, true)
const renderDisposable = terminal.onRender(() => {
if (activeSessions.size > 0) {
syncPlaceholderOwnership()
}
})
return {
dispose: () => {
activeSessions.clear()
element.classList.remove(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)
element.removeEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, handleSessionStart)
element.removeEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, handleSessionEnd)
element.removeEventListener('blur', handleBlur, true)
renderDisposable.dispose()
}
}
}
@@ -311,7 +311,7 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(rig.compositionView.style.justifyContent).toBe('')
})
it('masks a fully dimmed row instead of repeating it after the preedit', async () => {
it('keeps arbitrary fully dimmed output visible at column zero', async () => {
const rig = openTerminal()
const dimmedRow = 'Waiting for input'
await rig.write(`\x1b[2m${dimmedRow}\x1b[22m\x1b[${dimmedRow.length}D`)
@@ -322,7 +322,7 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret, remainder])
expect(stripMarks(preedit!.textContent)).toBe('아')
expect(remainder!.textContent).toBe(dimmedRow)
expect(remainder!.style.visibility).toBe('hidden')
expect(remainder!.style.visibility).toBe('')
})
it('keeps a wholly dim mid-line tail visible', async () => {
@@ -349,20 +349,6 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(remainder!.style.visibility).toBe('')
})
it('re-evaluates dim masking when a repaint changes only cell style', async () => {
const rig = openTerminal()
const dimmedRow = 'Waiting for input'
await rig.write(`\x1b[2m${dimmedRow}\x1b[22m\x1b[${dimmedRow.length}D`)
rig.compose('아')
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('hidden')
await rig.writeAwaitingRender(`\x1b[22m${dimmedRow}\x1b[${dimmedRow.length}D`)
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('')
await rig.writeAwaitingRender(`\x1b[2m${dimmedRow}\x1b[22m\x1b[${dimmedRow.length}D`)
expect(viewParts(rig.compositionView).remainder!.style.visibility).toBe('hidden')
})
it('themes the overlay from options.theme instead of the stock #000/#FFF', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
@@ -95,6 +95,7 @@ import {
resolveNonLatinControlChordInput
} from './terminal-non-latin-control-chord'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
import { installTerminalImeComposerPlaceholderMask } from './terminal-ime-composer-placeholder-mask'
import { isCurrentPlatformIosWeb } from '@/lib/ios-web-platform'
import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state'
import {
@@ -1067,6 +1068,7 @@ export function useTerminalPaneLifecycle({
? installTerminalImeLinuxCandidateState(pane.terminal.element)
: null
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
const imeComposerPlaceholderMask = installTerminalImeComposerPlaceholderMask(pane.terminal)
// Why after the tracker: the preedit stops propagation on `input` while
// a syllable is held, so anything on this element that needs those
// events has to be registered ahead of it. Nothing does today — the
@@ -1082,6 +1084,7 @@ export function useTerminalPaneLifecycle({
: null
imeCompositionDisposablesRef.current.set(pane.id, {
dispose: () => {
imeComposerPlaceholderMask.dispose()
imeCompositionTracker.dispose()
linuxImeCandidateState?.dispose()
iosHangulPreedit?.dispose()
+34 -29
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { detectTerminalComposerDraft } from './terminal-composer-draft'
import {
detectTerminalComposerDraft,
hasTerminalComposerPlaceholder
} from './terminal-composer-draft'
describe('detectTerminalComposerDraft', () => {
it('separates a cursor-right suggestion from the composer line', () => {
@@ -26,20 +29,21 @@ describe('detectTerminalComposerDraft', () => {
})
it('keeps stock dim placeholders out of draft metadata', () => {
expect(
detectTerminalComposerDraft({
rows: ['› Ask Codex to do anything'],
typedRows: ['›'],
promptGlyphBoldRows: [true],
rowsBelow: [],
typedRowsBelow: [],
beforeCursor: '› ',
afterCursor: '',
rawAfterCursor: 'Ask Codex to do anything',
cursorHidden: false,
cursorViewportRow: 4
})
).toBeNull()
const context = {
rows: ['› Ask Codex to do anything'],
typedRows: ['›'],
promptGlyphBoldRows: [true],
rowsBelow: ['', 'gpt-5.6 · ~/repo'],
typedRowsBelow: ['', 'gpt-5.6 · ~/repo'],
beforeCursor: '› ',
afterCursor: '',
rawAfterCursor: 'Ask Codex to do anything',
cursorHidden: false,
cursorViewportRow: 4
}
expect(detectTerminalComposerDraft(context)).toBeNull()
expect(hasTerminalComposerPlaceholder(context)).toBe(true)
})
it('does not duplicate real typed text left of the cursor', () => {
@@ -187,20 +191,21 @@ describe('detectTerminalComposerDraft', () => {
})
it('preserves an ordinary shell prompt that uses the Codex glyph', () => {
expect(
detectTerminalComposerDraft({
rows: ['last command output', '› git status'],
typedRows: ['last command output', '› git status'],
promptGlyphBoldRows: [false, true],
rowsBelow: [],
typedRowsBelow: [],
beforeCursor: '› git status',
afterCursor: '',
rawAfterCursor: '',
cursorHidden: false,
cursorViewportRow: 7
})
).toBeNull()
const context = {
rows: ['last command output', '› git status'],
typedRows: ['last command output', '› git status'],
promptGlyphBoldRows: [false, true],
rowsBelow: [],
typedRowsBelow: [],
beforeCursor: '› git status',
afterCursor: '',
rawAfterCursor: '',
cursorHidden: false,
cursorViewportRow: 7
}
expect(detectTerminalComposerDraft(context)).toBeNull()
expect(hasTerminalComposerPlaceholder(context)).toBe(false)
})
it('keeps the side-thread placeholder out of draft metadata', () => {
+33 -5
View File
@@ -22,6 +22,8 @@ export type TerminalComposerDraft = {
promptGlyph: '❯' | '›' | '»'
}
type TerminalComposerMatch = TerminalComposerDraft & { placeholder: boolean }
const COMPOSER_FRAME_LINE = /^[─━-]{8,}\s*$/
const CODEX_FOOTER_LINE = /^\s*(?:gpt-\S+|o\d\S*)\s+[·•]\s+\S.*$/i
@@ -98,9 +100,9 @@ function isStockPlaceholder(
)
}
export function detectTerminalComposerDraft(
function detectTerminalComposer(
context: TerminalCursorContext | null | undefined
): TerminalComposerDraft | null {
): TerminalComposerMatch | null {
if (!context || context.cursorHidden || context.rows.length === 0) {
return null
}
@@ -108,7 +110,8 @@ export function detectTerminalComposerDraft(
const codexFooterIndex = findCodexFooterIndex(context)
let afterCursor = context.afterCursor || context.rawAfterCursor
let continuationRows = composerContinuationRows(context, afterCursor, codexFooterIndex)
if (isStockPlaceholder(afterCursor, continuationRows)) {
const placeholder = isStockPlaceholder(afterCursor, continuationRows)
if (placeholder) {
afterCursor = ''
continuationRows = []
}
@@ -155,14 +158,17 @@ export function detectTerminalComposerDraft(
.join('')
.trim()
if (!text) {
return null
if (!placeholder) {
return null
}
}
return {
text,
promptRow: context.cursorViewportRow - (cursorIndex - index),
cursorRow: context.cursorViewportRow,
endRow: context.cursorViewportRow + continuationRows.length,
promptGlyph: glyph
promptGlyph: glyph,
placeholder: !text && placeholder
}
}
if (row.length > 0 && context.rowsWrapped?.[index] !== true && !/^\s/.test(row)) {
@@ -171,3 +177,25 @@ export function detectTerminalComposerDraft(
}
return null
}
export function detectTerminalComposerDraft(
context: TerminalCursorContext | null | undefined
): TerminalComposerDraft | null {
const match = detectTerminalComposer(context)
if (!match || match.placeholder) {
return null
}
return {
text: match.text,
promptRow: match.promptRow,
cursorRow: match.cursorRow,
endRow: match.endRow,
promptGlyph: match.promptGlyph
}
}
export function hasTerminalComposerPlaceholder(
context: TerminalCursorContext | null | undefined
): boolean {
return detectTerminalComposer(context)?.placeholder === true
}
@@ -1,7 +1,35 @@
import type { IBufferLine, Terminal } from '@xterm/headless'
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
import type { TerminalCursorContext } from './terminal-composer-draft'
function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string {
type TerminalCursorCell = {
getChars(): string
getWidth(): number
isBold(): boolean | number
isDim(): boolean | number
isFgDefault(): boolean | number
}
type TerminalCursorLine = {
readonly isWrapped: boolean
readonly length: number
getCell(column: number): TerminalCursorCell | undefined
translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
}
export type TerminalCursorContextSource = {
readonly rows: number
readonly modes: { readonly showCursor: boolean }
readonly buffer: {
readonly active: {
readonly baseY: number
readonly cursorX: number
readonly cursorY: number
readonly viewportY: number
getLine(row: number): TerminalCursorLine | undefined
}
}
}
function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): string {
let text = ''
for (let x = fromX; x < line.length; x += 1) {
const cell = line.getCell(x)
@@ -13,7 +41,7 @@ function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string {
return trimRight ? text.trimEnd() : text
}
function firstVisibleCellIsBold(line: IBufferLine): boolean {
function firstVisibleCellIsBold(line: TerminalCursorLine): boolean {
for (let x = 0; x < line.length; x += 1) {
const cell = line.getCell(x)
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
@@ -24,7 +52,7 @@ function firstVisibleCellIsBold(line: IBufferLine): boolean {
return false
}
function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean {
function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean {
for (let x = 0; x < line.length; x += 1) {
const cell = line.getCell(x)
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
@@ -36,8 +64,8 @@ function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean {
}
export function readTerminalCursorLineContext(
terminal: Terminal,
rowsAbove: number
terminal: TerminalCursorContextSource,
rowsAroundCursor: number
): TerminalCursorContext | null {
const buffer = terminal.buffer.active
const cursorRow = buffer.baseY + buffer.cursorY
@@ -49,7 +77,8 @@ export function readTerminalCursorLineContext(
const typedRows: string[] = []
const promptGlyphBoldRows: boolean[] = []
const rowsWrapped: boolean[] = []
const start = Math.max(buffer.viewportY, cursorRow - Math.max(0, Math.floor(rowsAbove)))
const rowRadius = Math.max(0, Math.floor(rowsAroundCursor))
const start = Math.max(buffer.viewportY, cursorRow - rowRadius)
for (let row = start; row <= cursorRow; row += 1) {
const line = buffer.getLine(row)
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
@@ -62,10 +91,7 @@ export function readTerminalCursorLineContext(
const typedRowsBelow: string[] = []
const rowsBelowWrapped: boolean[] = []
const rowsBelowCustomForeground: boolean[] = []
const end = Math.min(
buffer.viewportY + terminal.rows - 1,
cursorRow + Math.max(0, Math.floor(rowsAbove))
)
const end = Math.min(buffer.viewportY + terminal.rows - 1, cursorRow + rowRadius)
for (let row = cursorRow + 1; row <= end; row += 1) {
const line = buffer.getLine(row)
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
@@ -68,35 +68,41 @@ function rendersEverythingItCovers(sample: MidlinePreeditOcclusionSample): boole
}
test.describe('Terminal mid-line Korean preedit occlusion', () => {
test('masks a fully dimmed row during its first Korean preedit', async ({
test('masks a semantically owned Codex placeholder during its first Korean preedit', async ({
orcaPage
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
const dimmedRow = 'Waiting for input'
const placeholder = 'Ask Codex to do anything'
await writeToActiveTerminal(
orcaPage,
`\x1b[2J\x1b[H\x1b[2m${dimmedRow}\x1b[22m\x1b[${dimmedRow.length}D`
[
'\x1b[2J\x1b[H\x1b[1m›\x1b[22m \x1b7',
`\x1b[2m${placeholder}\x1b[22m`,
'\r\n\r\n\x1b[2mgpt-5.6 · ~/repo\x1b[22m\x1b8'
].join('')
)
await setImeComposition(arena.session, '아')
const sample = await sampleOpenComposition(orcaPage, '아')
expect(sample.cursorColumn, 'the cursor is not at the dimmed row start').toBe(0)
expect(sample.rowTailFromCursor, 'the dimmed row is not under the cursor').toBe(dimmedRow)
expect(sample.cursorColumn, 'the cursor is not after the Codex prompt').toBe(2)
expect(sample.rowTailFromCursor, 'the Codex placeholder is not under the cursor').toBe(
placeholder
)
expect(
sample.hiddenByOverlay,
`the opaque overlay does not mask the full dimmed row — ${describeOcclusion(sample)}`
).toBe(dimmedRow)
expect(sample.overlayText, 'the dimmed row is repeated after the Korean preedit').toBe('아')
expect(sample.remainderText, 'the hidden span lost the dimmed row width').toBe(dimmedRow)
expect(sample.remainderVisibility, 'the dimmed row is still painted').toBe('hidden')
`the opaque overlay does not mask the full placeholder — ${describeOcclusion(sample)}`
).toBe(placeholder)
expect(sample.overlayText, 'the Codex placeholder is repeated after the preedit').toBe('아')
expect(sample.remainderText, 'the hidden span lost the placeholder width').toBe(placeholder)
expect(sample.remainderVisibility, 'the Codex placeholder is still painted').toBe('hidden')
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
'korean-fully-dimmed-row-preedit',
'korean-codex-placeholder-preedit',
!completed
)
}