package daemon import ( "context" "errors" "time" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/picker" ) // noteAttention records attention for a tab. If the tab is currently visible, // it is kept long enough to render one pulse before paint acknowledges it. The // caller must not hold tab.mu. func (d *Daemon) noteAttention(sess *session, tb *tab) { if sess == nil || tb == nil { return } now := d.daemonNow() sess.mu.Lock() if !tb.attention { tb.attentionAt = now } tb.attention = true for _, attachment := range sess.snapshotAttachmentsLocked() { view := attachment.viewSnapshot() if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() { tb.attentionVisiblePaint = true break } } sess.mu.Unlock() // Do not repaint here: this runs on the PTY reader goroutine, and paint // can block on a slow client's socket (Transport.Send has no deadline) — // that would let one wedged client stall a different session's reader. // The animator's first tick (<=120ms) delivers the repaint, and pulse // frame 0 renders the bell as blank anyway, so nothing is visibly lost // by not painting immediately. d.pokeAttentionTicker() } // jumpAttention switches to the oldest pending attention target: a tab of the // attached session first, then another session of this daemon, then the // oldest bell among the client's routes on any other daemon, which the client // navigates to. Returning nil never implies a switch happened — // it also covers "no target exists", which is routine and not an error. Only // a failure to reach a target that does exist is a genuine error. func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error { return d.jumpAttentionForAttachment(sess, ac, nil) } func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, effect *attachmentEffect) error { if sess == nil || ac == nil { return nil } if idx, ok := oldestAttentionTab(sess); ok { if sess.switchAttachmentTab(ac, idx) { var lease *attachmentLease if effect != nil { lease = effect.lease } d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, lease) d.invalidateRender(sess, ac, true, "attention.go") } return nil } target, ok := d.oldestOtherSessionAttention(sess) if !ok { return d.jumpRouteAttention(sess, ac, effect) } pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex} if effect == nil { return d.switchToTarget(sess, ac, pickerTarget) } return d.switchActiveTargetForAttachment(effect, pickerTarget) } // jumpRouteAttention asks the client to navigate to the oldest attention // route it published for another daemon. The serving daemon's own sessions // were already considered, so a local route of this daemon is skipped. func (d *Daemon) jumpRouteAttention(sess *session, ac *attachedClient, effect *attachmentEffect) error { entry, ok := oldestRouteAttention(ac.routeSnapshotCopy(), d.ownsLifecycle) if !ok { return nil } exec := paletteExec{d: d, sess: sess, attachment: sess, ac: ac, effect: effect} err := exec.NavigateRecentRoute(entry) if errors.Is(err, errAttachmentTransition) { return nil } return err } // oldestRouteAttention selects the route whose attention the client observed // first. owned reports lifecycles this daemon serves itself. func oldestRouteAttention(snapshot protocol.RecentRouteSnapshot, owned func(domain.SessionLifecycleID) bool) (protocol.RouteNavigationAction, bool) { var best protocol.RecentRouteEntry found := false for _, entry := range snapshot.Entries { if !entry.Attention || entry.AttentionSeq == 0 || owned(entry.Target.LifecycleID) { continue } if !found || entry.AttentionSeq < best.AttentionSeq { best, found = entry, true } } if !found || snapshot.Generation == 0 { return protocol.RouteNavigationAction{}, false } return protocol.RouteNavigationAction{SnapshotGeneration: snapshot.Generation, Key: best.Key, Generation: best.Generation}, true } // ownsLifecycle reports whether this daemon holds the lifecycle, live or // stopped. func (d *Daemon) ownsLifecycle(lifecycle domain.SessionLifecycleID) bool { d.mu.Lock() sessions := sessionsSnapshot(d.sessions) stopped := false for _, inactive := range d.inactive { stopped = stopped || inactive.incarnation == lifecycle } d.mu.Unlock() if stopped { return true } for _, sess := range sessions { sess.mu.Lock() owned := sess.incarnation == lifecycle sess.mu.Unlock() if owned { return true } } return false } func oldestAttentionTab(sess *session) (int, bool) { idx, _, ok := oldestAttentionTabWithTime(sess) return idx, ok } type attentionTarget struct { sessionID domain.SessionID tabIndex int at time.Time } func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) { d.mu.Lock() sessions := make([]*session, 0, len(d.sessions)) for _, sess := range d.sessions { if sess != nil && sess != current { sessions = append(sessions, sess) } } d.mu.Unlock() var target attentionTarget found := false for _, sess := range sessions { idx, at, ok := oldestAttentionTabWithTime(sess) if !ok { continue } if !found || at.Before(target.at) { target = attentionTarget{sessionID: sess.id, tabIndex: idx, at: at} found = true } } return target, found } func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) { sess.mu.Lock() defer sess.mu.Unlock() idx := -1 var oldest time.Time for i, tb := range sess.tabs { if !tb.attention { continue } if idx == -1 || tb.attentionAt.Before(oldest) { idx = i oldest = tb.attentionAt } } return idx, oldest, idx != -1 } func (d *Daemon) repaintAttachedClients(sess *session) { for _, ac := range sess.snapshotAttachments() { d.invalidateRender(sess, ac, false, "attention.go") } } // refreshClientPickers republishes the picker source of every attachment whose // interaction the client presents. A bell raised or cleared while that modal is // open must reach it, and client-owned presentation suppresses the daemon's own // paints: only a newer source revision updates the client's rows. Unchanged // line sets publish nothing, so the attention pulse stays cheap. func (d *Daemon) refreshClientPickers() { d.mu.Lock() sessions := sessionsSnapshot(d.sessions) d.mu.Unlock() for _, sess := range sessions { for _, ac := range sess.snapshotAttachments() { if ac.overlays == nil || !ac.overlays.pickerClientActive() { continue } d.refreshPickerSnapshot(ac) } } } func (d *Daemon) repaintAllAttachedClients() { d.mu.Lock() sessions := sessionsSnapshot(d.sessions) d.mu.Unlock() for _, sess := range sessions { d.repaintAttachedClients(sess) } } // ackAttention clears the bell of the tab ac shows. A client whose terminal // window reported losing focus is not looked at, so it never clears a bell: // the bell stays for every client until one that may be seen shows the tab. func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { if tb == nil || ac == nil || !ac.terminalFocus().MaySee() { return false } s.mu.Lock() defer s.mu.Unlock() view := ac.viewSnapshot() if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention { return false } if tb.attentionVisiblePaint && !visible { return false } tb.attention = false tb.attentionAt = time.Time{} tb.attentionVisiblePaint = false return true } func (s *session) anyAttention() bool { s.mu.Lock() defer s.mu.Unlock() for _, tb := range s.tabs { if tb.attention { return true } } return false } func (d *Daemon) pokeAttentionTicker() { if d == nil || d.animWake == nil { return } select { case d.animWake <- struct{}{}: default: } } func (d *Daemon) attentionFrame() int { if d == nil { return 0 } d.attnMu.Lock() defer d.attnMu.Unlock() return d.animFrame } func (d *Daemon) attentionAnimator(ctx context.Context) { active := false var timer ports.Timer for { if !active { if !d.anyAttention() { d.setAttentionFrame(0) select { case <-ctx.Done(): return case <-d.animWake: continue } } active = true } if timer == nil { timer = d.clock.NewTimer(pulseFrameInterval) } else { timer.Reset(pulseFrameInterval) } select { case <-ctx.Done(): if timer != nil { timer.Stop() } return case <-d.animWake: if !timer.Stop() { select { case <-timer.C(): default: } } case <-timer.C(): d.advanceAttentionFrame() d.refreshClientPickers() d.repaintAllAttachedClients() } if !d.anyAttention() { d.setAttentionFrame(0) d.repaintAllAttachedClients() active = false } } } func (d *Daemon) anyAttention() bool { d.mu.Lock() sessions := sessionsSnapshot(d.sessions) d.mu.Unlock() for _, sess := range sessions { if sess.anyAttention() { return true } for _, ac := range sess.snapshotAttachments() { snapshot := ac.routeSnapshotCopy() for _, entry := range snapshot.Entries { if entry.Attention { return true } } } } return false } func (d *Daemon) advanceAttentionFrame() { d.attnMu.Lock() d.animFrame = (d.animFrame + 1) % pulseFrameCount d.attnMu.Unlock() } func (d *Daemon) setAttentionFrame(frame int) { d.attnMu.Lock() d.animFrame = frame d.attnMu.Unlock() }