detached 1589bdf

fix(screen): match xterm for REP eligibility, DECSTR saved cursor and alt-screen charsets

commit
1589bdfe764f0504fefae7e8ff4849af0d25186c
author
brice · · 2026-10-15 02:37 UTC
parent
a3aae45
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +41 -3
M screen.go +2 -0
@@ -229,6 +229,8 @@ func (s *Screen) Write(data []byte) {
229229 if data[0] == 0x1b {
230230 consumed, partial := s.consumeEscape(data)
231231 if consumed > 0 {
232+ // A completed sequence (including REP itself) ends REP eligibility.
233+ s.lastPrinted = 0
232234 data = data[consumed:]
233235 continue
234236 }
@@ -229,6 +229,8 @@ func (s *Screen) Write(data []byte) {
229 if data[0] == 0x1b {229 if data[0] == 0x1b {
230 consumed, partial := s.consumeEscape(data)230 consumed, partial := s.consumeEscape(data)
231 if consumed > 0 {231 if consumed > 0 {
232+ // A completed sequence (including REP itself) ends REP eligibility.
233+ s.lastPrinted = 0
232 data = data[consumed:]234 data = data[consumed:]
233 continue235 continue
234 }236 }
M screen_charset_test.go +23 -1
@@ -21,7 +21,12 @@ func TestCharsetsAndRepeat(t *testing.T) {
2121 {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"},
2222 {name: "REP defaults to one", input: "a\x1b[b", want: "aa"},
2323 {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"},
24- {name: "REP after control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"},
24+ {name: "REP after a control is ignored", input: "ab\r\x1b[2b", want: "ab"},
25+ {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"},
26+ {name: "consecutive REP repeats once", input: "a\x1b[b\x1b[b", want: "aa"},
27+ {name: "DECSC and DECRC restore SO invocation", input: "\x1b)0\x0e\x1b7\x0f\x1b8q", want: "─"},
28+ {name: "leaving the alternate screen restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"},
29+ {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"},
2530 {name: "partial charset escape across writes", input: "\x1b(", want: ""},
2631 }
2732 for _, tt := range tests {
@@ -44,6 +49,23 @@ func TestCharsetDesignationSplitAcrossWrites(t *testing.T) {
4449 }
4550 }
4651
52+func TestSoftResetCursorAppearance(t *testing.T) {
53+ s := NewScreen(4, 1)
54+ s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p"))
55+ if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible {
56+ t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible)
57+ }
58+}
59+
60+func TestRepeatSplitAcrossWrites(t *testing.T) {
61+ s := NewScreen(10, 1)
62+ s.Write([]byte("a\x1b["))
63+ s.Write([]byte("2b"))
64+ if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" {
65+ t.Fatalf("line = %q, want %q", got, "aaa")
66+ }
67+}
68+
4769 func TestRepeatIsBounded(t *testing.T) {
4870 s := NewScreen(4, 2)
4971 s.Write([]byte("x\x1b[999999999b"))
@@ -21,7 +21,12 @@ func TestCharsetsAndRepeat(t *testing.T) {
21 {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"},21 {name: "REP repeats the last graphic char", input: "\x1b(0q\x1b[4b", want: "─────"},
22 {name: "REP defaults to one", input: "a\x1b[b", want: "aa"},22 {name: "REP defaults to one", input: "a\x1b[b", want: "aa"},
23 {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"},23 {name: "REP without a prior char is ignored", input: "\x1b[3bx", want: "x"},
24- {name: "REP after control repeats last graphic", input: "ab\r\x1b[2b", want: "bb"},24+ {name: "REP after a control is ignored", input: "ab\r\x1b[2b", want: "ab"},
25+ {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"},
26+ {name: "consecutive REP repeats once", input: "a\x1b[b\x1b[b", want: "aa"},
27+ {name: "DECSC and DECRC restore SO invocation", input: "\x1b)0\x0e\x1b7\x0f\x1b8q", want: "─"},
28+ {name: "leaving the alternate screen restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"},
29+ {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"},
25 {name: "partial charset escape across writes", input: "\x1b(", want: ""},30 {name: "partial charset escape across writes", input: "\x1b(", want: ""},
26 }31 }
27 for _, tt := range tests {32 for _, tt := range tests {
@@ -44,6 +49,23 @@ func TestCharsetDesignationSplitAcrossWrites(t *testing.T) {
44 }49 }
45 }50 }
46 51
52+func TestSoftResetCursorAppearance(t *testing.T) {
53+ s := NewScreen(4, 1)
54+ s.Write([]byte("\x1b[6 q\x1b[?25l\x1b[!p"))
55+ if s.cursorStyleSet || s.cursorStyle != 0 || !s.cursorVisible {
56+ t.Fatalf("cursor style=%d set=%v visible=%v, want defaults", s.cursorStyle, s.cursorStyleSet, s.cursorVisible)
57+ }
58+}
59+
60+func TestRepeatSplitAcrossWrites(t *testing.T) {
61+ s := NewScreen(10, 1)
62+ s.Write([]byte("a\x1b["))
63+ s.Write([]byte("2b"))
64+ if got := strings.TrimRight(lineText(s, 0), " "); got != "aaa" {
65+ t.Fatalf("line = %q, want %q", got, "aaa")
66+ }
67+}
68+
47 func TestRepeatIsBounded(t *testing.T) {69 func TestRepeatIsBounded(t *testing.T) {
48 s := NewScreen(4, 2)70 s := NewScreen(4, 2)
49 s.Write([]byte("x\x1b[999999999b"))71 s.Write([]byte("x\x1b[999999999b"))
M screen_modes.go +12 -2
@@ -18,6 +18,8 @@ type screenState struct {
1818 originMode bool
1919 insertMode bool
2020 kittyKeyboard kittyKeyboardStack
21+ charsets [2]byte
22+ shiftOut bool
2123 }
2224
2325 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -129,17 +131,21 @@ func (s *Screen) reset() {
129131 }
130132
131133 // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins,
132-// and character sets without touching screen contents or the cursor position.
134+// cursor appearance, and character sets without touching screen contents or
135+// the cursor position. Like xterm, the saved cursor becomes home with the
136+// reset state.
133137 func (s *Screen) softReset() {
134138 s.Style = renderer.DefaultStyle()
135- s.savedCursor = cursorState{}
136139 s.originMode = false
137140 s.insertMode = false
138141 s.autoWrapMode = true
139142 s.applicationCursorMode = false
140143 s.cursorVisible = true
144+ s.cursorStyle = 0
145+ s.cursorStyleSet = false
141146 s.charsets = [2]byte{}
142147 s.shiftOut = false
148+ s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true}
143149 s.resetScrollRegion()
144150 }
145151
@@ -246,6 +252,8 @@ func (s *Screen) enterAlternateScreen() {
246252 originMode: s.originMode,
247253 insertMode: s.insertMode,
248254 kittyKeyboard: s.kittyKeyboard,
255+ charsets: s.charsets,
256+ shiftOut: s.shiftOut,
249257 }
250258 // The main and alternate screens keep independent keyboard stacks.
251259 s.kittyKeyboard = nil
@@ -279,6 +287,8 @@ func (s *Screen) exitAlternateScreen() {
279287 s.originMode = state.originMode
280288 s.insertMode = state.insertMode
281289 s.kittyKeyboard = state.kittyKeyboard
290+ s.charsets = state.charsets
291+ s.shiftOut = state.shiftOut
282292 s.alternate = nil
283293 s.fullRedraw()
284294 }
@@ -18,6 +18,8 @@ type screenState struct {
18 originMode bool18 originMode bool
19 insertMode bool19 insertMode bool
20 kittyKeyboard kittyKeyboardStack20 kittyKeyboard kittyKeyboardStack
21+ charsets [2]byte
22+ shiftOut bool
21 }23 }
22 24
23 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)25 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
@@ -129,17 +131,21 @@ func (s *Screen) reset() {
129 }131 }
130 132
131 // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins,133 // softReset implements DECSTR (CSI ! p): it resets modes, rendition, margins,
132-// and character sets without touching screen contents or the cursor position.134+// cursor appearance, and character sets without touching screen contents or
135+// the cursor position. Like xterm, the saved cursor becomes home with the
136+// reset state.
133 func (s *Screen) softReset() {137 func (s *Screen) softReset() {
134 s.Style = renderer.DefaultStyle()138 s.Style = renderer.DefaultStyle()
135- s.savedCursor = cursorState{}
136 s.originMode = false139 s.originMode = false
137 s.insertMode = false140 s.insertMode = false
138 s.autoWrapMode = true141 s.autoWrapMode = true
139 s.applicationCursorMode = false142 s.applicationCursorMode = false
140 s.cursorVisible = true143 s.cursorVisible = true
144+ s.cursorStyle = 0
145+ s.cursorStyleSet = false
141 s.charsets = [2]byte{}146 s.charsets = [2]byte{}
142 s.shiftOut = false147 s.shiftOut = false
148+ s.savedCursor = cursorState{style: s.Style, autoWrapMode: true, saved: true}
143 s.resetScrollRegion()149 s.resetScrollRegion()
144 }150 }
145 151
@@ -246,6 +252,8 @@ func (s *Screen) enterAlternateScreen() {
246 originMode: s.originMode,252 originMode: s.originMode,
247 insertMode: s.insertMode,253 insertMode: s.insertMode,
248 kittyKeyboard: s.kittyKeyboard,254 kittyKeyboard: s.kittyKeyboard,
255+ charsets: s.charsets,
256+ shiftOut: s.shiftOut,
249 }257 }
250 // The main and alternate screens keep independent keyboard stacks.258 // The main and alternate screens keep independent keyboard stacks.
251 s.kittyKeyboard = nil259 s.kittyKeyboard = nil
@@ -279,6 +287,8 @@ func (s *Screen) exitAlternateScreen() {
279 s.originMode = state.originMode287 s.originMode = state.originMode
280 s.insertMode = state.insertMode288 s.insertMode = state.insertMode
281 s.kittyKeyboard = state.kittyKeyboard289 s.kittyKeyboard = state.kittyKeyboard
290+ s.charsets = state.charsets
291+ s.shiftOut = state.shiftOut
282 s.alternate = nil292 s.alternate = nil
283 s.fullRedraw()293 s.fullRedraw()
284 }294 }
M screen_write.go +4 -0
@@ -3,6 +3,10 @@ package vt
33 import renderer "github.com/bnema/vev-vt/core"
44
55 func (s *Screen) putRune(r rune) {
6+ if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
7+ // REP only repeats a graphic character that directly precedes it.
8+ s.lastPrinted = 0
9+ }
610 switch r {
711 case '\a':
812 if s.OnBell != nil {
@@ -3,6 +3,10 @@ package vt
3 import renderer "github.com/bnema/vev-vt/core"3 import renderer "github.com/bnema/vev-vt/core"
4 4
5 func (s *Screen) putRune(r rune) {5 func (s *Screen) putRune(r rune) {
6+ if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
7+ // REP only repeats a graphic character that directly precedes it.
8+ s.lastPrinted = 0
9+ }
6 switch r {10 switch r {
7 case '\a':11 case '\a':
8 if s.OnBell != nil {12 if s.OnBell != nil {

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 2

Tags 13

↑↓ moveenter openesc close