mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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({
|
||||
|
||||
@@ -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<ITerminalOptions['windowsPty']> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user