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), ¬ices.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(¬ices) {
839 return;
840 }
841 let mut read = std::mem::take(&mut kept.read);
842 for notice in read.read(¬ices) {
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;