mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-14974-closeout
This commit is contained in:
File diff suppressed because one or more lines are too long
Generated
+24
-24
@@ -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: {}
|
||||
|
||||
|
||||
+263
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user