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

Closed
opened 2026-01-31 08:55:35 +00:00 by claunia · 3 comments
Owner

Originally created by @pkellyuk on GitHub (Dec 23, 2025).

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.

This appears distinct from:

  • #15711 (SetConsoleMode not taking effect - fixed in v1.19)
  • #18406 (Can't disable QuickEdit mode - feature request)
  • #13393 (Auto-scrolling stops - fixed in v1.14)
  • #13209 (Alternate screen buffer - working as designed)

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.

Originally created by @pkellyuk on GitHub (Dec 23, 2025). ## 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: - Mistral-vibe (Python): https://github.com/paulkelly/mistral-vibe (uses ctypes) - OpenCode (TypeScript/Bun): Uses Bun FFI to call kernel32.dll 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: - #15711 (SetConsoleMode not taking effect - fixed in v1.19) - #18406 (Can't disable QuickEdit mode - feature request) - #13393 (Auto-scrolling stops - fixed in v1.14) - #13209 (Alternate screen buffer - working as designed) 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.
claunia added the Needs-TriageNeeds-Tag-Fix labels 2026-01-31 08:55:35 +00:00
Author
Owner

@pkellyuk commented on GitHub (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 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.

@pkellyuk commented on GitHub (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 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.
Author
Owner

@pkellyuk commented on GitHub (Dec 23, 2025):

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.

@pkellyuk commented on GitHub (Dec 23, 2025): ### Full Stress Test Script Here's the complete test that reproduces the bug in 1.6 seconds: ```typescript #!/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.
Author
Owner

@pkellyuk commented on GitHub (Dec 23, 2025):

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!

@pkellyuk commented on GitHub (Dec 23, 2025): ## 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 - **Bun**: https://github.com/oven-sh/bun/issues/25663 - **Node.js**: https://github.com/nodejs/node/issues/61161 ### Workaround for TUI Apps Re-apply console mode flags **after** calling `setRawMode(true)`: ```javascript 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 in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/terminal#23902