detached 151010a

merge(feature): attention bells

commit
151010ae3cd1e23482e8d139847cbec686dba36f
author
brice · · 2026-07-03 15:37 UTC
merge of
88e5870 + 050ecf4 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1572 -76
M internal/domain/types.go +0 -17
@@ -20,20 +20,3 @@ type SessionID string
2020
2121 // TabID uniquely identifies a tab within a session.
2222 type TabID string
23-
24-// Tab is a single tab within a session.
25-type Tab struct {
26- ID TabID
27- Index int // 0-based; drives Alt+1..9 and status-bar order
28- Name string
29- Active bool
30-}
31-
32-// Session is a persistent multiplexer session containing one or more tabs.
33-type Session struct {
34- ID SessionID
35- Name string // display name; numbered "0","1"... when ephemeral
36- Ephemeral bool
37- Tabs []Tab
38- ActiveTab TabID
39-}
@@ -20,20 +20,3 @@ type SessionID string
20 20
21 // TabID uniquely identifies a tab within a session.21 // TabID uniquely identifies a tab within a session.
22 type TabID string22 type TabID string
23-
24-// Tab is a single tab within a session.
25-type Tab struct {
26- ID TabID
27- Index int // 0-based; drives Alt+1..9 and status-bar order
28- Name string
29- Active bool
30-}
31-
32-// Session is a persistent multiplexer session containing one or more tabs.
33-type Session struct {
34- ID SessionID
35- Name string // display name; numbered "0","1"... when ephemeral
36- Ephemeral bool
37- Tabs []Tab
38- ActiveTab TabID
39-}
A internal/usecase/daemon/attention.go +257 -0
@@ -0,0 +1,257 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+)
11+
12+// noteAttention records attention for tabs that are not currently visible to an
13+// attached client. The caller must not hold tab.mu.
14+func (d *Daemon) noteAttention(sess *session, tb *tab) {
15+ if sess == nil || tb == nil {
16+ return
17+ }
18+
19+ now := d.clock.Now()
20+ if now.IsZero() {
21+ now = time.Now()
22+ }
23+
24+ sess.mu.Lock()
25+ visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
26+ if visible {
27+ sess.mu.Unlock()
28+ return
29+ }
30+ if !tb.attention {
31+ tb.attentionAt = now
32+ }
33+ tb.attention = true
34+ sess.mu.Unlock()
35+
36+ // Do not repaint here: this runs on the PTY reader goroutine, and paint
37+ // can block on a slow client's socket (Transport.Send has no deadline) —
38+ // that would let one wedged client stall a different session's reader.
39+ // The animator's first tick (<=120ms) delivers the repaint, and pulse
40+ // frame 0 renders the bell as blank anyway, so nothing is visibly lost
41+ // by not painting immediately.
42+ d.pokeAttentionTicker()
43+}
44+
45+func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
46+ if sess == nil || ac == nil {
47+ return
48+ }
49+ if idx, ok := oldestAttentionTab(sess); ok {
50+ if sess.switchTab(idx) {
51+ d.paint(sess, ac, true)
52+ }
53+ return
54+ }
55+
56+ target, ok := d.oldestOtherSessionAttention(sess)
57+ if !ok {
58+ return
59+ }
60+ d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
61+}
62+
63+func oldestAttentionTab(sess *session) (int, bool) {
64+ idx, _, ok := oldestAttentionTabWithTime(sess)
65+ return idx, ok
66+}
67+
68+type attentionTarget struct {
69+ sessionID domain.SessionID
70+ tabIndex int
71+ at time.Time
72+}
73+
74+func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
75+ d.mu.Lock()
76+ sessions := make([]*session, 0, len(d.sessions))
77+ for _, sess := range d.sessions {
78+ if sess != current {
79+ sessions = append(sessions, sess)
80+ }
81+ }
82+ d.mu.Unlock()
83+
84+ var target attentionTarget
85+ found := false
86+ for _, sess := range sessions {
87+ idx, at, ok := oldestAttentionTabWithTime(sess)
88+ if !ok {
89+ continue
90+ }
91+ if !found || at.Before(target.at) {
92+ target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
93+ found = true
94+ }
95+ }
96+ return target, found
97+}
98+
99+func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
100+ sess.mu.Lock()
101+ defer sess.mu.Unlock()
102+ idx := -1
103+ var oldest time.Time
104+ for i, tb := range sess.tabs {
105+ if !tb.attention {
106+ continue
107+ }
108+ if idx == -1 || tb.attentionAt.Before(oldest) {
109+ idx = i
110+ oldest = tb.attentionAt
111+ }
112+ }
113+ return idx, oldest, idx != -1
114+}
115+
116+func (d *Daemon) repaintAttachedClients(sess *session) {
117+ sess.mu.Lock()
118+ ac := sess.client
119+ sess.mu.Unlock()
120+ if ac != nil {
121+ d.paint(sess, ac, false)
122+ }
123+}
124+
125+func (d *Daemon) repaintAllAttachedClients() {
126+ d.mu.Lock()
127+ sessions := make([]*session, 0, len(d.sessions))
128+ for _, sess := range d.sessions {
129+ sessions = append(sessions, sess)
130+ }
131+ d.mu.Unlock()
132+
133+ for _, sess := range sessions {
134+ d.repaintAttachedClients(sess)
135+ }
136+}
137+
138+func (s *session) ackAttention(tb *tab) bool {
139+ if tb == nil {
140+ return false
141+ }
142+ s.mu.Lock()
143+ defer s.mu.Unlock()
144+ if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
145+ return false
146+ }
147+ tb.attention = false
148+ tb.attentionAt = time.Time{}
149+ return true
150+}
151+
152+func (s *session) anyAttention() bool {
153+ s.mu.Lock()
154+ defer s.mu.Unlock()
155+ for _, tb := range s.tabs {
156+ if tb.attention {
157+ return true
158+ }
159+ }
160+ return false
161+}
162+
163+func (d *Daemon) pokeAttentionTicker() {
164+ if d == nil || d.animWake == nil {
165+ return
166+ }
167+ select {
168+ case d.animWake <- struct{}{}:
169+ default:
170+ }
171+}
172+
173+func (d *Daemon) attentionFrame() int {
174+ if d == nil {
175+ return 0
176+ }
177+ d.attnMu.Lock()
178+ defer d.attnMu.Unlock()
179+ return d.animFrame
180+}
181+
182+func (d *Daemon) attentionAnimator(ctx context.Context) {
183+ active := false
184+ var timer ports.Timer
185+ for {
186+ if !active {
187+ if !d.anyAttention() {
188+ d.setAttentionFrame(0)
189+ select {
190+ case <-ctx.Done():
191+ return
192+ case <-d.animWake:
193+ continue
194+ }
195+ }
196+ active = true
197+ }
198+
199+ if timer == nil {
200+ timer = d.clock.NewTimer(pulseFrameInterval)
201+ } else {
202+ timer.Reset(pulseFrameInterval)
203+ }
204+
205+ select {
206+ case <-ctx.Done():
207+ if timer != nil {
208+ timer.Stop()
209+ }
210+ return
211+ case <-d.animWake:
212+ if !timer.Stop() {
213+ select {
214+ case <-timer.C():
215+ default:
216+ }
217+ }
218+ case <-timer.C():
219+ d.advanceAttentionFrame()
220+ d.repaintAllAttachedClients()
221+ }
222+
223+ if !d.anyAttention() {
224+ d.setAttentionFrame(0)
225+ d.repaintAllAttachedClients()
226+ active = false
227+ }
228+ }
229+}
230+
231+func (d *Daemon) anyAttention() bool {
232+ d.mu.Lock()
233+ sessions := make([]*session, 0, len(d.sessions))
234+ for _, sess := range d.sessions {
235+ sessions = append(sessions, sess)
236+ }
237+ d.mu.Unlock()
238+
239+ for _, sess := range sessions {
240+ if sess.anyAttention() {
241+ return true
242+ }
243+ }
244+ return false
245+}
246+
247+func (d *Daemon) advanceAttentionFrame() {
248+ d.attnMu.Lock()
249+ d.animFrame = (d.animFrame + 1) % pulseFrameCount
250+ d.attnMu.Unlock()
251+}
252+
253+func (d *Daemon) setAttentionFrame(frame int) {
254+ d.attnMu.Lock()
255+ d.animFrame = frame
256+ d.attnMu.Unlock()
257+}
@@ -0,0 +1,257 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+)
11+
12+// noteAttention records attention for tabs that are not currently visible to an
13+// attached client. The caller must not hold tab.mu.
14+func (d *Daemon) noteAttention(sess *session, tb *tab) {
15+ if sess == nil || tb == nil {
16+ return
17+ }
18+
19+ now := d.clock.Now()
20+ if now.IsZero() {
21+ now = time.Now()
22+ }
23+
24+ sess.mu.Lock()
25+ visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
26+ if visible {
27+ sess.mu.Unlock()
28+ return
29+ }
30+ if !tb.attention {
31+ tb.attentionAt = now
32+ }
33+ tb.attention = true
34+ sess.mu.Unlock()
35+
36+ // Do not repaint here: this runs on the PTY reader goroutine, and paint
37+ // can block on a slow client's socket (Transport.Send has no deadline) —
38+ // that would let one wedged client stall a different session's reader.
39+ // The animator's first tick (<=120ms) delivers the repaint, and pulse
40+ // frame 0 renders the bell as blank anyway, so nothing is visibly lost
41+ // by not painting immediately.
42+ d.pokeAttentionTicker()
43+}
44+
45+func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
46+ if sess == nil || ac == nil {
47+ return
48+ }
49+ if idx, ok := oldestAttentionTab(sess); ok {
50+ if sess.switchTab(idx) {
51+ d.paint(sess, ac, true)
52+ }
53+ return
54+ }
55+
56+ target, ok := d.oldestOtherSessionAttention(sess)
57+ if !ok {
58+ return
59+ }
60+ d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
61+}
62+
63+func oldestAttentionTab(sess *session) (int, bool) {
64+ idx, _, ok := oldestAttentionTabWithTime(sess)
65+ return idx, ok
66+}
67+
68+type attentionTarget struct {
69+ sessionID domain.SessionID
70+ tabIndex int
71+ at time.Time
72+}
73+
74+func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
75+ d.mu.Lock()
76+ sessions := make([]*session, 0, len(d.sessions))
77+ for _, sess := range d.sessions {
78+ if sess != current {
79+ sessions = append(sessions, sess)
80+ }
81+ }
82+ d.mu.Unlock()
83+
84+ var target attentionTarget
85+ found := false
86+ for _, sess := range sessions {
87+ idx, at, ok := oldestAttentionTabWithTime(sess)
88+ if !ok {
89+ continue
90+ }
91+ if !found || at.Before(target.at) {
92+ target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
93+ found = true
94+ }
95+ }
96+ return target, found
97+}
98+
99+func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
100+ sess.mu.Lock()
101+ defer sess.mu.Unlock()
102+ idx := -1
103+ var oldest time.Time
104+ for i, tb := range sess.tabs {
105+ if !tb.attention {
106+ continue
107+ }
108+ if idx == -1 || tb.attentionAt.Before(oldest) {
109+ idx = i
110+ oldest = tb.attentionAt
111+ }
112+ }
113+ return idx, oldest, idx != -1
114+}
115+
116+func (d *Daemon) repaintAttachedClients(sess *session) {
117+ sess.mu.Lock()
118+ ac := sess.client
119+ sess.mu.Unlock()
120+ if ac != nil {
121+ d.paint(sess, ac, false)
122+ }
123+}
124+
125+func (d *Daemon) repaintAllAttachedClients() {
126+ d.mu.Lock()
127+ sessions := make([]*session, 0, len(d.sessions))
128+ for _, sess := range d.sessions {
129+ sessions = append(sessions, sess)
130+ }
131+ d.mu.Unlock()
132+
133+ for _, sess := range sessions {
134+ d.repaintAttachedClients(sess)
135+ }
136+}
137+
138+func (s *session) ackAttention(tb *tab) bool {
139+ if tb == nil {
140+ return false
141+ }
142+ s.mu.Lock()
143+ defer s.mu.Unlock()
144+ if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
145+ return false
146+ }
147+ tb.attention = false
148+ tb.attentionAt = time.Time{}
149+ return true
150+}
151+
152+func (s *session) anyAttention() bool {
153+ s.mu.Lock()
154+ defer s.mu.Unlock()
155+ for _, tb := range s.tabs {
156+ if tb.attention {
157+ return true
158+ }
159+ }
160+ return false
161+}
162+
163+func (d *Daemon) pokeAttentionTicker() {
164+ if d == nil || d.animWake == nil {
165+ return
166+ }
167+ select {
168+ case d.animWake <- struct{}{}:
169+ default:
170+ }
171+}
172+
173+func (d *Daemon) attentionFrame() int {
174+ if d == nil {
175+ return 0
176+ }
177+ d.attnMu.Lock()
178+ defer d.attnMu.Unlock()
179+ return d.animFrame
180+}
181+
182+func (d *Daemon) attentionAnimator(ctx context.Context) {
183+ active := false
184+ var timer ports.Timer
185+ for {
186+ if !active {
187+ if !d.anyAttention() {
188+ d.setAttentionFrame(0)
189+ select {
190+ case <-ctx.Done():
191+ return
192+ case <-d.animWake:
193+ continue
194+ }
195+ }
196+ active = true
197+ }
198+
199+ if timer == nil {
200+ timer = d.clock.NewTimer(pulseFrameInterval)
201+ } else {
202+ timer.Reset(pulseFrameInterval)
203+ }
204+
205+ select {
206+ case <-ctx.Done():
207+ if timer != nil {
208+ timer.Stop()
209+ }
210+ return
211+ case <-d.animWake:
212+ if !timer.Stop() {
213+ select {
214+ case <-timer.C():
215+ default:
216+ }
217+ }
218+ case <-timer.C():
219+ d.advanceAttentionFrame()
220+ d.repaintAllAttachedClients()
221+ }
222+
223+ if !d.anyAttention() {
224+ d.setAttentionFrame(0)
225+ d.repaintAllAttachedClients()
226+ active = false
227+ }
228+ }
229+}
230+
231+func (d *Daemon) anyAttention() bool {
232+ d.mu.Lock()
233+ sessions := make([]*session, 0, len(d.sessions))
234+ for _, sess := range d.sessions {
235+ sessions = append(sessions, sess)
236+ }
237+ d.mu.Unlock()
238+
239+ for _, sess := range sessions {
240+ if sess.anyAttention() {
241+ return true
242+ }
243+ }
244+ return false
245+}
246+
247+func (d *Daemon) advanceAttentionFrame() {
248+ d.attnMu.Lock()
249+ d.animFrame = (d.animFrame + 1) % pulseFrameCount
250+ d.attnMu.Unlock()
251+}
252+
253+func (d *Daemon) setAttentionFrame(frame int) {
254+ d.attnMu.Lock()
255+ d.animFrame = frame
256+ d.attnMu.Unlock()
257+}
A internal/usecase/daemon/attention_test.go +552 -0
@@ -0,0 +1,552 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/pkg/renderer"
18+ "github.com/bnema/vev/pkg/vt"
19+)
20+
21+func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
22+ d, sess, _, _, releases := newManualTabSession(t, 2)
23+ defer releases[0]()
24+ defer releases[1]()
25+ sess.active = 0
26+
27+ d.noteAttention(sess, sess.tabs[1])
28+
29+ sess.mu.Lock()
30+ defer sess.mu.Unlock()
31+ require.True(t, sess.tabs[1].attention)
32+ require.False(t, sess.tabs[1].attentionAt.IsZero())
33+ require.False(t, sess.tabs[0].attention)
34+}
35+
36+func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
37+ d, sess, _, _, releases := newManualTabSession(t, 1)
38+ defer releases[0]()
39+ sess.active = 0
40+
41+ d.noteAttention(sess, sess.tabs[0])
42+
43+ sess.mu.Lock()
44+ defer sess.mu.Unlock()
45+ require.False(t, sess.tabs[0].attention)
46+ require.True(t, sess.tabs[0].attentionAt.IsZero())
47+}
48+
49+func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
50+ d, sess, ac, _, releases := newManualTabSession(t, 1)
51+ defer releases[0]()
52+ sess.active = 0
53+ require.True(t, sess.detachIfCurrent(ac))
54+
55+ d.noteAttention(sess, sess.tabs[0])
56+
57+ sess.mu.Lock()
58+ defer sess.mu.Unlock()
59+ require.True(t, sess.tabs[0].attention)
60+ require.False(t, sess.tabs[0].attentionAt.IsZero())
61+}
62+
63+func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
64+ p1, releasePTY1 := newBlockingPTY(t)
65+ p2 := newScriptPTY([][]byte{[]byte("\a")})
66+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
67+ defer releasePTY1()
68+ sess.active = 0
69+
70+ d.sessWg.Add(1)
71+ go d.ptyReader(sess, sess.tabs[1])
72+
73+ require.Eventually(t, func() bool {
74+ sess.mu.Lock()
75+ defer sess.mu.Unlock()
76+ return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
77+ }, 2*time.Second, 5*time.Millisecond)
78+
79+ _ = p2.Close()
80+ d.sessWg.Wait()
81+}
82+
83+// TestNoteAttentionDoesNotBlockOnWedgedOtherClient is the regression test for
84+// the reader-blocks-on-a-slow-client bug: noteAttention runs on the PTY
85+// reader goroutine (see ptyReader in render.go), so it must never repaint
86+// synchronously — paint ends in Transport.Send, which has no deadline, and a
87+// stalled client's socket would otherwise stall the reader of an unrelated
88+// session. A second session's client whose Send never returns must not stop
89+// noteAttention from returning promptly.
90+func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
91+ d, sess, _, _, releases := newManualTabSession(t, 2)
92+ defer releases[0]()
93+ defer releases[1]()
94+ sess.active = 0
95+
96+ trW := portsmocks.NewMockTransport(t)
97+ block := make(chan struct{})
98+ t.Cleanup(func() { close(block) })
99+ trW.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
100+ <-block
101+ return nil
102+ }).Maybe()
103+ trW.EXPECT().Close().Return(nil).Maybe()
104+ acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
105+ sctxW, cancelW := context.WithCancel(d.serveCtx)
106+ t.Cleanup(cancelW)
107+ tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}
108+ sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109+ acW.setSession(sessW)
110+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
111+ d.sessions[sessW.id] = sessW
112+
113+ done := make(chan struct{})
114+ go func() {
115+ d.noteAttention(sess, sess.tabs[1])
116+ close(done)
117+ }()
118+
119+ select {
120+ case <-done:
121+ case <-time.After(2 * time.Second):
122+ t.Fatal("noteAttention blocked on a wedged other-session client's Send")
123+ }
124+
125+ sess.mu.Lock()
126+ defer sess.mu.Unlock()
127+ require.True(t, sess.tabs[1].attention)
128+}
129+
130+type scriptPTY struct {
131+ mu sync.Mutex
132+ reads [][]byte
133+ closed chan struct{}
134+ once sync.Once
135+}
136+
137+func newScriptPTY(reads [][]byte) *scriptPTY {
138+ return &scriptPTY{reads: reads, closed: make(chan struct{})}
139+}
140+
141+func (p *scriptPTY) Read(b []byte) (int, error) {
142+ p.mu.Lock()
143+ if len(p.reads) > 0 {
144+ next := p.reads[0]
145+ p.reads = p.reads[1:]
146+ p.mu.Unlock()
147+ return copy(b, next), nil
148+ }
149+ p.mu.Unlock()
150+ <-p.closed
151+ return 0, io.EOF
152+}
153+
154+func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
155+func (p *scriptPTY) Close() error {
156+ p.once.Do(func() { close(p.closed) })
157+ return nil
158+}
159+func (p *scriptPTY) Resize(domain.Size) error { return nil }
160+func (p *scriptPTY) Pid() int { return 4242 }
161+
162+func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
164+ defer releases[0]()
165+ defer releases[1]()
166+ sess.active = 0
167+ sess.mu.Lock()
168+ sess.tabs[0].attention = true
169+ sess.tabs[0].attentionAt = time.Unix(1, 0)
170+ sess.tabs[1].attention = true
171+ sess.tabs[1].attentionAt = time.Unix(2, 0)
172+ sess.mu.Unlock()
173+
174+ d.paint(sess, ac, true)
175+ _ = mustOutputData(t, sends)
176+ // The deferred repaint (from ackAttention) targets all attached clients,
177+ // including this one, but the bars it would draw are already exactly what
178+ // the full paint above just sent: no second frame should follow.
179+ select {
180+ case f := <-sends:
181+ t.Fatalf("unexpected extra frame after ack: %v", f.Type)
182+ case <-time.After(50 * time.Millisecond):
183+ }
184+
185+ sess.mu.Lock()
186+ defer sess.mu.Unlock()
187+ require.False(t, sess.tabs[0].attention)
188+ require.True(t, sess.tabs[0].attentionAt.IsZero())
189+ require.True(t, sess.tabs[1].attention)
190+ require.False(t, sess.tabs[1].attentionAt.IsZero())
191+}
192+
193+func TestPaintPreservesBackgroundAttention(t *testing.T) {
194+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
195+ defer releases[0]()
196+ defer releases[1]()
197+ sess.active = 0
198+ sess.mu.Lock()
199+ sess.tabs[1].attention = true
200+ sess.tabs[1].attentionAt = time.Unix(2, 0)
201+ sess.mu.Unlock()
202+ d.setAttentionFrame(1)
203+
204+ d.paint(sess, ac, true)
205+ data := mustOutputData(t, sends)
206+
207+ require.Contains(t, string(data), "")
208+ sess.mu.Lock()
209+ defer sess.mu.Unlock()
210+ require.True(t, sess.tabs[1].attention)
211+}
212+
213+func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
214+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
215+ defer releases[0]()
216+ defer releases[1]()
217+ sess.active = 0
218+ sess.mu.Lock()
219+ sess.tabs[1].attention = true
220+ sess.tabs[1].attentionAt = time.Unix(2, 0)
221+ sess.mu.Unlock()
222+
223+ require.True(t, sess.switchTab(1))
224+ d.paint(sess, ac, true)
225+ data := mustOutputData(t, sends)
226+ // As above: the deferred all-clients repaint has nothing new to say about
227+ // this client's bars, so no second frame follows.
228+ select {
229+ case f := <-sends:
230+ t.Fatalf("unexpected extra frame after ack: %v", f.Type)
231+ case <-time.After(50 * time.Millisecond):
232+ }
233+
234+ require.NotContains(t, string(data), "")
235+ sess.mu.Lock()
236+ defer sess.mu.Unlock()
237+ require.False(t, sess.tabs[1].attention)
238+ require.True(t, sess.tabs[1].attentionAt.IsZero())
239+}
240+
241+func TestCloseRingingTabClearsClientBar(t *testing.T) {
242+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
243+ defer releases[0]()
244+ defer releases[1]()
245+ sess.active = 0
246+ sess.mu.Lock()
247+ ringing := sess.tabs[1]
248+ ringing.attention = true
249+ ringing.attentionAt = time.Unix(2, 0)
250+ sess.mu.Unlock()
251+ d.setAttentionFrame(1)
252+
253+ d.paint(sess, ac, true)
254+ before := mustOutputData(t, sends)
255+ require.Contains(t, string(before), "")
256+
257+ d.closeTab(sess, ringing, true)
258+ after := mustOutputData(t, sends)
259+ require.NotContains(t, string(after), "")
260+}
261+
262+// TestAnimationRepaintConfinedToBarRows is the regression test for the pulse
263+// animator: repaintAttachedClients must diff against the warm barCache
264+// (reset=false), so a repaint with nothing new to say sends no frame at all,
265+// and a repaint following only a pulse-frame advance touches the bar rows
266+// without re-emitting screen content.
267+func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
268+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
269+ defer releases[0]()
270+ defer releases[1]()
271+ sess.active = 0
272+ sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))
273+ sess.mu.Lock()
274+ sess.tabs[1].attention = true
275+ sess.tabs[1].attentionAt = time.Unix(1, 0)
276+ sess.mu.Unlock()
277+
278+ d.paint(sess, ac, true)
279+ full := mustOutputData(t, sends)
280+ require.Contains(t, string(full), "MIDSCREENMARKER")
281+
282+ // Nothing changed: a repaint against the warm cache must send nothing.
283+ d.repaintAttachedClients(sess)
284+ select {
285+ case f := <-sends:
286+ t.Fatalf("unexpected frame on unchanged repaint: %v", f.Type)
287+ case <-time.After(50 * time.Millisecond):
288+ }
289+
290+ // Advance the pulse frame (only the bells' rendered style changes) and
291+ // repaint: the bytes sent must not repaint screen content.
292+ d.advanceAttentionFrame()
293+ d.repaintAttachedClients(sess)
294+ data := mustOutputData(t, sends)
295+ require.NotContains(t, string(data), "MIDSCREENMARKER")
296+}
297+
298+// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the
299+// same invariant at the composeClientFrameWithState level: with a warm
300+// barCache and no screen damage, changing only bars.attentionFrame must
301+// produce damage confined to row 0 (top bar) and the last row (bottom bar).
302+func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
303+ d, sess, _, _, releases := newManualTabSession(t, 2)
304+ defer releases[0]()
305+ defer releases[1]()
306+ sess.active = 0
307+ sess.mu.Lock()
308+ sess.tabs[1].attention = true
309+ sess.tabs[1].attentionAt = time.Unix(1, 0)
310+ sess.mu.Unlock()
311+
312+ tb := sess.tabs[0]
313+ var cache barCache
314+ bars := d.barStateFor(sess, "")
315+ _, damage := composeClientFrameWithState(bars, tb, true, &cache)
316+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
317+ tb.screen.ClearDamage()
318+
319+ bars.attentionFrame++
320+ _, damage = composeClientFrameWithState(bars, tb, false, &cache)
321+ require.NotEmpty(t, damage)
322+ lastRow := tb.screen.Frame.Height + 1
323+ for _, dmg := range damage {
324+ require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
325+ }
326+}
327+
328+// TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a
329+// ringing tab in one session must refresh the bottom bar of a client attached
330+// to a DIFFERENT session, since that client's bar shows the ringing session's
331+// bell until it stops.
332+func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
333+ pA1, releaseA1 := newBlockingPTY(t)
334+ pA2, releaseA2 := newBlockingPTY(t)
335+ pB, releaseB := newBlockingPTY(t)
336+ defer releaseA1()
337+ defer releaseA2()
338+ defer releaseB()
339+
340+ d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2)
341+ sessA.name = "ringer"
342+ ringing := sessA.tabs[1]
343+ sessA.mu.Lock()
344+ ringing.attention = true
345+ ringing.attentionAt = time.Unix(1, 0)
346+ sessA.mu.Unlock()
347+ d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
348+
349+ trB, sendsB := newCapturingTransport(t)
350+ acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
351+ sctxB, cancelB := context.WithCancel(d.serveCtx)
352+ t.Cleanup(cancelB)
353+ tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}
354+ sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
355+ acB.setSession(sessB)
356+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
357+ d.sessions[sessB.id] = sessB
358+
359+ d.paint(sessB, acB, true)
360+ before := mustOutputData(t, sendsB)
361+ require.Contains(t, string(before), string(attentionGlyph))
362+
363+ d.closeTab(sessA, ringing, true)
364+ _ = mustOutputData(t, sendsA)
365+
366+ after := mustOutputData(t, sendsB)
367+ require.NotContains(t, string(after), string(attentionGlyph))
368+}
369+
370+func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
371+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
372+ defer releases[0]()
373+ defer releases[1]()
374+ defer releases[2]()
375+ sess.active = 0
376+ sess.mu.Lock()
377+ sess.tabs[1].attention = true
378+ sess.tabs[1].attentionAt = time.Unix(20, 0)
379+ sess.tabs[2].attention = true
380+ sess.tabs[2].attentionAt = time.Unix(10, 0)
381+ sess.mu.Unlock()
382+
383+ d.handleInput(sess, ac, []byte("\x1ba"))
384+
385+ require.Equal(t, 2, activeTabIndex(sess))
386+ _ = mustOutputData(t, sends)
387+}
388+
389+func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
390+ p1, release1 := newBlockingPTY(t)
391+ p2, release2 := newBlockingPTY(t)
392+ p3, release3 := newBlockingPTY(t)
393+ defer release1()
394+ defer release2()
395+ defer release3()
396+ d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
397+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
398+ defer cancel2()
399+ sess2 := &session{
400+ id: "other",
401+ name: "other",
402+ ctx: sctx2,
403+ cancel: cancel2,
404+ tabs: []*tab{
405+ {
406+ pty: p2,
407+ screen: vt.NewScreen(80, 23),
408+ dirty: make(chan struct{}, 1),
409+ size: domain.Size{Cols: 80, Rows: 23},
410+ ctx: sctx2,
411+ cancel: cancel2,
412+ attention: true,
413+ attentionAt: time.Unix(9, 0),
414+ },
415+ {
416+ pty: p3,
417+ screen: vt.NewScreen(80, 23),
418+ dirty: make(chan struct{}, 1),
419+ size: domain.Size{Cols: 80, Rows: 23},
420+ ctx: sctx2,
421+ cancel: cancel2,
422+ attention: true,
423+ attentionAt: time.Unix(5, 0),
424+ },
425+ },
426+ }
427+ d.sessions[sess2.id] = sess2
428+
429+ d.jumpAttention(sess1, ac)
430+
431+ require.Same(t, sess2, ac.currentSession())
432+ require.Same(t, ac, sess2.client)
433+ require.Nil(t, sess1.client)
434+ require.Equal(t, 1, activeTabIndex(sess2))
435+ _ = mustOutputData(t, sends)
436+}
437+
438+func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
439+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
440+ defer releases[0]()
441+ defer releases[1]()
442+ sess.active = 0
443+
444+ d.jumpAttention(sess, ac)
445+
446+ require.Same(t, sess, ac.currentSession())
447+ require.Equal(t, 0, activeTabIndex(sess))
448+ select {
449+ case f := <-sends:
450+ t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
451+ case <-time.After(20 * time.Millisecond):
452+ }
453+}
454+
455+func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
456+ d, sess, _, sends, releases := newManualTabSession(t, 2)
457+ defer releases[0]()
458+ defer releases[1]()
459+ clk := newManualAttentionClock()
460+ d.clock = clk
461+ sess.active = 0
462+
463+ ctx, cancel := context.WithCancel(context.Background())
464+ done := make(chan struct{})
465+ go func() {
466+ d.attentionAnimator(ctx)
467+ close(done)
468+ }()
469+
470+ select {
471+ case <-clk.timers:
472+ t.Fatal("parked animator requested timer with no attention")
473+ case <-time.After(20 * time.Millisecond):
474+ }
475+
476+ sess.mu.Lock()
477+ sess.tabs[1].attention = true
478+ sess.tabs[1].attentionAt = time.Unix(1, 0)
479+ sess.mu.Unlock()
480+ d.pokeAttentionTicker()
481+
482+ timer := clk.nextTimer(t)
483+ timer.fire()
484+ _ = mustOutputData(t, sends)
485+ require.Equal(t, 1, d.attentionFrame())
486+
487+ sess.mu.Lock()
488+ sess.tabs[1].attention = false
489+ sess.tabs[1].attentionAt = time.Time{}
490+ sess.mu.Unlock()
491+ d.pokeAttentionTicker()
492+
493+ require.Eventually(t, func() bool { return d.attentionFrame() == 0 }, time.Second, time.Millisecond)
494+ _ = mustOutputData(t, sends)
495+ select {
496+ case <-clk.timers:
497+ t.Fatal("animator requested new timer after attention cleared")
498+ case <-time.After(20 * time.Millisecond):
499+ }
500+
501+ cancel()
502+ require.Eventually(t, func() bool {
503+ select {
504+ case <-done:
505+ return true
506+ default:
507+ return false
508+ }
509+ }, time.Second, time.Millisecond)
510+}
511+
512+func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
513+ d := New(nil, stubClock{}, nil)
514+ require.Equal(t, 1, cap(d.animWake))
515+ d.pokeAttentionTicker()
516+ d.pokeAttentionTicker()
517+ require.Len(t, d.animWake, 1)
518+}
519+
520+type manualAttentionClock struct {
521+ timers chan *manualAttentionTimer
522+}
523+
524+func newManualAttentionClock() *manualAttentionClock {
525+ return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
526+}
527+
528+func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
529+func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
530+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
531+ c.timers <- t
532+ return t
533+}
534+func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
535+ t.Helper()
536+ select {
537+ case timer := <-c.timers:
538+ return timer
539+ case <-time.After(time.Second):
540+ t.Fatal("timed out waiting for attention timer")
541+ return nil
542+ }
543+}
544+
545+type manualAttentionTimer struct {
546+ ch chan time.Time
547+}
548+
549+func (t *manualAttentionTimer) C() <-chan time.Time { return t.ch }
550+func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
551+func (t *manualAttentionTimer) Stop() bool { return true }
552+func (t *manualAttentionTimer) fire() { t.ch <- time.Now() }
@@ -0,0 +1,552 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/pkg/renderer"
18+ "github.com/bnema/vev/pkg/vt"
19+)
20+
21+func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
22+ d, sess, _, _, releases := newManualTabSession(t, 2)
23+ defer releases[0]()
24+ defer releases[1]()
25+ sess.active = 0
26+
27+ d.noteAttention(sess, sess.tabs[1])
28+
29+ sess.mu.Lock()
30+ defer sess.mu.Unlock()
31+ require.True(t, sess.tabs[1].attention)
32+ require.False(t, sess.tabs[1].attentionAt.IsZero())
33+ require.False(t, sess.tabs[0].attention)
34+}
35+
36+func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
37+ d, sess, _, _, releases := newManualTabSession(t, 1)
38+ defer releases[0]()
39+ sess.active = 0
40+
41+ d.noteAttention(sess, sess.tabs[0])
42+
43+ sess.mu.Lock()
44+ defer sess.mu.Unlock()
45+ require.False(t, sess.tabs[0].attention)
46+ require.True(t, sess.tabs[0].attentionAt.IsZero())
47+}
48+
49+func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
50+ d, sess, ac, _, releases := newManualTabSession(t, 1)
51+ defer releases[0]()
52+ sess.active = 0
53+ require.True(t, sess.detachIfCurrent(ac))
54+
55+ d.noteAttention(sess, sess.tabs[0])
56+
57+ sess.mu.Lock()
58+ defer sess.mu.Unlock()
59+ require.True(t, sess.tabs[0].attention)
60+ require.False(t, sess.tabs[0].attentionAt.IsZero())
61+}
62+
63+func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
64+ p1, releasePTY1 := newBlockingPTY(t)
65+ p2 := newScriptPTY([][]byte{[]byte("\a")})
66+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
67+ defer releasePTY1()
68+ sess.active = 0
69+
70+ d.sessWg.Add(1)
71+ go d.ptyReader(sess, sess.tabs[1])
72+
73+ require.Eventually(t, func() bool {
74+ sess.mu.Lock()
75+ defer sess.mu.Unlock()
76+ return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
77+ }, 2*time.Second, 5*time.Millisecond)
78+
79+ _ = p2.Close()
80+ d.sessWg.Wait()
81+}
82+
83+// TestNoteAttentionDoesNotBlockOnWedgedOtherClient is the regression test for
84+// the reader-blocks-on-a-slow-client bug: noteAttention runs on the PTY
85+// reader goroutine (see ptyReader in render.go), so it must never repaint
86+// synchronously — paint ends in Transport.Send, which has no deadline, and a
87+// stalled client's socket would otherwise stall the reader of an unrelated
88+// session. A second session's client whose Send never returns must not stop
89+// noteAttention from returning promptly.
90+func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
91+ d, sess, _, _, releases := newManualTabSession(t, 2)
92+ defer releases[0]()
93+ defer releases[1]()
94+ sess.active = 0
95+
96+ trW := portsmocks.NewMockTransport(t)
97+ block := make(chan struct{})
98+ t.Cleanup(func() { close(block) })
99+ trW.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
100+ <-block
101+ return nil
102+ }).Maybe()
103+ trW.EXPECT().Close().Return(nil).Maybe()
104+ acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
105+ sctxW, cancelW := context.WithCancel(d.serveCtx)
106+ t.Cleanup(cancelW)
107+ tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}
108+ sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109+ acW.setSession(sessW)
110+ acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
111+ d.sessions[sessW.id] = sessW
112+
113+ done := make(chan struct{})
114+ go func() {
115+ d.noteAttention(sess, sess.tabs[1])
116+ close(done)
117+ }()
118+
119+ select {
120+ case <-done:
121+ case <-time.After(2 * time.Second):
122+ t.Fatal("noteAttention blocked on a wedged other-session client's Send")
123+ }
124+
125+ sess.mu.Lock()
126+ defer sess.mu.Unlock()
127+ require.True(t, sess.tabs[1].attention)
128+}
129+
130+type scriptPTY struct {
131+ mu sync.Mutex
132+ reads [][]byte
133+ closed chan struct{}
134+ once sync.Once
135+}
136+
137+func newScriptPTY(reads [][]byte) *scriptPTY {
138+ return &scriptPTY{reads: reads, closed: make(chan struct{})}
139+}
140+
141+func (p *scriptPTY) Read(b []byte) (int, error) {
142+ p.mu.Lock()
143+ if len(p.reads) > 0 {
144+ next := p.reads[0]
145+ p.reads = p.reads[1:]
146+ p.mu.Unlock()
147+ return copy(b, next), nil
148+ }
149+ p.mu.Unlock()
150+ <-p.closed
151+ return 0, io.EOF
152+}
153+
154+func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
155+func (p *scriptPTY) Close() error {
156+ p.once.Do(func() { close(p.closed) })
157+ return nil
158+}
159+func (p *scriptPTY) Resize(domain.Size) error { return nil }
160+func (p *scriptPTY) Pid() int { return 4242 }
161+
162+func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
164+ defer releases[0]()
165+ defer releases[1]()
166+ sess.active = 0
167+ sess.mu.Lock()
168+ sess.tabs[0].attention = true
169+ sess.tabs[0].attentionAt = time.Unix(1, 0)
170+ sess.tabs[1].attention = true
171+ sess.tabs[1].attentionAt = time.Unix(2, 0)
172+ sess.mu.Unlock()
173+
174+ d.paint(sess, ac, true)
175+ _ = mustOutputData(t, sends)
176+ // The deferred repaint (from ackAttention) targets all attached clients,
177+ // including this one, but the bars it would draw are already exactly what
178+ // the full paint above just sent: no second frame should follow.
179+ select {
180+ case f := <-sends:
181+ t.Fatalf("unexpected extra frame after ack: %v", f.Type)
182+ case <-time.After(50 * time.Millisecond):
183+ }
184+
185+ sess.mu.Lock()
186+ defer sess.mu.Unlock()
187+ require.False(t, sess.tabs[0].attention)
188+ require.True(t, sess.tabs[0].attentionAt.IsZero())
189+ require.True(t, sess.tabs[1].attention)
190+ require.False(t, sess.tabs[1].attentionAt.IsZero())
191+}
192+
193+func TestPaintPreservesBackgroundAttention(t *testing.T) {
194+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
195+ defer releases[0]()
196+ defer releases[1]()
197+ sess.active = 0
198+ sess.mu.Lock()
199+ sess.tabs[1].attention = true
200+ sess.tabs[1].attentionAt = time.Unix(2, 0)
201+ sess.mu.Unlock()
202+ d.setAttentionFrame(1)
203+
204+ d.paint(sess, ac, true)
205+ data := mustOutputData(t, sends)
206+
207+ require.Contains(t, string(data), "")
208+ sess.mu.Lock()
209+ defer sess.mu.Unlock()
210+ require.True(t, sess.tabs[1].attention)
211+}
212+
213+func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
214+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
215+ defer releases[0]()
216+ defer releases[1]()
217+ sess.active = 0
218+ sess.mu.Lock()
219+ sess.tabs[1].attention = true
220+ sess.tabs[1].attentionAt = time.Unix(2, 0)
221+ sess.mu.Unlock()
222+
223+ require.True(t, sess.switchTab(1))
224+ d.paint(sess, ac, true)
225+ data := mustOutputData(t, sends)
226+ // As above: the deferred all-clients repaint has nothing new to say about
227+ // this client's bars, so no second frame follows.
228+ select {
229+ case f := <-sends:
230+ t.Fatalf("unexpected extra frame after ack: %v", f.Type)
231+ case <-time.After(50 * time.Millisecond):
232+ }
233+
234+ require.NotContains(t, string(data), "")
235+ sess.mu.Lock()
236+ defer sess.mu.Unlock()
237+ require.False(t, sess.tabs[1].attention)
238+ require.True(t, sess.tabs[1].attentionAt.IsZero())
239+}
240+
241+func TestCloseRingingTabClearsClientBar(t *testing.T) {
242+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
243+ defer releases[0]()
244+ defer releases[1]()
245+ sess.active = 0
246+ sess.mu.Lock()
247+ ringing := sess.tabs[1]
248+ ringing.attention = true
249+ ringing.attentionAt = time.Unix(2, 0)
250+ sess.mu.Unlock()
251+ d.setAttentionFrame(1)
252+
253+ d.paint(sess, ac, true)
254+ before := mustOutputData(t, sends)
255+ require.Contains(t, string(before), "")
256+
257+ d.closeTab(sess, ringing, true)
258+ after := mustOutputData(t, sends)
259+ require.NotContains(t, string(after), "")
260+}
261+
262+// TestAnimationRepaintConfinedToBarRows is the regression test for the pulse
263+// animator: repaintAttachedClients must diff against the warm barCache
264+// (reset=false), so a repaint with nothing new to say sends no frame at all,
265+// and a repaint following only a pulse-frame advance touches the bar rows
266+// without re-emitting screen content.
267+func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
268+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
269+ defer releases[0]()
270+ defer releases[1]()
271+ sess.active = 0
272+ sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))
273+ sess.mu.Lock()
274+ sess.tabs[1].attention = true
275+ sess.tabs[1].attentionAt = time.Unix(1, 0)
276+ sess.mu.Unlock()
277+
278+ d.paint(sess, ac, true)
279+ full := mustOutputData(t, sends)
280+ require.Contains(t, string(full), "MIDSCREENMARKER")
281+
282+ // Nothing changed: a repaint against the warm cache must send nothing.
283+ d.repaintAttachedClients(sess)
284+ select {
285+ case f := <-sends:
286+ t.Fatalf("unexpected frame on unchanged repaint: %v", f.Type)
287+ case <-time.After(50 * time.Millisecond):
288+ }
289+
290+ // Advance the pulse frame (only the bells' rendered style changes) and
291+ // repaint: the bytes sent must not repaint screen content.
292+ d.advanceAttentionFrame()
293+ d.repaintAttachedClients(sess)
294+ data := mustOutputData(t, sends)
295+ require.NotContains(t, string(data), "MIDSCREENMARKER")
296+}
297+
298+// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the
299+// same invariant at the composeClientFrameWithState level: with a warm
300+// barCache and no screen damage, changing only bars.attentionFrame must
301+// produce damage confined to row 0 (top bar) and the last row (bottom bar).
302+func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
303+ d, sess, _, _, releases := newManualTabSession(t, 2)
304+ defer releases[0]()
305+ defer releases[1]()
306+ sess.active = 0
307+ sess.mu.Lock()
308+ sess.tabs[1].attention = true
309+ sess.tabs[1].attentionAt = time.Unix(1, 0)
310+ sess.mu.Unlock()
311+
312+ tb := sess.tabs[0]
313+ var cache barCache
314+ bars := d.barStateFor(sess, "")
315+ _, damage := composeClientFrameWithState(bars, tb, true, &cache)
316+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
317+ tb.screen.ClearDamage()
318+
319+ bars.attentionFrame++
320+ _, damage = composeClientFrameWithState(bars, tb, false, &cache)
321+ require.NotEmpty(t, damage)
322+ lastRow := tb.screen.Frame.Height + 1
323+ for _, dmg := range damage {
324+ require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
325+ }
326+}
327+
328+// TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a
329+// ringing tab in one session must refresh the bottom bar of a client attached
330+// to a DIFFERENT session, since that client's bar shows the ringing session's
331+// bell until it stops.
332+func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
333+ pA1, releaseA1 := newBlockingPTY(t)
334+ pA2, releaseA2 := newBlockingPTY(t)
335+ pB, releaseB := newBlockingPTY(t)
336+ defer releaseA1()
337+ defer releaseA2()
338+ defer releaseB()
339+
340+ d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2)
341+ sessA.name = "ringer"
342+ ringing := sessA.tabs[1]
343+ sessA.mu.Lock()
344+ ringing.attention = true
345+ ringing.attentionAt = time.Unix(1, 0)
346+ sessA.mu.Unlock()
347+ d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
348+
349+ trB, sendsB := newCapturingTransport(t)
350+ acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
351+ sctxB, cancelB := context.WithCancel(d.serveCtx)
352+ t.Cleanup(cancelB)
353+ tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}
354+ sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
355+ acB.setSession(sessB)
356+ acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
357+ d.sessions[sessB.id] = sessB
358+
359+ d.paint(sessB, acB, true)
360+ before := mustOutputData(t, sendsB)
361+ require.Contains(t, string(before), string(attentionGlyph))
362+
363+ d.closeTab(sessA, ringing, true)
364+ _ = mustOutputData(t, sendsA)
365+
366+ after := mustOutputData(t, sendsB)
367+ require.NotContains(t, string(after), string(attentionGlyph))
368+}
369+
370+func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
371+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
372+ defer releases[0]()
373+ defer releases[1]()
374+ defer releases[2]()
375+ sess.active = 0
376+ sess.mu.Lock()
377+ sess.tabs[1].attention = true
378+ sess.tabs[1].attentionAt = time.Unix(20, 0)
379+ sess.tabs[2].attention = true
380+ sess.tabs[2].attentionAt = time.Unix(10, 0)
381+ sess.mu.Unlock()
382+
383+ d.handleInput(sess, ac, []byte("\x1ba"))
384+
385+ require.Equal(t, 2, activeTabIndex(sess))
386+ _ = mustOutputData(t, sends)
387+}
388+
389+func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
390+ p1, release1 := newBlockingPTY(t)
391+ p2, release2 := newBlockingPTY(t)
392+ p3, release3 := newBlockingPTY(t)
393+ defer release1()
394+ defer release2()
395+ defer release3()
396+ d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
397+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
398+ defer cancel2()
399+ sess2 := &session{
400+ id: "other",
401+ name: "other",
402+ ctx: sctx2,
403+ cancel: cancel2,
404+ tabs: []*tab{
405+ {
406+ pty: p2,
407+ screen: vt.NewScreen(80, 23),
408+ dirty: make(chan struct{}, 1),
409+ size: domain.Size{Cols: 80, Rows: 23},
410+ ctx: sctx2,
411+ cancel: cancel2,
412+ attention: true,
413+ attentionAt: time.Unix(9, 0),
414+ },
415+ {
416+ pty: p3,
417+ screen: vt.NewScreen(80, 23),
418+ dirty: make(chan struct{}, 1),
419+ size: domain.Size{Cols: 80, Rows: 23},
420+ ctx: sctx2,
421+ cancel: cancel2,
422+ attention: true,
423+ attentionAt: time.Unix(5, 0),
424+ },
425+ },
426+ }
427+ d.sessions[sess2.id] = sess2
428+
429+ d.jumpAttention(sess1, ac)
430+
431+ require.Same(t, sess2, ac.currentSession())
432+ require.Same(t, ac, sess2.client)
433+ require.Nil(t, sess1.client)
434+ require.Equal(t, 1, activeTabIndex(sess2))
435+ _ = mustOutputData(t, sends)
436+}
437+
438+func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
439+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
440+ defer releases[0]()
441+ defer releases[1]()
442+ sess.active = 0
443+
444+ d.jumpAttention(sess, ac)
445+
446+ require.Same(t, sess, ac.currentSession())
447+ require.Equal(t, 0, activeTabIndex(sess))
448+ select {
449+ case f := <-sends:
450+ t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
451+ case <-time.After(20 * time.Millisecond):
452+ }
453+}
454+
455+func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
456+ d, sess, _, sends, releases := newManualTabSession(t, 2)
457+ defer releases[0]()
458+ defer releases[1]()
459+ clk := newManualAttentionClock()
460+ d.clock = clk
461+ sess.active = 0
462+
463+ ctx, cancel := context.WithCancel(context.Background())
464+ done := make(chan struct{})
465+ go func() {
466+ d.attentionAnimator(ctx)
467+ close(done)
468+ }()
469+
470+ select {
471+ case <-clk.timers:
472+ t.Fatal("parked animator requested timer with no attention")
473+ case <-time.After(20 * time.Millisecond):
474+ }
475+
476+ sess.mu.Lock()
477+ sess.tabs[1].attention = true
478+ sess.tabs[1].attentionAt = time.Unix(1, 0)
479+ sess.mu.Unlock()
480+ d.pokeAttentionTicker()
481+
482+ timer := clk.nextTimer(t)
483+ timer.fire()
484+ _ = mustOutputData(t, sends)
485+ require.Equal(t, 1, d.attentionFrame())
486+
487+ sess.mu.Lock()
488+ sess.tabs[1].attention = false
489+ sess.tabs[1].attentionAt = time.Time{}
490+ sess.mu.Unlock()
491+ d.pokeAttentionTicker()
492+
493+ require.Eventually(t, func() bool { return d.attentionFrame() == 0 }, time.Second, time.Millisecond)
494+ _ = mustOutputData(t, sends)
495+ select {
496+ case <-clk.timers:
497+ t.Fatal("animator requested new timer after attention cleared")
498+ case <-time.After(20 * time.Millisecond):
499+ }
500+
501+ cancel()
502+ require.Eventually(t, func() bool {
503+ select {
504+ case <-done:
505+ return true
506+ default:
507+ return false
508+ }
509+ }, time.Second, time.Millisecond)
510+}
511+
512+func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
513+ d := New(nil, stubClock{}, nil)
514+ require.Equal(t, 1, cap(d.animWake))
515+ d.pokeAttentionTicker()
516+ d.pokeAttentionTicker()
517+ require.Len(t, d.animWake, 1)
518+}
519+
520+type manualAttentionClock struct {
521+ timers chan *manualAttentionTimer
522+}
523+
524+func newManualAttentionClock() *manualAttentionClock {
525+ return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
526+}
527+
528+func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
529+func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
530+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
531+ c.timers <- t
532+ return t
533+}
534+func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
535+ t.Helper()
536+ select {
537+ case timer := <-c.timers:
538+ return timer
539+ case <-time.After(time.Second):
540+ t.Fatal("timed out waiting for attention timer")
541+ return nil
542+ }
543+}
544+
545+type manualAttentionTimer struct {
546+ ch chan time.Time
547+}
548+
549+func (t *manualAttentionTimer) C() <-chan time.Time { return t.ch }
550+func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
551+func (t *manualAttentionTimer) Stop() bool { return true }
552+func (t *manualAttentionTimer) fire() { t.ch <- time.Now() }
M internal/usecase/daemon/client.go +1 -0
@@ -45,6 +45,7 @@ import (
4545 type attachedClient struct {
4646 tr ports.Transport
4747 rend *renderer.Renderer
48+ bars barCache // only touched while sendMu is held
4849 size domain.Size
4950 keys *keys.Router
5051 sess Guarded[*session]
@@ -45,6 +45,7 @@ import (
45 type attachedClient struct {45 type attachedClient struct {
46 tr ports.Transport46 tr ports.Transport
47 rend *renderer.Renderer47 rend *renderer.Renderer
48+ bars barCache // only touched while sendMu is held
48 size domain.Size49 size domain.Size
49 keys *keys.Router50 keys *keys.Router
50 sess Guarded[*session]51 sess Guarded[*session]
M internal/usecase/daemon/copymode.go +10 -2
@@ -307,8 +307,16 @@ func isCopyEscapePrefix(data []byte) bool {
307307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308308 }
309309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {
310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311+ styles := newThemeStyles(bars.theme)
311312 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap, copyStatusStyle, selectionStyle)
313+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
315+ frame := renderer.NewFrame(width, screenRows+2)
316+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
317+ for y := range screenRows {
318+ copy(frame.Row(y+1), copyFrame.Row(y))
319+ }
320+ copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
313321 return frame, []renderer.Damage{renderer.FullRedraw()}
314322 }
@@ -307,8 +307,16 @@ func isCopyEscapePrefix(data []byte) bool {
307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308 }308 }
309 309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311+ styles := newThemeStyles(bars.theme)
311 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)312 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap, copyStatusStyle, selectionStyle)313+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
315+ frame := renderer.NewFrame(width, screenRows+2)
316+ drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
317+ for y := range screenRows {
318+ copy(frame.Row(y+1), copyFrame.Row(y))
319+ }
320+ copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
313 return frame, []renderer.Damage{renderer.FullRedraw()}321 return frame, []renderer.Damage{renderer.FullRedraw()}
314 }322 }
M internal/usecase/daemon/copymode_test.go +21 -0
@@ -18,6 +18,7 @@ import (
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
2324 // --- test doubles -----------------------------------------------------------
@@ -25,6 +26,26 @@ import (
2526 // stubClock returns timers whose channel never fires, so a scheduler under it
2627 // blocks in its debounce loop until the session context is cancelled. Used by
2728
29+func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
32+ defer release()
33+ sess.name = "work"
34+ tb := sess.activeTab()
35+ tb.screen = vt.NewScreen(12, 3)
36+ tb.screen.Write([]byte("live"))
37+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
38+ mode := scopy.NewMode(snap)
39+
40+ frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
41+
42+ require.Equal(t, 12, frame.Width)
43+ require.Equal(t, 5, frame.Height)
44+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
45+ require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
46+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47+}
48+
2849 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
2950 writes := make(chan []byte, 1)
3051 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -18,6 +18,7 @@ import (
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
23 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
@@ -25,6 +26,26 @@ import (
25 // stubClock returns timers whose channel never fires, so a scheduler under it26 // stubClock returns timers whose channel never fires, so a scheduler under it
26 // blocks in its debounce loop until the session context is cancelled. Used by27 // blocks in its debounce loop until the session context is cancelled. Used by
27 28
29+func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
32+ defer release()
33+ sess.name = "work"
34+ tb := sess.activeTab()
35+ tb.screen = vt.NewScreen(12, 3)
36+ tb.screen.Write([]byte("live"))
37+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
38+ mode := scopy.NewMode(snap)
39+
40+ frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
41+
42+ require.Equal(t, 12, frame.Width)
43+ require.Equal(t, 5, frame.Height)
44+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
45+ require.Contains(t, rowText(frame.Row(4)), "[VISUAL]")
46+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
47+}
48+
28 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {49 func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
29 writes := make(chan []byte, 1)50 writes := make(chan []byte, 1)
30 p, _ := newBlockingPTYWithWrites(t, writes)51 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/daemon.go +9 -0
@@ -81,6 +81,10 @@ type Daemon struct {
8181 // with Wait.
8282 notifies []chan struct{}
8383
84+ attnMu sync.Mutex
85+ animFrame int
86+ animWake chan struct{}
87+
8488 ptys ports.PTYFactory
8589 clock ports.Clock
8690 log *slog.Logger
@@ -136,6 +140,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
136140 baseEnv: os.Environ(),
137141 shell: shell,
138142 done: make(chan struct{}),
143+ animWake: make(chan struct{}, 1),
139144 }
140145 for _, o := range opts {
141146 o(d)
@@ -152,6 +157,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
152157 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
153158 defer d.hardCancel()
154159
160+ d.sessWg.Go(func() {
161+ d.attentionAnimator(d.serveCtx)
162+ })
163+
155164 // Break the accept loop when either the parent context is cancelled or the
156165 // registry drains to empty: both close the listener, which fails Accept.
157166 go func() {
@@ -81,6 +81,10 @@ type Daemon struct {
81 // with Wait.81 // with Wait.
82 notifies []chan struct{}82 notifies []chan struct{}
83 83
84+ attnMu sync.Mutex
85+ animFrame int
86+ animWake chan struct{}
87+
84 ptys ports.PTYFactory88 ptys ports.PTYFactory
85 clock ports.Clock89 clock ports.Clock
86 log *slog.Logger90 log *slog.Logger
@@ -136,6 +140,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
136 baseEnv: os.Environ(),140 baseEnv: os.Environ(),
137 shell: shell,141 shell: shell,
138 done: make(chan struct{}),142 done: make(chan struct{}),
143+ animWake: make(chan struct{}, 1),
139 }144 }
140 for _, o := range opts {145 for _, o := range opts {
141 o(d)146 o(d)
@@ -152,6 +157,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
152 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())157 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
153 defer d.hardCancel()158 defer d.hardCancel()
154 159
160+ d.sessWg.Go(func() {
161+ d.attentionAnimator(d.serveCtx)
162+ })
163+
155 // Break the accept loop when either the parent context is cancelled or the164 // Break the accept loop when either the parent context is cancelled or the
156 // registry drains to empty: both close the listener, which fails Accept.165 // registry drains to empty: both close the listener, which fails Accept.
157 go func() {166 go func() {
M internal/usecase/daemon/input.go +40 -2
@@ -26,6 +26,9 @@
2626 package daemon
2727
2828 import (
29+ "bytes"
30+ "strconv"
31+
2932 scopy "github.com/bnema/vev/internal/usecase/copy"
3033 "github.com/bnema/vev/internal/usecase/keys"
3134 "github.com/bnema/vev/internal/usecase/mouse"
@@ -92,10 +95,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
9295 tb.mu.Unlock()
9396
9497 if mouseMode != 0 {
95- if !mouseSGR || ev.Row >= childRows {
98+ if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
9699 return
97100 }
98- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
101+ daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))
99102 return
100103 }
101104
@@ -161,6 +164,39 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
161164 }
162165 }
163166
167+func sgrRowOffset(raw []byte, delta int) []byte {
168+ if len(raw) < len("\x1b[<0;1;1M") {
169+ return raw
170+ }
171+ end := len(raw) - 1
172+ if raw[0] != '\x1b' || raw[1] != '[' || raw[2] != '<' || (raw[end] != 'M' && raw[end] != 'm') {
173+ return raw
174+ }
175+
176+ parts := bytes.Split(raw[3:end], []byte(";"))
177+ if len(parts) != 3 {
178+ return raw
179+ }
180+ cy, err := strconv.Atoi(string(parts[2]))
181+ if err != nil {
182+ return raw
183+ }
184+ cy += delta
185+ if cy < 1 {
186+ return raw
187+ }
188+
189+ out := make([]byte, 0, len(raw)+4)
190+ out = append(out, raw[:3]...)
191+ out = append(out, parts[0]...)
192+ out = append(out, ';')
193+ out = append(out, parts[1]...)
194+ out = append(out, ';')
195+ out = strconv.AppendInt(out, int64(cy), 10)
196+ out = append(out, raw[end])
197+ return out
198+}
199+
164200 type daemonKeyHandler struct {
165201 d *Daemon
166202 ac *attachedClient
@@ -188,6 +224,8 @@ func (h daemonKeyHandler) Action(action keys.Action) {
188224 switch action {
189225 case keys.ActionOpenPalette:
190226 h.d.enterPalette(sess, h.ac)
227+ case keys.ActionJumpAttention:
228+ h.d.jumpAttention(sess, h.ac)
191229 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
192230 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
193231 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -26,6 +26,9 @@
26 package daemon26 package daemon
27 27
28 import (28 import (
29+ "bytes"
30+ "strconv"
31+
29 scopy "github.com/bnema/vev/internal/usecase/copy"32 scopy "github.com/bnema/vev/internal/usecase/copy"
30 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
31 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
@@ -92,10 +95,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
92 tb.mu.Unlock()95 tb.mu.Unlock()
93 96
94 if mouseMode != 0 {97 if mouseMode != 0 {
95- if !mouseSGR || ev.Row >= childRows {98+ if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
96 return99 return
97 }100 }
98- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)101+ daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))
99 return102 return
100 }103 }
101 104
@@ -161,6 +164,39 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
161 }164 }
162 }165 }
163 166
167+func sgrRowOffset(raw []byte, delta int) []byte {
168+ if len(raw) < len("\x1b[<0;1;1M") {
169+ return raw
170+ }
171+ end := len(raw) - 1
172+ if raw[0] != '\x1b' || raw[1] != '[' || raw[2] != '<' || (raw[end] != 'M' && raw[end] != 'm') {
173+ return raw
174+ }
175+
176+ parts := bytes.Split(raw[3:end], []byte(";"))
177+ if len(parts) != 3 {
178+ return raw
179+ }
180+ cy, err := strconv.Atoi(string(parts[2]))
181+ if err != nil {
182+ return raw
183+ }
184+ cy += delta
185+ if cy < 1 {
186+ return raw
187+ }
188+
189+ out := make([]byte, 0, len(raw)+4)
190+ out = append(out, raw[:3]...)
191+ out = append(out, parts[0]...)
192+ out = append(out, ';')
193+ out = append(out, parts[1]...)
194+ out = append(out, ';')
195+ out = strconv.AppendInt(out, int64(cy), 10)
196+ out = append(out, raw[end])
197+ return out
198+}
199+
164 type daemonKeyHandler struct {200 type daemonKeyHandler struct {
165 d *Daemon201 d *Daemon
166 ac *attachedClient202 ac *attachedClient
@@ -188,6 +224,8 @@ func (h daemonKeyHandler) Action(action keys.Action) {
188 switch action {224 switch action {
189 case keys.ActionOpenPalette:225 case keys.ActionOpenPalette:
190 h.d.enterPalette(sess, h.ac)226 h.d.enterPalette(sess, h.ac)
227+ case keys.ActionJumpAttention:
228+ h.d.jumpAttention(sess, h.ac)
191 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,229 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
192 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,230 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
193 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:231 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +37 -2
@@ -206,6 +206,33 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
206206 require.Nil(t, ac.copyMode)
207207 }
208208
209+func TestSGRRowOffset(t *testing.T) {
210+ t.Parallel()
211+
212+ cases := []struct {
213+ name string
214+ raw []byte
215+ delta int
216+ want []byte
217+ }{
218+ {name: "decrements press row", raw: []byte("\x1b[<0;2;3M"), delta: -1, want: []byte("\x1b[<0;2;2M")},
219+ {name: "increments release row", raw: []byte("\x1b[<0;2;3m"), delta: 2, want: []byte("\x1b[<0;2;5m")},
220+ {name: "leaves empty unchanged", raw: []byte(""), delta: -1, want: []byte("")},
221+ {name: "leaves non sgr unchanged", raw: []byte("abc"), delta: -1, want: []byte("abc")},
222+ {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
223+ {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
224+ {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
225+ {name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
226+ {name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
227+ }
228+
229+ for _, tc := range cases {
230+ t.Run(tc.name, func(t *testing.T) {
231+ require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
232+ })
233+ }
234+}
235+
209236 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210237 writes := make(chan []byte, 4)
211238 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -230,9 +257,17 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
230257
231258 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
232259 d.handleInput(sess, ac, raw)
233- require.Equal(t, raw, <-writes)
260+ require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
234261
235- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
262+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
263+ select {
264+ case got := <-writes:
265+ t.Fatalf("top-row mouse report forwarded: %q", got)
266+ default:
267+ }
268+
269+ statusRowReport := []byte("\x1b[<0;1;25M")
270+ d.handleInput(sess, ac, statusRowReport)
236271 select {
237272 case got := <-writes:
238273 t.Fatalf("status-row mouse report forwarded: %q", got)
@@ -206,6 +206,33 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
206 require.Nil(t, ac.copyMode)206 require.Nil(t, ac.copyMode)
207 }207 }
208 208
209+func TestSGRRowOffset(t *testing.T) {
210+ t.Parallel()
211+
212+ cases := []struct {
213+ name string
214+ raw []byte
215+ delta int
216+ want []byte
217+ }{
218+ {name: "decrements press row", raw: []byte("\x1b[<0;2;3M"), delta: -1, want: []byte("\x1b[<0;2;2M")},
219+ {name: "increments release row", raw: []byte("\x1b[<0;2;3m"), delta: 2, want: []byte("\x1b[<0;2;5m")},
220+ {name: "leaves empty unchanged", raw: []byte(""), delta: -1, want: []byte("")},
221+ {name: "leaves non sgr unchanged", raw: []byte("abc"), delta: -1, want: []byte("abc")},
222+ {name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
223+ {name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
224+ {name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
225+ {name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
226+ {name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
227+ }
228+
229+ for _, tc := range cases {
230+ t.Run(tc.name, func(t *testing.T) {
231+ require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
232+ })
233+ }
234+}
235+
209 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {236 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210 writes := make(chan []byte, 4)237 writes := make(chan []byte, 4)
211 p, _ := newBlockingPTYWithWrites(t, writes)238 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -230,9 +257,17 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
230 257
231 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))258 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
232 d.handleInput(sess, ac, raw)259 d.handleInput(sess, ac, raw)
233- require.Equal(t, raw, <-writes)260+ require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
234 261
235- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))262+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
263+ select {
264+ case got := <-writes:
265+ t.Fatalf("top-row mouse report forwarded: %q", got)
266+ default:
267+ }
268+
269+ statusRowReport := []byte("\x1b[<0;1;25M")
270+ d.handleInput(sess, ac, statusRowReport)
236 select {271 select {
237 case got := <-writes:272 case got := <-writes:
238 t.Fatalf("status-row mouse report forwarded: %q", got)273 t.Fatalf("status-row mouse report forwarded: %q", got)
M internal/usecase/daemon/picker.go +15 -2
@@ -61,8 +61,17 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
6161 for _, s := range sessions {
6262 s.mu.Lock()
6363 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
64- for i := range s.tabs {
65- view.Tabs[i] = strconv.Itoa(i + 1)
64+ sessionAttention := false
65+ for i, tb := range s.tabs {
66+ label := strconv.Itoa(i + 1)
67+ if tb.attention {
68+ label = attentionSuffix(label)
69+ sessionAttention = true
70+ }
71+ view.Tabs[i] = label
72+ }
73+ if sessionAttention {
74+ view.Name = attentionSuffix(view.Name)
6675 }
6776 if s == cur {
6877 curTab = s.active
@@ -73,6 +82,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7382 return views, curTab
7483 }
7584
85+func attentionSuffix(label string) string {
86+ return label + " " + string(attentionGlyph)
87+}
88+
7689 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
7790 sess := ac.currentSession()
7891 if sess == nil {
@@ -61,8 +61,17 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
61 for _, s := range sessions {61 for _, s := range sessions {
62 s.mu.Lock()62 s.mu.Lock()
63 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}63 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
64- for i := range s.tabs {64+ sessionAttention := false
65- view.Tabs[i] = strconv.Itoa(i + 1)65+ for i, tb := range s.tabs {
66+ label := strconv.Itoa(i + 1)
67+ if tb.attention {
68+ label = attentionSuffix(label)
69+ sessionAttention = true
70+ }
71+ view.Tabs[i] = label
72+ }
73+ if sessionAttention {
74+ view.Name = attentionSuffix(view.Name)
66 }75 }
67 if s == cur {76 if s == cur {
68 curTab = s.active77 curTab = s.active
@@ -73,6 +82,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
73 return views, curTab82 return views, curTab
74 }83 }
75 84
85+func attentionSuffix(label string) string {
86+ return label + " " + string(attentionGlyph)
87+}
88+
76 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {89 func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
77 sess := ac.currentSession()90 sess := ac.currentSession()
78 if sess == nil {91 if sess == nil {
M internal/usecase/daemon/picker_test.go +23 -0
@@ -32,6 +32,29 @@ func TestAltTForwardsToPTY(t *testing.T) {
3232 require.Equal(t, []byte("\x1bt"), <-writes)
3333 }
3434
35+func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
36+ d := newTestDaemon(t, nil, stubClock{})
37+ ctx, cancel := context.WithCancel(d.serveCtx)
38+ defer cancel()
39+ current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
40+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
41+ ringing.mu.Lock()
42+ ringing.tabs[1].attention = true
43+ ringing.tabs[1].attentionAt = time.Unix(10, 0)
44+ ringing.mu.Unlock()
45+ d.sessions[current.id] = current
46+ d.sessions[ringing.id] = ringing
47+
48+ views, curTab := d.pickerViews(current)
49+
50+ require.Equal(t, 0, curTab)
51+ require.Len(t, views, 2)
52+ require.Equal(t, "alpha", views[0].Name)
53+ require.Equal(t, []string{"1", "2"}, views[0].Tabs)
54+ require.Equal(t, "beta ", views[1].Name)
55+ require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
56+}
57+
3558 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
3659 d, sess, ac, sends, releases := newManualTabSession(t, 2)
3760 defer func() {
@@ -32,6 +32,29 @@ func TestAltTForwardsToPTY(t *testing.T) {
32 require.Equal(t, []byte("\x1bt"), <-writes)32 require.Equal(t, []byte("\x1bt"), <-writes)
33 }33 }
34 34
35+func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
36+ d := newTestDaemon(t, nil, stubClock{})
37+ ctx, cancel := context.WithCancel(d.serveCtx)
38+ defer cancel()
39+ current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
40+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
41+ ringing.mu.Lock()
42+ ringing.tabs[1].attention = true
43+ ringing.tabs[1].attentionAt = time.Unix(10, 0)
44+ ringing.mu.Unlock()
45+ d.sessions[current.id] = current
46+ d.sessions[ringing.id] = ringing
47+
48+ views, curTab := d.pickerViews(current)
49+
50+ require.Equal(t, 0, curTab)
51+ require.Len(t, views, 2)
52+ require.Equal(t, "alpha", views[0].Name)
53+ require.Equal(t, []string{"1", "2"}, views[0].Tabs)
54+ require.Equal(t, "beta ", views[1].Name)
55+ require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
56+}
57+
35 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {58 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
36 d, sess, ac, sends, releases := newManualTabSession(t, 2)59 d, sess, ac, sends, releases := newManualTabSession(t, 2)
37 defer func() {60 defer func() {
M internal/usecase/daemon/render.go +94 -10
@@ -47,8 +47,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
4747 defer d.sessWg.Done()
4848 buf := make([]byte, ptyReadBufSize)
4949 var resp []byte
50+ attentionCh := make(chan struct{}, 1)
5051 tb.mu.Lock()
5152 tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
53+ tb.screen.OnBell = func() { signal(attentionCh) }
54+ tb.screen.OnNotify = func(string, string) { signal(attentionCh) }
5255 tb.mu.Unlock()
5356 for {
5457 n, err := tb.pty.Read(buf)
@@ -59,6 +62,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
5962 tb.screen.Write(data)
6063 isSyncing := tb.screen.SyncUpdateActive()
6164 tb.mu.Unlock()
65+ select {
66+ case <-attentionCh:
67+ d.noteAttention(sess, tb)
68+ default:
69+ }
6270 if len(resp) > 0 {
6371 if _, writeErr := tb.pty.Write(resp); writeErr != nil {
6472 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
@@ -273,19 +281,25 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
273281 if !promptActive {
274282 ac.promptMu.Unlock()
275283 }
284+ if sess.ackAttention(tb) {
285+ defer d.repaintAllAttachedClients()
286+ }
287+ bars := d.barStateFor(sess, copyFeedback)
288+ bars.theme = ac.getTheme()
276289
277290 styles := newThemeStyles(ac.getTheme())
278291
279292 tb.mu.Lock()
280293 if reset || copyActive || pickerActive || paletteActive || promptActive {
281294 ac.rend.Reset()
295+ ac.bars.Reset()
282296 }
283297 if reset || pickerActive || paletteActive || promptActive {
284298 ac.lastCursor.valid = false
285299 }
286- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles)
300+ frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)
287301 if copyActive {
288- frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)
302+ frame, damage = composeCopyClientFrame(copyMode, tb, bars)
289303 }
290304 if pickerActive {
291305 if previewTab == tb {
@@ -338,7 +352,7 @@ func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
338352 if !ok {
339353 style = 5
340354 }
341- return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
355+ return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true}
342356 }
343357
344358 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
@@ -392,18 +406,88 @@ func resolveThemeStyles(styles []themeStyles) themeStyles {
392406 return newThemeStyles(themeui.Theme{})
393407 }
394408
395-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
409+type barCache struct {
410+ top []renderer.Cell
411+ bottom []renderer.Cell
412+}
413+
414+func (c *barCache) Reset() {
415+ c.top = nil
416+ c.bottom = nil
417+}
418+
419+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
420+ return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
421+}
422+
423+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
424+ var cache *barCache
425+ if len(caches) > 0 {
426+ cache = caches[0]
427+ }
428+ styles := newThemeStyles(bars.theme)
396429 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
397- frame := renderer.NewFrame(width, screenRows+1)
430+ frame := renderer.NewFrame(width, screenRows+2)
431+ topBar := frame.Row(0)
432+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)
398433 for y := range screenRows {
399- copy(frame.Row(y), tb.screen.Frame.Row(y))
434+ copy(frame.Row(y+1), tb.screen.Frame.Row(y))
400435 }
401- styleSet := resolveThemeStyles(styles)
402- drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet)
436+ bottomBar := frame.Row(screenRows + 1)
437+ drawStatusBarState(bottomBar, bars, styles)
403438 if full {
439+ if cache != nil {
440+ cache.capture(topBar, bottomBar)
441+ }
404442 return frame, []renderer.Damage{renderer.FullRedraw()}
405443 }
406- damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
407- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
444+ damage := offsetDamage(tb.screen.Damage())
445+ if cache == nil || !sameCells(cache.top, topBar) {
446+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
447+ }
448+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
449+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
450+ }
451+ if cache != nil {
452+ cache.capture(topBar, bottomBar)
453+ }
408454 return frame, damage
409455 }
456+
457+func (c *barCache) capture(top, bottom []renderer.Cell) {
458+ c.top = cloneCells(c.top, top)
459+ c.bottom = cloneCells(c.bottom, bottom)
460+}
461+
462+func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
463+ if cap(dst) < len(src) {
464+ dst = make([]renderer.Cell, len(src))
465+ } else {
466+ dst = dst[:len(src)]
467+ }
468+ copy(dst, src)
469+ return dst
470+}
471+
472+func sameCells(a, b []renderer.Cell) bool {
473+ if len(a) != len(b) {
474+ return false
475+ }
476+ for i := range a {
477+ if !a[i].Equal(b[i]) {
478+ return false
479+ }
480+ }
481+ return true
482+}
483+
484+func offsetDamage(in []renderer.Damage) []renderer.Damage {
485+ out := make([]renderer.Damage, len(in))
486+ for i, d := range in {
487+ out[i] = d
488+ if d.Kind != renderer.DamageFullRedraw {
489+ out[i].Y++
490+ }
491+ }
492+ return out
493+}
@@ -47,8 +47,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
47 defer d.sessWg.Done()47 defer d.sessWg.Done()
48 buf := make([]byte, ptyReadBufSize)48 buf := make([]byte, ptyReadBufSize)
49 var resp []byte49 var resp []byte
50+ attentionCh := make(chan struct{}, 1)
50 tb.mu.Lock()51 tb.mu.Lock()
51 tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }52 tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
53+ tb.screen.OnBell = func() { signal(attentionCh) }
54+ tb.screen.OnNotify = func(string, string) { signal(attentionCh) }
52 tb.mu.Unlock()55 tb.mu.Unlock()
53 for {56 for {
54 n, err := tb.pty.Read(buf)57 n, err := tb.pty.Read(buf)
@@ -59,6 +62,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
59 tb.screen.Write(data)62 tb.screen.Write(data)
60 isSyncing := tb.screen.SyncUpdateActive()63 isSyncing := tb.screen.SyncUpdateActive()
61 tb.mu.Unlock()64 tb.mu.Unlock()
65+ select {
66+ case <-attentionCh:
67+ d.noteAttention(sess, tb)
68+ default:
69+ }
62 if len(resp) > 0 {70 if len(resp) > 0 {
63 if _, writeErr := tb.pty.Write(resp); writeErr != nil {71 if _, writeErr := tb.pty.Write(resp); writeErr != nil {
64 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)72 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
@@ -273,19 +281,25 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
273 if !promptActive {281 if !promptActive {
274 ac.promptMu.Unlock()282 ac.promptMu.Unlock()
275 }283 }
284+ if sess.ackAttention(tb) {
285+ defer d.repaintAllAttachedClients()
286+ }
287+ bars := d.barStateFor(sess, copyFeedback)
288+ bars.theme = ac.getTheme()
276 289
277 styles := newThemeStyles(ac.getTheme())290 styles := newThemeStyles(ac.getTheme())
278 291
279 tb.mu.Lock()292 tb.mu.Lock()
280 if reset || copyActive || pickerActive || paletteActive || promptActive {293 if reset || copyActive || pickerActive || paletteActive || promptActive {
281 ac.rend.Reset()294 ac.rend.Reset()
295+ ac.bars.Reset()
282 }296 }
283 if reset || pickerActive || paletteActive || promptActive {297 if reset || pickerActive || paletteActive || promptActive {
284 ac.lastCursor.valid = false298 ac.lastCursor.valid = false
285 }299 }
286- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles)300+ frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)
287 if copyActive {301 if copyActive {
288- frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)302+ frame, damage = composeCopyClientFrame(copyMode, tb, bars)
289 }303 }
290 if pickerActive {304 if pickerActive {
291 if previewTab == tb {305 if previewTab == tb {
@@ -338,7 +352,7 @@ func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
338 if !ok {352 if !ok {
339 style = 5353 style = 5
340 }354 }
341- return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}355+ return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true}
342 }356 }
343 357
344 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {358 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
@@ -392,18 +406,88 @@ func resolveThemeStyles(styles []themeStyles) themeStyles {
392 return newThemeStyles(themeui.Theme{})406 return newThemeStyles(themeui.Theme{})
393 }407 }
394 408
395-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {409+type barCache struct {
410+ top []renderer.Cell
411+ bottom []renderer.Cell
412+}
413+
414+func (c *barCache) Reset() {
415+ c.top = nil
416+ c.bottom = nil
417+}
418+
419+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
420+ return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
421+}
422+
423+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
424+ var cache *barCache
425+ if len(caches) > 0 {
426+ cache = caches[0]
427+ }
428+ styles := newThemeStyles(bars.theme)
396 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height429 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
397- frame := renderer.NewFrame(width, screenRows+1)430+ frame := renderer.NewFrame(width, screenRows+2)
431+ topBar := frame.Row(0)
432+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)
398 for y := range screenRows {433 for y := range screenRows {
399- copy(frame.Row(y), tb.screen.Frame.Row(y))434+ copy(frame.Row(y+1), tb.screen.Frame.Row(y))
400 }435 }
401- styleSet := resolveThemeStyles(styles)436+ bottomBar := frame.Row(screenRows + 1)
402- drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet)437+ drawStatusBarState(bottomBar, bars, styles)
403 if full {438 if full {
439+ if cache != nil {
440+ cache.capture(topBar, bottomBar)
441+ }
404 return frame, []renderer.Damage{renderer.FullRedraw()}442 return frame, []renderer.Damage{renderer.FullRedraw()}
405 }443 }
406- damage := append([]renderer.Damage(nil), tb.screen.Damage()...)444+ damage := offsetDamage(tb.screen.Damage())
407- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})445+ if cache == nil || !sameCells(cache.top, topBar) {
446+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
447+ }
448+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
449+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
450+ }
451+ if cache != nil {
452+ cache.capture(topBar, bottomBar)
453+ }
408 return frame, damage454 return frame, damage
409 }455 }
456+
457+func (c *barCache) capture(top, bottom []renderer.Cell) {
458+ c.top = cloneCells(c.top, top)
459+ c.bottom = cloneCells(c.bottom, bottom)
460+}
461+
462+func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
463+ if cap(dst) < len(src) {
464+ dst = make([]renderer.Cell, len(src))
465+ } else {
466+ dst = dst[:len(src)]
467+ }
468+ copy(dst, src)
469+ return dst
470+}
471+
472+func sameCells(a, b []renderer.Cell) bool {
473+ if len(a) != len(b) {
474+ return false
475+ }
476+ for i := range a {
477+ if !a[i].Equal(b[i]) {
478+ return false
479+ }
480+ }
481+ return true
482+}
483+
484+func offsetDamage(in []renderer.Damage) []renderer.Damage {
485+ out := make([]renderer.Damage, len(in))
486+ for i, d := range in {
487+ out[i] = d
488+ if d.Kind != renderer.DamageFullRedraw {
489+ out[i].Y++
490+ }
491+ }
492+ return out
493+}
M internal/usecase/daemon/render_test.go +74 -5
@@ -279,14 +279,17 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
279279 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
280280 ac.setSession(sess)
281281
282- d.resize(sess, ac, domain.Size{Cols: 4, Rows: 3})
282+ // Client rows are one more than the equivalent case in a single-bar
283+ // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
284+ // so a client height of 4 (not 3) is what yields the same 2-row tab.
285+ d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
283286 require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))
284287 require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))
285288 require.Equal(t, 2, win.scrollback.Len())
286289 require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))
287290 require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))
288291
289- d.resize(sess, ac, domain.Size{Cols: 6, Rows: 5})
292+ d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
290293 require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))
291294 require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))
292295 require.Equal(t, 2, win.scrollback.Len())
@@ -304,13 +307,79 @@ func cellsString(row []renderer.Cell) string {
304307 return string(runes)
305308 }
306309
310+func TestTabSizeReservesTopAndBottomChromeRows(t *testing.T) {
311+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tabSize(domain.Size{Cols: 80, Rows: 24}))
312+ require.Equal(t, domain.Size{Cols: 80, Rows: 1}, tabSize(domain.Size{Cols: 80, Rows: 2}))
313+}
314+
315+func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) {
316+ damage := offsetDamage([]renderer.Damage{
317+ {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1},
318+ renderer.FullRedraw(),
319+ })
320+ require.Equal(t, []renderer.Damage{
321+ {Kind: renderer.DamageText, X: 2, Y: 4, Width: 4, Height: 1},
322+ renderer.FullRedraw(),
323+ }, damage)
324+}
325+
326+func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
327+ sess, win := newBarCacheTestSession()
328+ var cache barCache
329+
330+ _, damage := composeClientFrame(sess, win, true, "", &cache)
331+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
332+ win.screen.ClearDamage()
333+ win.screen.Write([]byte("x"))
334+
335+ _, damage = composeClientFrame(sess, win, false, "", &cache)
336+
337+ require.NotEmpty(t, damage)
338+ for _, d := range damage {
339+ require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
340+ require.NotEqual(t, win.screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
341+ }
342+}
343+
344+func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
345+ sess, win := newBarCacheTestSession()
346+ var cache barCache
347+ composeClientFrame(sess, win, true, "", &cache)
348+ win.screen.ClearDamage()
349+
350+ sess.name = "renamed"
351+ _, damage := composeClientFrame(sess, win, false, "", &cache)
352+
353+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.screen.Frame.Height + 1, Width: win.screen.Frame.Width, Height: 1}}, damage)
354+}
355+
356+func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
357+ sess, win := newBarCacheTestSession()
358+ var cache barCache
359+ cache.top = []renderer.Cell{renderer.BlankCell()}
360+ cache.bottom = []renderer.Cell{renderer.BlankCell()}
361+
362+ _, damage := composeClientFrame(sess, win, true, "", &cache)
363+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
364+ win.screen.ClearDamage()
365+
366+ _, damage = composeClientFrame(sess, win, false, "", &cache)
367+ require.Empty(t, damage)
368+}
369+
370+func newBarCacheTestSession() (*session, *tab) {
371+ win := &tab{screen: vt.NewScreen(20, 3), size: domain.Size{Cols: 20, Rows: 3}}
372+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
373+ return sess, win
374+}
375+
307376 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
308377 newSize := domain.Size{Cols: 100, Rows: 30}
309378
310379 p := portsmocks.NewMockPTY(t)
311380 var screenWidthAtResize int
312381 win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
313- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
382+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
314383 // The screen must not yet be resized when the PTY is: proves order.
315384 screenWidthAtResize = win.screen.Frame.Width
316385 return nil
@@ -336,7 +405,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
336405
337406 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
338407 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
339- require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
408+ require.Equal(t, 28, win.screen.Frame.Height, "screen reserves top and bottom chrome rows")
340409 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
341410 }
342411
@@ -647,7 +716,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
647716 win.screen.Write([]byte("\x1b[2;3H"))
648717 d.paint(sess, ac, false)
649718 data = mustOutputData(t, sends)
650- require.Contains(t, string(data), "\x1b[2;3H")
719+ require.Contains(t, string(data), "\x1b[3;3H")
651720 require.Contains(t, string(data), "\x1b[?25h")
652721
653722 win.screen.Write([]byte("\x1b[?25l"))
@@ -279,14 +279,17 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
279 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}279 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
280 ac.setSession(sess)280 ac.setSession(sess)
281 281
282- d.resize(sess, ac, domain.Size{Cols: 4, Rows: 3})282+ // Client rows are one more than the equivalent case in a single-bar
283+ // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
284+ // so a client height of 4 (not 3) is what yields the same 2-row tab.
285+ d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
283 require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))286 require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))
284 require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))287 require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))
285 require.Equal(t, 2, win.scrollback.Len())288 require.Equal(t, 2, win.scrollback.Len())
286 require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))289 require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))
287 require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))290 require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))
288 291
289- d.resize(sess, ac, domain.Size{Cols: 6, Rows: 5})292+ d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
290 require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))293 require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))
291 require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))294 require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))
292 require.Equal(t, 2, win.scrollback.Len())295 require.Equal(t, 2, win.scrollback.Len())
@@ -304,13 +307,79 @@ func cellsString(row []renderer.Cell) string {
304 return string(runes)307 return string(runes)
305 }308 }
306 309
310+func TestTabSizeReservesTopAndBottomChromeRows(t *testing.T) {
311+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tabSize(domain.Size{Cols: 80, Rows: 24}))
312+ require.Equal(t, domain.Size{Cols: 80, Rows: 1}, tabSize(domain.Size{Cols: 80, Rows: 2}))
313+}
314+
315+func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) {
316+ damage := offsetDamage([]renderer.Damage{
317+ {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1},
318+ renderer.FullRedraw(),
319+ })
320+ require.Equal(t, []renderer.Damage{
321+ {Kind: renderer.DamageText, X: 2, Y: 4, Width: 4, Height: 1},
322+ renderer.FullRedraw(),
323+ }, damage)
324+}
325+
326+func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
327+ sess, win := newBarCacheTestSession()
328+ var cache barCache
329+
330+ _, damage := composeClientFrame(sess, win, true, "", &cache)
331+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
332+ win.screen.ClearDamage()
333+ win.screen.Write([]byte("x"))
334+
335+ _, damage = composeClientFrame(sess, win, false, "", &cache)
336+
337+ require.NotEmpty(t, damage)
338+ for _, d := range damage {
339+ require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
340+ require.NotEqual(t, win.screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
341+ }
342+}
343+
344+func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
345+ sess, win := newBarCacheTestSession()
346+ var cache barCache
347+ composeClientFrame(sess, win, true, "", &cache)
348+ win.screen.ClearDamage()
349+
350+ sess.name = "renamed"
351+ _, damage := composeClientFrame(sess, win, false, "", &cache)
352+
353+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.screen.Frame.Height + 1, Width: win.screen.Frame.Width, Height: 1}}, damage)
354+}
355+
356+func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
357+ sess, win := newBarCacheTestSession()
358+ var cache barCache
359+ cache.top = []renderer.Cell{renderer.BlankCell()}
360+ cache.bottom = []renderer.Cell{renderer.BlankCell()}
361+
362+ _, damage := composeClientFrame(sess, win, true, "", &cache)
363+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
364+ win.screen.ClearDamage()
365+
366+ _, damage = composeClientFrame(sess, win, false, "", &cache)
367+ require.Empty(t, damage)
368+}
369+
370+func newBarCacheTestSession() (*session, *tab) {
371+ win := &tab{screen: vt.NewScreen(20, 3), size: domain.Size{Cols: 20, Rows: 3}}
372+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
373+ return sess, win
374+}
375+
307 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {376 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
308 newSize := domain.Size{Cols: 100, Rows: 30}377 newSize := domain.Size{Cols: 100, Rows: 30}
309 378
310 p := portsmocks.NewMockPTY(t)379 p := portsmocks.NewMockPTY(t)
311 var screenWidthAtResize int380 var screenWidthAtResize int
312 win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}381 win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
313- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {382+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
314 // The screen must not yet be resized when the PTY is: proves order.383 // The screen must not yet be resized when the PTY is: proves order.
315 screenWidthAtResize = win.screen.Frame.Width384 screenWidthAtResize = win.screen.Frame.Width
316 return nil385 return nil
@@ -336,7 +405,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
336 405
337 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")406 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
338 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")407 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
339- require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")408+ require.Equal(t, 28, win.screen.Frame.Height, "screen reserves top and bottom chrome rows")
340 require.True(t, gotOutput.Load(), "resize forces a full redraw output")409 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
341 }410 }
342 411
@@ -647,7 +716,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
647 win.screen.Write([]byte("\x1b[2;3H"))716 win.screen.Write([]byte("\x1b[2;3H"))
648 d.paint(sess, ac, false)717 d.paint(sess, ac, false)
649 data = mustOutputData(t, sends)718 data = mustOutputData(t, sends)
650- require.Contains(t, string(data), "\x1b[2;3H")719+ require.Contains(t, string(data), "\x1b[3;3H")
651 require.Contains(t, string(data), "\x1b[?25h")720 require.Contains(t, string(data), "\x1b[?25h")
652 721
653 win.screen.Write([]byte("\x1b[?25l"))722 win.screen.Write([]byte("\x1b[?25l"))
M internal/usecase/daemon/session.go +17 -7
@@ -20,9 +20,8 @@
2020 // Locking: a session's screen and per-client renderer shadow are both guarded
2121 // by tab.mu; the attached-client pointer by session.mu; the registry by
2222 // Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
23+// sendMu > Daemon.mu > session.mu > tab.mu — the PTY reader only ever takes
24+// tab.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -32,6 +31,7 @@ import (
3231 "strconv"
3332 "strings"
3433 "sync"
34+ "time"
3535
3636 "github.com/bnema/vev/internal/domain"
3737 "github.com/bnema/vev/internal/ports"
@@ -68,9 +68,12 @@ type tab struct {
6868 // previewClient tracks the one client currently previewing this tab in the picker.
6969 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
7070 previewClient *attachedClient
71- size domain.Size
72- ctx context.Context
73- cancel context.CancelFunc
71+ // attention and attentionAt are guarded by the owning session.mu.
72+ attention bool
73+ attentionAt time.Time
74+ size domain.Size
75+ ctx context.Context
76+ cancel context.CancelFunc
7477 }
7578
7679 // attachedClient is a client currently attached to a session's tab. rend is
@@ -155,7 +158,7 @@ func tabSize(clientSize domain.Size) domain.Size {
155158 if !clientSize.Valid() {
156159 clientSize = defaultSize
157160 }
158- rows := max(clientSize.Rows-1, 1)
161+ rows := max(clientSize.Rows-2, 1)
159162 return domain.Size{Cols: clientSize.Cols, Rows: rows}
160163 }
161164
@@ -242,6 +245,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
242245 d.killSession(sess, ports.ReasonSessionKilled)
243246 return
244247 }
248+ ringing := tb.attention
245249 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
246250 if sess.active >= len(sess.tabs) {
247251 sess.active = len(sess.tabs) - 1
@@ -259,12 +263,16 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
259263 if repaint && ac != nil {
260264 d.paint(sess, ac, true)
261265 }
266+ if ringing {
267+ d.repaintAllAttachedClients()
268+ }
262269 }
263270
264271 // ptyReader drains child output into the VT screen and pokes the dirty channel
265272 // (non-blocking: a full channel already means a render is pending). On any read
266273
267274 func (d *Daemon) killSession(sess *session, reason uint8) {
275+ ringing := sess.anyAttention()
268276 d.mu.Lock()
269277 if _, ok := d.sessions[sess.id]; !ok {
270278 d.mu.Unlock()
@@ -299,6 +307,8 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
299307 }
300308 if empty {
301309 d.doneOnce.Do(func() { close(d.done) })
310+ } else if ringing {
311+ d.repaintAllAttachedClients()
302312 }
303313
304314 if ac != nil {
@@ -20,9 +20,8 @@
20 // Locking: a session's screen and per-client renderer shadow are both guarded20 // Locking: a session's screen and per-client renderer shadow are both guarded
21 // by tab.mu; the attached-client pointer by session.mu; the registry by21 // by tab.mu; the attached-client pointer by session.mu; the registry by
22 // Daemon.mu. When more than one is held the order is always22 // Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >23+// sendMu > Daemon.mu > session.mu > tab.mu — the PTY reader only ever takes
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on24+// tab.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
@@ -32,6 +31,7 @@ import (
32 "strconv"31 "strconv"
33 "strings"32 "strings"
34 "sync"33 "sync"
34+ "time"
35 35
36 "github.com/bnema/vev/internal/domain"36 "github.com/bnema/vev/internal/domain"
37 "github.com/bnema/vev/internal/ports"37 "github.com/bnema/vev/internal/ports"
@@ -68,9 +68,12 @@ type tab struct {
68 // previewClient tracks the one client currently previewing this tab in the picker.68 // previewClient tracks the one client currently previewing this tab in the picker.
69 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.69 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
70 previewClient *attachedClient70 previewClient *attachedClient
71- size domain.Size71+ // attention and attentionAt are guarded by the owning session.mu.
72- ctx context.Context72+ attention bool
73- cancel context.CancelFunc73+ attentionAt time.Time
74+ size domain.Size
75+ ctx context.Context
76+ cancel context.CancelFunc
74 }77 }
75 78
76 // attachedClient is a client currently attached to a session's tab. rend is79 // attachedClient is a client currently attached to a session's tab. rend is
@@ -155,7 +158,7 @@ func tabSize(clientSize domain.Size) domain.Size {
155 if !clientSize.Valid() {158 if !clientSize.Valid() {
156 clientSize = defaultSize159 clientSize = defaultSize
157 }160 }
158- rows := max(clientSize.Rows-1, 1)161+ rows := max(clientSize.Rows-2, 1)
159 return domain.Size{Cols: clientSize.Cols, Rows: rows}162 return domain.Size{Cols: clientSize.Cols, Rows: rows}
160 }163 }
161 164
@@ -242,6 +245,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
242 d.killSession(sess, ports.ReasonSessionKilled)245 d.killSession(sess, ports.ReasonSessionKilled)
243 return246 return
244 }247 }
248+ ringing := tb.attention
245 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)249 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
246 if sess.active >= len(sess.tabs) {250 if sess.active >= len(sess.tabs) {
247 sess.active = len(sess.tabs) - 1251 sess.active = len(sess.tabs) - 1
@@ -259,12 +263,16 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
259 if repaint && ac != nil {263 if repaint && ac != nil {
260 d.paint(sess, ac, true)264 d.paint(sess, ac, true)
261 }265 }
266+ if ringing {
267+ d.repaintAllAttachedClients()
268+ }
262 }269 }
263 270
264 // ptyReader drains child output into the VT screen and pokes the dirty channel271 // ptyReader drains child output into the VT screen and pokes the dirty channel
265 // (non-blocking: a full channel already means a render is pending). On any read272 // (non-blocking: a full channel already means a render is pending). On any read
266 273
267 func (d *Daemon) killSession(sess *session, reason uint8) {274 func (d *Daemon) killSession(sess *session, reason uint8) {
275+ ringing := sess.anyAttention()
268 d.mu.Lock()276 d.mu.Lock()
269 if _, ok := d.sessions[sess.id]; !ok {277 if _, ok := d.sessions[sess.id]; !ok {
270 d.mu.Unlock()278 d.mu.Unlock()
@@ -299,6 +307,8 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
299 }307 }
300 if empty {308 if empty {
301 d.doneOnce.Do(func() { close(d.done) })309 d.doneOnce.Do(func() { close(d.done) })
310+ } else if ringing {
311+ d.repaintAllAttachedClients()
302 }312 }
303 313
304 if ac != nil {314 if ac != nil {
M internal/usecase/daemon/status.go +166 -17
@@ -26,36 +26,99 @@
2626 package daemon
2727
2828 import (
29+ "sort"
2930 "strconv"
31+ "strings"
32+ "time"
3033
34+ themeui "github.com/bnema/vev/internal/usecase/theme"
3135 "github.com/bnema/vev/pkg/renderer"
3236 )
3337
34-func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) {
35- for i := range row {
36- row[i] = renderer.BlankCell()
38+const (
39+ attentionGlyph = ''
40+ pulseFrameCount = 30
41+ pulseFrameInterval = 120 * time.Millisecond
42+)
43+
44+func pulseStyle(frame int) (renderer.Style, bool) {
45+ f := frame % pulseFrameCount
46+ if f == 0 {
47+ return renderer.DefaultStyle(), false
3748 }
38- styleSet := resolveThemeStyles(styles)
39- status := sess.statusSegments()
49+ peak := pulseFrameCount / 2
50+ distance := f - peak
51+ if distance < 0 {
52+ distance = -distance
53+ }
54+ intensity := 1 - float64(distance)/float64(peak)
55+ style := renderer.DefaultStyle()
56+ style.Bold = true
57+ style.Foreground = 244 + int(intensity*11)
58+ return style, true
59+}
60+
61+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) {
62+ clearStatusRow(row)
4063 x := 0
41- writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar)
4264 for _, w := range status.tabs {
43- style := styleSet.statusBar
65+ style := styles.statusBar
4466 if w.active {
45- style = styleSet.accent
67+ style = styles.accent
4668 }
47- writeStatusText(row, &x, " "+w.name+" ", style)
69+ writeStatusText(row, &x, " "+w.name, style)
70+ if w.attention {
71+ writeStatusText(row, &x, " ", style)
72+ writeBell(row, &x, frame)
73+ }
74+ writeStatusText(row, &x, " ", style)
75+ }
76+}
77+
78+func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
79+ clearStatusRow(row)
80+ leftText := " " + state.status.session + " "
81+ x := 0
82+ writeStatusText(row, &x, leftText, styles.statusBar)
83+
84+ reservedLeft := len([]rune(leftText))
85+ if state.copyFeedback != "" {
86+ drawRightPlainText(row, state.copyFeedback, reservedLeft, styles.statusBar)
87+ return
4888 }
89+ rightText := state.attentionStackText(len(row), reservedLeft)
4990 if rightText == "" {
5091 return
5192 }
52- style := styleSet.statusBar
5393 rightWidth := len([]rune(rightText)) + 1
54- if len(row)-x-1 < rightWidth {
94+ x = len(row) - rightWidth
95+ writeStatusText(row, &x, " ", styles.statusBar)
96+ writeAttentionText(row, &x, rightText, state.attentionFrame, styles.statusBar)
97+}
98+
99+func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
100+ if len([]rune(text))+1+reservedLeft > len(row) {
55101 return
56102 }
57- x = len(row) - rightWidth
58- writeStatusText(row, &x, " "+rightText, style)
103+ x := len(row) - len([]rune(text)) - 1
104+ writeStatusText(row, &x, " "+text, style)
105+}
106+
107+func clearStatusRow(row []renderer.Cell) {
108+ for i := range row {
109+ row[i] = renderer.BlankCell()
110+ }
111+}
112+
113+type barState struct {
114+ status statusSnapshot
115+ copyFeedback string
116+ otherAttention []string
117+ attentionFrame int
118+ // theme is the client's terminal theme, if reported. Its zero value
119+ // (Theme{}, Known: false) is a valid "no theme" default that resolves to
120+ // the pre-theme fallback styles (see newThemeStyles / theme.usable).
121+ theme themeui.Theme
59122 }
60123
61124 type statusSnapshot struct {
@@ -64,8 +127,9 @@ type statusSnapshot struct {
64127 }
65128
66129 type statusTab struct {
67- name string
68- active bool
130+ name string
131+ active bool
132+ attention bool
69133 }
70134
71135 func (s *session) statusSegments() statusSnapshot {
@@ -76,13 +140,98 @@ func (s *session) statusSegments() statusSnapshot {
76140 name += "*"
77141 }
78142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
79- for i := range s.tabs {
143+ for i, tb := range s.tabs {
80144 name := strconv.Itoa(i + 1)
81- snap.tabs[i] = statusTab{name: name, active: i == s.active}
145+ snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
82146 }
83147 return snap
84148 }
85149
150+func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
151+ state := barState{copyFeedback: copyFeedback}
152+ if d != nil {
153+ state.attentionFrame = d.attentionFrame()
154+ }
155+ if cur != nil {
156+ state.status = cur.statusSegments()
157+ }
158+ if d == nil || copyFeedback != "" {
159+ return state
160+ }
161+ type attentionSession struct {
162+ name string
163+ at time.Time
164+ }
165+ d.mu.Lock()
166+ attention := make([]attentionSession, 0, len(d.sessions))
167+ for _, sess := range d.sessions {
168+ if sess == cur {
169+ continue
170+ }
171+ sess.mu.Lock()
172+ var oldest time.Time
173+ found := false
174+ for _, tb := range sess.tabs {
175+ if !tb.attention {
176+ continue
177+ }
178+ if !found || tb.attentionAt.Before(oldest) {
179+ oldest = tb.attentionAt
180+ found = true
181+ }
182+ }
183+ if found {
184+ attention = append(attention, attentionSession{name: sess.name, at: oldest})
185+ }
186+ sess.mu.Unlock()
187+ }
188+ d.mu.Unlock()
189+ sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })
190+ state.otherAttention = make([]string, len(attention))
191+ for i := range attention {
192+ state.otherAttention[i] = attention[i].name
193+ }
194+ return state
195+}
196+
197+func (s barState) attentionStackText(width, reservedLeft int) string {
198+ if len(s.otherAttention) == 0 {
199+ return ""
200+ }
201+ parts := make([]string, len(s.otherAttention))
202+ for i, name := range s.otherAttention {
203+ parts[i] = string(attentionGlyph) + " " + name
204+ }
205+ full := strings.Join(parts, " ")
206+ if len([]rune(full))+1+reservedLeft <= width {
207+ return full
208+ }
209+ collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))
210+ if len([]rune(collapsed))+1+reservedLeft <= width {
211+ return collapsed
212+ }
213+ return ""
214+}
215+
216+func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {
217+ for _, r := range text {
218+ if r == attentionGlyph {
219+ writeBell(row, x, frame)
220+ continue
221+ }
222+ writeStatusText(row, x, string(r), style)
223+ }
224+}
225+
226+func writeBell(row []renderer.Cell, x *int, frame int) {
227+ style, visible := pulseStyle(frame)
228+ if !visible {
229+ writeStatusText(row, x, " ", renderer.DefaultStyle())
230+ return
231+ }
232+ writeStatusText(row, x, string(attentionGlyph), style)
233+}
234+
86235 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
87236 for _, r := range text {
88237 if *x >= len(row) {
@@ -26,36 +26,99 @@
26 package daemon26 package daemon
27 27
28 import (28 import (
29+ "sort"
29 "strconv"30 "strconv"
31+ "strings"
32+ "time"
30 33
34+ themeui "github.com/bnema/vev/internal/usecase/theme"
31 "github.com/bnema/vev/pkg/renderer"35 "github.com/bnema/vev/pkg/renderer"
32 )36 )
33 37
34-func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) {38+const (
35- for i := range row {39+ attentionGlyph = ''
36- row[i] = renderer.BlankCell()40+ pulseFrameCount = 30
41+ pulseFrameInterval = 120 * time.Millisecond
42+)
43+
44+func pulseStyle(frame int) (renderer.Style, bool) {
45+ f := frame % pulseFrameCount
46+ if f == 0 {
47+ return renderer.DefaultStyle(), false
37 }48 }
38- styleSet := resolveThemeStyles(styles)49+ peak := pulseFrameCount / 2
39- status := sess.statusSegments()50+ distance := f - peak
51+ if distance < 0 {
52+ distance = -distance
53+ }
54+ intensity := 1 - float64(distance)/float64(peak)
55+ style := renderer.DefaultStyle()
56+ style.Bold = true
57+ style.Foreground = 244 + int(intensity*11)
58+ return style, true
59+}
60+
61+func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) {
62+ clearStatusRow(row)
40 x := 063 x := 0
41- writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar)
42 for _, w := range status.tabs {64 for _, w := range status.tabs {
43- style := styleSet.statusBar65+ style := styles.statusBar
44 if w.active {66 if w.active {
45- style = styleSet.accent67+ style = styles.accent
46 }68 }
47- writeStatusText(row, &x, " "+w.name+" ", style)69+ writeStatusText(row, &x, " "+w.name, style)
70+ if w.attention {
71+ writeStatusText(row, &x, " ", style)
72+ writeBell(row, &x, frame)
73+ }
74+ writeStatusText(row, &x, " ", style)
75+ }
76+}
77+
78+func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
79+ clearStatusRow(row)
80+ leftText := " " + state.status.session + " "
81+ x := 0
82+ writeStatusText(row, &x, leftText, styles.statusBar)
83+
84+ reservedLeft := len([]rune(leftText))
85+ if state.copyFeedback != "" {
86+ drawRightPlainText(row, state.copyFeedback, reservedLeft, styles.statusBar)
87+ return
48 }88 }
89+ rightText := state.attentionStackText(len(row), reservedLeft)
49 if rightText == "" {90 if rightText == "" {
50 return91 return
51 }92 }
52- style := styleSet.statusBar
53 rightWidth := len([]rune(rightText)) + 193 rightWidth := len([]rune(rightText)) + 1
54- if len(row)-x-1 < rightWidth {94+ x = len(row) - rightWidth
95+ writeStatusText(row, &x, " ", styles.statusBar)
96+ writeAttentionText(row, &x, rightText, state.attentionFrame, styles.statusBar)
97+}
98+
99+func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
100+ if len([]rune(text))+1+reservedLeft > len(row) {
55 return101 return
56 }102 }
57- x = len(row) - rightWidth103+ x := len(row) - len([]rune(text)) - 1
58- writeStatusText(row, &x, " "+rightText, style)104+ writeStatusText(row, &x, " "+text, style)
105+}
106+
107+func clearStatusRow(row []renderer.Cell) {
108+ for i := range row {
109+ row[i] = renderer.BlankCell()
110+ }
111+}
112+
113+type barState struct {
114+ status statusSnapshot
115+ copyFeedback string
116+ otherAttention []string
117+ attentionFrame int
118+ // theme is the client's terminal theme, if reported. Its zero value
119+ // (Theme{}, Known: false) is a valid "no theme" default that resolves to
120+ // the pre-theme fallback styles (see newThemeStyles / theme.usable).
121+ theme themeui.Theme
59 }122 }
60 123
61 type statusSnapshot struct {124 type statusSnapshot struct {
@@ -64,8 +127,9 @@ type statusSnapshot struct {
64 }127 }
65 128
66 type statusTab struct {129 type statusTab struct {
67- name string130+ name string
68- active bool131+ active bool
132+ attention bool
69 }133 }
70 134
71 func (s *session) statusSegments() statusSnapshot {135 func (s *session) statusSegments() statusSnapshot {
@@ -76,13 +140,98 @@ func (s *session) statusSegments() statusSnapshot {
76 name += "*"140 name += "*"
77 }141 }
78 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
79- for i := range s.tabs {143+ for i, tb := range s.tabs {
80 name := strconv.Itoa(i + 1)144 name := strconv.Itoa(i + 1)
81- snap.tabs[i] = statusTab{name: name, active: i == s.active}145+ snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
82 }146 }
83 return snap147 return snap
84 }148 }
85 149
150+func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
151+ state := barState{copyFeedback: copyFeedback}
152+ if d != nil {
153+ state.attentionFrame = d.attentionFrame()
154+ }
155+ if cur != nil {
156+ state.status = cur.statusSegments()
157+ }
158+ if d == nil || copyFeedback != "" {
159+ return state
160+ }
161+ type attentionSession struct {
162+ name string
163+ at time.Time
164+ }
165+ d.mu.Lock()
166+ attention := make([]attentionSession, 0, len(d.sessions))
167+ for _, sess := range d.sessions {
168+ if sess == cur {
169+ continue
170+ }
171+ sess.mu.Lock()
172+ var oldest time.Time
173+ found := false
174+ for _, tb := range sess.tabs {
175+ if !tb.attention {
176+ continue
177+ }
178+ if !found || tb.attentionAt.Before(oldest) {
179+ oldest = tb.attentionAt
180+ found = true
181+ }
182+ }
183+ if found {
184+ attention = append(attention, attentionSession{name: sess.name, at: oldest})
185+ }
186+ sess.mu.Unlock()
187+ }
188+ d.mu.Unlock()
189+ sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })
190+ state.otherAttention = make([]string, len(attention))
191+ for i := range attention {
192+ state.otherAttention[i] = attention[i].name
193+ }
194+ return state
195+}
196+
197+func (s barState) attentionStackText(width, reservedLeft int) string {
198+ if len(s.otherAttention) == 0 {
199+ return ""
200+ }
201+ parts := make([]string, len(s.otherAttention))
202+ for i, name := range s.otherAttention {
203+ parts[i] = string(attentionGlyph) + " " + name
204+ }
205+ full := strings.Join(parts, " ")
206+ if len([]rune(full))+1+reservedLeft <= width {
207+ return full
208+ }
209+ collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))
210+ if len([]rune(collapsed))+1+reservedLeft <= width {
211+ return collapsed
212+ }
213+ return ""
214+}
215+
216+func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {
217+ for _, r := range text {
218+ if r == attentionGlyph {
219+ writeBell(row, x, frame)
220+ continue
221+ }
222+ writeStatusText(row, x, string(r), style)
223+ }
224+}
225+
226+func writeBell(row []renderer.Cell, x *int, frame int) {
227+ style, visible := pulseStyle(frame)
228+ if !visible {
229+ writeStatusText(row, x, " ", renderer.DefaultStyle())
230+ return
231+ }
232+ writeStatusText(row, x, string(attentionGlyph), style)
233+}
234+
86 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {235 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
87 for _, r := range text {236 for _, r := range text {
88 if *x >= len(row) {237 if *x >= len(row) {
M internal/usecase/daemon/status_test.go +114 -11
@@ -4,6 +4,7 @@ import (
44 "strings"
55 "sync"
66 "testing"
7+ "time"
78
89 "github.com/stretchr/testify/require"
910
@@ -36,12 +37,13 @@ func TestStatusCompositionGolden(t *testing.T) {
3637 frame, damage := composeClientFrame(sess, win, true, "")
3738
3839 require.Equal(t, 12, frame.Width)
39- require.Equal(t, 3, frame.Height)
40- require.Equal(t, "hello ", rowText(frame.Row(0)))
41- require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
40+ require.Equal(t, 4, frame.Height)
41+ require.Equal(t, " 1 2 ", rowText(frame.Row(0)))
42+ require.Equal(t, "hello ", rowText(frame.Row(1)))
43+ require.Equal(t, " work ", rowText(frame.Row(3)))
4244 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
43- for i, c := range frame.Row(2) {
44- if i >= len(" work 1 ") && i < len(" work 1 2 ") {
45+ for i, c := range frame.Row(0) {
46+ if i >= len(" 1 ") && i < len(" 1 2 ") {
4547 require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
4648 }
4749 }
@@ -63,8 +65,8 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
6365 Known: true,
6466 })
6567
66- d := newThemeStyles(ac.getTheme())
67- frame, damage := composeClientFrame(sess, win, true, "", d)
68+ bars := barState{status: sess.statusSegments(), theme: ac.getTheme()}
69+ frame, damage := composeClientFrameWithState(bars, win, true)
6870 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
6971
7072 require.NoError(t, err)
@@ -216,7 +218,8 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
216218
217219 frame, _ := composeClientFrame(sess, win, true, "")
218220
219- require.Equal(t, " 0* 1 ", rowText(frame.Row(2)))
221+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
222+ require.Equal(t, " 0* ", rowText(frame.Row(3)))
220223 }
221224
222225 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
@@ -229,13 +232,113 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
229232 win.size = domain.Size{Cols: 30, Rows: 2}
230233
231234 frame, _ := composeClientFrame(sess, win, true, "ok")
232- require.Equal(t, " work 1 ok", rowText(frame.Row(2)))
235+ require.Equal(t, " work ok", rowText(frame.Row(3)))
233236
234237 frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
235- require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))
238+ require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
236239
237240 frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
238- require.Equal(t, " work 1 ", rowText(frame.Row(2)))
241+ require.Equal(t, " work ", rowText(frame.Row(3)))
242+}
243+
244+func TestAttentionConstants(t *testing.T) {
245+ require.Equal(t, '', attentionGlyph)
246+ require.Equal(t, 30, pulseFrameCount)
247+ require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
248+}
249+
250+func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {
251+ _, visible := pulseStyle(0)
252+ require.False(t, visible)
253+
254+ low, visible := pulseStyle(1)
255+ require.True(t, visible)
256+ peak, visible := pulseStyle(pulseFrameCount / 2)
257+ require.True(t, visible)
258+ require.Greater(t, peak.Foreground, low.Foreground)
259+}
260+
261+func TestTopBarRendersAttentionBell(t *testing.T) {
262+ p1, releasePTY1 := newBlockingPTY(t)
263+ p2, releasePTY2 := newBlockingPTY(t)
264+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
265+ defer releasePTY1()
266+ defer releasePTY2()
267+ win := sess.activeTab()
268+ win.screen = vt.NewScreen(18, 2)
269+ win.size = domain.Size{Cols: 18, Rows: 2}
270+ sess.mu.Lock()
271+ sess.tabs[1].attention = true
272+ sess.tabs[1].attentionAt = time.Unix(10, 0)
273+ sess.mu.Unlock()
274+
275+ frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
276+
277+ require.Contains(t, rowText(frame.Row(0)), "2 ")
278+ for _, c := range frame.Row(0) {
279+ if c.Rune == attentionGlyph {
280+ require.True(t, c.Style.Bold)
281+ return
282+ }
283+ }
284+ t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
285+}
286+
287+func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {
288+ p, releasePTY := newBlockingPTY(t)
289+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
290+ defer releasePTY()
291+ sess.name = "current"
292+ base := time.Unix(100, 0)
293+ newAttendedSession := func(id, name string, at time.Time) *session {
294+ tb := &tab{attention: true, attentionAt: at}
295+ return &session{id: domain.SessionID(id), name: name, tabs: []*tab{tb}}
296+ }
297+ d.sessions["old"] = newAttendedSession("old", "old", base)
298+ d.sessions["new"] = newAttendedSession("new", "new", base.Add(time.Minute))
299+
300+ state := d.barStateFor(sess, "")
301+ state.attentionFrame = 1
302+ require.Equal(t, []string{"old", "new"}, state.otherAttention)
303+
304+ styles := resolveThemeStyles(nil)
305+ wide := make([]renderer.Cell, 24)
306+ drawStatusBarState(wide, state, styles)
307+ require.Equal(t, " current  old  new", rowText(wide))
308+
309+ narrow := make([]renderer.Cell, 14)
310+ drawStatusBarState(narrow, state, styles)
311+ require.Equal(t, " current  ×2", rowText(narrow))
312+
313+ drawStatusBarState(narrow, barState{status: state.status, copyFeedback: "ok", otherAttention: state.otherAttention}, styles)
314+ require.Equal(t, " current ok", rowText(narrow))
315+}
316+
317+func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}
322+
323+ state := d.barStateFor(sess, "")
324+
325+ require.Equal(t, []string{"zero"}, state.otherAttention)
326+}
327+
328+func TestBarStateForExcludesCurrentSession(t *testing.T) {
329+ p, releasePTY := newBlockingPTY(t)
330+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
331+ defer releasePTY()
332+ sess.name = "current"
333+ sess.mu.Lock()
334+ sess.tabs[0].attention = true
335+ sess.tabs[0].attentionAt = time.Unix(1, 0)
336+ sess.mu.Unlock()
337+ d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}
338+
339+ state := d.barStateFor(sess, "")
340+
341+ require.Equal(t, []string{"other"}, state.otherAttention)
239342 }
240343
241344 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
@@ -4,6 +4,7 @@ import (
4 "strings"4 "strings"
5 "sync"5 "sync"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 10
@@ -36,12 +37,13 @@ func TestStatusCompositionGolden(t *testing.T) {
36 frame, damage := composeClientFrame(sess, win, true, "")37 frame, damage := composeClientFrame(sess, win, true, "")
37 38
38 require.Equal(t, 12, frame.Width)39 require.Equal(t, 12, frame.Width)
39- require.Equal(t, 3, frame.Height)40+ require.Equal(t, 4, frame.Height)
40- require.Equal(t, "hello ", rowText(frame.Row(0)))41+ require.Equal(t, " 1 2 ", rowText(frame.Row(0)))
41- require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))42+ require.Equal(t, "hello ", rowText(frame.Row(1)))
43+ require.Equal(t, " work ", rowText(frame.Row(3)))
42 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)44 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
43- for i, c := range frame.Row(2) {45+ for i, c := range frame.Row(0) {
44- if i >= len(" work 1 ") && i < len(" work 1 2 ") {46+ if i >= len(" 1 ") && i < len(" 1 2 ") {
45 require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)47 require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
46 }48 }
47 }49 }
@@ -63,8 +65,8 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
63 Known: true,65 Known: true,
64 })66 })
65 67
66- d := newThemeStyles(ac.getTheme())68+ bars := barState{status: sess.statusSegments(), theme: ac.getTheme()}
67- frame, damage := composeClientFrame(sess, win, true, "", d)69+ frame, damage := composeClientFrameWithState(bars, win, true)
68 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)70 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
69 71
70 require.NoError(t, err)72 require.NoError(t, err)
@@ -216,7 +218,8 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
216 218
217 frame, _ := composeClientFrame(sess, win, true, "")219 frame, _ := composeClientFrame(sess, win, true, "")
218 220
219- require.Equal(t, " 0* 1 ", rowText(frame.Row(2)))221+ require.Equal(t, " 1 ", rowText(frame.Row(0)))
222+ require.Equal(t, " 0* ", rowText(frame.Row(3)))
220 }223 }
221 224
222 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {225 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
@@ -229,13 +232,113 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
229 win.size = domain.Size{Cols: 30, Rows: 2}232 win.size = domain.Size{Cols: 30, Rows: 2}
230 233
231 frame, _ := composeClientFrame(sess, win, true, "ok")234 frame, _ := composeClientFrame(sess, win, true, "ok")
232- require.Equal(t, " work 1 ok", rowText(frame.Row(2)))235+ require.Equal(t, " work ok", rowText(frame.Row(3)))
233 236
234 frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")237 frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
235- require.Equal(t, " work 1 1234567890123456789", rowText(frame.Row(2)))238+ require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
236 239
237 frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")240 frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
238- require.Equal(t, " work 1 ", rowText(frame.Row(2)))241+ require.Equal(t, " work ", rowText(frame.Row(3)))
242+}
243+
244+func TestAttentionConstants(t *testing.T) {
245+ require.Equal(t, '', attentionGlyph)
246+ require.Equal(t, 30, pulseFrameCount)
247+ require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
248+}
249+
250+func TestPulseStyleHidesAtFrameZeroAndRamps(t *testing.T) {
251+ _, visible := pulseStyle(0)
252+ require.False(t, visible)
253+
254+ low, visible := pulseStyle(1)
255+ require.True(t, visible)
256+ peak, visible := pulseStyle(pulseFrameCount / 2)
257+ require.True(t, visible)
258+ require.Greater(t, peak.Foreground, low.Foreground)
259+}
260+
261+func TestTopBarRendersAttentionBell(t *testing.T) {
262+ p1, releasePTY1 := newBlockingPTY(t)
263+ p2, releasePTY2 := newBlockingPTY(t)
264+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
265+ defer releasePTY1()
266+ defer releasePTY2()
267+ win := sess.activeTab()
268+ win.screen = vt.NewScreen(18, 2)
269+ win.size = domain.Size{Cols: 18, Rows: 2}
270+ sess.mu.Lock()
271+ sess.tabs[1].attention = true
272+ sess.tabs[1].attentionAt = time.Unix(10, 0)
273+ sess.mu.Unlock()
274+
275+ frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
276+
277+ require.Contains(t, rowText(frame.Row(0)), "2 ")
278+ for _, c := range frame.Row(0) {
279+ if c.Rune == attentionGlyph {
280+ require.True(t, c.Style.Bold)
281+ return
282+ }
283+ }
284+ t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
285+}
286+
287+func TestStatusBarRendersOtherAttentionOldestFirstAndCollapses(t *testing.T) {
288+ p, releasePTY := newBlockingPTY(t)
289+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
290+ defer releasePTY()
291+ sess.name = "current"
292+ base := time.Unix(100, 0)
293+ newAttendedSession := func(id, name string, at time.Time) *session {
294+ tb := &tab{attention: true, attentionAt: at}
295+ return &session{id: domain.SessionID(id), name: name, tabs: []*tab{tb}}
296+ }
297+ d.sessions["old"] = newAttendedSession("old", "old", base)
298+ d.sessions["new"] = newAttendedSession("new", "new", base.Add(time.Minute))
299+
300+ state := d.barStateFor(sess, "")
301+ state.attentionFrame = 1
302+ require.Equal(t, []string{"old", "new"}, state.otherAttention)
303+
304+ styles := resolveThemeStyles(nil)
305+ wide := make([]renderer.Cell, 24)
306+ drawStatusBarState(wide, state, styles)
307+ require.Equal(t, " current  old  new", rowText(wide))
308+
309+ narrow := make([]renderer.Cell, 14)
310+ drawStatusBarState(narrow, state, styles)
311+ require.Equal(t, " current  ×2", rowText(narrow))
312+
313+ drawStatusBarState(narrow, barState{status: state.status, copyFeedback: "ok", otherAttention: state.otherAttention}, styles)
314+ require.Equal(t, " current ok", rowText(narrow))
315+}
316+
317+func TestBarStateForIncludesZeroTimeAttention(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ d.sessions["zero"] = &session{id: "zero", name: "zero", tabs: []*tab{{attention: true}}}
322+
323+ state := d.barStateFor(sess, "")
324+
325+ require.Equal(t, []string{"zero"}, state.otherAttention)
326+}
327+
328+func TestBarStateForExcludesCurrentSession(t *testing.T) {
329+ p, releasePTY := newBlockingPTY(t)
330+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
331+ defer releasePTY()
332+ sess.name = "current"
333+ sess.mu.Lock()
334+ sess.tabs[0].attention = true
335+ sess.tabs[0].attentionAt = time.Unix(1, 0)
336+ sess.mu.Unlock()
337+ d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}
338+
339+ state := d.barStateFor(sess, "")
340+
341+ require.Equal(t, []string{"other"}, state.otherAttention)
239 }342 }
240 343
241 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {344 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
M internal/usecase/keys/router.go +3 -0
@@ -28,6 +28,7 @@ const (
2828 ActionSwitchTab7
2929 ActionSwitchTab8
3030 ActionSwitchTab9
31+ ActionJumpAttention
3132 )
3233
3334 // Handler receives router outputs. Forward is called only for bytes that should
@@ -167,6 +168,8 @@ func binding(b byte) (Action, bool) {
167168 return ActionOpenPalette, true
168169 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
169170 return ActionSwitchTab1 + Action(b-'1'), true
171+ case 'a':
172+ return ActionJumpAttention, true
170173 default:
171174 return 0, false
172175 }
@@ -28,6 +28,7 @@ const (
28 ActionSwitchTab728 ActionSwitchTab7
29 ActionSwitchTab829 ActionSwitchTab8
30 ActionSwitchTab930 ActionSwitchTab9
31+ ActionJumpAttention
31 )32 )
32 33
33 // Handler receives router outputs. Forward is called only for bytes that should34 // Handler receives router outputs. Forward is called only for bytes that should
@@ -167,6 +168,8 @@ func binding(b byte) (Action, bool) {
167 return ActionOpenPalette, true168 return ActionOpenPalette, true
168 case '1', '2', '3', '4', '5', '6', '7', '8', '9':169 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
169 return ActionSwitchTab1 + Action(b-'1'), true170 return ActionSwitchTab1 + Action(b-'1'), true
171+ case 'a':
172+ return ActionJumpAttention, true
170 default:173 default:
171 return 0, false174 return 0, false
172 }175 }
M internal/usecase/keys/router_test.go +9 -0
@@ -69,6 +69,15 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
6969 require.Empty(t, h.forwards)
7070 }
7171
72+func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ NewRouter(clk, h).Route([]byte{ESC, 'a'})
76+ require.Equal(t, []Action{ActionJumpAttention}, h.actions)
77+ require.Empty(t, h.forwards)
78+ require.Empty(t, clk.timers)
79+}
80+
7281 func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
7382 for _, b := range []byte("cnpdxurt") {
7483 t.Run(string(b), func(t *testing.T) {
@@ -69,6 +69,15 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
69 require.Empty(t, h.forwards)69 require.Empty(t, h.forwards)
70 }70 }
71 71
72+func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
73+ clk := &fakeClock{}
74+ h := &captureHandler{}
75+ NewRouter(clk, h).Route([]byte{ESC, 'a'})
76+ require.Equal(t, []Action{ActionJumpAttention}, h.actions)
77+ require.Empty(t, h.forwards)
78+ require.Empty(t, clk.timers)
79+}
80+
72 func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {81 func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
73 for _, b := range []byte("cnpdxurt") {82 for _, b := range []byte("cnpdxurt") {
74 t.Run(string(b), func(t *testing.T) {83 t.Run(string(b), func(t *testing.T) {
M pkg/vt/screen.go +46 -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,36 @@ 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+ if strings.HasPrefix(p, "9;4;") {
633+ return
634+ }
635+ s.OnNotify("", p[len("9;"):])
636+ case strings.HasPrefix(p, "777;"):
637+ parts := strings.SplitN(p[len("777;"):], ";", 3)
638+ if parts[0] != "notify" {
639+ return
640+ }
641+ var title, body string
642+ if len(parts) > 1 {
643+ title = parts[1]
644+ }
645+ if len(parts) > 2 {
646+ body = parts[2]
647+ }
648+ s.OnNotify(title, body)
649+ }
650+}
651+
607652 func consumeSTString(data []byte) (consumed int, partial bool) {
608653 for i := 2; i < len(data); i++ {
609654 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,36 @@ 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+ if strings.HasPrefix(p, "9;4;") {
633+ return
634+ }
635+ s.OnNotify("", p[len("9;"):])
636+ case strings.HasPrefix(p, "777;"):
637+ parts := strings.SplitN(p[len("777;"):], ";", 3)
638+ if parts[0] != "notify" {
639+ return
640+ }
641+ var title, body string
642+ if len(parts) > 1 {
643+ title = parts[1]
644+ }
645+ if len(parts) > 2 {
646+ body = parts[2]
647+ }
648+ s.OnNotify(title, body)
649+ }
650+}
651+
607 func consumeSTString(data []byte) (consumed int, partial bool) {652 func consumeSTString(data []byte) (consumed int, partial bool) {
608 for i := 2; i < len(data); i++ {653 for i := 2; i < len(data); i++ {
609 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {654 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
M pkg/vt/screen_test.go +84 -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,89 @@ 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 TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124+ s := NewScreen(10, 2)
125+ calls := 0
126+ s.OnNotify = func(string, string) { calls++ }
127+ s.Write([]byte("\x1b]9;4;1;50\x07"))
128+ require.Equal(t, 0, calls)
129+}
130+
131+func TestOnNotifyOSC777STTerminated(t *testing.T) {
132+ s := NewScreen(10, 2)
133+ var gotTitle, gotBody string
134+ calls := 0
135+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
136+ s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
137+ require.Equal(t, 1, calls)
138+ require.Equal(t, "Claude", gotTitle)
139+ require.Equal(t, "needs input", gotBody)
140+}
141+
142+func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
143+ s := NewScreen(10, 2)
144+ calls := 0
145+ s.OnNotify = func(string, string) { calls++ }
146+ s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
147+ s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
148+ require.Equal(t, 0, calls)
149+}
150+
151+func TestOnNotifySplitAcrossWrites(t *testing.T) {
152+ s := NewScreen(10, 2)
153+ calls := 0
154+ s.OnNotify = func(string, string) { calls++ }
155+ s.Write([]byte("\x1b]9;par"))
156+ s.Write([]byte("tial\x07"))
157+ require.Equal(t, 1, calls)
158+}
159+
76160 // ---------------------------------------------------------------------------
77161 // Printable ASCII / UTF-8
78162 // ---------------------------------------------------------------------------
@@ -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,89 @@ 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 TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
124+ s := NewScreen(10, 2)
125+ calls := 0
126+ s.OnNotify = func(string, string) { calls++ }
127+ s.Write([]byte("\x1b]9;4;1;50\x07"))
128+ require.Equal(t, 0, calls)
129+}
130+
131+func TestOnNotifyOSC777STTerminated(t *testing.T) {
132+ s := NewScreen(10, 2)
133+ var gotTitle, gotBody string
134+ calls := 0
135+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
136+ s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
137+ require.Equal(t, 1, calls)
138+ require.Equal(t, "Claude", gotTitle)
139+ require.Equal(t, "needs input", gotBody)
140+}
141+
142+func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
143+ s := NewScreen(10, 2)
144+ calls := 0
145+ s.OnNotify = func(string, string) { calls++ }
146+ s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
147+ s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
148+ require.Equal(t, 0, calls)
149+}
150+
151+func TestOnNotifySplitAcrossWrites(t *testing.T) {
152+ s := NewScreen(10, 2)
153+ calls := 0
154+ s.OnNotify = func(string, string) { calls++ }
155+ s.Write([]byte("\x1b]9;par"))
156+ s.Write([]byte("tial\x07"))
157+ require.Equal(t, 1, calls)
158+}
159+
76 // ---------------------------------------------------------------------------160 // ---------------------------------------------------------------------------
77 // Printable ASCII / UTF-8161 // Printable ASCII / UTF-8
78 // ---------------------------------------------------------------------------162 // ---------------------------------------------------------------------------

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