Skip to content

Console mode corruption in long-running TUI sessions causes mouse selection and copy/paste to stop working #19674

Description

@pkellyuk

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

  • Windows Terminal Version: (affects latest versions including 1.21+)
  • Windows Version: Windows 10/11
  • Affected Applications: TUI applications built with various frameworks (OpenTui, Python Textual, etc.)
  • Onset: Typically occurs after 30+ minutes of continuous use

Steps to Reproduce

  1. Open Windows Terminal
  2. Launch a long-running TUI application (e.g., opencode, mistral-vibe, or similar)
  3. Use the application normally for 30+ minutes with mouse interactions
  4. Attempt to:
    • Use mouse to select text
    • Click on the scrollbar
    • Copy selected text with Ctrl+C or right-click

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:

  • Mouse selection stops working or becomes unresponsive
  • Scrollbar becomes unresponsive to clicks
  • Copy/paste functionality fails
  • Mouse input feels "broken" or inconsistent

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 SetConsoleMode via Windows API to refresh the console mode flags:

  1. Save original console modes on startup
  2. Periodically refresh console modes (every 30 seconds) using kernel32.dll functions:
    • GetStdHandle(STD_INPUT_HANDLE) / GetStdHandle(STD_OUTPUT_HANDLE)
    • GetConsoleMode() to read current modes
    • SetConsoleMode() to restore correct flags
  3. Ensure ENABLE_VIRTUAL_TERMINAL_PROCESSING and ENABLE_MOUSE_INPUT remain enabled
  4. Disable ENABLE_QUICK_EDIT_MODE when it interferes with TUI mouse handling

Implementation 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

  1. Is there any known mechanism in ConPTY or the console host that modifies console mode flags during runtime?
  2. Could Windows accessibility features or other system processes be interfering with console modes?
  3. Should applications expect console modes to remain stable once set, or is periodic refreshing the expected pattern?
  4. Is this something that should be fixed in Windows Terminal/ConPTY, or is the application-level workaround the recommended approach?

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.

Activity

  1. added
    Needs-TriageIt's a new issue that the core contributor team needs to triage at the next triage meeting
    Needs-Tag-FixDoesn't match tag requirements
    on Dec 23, 2025
  2. pkellyuk commented on Dec 23, 2025

    @pkellyuk
    Author

    🚨 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 101

    Console 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:

    1. Mouse tracking enabled via ANSI escape sequences (\x1b[?1000h, \x1b[?1002h, \x1b[?1003h)
    2. Console mode set via SetConsoleMode (enabling ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT)
    3. Rapid ANSI/VT output with colored text
    4. 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:

    1. Calls SetConsoleMode to enable ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT
    2. Enables mouse tracking via ANSI sequences
    3. Rapidly outputs colored ANSI text
    4. Queries console modes every 100 iterations
    5. Detects when ENABLE_MOUSE_INPUT flag disappears

    How to Run

    1. Install Bun: irm bun.sh/install.ps1 | iex
    2. Save script as console-mode-stress-test.ts
    3. Run: bun run console-mode-stress-test.ts
    4. 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 SetConsoleMode to 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.

  3. pkellyuk commented on Dec 23, 2025

    @pkellyuk
    Author

    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.ts and run with: bun run console-mode-stress-test.ts

    The script will automatically detect and report when console mode corruption occurs.

  4. pkellyuk commented on Dec 23, 2025

    @pkellyuk
    Author

    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 calls SetConsoleMode() and overwrites all existing console mode flags instead of preserving them. This wipes out ENABLE_MOUSE_INPUT and ENABLE_EXTENDED_FLAGS that TUI applications set.

    Timeline of what happens:

    1. TUI app sets console mode: SetConsoleMode(stdin, ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ...)
    2. Runtime enables raw mode: process.stdin.setRawMode(true)
    3. Runtime internally calls: SetConsoleMode(stdin, RAW_MODE_FLAGS) — clobbers mouse flags
    4. Mouse input stops working

    Proof

    I created a test that toggles individual features:

    • With setRawMode disabled → no corruption
    • With setRawMode enabled → corruption in ~150ms

    Correct Bug Reports Filed

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs-Tag-FixDoesn't match tag requirementsNeeds-TriageIt's a new issue that the core contributor team needs to triage at the next triage meeting

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions