1use 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#[derive(Component, Clone, Debug)]
54#[require(TuiPanel = TuiPanel::new(Placement::Rest), TranscriptView, PaneState)]
55pub struct TranscriptPane {
56 pub source: TranscriptSource,
58 pub title: Option<String>,
62}
63
64impl TranscriptPane {
65 pub fn new(source: TranscriptSource) -> Self {
67 Self {
68 source,
69 title: None,
70 }
71 }
72
73 pub fn titled(mut self, title: impl Into<String>) -> Self {
75 self.title = Some(title.into());
76 self
77 }
78}
79
80#[derive(Clone, Debug)]
82pub enum TranscriptSource {
83 Shown,
86 Agent(Entity),
89 Value(Conversation),
93}
94
95#[derive(Component, Clone, Copy, Debug, Default)]
99pub struct MainPane;
100
101pub type MainTranscript<'w, 's> = Single<'w, 's, &'static mut TranscriptView, With<MainPane>>;
115
116#[derive(Component, Clone, Copy, Debug, Default)]
122#[component(on_add = bind_pane_keys)]
123pub struct PaneKeys;
124
125#[derive(Component, Default)]
127pub(crate) struct PaneState {
128 pub(crate) transcript: Transcript,
130 pub(crate) scroll: Scroll,
132 pub(crate) parts: Vec<Part>,
135 pub(crate) rows: Vec<Line<'static>>,
137 pub(crate) below: Below,
138 pub(crate) area: Rect,
140}
141
142impl PaneState {
143 pub(crate) fn page(&self) -> usize {
145 usize::from(self.area.height).saturating_sub(1).max(1)
146 }
147}
148
149fn 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
193pub(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
241pub(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
289fn inner(pane: &TranscriptPane, area: Rect) -> Rect {
292 match pane.title {
293 Some(_) => Block::new().title("").inner(area),
294 None => area,
295 }
296}
297
298pub(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 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
330fn 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;