detached 0192a2e

Merge pull request #206 from m1lian/fix/windows-resize-and-esc

Detect console resize and bare Esc on Windows
commit
0192a2e532be8a41d0c2559aaa99f2a755fb49e4
author
Patric Eckhart · · 2026-09-24 11:48 UTC
committer
GitHub · · 2026-09-24 11:48 UTC
merge of
6d56e22 + 9f3cad2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.93
3 files · +198 -16
M packages/tui/resize_windows.go +142 -13
@@ -5,23 +5,152 @@ package tui
55 import (
66 "os"
77 "time"
8+ "unsafe"
9+
10+ "golang.org/x/sys/windows"
811 )
912
10-// Windows doesn't have SIGWINCH; resize events are signaled via input
11-// records. For v1 we just ignore resize and rely on the next render
12-// cycle to pick up the new size via term.GetSize.
13-func (p *ProcTerm) installResizeHandler() {}
13+// resizePollInterval bounds how long a size change goes unnoticed.
14+const resizePollInterval = 100 * time.Millisecond
15+
16+// Windows has no SIGWINCH. Console resize events arrive as input records,
17+// but stdin is read as a VT byte stream, so those records never reach us.
18+// Poll the console size instead and fire the callbacks on every change.
19+// This matters most under ConPTY hosts such as multiplexers, where the
20+// pane width changes without any user input.
21+func (p *ProcTerm) installResizeHandler() {
22+ p.resizeOnce.Do(func() {
23+ go func() {
24+ lastW, lastH := p.Size()
25+ ticker := time.NewTicker(resizePollInterval)
26+ defer ticker.Stop()
27+ for range ticker.C {
28+ w, h := p.Size()
29+ if w == lastW && h == lastH {
30+ continue
31+ }
32+ lastW, lastH = w, h
33+ for _, cb := range p.resizeCBs {
34+ cb()
35+ }
36+ }
37+ }()
38+ })
39+}
1440
1541 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
1642
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through
18-// to a blocking read. This means bare Esc on Windows is only detected
19-// after the next keystroke. Good enough for v1.
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {
21- var b [1]byte
22- _, err := in.Read(b[:])
23- if err != nil {
24- return 0, false, err
43+var (
44+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
45+ procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW")
46+ procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW")
47+ procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents")
48+)
49+
50+// inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only
51+// key events are decoded; the union is kept as raw bytes.
52+type inputRecord struct {
53+ eventType uint16
54+ _ uint16
55+ event [16]byte
56+}
57+
58+const keyEvent = 0x0001
59+
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.
63+func (r *inputRecord) yieldsByte() bool {
64+ if r.eventType != keyEvent {
65+ return false
66+ }
67+ keyDown := *(*int32)(unsafe.Pointer(&r.event[0]))
68+ if keyDown == 0 {
69+ return false
70+ }
71+ char := *(*uint16)(unsafe.Pointer(&r.event[10]))
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
80+}
81+
82+// consoleHasByte checks pending console input without consuming bytes.
83+// Leading records that would not produce a byte are discarded so the
84+// handle stops signalling for them; otherwise a lone focus event would
85+// make every wait return immediately while ReadFile still blocks.
86+func consoleHasByte(h windows.Handle) (bool, error) {
87+ for {
88+ var count uint32
89+ if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 {
90+ return false, err
91+ }
92+ if count == 0 {
93+ return false, nil
94+ }
95+ records := make([]inputRecord, count)
96+ var read uint32
97+ if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 {
98+ return false, err
99+ }
100+ skip := uint32(0)
101+ for skip < read && !records[skip].yieldsByte() {
102+ skip++
103+ }
104+ if skip < read {
105+ return true, nil
106+ }
107+ if skip == 0 {
108+ return false, nil
109+ }
110+ if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 {
111+ return false, err
112+ }
113+ }
114+}
115+
116+// peekStdin waits up to d for one byte of console input. It uses the
117+// console handle's wait state plus PeekConsoleInput, so a missing reply
118+// (for example an OSC 11 background query the host never answers) or a
119+// bare Esc times out instead of blocking until the next keystroke.
120+// Non-console stdin falls back to a blocking read.
121+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
122+ h := windows.Handle(in.Fd())
123+ var mode uint32
124+ if windows.GetConsoleMode(h, &mode) != nil {
125+ var b [1]byte
126+ if _, err := in.Read(b[:]); err != nil {
127+ return 0, false, err
128+ }
129+ return b[0], true, nil
130+ }
131+ deadline := time.Now().Add(d)
132+ for {
133+ ready, err := consoleHasByte(h)
134+ if err != nil {
135+ return 0, false, err
136+ }
137+ if ready {
138+ var b [1]byte
139+ if _, err := in.Read(b[:]); err != nil {
140+ return 0, false, err
141+ }
142+ return b[0], true, nil
143+ }
144+ remaining := time.Until(deadline)
145+ if remaining <= 0 {
146+ return 0, false, nil
147+ }
148+ event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1)
149+ if err != nil {
150+ return 0, false, err
151+ }
152+ if event == uint32(windows.WAIT_TIMEOUT) {
153+ return 0, false, nil
154+ }
25155 }
26- return b[0], true, nil
27156 }
@@ -5,23 +5,152 @@ package tui
5 import (5 import (
6 "os"6 "os"
7 "time"7 "time"
8+ "unsafe"
9+
10+ "golang.org/x/sys/windows"
8 )11 )
9 12
10-// Windows doesn't have SIGWINCH; resize events are signaled via input13+// resizePollInterval bounds how long a size change goes unnoticed.
11-// records. For v1 we just ignore resize and rely on the next render14+const resizePollInterval = 100 * time.Millisecond
12-// cycle to pick up the new size via term.GetSize.15+
13-func (p *ProcTerm) installResizeHandler() {}16+// Windows has no SIGWINCH. Console resize events arrive as input records,
17+// but stdin is read as a VT byte stream, so those records never reach us.
18+// Poll the console size instead and fire the callbacks on every change.
19+// This matters most under ConPTY hosts such as multiplexers, where the
20+// pane width changes without any user input.
21+func (p *ProcTerm) installResizeHandler() {
22+ p.resizeOnce.Do(func() {
23+ go func() {
24+ lastW, lastH := p.Size()
25+ ticker := time.NewTicker(resizePollInterval)
26+ defer ticker.Stop()
27+ for range ticker.C {
28+ w, h := p.Size()
29+ if w == lastW && h == lastH {
30+ continue
31+ }
32+ lastW, lastH = w, h
33+ for _, cb := range p.resizeCBs {
34+ cb()
35+ }
36+ }
37+ }()
38+ })
39+}
14 40
15 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }41 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
16 42
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through43+var (
18-// to a blocking read. This means bare Esc on Windows is only detected44+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
19-// after the next keystroke. Good enough for v1.45+ procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW")
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {46+ procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW")
21- var b [1]byte47+ procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents")
22- _, err := in.Read(b[:])48+)
23- if err != nil {49+
24- return 0, false, err50+// inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only
51+// key events are decoded; the union is kept as raw bytes.
52+type inputRecord struct {
53+ eventType uint16
54+ _ uint16
55+ event [16]byte
56+}
57+
58+const keyEvent = 0x0001
59+
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.
63+func (r *inputRecord) yieldsByte() bool {
64+ if r.eventType != keyEvent {
65+ return false
66+ }
67+ keyDown := *(*int32)(unsafe.Pointer(&r.event[0]))
68+ if keyDown == 0 {
69+ return false
70+ }
71+ char := *(*uint16)(unsafe.Pointer(&r.event[10]))
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
80+}
81+
82+// consoleHasByte checks pending console input without consuming bytes.
83+// Leading records that would not produce a byte are discarded so the
84+// handle stops signalling for them; otherwise a lone focus event would
85+// make every wait return immediately while ReadFile still blocks.
86+func consoleHasByte(h windows.Handle) (bool, error) {
87+ for {
88+ var count uint32
89+ if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 {
90+ return false, err
91+ }
92+ if count == 0 {
93+ return false, nil
94+ }
95+ records := make([]inputRecord, count)
96+ var read uint32
97+ if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 {
98+ return false, err
99+ }
100+ skip := uint32(0)
101+ for skip < read && !records[skip].yieldsByte() {
102+ skip++
103+ }
104+ if skip < read {
105+ return true, nil
106+ }
107+ if skip == 0 {
108+ return false, nil
109+ }
110+ if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 {
111+ return false, err
112+ }
113+ }
114+}
115+
116+// peekStdin waits up to d for one byte of console input. It uses the
117+// console handle's wait state plus PeekConsoleInput, so a missing reply
118+// (for example an OSC 11 background query the host never answers) or a
119+// bare Esc times out instead of blocking until the next keystroke.
120+// Non-console stdin falls back to a blocking read.
121+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
122+ h := windows.Handle(in.Fd())
123+ var mode uint32
124+ if windows.GetConsoleMode(h, &mode) != nil {
125+ var b [1]byte
126+ if _, err := in.Read(b[:]); err != nil {
127+ return 0, false, err
128+ }
129+ return b[0], true, nil
130+ }
131+ deadline := time.Now().Add(d)
132+ for {
133+ ready, err := consoleHasByte(h)
134+ if err != nil {
135+ return 0, false, err
136+ }
137+ if ready {
138+ var b [1]byte
139+ if _, err := in.Read(b[:]); err != nil {
140+ return 0, false, err
141+ }
142+ return b[0], true, nil
143+ }
144+ remaining := time.Until(deadline)
145+ if remaining <= 0 {
146+ return 0, false, nil
147+ }
148+ event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1)
149+ if err != nil {
150+ return 0, false, err
151+ }
152+ if event == uint32(windows.WAIT_TIMEOUT) {
153+ return 0, false, nil
154+ }
25 }155 }
26- return b[0], true, nil
27 }156 }
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 (
44 "io"
55 "os"
66 "path/filepath"
7+ "sync"
78 "time"
89
910 "golang.org/x/term"
@@ -31,9 +32,10 @@ type Terminal interface {
3132
3233 // ProcTerm is a Terminal bound to the current process's tty.
3334 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.
3739 }
3840
3941 // 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.File35+ out *os.File
35- in *os.File36+ 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close