detached ecdef42

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
44
55 import (
66 "os"
7- "sync"
87 "time"
98 "unsafe"
109
@@ -14,15 +13,13 @@ import (
1413 // resizePollInterval bounds how long a size change goes unnoticed.
1514 const resizePollInterval = 100 * time.Millisecond
1615
17-var resizeWatcher sync.Once
18-
1916 // Windows has no SIGWINCH. Console resize events arrive as input records,
2017 // but stdin is read as a VT byte stream, so those records never reach us.
2118 // Poll the console size instead and fire the callbacks on every change.
2219 // This matters most under ConPTY hosts such as multiplexers, where the
2320 // pane width changes without any user input.
2421 func (p *ProcTerm) installResizeHandler() {
25- resizeWatcher.Do(func() {
22+ p.resizeOnce.Do(func() {
2623 go func() {
2724 lastW, lastH := p.Size()
2825 ticker := time.NewTicker(resizePollInterval)
@@ -60,16 +57,26 @@ type inputRecord struct {
6057
6158 const keyEvent = 0x0001
6259
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.
6663 func (r *inputRecord) yieldsByte() bool {
6764 if r.eventType != keyEvent {
6865 return false
6966 }
7067 keyDown := *(*int32)(unsafe.Pointer(&r.event[0]))
68+ if keyDown == 0 {
69+ return false
70+ }
7171 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
7380 }
7481
7582 // 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.Millisecond14 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 the19 // 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 = 0x000158 const keyEvent = 0x0001
62 59
63-// yieldsByte reports whether a console record turns into at least one60+// 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 false65 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 != 072+ 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 (
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