Fix duplicated/overprinted CJK glyphs in the terminal on Windows

On native Windows ConPTY, TUI agents (Codex, Antigravity) repaint blocks
in place. xterm parses the wide CJK/Korean cells correctly, but the local
Windows DOM renderer can leave the prior frame's wide glyphs painted on
top, so text shows duplicated/overprinted ("如" -> "如如") until the next
redraw. The existing East Asian repaint workaround only fired for recent
terminal *input* (Microsoft Pinyin commits); agent *output* was never
refreshed.

Extend the forced viewport refresh to native-ConPTY agent output carrying
East Asian double-width glyphs, via a new pure decision function
windowsEastAsianOutputPrefersRenderRefresh. The recent-input path (win32
client, incl. SSH) is preserved; remote/serve panes still skip the
per-chunk repaint. Bounded by the interactive-redraw chunk size.

Fixes #5921
This commit is contained in:
brennanb2025
2026-06-27 22:19:52 -07:00
parent bed50606f2
commit bcd8b4ba02
4 changed files with 180 additions and 12 deletions
@@ -7153,7 +7153,11 @@ describe('connectPanePty', () => {
}
})
it('does not force the Windows CJK repaint path without recent terminal input', async () => {
// Why: #5921 — Codex/Antigravity repaint CJK blocks in place and the local
// Windows DOM renderer overprints the prior wide glyphs ("如" → "如如"). Agent
// output is not recent input, so native-ConPTY CJK output must still force a
// viewport refresh to clear the stale wide cells.
it('forces the Windows CJK repaint path for native-ConPTY agent output without recent input', async () => {
const restoreNavigator = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
@@ -7184,6 +7188,48 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
expect(refresh).toHaveBeenCalledWith(0, 39, true)
} finally {
restoreNavigator()
}
})
it('does not force the Windows CJK repaint path for remote/serve agent output without recent input', async () => {
const restoreNavigator = temporarilySetNavigatorUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
)
try {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
}
)
transportFactoryQueue.push(transport)
// Why: a serve/remote-runtime pane is not a native local ConPTY, so its
// bytes render through a different layer; refreshing every CJK output
// chunk there is wasted work. Recent-input CJK still refreshes (separate
// win32-client path), but plain agent output must not.
enableActiveRuntimeEnvironment('env-1')
const pane = createPane(1)
const refresh = vi.fn()
const terminal = pane.terminal as typeof pane.terminal & {
_core?: { refresh: typeof refresh }
}
terminal._core = { refresh }
terminal.write = vi.fn((_data: string, callback?: () => void) => {
callback?.()
})
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
await flushAsyncTicks(6)
capturedDataCallback.current?.('已经安装完成,软件已更新后重启。')
expect(refresh).not.toHaveBeenCalled()
} finally {
restoreNavigator()
@@ -26,10 +26,10 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
import {
terminalOutputContainsEastAsianRendererRisk,
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
terminalRewriteOutputPrefersRenderRefresh,
windowsEastAsianOutputPrefersRenderRefresh
} from '@/lib/pane-manager/terminal-complex-script'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
@@ -2746,15 +2746,17 @@ export function connectPanePty(
return true
}
if (
shouldApplyWindowsRendererUnicodeRefresh &&
recentInput &&
data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS &&
terminalOutputContainsEastAsianRendererRisk(data)
windowsEastAsianOutputPrefersRenderRefresh(data, {
isWindowsClient: shouldApplyWindowsRendererUnicodeRefresh,
isNativeWindowsConpty: shouldApplyNativeWindowsRewriteRefresh,
hadRecentInput: recentInput,
maxInteractiveRedrawChars: FOREGROUND_INTERACTIVE_REDRAW_CHARS
})
) {
// Why: Microsoft Pinyin commits can surface as plain CJK foreground
// bytes; the prompt model is correct, but the local Windows renderer
// can leave individual glyph cells blank until repaint. Keep this
// scoped to recent East Asian text input, not all Unicode output.
// Why: CJK/Korean from Microsoft Pinyin commits and from TUI agents
// (Codex/Antigravity) repainting in place both leave stale wide-glyph
// cells in the local Windows DOM renderer; the buffer is correct, so a
// viewport repaint resolves the duplicated/overprinted glyphs (#5921).
return true
}
return (
@@ -3,7 +3,8 @@ import {
terminalOutputContainsEastAsianRendererRisk,
terminalOutputPrefersRenderRefresh,
terminalRewriteOutputRenderRefreshDecision,
terminalRewriteOutputPrefersRenderRefresh
terminalRewriteOutputPrefersRenderRefresh,
windowsEastAsianOutputPrefersRenderRefresh
} from './terminal-complex-script'
describe('terminalOutputPrefersRenderRefresh', () => {
@@ -243,3 +244,82 @@ describe('terminalRewriteOutputRenderRefreshDecision', () => {
})
})
})
describe('windowsEastAsianOutputPrefersRenderRefresh', () => {
const NATIVE_CONPTY = {
isWindowsClient: true,
isNativeWindowsConpty: true,
hadRecentInput: false,
maxInteractiveRedrawChars: 128 * 1024
}
// Why: regression for #5921 — Codex/Antigravity repaint CJK blocks in place and
// the local Windows DOM renderer overprints the prior wide glyphs ("如" → "如如"),
// so agent *output* (no recent input) must still force a viewport refresh.
it('refreshes native-ConPTY agent CJK output without recent input', () => {
expect(windowsEastAsianOutputPrefersRenderRefresh('如果还在主 checkout', NATIVE_CONPTY)).toBe(
true
)
})
it('refreshes native-ConPTY Korean output without recent input', () => {
expect(windowsEastAsianOutputPrefersRenderRefresh('한국어 출력', NATIVE_CONPTY)).toBe(true)
})
it('refreshes CJK output split from its cursor-position CSI in a prior chunk', () => {
// Why: the main-process batch window splits the CUP move from the glyph
// payload, so the CJK-only chunk carries no control bytes yet still repaints.
expect(windowsEastAsianOutputPrefersRenderRefresh('设计专用 worktree', NATIVE_CONPTY)).toBe(true)
})
it('still refreshes recent-input CJK on a non-ConPTY Windows client (Pinyin commit)', () => {
expect(
windowsEastAsianOutputPrefersRenderRefresh('请创建', {
isWindowsClient: true,
isNativeWindowsConpty: false,
hadRecentInput: true,
maxInteractiveRedrawChars: 128 * 1024
})
).toBe(true)
})
it('does not refresh East Asian output on non-Windows panes', () => {
expect(
windowsEastAsianOutputPrefersRenderRefresh('如果还在主', {
isWindowsClient: false,
isNativeWindowsConpty: false,
hadRecentInput: false,
maxInteractiveRedrawChars: 128 * 1024
})
).toBe(false)
})
it('does not refresh East Asian output on a remote/serve Windows client without recent input', () => {
// Why: SSH/serve panes (win32 client, not native ConPTY) render through a
// different layer; refreshing every CJK chunk there is wasted work.
expect(
windowsEastAsianOutputPrefersRenderRefresh('如果还在主', {
isWindowsClient: true,
isNativeWindowsConpty: false,
hadRecentInput: false,
maxInteractiveRedrawChars: 128 * 1024
})
).toBe(false)
})
it('does not refresh plain ASCII agent output', () => {
expect(windowsEastAsianOutputPrefersRenderRefresh('checkout worktree', NATIVE_CONPTY)).toBe(
false
)
})
it('skips bulk chunks larger than the interactive redraw bound', () => {
const bulk = '如'.repeat(200)
expect(
windowsEastAsianOutputPrefersRenderRefresh(bulk, {
...NATIVE_CONPTY,
maxInteractiveRedrawChars: 100
})
).toBe(false)
})
})
@@ -260,3 +260,43 @@ export function terminalOutputContainsEastAsianRendererRisk(data: string): boole
}
return false
}
export type WindowsEastAsianRefreshState = {
// Why: the local Windows DOM renderer is the only one that overprints wide
// glyphs; a win32 *client* alone gates the recent-input (IME commit) path,
// while native ConPTY gates the agent-output path (SSH/serve panes render
// through a different layer and must not pay the per-chunk repaint).
isWindowsClient: boolean
isNativeWindowsConpty: boolean
hadRecentInput: boolean
maxInteractiveRedrawChars: number
}
/**
* Whether a Windows foreground chunk needs a forced viewport refresh because it
* carries East Asian double-width glyphs that the local DOM renderer can paint
* over a stale prior frame.
*
* Why: TUI agents (Codex, Antigravity) repaint blocks in place. The wide-char
* cells xterm parses are correct, but the local Windows DOM renderer can leave
* the previous frame's wide glyphs painted on top, so CJK/Korean text shows
* duplicated/overprinted ("如" → "如如") until the next redraw. Recent-input CJK
* (Microsoft Pinyin commits) already forced a refresh; agent *output* is not
* recent input and its East Asian glyphs are equally affected, so refresh those
* native-ConPTY chunks too. Bounded by the interactive-redraw chunk size so a
* bulk paste/scrollback dump does not pay a per-chunk repaint cost.
*/
export function windowsEastAsianOutputPrefersRenderRefresh(
data: string,
state: WindowsEastAsianRefreshState
): boolean {
const recentInputRefresh = state.isWindowsClient && state.hadRecentInput
const agentOutputRefresh = state.isNativeWindowsConpty
if (!recentInputRefresh && !agentOutputRefresh) {
return false
}
if (data.length > state.maxInteractiveRedrawChars) {
return false
}
return terminalOutputContainsEastAsianRendererRisk(data)
}