detached e80fc3b

Detect console resize and bare Esc on Windows

commit
e80fc3b215f44511bfce47499dca01b2e18dea6b
author
Maximilian Feil · · 2026-09-24 11:05 UTC
parent
6f6753c
signature
unsigned
1 file · +135 -13
M packages/tui/resize_windows.go +135 -13
@@ -4,24 +4,146 @@ package tui
44
55 import (
66 "os"
7+ "sync"
78 "time"
9+ "unsafe"
10+
11+ "golang.org/x/sys/windows"
812 )
913
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() {}
14+// resizePollInterval bounds how long a size change goes unnoticed.
15+const resizePollInterval = 100 * time.Millisecond
16+
17+var resizeWatcher sync.Once
18+
19+// 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.
21+// Poll the console size instead and fire the callbacks on every change.
22+// This matters most under ConPTY hosts such as multiplexers, where the
23+// pane width changes without any user input.
24+func (p *ProcTerm) installResizeHandler() {
25+ resizeWatcher.Do(func() {
26+ go func() {
27+ lastW, lastH := p.Size()
28+ ticker := time.NewTicker(resizePollInterval)
29+ defer ticker.Stop()
30+ for range ticker.C {
31+ w, h := p.Size()
32+ if w == lastW && h == lastH {
33+ continue
34+ }
35+ lastW, lastH = w, h
36+ for _, cb := range p.resizeCBs {
37+ cb()
38+ }
39+ }
40+ }()
41+ })
42+}
1443
1544 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
1645
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
46+var (
47+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
48+ procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW")
49+ procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW")
50+ procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents")
51+)
52+
53+// inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only
54+// key events are decoded; the union is kept as raw bytes.
55+type inputRecord struct {
56+ eventType uint16
57+ _ uint16
58+ event [16]byte
59+}
60+
61+const keyEvent = 0x0001
62+
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.
66+func (r *inputRecord) yieldsByte() bool {
67+ if r.eventType != keyEvent {
68+ return false
69+ }
70+ keyDown := *(*int32)(unsafe.Pointer(&r.event[0]))
71+ char := *(*uint16)(unsafe.Pointer(&r.event[10]))
72+ return keyDown != 0 && char != 0
73+}
74+
75+// consoleHasByte checks pending console input without consuming bytes.
76+// Leading records that would not produce a byte are discarded so the
77+// handle stops signalling for them; otherwise a lone focus event would
78+// make every wait return immediately while ReadFile still blocks.
79+func consoleHasByte(h windows.Handle) (bool, error) {
80+ for {
81+ var count uint32
82+ if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 {
83+ return false, err
84+ }
85+ if count == 0 {
86+ return false, nil
87+ }
88+ records := make([]inputRecord, count)
89+ var read uint32
90+ if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 {
91+ return false, err
92+ }
93+ skip := uint32(0)
94+ for skip < read && !records[skip].yieldsByte() {
95+ skip++
96+ }
97+ if skip < read {
98+ return true, nil
99+ }
100+ if skip == 0 {
101+ return false, nil
102+ }
103+ if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 {
104+ return false, err
105+ }
106+ }
107+}
108+
109+// peekStdin waits up to d for one byte of console input. It uses the
110+// console handle's wait state plus PeekConsoleInput, so a missing reply
111+// (for example an OSC 11 background query the host never answers) or a
112+// bare Esc times out instead of blocking until the next keystroke.
113+// Non-console stdin falls back to a blocking read.
114+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
115+ h := windows.Handle(in.Fd())
116+ var mode uint32
117+ if windows.GetConsoleMode(h, &mode) != nil {
118+ var b [1]byte
119+ if _, err := in.Read(b[:]); err != nil {
120+ return 0, false, err
121+ }
122+ return b[0], true, nil
123+ }
124+ deadline := time.Now().Add(d)
125+ for {
126+ ready, err := consoleHasByte(h)
127+ if err != nil {
128+ return 0, false, err
129+ }
130+ if ready {
131+ var b [1]byte
132+ if _, err := in.Read(b[:]); err != nil {
133+ return 0, false, err
134+ }
135+ return b[0], true, nil
136+ }
137+ remaining := time.Until(deadline)
138+ if remaining <= 0 {
139+ return 0, false, nil
140+ }
141+ event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1)
142+ if err != nil {
143+ return 0, false, err
144+ }
145+ if event == uint32(windows.WAIT_TIMEOUT) {
146+ return 0, false, nil
147+ }
25148 }
26- return b[0], true, nil
27149 }
@@ -4,24 +4,146 @@ package tui
4 4
5 import (5 import (
6 "os"6 "os"
7+ "sync"
7 "time"8 "time"
9+ "unsafe"
10+
11+ "golang.org/x/sys/windows"
8 )12 )
9 13
10-// Windows doesn't have SIGWINCH; resize events are signaled via input14+// resizePollInterval bounds how long a size change goes unnoticed.
11-// records. For v1 we just ignore resize and rely on the next render15+const resizePollInterval = 100 * time.Millisecond
12-// cycle to pick up the new size via term.GetSize.16+
13-func (p *ProcTerm) installResizeHandler() {}17+var resizeWatcher sync.Once
18+
19+// 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.
21+// Poll the console size instead and fire the callbacks on every change.
22+// This matters most under ConPTY hosts such as multiplexers, where the
23+// pane width changes without any user input.
24+func (p *ProcTerm) installResizeHandler() {
25+ resizeWatcher.Do(func() {
26+ go func() {
27+ lastW, lastH := p.Size()
28+ ticker := time.NewTicker(resizePollInterval)
29+ defer ticker.Stop()
30+ for range ticker.C {
31+ w, h := p.Size()
32+ if w == lastW && h == lastH {
33+ continue
34+ }
35+ lastW, lastH = w, h
36+ for _, cb := range p.resizeCBs {
37+ cb()
38+ }
39+ }
40+ }()
41+ })
42+}
14 43
15 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }44 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
16 45
17-// peekStdin on Windows: no non-blocking primitive in v1; fall through46+var (
18-// to a blocking read. This means bare Esc on Windows is only detected47+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
19-// after the next keystroke. Good enough for v1.48+ procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW")
20-func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) {49+ procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW")
21- var b [1]byte50+ procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents")
22- _, err := in.Read(b[:])51+)
23- if err != nil {52+
24- return 0, false, err53+// inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only
54+// key events are decoded; the union is kept as raw bytes.
55+type inputRecord struct {
56+ eventType uint16
57+ _ uint16
58+ event [16]byte
59+}
60+
61+const keyEvent = 0x0001
62+
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.
66+func (r *inputRecord) yieldsByte() bool {
67+ if r.eventType != keyEvent {
68+ return false
69+ }
70+ keyDown := *(*int32)(unsafe.Pointer(&r.event[0]))
71+ char := *(*uint16)(unsafe.Pointer(&r.event[10]))
72+ return keyDown != 0 && char != 0
73+}
74+
75+// consoleHasByte checks pending console input without consuming bytes.
76+// Leading records that would not produce a byte are discarded so the
77+// handle stops signalling for them; otherwise a lone focus event would
78+// make every wait return immediately while ReadFile still blocks.
79+func consoleHasByte(h windows.Handle) (bool, error) {
80+ for {
81+ var count uint32
82+ if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 {
83+ return false, err
84+ }
85+ if count == 0 {
86+ return false, nil
87+ }
88+ records := make([]inputRecord, count)
89+ var read uint32
90+ if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 {
91+ return false, err
92+ }
93+ skip := uint32(0)
94+ for skip < read && !records[skip].yieldsByte() {
95+ skip++
96+ }
97+ if skip < read {
98+ return true, nil
99+ }
100+ if skip == 0 {
101+ return false, nil
102+ }
103+ if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 {
104+ return false, err
105+ }
106+ }
107+}
108+
109+// peekStdin waits up to d for one byte of console input. It uses the
110+// console handle's wait state plus PeekConsoleInput, so a missing reply
111+// (for example an OSC 11 background query the host never answers) or a
112+// bare Esc times out instead of blocking until the next keystroke.
113+// Non-console stdin falls back to a blocking read.
114+func peekStdin(in *os.File, d time.Duration) (byte, bool, error) {
115+ h := windows.Handle(in.Fd())
116+ var mode uint32
117+ if windows.GetConsoleMode(h, &mode) != nil {
118+ var b [1]byte
119+ if _, err := in.Read(b[:]); err != nil {
120+ return 0, false, err
121+ }
122+ return b[0], true, nil
123+ }
124+ deadline := time.Now().Add(d)
125+ for {
126+ ready, err := consoleHasByte(h)
127+ if err != nil {
128+ return 0, false, err
129+ }
130+ if ready {
131+ var b [1]byte
132+ if _, err := in.Read(b[:]); err != nil {
133+ return 0, false, err
134+ }
135+ return b[0], true, nil
136+ }
137+ remaining := time.Until(deadline)
138+ if remaining <= 0 {
139+ return 0, false, nil
140+ }
141+ event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1)
142+ if err != nil {
143+ return 0, false, err
144+ }
145+ if event == uint32(windows.WAIT_TIMEOUT) {
146+ return 0, false, nil
147+ }
25 }148 }
26- return b[0], true, nil
27 }149 }

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