detached c1b2985

feat(vt): retain terminal titles

commit
c1b29854891c95b4ffaeed622e7d30a458206355
author
brice · · 2026-07-09 23:21 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
aa3698c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +75 -3
M pkg/vt/screen.go +11 -3
@@ -84,6 +84,7 @@ type Screen struct {
8484 defaultFG renderer.RGB
8585 defaultBG renderer.RGB
8686 defaultColorsKnown bool
87+ terminalTitle string
8788
8889 damage []renderer.Damage
8990 escapeBuf []byte
@@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle
218219 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
219220 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
220221
222+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
223+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
224+
221225 func (s *Screen) Write(data []byte) {
222226 if len(s.escapeBuf) > 0 {
223227 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -735,15 +739,19 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
735739 }
736740
737741 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
738-// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
739-// set requests (OSC 52) are acted on; titles and every other OSC are still
740-// discarded.
742+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
743+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
744+// is discarded.
741745 func (s *Screen) handleOSC(payload []byte) {
742746 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
743747 s.handleOSC52(string(payload[len("52;"):]))
744748 return
745749 }
746750 p := string(payload)
751+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
752+ s.terminalTitle = title
753+ return
754+ }
747755 if p == "9;4" || strings.HasPrefix(p, "9;4;") {
748756 s.handleProgress(p[len("9;4"):])
749757 return
@@ -84,6 +84,7 @@ type Screen struct {
84 defaultFG renderer.RGB84 defaultFG renderer.RGB
85 defaultBG renderer.RGB85 defaultBG renderer.RGB
86 defaultColorsKnown bool86 defaultColorsKnown bool
87+ terminalTitle string
87 88
88 damage []renderer.Damage89 damage []renderer.Damage
89 escapeBuf []byte90 escapeBuf []byte
@@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle
218 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }219 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
219 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }220 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
220 221
222+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
223+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
224+
221 func (s *Screen) Write(data []byte) {225 func (s *Screen) Write(data []byte) {
222 if len(s.escapeBuf) > 0 {226 if len(s.escapeBuf) > 0 {
223 combined := make([]byte, 0, len(s.escapeBuf)+len(data))227 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -735,15 +739,19 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
735 }739 }
736 740
737 // handleOSC inspects a complete OSC payload (between "ESC ]" and its741 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
738-// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard742+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
739-// set requests (OSC 52) are acted on; titles and every other OSC are still743+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
740-// discarded.744+// is discarded.
741 func (s *Screen) handleOSC(payload []byte) {745 func (s *Screen) handleOSC(payload []byte) {
742 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {746 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
743 s.handleOSC52(string(payload[len("52;"):]))747 s.handleOSC52(string(payload[len("52;"):]))
744 return748 return
745 }749 }
746 p := string(payload)750 p := string(payload)
751+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
752+ s.terminalTitle = title
753+ return
754+ }
747 if p == "9;4" || strings.HasPrefix(p, "9;4;") {755 if p == "9;4" || strings.HasPrefix(p, "9;4;") {
748 s.handleProgress(p[len("9;4"):])756 s.handleProgress(p[len("9;4"):])
749 return757 return
M pkg/vt/screen_test.go +64 -0
@@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
185185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
186186 }
187187
188+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
189+ tests := []struct {
190+ name string
191+ seq string
192+ want string
193+ }{
194+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
195+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
196+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
197+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
198+ }
199+
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ s := NewScreen(10, 2)
203+ bells := 0
204+ s.OnBell = func() { bells++ }
205+ s.ClearDamage()
206+
207+ s.Write([]byte(tt.seq))
208+
209+ require.Equal(t, tt.want, s.TerminalTitle())
210+ require.Equal(t, 0, bells)
211+ require.Empty(t, s.Damage())
212+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
213+ })
214+ }
215+}
216+
217+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
218+ s := NewScreen(10, 2)
219+
220+ s.Write([]byte("\x1b]0;split"))
221+ s.Write([]byte(" title\x1b"))
222+ require.Empty(t, s.TerminalTitle())
223+ s.Write([]byte("\\"))
224+ require.Equal(t, "split title", s.TerminalTitle())
225+
226+ s.Write([]byte("\x1b]2;replacement\x07"))
227+ require.Equal(t, "replacement", s.TerminalTitle())
228+
229+ s.Write([]byte("\x1b]0;\x07"))
230+ require.Empty(t, s.TerminalTitle())
231+}
232+
233+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
234+ s := NewScreen(10, 2)
235+ s.Write([]byte("\x1b]0;retained\x07"))
236+
237+ for _, seq := range []string{
238+ "\x1b]1;icon title\x07",
239+ "\x1b]20;not selector two\x1b\\",
240+ "\x1b]777;other;x;y\x07",
241+ } {
242+ s.Write([]byte(seq))
243+ require.Equal(t, "retained", s.TerminalTitle())
244+ }
245+
246+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
247+ s.Write([]byte("\x1b]2;" + payload))
248+ require.Equal(t, "retained", s.TerminalTitle())
249+ require.Empty(t, s.escapeBuf)
250+}
251+
188252 func TestOnNotifyOSC9(t *testing.T) {
189253 s := NewScreen(10, 2)
190254 var gotTitle, gotBody string
@@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
186 }186 }
187 187
188+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
189+ tests := []struct {
190+ name string
191+ seq string
192+ want string
193+ }{
194+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
195+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
196+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
197+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
198+ }
199+
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ s := NewScreen(10, 2)
203+ bells := 0
204+ s.OnBell = func() { bells++ }
205+ s.ClearDamage()
206+
207+ s.Write([]byte(tt.seq))
208+
209+ require.Equal(t, tt.want, s.TerminalTitle())
210+ require.Equal(t, 0, bells)
211+ require.Empty(t, s.Damage())
212+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
213+ })
214+ }
215+}
216+
217+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
218+ s := NewScreen(10, 2)
219+
220+ s.Write([]byte("\x1b]0;split"))
221+ s.Write([]byte(" title\x1b"))
222+ require.Empty(t, s.TerminalTitle())
223+ s.Write([]byte("\\"))
224+ require.Equal(t, "split title", s.TerminalTitle())
225+
226+ s.Write([]byte("\x1b]2;replacement\x07"))
227+ require.Equal(t, "replacement", s.TerminalTitle())
228+
229+ s.Write([]byte("\x1b]0;\x07"))
230+ require.Empty(t, s.TerminalTitle())
231+}
232+
233+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
234+ s := NewScreen(10, 2)
235+ s.Write([]byte("\x1b]0;retained\x07"))
236+
237+ for _, seq := range []string{
238+ "\x1b]1;icon title\x07",
239+ "\x1b]20;not selector two\x1b\\",
240+ "\x1b]777;other;x;y\x07",
241+ } {
242+ s.Write([]byte(seq))
243+ require.Equal(t, "retained", s.TerminalTitle())
244+ }
245+
246+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
247+ s.Write([]byte("\x1b]2;" + payload))
248+ require.Equal(t, "retained", s.TerminalTitle())
249+ require.Empty(t, s.escapeBuf)
250+}
251+
188 func TestOnNotifyOSC9(t *testing.T) {252 func TestOnNotifyOSC9(t *testing.T) {
189 s := NewScreen(10, 2)253 s := NewScreen(10, 2)
190 var gotTitle, gotBody string254 var gotTitle, gotBody string

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 12

Tags 11

↑↓ moveenter openesc close