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;