detached 37efbea

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3939 // emulator must send back to the child process (DA, DSR, DECRQM reports).
4040 // The host wires it to the PTY input. Nil disables responses.
4141 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)
4250
4351 defaultFG renderer.RGB
4452 defaultBG renderer.RGB
@@ -178,7 +186,12 @@ func (s *Screen) Write(data []byte) {
178186
179187 func (s *Screen) putRune(r rune) {
180188 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:
182195 return
183196 case '\r':
184197 s.Col = 0
@@ -559,10 +572,12 @@ func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
559572 switch data[i] {
560573 case 0x07:
561574 s.applyOSC(data[2:i], []byte{0x07})
575+ s.handleOSC(data[2:i])
562576 return i + 1, false
563577 case 0x1b:
564578 if i+1 < len(data) && data[i+1] == '\\' {
565579 s.applyOSC(data[2:i], []byte{0x1b, '\\'})
580+ s.handleOSC(data[2:i])
566581 return i + 2, false
567582 }
568583 }
@@ -604,6 +619,33 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
604619 return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
605620 }
606621
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+
607649 func consumeSTString(data []byte) (consumed int, partial bool) {
608650 for i := 2; i < len(data); i++ {
609651 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.RGB51 defaultFG renderer.RGB
44 defaultBG renderer.RGB52 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 return195 return
183 case '\r':196 case '\r':
184 s.Col = 0197 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, false576 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, false581 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 (
55 "testing"
66
77 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
89 )
910
1011 // ---------------------------------------------------------------------------
@@ -73,6 +74,81 @@ func lineText(s *Screen, y int) string {
7374 return string(out)
7475 }
7576
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+
76152 // ---------------------------------------------------------------------------
77153 // Printable ASCII / UTF-8
78154 // ---------------------------------------------------------------------------
@@ -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-8153 // Printable ASCII / UTF-8
78 // ---------------------------------------------------------------------------154 // ---------------------------------------------------------------------------

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