Fix terminal table rendering after workspace restore (#4877)

* Stabilize terminal cursor rendering and typing checks

* Fix terminal table rendering after workspace restore

* Fix terminal table width underfit

* Tighten terminal renderer stabilization
This commit is contained in:
Neil 2026-06-08 03:25:55 -07:00 committed by GitHub
parent c4dcd88440
commit 3153356d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1263 additions and 211 deletions

View File

@ -1,20 +1,19 @@
diff --git a/package.json b/package.json
index c697677b35e736e23702c1abb357b3445c57d949..deb0387e66a8abb301006372ec9775bb54131d5a 100644
index c697677b35e736e23702c1abb357b3445c57d949..0a9f8b69e5a6f4ff7f54dbf437cf91bb1e3d3b61 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,16 @@
"name": "The xterm.js authors",
@@ -7,8 +7,15 @@
"url": "https://xtermjs.org/"
},
- "main": "lib/addon-ligatures.js",
+ "main": "lib/addon-ligatures.mjs",
"module": "lib/addon-ligatures.mjs",
"main": "lib/addon-ligatures.js",
- "module": "lib/addon-ligatures.mjs",
+ "module": "lib/addon-ligatures.js",
"types": "typings/addon-ligatures.d.ts",
+ "exports": {
+ ".": {
+ "types": "./typings/addon-ligatures.d.ts",
+ "import": "./lib/addon-ligatures.mjs",
+ "default": "./lib/addon-ligatures.mjs"
+ "import": "./lib/addon-ligatures.js",
+ "default": "./lib/addon-ligatures.js"
+ }
+ },
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-ligatures",

File diff suppressed because one or more lines are too long

View File

