Fix terminal glyph renderer fallback (#1850)

* fix terminal glyph renderer fallback

* remove terminal glyph design doc from pr
This commit is contained in:
Jinjing
2026-05-14 11:01:44 -07:00
committed by GitHub
parent 5b24ae4198
commit cfa98d4589
3 changed files with 80 additions and 15 deletions
@@ -12,7 +12,7 @@ import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
import { terminalOutputRequiresDomRenderer } from '@/lib/pane-manager/terminal-complex-script'
import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script'
import {
paneLeafId,
POST_REPLAY_MODE_RESET,
@@ -626,7 +626,7 @@ export function connectPanePty(
// Why: drain any queued background bytes BEFORE the replay paint, so the
// scheduler's deferred drain cannot land older bytes on top of the replay.
flushTerminalOutput(pane.terminal)
if (terminalOutputRequiresDomRenderer(data)) {
if (terminalOutputPrefersDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
replayIntoTerminal(pane, deps.replayingPanesRef, data)
@@ -641,7 +641,7 @@ export function connectPanePty(
}
const dataCallback = (data: string): void => {
if (terminalOutputRequiresDomRenderer(data)) {
if (terminalOutputPrefersDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
// Why: visibility is the right gate — split-pane layouts have multiple
@@ -1,16 +1,44 @@
import { describe, expect, it } from 'vitest'
import { terminalOutputRequiresDomRenderer } from './terminal-complex-script'
import { terminalOutputPrefersDomRenderer } from './terminal-complex-script'
describe('terminalOutputRequiresDomRenderer', () => {
describe('terminalOutputPrefersDomRenderer', () => {
it('detects Arabic terminal output', () => {
expect(terminalOutputRequiresDomRenderer('Arabic: السلام عليكم')).toBe(true)
expect(terminalOutputPrefersDomRenderer('Arabic: السلام عليكم')).toBe(true)
})
it('detects RTL scripts that need browser text shaping/order', () => {
expect(terminalOutputRequiresDomRenderer('Hebrew: שלום')).toBe(true)
expect(terminalOutputPrefersDomRenderer('Hebrew: שלום')).toBe(true)
})
it('does not disable WebGL for ordinary terminal output', () => {
expect(terminalOutputRequiresDomRenderer('abc 123 ── ✓')).toBe(false)
it('detects glyph classes common in agent terminal UIs', () => {
expect(terminalOutputPrefersDomRenderer('⠋ Working')).toBe(true)
expect(terminalOutputPrefersDomRenderer('├─ file.ts')).toBe(true)
expect(terminalOutputPrefersDomRenderer('█ progress')).toBe(true)
expect(terminalOutputPrefersDomRenderer('◆ status')).toBe(true)
expect(terminalOutputPrefersDomRenderer('\uE0B0 prompt')).toBe(true)
expect(terminalOutputPrefersDomRenderer('bad replacement ')).toBe(true)
})
it('detects emoji and variation sequences', () => {
expect(terminalOutputPrefersDomRenderer('status 🚀')).toBe(true)
expect(terminalOutputPrefersDomRenderer('developer 👩‍💻')).toBe(true)
expect(terminalOutputPrefersDomRenderer('heart ♥️')).toBe(true)
})
it('detects supplementary-plane complex-script ranges', () => {
expect(terminalOutputPrefersDomRenderer('Adlam: 𞤀')).toBe(true)
expect(terminalOutputPrefersDomRenderer('Medefaidrin: 𐻀')).toBe(true)
})
it('detects split surrogate chunks so fallback is not lost at chunk boundaries', () => {
const [high, low] = Array.from('🚀')[0].split('')
expect(terminalOutputPrefersDomRenderer(high)).toBe(true)
expect(terminalOutputPrefersDomRenderer(low)).toBe(true)
})
it('does not disable WebGL for ordinary terminal output or ANSI controls alone', () => {
expect(terminalOutputPrefersDomRenderer('abc 123 ✓')).toBe(false)
expect(terminalOutputPrefersDomRenderer('\x1b[32mplain green\x1b[0m')).toBe(false)
})
})
@@ -1,8 +1,45 @@
// Why: xterm's WebGL renderer draws per-cell glyphs from a texture atlas,
// bypassing the browser shaping/bidi path that the DOM renderer provides.
const COMPLEX_SCRIPT_PATTERN =
/[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0840-\u085F\u0860-\u086F\u0870-\u089F\u08A0-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]|\u{10EC0}-\u{10EFF}|\u{1E900}-\u{1E95F}/u
// Why: xterm WebGL renders from a glyph atlas; agent TUIs often combine glyphs
// that are safer through the browser text path even when they are not RTL.
const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u
export function terminalOutputRequiresDomRenderer(data: string): boolean {
return COMPLEX_SCRIPT_PATTERN.test(data)
function isInRange(value: number, start: number, end: number): boolean {
return value >= start && value <= end
}
function isRendererRiskCodePoint(value: number): boolean {
return (
isInRange(value, 0x0590, 0x08ff) ||
value === 0x200d ||
isInRange(value, 0x2500, 0x259f) ||
isInRange(value, 0x25a0, 0x25ff) ||
isInRange(value, 0x2800, 0x28ff) ||
isInRange(value, 0xd800, 0xdfff) ||
isInRange(value, 0xe000, 0xf8ff) ||
isInRange(value, 0xfb1d, 0xfdff) ||
isInRange(value, 0xfe00, 0xfe0f) ||
isInRange(value, 0xfe70, 0xfeff) ||
value === 0xfffd ||
isInRange(value, 0x10ec0, 0x10eff) ||
isInRange(value, 0x1e900, 0x1e95f) ||
isInRange(value, 0xe0100, 0xe01ef)
)
}
export function terminalOutputPrefersDomRenderer(data: string): boolean {
if (EMOJI_PRESENTATION_PATTERN.test(data)) {
return true
}
for (let i = 0; i < data.length; i += 1) {
const codePoint = data.codePointAt(i)
if (codePoint === undefined) {
continue
}
if (isRendererRiskCodePoint(codePoint)) {
return true
}
if (codePoint > 0xffff) {
i += 1
}
}
return false
}