From be3f30e2f8df2bb59bb8d7c907ed21cd2fc997f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:50:30 -0700 Subject: [PATCH] Fix Windows CJK terminal repaint and ConPTY wrap markers Fix native Windows CJK terminal repaint and ConPTY wrap metadata handling.\n\nNormalize filesystem realpath results before authorization so WSL UNC roots compare consistently in CI. --- src/main/daemon/pty-subprocess.test.ts | 27 +++++++ src/main/daemon/pty-subprocess.ts | 5 +- src/main/ipc/filesystem-auth.ts | 4 +- .../terminal-pane/pty-connection.test.ts | 4 +- .../terminal-pane/pty-connection.ts | 20 ++--- .../terminal-complex-script.test.ts | 81 +++++++++++++++++++ .../pane-manager/terminal-complex-script.ts | 28 +++++++ .../windows-pty-compatibility.test.ts | 39 +++++++++ .../pane-manager/windows-pty-compatibility.ts | 16 +++- 9 files changed, 206 insertions(+), 18 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 165426a6f75..3d50ce30ff8 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -176,6 +176,33 @@ describe('createPtySubprocess', () => { ) }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: 'C:\\repo', + env: { COMSPEC: 'C:\\Windows\\System32\\cmd.exe' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ useConptyDll: true }) + ) + }) + it('suppresses the first-run Powerlevel10k wizard for daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index a1de86b8762..1b8e72681a5 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -483,7 +483,10 @@ function spawnDaemonPtyWithWindowsFallback(args: { cols: args.cols, rows: args.rows, cwd, - env: args.env + env: args.env, + // Why: bundled ConPTY has the modern wrap-marker behavior xterm expects; + // legacy system ConPTY can corrupt full-width TUI rows in scrollback. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) }) try { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 6d7cca99442..f931ebe119b 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -328,7 +328,9 @@ export async function resolveAuthorizedPath( } try { - const realTarget = await realpath(resolvedTarget) + // Why: Windows/WSL realpath can return UNC-shaped paths that still need to + // compare against the resolved allow-list roots used by this module. + const realTarget = resolve(await realpath(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, { canonicalSourcePath: resolvedTarget 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 da12d557468..66ff5f50525 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7920,7 +7920,7 @@ describe('connectPanePty', () => { } }) - it('does not force the Windows CJK repaint path without recent terminal input', async () => { + it('forces the native Windows CJK repaint path for foreground agent output without recent terminal input', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -7951,7 +7951,7 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('已经安装完成,软件已更新后重启。') - expect(refresh).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledWith(0, 39, true) } 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 6e9ceead2a2..6c17c34560f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -31,10 +31,10 @@ import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' import { nativeWindowsRewriteNeedsFollowupRenderRefresh, - terminalOutputContainsEastAsianRendererRisk, terminalOutputPrefersRenderRefresh, terminalRewriteOutputRenderRefreshDecision, - terminalRewriteOutputPrefersRenderRefresh + terminalRewriteOutputPrefersRenderRefresh, + windowsEastAsianOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, @@ -3088,15 +3088,15 @@ export function connectPanePty( return { refresh: true, inPlaceRewrite: 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 native ConPTY agent + // output can leave stale wide-glyph cells in the local Windows DOM renderer. return { refresh: true, inPlaceRewrite: false } } 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 da4bef2ddd9..44c1a015557 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 @@ -5,6 +5,7 @@ import { terminalOutputPrefersRenderRefresh, terminalRewriteOutputRenderRefreshDecision, terminalRewriteOutputPrefersRenderRefresh, + windowsEastAsianOutputPrefersRenderRefresh, type TerminalRewriteOutputRenderRefreshState } from './terminal-complex-script' @@ -93,6 +94,86 @@ describe('terminalOutputContainsEastAsianRendererRisk', () => { }) }) +describe('windowsEastAsianOutputPrefersRenderRefresh', () => { + const maxInteractiveRedrawChars = 128 * 1024 + + it('refreshes native Windows ConPTY agent output with CJK or Korean glyphs', () => { + expect( + windowsEastAsianOutputPrefersRenderRefresh('已经安装完成,软件已更新后重启。', { + isWindowsClient: true, + isNativeWindowsConpty: true, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(true) + expect( + windowsEastAsianOutputPrefersRenderRefresh('Korean: 터미널', { + isWindowsClient: true, + isNativeWindowsConpty: true, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(true) + }) + + it('keeps the recent-input Windows renderer path for SSH and other non-native panes', () => { + expect( + windowsEastAsianOutputPrefersRenderRefresh('已经安装完成,软件已更新后重启。', { + isWindowsClient: true, + isNativeWindowsConpty: false, + hadRecentInput: true, + maxInteractiveRedrawChars + }) + ).toBe(true) + }) + + it('skips remote agent output and non-Windows clients without recent input', () => { + expect( + windowsEastAsianOutputPrefersRenderRefresh('已经安装完成,软件已更新后重启。', { + isWindowsClient: true, + isNativeWindowsConpty: false, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(false) + expect( + windowsEastAsianOutputPrefersRenderRefresh('已经安装完成,软件已更新后重启。', { + isWindowsClient: false, + isNativeWindowsConpty: false, + hadRecentInput: true, + maxInteractiveRedrawChars + }) + ).toBe(false) + }) + + it('does not refresh ASCII, non-East-Asian Unicode, or bulk chunks', () => { + expect( + windowsEastAsianOutputPrefersRenderRefresh('plain terminal output', { + isWindowsClient: true, + isNativeWindowsConpty: true, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(false) + expect( + windowsEastAsianOutputPrefersRenderRefresh('Arabic: السلام عليكم', { + isWindowsClient: true, + isNativeWindowsConpty: true, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(false) + expect( + windowsEastAsianOutputPrefersRenderRefresh('已'.repeat(maxInteractiveRedrawChars + 1), { + isWindowsClient: true, + isNativeWindowsConpty: true, + hadRecentInput: false, + maxInteractiveRedrawChars + }) + ).toBe(false) + }) +}) + describe('terminalRewriteOutputPrefersRenderRefresh', () => { it('detects in-place carriage-return redraws', () => { expect(terminalRewriteOutputPrefersRenderRefresh('\r• Working')).toBe(true) 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 76146099b73..a5556c7a6dc 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -281,3 +281,31 @@ export function terminalOutputContainsEastAsianRendererRisk(data: string): boole } return false } + +export type WindowsEastAsianRefreshState = { + // Why: recent IME commits are a Windows-client renderer issue, while agent + // output repainting is only forced for native ConPTY to avoid remote costs. + isWindowsClient: boolean + isNativeWindowsConpty: boolean + hadRecentInput: boolean + maxInteractiveRedrawChars: number +} + +/** + * Whether a Windows foreground chunk needs a viewport refresh because it carries + * East Asian double-width glyphs the local DOM renderer can paint over stale cells. + */ +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) +} diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index 249e5dda8b6..8e32f7d7246 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -1,3 +1,4 @@ +import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' import { buildWindowsPtyCompatibilityOptions, @@ -5,6 +6,10 @@ import { isLocalNativeWindowsPty } from './windows-pty-compatibility' +function writeTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + describe('buildWindowsPtyCompatibilityOptions', () => { it('returns ConPTY compatibility options for local Windows terminals', () => { expect( @@ -36,6 +41,40 @@ describe('buildWindowsPtyCompatibilityOptions', () => { }) }) + it('omits old Windows build numbers that enable xterm legacy wrap heuristics', () => { + expect( + buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.19045', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: null, + executionHostId: 'local' + }) + ).toEqual({ + windowsPty: { backend: 'conpty' } + }) + }) + + it('does not mark the row after a full-width Windows status line as wrapped', async () => { + const options = buildWindowsPtyCompatibilityOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.19045', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: null, + executionHostId: 'local' + }) + const terminal = new Terminal({ cols: 20, rows: 5, ...options }) + + await writeTerminal(terminal, `${'─'.repeat(20)}\r\nNEXT\r\n`) + + // Why: the Chinese report said scrollback stopped working; with the legacy + // xterm Windows wrap heuristic, a full-width row falsely wraps the next row. + expect(terminal.buffer.active.getLine(1)?.translateToString(true)).toBe('NEXT') + expect(terminal.buffer.active.getLine(1)?.isWrapped).toBe(false) + }) + it('skips compatibility options for SSH-backed Windows terminals', () => { expect( buildWindowsPtyCompatibilityOptions({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 5886dd84307..0f35968c21d 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -31,6 +31,17 @@ function parseWindowsBuildNumber(osRelease: string | null | undefined): number | return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined } +function buildXtermWindowsPtyOptions( + buildNumber: number | undefined +): NonNullable { + // Why: old system ConPTY does not provide reliable wrap markers; passing the + // low build number makes xterm mark full-width status rows as wrapped. + if (buildNumber === undefined || buildNumber < 21376) { + return { backend: 'conpty' } + } + return { backend: 'conpty', buildNumber } +} + /** * xterm options that select the native-Windows ConPTY backend, returned only for * a genuine local Windows pane and `{}` otherwise. @@ -47,10 +58,7 @@ export function buildWindowsPtyCompatibilityOptions( } const buildNumber = parseWindowsBuildNumber(context.osRelease) return { - // Why: native Windows shells are backed by ConPTY, and xterm's dedicated - // compatibility heuristics need the OS build to choose the right wrap path. - windowsPty: - buildNumber === undefined ? { backend: 'conpty' } : { backend: 'conpty', buildNumber } + windowsPty: buildXtermWindowsPtyOptions(buildNumber) } }