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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 84 | 84 | defaultFG renderer.RGB | |
| 85 | 85 | defaultBG renderer.RGB | |
| 86 | 86 | defaultColorsKnown bool | |
| 87 | + | terminalTitle string | |
| 87 | 88 | ||
| 88 | 89 | damage []renderer.Damage | |
| 89 | 90 | escapeBuf []byte | |
| @@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle | |||
| 218 | 219 | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |
| 219 | 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 | 225 | func (s *Screen) Write(data []byte) { | |
| 222 | 226 | if len(s.escapeBuf) > 0 { | |
| 223 | 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 | 741 | // 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. | |
| 741 | 745 | func (s *Screen) handleOSC(payload []byte) { | |
| 742 | 746 | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | |
| 743 | 747 | s.handleOSC52(string(payload[len("52;"):])) | |
| 744 | 748 | return | |
| 745 | 749 | } | |
| 746 | 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 | 755 | if p == "9;4" || strings.HasPrefix(p, "9;4;") { | |
| 748 | 756 | s.handleProgress(p[len("9;4"):]) | |
| 749 | 757 | return | |
| @@ -84,6 +84,7 @@ type Screen struct { | |||||
| 84 | defaultFG renderer.RGB | 84 | defaultFG renderer.RGB | ||
| 85 | defaultBG renderer.RGB | 85 | defaultBG renderer.RGB | ||
| 86 | defaultColorsKnown bool | 86 | defaultColorsKnown bool | ||
| 87 | + | terminalTitle string | |||
| 87 | 88 | ||||
| 88 | damage []renderer.Damage | 89 | damage []renderer.Damage | ||
| 89 | escapeBuf []byte | 90 | 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 its | 741 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | ||
| 738 | - | // terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard | 742 | + | // terminator). Terminal titles, notification sequences (OSC 9, OSC 777 |
| 739 | - | // set requests (OSC 52) are acted on; titles and every other OSC are still | 743 | + | // "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 | return | 748 | 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 | return | 757 | return | ||
M pkg/vt/screen_test.go +64 -0
| @@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |||
| 185 | 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 | 252 | func TestOnNotifyOSC9(t *testing.T) { | |
| 189 | 253 | s := NewScreen(10, 2) | |
| 190 | 254 | 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 string | 254 | var gotTitle, gotBody string | ||