detached 5b2d745

feat(daemon): add persistent attention bells

commit
5b2d7452f9fa32fdb2112015744097afe5a9bfad
author
brice · · 2026-07-03 14:00 UTC
committer
brice · · 2026-07-03 15:30 UTC
parent
55542be
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1232 -52
A internal/usecase/daemon/attention.go +269 -0
@@ -0,0 +1,269 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+// noteAttention records attention for tabs that are not currently visible to an
12+// attached client. The caller must not hold tab.mu.
13+func (d *Daemon) noteAttention(sess *session, tb *tab) {
14+ if sess == nil || tb == nil {
15+ return
16+ }
17+
18+ now := d.clock.Now()
19+ if now.IsZero() {
20+ now = time.Now()
21+ }
22+
23+ sess.mu.Lock()
24+ visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
25+ if visible {
26+ sess.mu.Unlock()
27+ return
28+ }
29+ if !tb.attention {
30+ tb.attentionAt = now
31+ }
32+ tb.attention = true
33+ sess.mu.Unlock()
34+
35+ d.repaintAttachedClients(sess)
36+ d.pokeAttentionTicker()
37+}
38+
39+func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
40+ if sess == nil || ac == nil {
41+ return
42+ }
43+ if idx, ok := oldestAttentionTab(sess); ok {
44+ if sess.switchTab(idx) {
45+ d.paint(sess, ac, true)
46+ }
47+ return
48+ }
49+
50+ target, ok := d.oldestOtherSessionAttention(sess)
51+ if !ok {
52+ return
53+ }
54+ d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
55+}
56+
57+func oldestAttentionTab(sess *session) (int, bool) {
58+ sess.mu.Lock()
59+ defer sess.mu.Unlock()
60+ idx := -1
61+ var oldest time.Time
62+ for i, tb := range sess.tabs {
63+ if !tb.attention {
64+ continue
65+ }
66+ if idx == -1 || tb.attentionAt.Before(oldest) {
67+ idx = i
68+ oldest = tb.attentionAt
69+ }
70+ }
71+ return idx, idx != -1
72+}
73+
74+type attentionTarget struct {
75+ sessionID domain.SessionID
76+ tabIndex int
77+ at time.Time
78+}
79+
80+func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
81+ d.mu.Lock()
82+ sessions := make([]*session, 0, len(d.sessions))
83+ for _, sess := range d.sessions {
84+ if sess != current {
85+ sessions = append(sessions, sess)
86+ }
87+ }
88+ d.mu.Unlock()
89+
90+ var target attentionTarget
91+ found := false
92+ for _, sess := range sessions {
93+ idx, at, ok := oldestAttentionTabWithTime(sess)
94+ if !ok {
95+ continue
96+ }
97+ if !found || at.Before(target.at) {
98+ target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
99+ found = true
100+ }
101+ }
102+ return target, found
103+}
104+
105+func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
106+ sess.mu.Lock()
107+ defer sess.mu.Unlock()
108+ idx := -1
109+ var oldest time.Time
110+ for i, tb := range sess.tabs {
111+ if !tb.attention {
112+ continue
113+ }
114+ if idx == -1 || tb.attentionAt.Before(oldest) {
115+ idx = i
116+ oldest = tb.attentionAt
117+ }
118+ }
119+ return idx, oldest, idx != -1
120+}
121+
122+func (d *Daemon) repaintAttachedClients(sess *session) {
123+ sess.mu.Lock()
124+ ac := sess.client
125+ sess.mu.Unlock()
126+ if ac != nil {
127+ d.paint(sess, ac, true)
128+ }
129+}
130+
131+func (d *Daemon) repaintAllAttachedClients() {
132+ d.mu.Lock()
133+ sessions := make([]*session, 0, len(d.sessions))
134+ for _, sess := range d.sessions {
135+ sessions = append(sessions, sess)
136+ }
137+ d.mu.Unlock()
138+
139+ for _, sess := range sessions {
140+ d.repaintAttachedClients(sess)
141+ }
142+}
143+
144+func (s *session) ackAttention(tb *tab) bool {
145+ if tb == nil {
146+ return false
147+ }
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
151+ return false
152+ }
153+ tb.attention = false
154+ tb.attentionAt = time.Time{}
155+ return true
156+}
157+
158+func (s *session) anyAttention() bool {
159+ s.mu.Lock()
160+ defer s.mu.Unlock()
161+ for _, tb := range s.tabs {
162+ if tb.attention {
163+ return true
164+ }
165+ }
166+ return false
167+}
168+
169+func (d *Daemon) pokeAttentionTicker() {
170+ if d == nil || d.animWake == nil {
171+ return
172+ }
173+ select {
174+ case d.animWake <- struct{}{}:
175+ default:
176+ }
177+}
178+
179+func (d *Daemon) attentionFrame(time.Time) int {
180+ if d == nil {
181+ return 0
182+ }
183+ d.attnMu.Lock()
184+ defer d.attnMu.Unlock()
185+ return d.animFrame
186+}
187+
188+func (d *Daemon) attentionAnimator(ctx context.Context) {
189+ active := false
190+ var timer portsTimer
191+ for {
192+ if !active {
193+ if !d.anyAttention() {
194+ d.setAttentionFrame(0)
195+ select {
196+ case <-ctx.Done():
197+ return
198+ case <-d.animWake:
199+ continue
200+ }
201+ }
202+ active = true
203+ }
204+
205+ if timer == nil {
206+ timer = d.clock.NewTimer(pulseFrameInterval)
207+ } else {
208+ timer.Reset(pulseFrameInterval)
209+ }
210+
211+ select {
212+ case <-ctx.Done():
213+ if timer != nil {
214+ timer.Stop()
215+ }
216+ return
217+ case <-d.animWake:
218+ if !timer.Stop() {
219+ select {
220+ case <-timer.C():
221+ default:
222+ }
223+ }
224+ case <-timer.C():
225+ d.advanceAttentionFrame()
226+ d.repaintAllAttachedClients()
227+ }
228+
229+ if !d.anyAttention() {
230+ d.setAttentionFrame(0)
231+ d.repaintAllAttachedClients()
232+ active = false
233+ }
234+ }
235+}
236+
237+func (d *Daemon) anyAttention() bool {
238+ d.mu.Lock()
239+ sessions := make([]*session, 0, len(d.sessions))
240+ for _, sess := range d.sessions {
241+ sessions = append(sessions, sess)
242+ }
243+ d.mu.Unlock()
244+
245+ for _, sess := range sessions {
246+ if sess.anyAttention() {
247+ return true
248+ }
249+ }
250+ return false
251+}
252+
253+func (d *Daemon) advanceAttentionFrame() {
254+ d.attnMu.Lock()
255+ d.animFrame = (d.animFrame + 1) % pulseFrameCount
256+ d.attnMu.Unlock()
257+}
258+
259+func (d *Daemon) setAttentionFrame(frame int) {
260+ d.attnMu.Lock()
261+ d.animFrame = frame
262+ d.attnMu.Unlock()
263+}
264+
265+type portsTimer interface {
266+ C() <-chan time.Time
267+ Reset(time.Duration) bool
268+ Stop() bool
269+}
@@ -0,0 +1,269 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+)
10+
11+// noteAttention records attention for tabs that are not currently visible to an
12+// attached client. The caller must not hold tab.mu.
13+func (d *Daemon) noteAttention(sess *session, tb *tab) {
14+ if sess == nil || tb == nil {
15+ return
16+ }
17+
18+ now := d.clock.Now()
19+ if now.IsZero() {
20+ now = time.Now()
21+ }
22+
23+ sess.mu.Lock()
24+ visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
25+ if visible {
26+ sess.mu.Unlock()
27+ return
28+ }
29+ if !tb.attention {
30+ tb.attentionAt = now
31+ }
32+ tb.attention = true
33+ sess.mu.Unlock()
34+
35+ d.repaintAttachedClients(sess)
36+ d.pokeAttentionTicker()
37+}
38+
39+func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
40+ if sess == nil || ac == nil {
41+ return
42+ }
43+ if idx, ok := oldestAttentionTab(sess); ok {
44+ if sess.switchTab(idx) {
45+ d.paint(sess, ac, true)
46+ }
47+ return
48+ }
49+
50+ target, ok := d.oldestOtherSessionAttention(sess)
51+ if !ok {
52+ return
53+ }
54+ d.switchToTarget(sess, ac, picker.Target{Session: target.sessionID, TabIndex: target.tabIndex})
55+}
56+
57+func oldestAttentionTab(sess *session) (int, bool) {
58+ sess.mu.Lock()
59+ defer sess.mu.Unlock()
60+ idx := -1
61+ var oldest time.Time
62+ for i, tb := range sess.tabs {
63+ if !tb.attention {
64+ continue
65+ }
66+ if idx == -1 || tb.attentionAt.Before(oldest) {
67+ idx = i
68+ oldest = tb.attentionAt
69+ }
70+ }
71+ return idx, idx != -1
72+}
73+
74+type attentionTarget struct {
75+ sessionID domain.SessionID
76+ tabIndex int
77+ at time.Time
78+}
79+
80+func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
81+ d.mu.Lock()
82+ sessions := make([]*session, 0, len(d.sessions))
83+ for _, sess := range d.sessions {
84+ if sess != current {
85+ sessions = append(sessions, sess)
86+ }
87+ }
88+ d.mu.Unlock()
89+
90+ var target attentionTarget
91+ found := false
92+ for _, sess := range sessions {
93+ idx, at, ok := oldestAttentionTabWithTime(sess)
94+ if !ok {
95+ continue
96+ }
97+ if !found || at.Before(target.at) {
98+ target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
99+ found = true
100+ }
101+ }
102+ return target, found
103+}
104+
105+func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
106+ sess.mu.Lock()
107+ defer sess.mu.Unlock()
108+ idx := -1
109+ var oldest time.Time
110+ for i, tb := range sess.tabs {
111+ if !tb.attention {
112+ continue
113+ }
114+ if idx == -1 || tb.attentionAt.Before(oldest) {
115+ idx = i
116+ oldest = tb.attentionAt
117+ }
118+ }
119+ return idx, oldest, idx != -1
120+}
121+
122+func (d *Daemon) repaintAttachedClients(sess *session) {
123+ sess.mu.Lock()
124+ ac := sess.client
125+ sess.mu.Unlock()
126+ if ac != nil {
127+ d.paint(sess, ac, true)
128+ }
129+}
130+
131+func (d *Daemon) repaintAllAttachedClients() {
132+ d.mu.Lock()
133+ sessions := make([]*session, 0, len(d.sessions))
134+ for _, sess := range d.sessions {
135+ sessions = append(sessions, sess)
136+ }
137+ d.mu.Unlock()
138+
139+ for _, sess := range sessions {
140+ d.repaintAttachedClients(sess)
141+ }
142+}
143+
144+func (s *session) ackAttention(tb *tab) bool {
145+ if tb == nil {
146+ return false
147+ }
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
151+ return false
152+ }
153+ tb.attention = false
154+ tb.attentionAt = time.Time{}
155+ return true
156+}
157+
158+func (s *session) anyAttention() bool {
159+ s.mu.Lock()
160+ defer s.mu.Unlock()
161+ for _, tb := range s.tabs {
162+ if tb.attention {
163+ return true
164+ }
165+ }
166+ return false
167+}
168+
169+func (d *Daemon) pokeAttentionTicker() {
170+ if d == nil || d.animWake == nil {
171+ return
172+ }
173+ select {
174+ case d.animWake <- struct{}{}:
175+ default:
176+ }
177+}
178+
179+func (d *Daemon) attentionFrame(time.Time) int {
180+ if d == nil {
181+ return 0
182+ }
183+ d.attnMu.Lock()
184+ defer d.attnMu.Unlock()
185+ return d.animFrame
186+}
187+
188+func (d *Daemon) attentionAnimator(ctx context.Context) {
189+ active := false
190+ var timer portsTimer
191+ for {
192+ if !active {
193+ if !d.anyAttention() {
194+ d.setAttentionFrame(0)
195+ select {
196+ case <-ctx.Done():
197+ return
198+ case <-d.animWake:
199+ continue
200+ }
201+ }
202+ active = true
203+ }
204+
205+ if timer == nil {
206+ timer = d.clock.NewTimer(pulseFrameInterval)
207+ } else {
208+ timer.Reset(pulseFrameInterval)
209+ }
210+
211+ select {
212+ case <-ctx.Done():
213+ if timer != nil {
214+ timer.Stop()
215+ }
216+ return
217+ case <-d.animWake:
218+ if !timer.Stop() {
219+ select {
220+ case <-timer.C():
221+ default:
222+ }
223+ }
224+ case <-timer.C():
225+ d.advanceAttentionFrame()
226+ d.repaintAllAttachedClients()
227+ }
228+
229+ if !d.anyAttention() {
230+ d.setAttentionFrame(0)
231+ d.repaintAllAttachedClients()
232+ active = false
233+ }
234+ }
235+}
236+
237+func (d *Daemon) anyAttention() bool {
238+ d.mu.Lock()
239+ sessions := make([]*session, 0, len(d.sessions))
240+ for _, sess := range d.sessions {
241+ sessions = append(sessions, sess)
242+ }
243+ d.mu.Unlock()
244+
245+ for _, sess := range sessions {
246+ if sess.anyAttention() {
247+ return true
248+ }
249+ }
250+ return false
251+}
252+
253+func (d *Daemon) advanceAttentionFrame() {
254+ d.attnMu.Lock()
255+ d.animFrame = (d.animFrame + 1) % pulseFrameCount
256+ d.attnMu.Unlock()
257+}
258+
259+func (d *Daemon) setAttentionFrame(frame int) {
260+ d.attnMu.Lock()
261+ d.animFrame = frame
262+ d.attnMu.Unlock()
263+}
264+
265+type portsTimer interface {
266+ C() <-chan time.Time
267+ Reset(time.Duration) bool
268+ Stop() bool
269+}
A internal/usecase/daemon/attention_test.go +380 -0
@@ -0,0 +1,380 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
18+ d, sess, _, _, releases := newManualTabSession(t, 2)
19+ defer releases[0]()
20+ defer releases[1]()
21+ sess.active = 0
22+
23+ d.noteAttention(sess, sess.tabs[1])
24+
25+ sess.mu.Lock()
26+ defer sess.mu.Unlock()
27+ require.True(t, sess.tabs[1].attention)
28+ require.False(t, sess.tabs[1].attentionAt.IsZero())
29+ require.False(t, sess.tabs[0].attention)
30+}
31+
32+func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
33+ d, sess, _, _, releases := newManualTabSession(t, 1)
34+ defer releases[0]()
35+ sess.active = 0
36+
37+ d.noteAttention(sess, sess.tabs[0])
38+
39+ sess.mu.Lock()
40+ defer sess.mu.Unlock()
41+ require.False(t, sess.tabs[0].attention)
42+ require.True(t, sess.tabs[0].attentionAt.IsZero())
43+}
44+
45+func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
46+ d, sess, ac, _, releases := newManualTabSession(t, 1)
47+ defer releases[0]()
48+ sess.active = 0
49+ require.True(t, sess.detachIfCurrent(ac))
50+
51+ d.noteAttention(sess, sess.tabs[0])
52+
53+ sess.mu.Lock()
54+ defer sess.mu.Unlock()
55+ require.True(t, sess.tabs[0].attention)
56+ require.False(t, sess.tabs[0].attentionAt.IsZero())
57+}
58+
59+func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
60+ p1, releasePTY1 := newBlockingPTY(t)
61+ p2 := newScriptPTY([][]byte{[]byte("\a")})
62+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
63+ defer releasePTY1()
64+ sess.active = 0
65+
66+ d.sessWg.Add(1)
67+ go d.ptyReader(sess, sess.tabs[1])
68+
69+ require.Eventually(t, func() bool {
70+ sess.mu.Lock()
71+ defer sess.mu.Unlock()
72+ return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
73+ }, 2*time.Second, 5*time.Millisecond)
74+
75+ _ = p2.Close()
76+ d.sessWg.Wait()
77+}
78+
79+type scriptPTY struct {
80+ mu sync.Mutex
81+ reads [][]byte
82+ closed chan struct{}
83+ once sync.Once
84+}
85+
86+func newScriptPTY(reads [][]byte) *scriptPTY {
87+ return &scriptPTY{reads: reads, closed: make(chan struct{})}
88+}
89+
90+func (p *scriptPTY) Read(b []byte) (int, error) {
91+ p.mu.Lock()
92+ if len(p.reads) > 0 {
93+ next := p.reads[0]
94+ p.reads = p.reads[1:]
95+ p.mu.Unlock()
96+ return copy(b, next), nil
97+ }
98+ p.mu.Unlock()
99+ <-p.closed
100+ return 0, io.EOF
101+}
102+
103+func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
104+func (p *scriptPTY) Close() error {
105+ p.once.Do(func() { close(p.closed) })
106+ return nil
107+}
108+func (p *scriptPTY) Resize(domain.Size) error { return nil }
109+func (p *scriptPTY) Pid() int { return 4242 }
110+
111+func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
112+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
113+ defer releases[0]()
114+ defer releases[1]()
115+ sess.active = 0
116+ sess.mu.Lock()
117+ sess.tabs[0].attention = true
118+ sess.tabs[0].attentionAt = time.Unix(1, 0)
119+ sess.tabs[1].attention = true
120+ sess.tabs[1].attentionAt = time.Unix(2, 0)
121+ sess.mu.Unlock()
122+
123+ d.paint(sess, ac, true)
124+ _ = mustOutputData(t, sends)
125+ _ = mustOutputData(t, sends) // deferred repaint after acknowledging the visible tab
126+
127+ sess.mu.Lock()
128+ defer sess.mu.Unlock()
129+ require.False(t, sess.tabs[0].attention)
130+ require.True(t, sess.tabs[0].attentionAt.IsZero())
131+ require.True(t, sess.tabs[1].attention)
132+ require.False(t, sess.tabs[1].attentionAt.IsZero())
133+}
134+
135+func TestPaintPreservesBackgroundAttention(t *testing.T) {
136+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
137+ defer releases[0]()
138+ defer releases[1]()
139+ sess.active = 0
140+ sess.mu.Lock()
141+ sess.tabs[1].attention = true
142+ sess.tabs[1].attentionAt = time.Unix(2, 0)
143+ sess.mu.Unlock()
144+ d.setAttentionFrame(1)
145+
146+ d.paint(sess, ac, true)
147+ data := mustOutputData(t, sends)
148+
149+ require.Contains(t, string(data), "")
150+ sess.mu.Lock()
151+ defer sess.mu.Unlock()
152+ require.True(t, sess.tabs[1].attention)
153+}
154+
155+func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
156+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
157+ defer releases[0]()
158+ defer releases[1]()
159+ sess.active = 0
160+ sess.mu.Lock()
161+ sess.tabs[1].attention = true
162+ sess.tabs[1].attentionAt = time.Unix(2, 0)
163+ sess.mu.Unlock()
164+
165+ require.True(t, sess.switchTab(1))
166+ d.paint(sess, ac, true)
167+ data := mustOutputData(t, sends)
168+ _ = mustOutputData(t, sends) // deferred repaint after acknowledging the visited tab
169+
170+ require.NotContains(t, string(data), "")
171+ sess.mu.Lock()
172+ defer sess.mu.Unlock()
173+ require.False(t, sess.tabs[1].attention)
174+ require.True(t, sess.tabs[1].attentionAt.IsZero())
175+}
176+
177+func TestCloseRingingTabClearsClientBar(t *testing.T) {
178+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
179+ defer releases[0]()
180+ defer releases[1]()
181+ sess.active = 0
182+ sess.mu.Lock()
183+ ringing := sess.tabs[1]
184+ ringing.attention = true
185+ ringing.attentionAt = time.Unix(2, 0)
186+ sess.mu.Unlock()
187+ d.setAttentionFrame(1)
188+
189+ d.paint(sess, ac, true)
190+ before := mustOutputData(t, sends)
191+ require.Contains(t, string(before), "")
192+
193+ d.closeTab(sess, ringing, true)
194+ after := mustOutputData(t, sends)
195+ require.NotContains(t, string(after), "")
196+}
197+
198+func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
199+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
200+ defer releases[0]()
201+ defer releases[1]()
202+ defer releases[2]()
203+ sess.active = 0
204+ sess.mu.Lock()
205+ sess.tabs[1].attention = true
206+ sess.tabs[1].attentionAt = time.Unix(20, 0)
207+ sess.tabs[2].attention = true
208+ sess.tabs[2].attentionAt = time.Unix(10, 0)
209+ sess.mu.Unlock()
210+
211+ d.handleInput(sess, ac, []byte("\x1ba"))
212+
213+ require.Equal(t, 2, activeTabIndex(sess))
214+ _ = mustOutputData(t, sends)
215+}
216+
217+func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
218+ p1, release1 := newBlockingPTY(t)
219+ p2, release2 := newBlockingPTY(t)
220+ p3, release3 := newBlockingPTY(t)
221+ defer release1()
222+ defer release2()
223+ defer release3()
224+ d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
225+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
226+ defer cancel2()
227+ sess2 := &session{
228+ id: "other",
229+ name: "other",
230+ ctx: sctx2,
231+ cancel: cancel2,
232+ tabs: []*tab{
233+ {
234+ pty: p2,
235+ screen: vt.NewScreen(80, 23),
236+ dirty: make(chan struct{}, 1),
237+ size: domain.Size{Cols: 80, Rows: 23},
238+ ctx: sctx2,
239+ cancel: cancel2,
240+ attention: true,
241+ attentionAt: time.Unix(9, 0),
242+ },
243+ {
244+ pty: p3,
245+ screen: vt.NewScreen(80, 23),
246+ dirty: make(chan struct{}, 1),
247+ size: domain.Size{Cols: 80, Rows: 23},
248+ ctx: sctx2,
249+ cancel: cancel2,
250+ attention: true,
251+ attentionAt: time.Unix(5, 0),
252+ },
253+ },
254+ }
255+ d.sessions[sess2.id] = sess2
256+
257+ d.jumpAttention(sess1, ac)
258+
259+ require.Same(t, sess2, ac.currentSession())
260+ require.Same(t, ac, sess2.client)
261+ require.Nil(t, sess1.client)
262+ require.Equal(t, 1, activeTabIndex(sess2))
263+ _ = mustOutputData(t, sends)
264+}
265+
266+func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
267+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
268+ defer releases[0]()
269+ defer releases[1]()
270+ sess.active = 0
271+
272+ d.jumpAttention(sess, ac)
273+
274+ require.Same(t, sess, ac.currentSession())
275+ require.Equal(t, 0, activeTabIndex(sess))
276+ select {
277+ case f := <-sends:
278+ t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
279+ case <-time.After(20 * time.Millisecond):
280+ }
281+}
282+
283+func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
284+ d, sess, _, sends, releases := newManualTabSession(t, 2)
285+ defer releases[0]()
286+ defer releases[1]()
287+ clk := newManualAttentionClock()
288+ d.clock = clk
289+ sess.active = 0
290+
291+ ctx, cancel := context.WithCancel(context.Background())
292+ done := make(chan struct{})
293+ go func() {
294+ d.attentionAnimator(ctx)
295+ close(done)
296+ }()
297+
298+ select {
299+ case <-clk.timers:
300+ t.Fatal("parked animator requested timer with no attention")
301+ case <-time.After(20 * time.Millisecond):
302+ }
303+
304+ sess.mu.Lock()
305+ sess.tabs[1].attention = true
306+ sess.tabs[1].attentionAt = time.Unix(1, 0)
307+ sess.mu.Unlock()
308+ d.pokeAttentionTicker()
309+
310+ timer := clk.nextTimer(t)
311+ timer.fire()
312+ _ = mustOutputData(t, sends)
313+ require.Equal(t, 1, d.attentionFrame(time.Time{}))
314+
315+ sess.mu.Lock()
316+ sess.tabs[1].attention = false
317+ sess.tabs[1].attentionAt = time.Time{}
318+ sess.mu.Unlock()
319+ d.pokeAttentionTicker()
320+
321+ require.Eventually(t, func() bool { return d.attentionFrame(time.Time{}) == 0 }, time.Second, time.Millisecond)
322+ _ = mustOutputData(t, sends)
323+ select {
324+ case <-clk.timers:
325+ t.Fatal("animator requested new timer after attention cleared")
326+ case <-time.After(20 * time.Millisecond):
327+ }
328+
329+ cancel()
330+ require.Eventually(t, func() bool {
331+ select {
332+ case <-done:
333+ return true
334+ default:
335+ return false
336+ }
337+ }, time.Second, time.Millisecond)
338+}
339+
340+func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
341+ d := New(nil, stubClock{}, nil)
342+ require.Equal(t, 1, cap(d.animWake))
343+ d.pokeAttentionTicker()
344+ d.pokeAttentionTicker()
345+ require.Len(t, d.animWake, 1)
346+}
347+
348+type manualAttentionClock struct {
349+ timers chan *manualAttentionTimer
350+}
351+
352+func newManualAttentionClock() *manualAttentionClock {
353+ return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
354+}
355+
356+func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
357+func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
358+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
359+ c.timers <- t
360+ return t
361+}
362+func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
363+ t.Helper()
364+ select {
365+ case timer := <-c.timers:
366+ return timer
367+ case <-time.After(time.Second):
368+ t.Fatal("timed out waiting for attention timer")
369+ return nil
370+ }
371+}
372+
373+type manualAttentionTimer struct {
374+ ch chan time.Time
375+}
376+
377+func (t *manualAttentionTimer) C() <-chan time.Time { return t.ch }
378+func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
379+func (t *manualAttentionTimer) Stop() bool { return true }
380+func (t *manualAttentionTimer) fire() { t.ch <- time.Now() }
@@ -0,0 +1,380 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
18+ d, sess, _, _, releases := newManualTabSession(t, 2)
19+ defer releases[0]()
20+ defer releases[1]()
21+ sess.active = 0
22+
23+ d.noteAttention(sess, sess.tabs[1])
24+
25+ sess.mu.Lock()
26+ defer sess.mu.Unlock()
27+ require.True(t, sess.tabs[1].attention)
28+ require.False(t, sess.tabs[1].attentionAt.IsZero())
29+ require.False(t, sess.tabs[0].attention)
30+}
31+
32+func TestNoteAttentionIgnoresVisibleTab(t *testing.T) {
33+ d, sess, _, _, releases := newManualTabSession(t, 1)
34+ defer releases[0]()
35+ sess.active = 0
36+
37+ d.noteAttention(sess, sess.tabs[0])
38+
39+ sess.mu.Lock()
40+ defer sess.mu.Unlock()
41+ require.False(t, sess.tabs[0].attention)
42+ require.True(t, sess.tabs[0].attentionAt.IsZero())
43+}
44+
45+func TestNoteAttentionFlagsDetachedActiveTab(t *testing.T) {
46+ d, sess, ac, _, releases := newManualTabSession(t, 1)
47+ defer releases[0]()
48+ sess.active = 0
49+ require.True(t, sess.detachIfCurrent(ac))
50+
51+ d.noteAttention(sess, sess.tabs[0])
52+
53+ sess.mu.Lock()
54+ defer sess.mu.Unlock()
55+ require.True(t, sess.tabs[0].attention)
56+ require.False(t, sess.tabs[0].attentionAt.IsZero())
57+}
58+
59+func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
60+ p1, releasePTY1 := newBlockingPTY(t)
61+ p2 := newScriptPTY([][]byte{[]byte("\a")})
62+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
63+ defer releasePTY1()
64+ sess.active = 0
65+
66+ d.sessWg.Add(1)
67+ go d.ptyReader(sess, sess.tabs[1])
68+
69+ require.Eventually(t, func() bool {
70+ sess.mu.Lock()
71+ defer sess.mu.Unlock()
72+ return sess.tabs[1].attention && !sess.tabs[1].attentionAt.IsZero()
73+ }, 2*time.Second, 5*time.Millisecond)
74+
75+ _ = p2.Close()
76+ d.sessWg.Wait()
77+}
78+
79+type scriptPTY struct {
80+ mu sync.Mutex
81+ reads [][]byte
82+ closed chan struct{}
83+ once sync.Once
84+}
85+
86+func newScriptPTY(reads [][]byte) *scriptPTY {
87+ return &scriptPTY{reads: reads, closed: make(chan struct{})}
88+}
89+
90+func (p *scriptPTY) Read(b []byte) (int, error) {
91+ p.mu.Lock()
92+ if len(p.reads) > 0 {
93+ next := p.reads[0]
94+ p.reads = p.reads[1:]
95+ p.mu.Unlock()
96+ return copy(b, next), nil
97+ }
98+ p.mu.Unlock()
99+ <-p.closed
100+ return 0, io.EOF
101+}
102+
103+func (p *scriptPTY) Write(b []byte) (int, error) { return len(b), nil }
104+func (p *scriptPTY) Close() error {
105+ p.once.Do(func() { close(p.closed) })
106+ return nil
107+}
108+func (p *scriptPTY) Resize(domain.Size) error { return nil }
109+func (p *scriptPTY) Pid() int { return 4242 }
110+
111+func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
112+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
113+ defer releases[0]()
114+ defer releases[1]()
115+ sess.active = 0
116+ sess.mu.Lock()
117+ sess.tabs[0].attention = true
118+ sess.tabs[0].attentionAt = time.Unix(1, 0)
119+ sess.tabs[1].attention = true
120+ sess.tabs[1].attentionAt = time.Unix(2, 0)
121+ sess.mu.Unlock()
122+
123+ d.paint(sess, ac, true)
124+ _ = mustOutputData(t, sends)
125+ _ = mustOutputData(t, sends) // deferred repaint after acknowledging the visible tab
126+
127+ sess.mu.Lock()
128+ defer sess.mu.Unlock()
129+ require.False(t, sess.tabs[0].attention)
130+ require.True(t, sess.tabs[0].attentionAt.IsZero())
131+ require.True(t, sess.tabs[1].attention)
132+ require.False(t, sess.tabs[1].attentionAt.IsZero())
133+}
134+
135+func TestPaintPreservesBackgroundAttention(t *testing.T) {
136+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
137+ defer releases[0]()
138+ defer releases[1]()
139+ sess.active = 0
140+ sess.mu.Lock()
141+ sess.tabs[1].attention = true
142+ sess.tabs[1].attentionAt = time.Unix(2, 0)
143+ sess.mu.Unlock()
144+ d.setAttentionFrame(1)
145+
146+ d.paint(sess, ac, true)
147+ data := mustOutputData(t, sends)
148+
149+ require.Contains(t, string(data), "")
150+ sess.mu.Lock()
151+ defer sess.mu.Unlock()
152+ require.True(t, sess.tabs[1].attention)
153+}
154+
155+func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
156+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
157+ defer releases[0]()
158+ defer releases[1]()
159+ sess.active = 0
160+ sess.mu.Lock()
161+ sess.tabs[1].attention = true
162+ sess.tabs[1].attentionAt = time.Unix(2, 0)
163+ sess.mu.Unlock()
164+
165+ require.True(t, sess.switchTab(1))
166+ d.paint(sess, ac, true)
167+ data := mustOutputData(t, sends)
168+ _ = mustOutputData(t, sends) // deferred repaint after acknowledging the visited tab
169+
170+ require.NotContains(t, string(data), "")
171+ sess.mu.Lock()
172+ defer sess.mu.Unlock()
173+ require.False(t, sess.tabs[1].attention)
174+ require.True(t, sess.tabs[1].attentionAt.IsZero())
175+}
176+
177+func TestCloseRingingTabClearsClientBar(t *testing.T) {
178+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
179+ defer releases[0]()
180+ defer releases[1]()
181+ sess.active = 0
182+ sess.mu.Lock()
183+ ringing := sess.tabs[1]
184+ ringing.attention = true
185+ ringing.attentionAt = time.Unix(2, 0)
186+ sess.mu.Unlock()
187+ d.setAttentionFrame(1)
188+
189+ d.paint(sess, ac, true)
190+ before := mustOutputData(t, sends)
191+ require.Contains(t, string(before), "")
192+
193+ d.closeTab(sess, ringing, true)
194+ after := mustOutputData(t, sends)
195+ require.NotContains(t, string(after), "")
196+}
197+
198+func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
199+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
200+ defer releases[0]()
201+ defer releases[1]()
202+ defer releases[2]()
203+ sess.active = 0
204+ sess.mu.Lock()
205+ sess.tabs[1].attention = true
206+ sess.tabs[1].attentionAt = time.Unix(20, 0)
207+ sess.tabs[2].attention = true
208+ sess.tabs[2].attentionAt = time.Unix(10, 0)
209+ sess.mu.Unlock()
210+
211+ d.handleInput(sess, ac, []byte("\x1ba"))
212+
213+ require.Equal(t, 2, activeTabIndex(sess))
214+ _ = mustOutputData(t, sends)
215+}
216+
217+func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
218+ p1, release1 := newBlockingPTY(t)
219+ p2, release2 := newBlockingPTY(t)
220+ p3, release3 := newBlockingPTY(t)
221+ defer release1()
222+ defer release2()
223+ defer release3()
224+ d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
225+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
226+ defer cancel2()
227+ sess2 := &session{
228+ id: "other",
229+ name: "other",
230+ ctx: sctx2,
231+ cancel: cancel2,
232+ tabs: []*tab{
233+ {
234+ pty: p2,
235+ screen: vt.NewScreen(80, 23),
236+ dirty: make(chan struct{}, 1),
237+ size: domain.Size{Cols: 80, Rows: 23},
238+ ctx: sctx2,
239+ cancel: cancel2,
240+ attention: true,
241+ attentionAt: time.Unix(9, 0),
242+ },
243+ {
244+ pty: p3,
245+ screen: vt.NewScreen(80, 23),
246+ dirty: make(chan struct{}, 1),
247+ size: domain.Size{Cols: 80, Rows: 23},
248+ ctx: sctx2,
249+ cancel: cancel2,
250+ attention: true,
251+ attentionAt: time.Unix(5, 0),
252+ },
253+ },
254+ }
255+ d.sessions[sess2.id] = sess2
256+
257+ d.jumpAttention(sess1, ac)
258+
259+ require.Same(t, sess2, ac.currentSession())
260+ require.Same(t, ac, sess2.client)
261+ require.Nil(t, sess1.client)
262+ require.Equal(t, 1, activeTabIndex(sess2))
263+ _ = mustOutputData(t, sends)
264+}
265+
266+func TestJumpAttentionNoopsWithNoBells(t *testing.T) {
267+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
268+ defer releases[0]()
269+ defer releases[1]()
270+ sess.active = 0
271+
272+ d.jumpAttention(sess, ac)
273+
274+ require.Same(t, sess, ac.currentSession())
275+ require.Equal(t, 0, activeTabIndex(sess))
276+ select {
277+ case f := <-sends:
278+ t.Fatalf("unexpected frame on no-op jump: %v", f.Type)
279+ case <-time.After(20 * time.Millisecond):
280+ }
281+}
282+
283+func TestAttentionAnimatorParksAdvancesAndResets(t *testing.T) {
284+ d, sess, _, sends, releases := newManualTabSession(t, 2)
285+ defer releases[0]()
286+ defer releases[1]()
287+ clk := newManualAttentionClock()
288+ d.clock = clk
289+ sess.active = 0
290+
291+ ctx, cancel := context.WithCancel(context.Background())
292+ done := make(chan struct{})
293+ go func() {
294+ d.attentionAnimator(ctx)
295+ close(done)
296+ }()
297+
298+ select {
299+ case <-clk.timers:
300+ t.Fatal("parked animator requested timer with no attention")
301+ case <-time.After(20 * time.Millisecond):
302+ }
303+
304+ sess.mu.Lock()
305+ sess.tabs[1].attention = true
306+ sess.tabs[1].attentionAt = time.Unix(1, 0)
307+ sess.mu.Unlock()
308+ d.pokeAttentionTicker()
309+
310+ timer := clk.nextTimer(t)
311+ timer.fire()
312+ _ = mustOutputData(t, sends)
313+ require.Equal(t, 1, d.attentionFrame(time.Time{}))
314+
315+ sess.mu.Lock()
316+ sess.tabs[1].attention = false
317+ sess.tabs[1].attentionAt = time.Time{}
318+ sess.mu.Unlock()
319+ d.pokeAttentionTicker()
320+
321+ require.Eventually(t, func() bool { return d.attentionFrame(time.Time{}) == 0 }, time.Second, time.Millisecond)
322+ _ = mustOutputData(t, sends)
323+ select {
324+ case <-clk.timers:
325+ t.Fatal("animator requested new timer after attention cleared")
326+ case <-time.After(20 * time.Millisecond):
327+ }
328+
329+ cancel()
330+ require.Eventually(t, func() bool {
331+ select {
332+ case <-done:
333+ return true
334+ default:
335+ return false
336+ }
337+ }, time.Second, time.Millisecond)
338+}
339+
340+func TestPokeAttentionTickerIsNonBlockingCapOne(t *testing.T) {
341+ d := New(nil, stubClock{}, nil)
342+ require.Equal(t, 1, cap(d.animWake))
343+ d.pokeAttentionTicker()
344+ d.pokeAttentionTicker()
345+ require.Len(t, d.animWake, 1)
346+}
347+
348+type manualAttentionClock struct {
349+ timers chan *manualAttentionTimer
350+}
351+
352+func newManualAttentionClock() *manualAttentionClock {
353+ return &manualAttentionClock{timers: make(chan *manualAttentionTimer, 8)}
354+}
355+
356+func (c *manualAttentionClock) Now() time.Time { return time.Unix(100, 0) }
357+func (c *manualAttentionClock) NewTimer(time.Duration) ports.Timer {
358+ t := &manualAttentionTimer{ch: make(chan time.Time, 1)}
359+ c.timers <- t
360+ return t
361+}
362+func (c *manualAttentionClock) nextTimer(t *testing.T) *manualAttentionTimer {
363+ t.Helper()
364+ select {
365+ case timer := <-c.timers:
366+ return timer
367+ case <-time.After(time.Second):
368+ t.Fatal("timed out waiting for attention timer")
369+ return nil
370+ }
371+}
372+
373+type manualAttentionTimer struct {
374+ ch chan time.Time
375+}
376+
377+func (t *manualAttentionTimer) C() <-chan time.Time { return t.ch }
378+func (t *manualAttentionTimer) Reset(time.Duration) bool { return true }
379+func (t *manualAttentionTimer) Stop() bool { return true }
380+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
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
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/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 +35 -2
@@ -206,6 +206,31 @@ 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+ }
226+
227+ for _, tc := range cases {
228+ t.Run(tc.name, func(t *testing.T) {
229+ require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
230+ })
231+ }
232+}
233+
209234 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210235 writes := make(chan []byte, 4)
211236 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -230,9 +255,17 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
230255
231256 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
232257 d.handleInput(sess, ac, raw)
233- require.Equal(t, raw, <-writes)
258+ require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
234259
235- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
260+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
261+ select {
262+ case got := <-writes:
263+ t.Fatalf("top-row mouse report forwarded: %q", got)
264+ default:
265+ }
266+
267+ statusRowReport := []byte("\x1b[<0;1;25M")
268+ d.handleInput(sess, ac, statusRowReport)
236269 select {
237270 case got := <-writes:
238271 t.Fatalf("status-row mouse report forwarded: %q", got)
@@ -206,6 +206,31 @@ 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+ }
226+
227+ for _, tc := range cases {
228+ t.Run(tc.name, func(t *testing.T) {
229+ require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
230+ })
231+ }
232+}
233+
209 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {234 func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
210 writes := make(chan []byte, 4)235 writes := make(chan []byte, 4)
211 p, _ := newBlockingPTYWithWrites(t, writes)236 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -230,9 +255,17 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
230 255
231 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))256 sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
232 d.handleInput(sess, ac, raw)257 d.handleInput(sess, ac, raw)
233- require.Equal(t, raw, <-writes)258+ require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
234 259
235- d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))260+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
261+ select {
262+ case got := <-writes:
263+ t.Fatalf("top-row mouse report forwarded: %q", got)
264+ default:
265+ }
266+
267+ statusRowReport := []byte("\x1b[<0;1;25M")
268+ d.handleInput(sess, ac, statusRowReport)
236 select {269 select {
237 case got := <-writes:270 case got := <-writes:
238 t.Fatalf("status-row mouse report forwarded: %q", got)271 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 +93 -9
@@ -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,17 +281,23 @@ 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.repaintAttachedClients(sess)
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 {
288302 frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)
289303 }
@@ -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,17 +281,23 @@ 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.repaintAttachedClients(sess)
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, styles.copyStatus, styles.selection)
289 }303 }
@@ -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 +14 -4
@@ -32,6 +32,7 @@ import (
3232 "strconv"
3333 "strings"
3434 "sync"
35+ "time"
3536
3637 "github.com/bnema/vev/internal/domain"
3738 "github.com/bnema/vev/internal/ports"
@@ -68,9 +69,12 @@ type tab struct {
6869 // previewClient tracks the one client currently previewing this tab in the picker.
6970 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
7071 previewClient *attachedClient
71- size domain.Size
72- ctx context.Context
73- cancel context.CancelFunc
72+ // attention and attentionAt are guarded by the owning session.mu.
73+ attention bool
74+ attentionAt time.Time
75+ size domain.Size
76+ ctx context.Context
77+ cancel context.CancelFunc
7478 }
7579
7680 // attachedClient is a client currently attached to a session's tab. rend is
@@ -155,7 +159,7 @@ func tabSize(clientSize domain.Size) domain.Size {
155159 if !clientSize.Valid() {
156160 clientSize = defaultSize
157161 }
158- rows := max(clientSize.Rows-1, 1)
162+ rows := max(clientSize.Rows-2, 1)
159163 return domain.Size{Cols: clientSize.Cols, Rows: rows}
160164 }
161165
@@ -242,6 +246,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
242246 d.killSession(sess, ports.ReasonSessionKilled)
243247 return
244248 }
249+ ringing := tb.attention
245250 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
246251 if sess.active >= len(sess.tabs) {
247252 sess.active = len(sess.tabs) - 1
@@ -258,6 +263,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
258263 _ = tb.pty.Close()
259264 if repaint && ac != nil {
260265 d.paint(sess, ac, true)
266+ } else if ringing {
267+ d.repaintAttachedClients(sess)
261268 }
262269 }
263270
@@ -265,6 +272,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
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 {
@@ -32,6 +32,7 @@ import (
32 "strconv"32 "strconv"
33 "strings"33 "strings"
34 "sync"34 "sync"
35+ "time"
35 36
36 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
37 "github.com/bnema/vev/internal/ports"38 "github.com/bnema/vev/internal/ports"
@@ -68,9 +69,12 @@ type tab struct {
68 // previewClient tracks the one client currently previewing this tab in the picker.69 // 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.70 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
70 previewClient *attachedClient71 previewClient *attachedClient
71- size domain.Size72+ // attention and attentionAt are guarded by the owning session.mu.
72- ctx context.Context73+ attention bool
73- cancel context.CancelFunc74+ attentionAt time.Time
75+ size domain.Size
76+ ctx context.Context
77+ cancel context.CancelFunc
74 }78 }
75 79
76 // attachedClient is a client currently attached to a session's tab. rend is80 // attachedClient is a client currently attached to a session's tab. rend is
@@ -155,7 +159,7 @@ func tabSize(clientSize domain.Size) domain.Size {
155 if !clientSize.Valid() {159 if !clientSize.Valid() {
156 clientSize = defaultSize160 clientSize = defaultSize
157 }161 }
158- rows := max(clientSize.Rows-1, 1)162+ rows := max(clientSize.Rows-2, 1)
159 return domain.Size{Cols: clientSize.Cols, Rows: rows}163 return domain.Size{Cols: clientSize.Cols, Rows: rows}
160 }164 }
161 165
@@ -242,6 +246,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
242 d.killSession(sess, ports.ReasonSessionKilled)246 d.killSession(sess, ports.ReasonSessionKilled)
243 return247 return
244 }248 }
249+ ringing := tb.attention
245 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)250 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
246 if sess.active >= len(sess.tabs) {251 if sess.active >= len(sess.tabs) {
247 sess.active = len(sess.tabs) - 1252 sess.active = len(sess.tabs) - 1
@@ -258,6 +263,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
258 _ = tb.pty.Close()263 _ = tb.pty.Close()
259 if repaint && ac != nil {264 if repaint && ac != nil {
260 d.paint(sess, ac, true)265 d.paint(sess, ac, true)
266+ } else if ringing {
267+ d.repaintAttachedClients(sess)
261 }268 }
262 }269 }
263 270
@@ -265,6 +272,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
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 +164 -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,96 @@ 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(d.clock.Now())
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+ oldest := time.Time{}
173+ for _, tb := range sess.tabs {
174+ if !tb.attention {
175+ continue
176+ }
177+ if oldest.IsZero() || tb.attentionAt.Before(oldest) {
178+ oldest = tb.attentionAt
179+ }
180+ }
181+ if !oldest.IsZero() {
182+ attention = append(attention, attentionSession{name: sess.name, at: oldest})
183+ }
184+ sess.mu.Unlock()
185+ }
186+ d.mu.Unlock()
187+ sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })
188+ state.otherAttention = make([]string, len(attention))
189+ for i := range attention {
190+ state.otherAttention[i] = attention[i].name
191+ }
192+ return state
193+}
194+
195+func (s barState) attentionStackText(width, reservedLeft int) string {
196+ if len(s.otherAttention) == 0 {
197+ return ""
198+ }
199+ parts := make([]string, len(s.otherAttention))
200+ for i, name := range s.otherAttention {
201+ parts[i] = string(attentionGlyph) + " " + name
202+ }
203+ full := strings.Join(parts, " ")
204+ if len([]rune(full))+1+reservedLeft <= width {
205+ return full
206+ }
207+ collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))
208+ if len([]rune(collapsed))+1+reservedLeft <= width {
209+ return collapsed
210+ }
211+ return ""
212+}
213+
214+func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {
215+ for _, r := range text {
216+ if r == attentionGlyph {
217+ writeBell(row, x, frame)
218+ continue
219+ }
220+ writeStatusText(row, x, string(r), style)
221+ }
222+}
223+
224+func writeBell(row []renderer.Cell, x *int, frame int) {
225+ style, visible := pulseStyle(frame)
226+ if !visible {
227+ writeStatusText(row, x, " ", renderer.DefaultStyle())
228+ return
229+ }
230+ writeStatusText(row, x, string(attentionGlyph), style)
231+}
232+
86233 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
87234 for _, r := range text {
88235 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,96 @@ 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(d.clock.Now())
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+ oldest := time.Time{}
173+ for _, tb := range sess.tabs {
174+ if !tb.attention {
175+ continue
176+ }
177+ if oldest.IsZero() || tb.attentionAt.Before(oldest) {
178+ oldest = tb.attentionAt
179+ }
180+ }
181+ if !oldest.IsZero() {
182+ attention = append(attention, attentionSession{name: sess.name, at: oldest})
183+ }
184+ sess.mu.Unlock()
185+ }
186+ d.mu.Unlock()
187+ sort.SliceStable(attention, func(i, j int) bool { return attention[i].at.Before(attention[j].at) })
188+ state.otherAttention = make([]string, len(attention))
189+ for i := range attention {
190+ state.otherAttention[i] = attention[i].name
191+ }
192+ return state
193+}
194+
195+func (s barState) attentionStackText(width, reservedLeft int) string {
196+ if len(s.otherAttention) == 0 {
197+ return ""
198+ }
199+ parts := make([]string, len(s.otherAttention))
200+ for i, name := range s.otherAttention {
201+ parts[i] = string(attentionGlyph) + " " + name
202+ }
203+ full := strings.Join(parts, " ")
204+ if len([]rune(full))+1+reservedLeft <= width {
205+ return full
206+ }
207+ collapsed := string(attentionGlyph) + " ×" + strconv.Itoa(len(s.otherAttention))
208+ if len([]rune(collapsed))+1+reservedLeft <= width {
209+ return collapsed
210+ }
211+ return ""
212+}
213+
214+func writeAttentionText(row []renderer.Cell, x *int, text string, frame int, style renderer.Style) {
215+ for _, r := range text {
216+ if r == attentionGlyph {
217+ writeBell(row, x, frame)
218+ continue
219+ }
220+ writeStatusText(row, x, string(r), style)
221+ }
222+}
223+
224+func writeBell(row []renderer.Cell, x *int, frame int) {
225+ style, visible := pulseStyle(frame)
226+ if !visible {
227+ writeStatusText(row, x, " ", renderer.DefaultStyle())
228+ return
229+ }
230+ writeStatusText(row, x, string(attentionGlyph), style)
231+}
232+
86 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {233 func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
87 for _, r := range text {234 for _, r := range text {
88 if *x >= len(row) {235 if *x >= len(row) {
M internal/usecase/daemon/status_test.go +103 -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,102 @@ 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 TestBarStateForExcludesCurrentSession(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ sess.name = "current"
322+ sess.mu.Lock()
323+ sess.tabs[0].attention = true
324+ sess.tabs[0].attentionAt = time.Unix(1, 0)
325+ sess.mu.Unlock()
326+ d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}
327+
328+ state := d.barStateFor(sess, "")
329+
330+ require.Equal(t, []string{"other"}, state.otherAttention)
239331 }
240332
241333 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,102 @@ 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 TestBarStateForExcludesCurrentSession(t *testing.T) {
318+ p, releasePTY := newBlockingPTY(t)
319+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
320+ defer releasePTY()
321+ sess.name = "current"
322+ sess.mu.Lock()
323+ sess.tabs[0].attention = true
324+ sess.tabs[0].attentionAt = time.Unix(1, 0)
325+ sess.mu.Unlock()
326+ d.sessions["other"] = &session{id: "other", name: "other", tabs: []*tab{{attention: true, attentionAt: time.Unix(2, 0)}}}
327+
328+ state := d.barStateFor(sess, "")
329+
330+ require.Equal(t, []string{"other"}, state.otherAttention)
239 }331 }
240 332
241 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {333 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close