mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user