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