浏览代码

fix(cli): Windows cmd.exe display bug with escape sequences (#4697) (#4985)

* fix(cli): Windows cmd.exe display bug with escape sequences (#4697)

On Windows cmd.exe, the \x1b[3J (clear scrollback buffer) escape sequence
is not properly supported and causes display artifacts like raw escape
sequences appearing in the output.

Changes:
- Add isWindowsTerminal() to detect Windows platform
- Add getTerminalClearSequence() that returns Windows-safe sequence
- Add normalizeLineEndings() and normalizeLineEndingsForOutput() utilities
- Update useTerminal hook to use platform-aware clear sequence

Fixes #4697

* refactor: improve terminal capability detection based on PR feedback

- Rename isWindowsTerminal() to isWindows() for clarity
- Add supportsScrollbackClear() to detect modern terminals (Windows Terminal, VS Code)
- Modern terminals on Windows now get full ANSI support
- Only legacy cmd.exe gets the reduced escape sequence
- Update tests to cover all terminal detection scenarios

* Update windows-cmd-display-fix.md
marius-kilocode 1 月之前
父节点
当前提交
69a541a6d8

+ 7 - 0
.changeset/windows-cmd-display-fix.md

@@ -0,0 +1,7 @@
+---
+"@kilocode/cli": patch
+---
+
+Fix Windows cmd.exe display bug with escape sequences
+
+On Windows cmd.exe, the `\x1b[3J` (clear scrollback buffer) escape sequence is not properly supported and causes display artifacts like raw escape sequences appearing in the output (e.g., `[\r\n\t...]`). 

+ 9 - 8
cli/src/state/hooks/useTerminal.ts

@@ -1,6 +1,7 @@
 import { useAtomValue, useSetAtom } from "jotai"
 import { refreshTerminalCounterAtom, messageResetCounterAtom } from "../atoms/ui.js"
 import { useCallback, useEffect, useRef } from "react"
+import { getTerminalClearSequence } from "../../ui/utils/terminalCapabilities.js"
 
 export function useTerminal(): void {
 	const width = useRef(process.stdout.columns)
@@ -10,10 +11,10 @@ export function useTerminal(): void {
 
 	const clearTerminal = useCallback(() => {
 		// Clear the terminal screen and reset cursor position
-		// \x1b[2J - Clear entire screen
-		// \x1b[3J - Clear scrollback buffer (needed for gnome-terminal)
-		// \x1b[H - Move cursor to home position (0,0)
-		process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
+		// Uses getTerminalClearSequence() which returns:
+		// - Windows: \x1b[2J\x1b[H (without \x1b[3J which causes display artifacts)
+		// - Unix/Mac: \x1b[2J\x1b[3J\x1b[H (full clear including scrollback)
+		process.stdout.write(getTerminalClearSequence())
 		// Increment the message reset counter to force re-render of Static component
 		incrementResetCounter((prev) => prev + 1)
 	}, [incrementResetCounter])
@@ -37,10 +38,10 @@ export function useTerminal(): void {
 			width.current = process.stdout.columns
 
 			// Clear the terminal screen and reset cursor position
-			// \x1b[2J - Clear entire screen
-			// \x1b[3J - Clear scrollback buffer (needed for gnome-terminal)
-			// \x1b[H - Move cursor to home position (0,0)
-			process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
+			// Uses getTerminalClearSequence() which returns:
+			// - Windows: \x1b[2J\x1b[H (without \x1b[3J which causes display artifacts)
+			// - Unix/Mac: \x1b[2J\x1b[3J\x1b[H (full clear including scrollback)
+			process.stdout.write(getTerminalClearSequence())
 
 			// Increment reset counter to force Static component remount
 			incrementResetCounter((prev) => prev + 1)

+ 327 - 0
cli/src/ui/utils/__tests__/terminalCapabilities.test.ts

@@ -0,0 +1,327 @@
+/**
+ * Tests for terminal capability detection utilities
+ * Including Windows-specific terminal handling
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+
+// Store original values
+const originalPlatform = process.platform
+const originalEnv = { ...process.env }
+
+/**
+ * Helper to mock process.platform
+ */
+function mockPlatform(platform: NodeJS.Platform) {
+	Object.defineProperty(process, "platform", {
+		value: platform,
+		writable: true,
+		configurable: true,
+	})
+}
+
+/**
+ * Helper to restore original platform
+ */
+function restorePlatform() {
+	Object.defineProperty(process, "platform", {
+		value: originalPlatform,
+		writable: true,
+		configurable: true,
+	})
+}
+
+/**
+ * Helper to mock environment variables
+ */
+function mockEnv(env: Record<string, string | undefined>) {
+	for (const [key, value] of Object.entries(env)) {
+		if (value === undefined) {
+			delete process.env[key]
+		} else {
+			process.env[key] = value
+		}
+	}
+}
+
+/**
+ * Helper to restore original environment
+ */
+function restoreEnv() {
+	// Clear all env vars that weren't in original
+	for (const key of Object.keys(process.env)) {
+		if (!(key in originalEnv)) {
+			delete process.env[key]
+		}
+	}
+	// Restore original values
+	for (const [key, value] of Object.entries(originalEnv)) {
+		process.env[key] = value
+	}
+}
+
+describe("terminalCapabilities", () => {
+	let writtenData: string[] = []
+	let originalWrite: typeof process.stdout.write
+
+	beforeEach(() => {
+		writtenData = []
+		originalWrite = process.stdout.write
+		vi.spyOn(process.stdout, "write").mockImplementation((data: string | Uint8Array) => {
+			writtenData.push(data.toString())
+			return true
+		})
+	})
+
+	afterEach(() => {
+		vi.restoreAllMocks()
+		restorePlatform()
+		restoreEnv()
+		process.stdout.write = originalWrite
+	})
+
+	describe("Windows platform detection", () => {
+		it("should detect Windows platform correctly", () => {
+			mockPlatform("win32")
+			expect(process.platform).toBe("win32")
+		})
+
+		it("should detect non-Windows platform correctly", () => {
+			mockPlatform("darwin")
+			expect(process.platform).toBe("darwin")
+
+			mockPlatform("linux")
+			expect(process.platform).toBe("linux")
+		})
+	})
+
+	describe("isWindows", () => {
+		it("should return true on Windows platform", async () => {
+			mockPlatform("win32")
+			vi.resetModules()
+			const { isWindows } = await import("../terminalCapabilities.js")
+			expect(isWindows()).toBe(true)
+		})
+
+		it("should return false on non-Windows platforms", async () => {
+			mockPlatform("darwin")
+			vi.resetModules()
+			const { isWindows } = await import("../terminalCapabilities.js")
+			expect(isWindows()).toBe(false)
+		})
+	})
+
+	describe("supportsScrollbackClear", () => {
+		it("should return true when WT_SESSION is set (Windows Terminal)", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: "some-session-id" })
+			vi.resetModules()
+			const { supportsScrollbackClear } = await import("../terminalCapabilities.js")
+			expect(supportsScrollbackClear()).toBe(true)
+		})
+
+		it("should return true when TERM_PROGRAM is vscode", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: "vscode" })
+			vi.resetModules()
+			const { supportsScrollbackClear } = await import("../terminalCapabilities.js")
+			expect(supportsScrollbackClear()).toBe(true)
+		})
+
+		it("should return false on Windows without modern terminal indicators", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { supportsScrollbackClear } = await import("../terminalCapabilities.js")
+			expect(supportsScrollbackClear()).toBe(false)
+		})
+
+		it("should return true on non-Windows platforms", async () => {
+			mockPlatform("darwin")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { supportsScrollbackClear } = await import("../terminalCapabilities.js")
+			expect(supportsScrollbackClear()).toBe(true)
+		})
+	})
+
+	describe("getTerminalClearSequence", () => {
+		it("should return Windows-compatible clear sequence on legacy Windows (cmd.exe)", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { getTerminalClearSequence } = await import("../terminalCapabilities.js")
+			const clearSeq = getTerminalClearSequence()
+
+			// Legacy Windows should NOT use \x1b[3J (clear scrollback) as it causes display issues
+			expect(clearSeq).not.toContain("\x1b[3J")
+			// Should still clear screen and move cursor home
+			expect(clearSeq).toContain("\x1b[2J")
+			expect(clearSeq).toContain("\x1b[H")
+		})
+
+		it("should return full ANSI clear sequence on Windows Terminal", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: "some-session-id" })
+			vi.resetModules()
+			const { getTerminalClearSequence } = await import("../terminalCapabilities.js")
+			const clearSeq = getTerminalClearSequence()
+
+			// Windows Terminal supports full clear sequence
+			expect(clearSeq).toContain("\x1b[2J")
+			expect(clearSeq).toContain("\x1b[3J")
+			expect(clearSeq).toContain("\x1b[H")
+		})
+
+		it("should return full ANSI clear sequence on VS Code terminal", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: "vscode" })
+			vi.resetModules()
+			const { getTerminalClearSequence } = await import("../terminalCapabilities.js")
+			const clearSeq = getTerminalClearSequence()
+
+			// VS Code terminal supports full clear sequence
+			expect(clearSeq).toContain("\x1b[2J")
+			expect(clearSeq).toContain("\x1b[3J")
+			expect(clearSeq).toContain("\x1b[H")
+		})
+
+		it("should return full ANSI clear sequence on non-Windows platforms", async () => {
+			mockPlatform("darwin")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { getTerminalClearSequence } = await import("../terminalCapabilities.js")
+			const clearSeq = getTerminalClearSequence()
+
+			// Non-Windows should use full clear sequence including scrollback
+			expect(clearSeq).toContain("\x1b[2J")
+			expect(clearSeq).toContain("\x1b[3J")
+			expect(clearSeq).toContain("\x1b[H")
+		})
+	})
+
+	describe("normalizeLineEndings", () => {
+		it("should convert CRLF to LF", async () => {
+			vi.resetModules()
+			const { normalizeLineEndings } = await import("../terminalCapabilities.js")
+
+			const input = "line1\r\nline2\r\nline3"
+			const result = normalizeLineEndings(input)
+			expect(result).toBe("line1\nline2\nline3")
+		})
+
+		it("should convert standalone CR to LF", async () => {
+			vi.resetModules()
+			const { normalizeLineEndings } = await import("../terminalCapabilities.js")
+
+			const input = "line1\rline2\rline3"
+			const result = normalizeLineEndings(input)
+			expect(result).toBe("line1\nline2\nline3")
+		})
+	})
+
+	describe("normalizeLineEndingsForOutput", () => {
+		it("should convert LF to CRLF on legacy Windows", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { normalizeLineEndingsForOutput } = await import("../terminalCapabilities.js")
+
+			const input = "line1\nline2\nline3"
+			const result = normalizeLineEndingsForOutput(input)
+			expect(result).toBe("line1\r\nline2\r\nline3")
+		})
+
+		it("should not convert on Windows Terminal", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: "some-session-id" })
+			vi.resetModules()
+			const { normalizeLineEndingsForOutput } = await import("../terminalCapabilities.js")
+
+			const input = "line1\nline2\nline3"
+			const result = normalizeLineEndingsForOutput(input)
+			expect(result).toBe("line1\nline2\nline3")
+		})
+
+		it("should not convert on VS Code terminal", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: "vscode" })
+			vi.resetModules()
+			const { normalizeLineEndingsForOutput } = await import("../terminalCapabilities.js")
+
+			const input = "line1\nline2\nline3"
+			const result = normalizeLineEndingsForOutput(input)
+			expect(result).toBe("line1\nline2\nline3")
+		})
+
+		it("should not double-convert already CRLF strings on legacy Windows", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { normalizeLineEndingsForOutput } = await import("../terminalCapabilities.js")
+
+			const input = "line1\r\nline2\r\nline3"
+			const result = normalizeLineEndingsForOutput(input)
+			// Should not become \r\r\n
+			expect(result).toBe("line1\r\nline2\r\nline3")
+			expect(result).not.toContain("\r\r\n")
+		})
+	})
+
+	describe("Windows cmd.exe display bug regression", () => {
+		/**
+		 * This test verifies the fix for GitHub issue #4697
+		 * Windows cmd mode display bug where GUI refreshes fast at the end
+		 * with [\r\n\t...] appearing incorrectly
+		 */
+		it("should not output raw escape sequences that cause display artifacts on legacy Windows", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { getTerminalClearSequence, normalizeLineEndingsForOutput } = await import(
+				"../terminalCapabilities.js"
+			)
+
+			// Simulate the problematic scenario: long response with mixed line endings
+			const longResponse = "This is a long response\nwith multiple lines\nand various content\n".repeat(50)
+
+			// Get the clear sequence
+			const clearSeq = getTerminalClearSequence()
+
+			// The clear sequence should not contain problematic sequences for legacy Windows
+			// \x1b[3J causes scrollback buffer issues in cmd.exe
+			expect(clearSeq).not.toContain("\x1b[3J")
+
+			// Normalize the output for Windows
+			const normalizedOutput = normalizeLineEndingsForOutput(longResponse)
+
+			// On legacy Windows, line endings should be CRLF
+			expect(normalizedOutput).toContain("\r\n")
+			// Should not have bare LF (which causes display issues in cmd.exe)
+			const bareLineFeeds = normalizedOutput.match(/(?<!\r)\n/g)
+			expect(bareLineFeeds).toBeNull()
+		})
+
+		it("should handle rapid updates without display artifacts", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { getTerminalClearSequence } = await import("../terminalCapabilities.js")
+
+			// Simulate rapid updates (like streaming)
+			const updates: string[] = []
+			for (let i = 0; i < 100; i++) {
+				const clearSeq = getTerminalClearSequence()
+				updates.push(clearSeq)
+			}
+
+			// All clear sequences should be consistent
+			const uniqueSequences = new Set(updates)
+			expect(uniqueSequences.size).toBe(1)
+
+			// The sequence should be Windows-safe for legacy terminals
+			const clearSeq = updates[0]
+			expect(clearSeq).not.toContain("\x1b[3J")
+		})
+	})
+})

