Merge remote-tracking branch 'origin/main' into brennanb2025/fix-14974-closeout

This commit is contained in:
Brennan Benson
2026-08-17 00:21:12 -07:00
5 changed files with 695 additions and 53 deletions
File diff suppressed because one or more lines are too long
+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: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
hash: 0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067
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=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
version: 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,263 @@
// @vitest-environment happy-dom
/**
* A mid-line composition must not visually swallow the character after the cursor.
*
* The preedit overlay (`.composition-view`) is an opaque box anchored to the cursor cell. Nothing
* reaches the PTY while composing, so the covered cells still hold their characters — the box just
* hides them for the whole composition (#12545). Composing `가` with the cursor before `하` in
* `안녕하세요` blanks `하` until the syllable commits.
*
* The fix renders the rest of the row's committed text after the preedit inside the overlay, so the
* composition reads as inserted text pushing the tail right. The overlay is also themed from
* `options.theme` instead of the stock `#000`/`#FFF`, with any alpha dropped — a see-through mask
* would re-expose the very cells the rendered tail stands in for.
*
* happy-dom performs no layout, so the cell size is supplied and geometry is not asserted; the
* on-screen geometry arm lives in `tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts`.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const CELL_WIDTH_PX = 8
const CELL_HEIGHT_PX = 16
const THEME = { background: '#112233', foreground: '#aabbcc' }
const openTerminals: Terminal[] = []
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
type Rig = {
compositionView: HTMLElement
compose: (preedit: string) => void
composeStart: () => void
composeUpdate: (preedit: string) => void
terminal: Terminal
write: (data: string) => Promise<void>
writeAwaitingRender: (data: string) => Promise<void>
}
function openTerminal(theme: { background: string; foreground: string } = THEME): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24, theme })
terminal.open(container)
const textarea = terminal.textarea
const compositionView = container.querySelector<HTMLElement>('.composition-view')
if (!textarea || !compositionView) {
throw new Error('xterm did not create the helper textarea and composition view')
}
openTerminals.push(terminal)
const cell = (
terminal as unknown as {
_core: {
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
}
}
)._core._renderService.dimensions.css.cell
cell.width = CELL_WIDTH_PX
cell.height = CELL_HEIGHT_PX
const write = (data: string): Promise<void> =>
new Promise((resolve) => terminal.write(data, resolve))
// Awaits the repaint the write triggers, so the tail refresh runs through the production
// terminal.onRender path rather than a test shortcut. The listener arms only after the write's
// parse callback, because a repaint scheduled by an earlier write can fire first and still show
// the old row.
const writeAwaitingRender = async (data: string): Promise<void> => {
await write(data)
await new Promise<void>((resolve) => {
const rendered = terminal.onRender(() => {
rendered.dispose()
resolve()
})
})
}
const composeStart = (): void => {
const start = new CompositionEvent('compositionstart', { bubbles: true })
Object.defineProperty(start, 'data', { value: '' })
textarea.dispatchEvent(start)
}
const composeUpdate = (preedit: string): void => {
const update = new CompositionEvent('compositionupdate', { bubbles: true })
Object.defineProperty(update, 'data', { value: preedit })
textarea.value = preedit
textarea.dispatchEvent(update)
}
const compose = (preedit: string): void => {
composeStart()
composeUpdate(preedit)
}
return {
compositionView,
compose,
composeStart,
composeUpdate,
terminal,
write,
writeAwaitingRender
}
}
function stripMarks(text: string | null): string {
return (text ?? '').replaceAll('‎', '')
}
describe('mid-line composition renders the covered row tail after the preedit', () => {
beforeEach(() => {
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(async () => {
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
await nextEventLoop()
await nextEventLoop()
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('shows the tail from the cursor when composing before committed text (#12545 repro)', async () => {
const rig = openTerminal()
// 안녕하세요 then CUB 6: each Hangul syllable is two cells, so the cursor lands on 하 (x=4).
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[0]!.style.textDecoration).toBe('underline')
expect(spans[1]!.textContent).toBe('하세요')
// Start-anchored so the preedit stays put and the pushed tail clips at the right edge.
expect(rig.compositionView.style.direction).toBe('ltr')
})
it('keeps the tail current as the preedit grows through the composition', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
// One composition stays active while the preedit grows through updates,
// matching how an IME actually streams ㄱ → 가 → 강.
rig.composeStart()
rig.composeUpdate('ㄱ')
rig.composeUpdate('가')
rig.composeUpdate('강')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('강')
expect(spans[1]!.textContent).toBe('하세요')
})
// The view is `white-space: nowrap`, which collapses runs of spaces exactly like `normal`.
// Without `pre` on the tail, an agent TUI's padded input row — `> text …spaces… |` — renders its
// right border a cell after the preedit while the real border stays put. xterm sets `pre` on its
// grid rows for the same reason.
it('preserves the tail spacing of a padded row so its trailing glyph stays on the grid', async () => {
const rig = openTerminal()
// A TUI input row: text, padding, then a real border glyph the trim cannot drop.
await rig.write('> hi |\x1b[13D')
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(spans[1]!.textContent, 'the tail must keep every padding cell').toBe('hi |')
expect(
spans[1]!.style.whiteSpace,
'nowrap collapses the padding, so the border lands left of its grid column'
).toBe('pre')
})
it('keeps the plain single-text overlay when composing at the end of the row', async () => {
const rig = openTerminal()
await rig.write('안녕하세요')
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
expect(stripMarks(rig.compositionView.textContent)).toBe('가')
// The rtl trick still keeps a long preedit's end in view when nothing follows the cursor.
expect(rig.compositionView.style.direction).toBe('rtl')
})
it('themes the overlay from options.theme instead of the stock #000/#FFF', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const { background, color } = rig.compositionView.style
expect([THEME.background, 'rgb(17, 34, 51)']).toContain(background)
expect([THEME.foreground, 'rgb(170, 187, 204)']).toContain(color)
})
it('drops the alpha of a translucent theme background so the mask stays opaque', async () => {
// terminalBackgroundOpacity composes theme.background down to rgba(); carried through as-is it
// would let the covered cells show straight through the tail this renders.
const rig = openTerminal({ background: 'rgba(17, 34, 51, 0.6)', foreground: '#aabbcc' })
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
expect(rig.compositionView.style.background).toBe('rgb(17, 34, 51)')
})
it('refreshes the tail when the row repaints under an open composition', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
// A TUI repaint: erase from the cursor, draw a different tail, put the cursor back.
await rig.writeAwaitingRender('\x1b[K체크\x1b[4D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('체크')
})
it('starts rendering a tail when text lands after an end-of-row composition began', async () => {
const rig = openTerminal()
await rig.write('안녕')
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
// Streamed output arrives to the right of the cursor while the composition is open.
await rig.writeAwaitingRender('하세요\x1b[6D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('하세요')
})
it('leaves no tail behind for the next composition after one ends', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: '가' })
rig.terminal.textarea!.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.textContent).toBe('')
})
})
@@ -0,0 +1,123 @@
import type { Page } from '@stablyai/playwright-test'
/**
* Measures what an active preedit overlay hides, against what it renders.
*
* The overlay has an opaque background and is absolutely positioned over the grid, so every cell
* its bounding rect covers is unreadable for the duration of the composition. That gives one
* invariant worth asserting on screen: **the overlay must render everything it covers**. Composing
* mid-line broke it — the box covered the character at the cursor and rendered only the preedit.
*
* This has to be measured from the real rect against the real cell grid. The class, the
* `textContent` and `display` are all identical either way, and a DOM emulator reports every rect
* as zero, so a unit-level arm cannot see the difference.
*/
export type MidlinePreeditOcclusionSample = {
/** Row text from the cursor rightwards — the committed characters a mid-line preedit sits over. */
rowTailFromCursor: string
/** Characters in the grid columns the overlay's bounding rect covers. */
hiddenByOverlay: string
/** Overlay text as rendered, LRM marks stripped. */
overlayText: string
overlayActive: boolean
cursorColumn: number
/** Columns covered by the overlay, as `[first, last]`; null when it covers none. */
coveredColumns: [number, number] | null
cellWidth: number
/** Kept in the sample so a geometry mismatch prints the rects that produced it. */
overlayRect: { left: number; right: number; width: number }
screenRect: { left: number; width: number }
}
function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to sample')
}
const terminal = pane.terminal
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
const view = pane.container.querySelector<HTMLElement>('.composition-view')
if (!screen || !view) {
throw new Error('Active terminal has no screen element or composition view')
}
const buffer = terminal.buffer.active
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
const cursorColumn = buffer.cursorX
const rowTailFromCursor = (line?.translateToString(true, cursorColumn) ?? '').trimEnd()
const screenRect = screen.getBoundingClientRect()
const overlayRect = view.getBoundingClientRect()
const cellWidth = terminal.cols > 0 ? screenRect.width / terminal.cols : 0
// A column counts as hidden when the overlay covers most of its cell, which keeps the sample
// stable against the sub-pixel width a proportional text node lands on.
let first: number | null = null
let last = -1
let hiddenByOverlay = ''
if (cellWidth > 0 && overlayRect.width > 0) {
for (let column = 0; column < terminal.cols; column++) {
const cellLeft = screenRect.left + column * cellWidth
const overlap =
Math.min(cellLeft + cellWidth, overlayRect.right) - Math.max(cellLeft, overlayRect.left)
if (overlap <= cellWidth / 2) {
continue
}
first ??= column
last = column
hiddenByOverlay += line?.getCell(column)?.getChars() ?? ''
}
}
return {
rowTailFromCursor,
hiddenByOverlay: hiddenByOverlay.trimEnd(),
// The overlay wraps its text in LRM marks; strip them so assertions read as the user sees it.
overlayText: (view.textContent ?? '').replaceAll('‎', ''),
overlayActive: view.classList.contains('active'),
cursorColumn,
coveredColumns: first === null ? null : [first, last],
cellWidth,
overlayRect: {
left: overlayRect.left,
right: overlayRect.right,
width: overlayRect.width
},
screenRect: { left: screenRect.left, width: screenRect.width }
}
}
export async function sampleMidlinePreeditOcclusion(
page: Page
): Promise<MidlinePreeditOcclusionSample> {
return page.evaluate(readMidlinePreeditOcclusion)
}
/** Writes straight to the emulator: the defect is in what the overlay draws, not in the pty. */
export async function writeToActiveTerminal(page: Page, data: string): Promise<void> {
await page.evaluate(async (payload: string) => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to write to')
}
await new Promise<void>((resolve) => pane.terminal.write(payload, resolve))
}, data)
}
@@ -0,0 +1,130 @@
/**
* Headless end-to-end coverage for #12545: a mid-line Korean composition must not swallow the
* character after the cursor.
*
* The preedit overlay is an opaque, absolutely-positioned box over the grid, so every cell its
* bounding rect covers is unreadable while the composition is open. That gives the invariant
* asserted here — **the overlay renders every committed cell it covers** — and it is the one the
* bug broke: the box covered `하` and drew only `가`. It is measured from the overlay's real rect
* against the real cell grid, which is what makes it an on-screen assertion rather than a
* `textContent` one; a DOM emulator reports every rect as zero and cannot see the difference.
*
* Reference terminal implementations do not settle this by geometry. Two mature ones compose marked
* text into the grid rather than into a floating box, and both still blank the cells under it — so
* moving off the overlay would not fix the report. Rendering the covered tail is what does.
*
* Composition is driven through CDP `Input.imeSetComposition`, so this runs in the normal headless
* project with no native input source. The row is written straight to the emulator because the
* defect is in what the overlay draws over the buffer, not in anything reaching the pty.
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
import { setImeComposition } from './terminal-ime-cdp-composition'
import {
sampleMidlinePreeditOcclusion,
writeToActiveTerminal,
type MidlinePreeditOcclusionSample
} from './terminal-ime-midline-occlusion-probe'
import { samplePreeditOverlay } from './terminal-ime-preedit-overlay-probe'
/**
* Waits for the preedit to reach the overlay at a non-zero size, then samples once.
*
* Sampled once rather than polled: the pane runs a live shell that can repaint the row out from
* under the composition, so only the frame the composition opened on carries the state the
* assertion is about. The readiness gate is deliberately blind to the tail, so it holds identically
* with and without the fix and the occlusion assertion is what discriminates.
*/
async function sampleOpenComposition(page: Page): Promise<MidlinePreeditOcclusionSample> {
await expect
.poll(
async () => {
const overlay = await samplePreeditOverlay(page)
return overlay.active && overlay.rect.width > 0 && overlay.text.startsWith('가')
},
{ message: 'the preedit never reached the overlay at a non-zero size' }
)
.toBe(true)
return sampleMidlinePreeditOcclusion(page)
}
/** Reads as one string so a failure prints what the overlay covered next to what it drew. */
function describeOcclusion(sample: MidlinePreeditOcclusionSample): string {
return `covers ${JSON.stringify(sample.hiddenByOverlay)} / renders ${JSON.stringify(sample.overlayText)}`
}
/**
* The invariant, stated so it survives a different cell width: every committed cell the overlay
* covers must appear in what it draws. How MANY cells it covers is a function of the runner's
* font metrics — 34.4px over an 8.43px grid spans four columns where an 8px grid spans two — so
* asserting the covered text verbatim pins the machine, not the behaviour.
*/
function rendersEverythingItCovers(sample: MidlinePreeditOcclusionSample): boolean {
return sample.overlayText.includes(sample.hiddenByOverlay)
}
test.describe('Terminal mid-line Korean preedit occlusion', () => {
test('renders the row tail it covers, so the character after the cursor stays readable', async ({
orcaPage
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
// The issue's repro: 안녕하세요, then CUB 6. Each Hangul syllable is two cells, so the
// cursor lands on 하.
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요\x1b[6D')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
expect(sample.cursorColumn, 'the cursor is not sitting on 하').toBe(4)
expect(sample.rowTailFromCursor, 'the row under the overlay is not the repro').toBe('하세요')
expect(sample.coveredColumns, 'the overlay covers no grid columns to assert about').not.toBe(
null
)
// The load-bearing assertion, and the only one that reads the overlay's geometry against the
// grid: an opaque box that covers committed cells has to reproduce them, or the user loses
// text for the length of the composition.
expect(
sample.hiddenByOverlay.length,
`the overlay covers no committed text, so there is nothing to assert — ${describeOcclusion(sample)}`
).toBeGreaterThan(0)
expect(
rendersEverythingItCovers(sample),
`the preedit overlay must render every committed cell it covers — ${describeOcclusion(sample)} ${JSON.stringify(sample)}`
).toBe(true)
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
'korean-midline-preedit-occlusion',
!completed
)
}
})
test('leaves the end-of-row composition untouched, with nothing covered to render', async ({
orcaPage
}, testInfo) => {
// Passes before and after the fix by design: the guard is against over-correcting into
// rendering a tail where the row has none.
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
expect(sample.rowTailFromCursor, 'text still sits after the cursor').toBe('')
expect(
describeOcclusion(sample),
`an end-of-row composition covers no committed text — ${JSON.stringify(sample)}`
).toBe('covers "" / renders "가"')
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-endofrow-preedit', !completed)
}
})
})