Skip to main content

rig_harness/
pick.rs

1//! Choices for the user that any front shows and answers: a plugin's
2//! [`Picker`], a filterable list on an entity of the plugin's. Inserting a
3//! new `Picker` replaces what it shows, and despawning it closes it. A
4//! front that shows pickers (`Query<(Entity, &Picker)>`) triggers [`Pick`]
5//! on the one the user picked from, or closed: the harness then runs the
6//! item's command, for the picker's agent or else the agent the user is
7//! looking at ([`ShownAgent`]), triggers [`Picked`] on
8//! it, and despawns it. A picker may take several items
9//! ([`Picker::multiple`]): the harness runs each one's command in list
10//! order. A [`Chosen`] holds the items picked and the text typed, either
11//! or both. A front answers an open [`Ask`] the same way: a `Pick` on it
12//! decides the answer, a question's choices with the note typed on them.
13//!
14//! ```no_run
15//! use rig_harness::pick::{Chosen, Picked, Picker};
16//! use rig_harness::prelude::*;
17//!
18//! /// Asks which branch to check out, or a new one's name.
19//! fn pick_branch(mut commands: Commands) {
20//!     let picker = Picker::new("Branch").item("main", "").item("dev", "").free_text();
21//!     commands.spawn(picker).observe(|picked: On<Picked>, mut notices: MessageWriter<Notice>| {
22//!         let Some(chosen) = &picked.chosen else {
23//!             return;
24//!         };
25//!         let branch = match (chosen.items.first(), &chosen.text) {
26//!             (Some(0), _) => "main".to_owned(),
27//!             (Some(_), _) => "dev".to_owned(),
28//!             (None, Some(name)) => name.clone(),
29//!             (None, None) => return,
30//!         };
31//!         notices.write(Notice::info(None, format!("checking out {branch}")));
32//!     });
33//! }
34//! ```
35
36use bevy_app::prelude::*;
37use bevy_ecs::prelude::*;
38use bevy_reflect::prelude::*;
39use rig_core::conversation::{Conversation, MessageId};
40use rig_core::effect::Decider;
41use rig_ecs::stage::{Decision, Outcome, Settled};
42
43use crate::ask::{Answer, Ask};
44use crate::commands::RunCommand;
45use crate::main_agent::{ShownAgent, keep_shown};
46
47/// One choice of a picker: what it shows, and the command line, without
48/// its `/`, that choosing it runs for the agent, such as
49/// `model openai/gpt-5`; an empty one runs nothing.
50#[derive(Reflect, Clone, Debug, Default, PartialEq, Eq)]
51#[reflect(Clone, Debug, Default, PartialEq)]
52pub struct PickItem {
53    /// What it shows.
54    pub label: String,
55    /// The command line it runs.
56    pub command: String,
57    /// What a front shows beside the label, kept whole where the label is
58    /// shortened to fit, such as a session's age or why a model cannot be
59    /// used.
60    pub detail: Option<String>,
61    /// Whether it is ticked at first, in a picker of several items.
62    pub checked: bool,
63}
64
65/// A filterable list for the user to choose one item, or several, from, on
66/// an entity of the plugin's. See [`pick`](crate::pick).
67#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Eq)]
68#[reflect(Component, Clone, Debug, Default, PartialEq)]
69pub struct Picker {
70    /// The agent an item's command runs for; `None` for the shown agent.
71    pub agent: Option<Entity>,
72    /// What is picked.
73    pub title: String,
74    /// More about it, shown above the list; may be empty.
75    pub body: String,
76    /// The choices.
77    pub items: Vec<PickItem>,
78    /// The position of the choice selected at first.
79    pub selected: usize,
80    /// Whether what the user types may be the answer itself
81    /// ([`Chosen`]'s `text`), besides filtering the items.
82    pub free_text: bool,
83    /// Whether the user may tick several items.
84    pub multiple: bool,
85}
86
87impl Picker {
88    /// An empty picker titled `title`.
89    pub fn new(title: impl Into<String>) -> Self {
90        Self {
91            title: title.into(),
92            ..Self::default()
93        }
94    }
95
96    /// The same picker, its items' commands run for `agent`.
97    pub fn for_agent(mut self, agent: Entity) -> Self {
98        self.agent = Some(agent);
99        self
100    }
101
102    /// The same picker, about `body`.
103    pub fn body(mut self, body: impl Into<String>) -> Self {
104        self.body = body.into();
105        self
106    }
107
108    /// The same picker with an item showing `label` that runs `command`
109    /// (empty: nothing).
110    pub fn item(mut self, label: impl Into<String>, command: impl Into<String>) -> Self {
111        self.items.push(PickItem {
112            label: label.into(),
113            command: command.into(),
114            detail: None,
115            checked: false,
116        });
117        self
118    }
119
120    /// The same picker, its last item ticked at first, or not.
121    pub fn checked(mut self, checked: bool) -> Self {
122        if let Some(item) = self.items.last_mut() {
123            item.checked = checked;
124        }
125        self
126    }
127
128    /// The same picker, taking several items.
129    pub fn multiple(mut self) -> Self {
130        self.multiple = true;
131        self
132    }
133
134    /// The same picker, also answered by what the user types.
135    pub fn free_text(mut self) -> Self {
136        self.free_text = true;
137        self
138    }
139}
140
141/// What the user chose: the items or choices picked and the text typed,
142/// either or both, such as a question's choice with a note on it.
143#[derive(Reflect, Clone, Debug, Default, PartialEq, Eq)]
144#[reflect(Clone, Debug, Default, PartialEq)]
145pub struct Chosen {
146    /// The positions of the [`Picker`]'s items, or the [`Ask`]'s choices,
147    /// picked: one, or any number for one that takes several; none for an
148    /// answer only typed.
149    pub items: Vec<usize>,
150    /// What the user typed, where free text is taken: alone, or a note on
151    /// the items.
152    pub text: Option<String>,
153}
154
155impl Chosen {
156    /// The item, or choice, at `index`.
157    pub fn item(index: usize) -> Self {
158        Self::items([index])
159    }
160
161    /// The items, or choices, at `indices`.
162    pub fn items(indices: impl IntoIterator<Item = usize>) -> Self {
163        Self {
164            items: indices.into_iter().collect(),
165            text: None,
166        }
167    }
168
169    /// What the user typed, `text`.
170    pub fn text(text: impl Into<String>) -> Self {
171        Self::default().with_text(text)
172    }
173
174    /// The same choice, with `text` typed as its note.
175    pub fn with_text(mut self, text: impl Into<String>) -> Self {
176        self.text = Some(text.into());
177        self
178    }
179
180    /// The positions chosen, in list order, each once.
181    fn positions(&self) -> Vec<usize> {
182        let mut positions = self.items.clone();
183        positions.sort_unstable();
184        positions.dedup();
185        positions
186    }
187}
188
189/// The position of the item a front highlights in a [`Picker`], set by the
190/// front on the picker's entity as the highlight moves, so a plugin can
191/// preview that item ([`PickPreview`]).
192#[derive(Component, Reflect, Clone, Copy, Debug, Default, PartialEq, Eq)]
193#[reflect(Component, Clone, Debug, Default, PartialEq)]
194pub struct Highlighted(pub usize);
195
196/// What a front shows beside a [`Picker`] of the plugin's, previewing the
197/// highlighted item ([`Highlighted`]): text, or a conversation drawn as a
198/// transcript, scrolled to entry `at` when given. Any plugin sets it on its
199/// picker and replaces it as the highlight moves.
200#[derive(Component, Clone, Debug, PartialEq)]
201pub enum PickPreview {
202    /// Plain text.
203    Text(String),
204    /// A conversation, such as a saved session's line.
205    Conversation {
206        /// The conversation.
207        conversation: Conversation,
208        /// The entry to show first; the end with `None`.
209        at: Option<MessageId>,
210    },
211}
212
213/// A front triggers it on a [`Picker`] or an open [`Ask`] when the user
214/// picks, or with `None` closes it, with [`Pick::person`]; a plugin that
215/// answers a question itself names itself in `by`. The harness carries out
216/// the choice, and the question's record names who answered it.
217#[derive(EntityEvent, Reflect, Clone, Debug)]
218#[reflect(Event, Clone, Debug)]
219pub struct Pick {
220    /// The picker or question.
221    pub entity: Entity,
222    /// What was chosen, or `None` when it was closed.
223    pub chosen: Option<Chosen>,
224    /// Who picked: [`Decider::Person`] from a front, else a plugin by name.
225    /// Reflection leaves it out and reads it as a person.
226    #[reflect(ignore, default = "person")]
227    pub by: Decider,
228}
229
230/// [`Decider::Person`], for reflection.
231fn person() -> Decider {
232    Decider::Person
233}
234
235impl Pick {
236    /// What the user chose on `entity` through a front.
237    pub fn person(entity: Entity, chosen: Option<Chosen>) -> Self {
238        Self {
239            entity,
240            chosen,
241            by: Decider::Person,
242        }
243    }
244}
245
246/// Triggered on a [`Picker`]'s entity when the user picked, or with `None`
247/// closed it, after its item's command was asked for and before it is
248/// despawned.
249#[derive(EntityEvent, Reflect, Clone, Debug)]
250#[reflect(Event, Clone, Debug)]
251pub struct Picked {
252    /// The picker.
253    pub entity: Entity,
254    /// What was chosen, or `None` when the picker was closed.
255    pub chosen: Option<Chosen>,
256}
257
258/// Carries out [`Pick`]s.
259pub struct PickPlugin;
260
261impl Plugin for PickPlugin {
262    fn build(&self, app: &mut App) {
263        app.init_resource::<ShownAgent>()
264            .add_systems(First, keep_shown)
265            .add_observer(pick);
266    }
267}
268
269/// Runs the chosen items' commands, in list order, for the picker's agent,
270/// else the one shown, and triggers [`Picked`] before despawning the
271/// picker; answers a question, a closed one refused.
272fn pick(
273    pick: On<Pick>,
274    pickers: Query<&Picker>,
275    mut asks: Query<(&Ask, &mut Decision), (Without<Outcome>, Without<Settled>)>,
276    shown: Res<ShownAgent>,
277    mut commands: Commands,
278) {
279    let (entity, chosen) = (pick.entity, pick.chosen.clone());
280    if let Ok(picker) = pickers.get(entity) {
281        let items = chosen.as_ref().map(Chosen::positions).unwrap_or_default();
282        let items = items.iter().filter_map(|index| picker.items.get(*index));
283        if let Some(agent) = picker.agent.or(shown.0) {
284            for item in items.filter(|item| !item.command.is_empty()) {
285                commands.trigger(RunCommand::new(agent, item.command.clone()));
286            }
287        }
288        commands.trigger(Picked { entity, chosen });
289        commands.entity(entity).try_despawn();
290        return;
291    }
292    let Ok((ask, mut decision)) = asks.get_mut(entity) else {
293        return;
294    };
295    let answer = match chosen {
296        Some(chosen) => {
297            let ids = chosen
298                .positions()
299                .into_iter()
300                .map(|index| ask.choices.get(index));
301            let ids: Option<Vec<String>> = ids.map(|c| c.map(|c| c.id.clone())).collect();
302            let typed = chosen.text.is_some();
303            ids.filter(|ids| ask.multiple || ids.len() == 1 || (ids.is_empty() && typed))
304                .map(|choices| {
305                    let answer = Answer {
306                        choices,
307                        text: chosen.text,
308                    };
309                    answer.outcome()
310                })
311        }
312        None => Some(Outcome::refused("The user closed the question.")),
313    };
314    if let Some(answer) = answer {
315        decision.decide(pick.by.clone(), answer);
316    }
317}
318
319#[cfg(test)]
320mod tests;