+ 83 - 0
cli/src/ui/utils/terminalCapabilities.ts

@@ -1,8 +1,91 @@
 /**
  * Terminal capability detection utilities
  * Detects support for Kitty keyboard protocol and other advanced features
+ * Also handles Windows terminal compatibility for proper display rendering
  */
 
+/**
+ * Check if running on Windows platform
+ */
+export function isWindows(): boolean {
+	return process.platform === "win32"
+}
+
+/**
+ * Check if the terminal supports the scrollback clear escape sequence (\x1b[3J)
+ *
+ * Modern terminals like Windows Terminal and VS Code's integrated terminal
+ * support this sequence, but legacy cmd.exe does not.
+ *
+ * Detection is based on environment variables:
+ * - WT_SESSION: Set by Windows Terminal
+ * - TERM_PROGRAM === 'vscode': Set by VS Code's integrated terminal
+ * - Non-Windows platforms: Generally support it
+ */
+export function supportsScrollbackClear(): boolean {
+	// Windows Terminal sets WT_SESSION env var
+	if (process.env.WT_SESSION) {
+		return true
+	}
+	// VS Code integrated terminal
+	if (process.env.TERM_PROGRAM === "vscode") {
+		return true
+	}
+	// Default: Unix/Mac support it, Windows cmd.exe doesn't
+	return !isWindows()
+}
+
+/**
+ * Get the appropriate terminal clear sequence for the current terminal
+ *
+ * On Windows cmd.exe, the \x1b[3J (clear scrollback buffer) escape sequence
+ * is not properly supported and can cause display artifacts like raw escape
+ * sequences appearing in the output (e.g., [\r\n\t...]).
+ *
+ * Modern terminals (Windows Terminal, VS Code) support the full sequence.
+ *
+ * This function returns a terminal-appropriate clear sequence:
+ * - Legacy Windows (cmd.exe): \x1b[2J\x1b[H (clear screen + cursor home)
+ * - Modern terminals: \x1b[2J\x1b[3J\x1b[H (clear screen + clear scrollback + cursor home)
+ */
+export function getTerminalClearSequence(): string {
+	if (!supportsScrollbackClear()) {
+		// Legacy Windows cmd.exe doesn't properly support \x1b[3J (clear scrollback)
+		// Using only clear screen and cursor home to avoid display artifacts
+		return "\x1b[2J\x1b[H"
+	}
+	// Full clear sequence for modern terminals
+	return "\x1b[2J\x1b[3J\x1b[H"
+}
+
+/**
+ * Normalize line endings for internal processing
+ * Converts all line endings to LF (\n) for consistent internal handling
+ */
+export function normalizeLineEndings(text: string): string {
+	// Convert CRLF to LF, then any remaining CR to LF
+	return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+/**
+ * Normalize line endings for terminal output
+ * On Windows (without modern terminal), converts LF to CRLF for proper display in cmd.exe
+ * On modern terminals, returns the text unchanged
+ *
+ * This prevents display artifacts where bare LF characters cause
+ * improper line rendering in legacy Windows terminals.
+ */
+export function normalizeLineEndingsForOutput(text: string): string {
+	// Only convert for legacy Windows terminals (not Windows Terminal or VS Code)
+	if (isWindows() && !process.env.WT_SESSION && process.env.TERM_PROGRAM !== "vscode") {
+		// First normalize to LF, then convert to CRLF for Windows
+		// This prevents double-conversion of already CRLF strings
+		const normalized = normalizeLineEndings(text)
+		return normalized.replace(/\n/g, "\r\n")
+	}
+	return text
+}
+
 /**
  * Check if terminal supports Kitty protocol
  * Partially copied from gemini-cli