1package daemon
2
3import (
4 "context"
5 "errors"
6 "time"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/picker"
12)
13
14// noteAttention records attention for a tab. If the tab is currently visible,
15// it is kept long enough to render one pulse before paint acknowledges it. The
16// caller must not hold tab.mu.
17func (d *Daemon) noteAttention(sess *session, tb *tab) {
18 if sess == nil || tb == nil {
19 return
20 }
21
22 now := d.clock.Now()
23 if now.IsZero() {
24 now = time.Now()
25 }
26
27 sess.mu.Lock()
28 if !tb.attention {
29 tb.attentionAt = now
30 }
31 tb.attention = true
32 for _, attachment := range sess.snapshotAttachmentsLocked() {
33 view := attachment.viewSnapshot()
34 if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() {
35 tb.attentionVisiblePaint = true
36 break
37 }
38 }
39 sess.mu.Unlock()
40
41 // Do not repaint here: this runs on the PTY reader goroutine, and paint
42 // can block on a slow client's socket (Transport.Send has no deadline) —
43 // that would let one wedged client stall a different session's reader.
44 // The animator's first tick (<=120ms) delivers the repaint, and pulse
45 // frame 0 renders the bell as blank anyway, so nothing is visibly lost
46 // by not painting immediately.
47 d.pokeAttentionTicker()
48}
49
50// jumpAttention switches to the oldest pending attention target: a tab of the
51// attached session first, then another session of this daemon, then the
52// oldest bell among the client's routes on any other daemon, which the client
53// navigates to. Returning nil never implies a switch happened —
54// it also covers "no target exists", which is routine and not an error. Only
55// a failure to reach a target that does exist is a genuine error.
56func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
57 return d.jumpAttentionForAttachment(sess, ac, nil)
58}
59
60func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, effect *attachmentEffect) error {
61 if sess == nil || ac == nil {
62 return nil
63 }
64 if idx, ok := oldestAttentionTab(sess); ok {
65 if sess.switchAttachmentTab(ac, idx) {
66 var lease *attachmentLease
67 if effect != nil {
68 lease = effect.lease
69 }
70 d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, lease)
71 d.invalidateRender(sess, ac, true, "attention.go")
72 }
73 return nil
74 }
75
76 target, ok := d.oldestOtherSessionAttention(sess)
77 if !ok {
78 return d.jumpRouteAttention(sess, ac, effect)
79 }
80 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
81 if effect == nil {
82 return d.switchToTarget(sess, ac, pickerTarget)
83 }
84 return d.switchActiveTargetForAttachment(effect, pickerTarget)
85}
86
87// jumpRouteAttention asks the client to navigate to the oldest attention
88// route it published for another daemon. The serving daemon's own sessions
89// were already considered, so a local route of this daemon is skipped.
90func (d *Daemon) jumpRouteAttention(sess *session, ac *attachedClient, effect *attachmentEffect) error {
91 entry, ok := oldestRouteAttention(ac.routeSnapshotCopy(), d.ownsLifecycle)
92 if !ok {
93 return nil
94 }
95 exec := paletteExec{d: d, sess: sess, attachment: sess, ac: ac, effect: effect}
96 err := exec.NavigateRecentRoute(entry)
97 if errors.Is(err, errAttachmentTransition) {
98 return nil
99 }
100 return err
101}
102
103// oldestRouteAttention selects the route whose attention the client observed
104// first. owned reports lifecycles this daemon serves itself.
105func oldestRouteAttention(snapshot protocol.RecentRouteSnapshot, owned func(domain.SessionLifecycleID) bool) (protocol.RouteNavigationAction, bool) {
106 var best protocol.RecentRouteEntry
107 found := false
108 for _, entry := range snapshot.Entries {
109 if !entry.Attention || entry.AttentionSeq == 0 || owned(entry.Target.LifecycleID) {
110 continue
111 }
112 if !found || entry.AttentionSeq < best.AttentionSeq {
113 best, found = entry, true
114 }
115 }
116 if !found || snapshot.Generation == 0 {
117 return protocol.RouteNavigationAction{}, false
118 }
119 return protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: best.Key, Generation: best.Generation}, true
120}
121
122// ownsLifecycle reports whether this daemon holds the lifecycle, live or
123// stopped.
124func (d *Daemon) ownsLifecycle(lifecycle domain.SessionLifecycleID) bool {
125 d.mu.Lock()
126 sessions := sessionsSnapshot(d.sessions)
127 stopped := false
128 for _, inactive := range d.inactive {
129 stopped = stopped || inactive.incarnation == lifecycle
130 }
131 d.mu.Unlock()
132 if stopped {
133 return true
134 }
135 for _, sess := range sessions {
136 sess.mu.Lock()
137 owned := sess.incarnation == lifecycle
138 sess.mu.Unlock()
139 if owned {
140 return true
141 }
142 }
143 return false
144}
145
146func oldestAttentionTab(sess *session) (int, bool) {
147 idx, _, ok := oldestAttentionTabWithTime(sess)
148 return idx, ok
149}
150
151type attentionTarget struct {
152 sessionID domain.SessionID
153 tabIndex int
154 at time.Time
155}
156
157func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
158 d.mu.Lock()
159 sessions := make([]*session, 0, len(d.sessions))
160 for _, sess := range d.sessions {
161 if sess != nil && sess != current {
162 sessions = append(sessions, sess)
163 }
164 }
165 d.mu.Unlock()
166
167 var target attentionTarget
168 found := false
169 for _, sess := range sessions {
170 idx, at, ok := oldestAttentionTabWithTime(sess)
171 if !ok {
172 continue
173 }
174 if !found || at.Before(target.at) {
175 target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at}
176 found = true
177 }
178 }
179 return target, found
180}
181
182func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
183 sess.mu.Lock()
184 defer sess.mu.Unlock()
185 idx := -1
186 var oldest time.Time
187 for i, tb := range sess.tabs {
188 if !tb.attention {
189 continue
190 }
191 if idx == -1 || tb.attentionAt.Before(oldest) {
192 idx = i
193 oldest = tb.attentionAt
194 }
195 }
196 return idx, oldest, idx != -1
197}
198
199func (d *Daemon) repaintAttachedClients(sess *session) {
200 for _, ac := range sess.snapshotAttachments() {
201 d.invalidateRender(sess, ac, false, "attention.go")
202 }
203}
204
205// refreshClientPickers republishes the picker source of every attachment whose
206// interaction the client presents. A bell raised or cleared while that modal is
207// open must reach it, and client-owned presentation suppresses the daemon's own
208// paints: only a newer source revision updates the client's rows. Unchanged
209// line sets publish nothing, so the attention pulse stays cheap.
210func (d *Daemon) refreshClientPickers() {
211 d.mu.Lock()
212 sessions := sessionsSnapshot(d.sessions)
213 d.mu.Unlock()
214
215 for _, sess := range sessions {
216 for _, ac := range sess.snapshotAttachments() {
217 if ac.overlays == nil || !ac.overlays.pickerClientActive() {
218 continue
219 }
220 d.refreshPickerSnapshot(ac)
221 }
222 }
223}
224
225func (d *Daemon) repaintAllAttachedClients() {
226 d.mu.Lock()
227 sessions := sessionsSnapshot(d.sessions)
228 d.mu.Unlock()
229
230 for _, sess := range sessions {
231 d.repaintAttachedClients(sess)
232 }
233}
234
235// ackAttention clears the bell of the tab ac shows. A client whose terminal
236// window reported losing focus is not looked at, so it never clears a bell:
237// the bell stays for every client until one that may be seen shows the tab.
238func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
239 if tb == nil || ac == nil || !ac.terminalFocus().MaySee() {
240 return false
241 }
242 s.mu.Lock()
243 defer s.mu.Unlock()
244 view := ac.viewSnapshot()
245 if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
246 return false
247 }
248 if tb.attentionVisiblePaint && !visible {
249 return false
250 }
251 tb.attention = false
252 tb.attentionAt = time.Time{}
253 tb.attentionVisiblePaint = false
254 return true
255}
256
257func (s *session) anyAttention() bool {
258 s.mu.Lock()
259 defer s.mu.Unlock()
260 for _, tb := range s.tabs {
261 if tb.attention {
262 return true
263 }
264 }
265 return false
266}
267
268func (d *Daemon) pokeAttentionTicker() {
269 if d == nil || d.animWake == nil {
270 return
271 }
272 select {
273 case d.animWake <- struct{}{}:
274 default:
275 }
276}
277
278func (d *Daemon) attentionFrame() int {
279 if d == nil {
280 return 0
281 }
282 d.attnMu.Lock()
283 defer d.attnMu.Unlock()
284 return d.animFrame
285}
286
287func (d *Daemon) attentionAnimator(ctx context.Context) {
288 active := false
289 var timer ports.Timer
290 for {
291 if !active {
292 if !d.anyAttention() {
293 d.setAttentionFrame(0)
294 select {
295 case <-ctx.Done():
296 return
297 case <-d.animWake:
298 continue
299 }
300 }
301 active = true
302 }
303
304 if timer == nil {
305 timer = d.clock.NewTimer(pulseFrameInterval)
306 } else {
307 timer.Reset(pulseFrameInterval)
308 }
309
310 select {
311 case <-ctx.Done():
312 if timer != nil {
313 timer.Stop()
314 }
315 return
316 case <-d.animWake:
317 if !timer.Stop() {
318 select {
319 case <-timer.C():
320 default:
321 }
322 }
323 case <-timer.C():
324 d.advanceAttentionFrame()
325 d.refreshClientPickers()
326 d.repaintAllAttachedClients()
327 }
328
329 if !d.anyAttention() {
330 d.setAttentionFrame(0)
331 d.repaintAllAttachedClients()
332 active = false
333 }
334 }
335}
336
337func (d *Daemon) anyAttention() bool {
338 d.mu.Lock()
339 sessions := sessionsSnapshot(d.sessions)
340 d.mu.Unlock()
341
342 for _, sess := range sessions {
343 if sess.anyAttention() {
344 return true
345 }
346 for _, ac := range sess.snapshotAttachments() {
347 snapshot := ac.routeSnapshotCopy()
348 for _, entry := range snapshot.Entries {
349 if entry.Attention {
350 return true
351 }
352 }
353 }
354 }
355 return false
356}
357
358func (d *Daemon) advanceAttentionFrame() {
359 d.attnMu.Lock()
360 d.animFrame = (d.animFrame + 1) % pulseFrameCount
361 d.attnMu.Unlock()
362}
363
364func (d *Daemon) setAttentionFrame(frame int) {
365 d.attnMu.Lock()
366 d.animFrame = frame
367 d.attnMu.Unlock()
368}