@ -74,8 +74,8 @@
"@electron-toolkit/utils": "^4.0.0",
"@linear/sdk": "^82.1.0",
"@parcel/watcher": "^2.5.6",
"@xterm/addon-serialize": "0.15.0-beta.220",
"@xterm/headless": "6.1.0-beta.220",
"@xterm/addon-serialize": "0.15.0-beta.285",
"@xterm/headless": "6.1.0-beta.285",
"agent-browser": "~0.27.0",
"electron-updater": "^6.8.3",
"node-pty": "^1.1.0",
@ -123,13 +123,13 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260421.2",
"@vitejs/plugin-react": "^5.2.0",
"@xterm/addon-fit": "0.12.0-beta.220",
"@xterm/addon-ligatures": "0.11.0-beta.220",
"@xterm/addon-search": "0.17.0-beta.220",
"@xterm/addon-unicode11": "0.10.0-beta.220",
"@xterm/addon-web-links": "0.13.0-beta.220",
"@xterm/addon-webgl": "0.20.0-beta.219",
"@xterm/xterm": "6.1.0-beta.220",
"@xterm/addon-fit": "0.12.0-beta.285",
"@xterm/addon-ligatures": "0.11.0-beta.285",
"@xterm/addon-search": "0.17.0-beta.285",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-web-links": "0.13.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -223,8 +223,7 @@
],
"patchedDependencies": {
"node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch",
"@xterm/addon-ligatures@0.11.0-beta.220": "config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch",
"@xterm/addon-webgl@0.20.0-beta.219": "config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch"
"@xterm/addon-ligatures@0.11.0-beta.285": "config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch"
}
},
"reactDoctor": {

View File

@ -5,12 +5,9 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
'@xterm/addon-ligatures@0.11.0-beta.220':
hash: df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.220.patch
'@xterm/addon-webgl@0.20.0-beta.219':
hash: c76ae4cd585a6b2ea39a39e3c021f125f42afdafeffd3b1a767a3200deb4ffb2
path: config/patches/@xterm__addon-webgl@0.20.0-beta.219.patch
'@xterm/addon-ligatures@0.11.0-beta.285':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.285.patch
node-pty@1.1.0:
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
path: config/patches/node-pty@1.1.0.patch
@ -32,11 +29,11 @@ importers:
specifier: ^2.5.6
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.220
version: 0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220)
specifier: 0.15.0-beta.285
version: 0.15.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/headless':
specifier: 6.1.0-beta.220
version: 6.1.0-beta.220
specifier: 6.1.0-beta.285
version: 6.1.0-beta.285
agent-browser:
specifier: ~0.27.0
version: 0.27.0
@ -174,26 +171,26 @@ importers:
specifier: ^5.2.0
version: 5.2.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.220
version: 0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220)
specifier: 0.12.0-beta.285
version: 0.12.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.220
version: 0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220)
specifier: 0.11.0-beta.285
version: 0.11.0-beta.285(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-search':
specifier: 0.17.0-beta.220
version: 0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220)
specifier: 0.17.0-beta.285
version: 0.17.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.220
version: 0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220)
specifier: 0.10.0-beta.285
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-web-links':
specifier: 0.13.0-beta.220
version: 0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220)
specifier: 0.13.0-beta.285
version: 0.13.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.219
version: 0.20.0-beta.219(patch_hash=c76ae4cd585a6b2ea39a39e3c021f125f42afdafeffd3b1a767a3200deb4ffb2)(@xterm/xterm@6.1.0-beta.220)
specifier: 0.20.0-beta.284
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
'@xterm/xterm':
specifier: 6.1.0-beta.220
version: 6.1.0-beta.220
specifier: 6.1.0-beta.285
version: 6.1.0-beta.285
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -3061,47 +3058,47 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-fit@0.12.0-beta.220':
resolution: {integrity: sha512-lTiEjsdJBYTvIdA8USs90j+pu7/9oCQEh8JYBDJoRxzzAcvk4V1GlEA++Ztspbfk4nSh2HVxwJvdeTD6ffAQlg==}
'@xterm/addon-fit@0.12.0-beta.285':
resolution: {integrity: sha512-rSVi0nMbK+9sgL1QXmJlv3nWvktj0AZeRd52yVzyNhl5mxxEkvxiwFJZlcEBRg27ipeWVAs1PeuCz0A9QnNnAQ==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-ligatures@0.11.0-beta.220':
resolution: {integrity: sha512-fw/mSPqwgBNVX2ejW3rKhXablP3pelKJWYlt1QIr40+zZ//ERcHZWZNLB1I9fojiGW++tCnIHKh0GPpI2h5HgA==}
'@xterm/addon-ligatures@0.11.0-beta.285':
resolution: {integrity: sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==}
engines: {node: '>8.0.0'}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-search@0.17.0-beta.220':
resolution: {integrity: sha512-2QwmGiZ8sn1ISYLuHIt0heUYN0wR6d+AWFZRGsekXbLCvRw/qjclCSKsDsIO1bTMBjey5K1MeZbse6kTKx+kDg==}
'@xterm/addon-search@0.17.0-beta.285':
resolution: {integrity: sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-serialize@0.15.0-beta.220':
resolution: {integrity: sha512-ssZ8rnSU0EOdDsFS5cI5LHKy/T0rQfZBE9iPph7rUxSAwZXbMGz0Vrsob79IKRvfE6oMZlFn+NE0Gg/mKq62Iw==}
'@xterm/addon-serialize@0.15.0-beta.285':
resolution: {integrity: sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-unicode11@0.10.0-beta.220':
resolution: {integrity: sha512-83VMUPOp7VKWsVlw+a6TrZP38VAQM7xigu0JcSj2gVO1xsYRyme9wlgKDX+lmgWrfJPSjgB0vfXpcxI4dt/Wvw==}
'@xterm/addon-unicode11@0.10.0-beta.285':
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-web-links@0.13.0-beta.220':
resolution: {integrity: sha512-XILFvFY0lOpTafIQxUOkS6x/MfZzarW3WbHZ40KEzAE8ImJqwOQiiqG17noLMUDD6rSOv7BRWNJACq4FflBNvg==}
'@xterm/addon-web-links@0.13.0-beta.285':
resolution: {integrity: sha512-deEjcrV/LQFM031l6Jop2mVK79RJru6SlNCjtFXb0Ex5GwIcPZCGW4VdZYMU5toQ3mMDR6yrzWDsb9SFtDrMeA==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.219':
resolution: {integrity: sha512-IaAcDDP0lhHIdYJuTFKZIMsRqlN8iR5U8lW9MiTK4mUAjettqZN2z/tkSsl4yV3GZ9/siOZofDl+GO1TK7y7ww==}
'@xterm/addon-webgl@0.20.0-beta.284':
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.220
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/headless@6.1.0-beta.220':
resolution: {integrity: sha512-oc+cI28eP/ktv91ddk1MxsEdfS41dnNvB3eMjNxfP7v+SaM7aXgXAL8Kj0/zzbpX/dG0nbA9wi3YvsybzCN3OQ==}
'@xterm/headless@6.1.0-beta.285':
resolution: {integrity: sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==}
'@xterm/xterm@6.1.0-beta.220':
resolution: {integrity: sha512-rwEKE75wfwfNWeGnjl0iQRA1W50HigKoGcGMeF3o5CqDSdI9IjmwzpV9xHXqLH6CYj6s9N1g6bbKFVDou9dJ/A==}
'@xterm/xterm@6.1.0-beta.285':
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
@ -4771,6 +4768,10 @@ packages:
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -5168,8 +5169,8 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
opentype.js@0.8.0:
resolution: {integrity: sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg==}
opentype.js@2.0.0:
resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==}
hasBin: true
ora@8.2.0:
@ -5985,9 +5986,6 @@ packages:
tiny-async-pool@1.3.0:
resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -8971,39 +8969,39 @@ snapshots:
'@xmldom/xmldom@0.9.10':
optional: true
'@xterm/addon-fit@0.12.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-fit@0.12.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-ligatures@0.11.0-beta.220(patch_hash=df21db050a4d85552cafbe583ece863d7fa19ed634a1219210a0455b986b6634)(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-ligatures@0.11.0-beta.285(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
lru-cache: 6.0.0
opentype.js: 0.8.0
'@xterm/xterm': 6.1.0-beta.285
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-search@0.17.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-serialize@0.15.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-serialize@0.15.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-unicode11@0.10.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-web-links@0.13.0-beta.220(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-web-links@0.13.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.219(patch_hash=c76ae4cd585a6b2ea39a39e3c021f125f42afdafeffd3b1a767a3200deb4ffb2)(@xterm/xterm@6.1.0-beta.220)':
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.220
'@xterm/xterm': 6.1.0-beta.285
'@xterm/headless@6.1.0-beta.220': {}
'@xterm/headless@6.1.0-beta.285': {}
'@xterm/xterm@6.1.0-beta.220': {}
'@xterm/xterm@6.1.0-beta.285': {}
abbrev@4.0.0: {}
@ -10827,6 +10825,8 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@11.5.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -11473,9 +11473,7 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
opentype.js@0.8.0:
dependencies:
tiny-inflate: 1.0.3
opentype.js@2.0.0: {}
ora@8.2.0:
dependencies:
@ -12515,8 +12513,6 @@ snapshots:
dependencies:
semver: 5.7.2
tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {}
tiny-typed-emitter@2.1.0: {}

View File

@ -74,6 +74,10 @@ describe('buildDefaultTerminalOptions', () => {
expect(buildDefaultTerminalOptions().cursorInactiveStyle).toBe('outline')
})
it('does not reserve a classic scrollbar gutter when fitting terminal columns', () => {
expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(0)
})
it('only uses inactive outline for block cursors', () => {
expect(resolveTerminalCursorInactiveStyle('block')).toBe('outline')
expect(resolveTerminalCursorInactiveStyle('bar')).toBe('bar')
@ -88,10 +92,6 @@ describe('buildDefaultTerminalOptions', () => {
// silently breaks enhanced chords, especially inside tmux.
expect(buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard).toBe(true)
})
it('uses xterm scrollbar options for the slimmer terminal scrollbar', () => {
expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(7)
})
})
describe('attachWebgl', () => {
@ -116,6 +116,7 @@ describe('attachWebgl', () => {
it('keeps a pane on the DOM renderer after WebGL context loss', () => {
const pane = createPane()
pane.terminalGpuAcceleration = 'on'
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
@ -136,6 +137,7 @@ describe('attachWebgl', () => {
it('repaints the current buffer after WebGL attaches', () => {
const pane = createPane()
pane.terminalGpuAcceleration = 'on'
attachWebgl(pane)
@ -144,6 +146,7 @@ describe('attachWebgl', () => {
it('does not attach WebGL while initial rendering is deferred', () => {
const pane = createPane()
pane.terminalGpuAcceleration = 'on'
pane.webglAttachmentDeferred = true
attachWebgl(pane)
@ -162,6 +165,15 @@ describe('attachWebgl', () => {
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
})
it('uses DOM rendering for auto GPU acceleration', () => {
const pane = createPane()
attachWebgl(pane)
expect(pane.webglAddon).toBeNull()
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
})
it('uses DOM rendering for auto GPU acceleration on Linux', () => {
vi.stubGlobal('navigator', {
platform: 'Linux x86_64',
@ -188,58 +200,21 @@ describe('attachWebgl', () => {
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('uses DOM for later auto panes after WebGL attach fails until the suggestion resets', () => {
const firstPane = createPane()
vi.mocked(WebglAddon).mockImplementationOnce(() => {
throw new Error('webgl unavailable')
})
attachWebgl(firstPane)
expect(firstPane.webglAddon).toBeNull()
const laterAutoPane = createPane()
attachWebgl(laterAutoPane)
expect(laterAutoPane.terminal.loadAddon).not.toHaveBeenCalled()
resetTerminalWebglSuggestion()
const retriedAutoPane = createPane()
attachWebgl(retriedAutoPane)
expect(retriedAutoPane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('still attempts WebGL in on mode after auto mode suggests DOM', () => {
const autoPane = createPane()
vi.mocked(WebglAddon).mockImplementationOnce(() => {
throw new Error('webgl unavailable')
})
attachWebgl(autoPane)
const forcedPane = createPane()
forcedPane.terminalGpuAcceleration = 'on'
attachWebgl(forcedPane)
expect(forcedPane.terminal.loadAddon).toHaveBeenCalledTimes(1)
})
it('keeps auto-mode panes on DOM after complex-script output', () => {
const pane = createPane()
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
markComplexScriptOutput(pane)
expect(pane.hasComplexScriptOutput).toBe(true)
expect(pane.webglAddon).toBeNull()
expect(webglMock.dispose).toHaveBeenCalledTimes(1)
expect(webglMock.dispose).not.toHaveBeenCalled()
attachWebgl(pane)
expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
})
it('allows explicit on mode to override complex-script DOM fallback', () => {

View File

@ -24,6 +24,7 @@ import {
} from './pane-fit-resize-observer'
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import {
ENABLE_WEBGL_RENDERER,
@ -197,7 +198,7 @@ export function openTerminal(pane: ManagedPaneInternal): void {
terminal.loadAddon(unicode11Addon)
terminal.loadAddon(webLinksAddon)
// Activate Unicode 11 widths *before* any caller-driven write. CJK / emoji /
// Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji /
// ZWJ codepoints get baked into the buffer at the active unicode version on
// write — if a restore (snapshot, scrollback, cold-restore) writes bytes
// through xterm while the default v6 width tables are still active, wide
@ -206,7 +207,7 @@ export function openTerminal(pane: ManagedPaneInternal): void {
// (replayTerminalLayout → splitPane/createInitialPane → openTerminal,
// restoreScrollbackBuffers, handleReattachResult) run after openTerminal,
// so the activation must stay at this position.
terminal.unicode.activeVersion = '11'
activateOrcaTerminalUnicodeProvider(terminal)
// Why: the OS reads the focused textarea's screen rect at compositionstart to
// decide where to display the IME candidate window. xterm.js only repositions

View File

@ -26,18 +26,16 @@ export function buildDefaultTerminalOptions(): ITerminalOptions {
fontWeight: '300',
fontWeightBold: '500',
scrollback: 10000,
// Why: xterm 6.x renders its own DOM scrollbar (default 14px), which no CSS
// on .xterm-viewport can resize. Slim it via the option. Setting width also
// enables the overview ruler, whose border is hidden via overviewRulerBorder
// in composeActiveTerminalTheme.
scrollbar: {
width: 7
},
allowTransparency: false,
// Why: on macOS, non-US layouts rely on Option to compose characters like @ and €.
macOptionIsMeta: false,
macOptionClickForcesSelection: true,
drawBoldTextInBrightColors: true,
scrollbar: {
// Why: xterm's DOM scrollbar overlays the terminal in Electron; reserving
// gutter width in FitAddon underfits wide table/TUI output by a column.
width: 0
},
// Why: advertise kitty keyboard protocol support so CLIs that probe
// (CSI ? u) know Orca accepts enhanced key reporting. Orca still writes
// CSI-u for Shift+Enter on non-Windows platforms; programs that respect

View File

@ -0,0 +1,71 @@
import type { IUnicodeHandling, IUnicodeVersionProvider } from '@xterm/xterm'
type XtermTerminalWithUnicodeCore = {
unicode: IUnicodeHandling
_core?: {
unicodeService?: {
_providers?: Record<string, IUnicodeVersionProvider>
}
}
}
const ORCA_UNICODE_VERSION = 'orca-11-zwj'
const UNICODE11_VERSION = '11'
const ZERO_WIDTH_JOINER = 0x200d
function extractWidth(properties: number): 0 | 1 | 2 {
return ((properties >> 1) & 3) as 0 | 1 | 2
}
function extractCharKind(properties: number): number {
return properties >> 3
}
function createProperties(charKind: number, width: 0 | 1 | 2, shouldJoin: boolean): number {
return ((charKind & 0xffffff) << 3) | ((width & 3) << 1) | (shouldJoin ? 1 : 0)
}
class OrcaUnicodeProvider implements IUnicodeVersionProvider {
public readonly version = ORCA_UNICODE_VERSION
public constructor(private readonly baseProvider: IUnicodeVersionProvider) {}
public wcwidth(codepoint: number): 0 | 1 | 2 {
return this.baseProvider.wcwidth(codepoint)
}
public charProperties(codepoint: number, preceding: number): number {
const precedingWidth = extractWidth(preceding)
const precedingKind = extractCharKind(preceding)
if (codepoint === ZERO_WIDTH_JOINER && precedingWidth > 0) {
return createProperties(ZERO_WIDTH_JOINER, precedingWidth, true)
}
if (precedingKind === ZERO_WIDTH_JOINER && precedingWidth > 0 && this.wcwidth(codepoint) > 0) {
// Why: CLIs render ZWJ emoji as one visible glyph and budget them as one
// wide cell pair; xterm Unicode11 otherwise advances for both emoji parts.
return createProperties(codepoint, precedingWidth, true)
}
return this.baseProvider.charProperties(codepoint, preceding)
}
}
export function activateOrcaTerminalUnicodeProvider(terminal: XtermTerminalWithUnicodeCore): void {
const { unicode } = terminal
if (unicode.activeVersion === ORCA_UNICODE_VERSION) {
return
}
const baseProvider = terminal._core?.unicodeService?._providers?.[UNICODE11_VERSION]
if (!baseProvider) {
unicode.activeVersion = UNICODE11_VERSION
return
}
if (!unicode.versions.includes(ORCA_UNICODE_VERSION)) {
unicode.register(new OrcaUnicodeProvider(baseProvider))
}
unicode.activeVersion = ORCA_UNICODE_VERSION
}

View File

@ -2,12 +2,10 @@ import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
export const ENABLE_WEBGL_RENDERER = true
let suggestedRendererType: 'dom' | undefined
export function resetTerminalWebglSuggestion(): void {
// Why: VS Code clears its suggested renderer when gpuAcceleration changes,
// letting "auto" retry WebGL after a user toggles the setting.
suggestedRendererType = undefined
// Why: retained for settings/tests while "auto" intentionally stays on DOM
// for this release to avoid renderer metric changes corrupting TUI tables.
}
function isLinuxRenderer(): boolean {
@ -21,16 +19,17 @@ export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean {
if (pane.terminalGpuAcceleration === 'on') {
return true
}
if (pane.terminalGpuAcceleration === 'auto') {
// Why: WebGL and DOM report different cell metrics on current xterm beta;
// switching renderers after emoji/table output rewraps completed TUI rows.
return false
}
if (isLinuxRenderer()) {
// Why: multiple Linux/Wayland GPU stacks corrupt xterm's WebGL glyph atlas
// without raising context loss; tab switching only masks it by rebuilding WebGL.
return false
}
return (
pane.terminalGpuAcceleration === 'auto' &&
suggestedRendererType === undefined &&
!pane.hasComplexScriptOutput
)
return false
}
function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void {
@ -117,12 +116,6 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
pane.webglAddon = webglAddon
refreshTerminalAfterWebglAttach(pane)
} catch (err) {
if (pane.terminalGpuAcceleration === 'auto') {
// Why: mirrors VS Code's `terminal.integrated.gpuAcceleration=auto`
// behavior: once WebGL fails, keep subsequent auto panes on DOM until
// the setting changes and resets the suggestion.
suggestedRendererType = 'dom'
}
// WebGL not available — default DOM renderer is fine, but log it for debugging
console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err)
pane.webglAddon = null

View File

@ -0,0 +1,98 @@
| Emoji | Name | Category | Unicode | Description | Mood | Popularity | Year Added |
|-------|------|----------|---------|-------------|------|------------|------------|
| 😀 | Grinning Face | Smileys | U+1F600 | A happy grinning face | Positive | 95 | 2015 |
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
| 🧑‍💻 | Technologist | People | U+1F9D1 U+200D U+1F4BB | Person at computer | Focused | 92 | 2020 |
| 🎉 | Party Popper | Events | U+1F389 | Celebration popper | Festive | 97 | 2010 |
| 🐱 | Cat Face | Animals | U+1F431 | A cute cat face | Cute | 85 | 2010 |
| 🍕 | Pizza | Food | U+1F355 | Slice of pizza | Hungry | 90 | 2010 |
| 💻 | Laptop | Objects | U+1F4BB | A personal computer | Productive | 78 | 2010 |
| 🌍 | Globe Europe-Africa | Travel | U+1F30D | Earth showing Europe/Africa | Wanderlust | 72 | 2010 |
| ❤️ | Red Heart | Symbols | U+2764 | A red heart symbol | Loving | 99 | 2010 |
| 🎵 | Musical Note | Music | U+1F3B5 | Single eighth note | Musical | 76 | 2010 |
| 🤖 | Robot Face | Smileys | U+1F916 | A mechanical robot | Neutral | 81 | 2016 |
| ☕ | Hot Beverage | Food | U+1F375 | A steaming coffee cup | Awake | 89 | 2010 |
| 🌈 | Rainbow | Nature | U+1F308 | A colorful rainbow | Hopeful | 83 | 2010 |
| 🦄 | Unicorn Face | Animals | U+1F984 | A fantasy unicorn | Magical | 91 | 2015 |
| ⚡ | High Voltage | Symbols | U+26A1 | Lightning bolt | Energetic | 86 | 2010 |
| 🎸 | Guitar | Music | U+1F3B8 | A six-string guitar | Rocking | 71 | 2010 |
| 🍀 | Four Leaf Clover | Nature | U+1F340 | A lucky clover | Lucky | 82 | 2010 |
| 🏆 | Trophy | Awards | U+1F3C6 | A gold championship cup | Victorious | 87 | 2010 |
| 🧠 | Brain | Body | U+1F9E0 | A human brain | Smart | 93 | 2018 |
| 🌮 | Taco | Food | U+1F32E | A folded taco | Tasty | 84 | 2015 |
| 🛸 | Flying Saucer | Travel | U+1F6F8 | An alien spacecraft | Mysterious | 69 | 2017 |
| 🎨 | Artist Palette | Arts | U+1F3A8 | A painter's palette | Creative | 77 | 2010 |
| 🐧 | Penguin | Animals | U+1F427 | A cute penguin | Chill | 79 | 2010 |
| 💎 | Gem Stone | Objects | U+1F48E | A sparkling diamond | Precious | 74 | 2010 |
| 🎮 | Video Game | Objects | U+1F3AE | A game controller | Gaming | 88 | 2010 |
| 🌊 | Water Wave | Nature | U+1F30A | A crashing ocean wave | Calm | 80 | 2010 |
| 🔥 | Fire | Nature | U+1F525 | A burning flame | Lit | 98 | 2010 |
| 🌟 | Glowing Star | Symbols | U+1F31F | A twinkling star | Bright | 85 | 2010 |
| 🎤 | Microphone | Music | U+1F3A4 | A stage microphone | Singing | 73 | 2010 |
| 🐶 | Dog Face | Animals | U+1F436 | A friendly dog face | Loyal | 86 | 2010 |
| 🍩 | Doughnut | Food | U+1F369 | A frosted doughnut | Sweet | 82 | 2010 |
| 🚲 | Bicycle | Travel | U+1F6B2 | A push bike | Active | 67 | 2010 |
| 🎭 | Performing Arts | Arts | U+1F3AD | Theater masks | Dramatic | 64 | 2010 |
| 🐉 | Dragon | Animals | U+1F409 | A mythical dragon | Powerful | 78 | 2010 |
| 🎪 | Circus Tent | Events | U+1F3AA | A striped circus tent | Fun | 61 | 2010 |
| 🧊 | Ice Cube | Objects | U+1F9CA | A block of ice | Cool | 70 | 2019 |
| 🎯 | Bullseye | Symbols | U+1F3AF | A dart hitting target | Focused | 83 | 2010 |
| 🐬 | Dolphin | Animals | U+1F42C | A leaping dolphin | Playful | 80 | 2010 |
| 🚁 | Helicopter | Travel | U+1F681 | A flying helicopter | Aerial | 63 | 2010 |
| 🦊 | Fox Face | Animals | U+1F98A | A clever fox | Sneaky | 79 | 2016 |
| 🎲 | Game Die | Objects | U+1F3B2 | A six-sided die | Random | 68 | 2010 |
| 🍔 | Hamburger | Food | U+1F354 | A classic burger | Juicy | 91 | 2010 |
| 🦋 | Butterfly | Animals | U+1F98B | A colorful butterfly | Graceful | 84 | 2016 |
| 🏔️ | Snow-Capped Mountain | Travel | U+1F3D4 | A snowy mountain peak | Adventurous | 71 | 2010 |
| 🎩 | Top Hat | Objects | U+1F3A9 | A formal top hat | Classy | 62 | 2010 |
| 🐳 | Spouting Whale | Animals | U+1F433 | A whale spraying water | Majestic | 76 | 2010 |
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
| 🎹 | Musical Keyboard | Music | U+1F3B9 | A piano keyboard | Melodic | 66 | 2010 |
| 🍉 | Watermelon | Food | U+1F349 | A slice of watermelon | Refreshing | 81 | 2010 |
| 🧩 | Puzzle Piece | Objects | U+1F9E9 | A jigsaw puzzle piece | Challenging | 75 | 2018 |
| 🦀 | Crab | Animals | U+1F980 | A red crab | Feisty | 68 | 2015 |
| 🏄 | Surfing | Sports | U+1F3C4 | Someone riding a wave | Gnarly | 65 | 2010 |
| 🧑‍🎤 | Singer | People | U+1F9D1 U+200D U+1F3A4 | A stage performer | Talented | 78 | 2020 |
| 🌵 | Cactus | Nature | U+1F335 | A desert cactus | Tough | 73 | 2010 |
| 🧲 | Magnet | Objects | U+1F9F2 | A horseshoe magnet | Attractive | 58 | 2018 |
| 🦆 | Duck | Animals | U+1F986 | A yellow duck | Quirky | 74 | 2016 |
| 🚇 | Metro | Travel | U+1F687 | A subway train | Urban | 59 | 2010 |
| 🧾 | Receipt | Objects | U+1F9FE | A paper receipt | Transactional | 56 | 2018 |
| 🫘 | Beans | Food | U+1FAD8 | A bowl of beans | Nutritious | 42 | 2021 |
| 🦕 | Sauropod | Animals | U+1F995 | A long-neck dinosaur | Prehistoric | 72 | 2016 |
| 🏵️ | Rosette | Awards | U+1F3F5 | A decorative rosette | Honorable | 51 | 2010 |
| 🦮 | Guide Dog | Animals | U+1F9AE | A seeing-eye dog | Helpful | 67 | 2019 |
| 🦴 | Bone | Body | U+1F9B4 | A dog bone | Gnawing | 60 | 2019 |
| 🥏 | Flying Disc | Sports | U+1F94F | A frisbee | Airborne | 48 | 2017 |
| 🦧 | Orangutan | Animals | U+1F9A7 | A red-haired ape | Wise | 55 | 2019 |
| 🥭 | Mango | Food | U+1F96D | A ripe tropical fruit | Tropical | 71 | 2018 |
| 🧋 | Bubble Tea | Food | U+1F9CB | Tapioca pearl drink | Trendy | 78 | 2020 |
| 🪀 | Yo-Yo | Objects | U+1FA80 | A string toy | Nostalgic | 44 | 2020 |
| 🦤 | Dodo | Animals | U+1F9A4 | An extinct flightless bird | Extinct | 53 | 2019 |
| 🪐 | Ringed Planet | Space | U+1FA90 | Saturn with rings | Astronomical | 77 | 2020 |
| 🛹 | Skateboard | Sports | U+1F6F9 | A kick push skateboard | Rad | 67 | 2019 |
| 🥲 | Smiling Face with Tear | Smileys | U+1F972 | Happy but crying inside | Bittersweet | 90 | 2020 |
| 🫠 | Melting Face | Smileys | U+1FAE0 | Face slowly melting | Uncomfortable | 69 | 2021 |
| 🪩 | Mirror Ball | Objects | U+1FAA9 | A disco ball | Groovy | 73 | 2022 |
| 🪭 | Folding Hand Fan | Objects | U+1FAAF | A collapsible hand fan | Elegant | 38 | 2023 |
| 🫎 | Moose | Animals | U+1FACE | A large antlered mammal | Majestic | 45 | 2023 |
| 🪸 | Coral | Nature | U+1FAB8 | A coral reef formation | Vibrant | 52 | 2023 |
| 🪼 | Jellyfish | Animals | U+1FABC | A floating gelatinous sea creature | Ethereal | 49 | 2023 |
| 🫏 | Donkey | Animals | U+1FACF | A gray donkey | Stubborn | 41 | 2023 |
| 🫅 | Person with Crown | People | U+1FAC5 | A crowned royal figure | Regal | 58 | 2022 |
| 🪇 | Maracas | Music | U+1FA87 | A handheld percussion shaker | Rhythmic | 43 | 2023 |
| 🫘 | Beans | Food | U+1FAD8 | A bowl of legumes | Hearty | 42 | 2021 |
| 🦪 | Oyster | Food | U+1F9AA | A closed shellfish | Briny | 54 | 2019 |
| 🪨 | Rock | Nature | U+1FAA8 | A grey stone | Solid | 47 | 2020 |
| 🥸 | Disguised Face | Smileys | U+1F978 | Face with fake nose/glasses | Sneaky | 66 | 2021 |
| 🦬 | Bison | Animals | U+1F9AC | A large North American bovine | Sturdy | 50 | 2019 |
| 🥟 | Dumpling | Food | U+1F95F | A folded filled dough | Steamy | 76 | 2018 |
| 🪴 | Potted Plant | Nature | U+1FAB4 | A houseplant in a pot | Green | 68 | 2020 |
| 🛼 | Roller Skate | Sports | U+1F6FC | A quad-wheeled skate | Retro | 56 | 2021 |
| 🥏 | Flying Disc | Sports | U+1F94F | A plastic throwing disc | Soaring | 48 | 2017 |
| 🧃 | Beverage Box | Food | U+1F9C3 | A juice box | Convenient | 63 | 2018 |
| 🦚 | Peacock | Animals | U+1F99A | A bird with iridescent tail | Proud | 75 | 2018 |
| 🫧 | Bubbles | Nature | U+1FAE7 | Floating iridescent spheres | Light | 57 | 2023 |
| 🥅 | Goal Net | Sports | U+1F945 | A soccer/hockey goal | Scoring | 44 | 2017 |
| 🦺 | Safety Vest | Objects | U+1F9BA | A high-visibility vest | Protected | 51 | 2019 |
| ✈️ | Airplane | Travel | U+2708 | A flying commercial jet | Traveling | 94 | 2010 |

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@ -47,6 +47,11 @@ type LongTableDebugWindow = Window & {
}
}
const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
@ -129,6 +134,177 @@ const timer = setInterval(() => {
`
}
function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
return `
const table = ${JSON.stringify(table)}
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
const tableOverhead = preferredWidths.length * 3 + 1
const widthBudget = Math.max(
minimumWidths.reduce((sum, width) => sum + width, 0),
Math.min(
preferredWidths.reduce((sum, width) => sum + width, 0),
(process.stdout.columns || 100) - tableOverhead - 1
)
)
let remaining = widthBudget
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
const widths = preferredWidths.map((preferred, index) => {
const minimum = minimumWidths[index]
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
remaining -= width
remainingPreferred -= preferred
return width
})
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
}
return width
}
function padCell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
}
function splitToWidth(text, width) {
const parts = []
let line = ''
for (const char of String(text)) {
const next = line + char
if (line && cellWidth(next) > width) {
parts.push(line)
line = char
} else {
line = next
}
}
if (line) parts.push(line)
return parts
}
function wrapCell(value, width) {
const words = String(value).split(/\\s+/)
const lines = []
let line = ''
for (const word of words) {
if (cellWidth(word) > width) {
if (line) {
lines.push(line)
line = ''
}
lines.push(...splitToWidth(word, width))
continue
}
const next = line ? line + ' ' + word : word
if (line && cellWidth(next) > width) {
lines.push(line)
line = word
} else {
line = next
}
}
if (line) lines.push(line)
return lines.length ? lines : ['']
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
const height = Math.max(...wrappedCells.map((cell) => cell.length))
const rows = []
for (let line = 0; line < height; line += 1) {
rows.push(
border.vertical +
widths
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
.join(border.vertical) +
border.vertical
)
}
return rows
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
process.stdout.write(rendered.join('\\r\\n'))
process.stdout.write('\\r\\n')
process.stdout.write('\\r\\n${marker}\\r\\n')
process.stdout.write('\\x1b[?2026l')
`
}
function narrowSignerMarkdownTableScript(runId: string): string {
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const rows = [
'| # | Status | Signer | Action |',
'| ---: | --- | --- | --- |',
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
]
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
process.stdout.write(row + '\\r\\n')
}
process.stdout.write('${marker}\\r\\n')
process.stdout.write('\\x1b[?2026l')
`
}
async function setNarrowTerminalViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 920, height: 820 })
await page.waitForTimeout(250)
await page.evaluate(() => {
const store = window.__store
if (store?.getState().rightSidebarOpen) {
store.setState({ rightSidebarOpen: false })
}
})
await page.waitForTimeout(250)
}
async function setRenderedTableViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1180, height: 820 })
await page.waitForTimeout(250)
await page.evaluate(() => {
const store = window.__store
if (store?.getState().rightSidebarOpen) {
store.setState({ rightSidebarOpen: false })
}
})
await page.waitForTimeout(250)
}
async function scrollActiveTerminalLikeUser(page: Page): Promise<void> {
const target = await page.evaluate(() => {
const store = window.__store
@ -164,6 +340,185 @@ async function scrollActiveTerminalLikeUser(page: Page): Promise<void> {
await page.waitForTimeout(250)
}
async function scrollActiveTerminalToText(page: Page, text: string): Promise<void> {
const target = await page.evaluate(() => {
const store = window.__store
const state = 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('Active terminal pane unavailable')
}
pane.terminal.focus()
pane.terminal.scrollToBottom()
const viewport =
pane.container.querySelector<HTMLElement>('.xterm-viewport') ??
pane.container.querySelector<HTMLElement>('.xterm')
if (!viewport) {
throw new Error('Active terminal viewport unavailable')
}
const rect = viewport.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
})
await page.mouse.move(target.x, target.y)
for (let attempt = 0; attempt < 80; attempt += 1) {
if ((await readActiveTerminalVisibleText(page)).includes(text)) {
return
}
await page.mouse.wheel(0, -600)
await page.waitForTimeout(50)
}
throw new Error(`Text not visible after scrolling terminal: ${text}`)
}
async function readActiveTerminalVisibleText(page: Page): Promise<string> {
return page.evaluate(() => {
const store = window.__store
const state = 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('Active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
return Array.from({ length: pane.terminal.rows }, (_, row) => {
const line = buffer.getLine(buffer.viewportY + row)
return line?.translateToString(true) ?? ''
}).join('\n')
})
}
async function forceDarkTerminalRendererPath(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store unavailable')
}
const state = store.getState()
store.setState({
settings: {
...state.settings!,
terminalGpuAcceleration: 'auto',
theme: 'dark'
}
})
})
await page.waitForTimeout(250)
}
async function readTerminalRightEdgeOverpaint(page: Page): Promise<{
screenRight: number
offenderCount: number
offenders: { text: string; right: number; width: number }[]
}> {
return page.evaluate(() => {
const store = window.__store
const state = 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
const screen = pane?.container.querySelector<HTMLElement>('.xterm-screen')
const rows = pane?.container.querySelector<HTMLElement>('.xterm-rows')
if (!pane || !screen || !rows) {
throw new Error('Active terminal DOM unavailable')
}
const screenRect = screen.getBoundingClientRect()
const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0
const maxRight = screenRect.right + Math.max(1, cellWidth * 0.5)
const offenders = Array.from(rows.querySelectorAll<HTMLElement>('span'))
.map((span) => {
const rect = span.getBoundingClientRect()
return {
text: span.textContent ?? '',
right: rect.right,
width: rect.width
}
})
.filter((span) => span.width > 0 && span.right > maxRight)
.slice(0, 12)
return {
screenRight: screenRect.right,
offenderCount: offenders.length,
offenders
}
})
}
async function readTerminalBoxTableWrapDiagnostics(page: Page): Promise<{
cols: number
rows: number
baseY: number
viewportY: number
wrappedBoxLines: { index: number; text: string }[]
nearSinger: { index: number; isWrapped: boolean; text: string }[]
}> {
return page.evaluate(() => {
const store = window.__store
const state = 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('Active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
const lineCount = buffer.baseY + buffer.length
const lines = Array.from({ length: lineCount }, (_, index) => {
const line = buffer.getLine(index)
return {
index,
isWrapped: line?.isWrapped === true,
text: line?.translateToString(true) ?? ''
}
})
const wrappedBoxLines = lines
.filter((line) => line.isWrapped && /[┌┬┐├┼┤└┴┘│─]/.test(line.text))
.slice(0, 20)
const singerIndex = lines.findIndex((line) => line.text.includes('Singer'))
const nearSinger =
singerIndex === -1 ? [] : lines.slice(Math.max(0, singerIndex - 4), singerIndex + 7)
return {
cols: pane.terminal.cols,
rows: pane.terminal.rows,
baseY: buffer.baseY,
viewportY: buffer.viewportY,
wrappedBoxLines,
nearSinger
}
})
}
async function closeFeatureTips(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
@ -302,4 +657,162 @@ test.describe('Terminal long table scroll restore repro', () => {
rmSync(scriptPath, { force: true })
}
})
test('keeps narrow wrapped signer markdown table coherent after restore and scroll', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(() => {
window.__store
?.getState()
.markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset()
})
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'narrow signer table repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await setRenderedTableViewport(orcaPage)
await forceDarkTerminalRendererPath(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-narrow-signer-table-${runId}.mjs`)
writeFileSync(scriptPath, narrowSignerMarkdownTableScript(runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await orcaPage.waitForTimeout(80)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(1_000)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'narrow signer table marker did not survive workspace switch'
})
.toContain(marker)
await scrollActiveTerminalLikeUser(orcaPage)
await closeFeatureTips(orcaPage)
const diagnostics = await readTerminalRenderDiagnostics(orcaPage)
const hiddenDebug = await orcaPage.evaluate(() =>
(window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot()
)
expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0)
expect(diagnostics.cols).toBeLessThanOrEqual(110)
expect(diagnostics.hasWebgl).toBe(false)
expect(diagnostics.cursorHidden).toBe(false)
const content = await getTerminalContent(orcaPage, 30_000)
expect(content).toContain('Signer')
expect(content).toContain('did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725')
expect(content).toContain(marker)
const screenshotPath = testInfo.outputPath('narrow-signer-table-after-switch-scroll.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('narrow-signer-table-after-switch-scroll.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
test('keeps real emoji markdown table right edge clean after restore and scroll', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(() => {
window.__store
?.getState()
.markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset()
})
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'real emoji table repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await setNarrowTerminalViewport(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-emoji-fixture-table-${runId}.mjs`)
writeFileSync(scriptPath, emojiFixtureMarkdownTableScript(EMOJI_TABLE_FIXTURE, runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await orcaPage.waitForTimeout(80)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(1_000)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'real emoji table marker did not survive workspace switch'
})
.toContain(marker)
await scrollActiveTerminalToText(orcaPage, 'Singer')
await closeFeatureTips(orcaPage)
await expect
.poll(() => readActiveTerminalVisibleText(orcaPage), {
timeout: 5_000,
message: 'Singer row should be visible before screenshot'
})
.toContain('Singer')
const diagnostics = await readTerminalRenderDiagnostics(orcaPage)
const overpaint = await readTerminalRightEdgeOverpaint(orcaPage)
const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage)
const hiddenDebug = await orcaPage.evaluate(() =>
(window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot()
)
expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0)
expect(diagnostics.cols).toBeLessThan(100)
expect(diagnostics.hasWebgl).toBe(false)
expect(diagnostics.cursorHidden).toBe(false)
testInfo.annotations.push({
type: 'real-emoji-table-overpaint',
description: JSON.stringify(overpaint)
})
testInfo.annotations.push({
type: 'real-emoji-table-wrap-diagnostics',
description: JSON.stringify(wrapDiagnostics)
})
const screenshotPath = testInfo.outputPath('real-emoji-table-after-switch-scroll.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('real-emoji-table-after-switch-scroll.png', {
path: screenshotPath,
contentType: 'image/png'
})
expect(overpaint.offenders).toEqual([])
expect(wrapDiagnostics.wrappedBoxLines).toEqual([])
} finally {
rmSync(scriptPath, { force: true })
}
})
})

View File

@ -255,12 +255,7 @@ test.describe('OpenCode emoji table terminal rendering', () => {
const renderState = await readActiveTerminalRenderState(orcaPage)
const blinkSamples = await sampleCursorBlink(orcaPage)
const screenshotPath = testInfo.outputPath('opencode-emoji-table-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('opencode-emoji-table-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
testInfo.annotations.push({
type: 'opencode-emoji-table-rendering',
description: JSON.stringify({ renderState, blinkSamples })

View File

@ -0,0 +1,454 @@
import { randomUUID } from 'node:crypto'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
type BrowserTerminalPane = {
terminal: {
cols: number
rows: number
buffer: {
active: {
baseY: number
length: number
viewportY: number
getLine: (index: number) =>
| {
isWrapped?: boolean
translateToString: (trimRight?: boolean) => string
}
| undefined
}
}
focus: () => void
scrollToBottom: () => void
_core?: {
coreService?: { isCursorHidden?: boolean }
_renderService?: { dimensions?: { css?: { cell?: { width?: number } } } }
}
}
container: HTMLElement
webglAddon?: unknown
}
type RawTableDebugWindow = Window & {
getActiveTestPane?: () => BrowserTerminalPane
}
const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
function rawEmojiFixtureBoxTableScript(table: string, runId: string): string {
const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
return `
const table = ${JSON.stringify(table)}
const widths = [5, 17, 10, 25, 23, 12, 10, 10]
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
const segmenter =
typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function graphemes(text) {
if (!segmenter) return Array.from(String(text))
return Array.from(segmenter.segment(String(text)), (part) => part.segment)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const cluster of graphemes(text)) {
if (cluster.includes('\\u200d')) {
width += 2
continue
}
const codePoint = cluster.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function clipCell(value, width) {
let text = ''
let used = 0
for (const cluster of graphemes(value)) {
const nextWidth = cellWidth(cluster)
if (used + nextWidth > width) break
text += cluster
used += nextWidth
}
return text + ' '.repeat(Math.max(0, width - used))
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
return (
border.vertical +
widths.map((width, index) => ' ' + clipCell(cells[index] ?? '', width) + ' ').join(border.vertical) +
border.vertical
)
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
process.stdout.write('\\x1b[?2026h\\x1b[2J\\x1b[H')
process.stdout.write(rendered.join('\\r\\n'))
process.stdout.write('\\r\\n${marker}\\r\\n')
process.stdout.write('\\x1b[?2026l')
`
}
async function setWideRenderedTableViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1480, height: 820 })
await page.waitForTimeout(250)
await page.evaluate(() => {
const store = window.__store
if (store?.getState().rightSidebarOpen) {
store.setState({ rightSidebarOpen: false })
}
})
await page.waitForTimeout(250)
}
async function scrollActiveTerminalToText(page: Page, text: string): Promise<void> {
const target = await page.evaluate(() => {
const store = window.__store
const state = 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('Active terminal pane unavailable')
}
pane.terminal.focus()
pane.terminal.scrollToBottom()
const viewport =
pane.container.querySelector<HTMLElement>('.xterm-viewport') ??
pane.container.querySelector<HTMLElement>('.xterm')
if (!viewport) {
throw new Error('Active terminal viewport unavailable')
}
const rect = viewport.getBoundingClientRect()
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
})
await page.mouse.move(target.x, target.y)
for (let attempt = 0; attempt < 80; attempt += 1) {
if ((await readActiveTerminalVisibleText(page)).includes(text)) {
return
}
await page.mouse.wheel(0, -600)
await page.waitForTimeout(50)
}
throw new Error(`Text not visible after scrolling terminal: ${text}`)
}
async function readActiveTerminalVisibleText(page: Page): Promise<string> {
return page.evaluate(() => {
const pane = (window as RawTableDebugWindow).getActiveTestPane?.()
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
return Array.from({ length: pane.terminal.rows }, (_, row) => {
const line = buffer.getLine(buffer.viewportY + row)
return line?.translateToString(true) ?? ''
}).join('\n')
})
}
async function readTerminalBoxTableWrapDiagnostics(page: Page): Promise<{
cols: number
rows: number
baseY: number
viewportY: number
wrappedBoxLines: { index: number; text: string }[]
wrappedSingerContinuationLines: { index: number; text: string }[]
nearSinger: { index: number; isWrapped: boolean; text: string }[]
}> {
return page.evaluate(() => {
const pane = (window as RawTableDebugWindow).getActiveTestPane?.()
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const buffer = pane.terminal.buffer.active
const lineCount = buffer.baseY + buffer.length
const lines = Array.from({ length: lineCount }, (_, index) => {
const line = buffer.getLine(index)
return {
index,
isWrapped: line?.isWrapped === true,
text: line?.translateToString(true) ?? ''
}
})
const wrappedBoxLines = lines
.filter((line) => line.isWrapped && /[┌┬┐├┼┤└┴┘│─]/.test(line.text))
.slice(0, 20)
const wrappedSingerContinuationLines = lines
.filter((line) => line.isWrapped && /U\\+1F3A4|A stage performer|Talented/.test(line.text))
.slice(0, 20)
const singerIndex = lines.findIndex((line) => line.text.includes('Singer'))
const nearSinger =
singerIndex === -1 ? [] : lines.slice(Math.max(0, singerIndex - 4), singerIndex + 7)
return {
cols: pane.terminal.cols,
rows: pane.terminal.rows,
baseY: buffer.baseY,
viewportY: buffer.viewportY,
wrappedBoxLines,
wrappedSingerContinuationLines,
nearSinger
}
})
}
async function readTerminalRightEdgeOverpaint(page: Page): Promise<{
screenRight: number
offenderCount: number
offenders: { text: string; right: number; width: number }[]
}> {
return page.evaluate(() => {
const pane = (window as RawTableDebugWindow).getActiveTestPane?.()
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
const rows = pane.container.querySelector<HTMLElement>('.xterm-rows')
if (!screen || !rows) {
throw new Error('Active terminal DOM unavailable')
}
const screenRect = screen.getBoundingClientRect()
const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0
const maxRight = screenRect.right + Math.max(1, cellWidth * 0.5)
const offenders = Array.from(rows.querySelectorAll<HTMLElement>('span'))
.map((span) => {
const rect = span.getBoundingClientRect()
return {
text: span.textContent ?? '',
right: rect.right,
width: rect.width
}
})
.filter((span) => span.width > 0 && span.right > maxRight)
.slice(0, 12)
return {
screenRight: screenRect.right,
offenderCount: offenders.length,
offenders
}
})
}
async function readVisibleSingerRowGeometry(page: Page): Promise<{
cols: number
screenRight: number
rowRight: number
rowText: string
}> {
return page.evaluate(() => {
const pane = (window as RawTableDebugWindow).getActiveTestPane?.()
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
const rows = pane.container.querySelector<HTMLElement>('.xterm-rows')
if (!screen || !rows) {
throw new Error('Active terminal DOM unavailable')
}
const row = Array.from(rows.children).find((element) =>
(element.textContent ?? '').includes('Singer')
) as HTMLElement | undefined
if (!row) {
throw new Error('Singer row DOM unavailable')
}
const screenRect = screen.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
return {
cols: pane.terminal.cols,
screenRight: screenRect.right,
rowRight: rowRect.right,
rowText: row.textContent ?? ''
}
})
}
async function readTerminalRenderDiagnostics(page: Page): Promise<{
hasWebgl: boolean
cursorHidden: boolean | null
}> {
return page.evaluate(() => {
const pane = (window as RawTableDebugWindow).getActiveTestPane?.()
if (!pane) {
throw new Error('Active terminal pane unavailable')
}
const terminalCore = pane.terminal._core
return {
hasWebgl: Boolean(pane.webglAddon),
cursorHidden: terminalCore?.coreService?.isCursorHidden ?? null
}
})
}
async function closeFeatureTips(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
if (store?.getState().activeModal === 'feature-tips') {
store.getState().closeModal()
}
})
}
test.describe('Terminal raw emoji table scroll restore repro', () => {
test.beforeEach(async ({ orcaPage }) => {
await orcaPage.evaluate(() => {
;(window as RawTableDebugWindow).getActiveTestPane = () => {
const store = window.__store
const state = 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('Active terminal pane unavailable')
}
return pane as BrowserTerminalPane
}
})
})
test('keeps raw emoji box table aligned after restore and scroll', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await closeFeatureTips(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'raw emoji table repro needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await setWideRenderedTableViewport(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const marker = `RAW_EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-raw-emoji-fixture-table-${runId}.mjs`)
writeFileSync(scriptPath, rawEmojiFixtureBoxTableScript(EMOJI_TABLE_FIXTURE, runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await orcaPage.waitForTimeout(80)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(1_000)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'raw emoji table marker did not survive workspace switch'
})
.toContain(marker)
await scrollActiveTerminalToText(orcaPage, 'Singer')
await closeFeatureTips(orcaPage)
const diagnostics = await readTerminalRenderDiagnostics(orcaPage)
const overpaint = await readTerminalRightEdgeOverpaint(orcaPage)
const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage)
const singerGeometry = await readVisibleSingerRowGeometry(orcaPage)
testInfo.annotations.push({
type: 'raw-emoji-table-singer-geometry',
description: JSON.stringify(singerGeometry)
})
testInfo.annotations.push({
type: 'raw-emoji-table-overpaint',
description: JSON.stringify(overpaint)
})
testInfo.annotations.push({
type: 'raw-emoji-table-wrap-diagnostics',
description: JSON.stringify(wrapDiagnostics)
})
const screenshotPath = testInfo.outputPath('raw-emoji-table-after-switch-scroll.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('raw-emoji-table-after-switch-scroll.png', {
path: screenshotPath,
contentType: 'image/png'
})
expect(diagnostics.hasWebgl).toBe(false)
expect(diagnostics.cursorHidden).toBe(false)
expect(overpaint.offenders).toEqual([])
expect(wrapDiagnostics.wrappedBoxLines).toEqual([])
expect(wrapDiagnostics.wrappedSingerContinuationLines).toEqual([])
expect(singerGeometry.rowRight).toBeLessThanOrEqual(singerGeometry.screenRight + 1)
} finally {
rmSync(scriptPath, { force: true })
}
})
})