diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ac1c52a4174..66ab4f9d579 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e6930cfbd9d..e5dfa8da438 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 ( 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 c5da3cf4289..9bc70cc1877 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 @@ -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) + }) +}) 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 e5044f5ea3c..c1128833c37 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -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) +}