detached 6209370

fix(screen): restore charsets only on 1049 and end REP after fragmented Kitty APCs

commit
62093707819230e52a827c950cefcb12d3295eab
author
brice · · 2026-10-15 02:38 UTC
parent
1589bdf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +23 -5
M screen.go +3 -0
@@ -184,6 +184,8 @@ func (s *Screen) Write(data []byte) {
184184 if len(s.escapeBuf) == 0 {
185185 s.trimEscapeBuffers()
186186 } else if isKittyEscapeContinuation(s.escapeBuf, data) {
187+ // A Kitty APC sits between any earlier graphic and what follows.
188+ s.lastPrinted = 0
187189 prefix := s.escapeBuf
188190 s.escapeBuf = nil
189191 data = s.continueKittyEscape(prefix, data)
@@ -218,6 +220,7 @@ func (s *Screen) Write(data []byte) {
218220 }
219221 }
220222 if s.kittyDiscard {
223+ s.lastPrinted = 0
221224 consumed := s.consumeKittyDiscard(data)
222225 data = data[consumed:]
223226 if s.kittyDiscard {
@@ -184,6 +184,8 @@ func (s *Screen) Write(data []byte) {
184 if len(s.escapeBuf) == 0 {184 if len(s.escapeBuf) == 0 {
185 s.trimEscapeBuffers()185 s.trimEscapeBuffers()
186 } else if isKittyEscapeContinuation(s.escapeBuf, data) {186 } else if isKittyEscapeContinuation(s.escapeBuf, data) {
187+ // A Kitty APC sits between any earlier graphic and what follows.
188+ s.lastPrinted = 0
187 prefix := s.escapeBuf189 prefix := s.escapeBuf
188 s.escapeBuf = nil190 s.escapeBuf = nil
189 data = s.continueKittyEscape(prefix, data)191 data = s.continueKittyEscape(prefix, data)
@@ -218,6 +220,7 @@ func (s *Screen) Write(data []byte) {
218 }220 }
219 }221 }
220 if s.kittyDiscard {222 if s.kittyDiscard {
223+ s.lastPrinted = 0
221 consumed := s.consumeKittyDiscard(data)224 consumed := s.consumeKittyDiscard(data)
222 data = data[consumed:]225 data = data[consumed:]
223 if s.kittyDiscard {226 if s.kittyDiscard {
M screen_charset_test.go +13 -1
@@ -25,7 +25,9 @@ func TestCharsetsAndRepeat(t *testing.T) {
2525 {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"},
2626 {name: "consecutive REP repeats once", input: "a\x1b[b\x1b[b", want: "aa"},
2727 {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: "─"},
28+ {name: "leaving 1049 restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"},
29+ {name: "leaving 47 keeps current charsets", input: "\x1b(0\x1b[?47h\x1b(B\x1b[?47lq", want: "q"},
30+ {name: "leaving 1047 keeps current charsets", input: "\x1b(0\x1b[?1047h\x1b(B\x1b[?1047lq", want: "q"},
2931 {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"},
3032 {name: "partial charset escape across writes", input: "\x1b(", want: ""},
3133 }
@@ -66,6 +68,16 @@ func TestRepeatSplitAcrossWrites(t *testing.T) {
6668 }
6769 }
6870
71+func TestRepeatAfterFragmentedKittyAPCIsIgnored(t *testing.T) {
72+ s := NewScreen(10, 1)
73+ s.Write([]byte("a\x1b_G"))
74+ s.Write([]byte("a=q,i=1;\x1b\\"))
75+ s.Write([]byte("\x1b[2b"))
76+ if got := strings.TrimRight(lineText(s, 0), " "); got != "a" {
77+ t.Fatalf("line = %q, want %q", got, "a")
78+ }
79+}
80+
6981 func TestRepeatIsBounded(t *testing.T) {
7082 s := NewScreen(4, 2)
7183 s.Write([]byte("x\x1b[999999999b"))
@@ -25,7 +25,9 @@ func TestCharsetsAndRepeat(t *testing.T) {
25 {name: "REP after another sequence is ignored", input: "a\x1b[1m\x1b[2b", want: "a"},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"},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: "─"},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: "─"},28+ {name: "leaving 1049 restores charsets", input: "\x1b(0\x1b[?1049h\x1b(B\x1b[?1049lq", want: "─"},
29+ {name: "leaving 47 keeps current charsets", input: "\x1b(0\x1b[?47h\x1b(B\x1b[?47lq", want: "q"},
30+ {name: "leaving 1047 keeps current charsets", input: "\x1b(0\x1b[?1047h\x1b(B\x1b[?1047lq", want: "q"},
29 {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"},31 {name: "DECSTR saved cursor restores home", input: "\x1b[1;5Hx\x1b[!p\x1b8y", want: "y x"},
30 {name: "partial charset escape across writes", input: "\x1b(", want: ""},32 {name: "partial charset escape across writes", input: "\x1b(", want: ""},
31 }33 }
@@ -66,6 +68,16 @@ func TestRepeatSplitAcrossWrites(t *testing.T) {
66 }68 }
67 }69 }
68 70
71+func TestRepeatAfterFragmentedKittyAPCIsIgnored(t *testing.T) {
72+ s := NewScreen(10, 1)
73+ s.Write([]byte("a\x1b_G"))
74+ s.Write([]byte("a=q,i=1;\x1b\\"))
75+ s.Write([]byte("\x1b[2b"))
76+ if got := strings.TrimRight(lineText(s, 0), " "); got != "a" {
77+ t.Fatalf("line = %q, want %q", got, "a")
78+ }
79+}
80+
69 func TestRepeatIsBounded(t *testing.T) {81 func TestRepeatIsBounded(t *testing.T) {
70 s := NewScreen(4, 2)82 s := NewScreen(4, 2)
71 s.Write([]byte("x\x1b[999999999b"))83 s.Write([]byte("x\x1b[999999999b"))
M screen_modes.go +7 -4
@@ -211,7 +211,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
211211 if enabled {
212212 s.enterAlternateScreen()
213213 } else {
214- s.exitAlternateScreen()
214+ // Only 1049 saves and restores cursor state, which includes charsets.
215+ s.exitAlternateScreen(mode == 1049)
215216 }
216217 case 2026:
217218 s.syncUpdateActive = enabled
@@ -269,7 +270,7 @@ func (s *Screen) enterAlternateScreen() {
269270 s.fullRedraw()
270271 }
271272
272-func (s *Screen) exitAlternateScreen() {
273+func (s *Screen) exitAlternateScreen(restoreCharsets bool) {
273274 if s.alternate == nil {
274275 return
275276 }
@@ -287,8 +288,10 @@ func (s *Screen) exitAlternateScreen() {
287288 s.originMode = state.originMode
288289 s.insertMode = state.insertMode
289290 s.kittyKeyboard = state.kittyKeyboard
290- s.charsets = state.charsets
291- s.shiftOut = state.shiftOut
291+ if restoreCharsets {
292+ s.charsets = state.charsets
293+ s.shiftOut = state.shiftOut
294+ }
292295 s.alternate = nil
293296 s.fullRedraw()
294297 }
@@ -211,7 +211,8 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
211 if enabled {211 if enabled {
212 s.enterAlternateScreen()212 s.enterAlternateScreen()
213 } else {213 } else {
214- s.exitAlternateScreen()214+ // Only 1049 saves and restores cursor state, which includes charsets.
215+ s.exitAlternateScreen(mode == 1049)
215 }216 }
216 case 2026:217 case 2026:
217 s.syncUpdateActive = enabled218 s.syncUpdateActive = enabled
@@ -269,7 +270,7 @@ func (s *Screen) enterAlternateScreen() {
269 s.fullRedraw()270 s.fullRedraw()
270 }271 }
271 272
272-func (s *Screen) exitAlternateScreen() {273+func (s *Screen) exitAlternateScreen(restoreCharsets bool) {
273 if s.alternate == nil {274 if s.alternate == nil {
274 return275 return
275 }276 }
@@ -287,8 +288,10 @@ func (s *Screen) exitAlternateScreen() {
287 s.originMode = state.originMode288 s.originMode = state.originMode
288 s.insertMode = state.insertMode289 s.insertMode = state.insertMode
289 s.kittyKeyboard = state.kittyKeyboard290 s.kittyKeyboard = state.kittyKeyboard
290- s.charsets = state.charsets291+ if restoreCharsets {
291- s.shiftOut = state.shiftOut292+ s.charsets = state.charsets
293+ s.shiftOut = state.shiftOut
294+ }
292 s.alternate = nil295 s.alternate = nil
293 s.fullRedraw()296 s.fullRedraw()
294 }297 }

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