Preserve Windows VT navigation keys and per-terminal resize watchers
- commit
- ecdef4217a77a93aa55f800939d76de4e41a8c53
- author
- patriceckhart · · 2026-09-24 11:39 UTC
- parent
- 50acf01
- signature
- unsigned
3 files · +71 -11
M packages/tui/resize_windows.go +15 -8
| @@ -4,7 +4,6 @@ package tui | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
| 7 | - | "sync" | |
| 8 | 7 | "time" | |
| 9 | 8 | "unsafe" | |
| 10 | 9 | ||
| @@ -14,15 +13,13 @@ import ( | |||
| 14 | 13 | // resizePollInterval bounds how long a size change goes unnoticed. | |
| 15 | 14 | const resizePollInterval = 100 * time.Millisecond | |
| 16 | 15 | ||
| 17 | - | var resizeWatcher sync.Once | |
| 18 | - | ||
| 19 | 16 | // Windows has no SIGWINCH. Console resize events arrive as input records, | |
| 20 | 17 | // but stdin is read as a VT byte stream, so those records never reach us. | |
| 21 | 18 | // Poll the console size instead and fire the callbacks on every change. | |
| 22 | 19 | // This matters most under ConPTY hosts such as multiplexers, where the | |
| 23 | 20 | // pane width changes without any user input. | |
| 24 | 21 | func (p *ProcTerm) installResizeHandler() { | |
| 25 | - | resizeWatcher.Do(func() { | |
| 22 | + | p.resizeOnce.Do(func() { | |
| 26 | 23 | go func() { | |
| 27 | 24 | lastW, lastH := p.Size() | |
| 28 | 25 | ticker := time.NewTicker(resizePollInterval) | |
| @@ -60,16 +57,26 @@ type inputRecord struct { | |||
| 60 | 57 | ||
| 61 | 58 | const keyEvent = 0x0001 | |
| 62 | 59 | ||
| 63 | - | // yieldsByte reports whether a console record turns into at least one | |
| 64 | - | // byte for ReadFile: a key-down with a character. Key-ups, focus, | |
| 65 | - | // mouse, menu, and buffer-size records produce no bytes. | |
| 60 | + | // yieldsByte reports whether a key-down can produce input in VT mode. | |
| 61 | + | // Navigation and function keys emit escape sequences even when their | |
| 62 | + | // UnicodeChar is zero. Key-ups and non-key records produce no bytes. | |
| 66 | 63 | func (r *inputRecord) yieldsByte() bool { | |
| 67 | 64 | if r.eventType != keyEvent { | |
| 68 | 65 | return false | |
| 69 | 66 | } | |
| 70 | 67 | keyDown := *(*int32)(unsafe.Pointer(&r.event[0])) | |
| 68 | + | if keyDown == 0 { | |
| 69 | + | return false | |
| 70 | + | } | |
| 71 | 71 | char := *(*uint16)(unsafe.Pointer(&r.event[10])) | |
| 72 | - | return keyDown != 0 && char != 0 | |
| 72 | + | if char != 0 { | |
| 73 | + | return true | |
| 74 | + | } | |
| 75 | + | key := *(*uint16)(unsafe.Pointer(&r.event[4])) | |
| 76 | + | // Win32 virtual-key codes for PageUp/Down, End/Home, arrows, | |
| 77 | + | // Insert/Delete and F1-F24. ReadConsole translates these into VT | |
| 78 | + | // sequences, despite the input record having no Unicode character. | |
| 79 | + | return key >= 0x21 && key <= 0x28 || key == 0x2d || key == 0x2e || key >= 0x70 && key <= 0x87 | |
| 73 | 80 | } | |
| 74 | 81 | ||
| 75 | 82 | // consoleHasByte checks pending console input without consuming bytes. | |
| @@ -4,7 +4,6 @@ package tui | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
| 7 | - | "sync" | |||
| 8 | "time" | 7 | "time" | ||
| 9 | "unsafe" | 8 | "unsafe" | ||
| 10 | 9 | ||||
| @@ -14,15 +13,13 @@ import ( | |||||
| 14 | // resizePollInterval bounds how long a size change goes unnoticed. | 13 | // resizePollInterval bounds how long a size change goes unnoticed. | ||
| 15 | const resizePollInterval = 100 * time.Millisecond | 14 | const resizePollInterval = 100 * time.Millisecond | ||
| 16 | 15 | ||||
| 17 | - | var resizeWatcher sync.Once | |||
| 18 | - | ||||
| 19 | // Windows has no SIGWINCH. Console resize events arrive as input records, | 16 | // Windows has no SIGWINCH. Console resize events arrive as input records, | ||
| 20 | // but stdin is read as a VT byte stream, so those records never reach us. | 17 | // but stdin is read as a VT byte stream, so those records never reach us. | ||
| 21 | // Poll the console size instead and fire the callbacks on every change. | 18 | // Poll the console size instead and fire the callbacks on every change. | ||
| 22 | // This matters most under ConPTY hosts such as multiplexers, where the | 19 | // This matters most under ConPTY hosts such as multiplexers, where the | ||
| 23 | // pane width changes without any user input. | 20 | // pane width changes without any user input. | ||
| 24 | func (p *ProcTerm) installResizeHandler() { | 21 | func (p *ProcTerm) installResizeHandler() { | ||
| 25 | - | resizeWatcher.Do(func() { | 22 | + | p.resizeOnce.Do(func() { |
| 26 | go func() { | 23 | go func() { | ||
| 27 | lastW, lastH := p.Size() | 24 | lastW, lastH := p.Size() | ||
| 28 | ticker := time.NewTicker(resizePollInterval) | 25 | ticker := time.NewTicker(resizePollInterval) | ||
| @@ -60,16 +57,26 @@ type inputRecord struct { | |||||
| 60 | 57 | ||||
| 61 | const keyEvent = 0x0001 | 58 | const keyEvent = 0x0001 | ||
| 62 | 59 | ||||
| 63 | - | // yieldsByte reports whether a console record turns into at least one | 60 | + | // yieldsByte reports whether a key-down can produce input in VT mode. |
| 64 | - | // byte for ReadFile: a key-down with a character. Key-ups, focus, | 61 | + | // Navigation and function keys emit escape sequences even when their |
| 65 | - | // mouse, menu, and buffer-size records produce no bytes. | 62 | + | // UnicodeChar is zero. Key-ups and non-key records produce no bytes. |
| 66 | func (r *inputRecord) yieldsByte() bool { | 63 | func (r *inputRecord) yieldsByte() bool { | ||
| 67 | if r.eventType != keyEvent { | 64 | if r.eventType != keyEvent { | ||
| 68 | return false | 65 | return false | ||
| 69 | } | 66 | } | ||
| 70 | keyDown := *(*int32)(unsafe.Pointer(&r.event[0])) | 67 | keyDown := *(*int32)(unsafe.Pointer(&r.event[0])) | ||
| 68 | + | if keyDown == 0 { | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | char := *(*uint16)(unsafe.Pointer(&r.event[10])) | 71 | char := *(*uint16)(unsafe.Pointer(&r.event[10])) | ||
| 72 | - | return keyDown != 0 && char != 0 | 72 | + | if char != 0 { |
| 73 | + | return true | |||
| 74 | + | } | |||
| 75 | + | key := *(*uint16)(unsafe.Pointer(&r.event[4])) | |||
| 76 | + | // Win32 virtual-key codes for PageUp/Down, End/Home, arrows, | |||
| 77 | + | // Insert/Delete and F1-F24. ReadConsole translates these into VT | |||
| 78 | + | // sequences, despite the input record having no Unicode character. | |||
| 79 | + | return key >= 0x21 && key <= 0x28 || key == 0x2d || key == 0x2e || key >= 0x70 && key <= 0x87 | |||
| 73 | } | 80 | } | ||
| 74 | 81 | ||||
| 75 | // consoleHasByte checks pending console input without consuming bytes. | 82 | // consoleHasByte checks pending console input without consuming bytes. | ||
A packages/tui/resize_windows_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResizeWatcherIsPerTerminal(t *testing.T) { | |
| 11 | + | first, second := &ProcTerm{}, &ProcTerm{} | |
| 12 | + | var started int | |
| 13 | + | first.resizeOnce.Do(func() { started++ }) | |
| 14 | + | first.resizeOnce.Do(func() { started++ }) | |
| 15 | + | second.resizeOnce.Do(func() { started++ }) | |
| 16 | + | if started != 2 { | |
| 17 | + | t.Fatalf("watchers started = %d, want one per terminal", started) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestConsoleRecordYieldsByte(t *testing.T) { | |
| 22 | + | for _, tc := range []struct { | |
| 23 | + | name string | |
| 24 | + | eventType uint16 | |
| 25 | + | keyDown bool | |
| 26 | + | key uint16 | |
| 27 | + | char uint16 | |
| 28 | + | want bool | |
| 29 | + | }{ | |
| 30 | + | {"character", keyEvent, true, 0x41, 'a', true}, | |
| 31 | + | {"key up", keyEvent, false, 0x26, 0, false}, | |
| 32 | + | {"arrow up", keyEvent, true, 0x26, 0, true}, | |
| 33 | + | {"delete", keyEvent, true, 0x2e, 0, true}, | |
| 34 | + | {"home", keyEvent, true, 0x24, 0, true}, | |
| 35 | + | {"function key", keyEvent, true, 0x70, 0, true}, | |
| 36 | + | {"shift", keyEvent, true, 0x10, 0, false}, | |
| 37 | + | {"focus", 0x0010, false, 0, 0, false}, | |
| 38 | + | } { | |
| 39 | + | t.Run(tc.name, func(t *testing.T) { | |
| 40 | + | r := inputRecord{eventType: tc.eventType} | |
| 41 | + | if tc.keyDown { | |
| 42 | + | binary.LittleEndian.PutUint32(r.event[0:4], 1) | |
| 43 | + | } | |
| 44 | + | binary.LittleEndian.PutUint16(r.event[4:6], tc.key) | |
| 45 | + | binary.LittleEndian.PutUint16(r.event[10:12], tc.char) | |
| 46 | + | if got := r.yieldsByte(); got != tc.want { | |
| 47 | + | t.Errorf("yieldsByte() = %v, want %v", got, tc.want) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResizeWatcherIsPerTerminal(t *testing.T) { | |||
| 11 | + | first, second := &ProcTerm{}, &ProcTerm{} | |||
| 12 | + | var started int | |||
| 13 | + | first.resizeOnce.Do(func() { started++ }) | |||
| 14 | + | first.resizeOnce.Do(func() { started++ }) | |||
| 15 | + | second.resizeOnce.Do(func() { started++ }) | |||
| 16 | + | if started != 2 { | |||
| 17 | + | t.Fatalf("watchers started = %d, want one per terminal", started) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestConsoleRecordYieldsByte(t *testing.T) { | |||
| 22 | + | for _, tc := range []struct { | |||
| 23 | + | name string | |||
| 24 | + | eventType uint16 | |||
| 25 | + | keyDown bool | |||
| 26 | + | key uint16 | |||
| 27 | + | char uint16 | |||
| 28 | + | want bool | |||
| 29 | + | }{ | |||
| 30 | + | {"character", keyEvent, true, 0x41, 'a', true}, | |||
| 31 | + | {"key up", keyEvent, false, 0x26, 0, false}, | |||
| 32 | + | {"arrow up", keyEvent, true, 0x26, 0, true}, | |||
| 33 | + | {"delete", keyEvent, true, 0x2e, 0, true}, | |||
| 34 | + | {"home", keyEvent, true, 0x24, 0, true}, | |||
| 35 | + | {"function key", keyEvent, true, 0x70, 0, true}, | |||
| 36 | + | {"shift", keyEvent, true, 0x10, 0, false}, | |||
| 37 | + | {"focus", 0x0010, false, 0, 0, false}, | |||
| 38 | + | } { | |||
| 39 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 40 | + | r := inputRecord{eventType: tc.eventType} | |||
| 41 | + | if tc.keyDown { | |||
| 42 | + | binary.LittleEndian.PutUint32(r.event[0:4], 1) | |||
| 43 | + | } | |||
| 44 | + | binary.LittleEndian.PutUint16(r.event[4:6], tc.key) | |||
| 45 | + | binary.LittleEndian.PutUint16(r.event[10:12], tc.char) | |||
| 46 | + | if got := r.yieldsByte(); got != tc.want { | |||
| 47 | + | t.Errorf("yieldsByte() = %v, want %v", got, tc.want) | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
M packages/tui/terminal.go +5 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "io" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "golang.org/x/term" | |
| @@ -31,9 +32,10 @@ type Terminal interface { | |||
| 31 | 32 | ||
| 32 | 33 | // ProcTerm is a Terminal bound to the current process's tty. | |
| 33 | 34 | type ProcTerm struct { | |
| 34 | - | out *os.File | |
| 35 | - | in *os.File | |
| 36 | - | resizeCBs []func() | |
| 35 | + | out *os.File | |
| 36 | + | in *os.File | |
| 37 | + | resizeCBs []func() | |
| 38 | + | resizeOnce sync.Once // Windows polls each terminal independently. | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | 41 | // NewProcTerm returns a Terminal bound to stdin/stdout. | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "io" | 4 | "io" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | "sync" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "golang.org/x/term" | 10 | "golang.org/x/term" | ||
| @@ -31,9 +32,10 @@ type Terminal interface { | |||||
| 31 | 32 | ||||
| 32 | // ProcTerm is a Terminal bound to the current process's tty. | 33 | // ProcTerm is a Terminal bound to the current process's tty. | ||
| 33 | type ProcTerm struct { | 34 | type ProcTerm struct { | ||
| 34 | - | out *os.File | 35 | + | out *os.File |
| 35 | - | in *os.File | 36 | + | in *os.File |
| 36 | - | resizeCBs []func() | 37 | + | resizeCBs []func() |
| 38 | + | resizeOnce sync.Once // Windows polls each terminal independently. | |||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| 39 | // NewProcTerm returns a Terminal bound to stdin/stdout. | 41 | // NewProcTerm returns a Terminal bound to stdin/stdout. | ||