feat(vt): report terminal attention signals
- commit
- 37efbea958b35c542324daa8747d8aa67b61af8c
- author
- brice · · 2026-07-03 14:00 UTC
- committer
- brice · · 2026-07-03 14:57 UTC
- parent
- 88e5870
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +119 -1
M pkg/vt/screen.go +43 -1
| @@ -39,6 +39,14 @@ type Screen struct { | |||
| 39 | 39 | // emulator must send back to the child process (DA, DSR, DECRQM reports). | |
| 40 | 40 | // The host wires it to the PTY input. Nil disables responses. | |
| 41 | 41 | OnResponse func([]byte) | |
| 42 | + | // OnBell is called synchronously from Write for each lone BEL (0x07) | |
| 43 | + | // outside escape sequences. BELs that terminate an OSC never fire it. | |
| 44 | + | // Nil disables bell reporting. | |
| 45 | + | OnBell func() | |
| 46 | + | // OnNotify is called synchronously from Write for explicit terminal | |
| 47 | + | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |
| 48 | + | // All other OSC payloads remain discarded. Nil disables it. | |
| 49 | + | OnNotify func(title, body string) | |
| 42 | 50 | ||
| 43 | 51 | defaultFG renderer.RGB | |
| 44 | 52 | defaultBG renderer.RGB | |
| @@ -178,7 +186,12 @@ func (s *Screen) Write(data []byte) { | |||
| 178 | 186 | ||
| 179 | 187 | func (s *Screen) putRune(r rune) { | |
| 180 | 188 | switch r { | |
| 181 | - | case '\a', 0x00, 0x0e, 0x0f, 0x7f: | |
| 189 | + | case '\a': | |
| 190 | + | if s.OnBell != nil { | |
| 191 | + | s.OnBell() | |
| 192 | + | } | |
| 193 | + | return | |
| 194 | + | case 0x00, 0x0e, 0x0f, 0x7f: | |
| 182 | 195 | return | |
| 183 | 196 | case '\r': | |
| 184 | 197 | s.Col = 0 | |
| @@ -559,10 +572,12 @@ func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) { | |||
| 559 | 572 | switch data[i] { | |
| 560 | 573 | case 0x07: | |
| 561 | 574 | s.applyOSC(data[2:i], []byte{0x07}) | |
| 575 | + | s.handleOSC(data[2:i]) | |
| 562 | 576 | return i + 1, false | |
| 563 | 577 | case 0x1b: | |
| 564 | 578 | if i+1 < len(data) && data[i+1] == '\\' { | |
| 565 | 579 | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | |
| 580 | + | s.handleOSC(data[2:i]) | |
| 566 | 581 | return i + 2, false | |
| 567 | 582 | } | |
| 568 | 583 | } | |
| @@ -604,6 +619,33 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 604 | 619 | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | |
| 605 | 620 | } | |
| 606 | 621 | ||
| 622 | + | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | |
| 623 | + | // terminator). Only notification sequences are acted on; titles, clipboard | |
| 624 | + | // and every other OSC are still discarded. | |
| 625 | + | func (s *Screen) handleOSC(payload []byte) { | |
| 626 | + | if s.OnNotify == nil { | |
| 627 | + | return | |
| 628 | + | } | |
| 629 | + | p := string(payload) | |
| 630 | + | switch { | |
| 631 | + | case strings.HasPrefix(p, "9;"): | |
| 632 | + | s.OnNotify("", p[len("9;"):]) | |
| 633 | + | case strings.HasPrefix(p, "777;"): | |
| 634 | + | parts := strings.SplitN(p[len("777;"):], ";", 3) | |
| 635 | + | if parts[0] != "notify" { | |
| 636 | + | return | |
| 637 | + | } | |
| 638 | + | var title, body string | |
| 639 | + | if len(parts) > 1 { | |
| 640 | + | title = parts[1] | |
| 641 | + | } | |
| 642 | + | if len(parts) > 2 { | |
| 643 | + | body = parts[2] | |
| 644 | + | } | |
| 645 | + | s.OnNotify(title, body) | |
| 646 | + | } | |
| 647 | + | } | |
| 648 | + | ||
| 607 | 649 | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 608 | 650 | for i := 2; i < len(data); i++ { | |
| 609 | 651 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| @@ -39,6 +39,14 @@ type Screen struct { | |||||
| 39 | // emulator must send back to the child process (DA, DSR, DECRQM reports). | 39 | // emulator must send back to the child process (DA, DSR, DECRQM reports). | ||
| 40 | // The host wires it to the PTY input. Nil disables responses. | 40 | // The host wires it to the PTY input. Nil disables responses. | ||
| 41 | OnResponse func([]byte) | 41 | OnResponse func([]byte) | ||
| 42 | + | // OnBell is called synchronously from Write for each lone BEL (0x07) | |||
| 43 | + | // outside escape sequences. BELs that terminate an OSC never fire it. | |||
| 44 | + | // Nil disables bell reporting. | |||
| 45 | + | OnBell func() | |||
| 46 | + | // OnNotify is called synchronously from Write for explicit terminal | |||
| 47 | + | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |||
| 48 | + | // All other OSC payloads remain discarded. Nil disables it. | |||
| 49 | + | OnNotify func(title, body string) | |||
| 42 | 50 | ||||
| 43 | defaultFG renderer.RGB | 51 | defaultFG renderer.RGB | ||
| 44 | defaultBG renderer.RGB | 52 | defaultBG renderer.RGB | ||
| @@ -178,7 +186,12 @@ func (s *Screen) Write(data []byte) { | |||||
| 178 | 186 | ||||
| 179 | func (s *Screen) putRune(r rune) { | 187 | func (s *Screen) putRune(r rune) { | ||
| 180 | switch r { | 188 | switch r { | ||
| 181 | - | case '\a', 0x00, 0x0e, 0x0f, 0x7f: | 189 | + | case '\a': |
| 190 | + | if s.OnBell != nil { | |||
| 191 | + | s.OnBell() | |||
| 192 | + | } | |||
| 193 | + | return | |||
| 194 | + | case 0x00, 0x0e, 0x0f, 0x7f: | |||
| 182 | return | 195 | return | ||
| 183 | case '\r': | 196 | case '\r': | ||
| 184 | s.Col = 0 | 197 | s.Col = 0 | ||
| @@ -559,10 +572,12 @@ func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) { | |||||
| 559 | switch data[i] { | 572 | switch data[i] { | ||
| 560 | case 0x07: | 573 | case 0x07: | ||
| 561 | s.applyOSC(data[2:i], []byte{0x07}) | 574 | s.applyOSC(data[2:i], []byte{0x07}) | ||
| 575 | + | s.handleOSC(data[2:i]) | |||
| 562 | return i + 1, false | 576 | return i + 1, false | ||
| 563 | case 0x1b: | 577 | case 0x1b: | ||
| 564 | if i+1 < len(data) && data[i+1] == '\\' { | 578 | if i+1 < len(data) && data[i+1] == '\\' { | ||
| 565 | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | 579 | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | ||
| 580 | + | s.handleOSC(data[2:i]) | |||
| 566 | return i + 2, false | 581 | return i + 2, false | ||
| 567 | } | 582 | } | ||
| 568 | } | 583 | } | ||
| @@ -604,6 +619,33 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||||
| 604 | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | 619 | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | ||
| 605 | } | 620 | } | ||
| 606 | 621 | ||||
| 622 | + | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | |||
| 623 | + | // terminator). Only notification sequences are acted on; titles, clipboard | |||
| 624 | + | // and every other OSC are still discarded. | |||
| 625 | + | func (s *Screen) handleOSC(payload []byte) { | |||
| 626 | + | if s.OnNotify == nil { | |||
| 627 | + | return | |||
| 628 | + | } | |||
| 629 | + | p := string(payload) | |||
| 630 | + | switch { | |||
| 631 | + | case strings.HasPrefix(p, "9;"): | |||
| 632 | + | s.OnNotify("", p[len("9;"):]) | |||
| 633 | + | case strings.HasPrefix(p, "777;"): | |||
| 634 | + | parts := strings.SplitN(p[len("777;"):], ";", 3) | |||
| 635 | + | if parts[0] != "notify" { | |||
| 636 | + | return | |||
| 637 | + | } | |||
| 638 | + | var title, body string | |||
| 639 | + | if len(parts) > 1 { | |||
| 640 | + | title = parts[1] | |||
| 641 | + | } | |||
| 642 | + | if len(parts) > 2 { | |||
| 643 | + | body = parts[2] | |||
| 644 | + | } | |||
| 645 | + | s.OnNotify(title, body) | |||
| 646 | + | } | |||
| 647 | + | } | |||
| 648 | + | ||||
| 607 | func consumeSTString(data []byte) (consumed int, partial bool) { | 649 | func consumeSTString(data []byte) (consumed int, partial bool) { | ||
| 608 | for i := 2; i < len(data); i++ { | 650 | for i := 2; i < len(data); i++ { | ||
| 609 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | 651 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | ||
M pkg/vt/screen_test.go +76 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // --------------------------------------------------------------------------- | |
| @@ -73,6 +74,81 @@ func lineText(s *Screen, y int) string { | |||
| 73 | 74 | return string(out) | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 77 | + | func rowString(row []renderer.Cell) string { | |
| 78 | + | out := make([]rune, len(row)) | |
| 79 | + | for i, cell := range row { | |
| 80 | + | out[i] = cell.Rune | |
| 81 | + | } | |
| 82 | + | return string(out) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // --------------------------------------------------------------------------- | |
| 86 | + | // Bell / notifications | |
| 87 | + | // --------------------------------------------------------------------------- | |
| 88 | + | ||
| 89 | + | func TestOnBellFiresForLoneBEL(t *testing.T) { | |
| 90 | + | s := NewScreen(10, 2) | |
| 91 | + | rang := 0 | |
| 92 | + | s.OnBell = func() { rang++ } | |
| 93 | + | s.Write([]byte("hi\ahi\a")) | |
| 94 | + | require.Equal(t, 2, rang) | |
| 95 | + | require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |
| 99 | + | s := NewScreen(10, 2) | |
| 100 | + | rang := 0 | |
| 101 | + | s.OnBell = func() { rang++ } | |
| 102 | + | s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell | |
| 103 | + | require.Equal(t, 0, rang) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |
| 107 | + | s := NewScreen(10, 2) | |
| 108 | + | s.Write([]byte("a\ab")) // no OnBell set: must not panic | |
| 109 | + | require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestOnNotifyOSC9(t *testing.T) { | |
| 113 | + | s := NewScreen(10, 2) | |
| 114 | + | var gotTitle, gotBody string | |
| 115 | + | calls := 0 | |
| 116 | + | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |
| 117 | + | s.Write([]byte("\x1b]9;agent done\x07")) | |
| 118 | + | require.Equal(t, 1, calls) | |
| 119 | + | require.Equal(t, "", gotTitle) | |
| 120 | + | require.Equal(t, "agent done", gotBody) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestOnNotifyOSC777STTerminated(t *testing.T) { | |
| 124 | + | s := NewScreen(10, 2) | |
| 125 | + | var gotTitle, gotBody string | |
| 126 | + | calls := 0 | |
| 127 | + | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |
| 128 | + | s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\")) | |
| 129 | + | require.Equal(t, 1, calls) | |
| 130 | + | require.Equal(t, "Claude", gotTitle) | |
| 131 | + | require.Equal(t, "needs input", gotBody) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestOnNotifyIgnoresOtherOSC(t *testing.T) { | |
| 135 | + | s := NewScreen(10, 2) | |
| 136 | + | calls := 0 | |
| 137 | + | s.OnNotify = func(string, string) { calls++ } | |
| 138 | + | s.Write([]byte("\x1b]0;window title\x07")) // title: discarded | |
| 139 | + | s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded | |
| 140 | + | require.Equal(t, 0, calls) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestOnNotifySplitAcrossWrites(t *testing.T) { | |
| 144 | + | s := NewScreen(10, 2) | |
| 145 | + | calls := 0 | |
| 146 | + | s.OnNotify = func(string, string) { calls++ } | |
| 147 | + | s.Write([]byte("\x1b]9;par")) | |
| 148 | + | s.Write([]byte("tial\x07")) | |
| 149 | + | require.Equal(t, 1, calls) | |
| 150 | + | } | |
| 151 | + | ||
| 76 | 152 | // --------------------------------------------------------------------------- | |
| 77 | 153 | // Printable ASCII / UTF-8 | |
| 78 | 154 | // --------------------------------------------------------------------------- | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- | ||
| @@ -73,6 +74,81 @@ func lineText(s *Screen, y int) string { | |||||
| 73 | return string(out) | 74 | return string(out) | ||
| 74 | } | 75 | } | ||
| 75 | 76 | ||||
| 77 | + | func rowString(row []renderer.Cell) string { | |||
| 78 | + | out := make([]rune, len(row)) | |||
| 79 | + | for i, cell := range row { | |||
| 80 | + | out[i] = cell.Rune | |||
| 81 | + | } | |||
| 82 | + | return string(out) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // --------------------------------------------------------------------------- | |||
| 86 | + | // Bell / notifications | |||
| 87 | + | // --------------------------------------------------------------------------- | |||
| 88 | + | ||||
| 89 | + | func TestOnBellFiresForLoneBEL(t *testing.T) { | |||
| 90 | + | s := NewScreen(10, 2) | |||
| 91 | + | rang := 0 | |||
| 92 | + | s.OnBell = func() { rang++ } | |||
| 93 | + | s.Write([]byte("hi\ahi\a")) | |||
| 94 | + | require.Equal(t, 2, rang) | |||
| 95 | + | require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |||
| 99 | + | s := NewScreen(10, 2) | |||
| 100 | + | rang := 0 | |||
| 101 | + | s.OnBell = func() { rang++ } | |||
| 102 | + | s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell | |||
| 103 | + | require.Equal(t, 0, rang) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |||
| 107 | + | s := NewScreen(10, 2) | |||
| 108 | + | s.Write([]byte("a\ab")) // no OnBell set: must not panic | |||
| 109 | + | require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestOnNotifyOSC9(t *testing.T) { | |||
| 113 | + | s := NewScreen(10, 2) | |||
| 114 | + | var gotTitle, gotBody string | |||
| 115 | + | calls := 0 | |||
| 116 | + | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |||
| 117 | + | s.Write([]byte("\x1b]9;agent done\x07")) | |||
| 118 | + | require.Equal(t, 1, calls) | |||
| 119 | + | require.Equal(t, "", gotTitle) | |||
| 120 | + | require.Equal(t, "agent done", gotBody) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestOnNotifyOSC777STTerminated(t *testing.T) { | |||
| 124 | + | s := NewScreen(10, 2) | |||
| 125 | + | var gotTitle, gotBody string | |||
| 126 | + | calls := 0 | |||
| 127 | + | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |||
| 128 | + | s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\")) | |||
| 129 | + | require.Equal(t, 1, calls) | |||
| 130 | + | require.Equal(t, "Claude", gotTitle) | |||
| 131 | + | require.Equal(t, "needs input", gotBody) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestOnNotifyIgnoresOtherOSC(t *testing.T) { | |||
| 135 | + | s := NewScreen(10, 2) | |||
| 136 | + | calls := 0 | |||
| 137 | + | s.OnNotify = func(string, string) { calls++ } | |||
| 138 | + | s.Write([]byte("\x1b]0;window title\x07")) // title: discarded | |||
| 139 | + | s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded | |||
| 140 | + | require.Equal(t, 0, calls) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestOnNotifySplitAcrossWrites(t *testing.T) { | |||
| 144 | + | s := NewScreen(10, 2) | |||
| 145 | + | calls := 0 | |||
| 146 | + | s.OnNotify = func(string, string) { calls++ } | |||
| 147 | + | s.Write([]byte("\x1b]9;par")) | |||
| 148 | + | s.Write([]byte("tial\x07")) | |||
| 149 | + | require.Equal(t, 1, calls) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 76 | // --------------------------------------------------------------------------- | 152 | // --------------------------------------------------------------------------- | ||
| 77 | // Printable ASCII / UTF-8 | 153 | // Printable ASCII / UTF-8 | ||
| 78 | // --------------------------------------------------------------------------- | 154 | // --------------------------------------------------------------------------- | ||