Skip to main content

rig_tui/
renderers.rs

1//! Looks: how the transcript draws calls, notes, content, asides and text.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use bevy_app::App;
8use bevy_ecs::prelude::*;
9use bevy_ecs::system::SystemParam;
10use bevy_log::warn;
11use ratatui::style::{Style, Stylize};
12use ratatui::text::{Line, Span};
13use rig_core::completion::{AssistantContent, Message};
14use rig_core::message::{Audio, Document, Image, ToolCall, ToolResult, UserContent, Video};
15use rig_core::tool::{ExitStatus, KeptEnd, OutputCut, ToolResultContext};
16use rig_ecs::prelude::{Aside, AsideId, Author, AuthorKind, MessageId};
17use rig_harness::rig_core::conversation::Note;
18
19use super::{diff, markdown};
20use crate::Theme;
21
22/// A tool call and its result, if it has one yet, as a renderer sees them.
23pub struct ToolCallView<'a> {
24    /// The call. While it streams, its arguments are what has come of them,
25    /// and its id is not the final one.
26    pub call: &'a ToolCall,
27    /// Its result, once it is in the conversation.
28    pub result: Option<&'a ToolResult>,
29    /// Where the call stands.
30    pub state: CallState,
31    /// The transcript's colors.
32    pub theme: Theme,
33    /// What the call published, such as rig-core's `ExitStatus` and
34    /// `FilesChanged`, once it settled: in this run, or in an earlier one
35    /// of a resumed session whose effect log was kept.
36    pub facts: Option<&'a ToolResultContext>,
37    /// How long it ran, once it settled, as for [`facts`](Self::facts).
38    pub took: Option<Duration>,
39    /// The output so far of a running call whose tool reports it as it
40    /// runs ([`CallOutput`]), such as a command's: cut as tool output is
41    /// cut, its last lines after a line that counts the earlier ones.
42    /// `None` once the call answered, and before it printed anything.
43    ///
44    /// [`CallOutput`]: rig_harness::prelude::CallOutput
45    pub output: Option<&'a str>,
46    /// The tool and model calls the call started, and those they started,
47    /// in order, each after the one that started it, through any effect
48    /// between that is no call: such as a subagent's calls under the call
49    /// that spawned it, through the spawn and the brief it was sent. Also
50    /// after a resume, from the session's effect log
51    /// ([`PastEffects`](crate::PastEffects)).
52    pub children: &'a [ChildCall],
53}
54
55/// A tool or model call that a tool call started, or one of those
56/// started, as a renderer sees it ([`ToolCallView::children`]).
57#[derive(Clone, Debug, PartialEq)]
58pub struct ChildCall {
59    /// How far below the call it is: 0 for one the call started itself.
60    pub depth: usize,
61    /// The tool call, or `None` for a model call.
62    pub call: Option<ToolCall>,
63    /// The tool's name, or the model's catalog reference (`vendor/model`),
64    /// or `model` before it is known.
65    pub name: String,
66    /// The name of the agent it is for, such as a subagent's.
67    pub agent: Option<String>,
68    /// Where it stands; a model call is [`CallState::Streaming`] while its
69    /// reply streams in.
70    pub state: CallState,
71    /// Whether it failed: a tool's error result, or a model call's error.
72    pub failed: bool,
73    /// Its answer, once it has one: a tool's result, a model's reply, or
74    /// why it failed, whole.
75    pub answer: Option<String>,
76}
77
78/// Where a tool call stands. A call of an earlier turn, or one whose
79/// effect is gone, is `Done` when it has a result and `Running` when not.
80#[derive(Clone, Debug, PartialEq, Eq, Hash)]
81pub enum CallState {
82    /// The model is still streaming its arguments.
83    Streaming,
84    /// Not started: it waits for earlier calls of its reply it must not
85    /// run beside, or to be let through.
86    Waiting,
87    /// Held from starting, or from settling once answered: the holds'
88    /// [`Name`]s, which say why, such as `waiting for lease on src/a.rs`.
89    Held(Vec<String>),
90    /// Running, with its [`ToolProgress`] line once its owner keeps one.
91    ///
92    /// [`ToolProgress`]: rig_harness::prelude::ToolProgress
93    Running(Option<String>),
94    /// Answered.
95    Done,
96}
97
98/// What a hold without a [`Name`] is called.
99pub(crate) const UNNAMED_HOLD: &str = "unnamed hold";
100
101impl<'a> ToolCallView<'a> {
102    /// `call`, answered by `result` if it was, standing as `state`, with
103    /// no facts or output known, in the default colors.
104    pub fn new(call: &'a ToolCall, result: Option<&'a ToolResult>, state: CallState) -> Self {
105        Self {
106            call,
107            result,
108            state,
109            theme: Theme::default(),
110            facts: None,
111            took: None,
112            output: None,
113            children: &[],
114        }
115    }
116
117    /// The tool's name.
118    pub fn name(&self) -> &str {
119        &self.call.function.name
120    }
121
122    /// The string argument `key`, if the call has one.
123    pub fn argument(&self, key: &str) -> Option<&str> {
124        self.call.function.arguments.get(key)?.as_str()
125    }
126
127    /// The result's text, joined, if the tool answered.
128    pub fn result_text(&self) -> Option<String> {
129        self.result.map(result_text)
130    }
131
132    /// Whether the call failed: the tool answered with an error, or the
133    /// process it ran did not exit with 0 ([`ExitStatus`]).
134    pub fn failed(&self) -> bool {
135        self.result.is_some_and(|result| result.is_error)
136            || self.exit().is_some_and(|exit| !exit.success())
137    }
138
139    /// How the process the call ran ended, if the tool said.
140    fn exit(&self) -> Option<ExitStatus> {
141        self.facts?.get::<ExitStatus>().ok().flatten()
142    }
143
144    /// The header line: `title` in bold and `detail` dimmed on a band
145    /// tinted by where the call stands ([`Theme`]'s `call_*` colors), with
146    /// the state in words too when it is running, held or failed (how its
147    /// process ended, when it ran one).
148    pub fn header(&self, title: impl Into<String>, detail: impl Into<String>) -> Line<'static> {
149        let detail = detail.into();
150        let detail = (!detail.is_empty()).then(|| Span::from(detail).dim());
151        self.header_spans(title, detail.into_iter().collect())
152    }
153
154    /// [`header`](Self::header) with `detail` in spans styled by the look,
155    /// such as a highlighted command; the state's words still end it.
156    pub fn header_spans(
157        &self,
158        title: impl Into<String>,
159        detail: Vec<Span<'static>>,
160    ) -> Line<'static> {
161        let failure = || match self.exit() {
162            Some(ExitStatus::Exited(code)) if code != 0 => format!("exit {code}"),
163            Some(ExitStatus::Signaled) => "killed by a signal".to_owned(),
164            Some(ExitStatus::TimedOut) => "timed out".to_owned(),
165            Some(ExitStatus::Killed) => "killed".to_owned(),
166            _ => "failed".to_owned(),
167        };
168        let (band, state) = match (self.result, &self.state) {
169            (Some(_), _) if self.failed() => (self.theme.call_failed, failure()),
170            (_, CallState::Held(holds)) => (
171                self.theme.call_waiting,
172                format!("held: {}", holds.join(", ")),
173            ),
174            (Some(_), _) => (self.theme.call_done, String::new()),
175            (None, CallState::Running(_)) => (self.theme.call_running, "running".to_owned()),
176            (None, _) => (self.theme.call_waiting, String::new()),
177        };
178        let mut spans = vec![Span::from(" "), Span::from(title.into()).bold()];
179        if !detail.is_empty() {
180            spans.push(Span::from(" "));
181            spans.extend(detail);
182        }
183        if !state.is_empty() {
184            spans.push(Span::from(format!(" · {state}")).italic());
185        }
186        Line::from(spans).bg(band)
187    }
188
189    /// The whole result, indented under the header, red for an error. A unified diff is
190    /// coloured. The line that says the tool cut its output
191    /// ([`OutputCut`]), naming the file with all of it, is on its side.
192    /// Before the result, a running call's progress.
193    pub fn result_lines(&self) -> Vec<Line<'static>> {
194        let Some(text) = self.result_text() else {
195            return self.progress_lines();
196        };
197        if !self.failed()
198            && let Some(lines) = diff::unified_lines(&text, &self.theme)
199        {
200            return lines;
201        }
202        let (output, marker) = self.cut_marker(&text);
203        result_rows(output.into_iter(), marker, result_style(self.failed()))
204    }
205
206    /// The lines of `text`, the result, without the line that says the
207    /// tool cut its output, and that line with the end it is on, when the
208    /// call published [`OutputCut`] (a call of an earlier run has no facts,
209    /// so its marker is a line like any other).
210    fn cut_marker<'t>(&self, text: &'t str) -> (Vec<&'t str>, Option<(&'t str, KeptEnd)>) {
211        let mut lines: Vec<&str> = text.lines().collect();
212        let cut = self
213            .facts
214            .and_then(|facts| facts.get::<OutputCut>().ok().flatten());
215        let marker = cut.and_then(|cut| {
216            let line = match cut.kept {
217                // The output's ends are kept: the marker is its first line.
218                KeptEnd::Ends if lines.is_empty() => None,
219                KeptEnd::Ends => Some(lines.remove(0)),
220                KeptEnd::Start => lines.pop(),
221            };
222            Some((line?, cut.kept))
223        });
224        (lines, marker)
225    }
226
227    /// A running call's progress, indented under the header, where its
228    /// result goes once
229    /// it answers; none without one.
230    pub fn progress_lines(&self) -> Vec<Line<'static>> {
231        let CallState::Running(Some(progress)) = &self.state else {
232            return Vec::new();
233        };
234        result_rows(progress.lines(), None, Style::new().yellow())
235    }
236
237    /// The look of a tool with no renderer: the name, the arguments and
238    /// the result.
239    pub fn default_lines(&self) -> Vec<Line<'static>> {
240        let arguments = serde_json::Value::Object(self.call.function.arguments.clone()).to_string();
241        let mut lines = vec![self.header(self.name().to_owned(), arguments)];
242        lines.extend(self.result_lines());
243        lines
244    }
245}
246
247/// A note or a delivered text, as an entry renderer sees it: a plugin's
248/// [`Note`](rig_harness::rig_core::conversation::Note), or a text of an entry of the
249/// conversation another author wrote, such as a compaction summary.
250pub struct EntryView<'a> {
251    /// The plugin whose look draws it: the name of the plugin that wrote
252    /// it, or empty when no plugin did (an agent, rig).
253    pub plugin: &'a str,
254    /// What a collapsed one shows in a line: the note's title, or the
255    /// author's label.
256    pub title: &'a str,
257    /// What it holds; a delivered text without the header the model reads.
258    pub text: &'a str,
259}
260
261impl EntryView<'_> {
262    /// The look of a plugin with no renderer: `⤶` and the title, then
263    /// the text.
264    pub fn default_lines(&self) -> Vec<Line<'static>> {
265        let mut lines = vec![Line::styled(
266            format!("⤶ {}", self.title),
267            Style::new().magenta().bold(),
268        )];
269        lines.extend(text_rows(self.text, Style::new().dim()));
270        lines
271    }
272}
273
274/// A kind of content block a [`ContentRenderer`] draws.
275#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
276pub enum ContentKind {
277    /// An image, the user's or one the model made.
278    Image,
279    /// Audio.
280    Audio,
281    /// A video.
282    Video,
283    /// A document, such as a PDF.
284    Document,
285}
286
287impl ContentKind {
288    /// Its name: `image`, `audio`, `video` or `document`.
289    pub fn name(self) -> &'static str {
290        match self {
291            Self::Image => "image",
292            Self::Audio => "audio",
293            Self::Video => "video",
294            Self::Document => "document",
295        }
296    }
297}
298
299/// A content block of a message that is not text, a tool call or a
300/// result.
301#[derive(Clone, Copy, Debug)]
302pub enum ContentBlock<'a> {
303    /// An image, the user's or one the model made.
304    Image(&'a Image),
305    /// Audio.
306    Audio(&'a Audio),
307    /// A video.
308    Video(&'a Video),
309    /// A document.
310    Document(&'a Document),
311}
312
313impl<'a> ContentBlock<'a> {
314    /// A user's block, if it is one.
315    pub(crate) fn of_user(content: &'a UserContent) -> Option<Self> {
316        match content {
317            UserContent::Image(image) => Some(Self::Image(image)),
318            UserContent::Audio(audio) => Some(Self::Audio(audio)),
319            UserContent::Video(video) => Some(Self::Video(video)),
320            UserContent::Document(document) => Some(Self::Document(document)),
321            UserContent::Text(_) | UserContent::ToolResult(_) => None,
322        }
323    }
324
325    /// A reply's block, if it is one.
326    pub(crate) fn of_assistant(content: &'a AssistantContent) -> Option<Self> {
327        match content {
328            AssistantContent::Image(image) => Some(Self::Image(image)),
329            _ => None,
330        }
331    }
332
333    /// Its kind.
334    pub fn kind(self) -> ContentKind {
335        match self {
336            Self::Image(_) => ContentKind::Image,
337            Self::Audio(_) => ContentKind::Audio,
338            Self::Video(_) => ContentKind::Video,
339            Self::Document(_) => ContentKind::Document,
340        }
341    }
342}
343
344/// A content block of a message, as a content renderer sees it: an
345/// image, audio, a video or a document, the user's or the model's. Its
346/// rows are the transcript item [`TranscriptItem::Content`], which a
347/// plugin finds in the [`DrawnTranscript`], such as to draw an image over
348/// the rows its renderer left blank.
349///
350/// [`TranscriptItem::Content`]: crate::TranscriptItem::Content
351/// [`DrawnTranscript`]: crate::DrawnTranscript
352pub struct ContentView<'a> {
353    /// The message it is in, if the message has an id.
354    pub message: Option<MessageId>,
355    /// Its place among the message's blocks.
356    pub index: usize,
357    /// The block.
358    pub block: ContentBlock<'a>,
359    /// Whether the message was written by the model.
360    pub assistant: bool,
361}
362
363impl ContentView<'_> {
364    /// The look of a kind with no renderer: its name in brackets, such as
365    /// `[image]`.
366    pub fn default_lines(&self) -> Vec<Line<'static>> {
367        let name = self.block.kind().name();
368        vec![Line::from(format!("  [{name}]")).dim()]
369    }
370}
371
372/// The name of the plugin `author` is, or empty for any other author.
373pub(crate) fn plugin_name(author: &Author) -> &str {
374    match &author.kind {
375        AuthorKind::Plugin(name) => name,
376        _ => "",
377    }
378}
379
380/// A tool result's text, joined.
381pub(crate) fn result_text(result: &ToolResult) -> String {
382    result
383        .content
384        .iter()
385        .filter_map(|content| content.as_text())
386        .collect::<Vec<_>>()
387        .join("\n")
388}
389
390/// The style of a tool result's lines: red for an error, else dimmed.
391pub(crate) fn result_style(failed: bool) -> Style {
392    if failed {
393        Style::new().red()
394    } else {
395        Style::new().dim()
396    }
397}
398
399/// The indent of a tool's output and of the text under an entry's title,
400/// for a look that draws its own rows.
401pub const OUTPUT_INDENT: &str = "   ";
402
403/// The lines of `text` in `style`, each indented by [`OUTPUT_INDENT`].
404pub fn text_rows(text: &str, style: Style) -> Vec<Line<'static>> {
405    result_rows(text.lines(), None, style)
406}
407
408/// `lines` of a result in `style`, indented, with `marker`
409/// before them when the output's ends were kept and after them when its
410/// start was.
411fn result_rows<'t>(
412    lines: impl Iterator<Item = &'t str>,
413    marker: Option<(&'t str, KeptEnd)>,
414    style: Style,
415) -> Vec<Line<'static>> {
416    let (before, after) = match marker {
417        Some((marker, KeptEnd::Ends)) => (Some(marker), None),
418        Some((marker, KeptEnd::Start)) => (None, Some(marker)),
419        None => (None, None),
420    };
421    let rows = before.into_iter().chain(lines).chain(after);
422    rows.map(|line| {
423        let line = line.replace('\t', "    ");
424        Line::styled(format!("{OUTPUT_INDENT}{line}"), style)
425    })
426    .collect()
427}
428
429/// Draws a tool call as transcript lines, not yet wrapped.
430pub type RenderToolCall = dyn Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync;
431
432/// The look of the tool named `tool`, on an entity of its own.
433#[derive(Component, Clone)]
434pub struct ToolRenderer {
435    /// The tool's name.
436    pub tool: String,
437    /// Draws one of its calls.
438    pub render: Arc<RenderToolCall>,
439}
440
441impl ToolRenderer {
442    /// The look `render` of the tool `tool`.
443    pub fn new(
444        tool: &str,
445        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
446    ) -> Self {
447        let (tool, render) = (tool.to_owned(), Arc::new(render));
448        Self { tool, render }
449    }
450}
451
452/// Draws a note or a delivered text as transcript lines, not yet wrapped.
453pub type RenderEntry = dyn Fn(&EntryView<'_>) -> Vec<Line<'static>> + Send + Sync;
454
455/// The look of the notes and delivered texts of the plugin `plugin`, on an
456/// entity of its own.
457#[derive(Component, Clone)]
458pub struct EntryRenderer {
459    /// The plugin's name, as its [`Author::plugin`](rig_core::conversation::Author::plugin)
460    /// says, such as `btw` or `compaction`.
461    pub plugin: String,
462    /// Draws one of its notes or texts.
463    pub render: Arc<RenderEntry>,
464}
465
466impl EntryRenderer {
467    /// The look `render` of the plugin `plugin`'s notes and texts.
468    pub fn new(
469        plugin: &str,
470        render: impl Fn(&EntryView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
471    ) -> Self {
472        let (plugin, render) = (plugin.to_owned(), Arc::new(render));
473        Self { plugin, render }
474    }
475}
476
477/// Draws a content block as transcript lines, not yet wrapped.
478pub type RenderContent = dyn Fn(&ContentView<'_>) -> Vec<Line<'static>> + Send + Sync;
479
480/// The look of the content blocks of a kind, on an entity of its own.
481#[derive(Component, Clone)]
482pub struct ContentRenderer {
483    /// The kind it draws.
484    pub kind: ContentKind,
485    /// Draws one block.
486    pub render: Arc<RenderContent>,
487}
488
489impl ContentRenderer {
490    /// The look `render` of the blocks of `kind`.
491    pub fn new(
492        kind: ContentKind,
493        render: impl Fn(&ContentView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
494    ) -> Self {
495        let render = Arc::new(render);
496        Self { kind, render }
497    }
498}
499
500/// Draws an aside as transcript lines, not yet wrapped.
501pub type RenderAside = dyn Fn(&AsideView<'_>) -> Vec<Line<'static>> + Send + Sync;
502
503/// The look of the asides of a kind, on an entity of its own.
504#[derive(Component, Clone)]
505pub struct AsideRenderer {
506    /// The kind it draws, such as `note`.
507    pub kind: String,
508    /// Draws one aside.
509    pub render: Arc<RenderAside>,
510}
511
512impl AsideRenderer {
513    /// The look `render` of the asides of `kind`.
514    pub fn new(
515        kind: &str,
516        render: impl Fn(&AsideView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
517    ) -> Self {
518        let (kind, render) = (kind.to_owned(), Arc::new(render));
519        Self { kind, render }
520    }
521}
522
523/// The decorators that drew lines under a call or a message, each by name
524/// with the lines it drew among its own.
525pub(crate) type Decorated = Vec<(String, std::ops::Range<usize>)>;
526
527/// Lines drawn under every tool call, after its look, on an entity of
528/// its own.
529#[derive(Component, Clone)]
530pub struct CallDecorator {
531    /// What it is called, such as the plugin's name.
532    pub name: String,
533    /// Draws its lines under a call; none leaves the call as it is.
534    pub render: Arc<RenderToolCall>,
535}
536
537impl CallDecorator {
538    /// The decorator `name` drawing `render`'s lines.
539    pub fn new(
540        name: &str,
541        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
542    ) -> Self {
543        let (name, render) = (name.to_owned(), Arc::new(render));
544        Self { name, render }
545    }
546}
547
548/// Draws lines under a message.
549pub type DecorateMessage = dyn Fn(&DecoratedMessage<'_>) -> Vec<Line<'static>> + Send + Sync;
550
551/// Lines drawn under every message the user or the model wrote, after its
552/// own, on an entity of its own.
553#[derive(Component, Clone)]
554pub struct MessageDecorator {
555    /// What it is called, such as the plugin's name.
556    pub name: String,
557    /// Draws its lines under a message; none leaves it as it is.
558    pub render: Arc<DecorateMessage>,
559}
560
561impl MessageDecorator {
562    /// The decorator `name` drawing `render`'s lines.
563    pub fn new(
564        name: &str,
565        render: impl Fn(&DecoratedMessage<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
566    ) -> Self {
567        let (name, render) = (name.to_owned(), Arc::new(render));
568        Self { name, render }
569    }
570}
571
572/// An aside of a conversation, as an aside renderer sees it.
573pub struct AsideView<'a> {
574    /// Its id.
575    pub id: AsideId,
576    /// The aside.
577    pub aside: &'a Aside,
578    /// The colors it is drawn in.
579    pub theme: Theme,
580}
581
582/// Whose text a [`MessageView`] is.
583#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
584pub enum Role {
585    /// What the user typed.
586    User,
587    /// A text block of a reply, or the reply streaming in.
588    Assistant,
589    /// A reply's reasoning.
590    Reasoning,
591}
592
593impl Role {
594    /// Its name: `user`, `assistant` or `reasoning`.
595    pub fn name(self) -> &'static str {
596        match self {
597            Self::User => "user",
598            Self::Assistant => "assistant",
599            Self::Reasoning => "reasoning",
600        }
601    }
602}
603
604/// A text of a message, as a message renderer sees it.
605pub struct MessageView<'a> {
606    /// Whose text it is.
607    pub role: Role,
608    /// The message it is in; `None` for the reply streaming in.
609    pub message: Option<MessageId>,
610    /// The text's place among the message's content.
611    pub block: usize,
612    /// The text.
613    pub text: &'a str,
614    /// The labels drawn inside it ([`TextMark`](crate::TextMark)), each
615    /// before its byte.
616    pub marks: &'a [(usize, Span<'static>)],
617    /// The width its lines are wrapped to, which a reply's tables are fit
618    /// to.
619    pub width: usize,
620    /// The colors a reply's code, alerts and tables are drawn in.
621    pub theme: Theme,
622}
623
624impl MessageView<'_> {
625    /// The default look: the user's text after `›`, a reply's as markdown
626    /// with its marks, reasoning dimmed.
627    pub fn default_lines(&self) -> Vec<Line<'static>> {
628        match self.role {
629            Role::User => {
630                let lines = self.text.lines().enumerate();
631                let rows = lines.map(|(index, line)| {
632                    let prefix = if index == 0 { "› " } else { "  " };
633                    let text = line.replace('\t', "    ");
634                    Line::from(vec![Span::from(prefix).dim(), Span::from(text).bold()])
635                });
636                rows.collect()
637            }
638            Role::Assistant => {
639                markdown::render_marked(self.text, self.marks, &self.theme, Some(self.width))
640            }
641            Role::Reasoning => {
642                let lines = self.text.lines();
643                let style = Style::new().dim().italic();
644                let rows = lines.map(|line| Line::styled(line.replace('\t', "    "), style));
645                rows.collect()
646            }
647        }
648    }
649}
650
651/// A message the user or the model wrote, as a message decorator sees it.
652pub struct DecoratedMessage<'a> {
653    /// The message.
654    pub message: &'a Message,
655    /// Its id, when it has one.
656    pub id: Option<MessageId>,
657    /// Its author, when it has one.
658    pub author: Option<&'a Author>,
659}
660
661/// Registers transcript looks: how tool calls, notes, content blocks,
662/// asides and message text are drawn, and lines under every call or
663/// message.
664///
665/// Each look is a component on an entity of its own, named by what it
666/// draws: `renderer:<tool>`, `entry:<plugin>`, `content:<kind>`,
667/// `aside:<kind>`, `message:<role>`, `decorator:<name>`,
668/// `message-decorator:<name>`. The first look of a name stays (a later one
669/// is refused with a warning); `Disabled` on it, in `Plugin::finish`, frees
670/// it. A look spawned as a child of a [`TranscriptPane`](crate::TranscriptPane)
671/// applies in that pane only, before the global one. A change to a look
672/// draws the transcript again. Without a look, a call draws as
673/// [`ToolCallView::default_lines`], also while its arguments stream
674/// ([`CallState::Streaming`]). A renderer sees the calls its call started,
675/// through any effect in between, as [`ToolCallView::children`]:
676///
677/// ```no_run
678/// use rig_harness::prelude::*;
679/// use rig_tui::AppToolRenderersExt;
680/// use rig_tui::ratatui::text::Line;
681///
682/// fn build(app: &mut App) {
683///     app.add_tool_renderer("consult", |view| {
684///         let mut lines = view.default_lines();
685///         for child in view.children {
686///             let indent = "  ".repeat(child.depth + 2);
687///             lines.push(Line::from(format!("{indent}↳ {} {:?}", child.name, child.state)));
688///         }
689///         lines
690///     });
691/// }
692/// ```
693pub trait AppToolRenderersExt {
694    /// Draw the calls of `tool` with `render`.
695    fn add_tool_renderer(
696        &mut self,
697        tool: &str,
698        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
699    ) -> &mut Self;
700
701    /// Draw the notes and the entries the plugin `plugin` wrote, such as a
702    /// compaction's summary, with `render`; without one they draw as
703    /// [`EntryView::default_lines`].
704    fn add_entry_renderer(
705        &mut self,
706        plugin: &str,
707        render: impl Fn(&EntryView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
708    ) -> &mut Self;
709
710    /// Draw the content blocks of `kind` (an image, audio, a video or a
711    /// document) with `render`; without one they draw as
712    /// [`ContentView::default_lines`].
713    fn add_content_renderer(
714        &mut self,
715        kind: ContentKind,
716        render: impl Fn(&ContentView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
717    ) -> &mut Self;
718
719    /// Draw the asides of `kind` with `render` after the entry they
720    /// follow, as the transcript item
721    /// [`TranscriptItem::Aside`](crate::TranscriptItem::Aside). An aside of a
722    /// kind with no renderer is not drawn; a note (`note`) is drawn by its
723    /// plugin's entry renderer.
724    fn add_aside_renderer(
725        &mut self,
726        kind: &str,
727        render: impl Fn(&AsideView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
728    ) -> &mut Self;
729
730    /// Draw `render`'s lines under every tool call, after the call's own
731    /// look, whichever draws it. Decorators add up, in the order of their
732    /// entities. Its rows under a call are the transcript item
733    /// [`TranscriptItem::Decoration`](crate::TranscriptItem::Decoration), by
734    /// the call's id and `name`.
735    fn add_call_decorator(
736        &mut self,
737        name: &str,
738        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
739    ) -> &mut Self;
740}
741
742/// Spawns the global look `look` named `name`, unless a global one of the
743/// same key (`same`) is there: a disabled one, which a query skips, frees
744/// its key.
745fn add_look<T: Component>(app: &mut App, name: String, look: T, same: impl Fn(&T) -> bool) {
746    let world = app.world_mut();
747    let mut looks = world.query_filtered::<&T, Without<ChildOf>>();
748    if looks.iter(world).any(same) {
749        warn!("look not added: `{name}` has one; insert `Disabled` on it to replace it");
750        return;
751    }
752    world.spawn((Name::new(name), look));
753}
754
755impl AppToolRenderersExt for App {
756    fn add_tool_renderer(
757        &mut self,
758        tool: &str,
759        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
760    ) -> &mut Self {
761        let look = ToolRenderer::new(tool, render);
762        add_look(self, format!("renderer:{tool}"), look, |other| {
763            other.tool == tool
764        });
765        self
766    }
767
768    fn add_entry_renderer(
769        &mut self,
770        plugin: &str,
771        render: impl Fn(&EntryView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
772    ) -> &mut Self {
773        let look = EntryRenderer::new(plugin, render);
774        add_look(self, format!("entry:{plugin}"), look, |other| {
775            other.plugin == plugin
776        });
777        self
778    }
779
780    fn add_content_renderer(
781        &mut self,
782        kind: ContentKind,
783        render: impl Fn(&ContentView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
784    ) -> &mut Self {
785        let look = ContentRenderer::new(kind, render);
786        let name = format!("content:{}", kind.name());
787        add_look(self, name, look, |other| other.kind == kind);
788        self
789    }
790
791    fn add_aside_renderer(
792        &mut self,
793        kind: &str,
794        render: impl Fn(&AsideView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
795    ) -> &mut Self {
796        let look = AsideRenderer::new(kind, render);
797        add_look(self, format!("aside:{kind}"), look, |other| {
798            other.kind == kind
799        });
800        self
801    }
802
803    fn add_call_decorator(
804        &mut self,
805        name: &str,
806        render: impl Fn(&ToolCallView<'_>) -> Vec<Line<'static>> + Send + Sync + 'static,
807    ) -> &mut Self {
808        let look = CallDecorator::new(name, render);
809        add_look(self, format!("decorator:{name}"), look, |other| {
810            other.name == name
811        });
812        self
813    }
814}
815
816/// A look as [`Looks`] reads it: changed since the last frame or not, and
817/// the pane it applies in, if only one.
818type Scoped<T> = (Entity, Ref<'static, T>, Option<&'static ChildOf>);
819
820/// The looks plugins added, as a frame draws with them.
821#[derive(SystemParam)]
822pub(crate) struct Looks<'w, 's> {
823    tools: Query<'w, 's, Scoped<ToolRenderer>>,
824    entries: Query<'w, 's, Scoped<EntryRenderer>>,
825    contents: Query<'w, 's, Scoped<ContentRenderer>>,
826    asides: Query<'w, 's, Scoped<AsideRenderer>>,
827    decorators: Query<'w, 's, Scoped<CallDecorator>>,
828    message_decorators: Query<'w, 's, Scoped<MessageDecorator>>,
829}
830
831/// The looks of `looks` that apply in `pane` (`None`: in no pane of its
832/// own, such as a layout for an export), by key: those of the pane first,
833/// then the global ones, each in entity order.
834fn scoped<'a, T: Component, K, V>(
835    looks: impl Iterator<Item = (Entity, Ref<'a, T>, Option<&'a ChildOf>)>,
836    pane: Option<Entity>,
837    key: impl Fn(&'a T) -> (K, V),
838) -> Vec<(K, V)> {
839    let mut found: Vec<(bool, Entity, &'a T)> = looks
840        .filter_map(|(entity, look, parent)| match parent.map(ChildOf::parent) {
841            None => Some((true, entity, look.into_inner())),
842            Some(of) if Some(of) == pane => Some((false, entity, look.into_inner())),
843            Some(_) => None,
844        })
845        .collect();
846    found.sort_by_key(|(global, entity, _)| (*global, *entity));
847    found.into_iter().map(|(.., look)| key(look)).collect()
848}
849
850/// `looks` by key, the first of each key kept.
851fn first_of<K: Eq + std::hash::Hash, V>(looks: Vec<(K, V)>) -> HashMap<K, V> {
852    let mut map = HashMap::new();
853    for (key, look) in looks {
854        map.entry(key).or_insert(look);
855    }
856    map
857}
858
859impl Looks<'_, '_> {
860    /// Each look that applies in `pane` by what it draws: a pane's own
861    /// before the global one of the same key, and its decorators before the
862    /// global ones.
863    pub(crate) fn by_kind(&self, pane: Option<Entity>) -> Renderers<'_> {
864        let tools = scoped(self.tools.iter(), pane, |look| {
865            (look.tool.as_str(), &look.render)
866        });
867        let entries = scoped(self.entries.iter(), pane, |look| {
868            (look.plugin.as_str(), &look.render)
869        });
870        let contents = scoped(self.contents.iter(), pane, |look| (look.kind, &look.render));
871        let asides = scoped(self.asides.iter(), pane, |look| {
872            (look.kind.as_str(), &look.render)
873        });
874        let decorators = scoped(self.decorators.iter(), pane, |look| {
875            (look.name.as_str(), &look.render)
876        });
877        let message_decorators = scoped(self.message_decorators.iter(), pane, |look| {
878            (look.name.as_str(), &look.render)
879        });
880        Renderers {
881            tools: first_of(tools),
882            entries: first_of(entries),
883            contents: first_of(contents),
884            asides: first_of(asides),
885            decorators,
886            message_decorators,
887        }
888    }
889
890    /// Whether a look changed or was added since the last frame.
891    pub(crate) fn changed(&self) -> bool {
892        self.tools.iter().any(|(_, look, _)| look.is_changed())
893            || self.entries.iter().any(|(_, look, _)| look.is_changed())
894            || self.contents.iter().any(|(_, look, _)| look.is_changed())
895            || self.asides.iter().any(|(_, look, _)| look.is_changed())
896            || self.decorators.iter().any(|(_, look, _)| look.is_changed())
897            || (self.message_decorators.iter()).any(|(_, look, _)| look.is_changed())
898    }
899}
900
901/// The looks plugins added, by what each draws.
902#[derive(Default)]
903pub(crate) struct Renderers<'a> {
904    /// The tool renderers by tool name.
905    pub(crate) tools: HashMap<&'a str, &'a Arc<RenderToolCall>>,
906    /// The entry renderers by plugin name.
907    pub(crate) entries: HashMap<&'a str, &'a Arc<RenderEntry>>,
908    /// The content renderers by kind.
909    pub(crate) contents: HashMap<ContentKind, &'a Arc<RenderContent>>,
910    /// The aside renderers by kind.
911    pub(crate) asides: HashMap<&'a str, &'a Arc<RenderAside>>,
912    /// The call decorators, by name, in order.
913    pub(crate) decorators: Vec<(&'a str, &'a Arc<RenderToolCall>)>,
914    /// The message decorators, by name, in order.
915    pub(crate) message_decorators: Vec<(&'a str, &'a Arc<DecorateMessage>)>,
916}
917
918impl Renderers<'_> {
919    /// A tool call's lines, by its tool's renderer or the default look,
920    /// then every decorator's.
921    pub(crate) fn call(&self, view: &ToolCallView<'_>) -> Vec<Line<'static>> {
922        self.call_decorated(view).0
923    }
924
925    /// A tool call's lines, as [`call`](Self::call) draws them, and each
926    /// decorator that drew any with the lines it drew.
927    pub(crate) fn call_decorated(
928        &self,
929        view: &ToolCallView<'_>,
930    ) -> (Vec<Line<'static>>, Decorated) {
931        let mut lines = match self.tools.get(view.name()) {
932            Some(render) => render(view),
933            None => view.default_lines(),
934        };
935        let mut decorated = Vec::new();
936        for (name, decorate) in &self.decorators {
937            let start = lines.len();
938            lines.extend(decorate(view));
939            if lines.len() > start {
940                decorated.push(((*name).to_owned(), start..lines.len()));
941            }
942        }
943        (lines, decorated)
944    }
945
946    /// A note's or a delivered text's lines, by its plugin's renderer or
947    /// the default look.
948    pub(crate) fn entry(&self, view: &EntryView<'_>) -> Vec<Line<'static>> {
949        match self.entries.get(view.plugin) {
950            Some(render) => render(view),
951            None => view.default_lines(),
952        }
953    }
954
955    /// A content block's lines, by its kind's renderer or the default
956    /// look.
957    pub(crate) fn content(&self, view: &ContentView<'_>) -> Vec<Line<'static>> {
958        match self.contents.get(&view.block.kind()) {
959            Some(render) => render(view),
960            None => view.default_lines(),
961        }
962    }
963
964    /// An aside's lines: a note's by its plugin's entry renderer, any
965    /// other's by its kind's renderer; `None` for a kind nothing draws.
966    pub(crate) fn aside(&self, view: &AsideView<'_>) -> Option<Vec<Line<'static>>> {
967        if let Some(note) = Note::of(view.aside) {
968            let entry = EntryView {
969                plugin: plugin_name(&view.aside.author),
970                title: &note.title,
971                text: &note.text,
972            };
973            return Some(self.entry(&entry));
974        }
975        let render = self.asides.get(view.aside.kind.as_str())?;
976        Some(render(view))
977    }
978
979    /// Whether an aside of `aside`'s kind is drawn.
980    pub(crate) fn draws(&self, aside: &Aside) -> bool {
981        aside.kind == Note::KIND || self.asides.contains_key(aside.kind.as_str())
982    }
983
984    /// Every message decorator's lines under `message`, and each that drew
985    /// any with the lines it drew.
986    pub(crate) fn message_decorations(
987        &self,
988        message: &DecoratedMessage<'_>,
989    ) -> (Vec<Line<'static>>, Decorated) {
990        let (mut lines, mut decorated) = (Vec::new(), Vec::new());
991        for (name, decorate) in &self.message_decorators {
992            let start = lines.len();
993            lines.extend(decorate(message));
994            if lines.len() > start {
995                decorated.push(((*name).to_owned(), start..lines.len()));
996            }
997        }
998        (lines, decorated)
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests;