DEV Community

Andy Brummer
Andy Brummer

Posted on

Taming Windows Terminal's win32-input-mode in Go ConPTY Applications

gopher in hardhat looking at cryptic windows terminal codes

When building agnt, a tool that gives AI coding agents browser superpowers, I hit a frustrating issue: my Ctrl+Y hotkey worked perfectly on macOS and Linux, but on Windows it produced garbage like [89;21;25;1;8;1_ instead of toggling my overlay menu.

This article documents the debugging journey and the solution - a Go 1.23+ iterator-based parser that correctly handles Windows Terminal's win32-input-mode escape sequences.

The Problem

I was wrapping AI coding tools (Claude, Gemini, etc.) in a pseudo-terminal to inject an overlay UI. The overlay listens for Ctrl+Y (byte 0x19) to toggle a menu. Simple enough:

if b == 0x19 { // Ctrl+Y
    overlay.Toggle()
}
Enter fullscreen mode Exit fullscreen mode

On Unix, this worked immediately. On Windows with ConPTY, pressing Ctrl+Y produced:

[89;21;25;1;8;1_
Enter fullscreen mode Exit fullscreen mode

What is this? It's a win32-input-mode escape sequence.

What is win32-input-mode?

Windows Terminal introduced win32-input-mode to provide richer keyboard input to ConPTY applications. Instead of sending raw bytes, it sends structured escape sequences:

ESC [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _
Enter fullscreen mode Exit fullscreen mode

Where:

  • Vk - Virtual key code (89 = 'Y')
  • Sc - Scan code (21)
  • Uc - Unicode character (25 = Ctrl+Y)
  • Kd - Key down flag (1 = down, 0 = up)
  • Cs - Control key state (8 = Ctrl held)
  • Rc - Repeat count (1)

So [89;21;25;1;8;1_ decodes to: "Y key pressed with Ctrl held, unicode value 25".

The unicode value 25 (0x19) is exactly what I needed - but it's buried in an escape sequence!

The Debugging Journey

Step 1: Isolate the Trigger

First, I needed to understand when Windows Terminal enables win32-input-mode. I created a diagnostic tool:

// Direct stdin read - what bytes do we actually receive?
func main() {
    oldState, _ := term.MakeRaw(int(os.Stdin.Fd()))
    defer term.Restore(int(os.Stdin.Fd()), oldState)

    buf := make([]byte, 64)
    for {
        n, _ := os.Stdin.Read(buf)
        fmt.Printf("Read %d bytes: %q\n", n, buf[:n])
    }
}
Enter fullscreen mode Exit fullscreen mode

Finding 1: Direct stdin reads showed raw bytes (0x19 for Ctrl+Y). Good.

Finding 2: When I created a ConPTY but didn't write its output anywhere, stdin still showed raw bytes.

Finding 3: When I wrote ConPTY output to stdout, suddenly stdin switched to win32-input-mode sequences!

The trigger: writing PTY output to stdout causes Windows Terminal to enable win32-input-mode for the session.

Step 2: Attempt to Disable It

ConPTY supports a sequence to disable win32-input-mode:

fmt.Fprint(os.Stdout, "\x1b[?9001l") // Disable win32-input-mode
Enter fullscreen mode Exit fullscreen mode

This... didn't work reliably. Windows Terminal still sent the sequences in many scenarios.

Step 3: Parse the Sequences

If we can't disable it, we parse it. First attempt:

func parseWin32InputMode(data []byte) []byte {
    // Look for ESC [ ... _
    // Extract the Uc (unicode char) field
    // Return as raw bytes
}
Enter fullscreen mode Exit fullscreen mode

This worked for single keypresses, but failed intermittently. The debug output revealed:

[win32] seq=89;21;25;1;8;1 -> byte 25 (0x19)  // First sequence parsed!
[win32] passthrough byte 27 (0x1b)            // Wait, why?
[win32] passthrough byte 91 (0x5b) '['        // This is another ESC[!
Enter fullscreen mode Exit fullscreen mode

After parsing the first sequence correctly, subsequent sequences in the same buffer were passed through as raw bytes.

Step 4: The Buffer Boundary Bug

The culprit: os.Stdin.Read() returns arbitrary chunks. An escape sequence can be split across reads:

Read 1: ...25;1;8;1_\x1b     <- ends with ESC
Read 2: [17;29;0;0;0;1_...   <- starts with [
Enter fullscreen mode Exit fullscreen mode

When Read 1 ends with \x1b, my parser checked data[i+1] for [, but there was no next byte - it was in the next read! The ESC got passed through as a raw byte.

The Solution: An Iterator with Remainder Handling

Go 1.23 introduced iter.Seq, which is perfect for this. The iterator:

  1. Reads from stdin
  2. Parses win32-input-mode sequences
  3. Yields extracted bytes
  4. Holds incomplete sequences for the next read
// ScanWin32Input returns an iterator that reads from r and yields parsed bytes.
// Buffer boundaries are handled internally - incomplete sequences at the end
// of a read are held and combined with the next read.
func ScanWin32Input(r io.Reader) iter.Seq[byte] {
    return func(yield func(byte) bool) {
        var pending []byte
        buf := make([]byte, 256)

        for {
            n, err := r.Read(buf)
            if n > 0 {
                // Combine pending bytes with new data
                var data []byte
                if len(pending) > 0 {
                    data = make([]byte, len(pending)+n)
                    copy(data, pending)
                    copy(data[len(pending):], buf[:n])
                    pending = nil
                } else {
                    data = buf[:n]
                }

                // Parse and yield bytes
                parsed, remainder := parseWin32Sequences(data)
                pending = remainder

                for _, b := range parsed {
                    if !yield(b) {
                        return
                    }
                }
            }

            if err != nil {
                return
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The key is parseWin32Sequences returning a remainder - any incomplete sequence at the end of the buffer:

func parseWin32Sequences(data []byte) (parsed []byte, remainder []byte) {
    var result []byte
    i := 0

    for i < len(data) {
        if data[i] == 0x1b {
            // ESC at end of buffer? Save as remainder
            if i+1 >= len(data) {
                return result, data[i:]
            }

            if data[i+1] == '[' {
                // Look for sequence terminator '_'
                end := findTerminator(data, i+2)

                if end > 0 {
                    // Complete sequence - parse it
                    b := extractUnicodeChar(data[i+2 : end])
                    if b > 0 {
                        result = append(result, b)
                    }
                    i = end + 1
                    continue
                }

                // No terminator found - might be incomplete
                if !hitInvalidChar {
                    return result, data[i:] // Save as remainder
                }
            }
        }

        // Regular byte - pass through
        result = append(result, data[i])
        i++
    }

    return result, nil
}
Enter fullscreen mode Exit fullscreen mode

Usage

With the iterator, consuming parsed input is clean:

go func() {
    for b := range ScanWin32Input(os.Stdin) {
        inputCh <- b
    }
}()

// In main loop
for b := range inputCh {
    if b == 0x19 { // Ctrl+Y now works!
        overlay.Toggle()
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Windows Terminal enables win32-input-mode when you write PTY output to stdout. There's no reliable way to disable it.

  2. Buffer boundaries will break naive parsers. Always handle the case where a sequence is split across reads.

  3. Go 1.23+ iterators are perfect for streaming parsers. The iter.Seq pattern encapsulates state cleanly.

  4. Build diagnostic tools. The keylog utility that tested different scenarios (direct read, PTY only, PTY + output) was essential for isolating the trigger.

  5. Key-up events exist. Win32-input-mode sends both key-down (Kd=1) and key-up (Kd=0) events. Only emit bytes for key-down.

The Full Parser

The complete implementation handles:

  • Buffer boundary splitting
  • Key-down vs key-up filtering
  • Focus in/out sequences (ESC[I and ESC[O)
  • Invalid sequence recovery

You can find it in the agnt repository.

Resources

Top comments (0)