Skip to main content

rig_tui/
view.rs

1//! View state, kept apart from the agent core: the shown agent (the
2//! harness's [`ShownAgent`]), the input's completion, the pickers and questions
3//! waiting to be shown, each transcript pane as plugins read and steer it
4//! ([`TranscriptView`]): what is selected, the text lit and
5//! where it is scrolled, and the recent notices ([`Notices`]), which a
6//! reload keeps in the session's [`PluginData`] for the build it starts.
7
8use std::ops::Range;
9
10use bevy_ecs::message::MessageCursor;
11use bevy_ecs::prelude::*;
12use bevy_log::warn;
13use bevy_reflect::prelude::*;
14use ratatui::style::Style;
15use rig_core::completion::Message;
16use rig_core::conversation;
17use rig_core::message::CallId;
18use serde::{Deserialize, Serialize};
19
20use super::complete::Completion;
21use super::editor::{Editor, MainEditor};
22use super::jsonl::{self, Kept};
23use super::keys::KeyPress;
24use super::pane::{PaneState, TranscriptPane, TranscriptSource};
25use super::pick::PickerInput;
26use rig_ecs::agent::{Agent, AgentId, Conversation, Notice, NoticeLevel};
27use rig_ecs::inbox::Recalled;
28use rig_ecs::prelude::{Aside, AsideId, MessageId};
29use rig_harness::main_agent::ShownAgent;
30use rig_harness::prelude::{ExitReason, PluginData};
31
32/// Notices kept for display, and for the build a reload starts.
33const KEPT_NOTICES: usize = 200;
34
35/// The file of the session's [`PluginData`] that keeps the notices shown
36/// for the build a reload starts, until it reads them.
37const NOTICES_FILE: &str = "tui/notices.jsonl";
38
39/// The file of the session's [`PluginData`] that keeps what was typed and
40/// not sent for the build a reload starts.
41const DRAFT_FILE: &str = "tui/draft.txt";
42
43/// Ask the view to show the agent and send what is typed to it, such as
44/// a subagent picked with `/agents`.
45#[derive(EntityEvent, Reflect, Clone, Debug)]
46#[reflect(Event, Clone, Debug)]
47pub struct ShowAgent {
48    /// The agent.
49    pub entity: Entity,
50}
51
52/// The terminal view's state. Never saved and never read by the core.
53#[derive(Resource, Default)]
54pub(crate) struct TuiView {
55    /// The open completion list.
56    pub(crate) completion: Option<Completion>,
57    /// Where the token starts whose completion was closed with Esc.
58    pub(crate) dismissed: Option<usize>,
59    /// The pickers and questions to show, in the order they came; the
60    /// first is shown over the transcript and takes the keys.
61    pub(crate) queue: Vec<Entity>,
62    /// The keys pressed so far of a binding of several.
63    pub(crate) pending: Vec<KeyPress>,
64    /// A refused command put back in the input: sent again unchanged, it
65    /// goes to the model as a message.
66    pub(crate) refused: Option<String>,
67    /// The open questions shown beside the work, oldest first; the first
68    /// is shown.
69    pub(crate) inline: Vec<Entity>,
70}
71
72/// An item of a transcript, as plugins name it.
73#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
74pub enum TranscriptItem {
75    /// A message, by id.
76    Message(MessageId),
77    /// A tool call, by its call id.
78    Call(CallId),
79    /// An aside a look draws, such as a plugin's
80    /// [`Note`](rig_harness::rig_core::conversation::Note), by its id
81    /// ([`add_aside_renderer`](crate::AppToolRenderersExt::add_aside_renderer)).
82    Aside(AsideId),
83    /// A notice, by its number in the [`Notices`].
84    Notice(usize),
85    /// A content block of a message, an image, audio, a video or a
86    /// document, by the message's id and the block's place in it
87    /// ([`ContentView`](crate::ContentView)).
88    Content(MessageId, usize),
89    /// The rows a call decorator drew under a call, by the call's id and
90    /// the decorator's name
91    /// ([`add_call_decorator`](crate::AppToolRenderersExt::add_call_decorator)),
92    /// inside the call's rows. Selecting moves past it.
93    Decoration(CallId, String),
94    /// The rows a message decorator drew under a message, by the
95    /// message's id and the decorator's name
96    /// ([`MessageDecorator`](crate::MessageDecorator)),
97    /// inside the message's rows. Selecting moves past it.
98    MessageDecoration(MessageId, String),
99}
100
101/// A transcript pane's view, as plugins read and steer it: on each
102/// [`TranscriptPane`] (the default screen's is the
103/// [`MainTranscript`](crate::MainTranscript)). A change to it draws the
104/// pane again.
105///
106/// ```no_run
107/// use rig_harness::prelude::*;
108/// use rig_tui::ratatui::style::{Style, Stylize};
109/// use rig_tui::{Highlight, ItemRow, MainPane, ScrollTo, TranscriptItem, TranscriptView};
110///
111/// /// Lights a search's matches and shows the first in the message `hit`.
112/// fn show_match(
113///     hit: MessageId,
114///     mut panes: Query<(Entity, &mut TranscriptView), With<MainPane>>,
115///     mut commands: Commands,
116/// ) {
117///     let Ok((pane, mut view)) = panes.single_mut() else { return };
118///     let style = Style::new().on_yellow();
119///     let flaky = Highlight { text: "flaky".into(), style, ..Highlight::default() };
120///     view.highlights = vec![flaky];
121///     commands.trigger(ScrollTo { pane, item: Some(TranscriptItem::Message(hit)), row: ItemRow::Lit(0) });
122/// }
123/// ```
124#[derive(Component, Clone, Debug, Default)]
125pub struct TranscriptView {
126    /// The item the user selected, drawn marked.
127    pub selected: Option<TranscriptItem>,
128    /// Text lit where the transcript draws it, such as a search's
129    /// matches; text a wrap splits across two rows is lit on both.
130    pub highlights: Vec<Highlight>,
131    /// Labels drawn inside replies' text, such as a seam where a plugin
132    /// joined two replies. Never saved, never sent to a model.
133    pub marks: Vec<TextMark>,
134    /// The item and the row of it at the top while the view is scrolled
135    /// up; `None` while it follows the newest output.
136    top: Option<(TranscriptItem, usize)>,
137    /// The transcript's width at the last frame.
138    width: Option<u16>,
139}
140
141/// The notices the view shows, oldest first, those an earlier build of
142/// the session showed included: each drawn in the transcript of the agent
143/// it is about ([`ShownNotice::slot`]), and one about every agent where it
144/// was written. [`TranscriptItem::Notice`] names one by its number: its
145/// index here plus the older ones dropped.
146#[derive(Resource, Default)]
147pub struct Notices {
148    shown: Vec<ShownNotice>,
149    dropped: usize,
150    /// The notices collected, shared by each run of [`collect_notices`].
151    read: MessageCursor<Notice>,
152}
153
154/// Whose text a [`TextMark`] is in.
155#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
156pub enum MarkIn {
157    /// Text block `block` (its index among the content) of an assistant
158    /// message.
159    Message {
160        /// The message.
161        id: MessageId,
162        /// The text block's index among the message's content: not always
163        /// 0, as reasoning comes first when there is any.
164        block: usize,
165    },
166    /// The reply a model call is streaming (its `Partial` text), by the
167    /// call's entity.
168    Streaming(Entity),
169}
170
171/// A short label the transcript draws inside a reply's text, before byte
172/// `at` of it, markdown kept on both sides: a mark past the end goes at
173/// the end, one inside a character goes before it.
174///
175/// ```
176/// use rig_tui::ratatui::style::{Style, Stylize};
177/// use rig_tui::{MarkIn, TextMark, TranscriptView};
178///
179/// fn mark_seam(view: &mut TranscriptView, call: bevy_ecs::entity::Entity, so_far: &str) {
180///     view.marks.push(TextMark {
181///         r#in: MarkIn::Streaming(call),
182///         at: so_far.len(),
183///         label: " ⟳ continued ".into(),
184///         style: Style::new().dim(),
185///     });
186/// }
187/// ```
188#[derive(Clone, Debug, PartialEq, Eq, Hash)]
189pub struct TextMark {
190    /// Whose text it is in.
191    pub r#in: MarkIn,
192    /// The byte of the text it goes before.
193    pub at: usize,
194    /// What it shows.
195    pub label: String,
196    /// Its style.
197    pub style: Style,
198}
199
200/// Text to light in the transcript: wherever its `text` is drawn, or
201/// only its `spans`. Where matches of two highlights overlap, the one
202/// placed first in [`TranscriptView::highlights`] lights them and the
203/// other's match there is not lit, so a highlight placed first, in a style
204/// of its own, sets its text apart from the rest's.
205#[derive(Clone, Debug, Default, PartialEq, Eq)]
206pub struct Highlight {
207    /// The text.
208    pub text: String,
209    /// The style laid over it.
210    pub style: Style,
211    /// Whether its case must match too; else it is matched without regard
212    /// to case.
213    pub match_case: bool,
214    /// The matches a plugin found itself, such as a regular expression's,
215    /// each lit and counted as a match of `text` is; with any, `text` is
216    /// not sought. A span the transcript does not draw is not lit.
217    pub spans: Vec<LineSpan>,
218}
219
220/// A match a plugin found in a line of the transcript, as
221/// [`DrawnTranscript::lines`] gives them, to light with a [`Highlight`].
222#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
223pub struct LineSpan {
224    /// The item whose lines `line` counts, from its first; `None` counts
225    /// the whole transcript's, for rows of no item.
226    pub item: Option<TranscriptItem>,
227    /// The line, by its index in the item's
228    /// ([`DrawnTranscript::item`]'s [`lines`](DrawnTranscript::lines)).
229    pub line: usize,
230    /// Its bytes in the line's text; a match a wrap split is lit on each
231    /// of its rows and counted in the row it starts in.
232    pub bytes: Range<usize>,
233}
234
235/// Scrolls the transcript pane `pane` so that `row` of `item` is shown;
236/// `item: None` follows the newest output again. An item not drawn leaves
237/// the pane where it is. Its [`TranscriptView::top`] reads where it is.
238/// To show another agent in the main pane, trigger [`ShowAgent`] first.
239#[derive(Event, Clone, Debug)]
240pub struct ScrollTo {
241    /// The pane ([`TranscriptPane`]).
242    pub pane: Entity,
243    /// The item to show, or `None` for the newest output.
244    pub item: Option<TranscriptItem>,
245    /// Which row of the item to show, and where.
246    pub row: ItemRow,
247}
248
249/// A row of a [`TranscriptItem`] to scroll to, counted in the rows the
250/// view draws, which the view works out, wrapping included.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum ItemRow {
253    /// This row of the item, put at the top as far as the transcript
254    /// reaches, as [`TranscriptView::top`] reads it back.
255    Top(usize),
256    /// The item's first row, put at the top when it is not on screen.
257    Reveal,
258    /// The row of the item's match `n` (from 0) of the
259    /// [`TranscriptView::highlights`], counted top to bottom in the rows
260    /// drawn, put at the top when it is not on screen. With fewer matches
261    /// drawn, the item is revealed.
262    Lit(usize),
263}
264
265/// The transcript as the view drew it at its last frame: every row laid
266/// out, on screen or not, as drawn (wrapped, as its renderer drew it),
267/// with the items they belong to and the matches the view lit in each. A
268/// plugin counts and scrolls over this, not over the source text.
269/// [`TranscriptLayout`](crate::TranscriptLayout) lays one out without
270/// drawing it, for any agent; a plugin's test, with no terminal to draw,
271/// inserts such a one as this resource.
272///
273/// It costs nothing until a plugin adds it to a pane. From then on, each
274/// frame that lays out the pane, in
275/// [`TuiSystems::Layout`](crate::TuiSystems::Layout), copies the text of
276/// every row of it, and with [`TranscriptView::highlights`] set counts
277/// their matches in each, so its work and memory grow with the
278/// transcript; a streaming reply draws at most about 30 frames a second.
279/// Frames that draw the same rows leave it unchanged, so
280/// `Ref::is_changed` says what is drawn changed.
281///
282/// ```no_run
283/// use rig_harness::prelude::*;
284/// use rig_tui::{DrawnTranscript, ItemRow, ScrollTo, TranscriptItem};
285///
286/// /// Shows the first item with a lit match, by its first one.
287/// fn first_match(panes: Query<(Entity, Ref<DrawnTranscript>)>, mut commands: Commands) {
288///     for (pane, drawn) in panes.iter().filter(|(_, drawn)| drawn.is_changed()) {
289///         let mut lit = drawn.items().iter().filter(|(item, _)| drawn.lit_in(item) > 0);
290///         if let Some((item, _)) = lit.next() {
291///             commands.trigger(ScrollTo { pane, item: Some(item.clone()), row: ItemRow::Lit(0) });
292///         }
293///     }
294/// }
295/// ```
296#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
297pub struct DrawnTranscript {
298    agent: Option<Entity>,
299    area: ratatui::layout::Rect,
300    rows: Vec<DrawnRow>,
301    items: Vec<(TranscriptItem, Range<usize>)>,
302    on_screen: Range<usize>,
303}
304
305/// A row of the [`DrawnTranscript`].
306#[derive(Clone, Debug, Default, PartialEq, Eq)]
307pub struct DrawnRow {
308    /// Its text, as drawn.
309    pub text: String,
310    /// How it continues the row before it, when the view wrapped one
311    /// line into both; [`DrawnTranscript::lines`] rejoins them.
312    pub continues: Option<RowWrap>,
313    /// How many matches of each highlight the view lit starting in it,
314    /// by its index in [`TranscriptView::highlights`] (where two overlap,
315    /// the one placed first lit it); a match a wrap splits counts in the
316    /// row it starts in. Empty with no highlights.
317    pub lit: Vec<usize>,
318}
319
320/// A line of a [`DrawnTranscript`] as it was before the view wrapped it.
321#[derive(Clone, Debug, Default, PartialEq, Eq)]
322pub struct DrawnLine {
323    /// Its rows in the transcript.
324    pub rows: Range<usize>,
325    /// Its text: its rows rejoined, as each one's [`RowWrap`] says.
326    pub text: String,
327    /// Where each of its rows starts in `text`, by byte; the blanks a wrap
328    /// dropped end the row before.
329    pub starts: Vec<usize>,
330}
331
332impl DrawnLine {
333    /// The row of the transcript byte `at` of its text is drawn in.
334    pub fn row_at(&self, at: usize) -> usize {
335        let before = self.starts.partition_point(|&start| start <= at);
336        self.rows.start + before.saturating_sub(1)
337    }
338}
339
340/// How a [`DrawnRow`] continues the row before it, the view having
341/// wrapped one line into both: the line is the row before, then `gap`,
342/// then this row's text past its `indent`.
343#[derive(Clone, Debug, Default, PartialEq, Eq)]
344pub struct RowWrap {
345    /// The blanks the wrap dropped at the break.
346    pub gap: String,
347    /// How many spaces the row starts with that the line does not have,
348    /// such as those keeping a list item's wrapped text under its text.
349    pub indent: usize,
350}
351
352impl DrawnRow {
353    /// How many matches of every highlight the view lit in it.
354    pub fn lit_total(&self) -> usize {
355        self.lit.iter().sum()
356    }
357}
358
359impl DrawnTranscript {
360    /// The transcript `agent`'s `rows` make, `items` among them, with
361    /// `on_screen` on screen.
362    pub(crate) fn new(
363        agent: Option<Entity>,
364        rows: Vec<DrawnRow>,
365        items: Vec<(TranscriptItem, Range<usize>)>,
366        on_screen: Range<usize>,
367    ) -> Self {
368        Self {
369            agent,
370            area: ratatui::layout::Rect::default(),
371            rows,
372            items,
373            on_screen,
374        }
375    }
376
377    /// The same transcript, its rows on screen drawn in `area`.
378    pub(crate) fn at(mut self, area: ratatui::layout::Rect) -> Self {
379        self.area = area;
380        self
381    }
382
383    /// The agent whose transcript it is; `None` for a conversation no
384    /// agent holds, or with no agent shown, when the pane draws the
385    /// notices about every agent.
386    pub fn agent(&self) -> Option<Entity> {
387        self.agent
388    }
389
390    /// Where its rows on screen are drawn: the first at the top; empty in
391    /// one laid out without drawing it.
392    pub fn area(&self) -> ratatui::layout::Rect {
393        self.area
394    }
395
396    /// Its rows, top to bottom.
397    pub fn rows(&self) -> &[DrawnRow] {
398        &self.rows
399    }
400
401    /// Its lines as they were before the view wrapped them, top to
402    /// bottom, each with its rows. Text a wrap split across two rows is
403    /// whole here, for a search or a copy.
404    pub fn lines(&self) -> impl Iterator<Item = DrawnLine> + '_ {
405        let mut start = 0;
406        let lines = self.rows.chunk_by(|_, next| next.continues.is_some());
407        lines.map(move |rows| {
408            let mut text = String::new();
409            let mut starts = Vec::with_capacity(rows.len());
410            for row in rows {
411                match &row.continues {
412                    Some(joint) => {
413                        text.push_str(&joint.gap);
414                        starts.push(text.len());
415                        text.push_str(row.text.get(joint.indent..).unwrap_or(&row.text));
416                    }
417                    None => {
418                        starts.push(text.len());
419                        text.push_str(&row.text);
420                    }
421                }
422            }
423            let line = start..start + rows.len();
424            start = line.end;
425            DrawnLine {
426                rows: line,
427                text,
428                starts,
429            }
430        })
431    }
432
433    /// Each item's rows, top to bottom by first row. An item's rows hold
434    /// those of the items drawn inside it: a message's, its calls and the
435    /// notices and notes written inside it. Rows of no item, such as the
436    /// reply streaming in, are in none.
437    pub fn items(&self) -> &[(TranscriptItem, Range<usize>)] {
438        &self.items
439    }
440
441    /// The rows of `item`, or `None` when it is not drawn.
442    pub fn rows_of(&self, item: &TranscriptItem) -> Option<Range<usize>> {
443        let found = self.items.iter().find(|(drawn, _)| drawn == item);
444        found.map(|(_, rows)| rows.clone())
445    }
446
447    /// The innermost item `row` belongs to, such as a call rather than
448    /// its message, or a decorator's rows rather than their call.
449    pub fn item_at(&self, row: usize) -> Option<&TranscriptItem> {
450        let holding = self.items.iter().filter(|(_, rows)| rows.contains(&row));
451        holding
452            .max_by_key(|(_, rows)| rows.start)
453            .map(|(item, _)| item)
454    }
455
456    /// How many matches of every highlight the view lit in `item`'s rows:
457    /// `n` in [`ItemRow::Lit`] counts these, so it finds a row for each `n`
458    /// below this.
459    pub fn lit_in(&self, item: &TranscriptItem) -> usize {
460        let rows = self.rows_of(item).unwrap_or_default();
461        let rows = self.rows.get(rows).unwrap_or_default();
462        rows.iter().map(DrawnRow::lit_total).sum()
463    }
464
465    /// The rows on screen; none in one laid out without drawing it.
466    pub fn on_screen(&self) -> Range<usize> {
467        self.on_screen.clone()
468    }
469
470    /// Only `item`'s rows, as a transcript of their own: its rows from 0,
471    /// the items drawn in it, and its rows on screen.
472    /// `None` when it is not drawn.
473    pub fn item(&self, item: &TranscriptItem) -> Option<Self> {
474        let at = self.items.iter().position(|(drawn, _)| drawn == item)?;
475        let rows = self.items.get(at).map(|(_, rows)| rows.clone())?;
476        let start = rows.start;
477        let within = |inner: &Range<usize>| rows.start <= inner.start && inner.end <= rows.end;
478        // An item holding it comes before it, though it may have the same
479        // rows, such as a reply that only calls a tool.
480        let items: Vec<(TranscriptItem, Range<usize>)> = self
481            .items
482            .iter()
483            .skip(at)
484            .filter(|(_, inner)| within(inner))
485            .map(|(item, inner)| (item.clone(), inner.start - start..inner.end - start))
486            .collect();
487        let on_screen = self.on_screen.start.max(rows.start)..self.on_screen.end.min(rows.end);
488        let on_screen = if on_screen.is_empty() {
489            0..0
490        } else {
491            on_screen.start - start..on_screen.end - start
492        };
493        Some(Self {
494            agent: self.agent,
495            area: self.area,
496            rows: self.rows.get(rows).unwrap_or_default().to_vec(),
497            items,
498            on_screen,
499        })
500    }
501}
502
503/// A notice placed in a transcript.
504#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
505pub struct ShownNotice {
506    /// The agent it is about, or `None` for every agent.
507    pub agent: Option<AgentId>,
508    /// Where it was written in its agent's transcript (the one shown, for
509    /// a notice about every agent); `None` before that agent had a
510    /// message, or with no agent shown.
511    at: Option<Anchor>,
512    /// Whether it informs, warns or reports a failure.
513    #[serde(with = "level")]
514    pub level: NoticeLevel,
515    /// The text.
516    pub text: String,
517}
518
519/// Where a notice was written: after the first `items` content items of
520/// message `message` of `agent`'s conversation, which it stays after as
521/// messages are added, merged or spliced.
522#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
523struct Anchor {
524    agent: AgentId,
525    message: MessageId,
526    items: usize,
527}
528
529/// Where a notice or a note is drawn in an agent's transcript, by the
530/// index of a message in its [`Conversation`]. Slots order as they are
531/// drawn: the top, then each message with the slots inside it by item,
532/// then the slot after it. Those in the same slot are drawn in the order
533/// they came.
534#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
535pub enum TranscriptSlot {
536    /// Before the first message.
537    Top,
538    /// After this message.
539    After(usize),
540    /// Inside this message, before this content item.
541    Inside(usize, usize),
542}
543
544impl TranscriptSlot {
545    /// Where `aside` is drawn in `conversation`: after the entry it
546    /// follows while that is there, else after the first item of the entry
547    /// that stands for it now, such as the summary of a compaction that
548    /// took it out.
549    pub fn of_aside(aside: &Aside, conversation: &conversation::Conversation) -> Self {
550        let Some(after) = aside.after else {
551            return Self::Top;
552        };
553        if let Some(index) = conversation.index_of(after) {
554            return Self::After(index);
555        }
556        let Some(last) = conversation.messages().len().checked_sub(1) else {
557            return Self::Top;
558        };
559        let standing = conversation.start_of(after).min(last);
560        match items(conversation, standing) {
561            0 | 1 => Self::After(standing),
562            _ => Self::Inside(standing, 1),
563        }
564    }
565
566    /// Its place in drawing order.
567    fn order(self) -> Option<(usize, bool, usize)> {
568        match self {
569            Self::Top => None,
570            Self::Inside(message, item) => Some((message, false, item)),
571            Self::After(message) => Some((message, true, 0)),
572        }
573    }
574}
575
576impl Ord for TranscriptSlot {
577    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
578        self.order().cmp(&other.order())
579    }
580}
581
582impl PartialOrd for TranscriptSlot {
583    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
584        Some(self.cmp(other))
585    }
586}
587
588impl ShownNotice {
589    /// Whether it is shown with no agent shown: it is about every agent.
590    pub(crate) fn is_for_every_agent(&self) -> bool {
591        self.agent.is_none()
592    }
593
594    /// The agent whose transcript draws it: the one it is about, or for a
595    /// notice about every agent the one it was written in. `None` for one
596    /// about every agent written before any had a message, which every
597    /// transcript draws at its top. With no agent shown, the view draws
598    /// every notice about every agent.
599    pub fn drawn_in(&self) -> Option<&AgentId> {
600        let written = self.at.as_ref().map(|at| &at.agent);
601        written.or(self.agent.as_ref())
602    }
603
604    /// Where it is drawn in the transcript of the agent `id` with
605    /// `conversation`, or `None` when it is no part of it: it is about
606    /// another agent, or about every agent and was written in another's.
607    /// One whose message is gone goes where that message was.
608    pub fn slot(
609        &self,
610        id: &AgentId,
611        conversation: &conversation::Conversation,
612    ) -> Option<TranscriptSlot> {
613        if self.agent.as_ref().is_some_and(|agent| agent != id) {
614            return None;
615        }
616        let Some(at) = &self.at else {
617            return Some(TranscriptSlot::Top);
618        };
619        if &at.agent != id {
620            return None;
621        }
622        Some(match conversation.index_of(at.message) {
623            Some(index) if items(conversation, index) > at.items => {
624                TranscriptSlot::Inside(index, at.items)
625            }
626            Some(index) => TranscriptSlot::After(index),
627            None => match conversation.start_of(at.message).checked_sub(1) {
628                Some(index) => TranscriptSlot::After(index),
629                None => TranscriptSlot::Top,
630            },
631        })
632    }
633}
634
635/// The content items of message `index`.
636fn items(conversation: &conversation::Conversation, index: usize) -> usize {
637    match conversation.messages().get(index).map(|message| &**message) {
638        Some(Message::User { content }) => content.len(),
639        Some(Message::Assistant(assistant)) => assistant.content.len(),
640        _ => 0,
641    }
642}
643
644/// The end of `id`'s conversation, where a notice written now goes.
645fn anchor(id: &AgentId, conversation: &conversation::Conversation) -> Option<Anchor> {
646    let last = conversation.messages().len().checked_sub(1)?;
647    Some(Anchor {
648        agent: id.clone(),
649        message: conversation.id(last)?,
650        items: items(conversation, last),
651    })
652}
653
654/// A [`NoticeLevel`] as a word.
655mod level {
656    use rig_ecs::agent::NoticeLevel;
657    use serde::{Deserialize, Deserializer, Serializer};
658
659    pub(super) fn serialize<S: Serializer>(level: &NoticeLevel, out: S) -> Result<S::Ok, S::Error> {
660        out.serialize_str(match level {
661            NoticeLevel::Info => "info",
662            NoticeLevel::Warn => "warn",
663            NoticeLevel::Error => "error",
664        })
665    }
666
667    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
668        input: D,
669    ) -> Result<NoticeLevel, D::Error> {
670        Ok(match String::deserialize(input)?.as_str() {
671            "warn" => NoticeLevel::Warn,
672            "error" => NoticeLevel::Error,
673            _ => NoticeLevel::Info,
674        })
675    }
676}
677
678impl TranscriptView {
679    /// The item and its row at the top while the view is scrolled up;
680    /// `None` while it follows the newest output.
681    pub fn top(&self) -> Option<&(TranscriptItem, usize)> {
682        self.top.as_ref()
683    }
684
685    /// The pane's width in columns at the last frame laid out; `None`
686    /// before one, as in a run without the terminal. The view is changed
687    /// when it or [`top`](Self::top) changes, in
688    /// [`TuiSystems::Layout`](crate::TuiSystems::Layout), so `is_changed`
689    /// on it says so.
690    pub fn width(&self) -> Option<u16> {
691        self.width
692    }
693
694    /// Notes the item and row at the top as the layout placed them.
695    pub(crate) fn set_top(&mut self, top: Option<(TranscriptItem, usize)>) {
696        self.top = top;
697    }
698
699    /// Notes the width the layout laid the pane out at.
700    pub(crate) fn set_width(&mut self, width: u16) {
701        self.width = Some(width);
702    }
703}
704
705impl Notices {
706    /// The notices, oldest first.
707    pub fn all(&self) -> &[ShownNotice] {
708        &self.shown
709    }
710
711    /// The notices, oldest first, each with its number.
712    pub(crate) fn numbered(&self) -> impl Iterator<Item = (usize, &ShownNotice)> {
713        let numbers = self.dropped..;
714        numbers.zip(&self.shown)
715    }
716
717    /// Keeps `notice` for display, dropping the oldest past
718    /// [`KEPT_NOTICES`].
719    fn keep(&mut self, notice: ShownNotice) {
720        self.shown.push(notice);
721        let excess = self.shown.len().saturating_sub(KEPT_NOTICES);
722        self.shown.drain(..excess);
723        self.dropped += excess;
724    }
725}
726
727/// Reads back the notices the build before a reload kept, once: a run
728/// started any other way shows none of another run's.
729pub(crate) fn load_notices(data: Option<Res<PluginData>>, mut notices: ResMut<Notices>) {
730    let Some(data) = data else {
731        return;
732    };
733    notices.shown = jsonl::tail(&Kept(data.session.clone(), NOTICES_FILE), KEPT_NOTICES);
734    data.session.remove_file(NOTICES_FILE).ok();
735}
736
737/// Keeps the notices shown for the build a reload starts.
738pub(crate) fn keep_notices(
739    reason: Option<Res<ExitReason>>,
740    data: Option<Res<PluginData>>,
741    notices: Res<Notices>,
742) {
743    let (Some(reason), Some(data)) = (reason, data) else {
744        return;
745    };
746    if *reason != ExitReason::Reload || notices.shown.is_empty() {
747        return;
748    }
749    if let Err(failure) = jsonl::write(&Kept(data.session.clone(), NOTICES_FILE), &notices.shown) {
750        warn!("could not keep the notices for the next build: {failure}");
751    }
752}
753
754/// Keeps what was typed and not sent for the build a reload starts, such
755/// as a command typed during the rebuild that only the new build knows.
756/// While a question is answered in the input, what was typed before it.
757pub(crate) fn keep_draft(
758    reason: Option<Res<ExitReason>>,
759    data: Option<Res<PluginData>>,
760    (input, showing): (Query<&Editor, With<MainEditor>>, Query<&PickerInput>),
761) {
762    let (Some(reason), Some(data)) = (reason, data) else {
763        return;
764    };
765    let draft = showing
766        .iter()
767        .find_map(|choosing| choosing.draft.as_deref());
768    let typed = input.single().ok().map(Editor::text);
769    let text = draft.or(typed).unwrap_or_default();
770    if *reason != ExitReason::Reload || text.is_empty() {
771        return;
772    }
773    if let Err(failure) = data.session.write_file(DRAFT_FILE, text.as_bytes()) {
774        warn!("could not keep the input for the next build: {failure}");
775    }
776}
777
778/// Puts back in the input what the build before a reload kept.
779pub(crate) fn load_draft(
780    data: Option<Res<PluginData>>,
781    mut input: Query<&mut Editor, With<MainEditor>>,
782) {
783    let (Some(data), Ok(mut input)) = (data, input.single_mut()) else {
784        return;
785    };
786    if let Ok(text) = data.session.read_file(DRAFT_FILE) {
787        data.session.remove_file(DRAFT_FILE).ok();
788        input.set(String::from_utf8_lossy(&text).into_owned());
789    }
790}
791
792/// Shows the agent a [`ShowAgent`] names: the panes of the shown agent follow
793/// its newest output.
794pub(crate) fn on_focus(
795    focus: On<ShowAgent>,
796    agents: Query<(), With<Agent>>,
797    mut shown: ResMut<ShownAgent>,
798    mut view: ResMut<TuiView>,
799    mut panes: Query<(&TranscriptPane, &mut PaneState)>,
800) {
801    if !agents.contains(focus.entity) || shown.0 == Some(focus.entity) {
802        return;
803    }
804    shown.0 = Some(focus.entity);
805    let following = panes
806        .iter_mut()
807        .filter(|(pane, _)| matches!(pane.source, TranscriptSource::Shown));
808    for (_, mut state) in following {
809        state.scroll.follow();
810    }
811    view.completion = None;
812}
813
814/// Scrolls the pane a [`ScrollTo`] names to its item, which the next frame
815/// finds.
816pub(crate) fn on_scroll_to(scroll: On<ScrollTo>, mut panes: Query<&mut PaneState>) {
817    let Ok(mut state) = panes.get_mut(scroll.pane) else {
818        return;
819    };
820    match &scroll.item {
821        Some(item) => state.scroll.to(item.clone(), scroll.row),
822        None => state.scroll.follow(),
823    }
824}
825
826/// Keeps the latest notices for display, each placed where its agent's
827/// conversation ended when it arrived. One about an agent gone before it
828/// is read, such as a helper retired in the pass its model call failed,
829/// has no transcript left to be drawn in, and is not taken for one about
830/// every agent. It runs after the agent loop and again on exit, after the
831/// session log's last write, whose failure it keeps too.
832pub(crate) fn collect_notices(
833    notices: Res<Messages<Notice>>,
834    agents: Query<(&AgentId, &Conversation)>,
835    shown: Res<ShownAgent>,
836    mut kept: ResMut<Notices>,
837) {
838    if kept.read.is_empty(&notices) {
839        return;
840    }
841    let mut read = std::mem::take(&mut kept.read);
842    for notice in read.read(&notices) {
843        let about = match notice.agent {
844            Some(agent) => match agents.get(agent) {
845                Ok(about) => Some(about),
846                Err(_) => continue,
847            },
848            None => None,
849        };
850        let written = about.or_else(|| shown.0.and_then(|agent| agents.get(agent).ok()));
851        kept.keep(ShownNotice {
852            agent: about.map(|(id, _)| id.clone()),
853            at: written.and_then(|(id, conversation)| anchor(id, conversation)),
854            level: notice.level,
855            text: notice.text.clone(),
856        });
857    }
858    kept.read = read;
859}
860
861/// Puts what was typed and not sent back in the input, before what is
862/// typed there now, and says why a refused command was.
863pub(crate) fn recall_messages(
864    mut recalled: MessageReader<Recalled>,
865    agents: Query<(&AgentId, &Conversation)>,
866    shown: Res<ShownAgent>,
867    (mut view, mut notices): (ResMut<TuiView>, ResMut<Notices>),
868    mut input: Query<&mut Editor, With<MainEditor>>,
869) {
870    for recalled in recalled.read() {
871        if shown.0 != Some(recalled.agent) {
872            continue;
873        }
874        if let Some(why) = &recalled.why {
875            let agent = agents.get(recalled.agent).ok();
876            notices.keep(ShownNotice {
877                agent: agent.map(|(id, _)| id.clone()),
878                at: agent.and_then(|(id, conversation)| anchor(id, conversation)),
879                level: NoticeLevel::Error,
880                text: format!("{why} Enter sends it to the model as it is."),
881            });
882            view.refused = Some(recalled.text.clone());
883        }
884        let Ok(mut input) = input.single_mut() else {
885            continue;
886        };
887        let typed = input.take();
888        let text = if typed.trim().is_empty() {
889            recalled.text.clone()
890        } else {
891            format!("{}\n\n{typed}", recalled.text)
892        };
893        input.set(text);
894    }
895}
896
897#[cfg(test)]
898mod tests;