Skip to main content

rig_tui/
pane.rs

1//! Transcript panes: a [`TranscriptPane`] draws a conversation in its
2//! panel with the transcript's own layout, looks and state, live for an
3//! agent (the reply streaming in, running calls, the inbox, notices) or
4//! as a value (a saved session). Each pane has its own
5//! [`TranscriptView`] (what is selected and lit, where it is
6//! scrolled), its own scroll and, once a plugin adds one, its own
7//! [`DrawnTranscript`]. A look spawned as a child of a pane applies in it
8//! only. The default screen's transcript is one, marked [`MainPane`].
9//!
10//! ```no_run
11//! use rig_harness::prelude::*;
12//! use rig_tui::ratatui::layout::Constraint;
13//! use rig_tui::{PaneKeys, Placement, TranscriptPane, TranscriptSource, TuiPanel};
14//!
15//! /// Shows a subagent beside the main transcript, live.
16//! fn beside(subagent: Entity, mut commands: Commands) {
17//!     commands.spawn((
18//!         TranscriptPane::new(TranscriptSource::Agent(subagent)).titled("helper"),
19//!         TuiPanel::new(Placement::Right(Constraint::Percentage(40))),
20//!         PaneKeys,
21//!     ));
22//! }
23//! ```
24
25use bevy_ecs::lifecycle::HookContext;
26use bevy_ecs::prelude::*;
27use bevy_ecs::world::DeferredWorld;
28use crossterm::event::{KeyCode, KeyModifiers};
29use ratatui::layout::Rect;
30use ratatui::style::Stylize;
31use ratatui::text::{Line, Span};
32use ratatui::widgets::{Block, Clear, Widget};
33use rig_core::conversation::Conversation;
34use rig_ecs::agent::Conversation as AgentConversation;
35use rig_harness::main_agent::ShownAgent;
36
37use super::keys::{InputAction, KeyBinding, KeyPress, KeyScope};
38use super::panel::{PanelCanvas, Placement, TuiPanel};
39use super::render::{ShownConversation, Sources, TranscriptLayout, decorate, lay_out_transcript};
40use super::theme::Theme;
41use super::transcript::{Below, Part, Scroll, Transcript};
42use super::view::{DrawnTranscript, TranscriptView};
43
44/// A transcript in a panel: `source`'s conversation, drawn as the default
45/// screen draws its own, under a title row when it has a `title`. It
46/// takes what the other panels leave unless its [`TuiPanel`] says
47/// otherwise. Its rows are laid out and its scroll placed in
48/// [`TuiSystems::Layout`](crate::TuiSystems::Layout), so its
49/// [`TranscriptView::top`] and [`DrawnTranscript`] are current before
50/// [`TuiSystems::Draw`](crate::TuiSystems::Draw). With [`PaneKeys`] and the
51/// [`KeyFocus`](crate::KeyFocus), the keys scroll and select in
52/// it.
53#[derive(Component, Clone, Debug)]
54#[require(TuiPanel = TuiPanel::new(Placement::Rest), TranscriptView, PaneState)]
55pub struct TranscriptPane {
56    /// What it draws.
57    pub source: TranscriptSource,
58    /// The title on its first row, on the
59    /// [`panel_surface`](crate::Theme::panel_surface); `None` draws no
60    /// title row.
61    pub title: Option<String>,
62}
63
64impl TranscriptPane {
65    /// A pane drawing `source`, with no title row.
66    pub fn new(source: TranscriptSource) -> Self {
67        Self {
68            source,
69            title: None,
70        }
71    }
72
73    /// The same pane, under a title row reading `title`.
74    pub fn titled(mut self, title: impl Into<String>) -> Self {
75        self.title = Some(title.into());
76        self
77    }
78}
79
80/// What a [`TranscriptPane`] draws.
81#[derive(Clone, Debug)]
82pub enum TranscriptSource {
83    /// The shown agent ([`ShownAgent`]), whichever it is; with none, the
84    /// notices about every agent.
85    Shown,
86    /// This agent, live: the reply streaming in, its running calls, its
87    /// inbox and its notices.
88    Agent(Entity),
89    /// A conversation no agent of this app holds, such as a saved session
90    /// read with `rig_memory::read_conversation`: its messages and asides,
91    /// with no live parts or notices.
92    Value(Conversation),
93}
94
95/// Marks the transcript pane the keys scroll and select in while
96/// no other pane has the [`KeyFocus`](crate::KeyFocus): the default
97/// screen's. An app that builds its own screen marks its own.
98#[derive(Component, Clone, Copy, Debug, Default)]
99pub struct MainPane;
100
101/// The main transcript pane's view: what is selected and lit,
102/// and where it is. A system with it is skipped when there is no such
103/// pane, as in an app without the default screen.
104///
105/// ```no_run
106/// use rig_harness::prelude::*;
107/// use rig_tui::{MainTranscript, TranscriptItem};
108///
109/// fn select_first(mut view: MainTranscript, conversations: Query<&Conversation>) {
110///     let first = conversations.iter().find_map(|conversation| conversation.id(0));
111///     view.selected = first.map(TranscriptItem::Message);
112/// }
113/// ```
114pub type MainTranscript<'w, 's> = Single<'w, 's, &'static mut TranscriptView, With<MainPane>>;
115
116/// Binds the transcript's keys in a pane's own scope
117/// ([`KeyScope::Panel`]), on entities it owns: Up and Down scroll a row,
118/// PageUp and PageDown a page, and Alt+Up and Alt+Down select. They work
119/// while the pane has the [`KeyFocus`](crate::KeyFocus); Esc gives the keys
120/// back.
121#[derive(Component, Clone, Copy, Debug, Default)]
122#[component(on_add = bind_pane_keys)]
123pub struct PaneKeys;
124
125/// A pane's rows and scroll, laid out in `Layout` and drawn in `Draw`.
126#[derive(Component, Default)]
127pub(crate) struct PaneState {
128    /// Its messages' rows, kept between frames.
129    pub(crate) transcript: Transcript,
130    /// Where it is scrolled.
131    pub(crate) scroll: Scroll,
132    /// The parts laid out last, whose items, top to bottom, the selection
133    /// moves through.
134    pub(crate) parts: Vec<Part>,
135    /// The rows to draw this frame, and what is below them.
136    pub(crate) rows: Vec<Line<'static>>,
137    pub(crate) below: Below,
138    /// Where its rows go, inside its border.
139    pub(crate) area: Rect,
140}
141
142impl PaneState {
143    /// Rows a page key moves: the pane's height but one.
144    pub(crate) fn page(&self) -> usize {
145        usize::from(self.area.height).saturating_sub(1).max(1)
146    }
147}
148
149/// Binds [`PaneKeys`]' keys in the pane's scope.
150fn bind_pane_keys(mut world: DeferredWorld, context: HookContext) {
151    let pane = context.entity;
152    let alt = KeyModifiers::ALT;
153    let keys = [
154        (
155            KeyPress::key(KeyCode::Up),
156            InputAction::ScrollUp,
157            "Scroll up",
158        ),
159        (
160            KeyPress::key(KeyCode::Down),
161            InputAction::ScrollDown,
162            "Scroll down",
163        ),
164        (
165            KeyPress::key(KeyCode::PageUp),
166            InputAction::PageUp,
167            "Page up",
168        ),
169        (
170            KeyPress::key(KeyCode::PageDown),
171            InputAction::PageDown,
172            "Page down",
173        ),
174        (
175            KeyPress::new(KeyCode::Up, alt),
176            InputAction::SelectUp,
177            "Select the item above",
178        ),
179        (
180            KeyPress::new(KeyCode::Down, alt),
181            InputAction::SelectDown,
182            "Select the item below",
183        ),
184    ];
185    let mut commands = world.commands();
186    for (press, action, label) in keys {
187        let binding = KeyBinding::new(press, label).in_scope(KeyScope::Panel(pane));
188        let name = Name::new(format!("key:{}", binding.keys));
189        commands.spawn((name, binding, action, ChildOf(pane)));
190    }
191}
192
193/// Lays out every pane with room: its rows at its width, its scroll
194/// placed at its height, its [`TranscriptView::top`] and width noted (a
195/// change to either changes the view), and its [`DrawnTranscript`] set.
196/// In [`TuiSystems::Layout`](crate::TuiSystems::Layout), after the panels
197/// are laid out, and again with them after
198/// [`TuiSystems::Fit`](crate::TuiSystems::Fit) when a panel changed there.
199pub(crate) fn lay_out_panes(
200    from: TranscriptLayout,
201    shown: Res<ShownAgent>,
202    changed: Query<(), Changed<AgentConversation>>,
203    mut panes: Query<(
204        Entity,
205        Ref<TranscriptPane>,
206        &mut TranscriptView,
207        &mut PaneState,
208        &PanelCanvas,
209        Option<&mut DrawnTranscript>,
210    )>,
211) {
212    let looks_changed = from.looks.changed();
213    for (entity, pane, view, mut state, canvas, drawn) in &mut panes {
214        let area = inner(&pane, canvas.area());
215        if area.is_empty() {
216            continue;
217        }
218        let state = state.bypass_change_detection();
219        if looks_changed {
220            state.transcript.clear();
221        }
222        let agent = match &pane.source {
223            TranscriptSource::Shown => shown.0,
224            TranscriptSource::Agent(agent) => Some(*agent),
225            TranscriptSource::Value(_) => None,
226        };
227        let value = match &pane.source {
228            TranscriptSource::Value(conversation) => {
229                let mut value = ShownConversation::value(conversation, entity);
230                value.changed = pane.is_changed();
231                Some(value)
232            }
233            _ => None,
234        };
235        let live = agent.and_then(|agent| from.agent(agent, changed.contains(agent)));
236        let shown = value.or(live);
237        lay_out_pane(&from, entity, (view, state, drawn), shown.as_ref(), area);
238    }
239}
240
241/// Lays out the pane `entity` at `area` with `shown`, as
242/// [`lay_out_panes`] does.
243pub(crate) fn lay_out_pane(
244    from: &TranscriptLayout,
245    entity: Entity,
246    (mut view, state, drawn): (
247        Mut<TranscriptView>,
248        &mut PaneState,
249        Option<Mut<DrawnTranscript>>,
250    ),
251    shown: Option<&ShownConversation<'_>>,
252    area: Rect,
253) {
254    let renderers = from.looks.by_kind(Some(entity));
255    let sources = Sources {
256        view: &view,
257        notices: from.notices(),
258        renderers: &renderers,
259        facts: from.facts(),
260        theme: from.theme(),
261    };
262    let parts = lay_out_transcript(&mut state.transcript, &sources, shown, area.width);
263    let height = usize::from(area.height);
264    let highlights = &view.highlights;
265    let visible =
266        (state.transcript).visible(&parts, (area.width, height), &mut state.scroll, highlights);
267    if let Some(mut drawn) = drawn {
268        let agent = shown.and_then(|shown| shown.id.map(|_| shown.entity));
269        let on_screen = visible.first..visible.first + visible.rows.len();
270        let laid_out = state
271            .transcript
272            .laid_out(agent, &parts, highlights, on_screen);
273        drawn.set_if_neq(laid_out.at(area));
274    }
275    let selected = view.selected.as_ref();
276    let selected = selected.and_then(|item| state.transcript.rows_of(&parts, item));
277    state.rows = decorate(visible.rows, visible.first, selected, from.theme());
278    state.below = visible.below;
279    state.parts = parts;
280    state.area = area;
281    if view.top() != visible.top.as_ref() {
282        view.set_top(visible.top);
283    }
284    if view.width() != Some(area.width) {
285        view.set_width(area.width);
286    }
287}
288
289/// Where `pane`'s rows go in its panel's `area`: under its title row,
290/// when it has one.
291fn inner(pane: &TranscriptPane, area: Rect) -> Rect {
292    match pane.title {
293        Some(_) => Block::new().title("").inner(area),
294        None => area,
295    }
296}
297
298/// Draws every pane laid out this frame into its canvas: its
299/// title row, its rows, and a marker of what is below a pane scrolled up. In
300/// [`TuiSystems::Draw`](crate::TuiSystems::Draw).
301pub(crate) fn draw_panes(
302    mut panes: Query<(&TranscriptPane, &mut PaneState, &mut PanelCanvas)>,
303    theme: Res<Theme>,
304) {
305    for (pane, mut state, mut canvas) in &mut panes {
306        let state = state.bypass_change_detection();
307        let area = canvas.area();
308        if area.is_empty() {
309            continue;
310        }
311        let buffer = canvas.buffer_mut();
312        if let Some((title, row)) = pane.title.as_ref().zip(area.rows().next()) {
313            Line::from(format!(" {title}"))
314                .bold()
315                .bg(theme.panel_surface)
316                .render(row, buffer);
317        }
318        // Each row's own background fills its width, blank rows too.
319        for (row, area) in std::mem::take(&mut state.rows)
320            .iter()
321            .zip(state.area.rows())
322        {
323            buffer.set_style(area, row.style);
324            row.render(area, buffer);
325        }
326        draw_below(buffer, state.below, state.area, &theme);
327    }
328}
329
330/// A marker at the bottom right of a pane scrolled up: what is below and
331/// how to get there.
332fn draw_below(buffer: &mut ratatui::buffer::Buffer, below: Below, area: Rect, theme: &Theme) {
333    let text = match below {
334        Below::Nothing => return,
335        Below::More => " ↓ more below (PgDn) ",
336        Below::New => " ↓ new output (PgDn) ",
337    };
338    let width = u16::try_from(Span::from(text).width()).unwrap_or(u16::MAX);
339    if area.height == 0 || area.width < width {
340        return;
341    }
342    let marker = Rect::new(area.right() - width, area.bottom() - 1, width, 1);
343    Clear.render(marker, buffer);
344    Line::from(text)
345        .bold()
346        .style(theme.selection())
347        .render(marker, buffer);
348}
349
350#[cfg(test)]
351mod tests;