Repository navigation
Console mode corruption in long-running TUI sessions causes mouse selection and copy/paste to stop working #19674
Description
Activity
- addedNeeds-TriageIt's a new issue that the core contributor team needs to triage at the next triage meetingIt's a new issue that the core contributor team needs to triage at the next triage meetingNeeds-Tag-FixDoesn't match tag requirementsDoesn't match tag requirements
on Dec 23, 2025 🚨 Critical Update: Bug reproduces in 1.6 seconds (100% reproducible)
I've created a stress test script that reproduces this bug instantly and reliably.
Reproduction Results
Time to corruption: 1.6 seconds (not 30 minutes!)
Reproducibility: 100% - triggers every single run
Trigger point: Always at iteration 101Console Mode Changes Detected
Initial State (after SetConsoleMode): ENABLE_MOUSE_INPUT: YES ENABLE_EXTENDED_FLAGS: YES ENABLE_VIRTUAL_TERMINAL_INPUT: YES ENABLE_QUICK_EDIT_MODE: NO After 1.6 seconds: ENABLE_MOUSE_INPUT: NO ❌ ENABLE_EXTENDED_FLAGS: NO ❌ ENABLE_VIRTUAL_TERMINAL_INPUT: YES ENABLE_QUICK_EDIT_MODE: NO Changed bits: 0x01bf (ENABLE_MOUSE_INPUT + ENABLE_EXTENDED_FLAGS)What Triggers The Bug
The corruption happens when combining:
- Mouse tracking enabled via ANSI escape sequences (
\x1b[?1000h,\x1b[?1002h,\x1b[?1003h) - Console mode set via SetConsoleMode (enabling ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT)
- Rapid ANSI/VT output with colored text
- stdin in raw mode (process.stdin.setRawMode(true))
This combination causes ConPTY/Windows Console to immediately corrupt the console mode flags we just set.
Test Environment
- OS: Windows 10/11
- Terminal: Windows Terminal (latest stable)
- Runtime: Bun (Node.js compatible)
- Test script: Uses Bun FFI to call kernel32.dll GetConsoleMode/SetConsoleMode directly
Stress Test Script
The complete reproducible test case is available as a gist: https://gist.github.com (I'll create and link shortly)
Key parts of the test:
- Calls
SetConsoleModeto enable ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT - Enables mouse tracking via ANSI sequences
- Rapidly outputs colored ANSI text
- Queries console modes every 100 iterations
- Detects when ENABLE_MOUSE_INPUT flag disappears
How to Run
- Install Bun:
irm bun.sh/install.ps1 | iex - Save script as
console-mode-stress-test.ts - Run:
bun run console-mode-stress-test.ts - Bug triggers in ~1.6 seconds with output showing exact console mode changes
Impact
This demonstrates that the bug is far more severe than initially reported:
- Not a gradual degradation over 30 minutes
- Instant corruption when TUI conditions are met
- Affects any TUI application using mouse tracking + VT output on Windows Terminal
- 100% reproducible test case
The workaround of periodically calling
SetConsoleModeto refresh the flags is now clearly necessary, as the console modes are being actively corrupted by Windows Terminal/ConPTY almost immediately.Full test script available in the next comment due to length limits.
- Mouse tracking enabled via ANSI escape sequences (
Full Stress Test Script
Here's the complete test that reproduces the bug in 1.6 seconds:
#!/usr/bin/env bun /** * Windows Console Mode Stress Test * * This script attempts to reproduce the Windows console mode corruption issue * by stressing the console system with heavy mouse input, text output, and * console mode queries. * * Run with: bun run packages/opencode/test/console-mode-stress-test.ts */ import { dlopen, FFIType, suffix, type Pointer } from "bun:ffi" import { platform } from "os" // Windows console mode flags const ENABLE_PROCESSED_INPUT = 0x0001 const ENABLE_MOUSE_INPUT = 0x0010 const ENABLE_EXTENDED_FLAGS = 0x0080 const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200 const ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 const ENABLE_QUICK_EDIT_MODE = 0x0040 const STD_INPUT_HANDLE = -10 const STD_OUTPUT_HANDLE = -11 interface ConsoleState { stdinMode: number | null stdoutMode: number | null timestamp: number } class ConsoleModeTester { private kernel32: any = null private stdinHandle: Pointer | null = null private stdoutHandle: Pointer | null = null private initialStdinMode: number | null = null private initialStdoutMode: number | null = null private history: ConsoleState[] = [] private running = false constructor() { if (platform() !== "win32") { throw new Error("This test only works on Windows") } this.initializeKernel32() this.saveInitialModes() } private initializeKernel32() { this.kernel32 = dlopen(`kernel32.${suffix}`, { GetStdHandle: { args: [FFIType.i32], returns: FFIType.pointer, }, GetConsoleMode: { args: [FFIType.pointer, FFIType.pointer], returns: FFIType.i32, }, SetConsoleMode: { args: [FFIType.pointer, FFIType.u32], returns: FFIType.i32, }, }) this.stdinHandle = this.kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE) this.stdoutHandle = this.kernel32.symbols.GetStdHandle(STD_OUTPUT_HANDLE) } private getConsoleModes(): [number | null, number | null] { const modeBuffer = new Uint32Array(1) const modePtr = new DataView(modeBuffer.buffer) let stdinMode: number | null = null let stdoutMode: number | null = null if (this.stdinHandle && this.kernel32.symbols.GetConsoleMode(this.stdinHandle, modeBuffer)) { stdinMode = modePtr.getUint32(0, true) } if (this.stdoutHandle && this.kernel32.symbols.GetConsoleMode(this.stdoutHandle, modeBuffer)) { stdoutMode = modePtr.getUint32(0, true) } return [stdinMode, stdoutMode] } private saveInitialModes() { const [stdinMode, stdoutMode] = this.getConsoleModes() this.initialStdinMode = stdinMode this.initialStdoutMode = stdoutMode console.log("\n=== Initial Console Modes ===") console.log(`stdin: 0x${stdinMode?.toString(16).padStart(4, "0")} (${stdinMode})`) console.log(`stdout: 0x${stdoutMode?.toString(16).padStart(4, "0")} (${stdoutMode})`) this.logModeFlags(stdinMode, stdoutMode) } private logModeFlags(stdinMode: number | null, stdoutMode: number | null) { if (stdinMode !== null) { console.log("\nstdin flags:") console.log(` ENABLE_MOUSE_INPUT: ${(stdinMode & ENABLE_MOUSE_INPUT) ? "YES" : "NO"}`) console.log(` ENABLE_EXTENDED_FLAGS: ${(stdinMode & ENABLE_EXTENDED_FLAGS) ? "YES" : "NO"}`) console.log(` ENABLE_VIRTUAL_TERMINAL_INPUT: ${(stdinMode & ENABLE_VIRTUAL_TERMINAL_INPUT) ? "YES" : "NO"}`) console.log(` ENABLE_QUICK_EDIT_MODE: ${(stdinMode & ENABLE_QUICK_EDIT_MODE) ? "YES" : "NO"}`) } if (stdoutMode !== null) { console.log("\nstdout flags:") console.log(` ENABLE_VIRTUAL_TERMINAL_PROCESSING: ${(stdoutMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) ? "YES" : "NO"}`) } } private setupTUIMode() { console.log("\n=== Setting up TUI mode (enabling mouse input) ===") if (!this.stdinHandle || !this.stdoutHandle) return const modeBuffer = new Uint32Array(1) const modePtr = new DataView(modeBuffer.buffer) // Enable VT processing on stdout if (this.kernel32.symbols.GetConsoleMode(this.stdoutHandle, modeBuffer)) { const currentMode = modePtr.getUint32(0, true) const newMode = currentMode | ENABLE_VIRTUAL_TERMINAL_PROCESSING this.kernel32.symbols.SetConsoleMode(this.stdoutHandle, newMode) } // Enable mouse input on stdin if (this.kernel32.symbols.GetConsoleMode(this.stdinHandle, modeBuffer)) { const currentMode = modePtr.getUint32(0, true) let newMode = currentMode | ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT // Disable quick edit mode for mouse support newMode &= ~ENABLE_QUICK_EDIT_MODE this.kernel32.symbols.SetConsoleMode(this.stdinHandle, newMode) } const [stdinMode, stdoutMode] = this.getConsoleModes() console.log(`stdin: 0x${stdinMode?.toString(16).padStart(4, "0")} (${stdinMode})`) console.log(`stdout: 0x${stdoutMode?.toString(16).padStart(4, "0")} (${stdoutMode})`) } private recordState() { const [stdinMode, stdoutMode] = this.getConsoleModes() this.history.push({ stdinMode, stdoutMode, timestamp: Date.now(), }) } private detectCorruption(): boolean { if (this.history.length < 2) return false const current = this.history[this.history.length - 1] const initial = this.history[0] // Check if critical flags have changed if (current.stdinMode !== null && initial.stdinMode !== null) { const mouseInputLost = (initial.stdinMode & ENABLE_MOUSE_INPUT) && !(current.stdinMode & ENABLE_MOUSE_INPUT) const vtInputLost = (initial.stdinMode & ENABLE_VIRTUAL_TERMINAL_INPUT) && !(current.stdinMode & ENABLE_VIRTUAL_TERMINAL_INPUT) const quickEditEnabled = !(initial.stdinMode & ENABLE_QUICK_EDIT_MODE) && (current.stdinMode & ENABLE_QUICK_EDIT_MODE) if (mouseInputLost || vtInputLost || quickEditEnabled) { return true } } if (current.stdoutMode !== null && initial.stdoutMode !== null) { const vtProcessingLost = (initial.stdoutMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) && !(current.stdoutMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) if (vtProcessingLost) { return true } } return false } private outputStressPattern() { // Rapidly output text with ANSI codes to stress the console const colors = [31, 32, 33, 34, 35, 36] const randomColor = colors[Math.floor(Math.random() * colors.length)] process.stdout.write(`\x1b[${randomColor}m█\x1b[0m`) } private setupMouseEventLogging() { let lastMouseEvent = Date.now() let mouseEventCount = 0 let buffer = "" process.stdin.setRawMode(true) process.stdin.resume() process.stdin.setEncoding("utf8") process.stdin.on("data", (data: string) => { buffer += data // Parse mouse events (CSI < sequence) const mouseRegex = /\x1b\[<(\d+);(\d+);(\d+)([mM])/g let match while ((match = mouseRegex.exec(buffer)) !== null) { const [_, button, x, y, type] = match const eventType = type === "M" ? "DOWN" : "UP" const buttonName = button === "0" ? "LEFT" : button === "1" ? "MIDDLE" : button === "2" ? "RIGHT" : "MOVE" mouseEventCount++ lastMouseEvent = Date.now() // Log mouse event process.stdout.write(`\n🖱️ MOUSE ${eventType} - ${buttonName} at (${x},${y}) - Event #${mouseEventCount}\n`) // Remove matched sequence from buffer buffer = buffer.substring(match.index + match[0].length) } // Also check for regular mouse tracking sequences (simpler format) const simpleMouseRegex = /\x1b\[M(.{3})/g while ((match = simpleMouseRegex.exec(buffer)) !== null) { mouseEventCount++ lastMouseEvent = Date.now() process.stdout.write(`\n🖱️ MOUSE EVENT detected - Event #${mouseEventCount}\n`) buffer = buffer.substring(match.index + match[0].length) } // Clear old buffer data if (buffer.length > 100) { buffer = buffer.substring(buffer.length - 50) } }) // Monitor for lack of mouse events (indicates corruption) setInterval(() => { const timeSinceLastEvent = Date.now() - lastMouseEvent if (timeSinceLastEvent > 10000 && mouseEventCount > 0) { process.stdout.write(`\n⚠️ WARNING: No mouse events for ${Math.floor(timeSinceLastEvent / 1000)}s (total events: ${mouseEventCount})\n`) } }, 5000) } private async stressLoop() { let iterations = 0 const startTime = Date.now() console.log("\n=== Starting stress test ===") console.log("Instructions:") console.log(" 1. Move your mouse over this window") console.log(" 2. Click and drag to select text") console.log(" 3. Use the scrollbar") console.log(" 4. Press Ctrl+C to stop\n") console.log("Monitoring console modes for corruption...\n") console.log("🖱️ Mouse events will be logged below:") console.log(" - If mouse events stop appearing = bug triggered!\n") // Enable mouse tracking process.stdout.write("\x1b[?1000h") // Mouse tracking process.stdout.write("\x1b[?1002h") // Button event tracking process.stdout.write("\x1b[?1003h") // Any event tracking // Set up mouse event logging this.setupMouseEventLogging() while (this.running) { iterations++ // Output stress this.outputStressPattern() // Query console modes frequently this.recordState() // Check for corruption every 100 iterations if (iterations % 100 === 0) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) process.stdout.write(`\n[${elapsed}s] Iteration ${iterations} - Checking modes...`) if (this.detectCorruption()) { console.log("\n\n🚨 CONSOLE MODE CORRUPTION DETECTED! 🚨\n") this.reportCorruption() break } const [stdinMode, stdoutMode] = this.getConsoleModes() process.stdout.write(` stdin=0x${stdinMode?.toString(16).padStart(4, "0")} stdout=0x${stdoutMode?.toString(16).padStart(4, "0")}`) } // Vary the stress pattern if (iterations % 50 === 0) { // Write a burst of output for (let i = 0; i < 10; i++) { this.outputStressPattern() } } if (iterations % 200 === 0) { // Clear screen and redraw process.stdout.write("\x1b[2J\x1b[H") console.log(`Stress test running... ${iterations} iterations`) } // Small delay to prevent CPU spinning await new Promise(resolve => setTimeout(resolve, 10)) } // Disable mouse tracking process.stdout.write("\x1b[?1000l") process.stdout.write("\x1b[?1002l") process.stdout.write("\x1b[?1003l") } private reportCorruption() { const initial = this.history[0] const current = this.history[this.history.length - 1] console.log("=== Initial State ===") this.logModeFlags(initial.stdinMode, initial.stdoutMode) console.log("\n=== Current State (CORRUPTED) ===") this.logModeFlags(current.stdinMode, current.stdoutMode) console.log("\n=== Changes Detected ===") if (initial.stdinMode !== null && current.stdinMode !== null) { const changed = initial.stdinMode ^ current.stdinMode if (changed) { console.log(`stdin changed bits: 0x${changed.toString(16).padStart(4, "0")}`) if (changed & ENABLE_MOUSE_INPUT) console.log(" - ENABLE_MOUSE_INPUT changed") if (changed & ENABLE_VIRTUAL_TERMINAL_INPUT) console.log(" - ENABLE_VIRTUAL_TERMINAL_INPUT changed") if (changed & ENABLE_QUICK_EDIT_MODE) console.log(" - ENABLE_QUICK_EDIT_MODE changed") } } if (initial.stdoutMode !== null && current.stdoutMode !== null) { const changed = initial.stdoutMode ^ current.stdoutMode if (changed) { console.log(`stdout changed bits: 0x${changed.toString(16).padStart(4, "0")}`) if (changed & ENABLE_VIRTUAL_TERMINAL_PROCESSING) console.log(" - ENABLE_VIRTUAL_TERMINAL_PROCESSING changed") } } console.log(`\nTime to corruption: ${((current.timestamp - initial.timestamp) / 1000).toFixed(1)}s`) console.log(`Total iterations: ${this.history.length}`) } async run() { this.setupTUIMode() this.recordState() this.running = true // Handle Ctrl+C process.on("SIGINT", () => { console.log("\n\n=== Test interrupted by user ===") this.running = false const final = this.getConsoleModes() console.log("\n=== Final Console Modes ===") console.log(`stdin: 0x${final[0]?.toString(16).padStart(4, "0")} (${final[0]})`) console.log(`stdout: 0x${final[1]?.toString(16).padStart(4, "0")} (${final[1]})`) if (this.detectCorruption()) { console.log("\n⚠️ Console modes were corrupted during test") this.reportCorruption() } else { console.log("\n✅ No console mode corruption detected") } process.exit(0) }) await this.stressLoop() } } // Run the test const tester = new ConsoleModeTester() tester.run().catch(console.error)
Save as
console-mode-stress-test.tsand run with:bun run console-mode-stress-test.tsThe script will automatically detect and report when console mode corruption occurs.
Update: Root Cause Found - Not a Windows Terminal Bug
After further investigation, I've identified the actual root cause. This is not a Windows Terminal or ConPTY issue.
The Real Problem
When Node.js or Bun calls
process.stdin.setRawMode(true), the runtime internally callsSetConsoleMode()and overwrites all existing console mode flags instead of preserving them. This wipes outENABLE_MOUSE_INPUTandENABLE_EXTENDED_FLAGSthat TUI applications set.Timeline of what happens:
- TUI app sets console mode:
SetConsoleMode(stdin, ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ...) - Runtime enables raw mode:
process.stdin.setRawMode(true) - Runtime internally calls:
SetConsoleMode(stdin, RAW_MODE_FLAGS)— clobbers mouse flags - Mouse input stops working
Proof
I created a test that toggles individual features:
- With
setRawModedisabled → no corruption - With
setRawModeenabled → corruption in ~150ms
Correct Bug Reports Filed
- Bun: Windows: setRawMode(true) overwrites console mode flags instead of preserving them oven-sh/bun#25663
- Node.js: Windows: setRawMode(true) overwrites console mode flags instead of preserving them nodejs/node#61161
Workaround for TUI Apps
Re-apply console mode flags after calling
setRawMode(true):process.stdin.setRawMode(true) // Re-apply mouse input flags here via native/FFI call SetConsoleMode(stdin, currentMode | ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS)
Closing this issue as it's not a Windows Terminal bug. Apologies for the misdirection!
- TUI app sets console mode:
- added a commit that references this issue
on Mar 19, 2026 - added a commit that references this issue
on Apr 4, 2026 - added a commit that references this issue
on Oct 8, 2026
Description
During long-running TUI (Terminal User Interface) sessions in Windows Terminal, console mode flags become corrupted over time, causing mouse selection, scrollbar interaction, and copy/paste functionality to stop working. This affects multiple TUI applications including opencode and mistral-vibe.
Environment
Steps to Reproduce
opencode,mistral-vibe, or similar)Expected Behavior
Mouse selection, scrollbar, and copy/paste should continue working throughout the entire session regardless of duration.
Actual Behavior
After approximately 30+ minutes of use:
Root Cause Analysis
Through investigation, we've identified that Windows console mode flags are being corrupted during long-running sessions. Specifically:
Console Mode Flags Affected:
ENABLE_MOUSE_INPUT(0x0010)ENABLE_QUICK_EDIT_MODE(0x0040)ENABLE_EXTENDED_FLAGS(0x0080)ENABLE_VIRTUAL_TERMINAL_INPUT(0x0200)ENABLE_VIRTUAL_TERMINAL_PROCESSING(0x0004)The corruption appears to happen at the ConPTY or console host level, not within the TUI application itself.
Workaround
We've successfully worked around this issue by periodically calling
SetConsoleModevia Windows API to refresh the console mode flags:kernel32.dllfunctions:GetStdHandle(STD_INPUT_HANDLE)/GetStdHandle(STD_OUTPUT_HANDLE)GetConsoleMode()to read current modesSetConsoleMode()to restore correct flagsENABLE_VIRTUAL_TERMINAL_PROCESSINGandENABLE_MOUSE_INPUTremain enabledENABLE_QUICK_EDIT_MODEwhen it interferes with TUI mouse handlingImplementation examples:
The fact that this workaround is necessary and effective confirms that console modes are being corrupted by the system rather than by applications.
Related Issues
This appears distinct from:
Unlike those issues, this is about console mode flags being changed/corrupted by Windows/ConPTY during normal operation, not about applications being unable to set modes.
Impact
This affects any long-running TUI application on Windows Terminal where mouse interaction is critical to the user experience. Users must restart their TUI applications or implement platform-specific workarounds to maintain functionality.
Questions for Maintainers
Additional Context
This issue affects TUI frameworks across multiple languages and platforms (Python, TypeScript/Node.js, etc.), suggesting it's a Windows Terminal/ConPTY-level issue rather than a framework-specific problem.