Windows native TUI inserts [C/[D/[3~ when VT input is re-enabled

Open 💬 4 comments Opened Jun 15, 2026 by L0stInFades

What version of Codex CLI is running?

codex-cli 0.140.0-alpha.19 from the Windows npm package.

I also reproduced this on a native Windows source build from openai/codex@dfd03ea01b before applying a local diagnostic patch.

This appears to be a recent regression in the native Windows path: the same Windows environment was usable with the previous stable npm version before updating.

What subscription do you have?

Not included here. The issue reproduces in local TUI input handling before any model/API request is needed.

Which model were you using?

Not model-specific.

What platform is your computer?

Microsoft Windows NT 10.0.26200.0 x64

Additional context from codex doctor --json: Windows 11 Professional, x64.

WSL2 Fedora in the same Windows Terminal does not reproduce the issue.

What terminal emulator and version are you using (if applicable)?

Primary repro: Windows Terminal 1.24.11321.0 with PowerShell 7.6.2 (pwsh).

Also reproduced on native Windows in Git Bash and VS Code integrated terminal. No tmux/screen/zellij.

Codex doctor report

Redacted to the parts relevant to this TUI input issue:

{
  "schemaVersion": 1,
  "overallStatus": "ok",
  "codexVersion": "0.140.0-alpha.19",
  "checks": {
    "installation": {
      "status": "ok",
      "summary": "installation looks consistent",
      "details": {
        "install context": "npm on windows-x86_64"
      }
    },
    "runtime.provenance": {
      "status": "ok",
      "summary": "running npm on windows-x86_64",
      "details": {
        "platform": "windows-x86_64",
        "version": "0.140.0-alpha.19"
      }
    },
    "system.environment": {
      "status": "ok",
      "summary": "Windows 11 Professional [64-bit]",
      "details": {
        "os": "Windows 10.0.26200 (Windows 11 Professional) [64-bit]",
        "os type": "Windows",
        "os version": "10.0.26200"
      }
    },
    "terminal.env": {
      "status": "ok",
      "summary": "terminal metadata was detected",
      "details": {
        "terminal": "Windows Terminal",
        "terminal size": "120x50"
      }
    }
  }
}

What issue are you seeing?

I am filing this as a native Windows TUI input regression report, with a locally validated mitigation included below for context.

On native Windows terminals, the Codex TUI can intermittently insert terminal escape-sequence tails into the composer when navigation/editing keys are pressed. For example, pressing Right/Left/Down/Delete can leave literal text like [C, [D, [B, and [3~ in the input area.

This materially degrades usability of the native Windows TUI. The same workflow in WSL2 inside Windows Terminal does not show the issue.

What steps can reproduce the bug?

  1. Start native Windows codex in Windows Terminal + PowerShell.
  2. Focus the composer.
  3. Press arrow keys and Delete.
  4. Watch the composer text.

I could also reproduce this with constructed PTY input in both WinPTY and ConPTY paths by sending focus + navigation/delete sequences equivalent to:

ESC[O ESC[I ESC[C ESC[C ESC[C ESC[D ESC[D ESC[B ESC[3~ XYZ

On the affected build, the visible composer text contains escape tails such as [C, [D, [B, and [3~.

What is the expected behavior?

Arrow keys and Delete should be handled as key events/navigation/editing operations. Their escape-sequence bytes should not be inserted as text in the composer.

Additional information

I instrumented a local native Windows build to narrow this down. The evidence points to ENABLE_VIRTUAL_TERMINAL_INPUT being enabled on the console input handle while crossterm's Windows event stream is reading.

A representative failing state was:

input_mode=0x00000398, ENABLE_VIRTUAL_TERMINAL_INPUT=true
raw crossterm events: KeyEvent { code: Char('[') }, KeyEvent { code: Char('C') }, ...

After clearing ENABLE_VIRTUAL_TERMINAL_INPUT, the mode becomes 0x00000198 and the same navigation/delete input no longer leaks [C/[D/[B/[3~ into the composer.

The notable detail is that clearing this bit only during initial setup was not sufficient in this environment. Logging showed startup and event-stream creation with VT input disabled, followed by a later Poll::Pending path where the input mode had returned to 0x398.

A locally validated mitigation does the following:

  • Does not emit crossterm focus-change enable/disable commands on Windows.
  • Defaults keyboard enhancement mode off on native Windows, while keeping the existing env override.
  • Explicitly clears ENABLE_VIRTUAL_TERMINAL_INPUT after raw mode setup, after Windows color probing/palette probing, after crossterm EventStream::new(), and again when the event stream returns Poll::Pending before waiting for more stdin.

Validation of that local patch:

  • Windows-side cargo fmt --check: passed (only the existing rustfmt nightly-option warning).
  • cargo test -p codex-tui tui::keyboard_modes --lib: passed, 11 tests.
  • cargo check -p codex-tui: passed.
  • cargo build -p codex-cli --bin codex: passed.
  • WinPTY/ConPTY constructed-input probes no longer leave [C/[D/[B/[3~ visible in the composer.

I would be happy to open a PR with this Windows-gated patch if maintainers consider this direction appropriate. I am also happy to adjust the shape of the change if the preferred boundary is closer to the crossterm event-source layer.

View original on GitHub ↗

4 Comments

L0stInFades · 2 months ago

For completeness, here is the shape of the local mitigation that stopped the issue from reproducing in my Windows environment.

Diagnosis path

The visible [C/[D/[B/[3~ text was not coming from the composer itself. It was already present in the crossterm events as ordinary character events.

With temporary logging around the Windows console input mode and raw crossterm events, a failing run showed the input handle in this state while the TUI was reading:

input_mode=0x00000398
ENABLE_VIRTUAL_TERMINAL_INPUT=true
raw events include: Char('['), Char('C'), Char('['), Char('D'), Char('['), Char('3'), Char('~')

When the same input was read with ENABLE_VIRTUAL_TERMINAL_INPUT cleared, the mode was:

input_mode=0x00000198
ENABLE_VIRTUAL_TERMINAL_INPUT=false

and crossterm produced normal key events instead of character events for the escape-sequence bytes.

The important detail is that clearing it only during initial TUI setup was not enough in this environment. Logging showed startup and EventStream::new() with VT input already off, then a later event-stream wait path saw it back on:

startup / event stream creation: vt_input=false
later Poll::Pending path:        vt_input=true, mode=0x398

The local patch therefore treats ENABLE_VIRTUAL_TERMINAL_INPUT as a mode bit that may be restored while the event stream remains alive, rather than as a one-time initialization concern.

Shape of the local patch

The local patch has three parts.

1. Add a small Windows-only helper that clears VT input

In codex-rs/tui/src/tui.rs:

#[cfg(windows)]
fn disable_virtual_terminal_input() -> Result<()> {
    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
    use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT;
    use windows_sys::Win32::System::Console::GetConsoleMode;
    use windows_sys::Win32::System::Console::GetStdHandle;
    use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
    use windows_sys::Win32::System::Console::SetConsoleMode;

    let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
    if handle == INVALID_HANDLE_VALUE || handle == 0 {
        return Ok(());
    }

    let mut mode = 0;
    if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
        return Ok(());
    }

    if mode & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 {
        return Ok(());
    }

    if unsafe { SetConsoleMode(handle, mode & !ENABLE_VIRTUAL_TERMINAL_INPUT) } == 0 {
        return Err(std::io::Error::last_os_error());
    }

    Ok(())
}

This leaves output VT processing alone. It only clears the input-side VT bit on STD_INPUT_HANDLE, and treats missing/non-console handles as no-op.

2. Clear VT input at every place that can hand control to crossterm's Windows reader

I added let _ = disable_virtual_terminal_input(); on Windows after:

  • enable_raw_mode() in set_modes()
  • probe_windows_default_colors() in init()
  • terminal_palette::default_colors() in Tui::new()
  • crossterm::event::EventStream::new() in CrosstermEventSource::default()

The final call site was the one that made the repro stop reproducing locally:

match Pin::new(events).poll_next(cx) {
    Poll::Ready(Some(Ok(event))) => Some(event),
    Poll::Ready(Some(Err(_))) | Poll::Ready(None) => {
        *state = EventBrokerState::Start;
        return Poll::Ready(None);
    }
    Poll::Pending => {
        // Some Windows PTY paths can re-enable VT input while the crossterm
        // reader is waiting. Keep crossterm on console input records.
        #[cfg(windows)]
        let _ = super::disable_virtual_terminal_input();
        drop(state);
        match Pin::new(&mut self.resume_stream).poll_next(cx) {
            Poll::Ready(Some(())) => continue,
            Poll::Ready(None) => return Poll::Ready(None),
            Poll::Pending => return Poll::Pending,
        }
    }
}

Without the Poll::Pending guard, I could still observe VT input being off at initialization but on again before the next meaningful key read.

3. Avoid Windows input features that may introduce VT-style input sequences

I made two conservative Windows-specific changes:

  • Do not send crossterm EnableFocusChange / DisableFocusChange on Windows.
  • Disable keyboard enhancement by default on native Windows, while preserving the existing env override.

The keyboard enhancement helper now effectively does:

if let Some(disabled) = parse_bool_env(disable_env) {
    return disabled;
}

is_windows || (is_wsl && is_vscode_terminal)

The existing override still works, so CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT=0 can re-enable it for experiments.

Rationale

The rationale is that crossterm's Windows backend expects console input records for these navigation/editing keys. When ENABLE_VIRTUAL_TERMINAL_INPUT is set on the input handle, the console may instead surface VT bytes such as ESC [ C.

In the failing path, crossterm then reports the tail bytes as Char('[') and Char('C'), and the composer correctly inserts them as typed text. The local mitigation keeps the input handle in the console-record mode expected by that backend whenever the TUI is about to read from crossterm.

Validation

With the local patch:

  • Windows Terminal + pwsh no longer inserts [C/[D/[B/[3~ when using arrows/Delete.
  • Git Bash and VS Code integrated terminal no longer reproduce the same issue in my environment.
  • Constructed WinPTY and ConPTY input probes that used to leave escape tails visible no longer do so.
  • cargo fmt --check passed, except for the existing rustfmt warning about imports_granularity = Item.
  • cargo test -p codex-tui tui::keyboard_modes --lib passed, 11 tests.
  • cargo check -p codex-tui passed.
  • cargo build -p codex-cli --bin codex passed.

I have kept the local patch narrow and Windows-gated. I can send this as a PR if maintainers would like to evaluate this approach, or split the change differently if the preferred fix should live closer to the crossterm event-source boundary.

BLueStarBLS · 2 months ago

I also encountered a similar issue! Rollback to something before 0.140.0 like 0.139.0 might works.

lzt11319 · 2 months ago

Same problem happens in 0.139.0

see-the-world-please · 1 month ago

I can add a deterministic trigger and a concrete root-cause path for the same ENABLE_VIRTUAL_TERMINAL_INPUT failure described here.

Environment

  • Native Windows 11 x64 (10.0.22000)
  • codex-cli 0.145.0
  • Microsoft Store WSL 2.7.11.0
  • Native Windows Codex TUI launched from PowerShell

Observed incident

In a long-running native Windows Codex session, the console input mode was healthy at 0x1F0, then became stuck at 0x3D8 after batched parallel wsl.exe tool calls. At that point:

  • arrows inserted [A, [B, [C, or [D;
  • Delete inserted [3~;
  • Enter, Backspace, and other control-key handling became unreliable.

Restoring the same live console input handle from 0x3D8 to 0x1F0 immediately repaired key handling without restarting or resuming Codex. The relevant bit for the escape-tail symptom is ENABLE_VIRTUAL_TERMINAL_INPUT (0x200), matching this issue.

Deterministic isolated reproduction

I reproduced the mode corruption outside the user's active session in a disposable native Windows console:

  1. Initial CONIN$ mode: 0x1F7.
  2. Start WSL process A: wsl.exe -- sh -c "sleep 2".
  3. After A has changed the shared input mode, start WSL process B: wsl.exe -- sh -c "sleep 5" in the same console.
  4. A exits first; B exits last. Both return exit code 0.
  5. Final CONIN$ mode: 0x3D8, with ENABLE_VIRTUAL_TERMINAL_INPUT still set.

The exit-order timeline is:

initial                    0x1F7
A starts and saves 0x1F7  0x3D8
B starts and saves 0x3D8  0x3D8
A exits, restores 0x1F7   0x1F7
B exits, restores 0x3D8   0x3D8  <-- stuck

Controls:

  • 30 strictly serial wsl.exe -- true calls all restored the original mode.
  • One WSL process overlapping an ordinary PowerShell process restored correctly in either exit order.
  • PowerShell-to-WSL execution with inherited or redirected stdout/stderr restored correctly in 15/15 tests.
  • Reversing the two-WSL exit order restores correctly.

This explains why the problem appears and disappears intermittently: the final console mode depends on which overlapping WSL client exits last.

Root-cause path

WSL 2.7.11 opens the shared CONIN$, snapshots its current mode, enables ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT, and clears several other input flags:

https://github.com/microsoft/WSL/blob/2.7.11/src/windows/common/ConsoleState.cpp#L65-L86

Each WSL client later restores its own snapshot unconditionally in its destructor:

https://github.com/microsoft/WSL/blob/2.7.11/src/windows/common/ConsoleState.cpp#L118-L135

With overlapping clients, B can snapshot A's temporary mode and restore that temporary mode after A has already restored the original mode.

Codex then remains broken because its Windows raw-mode path does not defensively remove a later reintroduced ENABLE_VIRTUAL_TERMINAL_INPUT bit. That is consistent with the instrumentation and mitigation already reported in this issue.

Suggested mitigation in Codex

Any of these boundaries would prevent a WSL-side mode race from permanently breaking the TUI:

  • serialize tool calls that inherit the same Windows console input buffer;
  • run concurrent tools with isolated console input/ConPTY state; or
  • reassert the TUI's required Windows input mode after external tools complete and before polling input, specifically clearing ENABLE_VIRTUAL_TERMINAL_INPUT when using the Windows event API.

Companion WSL report: https://github.com/microsoft/WSL/issues/41201

No private session transcript or project paths are included; the reproduction above is standalone.