Skip to main content

gpui_ai/
cues.rs

1//! Interaction cues: the moments an application may want to mark with a
2//! sound, haptic, or notification.
3//!
4//! gpui-ai does not play audio. Components emit a [`Cue`] at the moments
5//! that matter (a reply arriving, a response settling, text copied, a
6//! prompt submitted, …) and an application can observe them in one place:
7//!
8//! ```no_run
9//! # fn example(cx: &mut gpui::App, play: fn(&str)) {
10//! use gpui_ai::cues::{self, Cue};
11//!
12//! let _cues = cues::observe(cx, move |cue, _cx| match cue {
13//!     Cue::ResponseSettled { .. } => play("chime"),
14//!     Cue::Copied => play("tick"),
15//!     _ => {}
16//! });
17//! // Keep `_cues` alive for as long as the sounds should play.
18//! # }
19//! ```
20//!
21//! Cues are hints, never state: every cue corresponds to a typed component
22//! event or snapshot transition that the application already receives.
23
24use gpui::{App, Global, SharedString};
25use std::{cell::Cell, rc::Rc};
26
27/// A moment worth a cue.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Cue {
30    /// A new message entered a conversation.
31    MessageArrived {
32        /// Stable message identifier.
33        message_id: SharedString,
34    },
35    /// A streaming response finished (successfully or not).
36    ResponseSettled {
37        /// Stable message identifier.
38        message_id: SharedString,
39        /// Whether the response completed rather than failed.
40        succeeded: bool,
41    },
42    /// Text was copied to the clipboard.
43    Copied,
44    /// The user submitted a prompt.
45    Submitted,
46    /// The user cancelled running work.
47    Cancelled,
48    /// The user chose a starter suggestion.
49    SuggestionSelected,
50    /// The user switched conversations.
51    ThreadSelected,
52    /// A tool call or approval gate was decided.
53    Decided {
54        /// Whether the decision was an approval.
55        approved: bool,
56    },
57}
58
59type Observer = Rc<dyn Fn(&Cue, &mut App)>;
60
61struct ObserverRegistration {
62    active: Rc<Cell<bool>>,
63    observer: Observer,
64}
65
66#[derive(Default)]
67struct CueHub {
68    observers: Vec<ObserverRegistration>,
69}
70
71impl Global for CueHub {}
72
73/// Keeps a cue observer registered; dropping it unregisters the observer.
74#[must_use = "dropping the subscription stops cue delivery"]
75pub struct CueSubscription {
76    active: Option<Rc<Cell<bool>>>,
77}
78
79impl CueSubscription {
80    /// Detaches the observer for the rest of the application's lifetime.
81    pub fn detach(mut self) {
82        self.active.take();
83    }
84}
85
86impl Drop for CueSubscription {
87    fn drop(&mut self) {
88        // The registration owns another reference, so this signal remains
89        // valid until that app next emits and prunes the observer.
90        if let Some(active) = self.active.as_ref() {
91            active.set(false);
92        }
93    }
94}
95
96/// Registers an observer for every cue emitted by gpui-ai components.
97pub fn observe(cx: &mut App, observer: impl Fn(&Cue, &mut App) + 'static) -> CueSubscription {
98    let active = Rc::new(Cell::new(true));
99    cx.default_global::<CueHub>()
100        .observers
101        .push(ObserverRegistration {
102            active: active.clone(),
103            observer: Rc::new(observer),
104        });
105    CueSubscription {
106        active: Some(active),
107    }
108}
109
110/// Emits a cue to every live observer. Components call this at the moments
111/// described on [`Cue`]; applications may also emit their own.
112pub fn emit(cx: &mut App, cue: Cue) {
113    if !cx.has_global::<CueHub>() {
114        return;
115    }
116    let observers: Vec<Observer> = {
117        let hub = cx.global_mut::<CueHub>();
118        hub.observers
119            .retain(|registration| registration.active.get());
120        hub.observers
121            .iter()
122            .map(|registration| registration.observer.clone())
123            .collect()
124    };
125    // Observers run outside the hub borrow: one of them may register another
126    // observer or drop a subscription.
127    for observer in observers {
128        observer(&cue, cx);
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use gpui::TestAppContext;
136    use std::{cell::RefCell, rc::Rc};
137
138    #[gpui::test]
139    fn observers_receive_cues_until_dropped(cx: &mut TestAppContext) {
140        let received = Rc::new(RefCell::new(Vec::new()));
141        let subscription = cx.update(|cx| {
142            let received = received.clone();
143            observe(cx, move |cue, _| received.borrow_mut().push(cue.clone()))
144        });
145        cx.update(|cx| emit(cx, Cue::Copied));
146        assert_eq!(received.borrow().as_slice(), &[Cue::Copied]);
147
148        drop(subscription);
149        cx.update(|cx| emit(cx, Cue::Submitted));
150        assert_eq!(received.borrow().as_slice(), &[Cue::Copied]);
151    }
152
153    #[gpui::test]
154    fn emitting_without_observers_is_a_no_op(cx: &mut TestAppContext) {
155        cx.update(|cx| emit(cx, Cue::Cancelled));
156    }
157}