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) } }