From cfa98d4589ce018bb36c2f7489fd76f71fa1de33 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 14 May 2026 11:01:44 -0700 Subject: [PATCH] Fix terminal glyph renderer fallback (#1850) * fix terminal glyph renderer fallback * remove terminal glyph design doc from pr --- .../terminal-pane/pty-connection.ts | 6 +-- .../terminal-complex-script.test.ts | 40 ++++++++++++--- .../pane-manager/terminal-complex-script.ts | 49 ++++++++++++++++--- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index cb52625f6c3..821888a3ac3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts index 0c27e91ba65..20d131fbe7e 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -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) }) }) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts index 6d7095ca115..23f226319f5 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -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 }