Skip to main content

rig_tui/
status.rs

1//! The status line: what the view shows under the transcript of the agent
2//! it shows. Each agent, and its running turn, has its [`StatusItems`], and
3//! the app its [`AppStatus`]; a plugin shows its items there by name in
4//! [`StatusSystems::Show`], and replaces or hides another plugin's in
5//! [`StatusSystems::Edit`], as plain text in a [`Tone`] or in runs of their
6//! own styles ([`StatusItem::styled`]). A plugin that also builds without
7//! the terminal view does it behind a default `tui` cargo feature. The
8//! plugin guide has an example and the default plugins' names and places.
9//! A [`StatusLine`] draws one in a panel, for the shown agent or another,
10//! and [`line()`] fits any items to a width.
11//!
12//! ```no_run
13//! use rig_harness::prelude::*;
14//! use rig_tui::{Side, StatusItem, StatusItems, StatusSystems, Tone};
15//!
16//! /// A gauge in place of `rig_basics::telemetry`'s `context` item. It
17//! /// shows its item again whenever its meter changes, so this replaces
18//! /// it every frame.
19//! fn gauge(mut agents: Query<&mut StatusItems, With<Agent>>) {
20//!     for mut items in &mut agents {
21//!         items.show(StatusItem::new("context", Side::Right, 40, 5).says("▮▮▯▯", Tone::Dim));
22//!     }
23//! }
24//!
25//! fn build(app: &mut App) {
26//!     app.add_systems(PostUpdate, gauge.in_set(StatusSystems::Edit));
27//! }
28//! ```
29
30use bevy_app::prelude::*;
31use bevy_ecs::prelude::*;
32use bevy_ecs::system::SystemParam;
33use bevy_reflect::prelude::*;
34
35use ratatui::layout::{Constraint, Layout};
36use ratatui::style::Style;
37use ratatui::text::{Line, Span};
38use ratatui::widgets::Widget;
39use rig_ecs::agent::{ActiveTurn, Agent, TurnOf};
40use rig_harness::main_agent::ShownAgent;
41use serde::{Deserialize, Serialize};
42use unicode_width::UnicodeWidthChar;
43
44use super::editor::Editor;
45use super::panel::{PanelCanvas, Placement, TuiPanel};
46
47/// One piece of the status line: its name, what it says, how, and its
48/// place.
49#[derive(Reflect, Clone, Debug, Default, PartialEq, Eq)]
50#[reflect(Clone, Debug, Default, PartialEq)]
51pub struct StatusItem {
52    /// What it is, such as `context`: one item of a name shows in a
53    /// [`StatusItems`], and another plugin replaces or hides it by name.
54    pub name: &'static str,
55    /// What it says.
56    pub text: String,
57    /// How it is drawn.
58    pub tone: Tone,
59    /// Its side of the line.
60    pub side: Side,
61    /// Its place on its side, lowest first.
62    pub order: u8,
63    /// How long it stays when the line is too narrow: the items with the
64    /// lowest go first, and one at `u8::MAX` never.
65    pub keep: u8,
66    /// How narrow it may be cut, ending in `…`, when the line is too
67    /// narrow, before it goes (by `keep`); 0 for an item never cut.
68    pub min_width: u16,
69    /// Its text in runs of their own styles, drawn in place of `text` in
70    /// its `tone`; see [`StatusItem::styled`].
71    #[reflect(ignore)]
72    pub styled: Option<Line<'static>>,
73}
74
75impl StatusItem {
76    /// An empty item named `name` on `side` at `order`, which stays by
77    /// `keep`.
78    pub const fn new(name: &'static str, side: Side, order: u8, keep: u8) -> Self {
79        Self {
80            name,
81            text: String::new(),
82            tone: Tone::Plain,
83            side,
84            order,
85            keep,
86            min_width: 0,
87            styled: None,
88        }
89    }
90
91    /// This item, cut down to no fewer than `min_width` columns when the
92    /// line is too narrow, before it goes.
93    pub const fn shrinks_to(mut self, min_width: u16) -> Self {
94        self.min_width = min_width;
95        self
96    }
97
98    /// This item saying `text` in `tone`.
99    pub fn says(&self, text: impl Into<String>, tone: Tone) -> Self {
100        Self {
101            text: text.into(),
102            tone,
103            styled: None,
104            ..self.clone()
105        }
106    }
107
108    /// This item saying `line`, each run in its own style, such as a gauge
109    /// coloured by how full it is; its `text` is the line's text.
110    ///
111    /// ```
112    /// use rig_tui::ratatui::style::Stylize;
113    /// use rig_tui::ratatui::text::{Line, Span};
114    /// use rig_tui::{Side, StatusItem};
115    ///
116    /// let gauge = StatusItem::new("context", Side::Right, 40, 5);
117    /// let gauge = gauge.styled(Line::from(vec![Span::from("▮▮").red(), Span::from("▯▯").dim()]));
118    /// assert_eq!(gauge.text, "▮▮▯▯");
119    /// ```
120    pub fn styled(&self, line: Line<'static>) -> Self {
121        Self {
122            text: line.to_string(),
123            tone: Tone::Plain,
124            styled: Some(line),
125            ..self.clone()
126        }
127    }
128}
129
130/// Status line items of an agent or of its running turn, one per name
131/// (every agent and turn has this component). The line shows those of the
132/// shown agent, its turn and the [`AppStatus`], ordered by side, then
133/// `order`, then name. A change redraws it.
134#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Eq)]
135#[reflect(Component, Clone, Debug, Default, PartialEq)]
136pub struct StatusItems(Vec<StatusItem>);
137
138impl StatusItems {
139    /// Shows `item` in place of the one of its name; an empty item hides
140    /// it.
141    pub fn show(&mut self, item: StatusItem) {
142        self.hide(item.name);
143        if !item.text.is_empty() {
144            self.0.push(item);
145        }
146    }
147
148    /// Hides the item named `name`. Its owner shows it again the next
149    /// time it shows it, so a plugin that hides another's item hides it
150    /// as often, in [`StatusSystems::Edit`].
151    pub fn hide(&mut self, name: &str) {
152        self.0.retain(|old| old.name != name);
153    }
154
155    /// The items, in no set order.
156    pub fn iter(&self) -> impl Iterator<Item = &StatusItem> {
157        self.0.iter()
158    }
159}
160
161/// The app's status line items, shown with every agent: a plugin shows
162/// one with `status.0.show(item)`.
163#[derive(Resource, Reflect, Clone, Debug, Default)]
164#[reflect(Resource, Clone, Debug, Default)]
165pub struct AppStatus(pub StatusItems);
166
167/// The side of the status line a [`StatusItem`] is on, read from settings
168/// as `left` or `right`.
169#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "lowercase")]
171pub enum Side {
172    /// Left-aligned, after the items of lower `order`.
173    #[default]
174    Left,
175    /// Right-aligned, after the left side.
176    Right,
177}
178
179/// How a view draws a [`StatusItem`]'s text.
180#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub enum Tone {
182    /// As the text around it.
183    #[default]
184    Plain,
185    /// Bold, such as the model.
186    Bold,
187    /// Dimmed, such as the right side's items.
188    Dim,
189    /// Green, such as `idle`.
190    Green,
191    /// Yellow, a warning or work in progress.
192    Yellow,
193    /// Red, an error or a context nearly full.
194    Red,
195    /// Cyan, the app's own state, such as a reload.
196    Cyan,
197    /// Magenta, other agents.
198    Magenta,
199}
200
201/// The systems in `PostUpdate` that bring the status line items up to
202/// date, in order; views draw after them.
203#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
204pub enum StatusSystems {
205    /// Each plugin shows its own items.
206    Show,
207    /// A plugin replaces or hides another plugin's items, by name.
208    Edit,
209}
210
211/// Gives every agent and turn its [`StatusItems`], and the app its
212/// [`AppStatus`].
213pub(crate) fn add(app: &mut App) {
214    app.register_required_components::<Agent, StatusItems>()
215        .register_required_components::<TurnOf, StatusItems>()
216        .init_resource::<AppStatus>()
217        .configure_sets(
218            PostUpdate,
219            (StatusSystems::Show, StatusSystems::Edit).chain(),
220        );
221}
222
223/// A status line: `agent`'s items (the shown agent's, with `None`),
224/// those of its running turn and the app's, by place, fit to its panel's
225/// width; with no agent, `no agent` where its model would be. It draws in
226/// one row of its [`TuiPanel`]. On an [`Editor`] it is
227/// drawn by the editor instead, in its bottom row while nothing is typed;
228/// the default screen's is so, for the shown agent.
229#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
230#[require(TuiPanel = TuiPanel::new(Placement::Bottom(Constraint::Length(1))))]
231pub struct StatusLine {
232    /// The agent whose items it shows; `None` for the one shown.
233    pub agent: Option<Entity>,
234}
235
236/// What a [`StatusLine`] shows: the agents' and turns' items, the app's,
237/// and which agent is shown.
238#[derive(SystemParam)]
239pub(crate) struct StatusSources<'w, 's> {
240    items: Query<'w, 's, &'static StatusItems>,
241    active: Query<'w, 's, &'static ActiveTurn>,
242    shown: Res<'w, ShownAgent>,
243    app: Option<Res<'w, AppStatus>>,
244}
245
246impl StatusSources<'_, '_> {
247    /// `status`'s line fit to `width`: its left and its right.
248    pub(crate) fn line(&self, status: &StatusLine, width: u16) -> (Line<'static>, Line<'static>) {
249        let agent = status.agent.or(self.shown.0);
250        let turn = agent.and_then(|agent| self.active.get(agent).ok());
251        let own = agent.and_then(|agent| self.items.get(agent).ok());
252        let turn = turn.and_then(|turn| self.items.get(turn.turn()).ok());
253        let app = self.app.as_deref().map(|app| &app.0);
254        let no_agent =
255            StatusItem::new("agent", Side::Left, 30, u8::MAX).says("no agent", Tone::Dim);
256        let no_agent = agent.is_none().then_some(&no_agent);
257        let items = [app, own, turn]
258            .into_iter()
259            .flatten()
260            .flat_map(StatusItems::iter);
261        self::line(items.chain(no_agent), width)
262    }
263}
264
265/// Draws each status line with room into its canvas, but those an editor
266/// draws.
267pub(crate) fn draw_status_lines(
268    mut lines: Query<(&StatusLine, &mut PanelCanvas), Without<Editor>>,
269    sources: StatusSources,
270) {
271    for (line, mut canvas) in &mut lines {
272        let area = canvas.area();
273        if area.is_empty() {
274            continue;
275        }
276        let (left, right) = sources.line(line, area.width);
277        let right_width = u16::try_from(right.width()).unwrap_or(u16::MAX);
278        let [left_area, right_area] =
279            Layout::horizontal([Constraint::Min(0), Constraint::Length(right_width)]).areas(area);
280        let buffer = canvas.buffer_mut();
281        left.render(left_area, buffer);
282        right.render(right_area, buffer);
283    }
284}
285
286/// A piece of the status line, from a [`StatusItem`]. When the line does
287/// not fit, [`fit`] cuts or drops the pieces with the lowest `keep` first.
288struct Piece {
289    keep: u8,
290    /// How narrow it may be cut; 0 for never.
291    min: usize,
292    line: Line<'static>,
293}
294
295impl Piece {
296    fn new(keep: u8, line: impl Into<Line<'static>>) -> Self {
297        let line = line.into();
298        Self { keep, min: 0, line }
299    }
300
301    /// The same piece, cut down to no fewer than `min` columns before it
302    /// goes.
303    fn shrinking_to(mut self, min: usize) -> Self {
304        self.min = min;
305        self
306    }
307
308    fn width(&self) -> usize {
309        self.line.width()
310    }
311
312    /// Whether it can still be cut.
313    fn can_shrink(&self) -> bool {
314        self.min > 0 && self.width() > self.min
315    }
316
317    /// Cuts it by `over` columns, to no fewer than its `min`, ending in `…`
318    /// in the style of the run it cuts.
319    fn shrink(&mut self, over: usize) {
320        let target = self.width().saturating_sub(over).max(self.min);
321        let mut spans: Vec<Span<'static>> = Vec::new();
322        let mut width = 0;
323        let mut cut = Style::new();
324        'runs: for span in &self.line.spans {
325            let mut text = String::new();
326            cut = span.style;
327            for character in span.content.chars() {
328                let next = width + character.width().unwrap_or(0);
329                if next + 1 > target {
330                    spans.push(Span::styled(text, span.style));
331                    break 'runs;
332                }
333                text.push(character);
334                width = next;
335            }
336            spans.push(Span::styled(text, span.style));
337        }
338        spans.push(Span::styled("…", cut));
339        self.line.spans = spans;
340    }
341}
342
343/// The gap between the pieces on the left, and between the two sides.
344const LEFT_GAP: &str = "  ";
345/// The gap between the pieces on the right.
346const RIGHT_GAP: &str = " ";
347
348/// The width of `pieces` joined by `gap`.
349fn joined_width(pieces: &[Piece], gap: &str) -> usize {
350    let gaps = pieces.len().saturating_sub(1) * gap.len();
351    pieces.iter().map(Piece::width).sum::<usize>() + gaps
352}
353
354/// Cuts, then drops, the pieces on the `left` and on the `right`
355/// with the lowest `keep` until both fit `width` with a gap between them:
356/// one that may shrink is cut to its `min` before it goes. What keeps
357/// `u8::MAX` stays, cut where it may; past that the line is cut at the
358/// edge.
359fn fit(left: &mut Vec<Piece>, right: &mut Vec<Piece>, width: usize) {
360    loop {
361        let separator = if right.is_empty() { 0 } else { LEFT_GAP.len() };
362        let used = joined_width(left, LEFT_GAP) + separator + joined_width(right, RIGHT_GAP);
363        let Some(over) = used.checked_sub(width).filter(|over| *over > 0) else {
364            return;
365        };
366        let lowest = |pieces: &[Piece]| {
367            pieces
368                .iter()
369                .enumerate()
370                .filter(|(_, piece)| piece.keep < u8::MAX || piece.can_shrink())
371                .min_by_key(|(_, piece)| piece.keep)
372                .map(|(index, piece)| (piece.keep, index))
373        };
374        let (side, index) = match (lowest(right.as_slice()), lowest(left.as_slice())) {
375            (Some((on_right, index)), on_left)
376                if on_left.is_none_or(|(on_left, _)| on_right <= on_left) =>
377            {
378                (&mut *right, index)
379            }
380            (_, Some((_, index))) => (&mut *left, index),
381            (_, None) => return,
382        };
383        match side.get_mut(index) {
384            Some(piece) if piece.can_shrink() => piece.shrink(over),
385            _ => {
386                side.remove(index);
387            }
388        }
389    }
390}
391
392/// `pieces` as one line, `gap` between them.
393fn join(pieces: Vec<Piece>, gap: &'static str) -> Line<'static> {
394    let mut spans = Vec::with_capacity(pieces.len() * 2);
395    for (index, piece) in pieces.into_iter().enumerate() {
396        if index > 0 {
397            spans.push(Span::from(gap));
398        }
399        spans.extend(piece.line.spans);
400    }
401    Line::from(spans)
402}
403
404/// A status line of `items` fit to `width` columns: the items on the left,
405/// and those on the right; each by place (`order`, then name),
406/// cut or dropped by `keep` when they do not fit. A plugin drawing a status
407/// line of its own, such as in a panel, draws these two.
408pub fn line<'a>(
409    items: impl IntoIterator<Item = &'a StatusItem>,
410    width: u16,
411) -> (Line<'static>, Line<'static>) {
412    let (mut left, mut right) = pieces(items);
413    fit(&mut left, &mut right, usize::from(width));
414    (join(left, LEFT_GAP), join(right, RIGHT_GAP))
415}
416
417/// The status line's pieces of `items`, left and right, by `order`, then
418/// name.
419fn pieces<'a>(items: impl IntoIterator<Item = &'a StatusItem>) -> (Vec<Piece>, Vec<Piece>) {
420    let mut items: Vec<&StatusItem> = items.into_iter().collect();
421    items.sort_by_key(|item| (item.order, item.name));
422    let piece = |item: &StatusItem| {
423        let style = match item.tone {
424            Tone::Plain => Style::new(),
425            Tone::Bold => Style::new().bold(),
426            Tone::Dim => Style::new().dim(),
427            Tone::Green => Style::new().green(),
428            Tone::Yellow => Style::new().yellow(),
429            Tone::Red => Style::new().red(),
430            Tone::Cyan => Style::new().cyan(),
431            Tone::Magenta => Style::new().magenta(),
432        };
433        let line = match &item.styled {
434            Some(line) => line.clone(),
435            None => Line::from(Span::styled(item.text.clone(), style)),
436        };
437        Piece::new(item.keep, line).shrinking_to(usize::from(item.min_width))
438    };
439    let (left, right): (Vec<_>, Vec<_>) =
440        items.into_iter().partition(|item| item.side == Side::Left);
441    (
442        left.into_iter().map(piece).collect(),
443        right.into_iter().map(piece).collect(),
444    )
445}
446
447#[cfg(test)]
448mod tests;