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()
}
On Unix, this worked immediately. On Windows with ConPTY, pressing Ctrl+Y produced:
[89;21;25;1;8;1_
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 _
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])
}
}
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
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
}
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[!
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 [
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:
- Reads from stdin
- Parses win32-input-mode sequences
- Yields extracted bytes
- 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
}
}
}
}
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
}
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()
}
}
Key Takeaways
Windows Terminal enables win32-input-mode when you write PTY output to stdout. There's no reliable way to disable it.
Buffer boundaries will break naive parsers. Always handle the case where a sequence is split across reads.
Go 1.23+ iterators are perfect for streaming parsers. The
iter.Seqpattern encapsulates state cleanly.Build diagnostic tools. The keylog utility that tested different scenarios (direct read, PTY only, PTY + output) was essential for isolating the trigger.
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[IandESC[O) - Invalid sequence recovery
You can find it in the agnt repository.
Resources
- Win32 Input Mode Spec
- ConPTY Documentation
- agnt - Browser superpowers for AI coding agents
- aymanbagabas/go-pty - Cross-platform PTY library for Go

Top comments (0)