use std::ops::Range;
use bevy_ecs::message::MessageCursor;
use bevy_ecs::prelude::*;
use bevy_log::warn;
use bevy_reflect::prelude::*;
use ratatui::style::Style;
use rig_core::completion::Message;
use rig_core::conversation;
use rig_core::message::CallId;
use serde::{Deserialize, Serialize};
use super::complete::Completion;
use super::editor::{Editor, MainEditor};
use super::jsonl::{self, Kept};
use super::keys::KeyPress;
use super::pane::{PaneState, TranscriptPane, TranscriptSource};
use super::pick::PickerInput;
use rig_ecs::agent::{Agent, AgentId, Conversation, Notice, NoticeLevel};
use rig_ecs::inbox::Recalled;
use rig_ecs::prelude::{Aside, AsideId, MessageId};
use rig_harness::main_agent::ShownAgent;
use rig_harness::prelude::{ExitReason, PluginData};
const KEPT_NOTICES: usize = 200;
const NOTICES_FILE: &str = "tui/notices.jsonl";
const DRAFT_FILE: &str = "tui/draft.txt";
#[derive(EntityEvent, Reflect, Clone, Debug)]
#[reflect(Event, Clone, Debug)]
pub struct ShowAgent {
pub entity: Entity,
}
#[derive(Resource, Default)]
pub(crate) struct TuiView {
pub(crate) completion: Option<Completion>,
pub(crate) dismissed: Option<usize>,
pub(crate) queue: Vec<Entity>,
pub(crate) pending: Vec<KeyPress>,
pub(crate) refused: Option<String>,
pub(crate) inline: Vec<Entity>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum TranscriptItem {
Message(MessageId),
Call(CallId),
Aside(AsideId),
Notice(usize),
Content(MessageId, usize),
Decoration(CallId, String),
MessageDecoration(MessageId, String),
}
#[derive(Component, Clone, Debug, Default)]
pub struct TranscriptView {
pub selected: Option<TranscriptItem>,
pub highlights: Vec<Highlight>,
pub marks: Vec<TextMark>,
top: Option<(TranscriptItem, usize)>,
width: Option<u16>,
}
#[derive(Resource, Default)]
pub struct Notices {
shown: Vec<ShownNotice>,
dropped: usize,
read: MessageCursor<Notice>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MarkIn {
Message {
id: MessageId,
block: usize,
},
Streaming(Entity),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TextMark {
pub r#in: MarkIn,
pub at: usize,
pub label: String,
pub style: Style,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Highlight {
pub text: String,
pub style: Style,
pub match_case: bool,
pub spans: Vec<LineSpan>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct LineSpan {
pub item: Option<TranscriptItem>,
pub line: usize,
pub bytes: Range<usize>,
}
#[derive(Event, Clone, Debug)]
pub struct ScrollTo {
pub pane: Entity,
pub item: Option<TranscriptItem>,
pub row: ItemRow,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ItemRow {
Top(usize),
Reveal,
Lit(usize),
}
#[derive(Component, Clone, Debug, Default, PartialEq, Eq)]
pub struct DrawnTranscript {
agent: Option<Entity>,
area: ratatui::layout::Rect,
rows: Vec<DrawnRow>,
items: Vec<(TranscriptItem, Range<usize>)>,
on_screen: Range<usize>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DrawnRow {
pub text: String,
pub continues: Option<RowWrap>,
pub lit: Vec<usize>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DrawnLine {
pub rows: Range<usize>,
pub text: String,
pub starts: Vec<usize>,
}
impl DrawnLine {
pub fn row_at(&self, at: usize) -> usize {
let before = self.starts.partition_point(|&start| start <= at);
self.rows.start + before.saturating_sub(1)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RowWrap {
pub gap: String,
pub indent: usize,
}
impl DrawnRow {
pub fn lit_total(&self) -> usize {
self.lit.iter().sum()
}
}
impl DrawnTranscript {
pub(crate) fn new(
agent: Option<Entity>,
rows: Vec<DrawnRow>,
items: Vec<(TranscriptItem, Range<usize>)>,
on_screen: Range<usize>,
) -> Self {
Self {
agent,
area: ratatui::layout::Rect::default(),
rows,
items,
on_screen,
}
}
pub(crate) fn at(mut self, area: ratatui::layout::Rect) -> Self {
self.area = area;
self
}
pub fn agent(&self) -> Option<Entity> {
self.agent
}
pub fn area(&self) -> ratatui::layout::Rect {
self.area
}
pub fn rows(&self) -> &[DrawnRow] {
&self.rows
}
pub fn lines(&self) -> impl Iterator<Item = DrawnLine> + '_ {
let mut start = 0;
let lines = self.rows.chunk_by(|_, next| next.continues.is_some());
lines.map(move |rows| {
let mut text = String::new();
let mut starts = Vec::with_capacity(rows.len());
for row in rows {
match &row.continues {
Some(joint) => {
text.push_str(&joint.gap);
starts.push(text.len());
text.push_str(row.text.get(joint.indent..).unwrap_or(&row.text));
}
None => {
starts.push(text.len());
text.push_str(&row.text);
}
}
}
let line = start..start + rows.len();
start = line.end;
DrawnLine {
rows: line,
text,
starts,
}
})
}
pub fn items(&self) -> &[(TranscriptItem, Range<usize>)] {
&self.items
}
pub fn rows_of(&self, item: &TranscriptItem) -> Option<Range<usize>> {
let found = self.items.iter().find(|(drawn, _)| drawn == item);
found.map(|(_, rows)| rows.clone())
}
pub fn item_at(&self, row: usize) -> Option<&TranscriptItem> {
let holding = self.items.iter().filter(|(_, rows)| rows.contains(&row));
holding
.max_by_key(|(_, rows)| rows.start)
.map(|(item, _)| item)
}
pub fn lit_in(&self, item: &TranscriptItem) -> usize {
let rows = self.rows_of(item).unwrap_or_default();
let rows = self.rows.get(rows).unwrap_or_default();
rows.iter().map(DrawnRow::lit_total).sum()
}
pub fn on_screen(&self) -> Range<usize> {
self.on_screen.clone()
}
pub fn item(&self, item: &TranscriptItem) -> Option<Self> {
let at = self.items.iter().position(|(drawn, _)| drawn == item)?;
let rows = self.items.get(at).map(|(_, rows)| rows.clone())?;
let start = rows.start;
let within = |inner: &Range<usize>| rows.start <= inner.start && inner.end <= rows.end;
let items: Vec<(TranscriptItem, Range<usize>)> = self
.items
.iter()
.skip(at)
.filter(|(_, inner)| within(inner))
.map(|(item, inner)| (item.clone(), inner.start - start..inner.end - start))
.collect();
let on_screen = self.on_screen.start.max(rows.start)..self.on_screen.end.min(rows.end);
let on_screen = if on_screen.is_empty() {
0..0
} else {
on_screen.start - start..on_screen.end - start
};
Some(Self {
agent: self.agent,
area: self.area,
rows: self.rows.get(rows).unwrap_or_default().to_vec(),
items,
on_screen,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShownNotice {
pub agent: Option<AgentId>,
at: Option<Anchor>,
#[serde(with = "level")]
pub level: NoticeLevel,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Anchor {
agent: AgentId,
message: MessageId,
items: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TranscriptSlot {
Top,
After(usize),
Inside(usize, usize),
}
impl TranscriptSlot {
pub fn of_aside(aside: &Aside, conversation: &conversation::Conversation) -> Self {
let Some(after) = aside.after else {
return Self::Top;
};
if let Some(index) = conversation.index_of(after) {
return Self::After(index);
}
let Some(last) = conversation.messages().len().checked_sub(1) else {
return Self::Top;
};
let standing = conversation.start_of(after).min(last);
match items(conversation, standing) {
0 | 1 => Self::After(standing),
_ => Self::Inside(standing, 1),
}
}
fn order(self) -> Option<(usize, bool, usize)> {
match self {
Self::Top => None,
Self::Inside(message, item) => Some((message, false, item)),
Self::After(message) => Some((message, true, 0)),
}
}
}
impl Ord for TranscriptSlot {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.order().cmp(&other.order())
}
}
impl PartialOrd for TranscriptSlot {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl ShownNotice {
pub(crate) fn is_for_every_agent(&self) -> bool {
self.agent.is_none()
}
pub fn drawn_in(&self) -> Option<&AgentId> {
let written = self.at.as_ref().map(|at| &at.agent);
written.or(self.agent.as_ref())
}
pub fn slot(
&self,
id: &AgentId,
conversation: &conversation::Conversation,
) -> Option<TranscriptSlot> {
if self.agent.as_ref().is_some_and(|agent| agent != id) {
return None;
}
let Some(at) = &self.at else {
return Some(TranscriptSlot::Top);
};
if &at.agent != id {
return None;
}
Some(match conversation.index_of(at.message) {
Some(index) if items(conversation, index) > at.items => {
TranscriptSlot::Inside(index, at.items)
}
Some(index) => TranscriptSlot::After(index),
None => match conversation.start_of(at.message).checked_sub(1) {
Some(index) => TranscriptSlot::After(index),
None => TranscriptSlot::Top,
},
})
}
}
fn items(conversation: &conversation::Conversation, index: usize) -> usize {
match conversation.messages().get(index).map(|message| &**message) {
Some(Message::User { content }) => content.len(),
Some(Message::Assistant(assistant)) => assistant.content.len(),
_ => 0,
}
}
fn anchor(id: &AgentId, conversation: &conversation::Conversation) -> Option<Anchor> {
let last = conversation.messages().len().checked_sub(1)?;
Some(Anchor {
agent: id.clone(),
message: conversation.id(last)?,
items: items(conversation, last),
})
}
mod level {
use rig_ecs::agent::NoticeLevel;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(level: &NoticeLevel, out: S) -> Result<S::Ok, S::Error> {
out.serialize_str(match level {
NoticeLevel::Info => "info",
NoticeLevel::Warn => "warn",
NoticeLevel::Error => "error",
})
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
input: D,
) -> Result<NoticeLevel, D::Error> {
Ok(match String::deserialize(input)?.as_str() {
"warn" => NoticeLevel::Warn,
"error" => NoticeLevel::Error,
_ => NoticeLevel::Info,
})
}
}
impl TranscriptView {
pub fn top(&self) -> Option<&(TranscriptItem, usize)> {
self.top.as_ref()
}
pub fn width(&self) -> Option<u16> {
self.width
}
pub(crate) fn set_top(&mut self, top: Option<(TranscriptItem, usize)>) {
self.top = top;
}
pub(crate) fn set_width(&mut self, width: u16) {
self.width = Some(width);
}
}
impl Notices {
pub fn all(&self) -> &[ShownNotice] {
&self.shown
}
pub(crate) fn numbered(&self) -> impl Iterator<Item = (usize, &ShownNotice)> {
let numbers = self.dropped..;
numbers.zip(&self.shown)
}
fn keep(&mut self, notice: ShownNotice) {
self.shown.push(notice);
let excess = self.shown.len().saturating_sub(KEPT_NOTICES);
self.shown.drain(..excess);
self.dropped += excess;
}
}
pub(crate) fn load_notices(data: Option<Res<PluginData>>, mut notices: ResMut<Notices>) {
let Some(data) = data else {
return;
};
notices.shown = jsonl::tail(&Kept(data.session.clone(), NOTICES_FILE), KEPT_NOTICES);
data.session.remove_file(NOTICES_FILE).ok();
}
pub(crate) fn keep_notices(
reason: Option<Res<ExitReason>>,
data: Option<Res<PluginData>>,
notices: Res<Notices>,
) {
let (Some(reason), Some(data)) = (reason, data) else {
return;
};
if *reason != ExitReason::Reload || notices.shown.is_empty() {
return;
}
if let Err(failure) = jsonl::write(&Kept(data.session.clone(), NOTICES_FILE), ¬ices.shown) {
warn!("could not keep the notices for the next build: {failure}");
}
}
pub(crate) fn keep_draft(
reason: Option<Res<ExitReason>>,
data: Option<Res<PluginData>>,
(input, showing): (Query<&Editor, With<MainEditor>>, Query<&PickerInput>),
) {
let (Some(reason), Some(data)) = (reason, data) else {
return;
};
let draft = showing
.iter()
.find_map(|choosing| choosing.draft.as_deref());
let typed = input.single().ok().map(Editor::text);
let text = draft.or(typed).unwrap_or_default();
if *reason != ExitReason::Reload || text.is_empty() {
return;
}
if let Err(failure) = data.session.write_file(DRAFT_FILE, text.as_bytes()) {
warn!("could not keep the input for the next build: {failure}");
}
}
pub(crate) fn load_draft(
data: Option<Res<PluginData>>,
mut input: Query<&mut Editor, With<MainEditor>>,
) {
let (Some(data), Ok(mut input)) = (data, input.single_mut()) else {
return;
};
if let Ok(text) = data.session.read_file(DRAFT_FILE) {
data.session.remove_file(DRAFT_FILE).ok();
input.set(String::from_utf8_lossy(&text).into_owned());
}
}
pub(crate) fn on_focus(
focus: On<ShowAgent>,
agents: Query<(), With<Agent>>,
mut shown: ResMut<ShownAgent>,
mut view: ResMut<TuiView>,
mut panes: Query<(&TranscriptPane, &mut PaneState)>,
) {
if !agents.contains(focus.entity) || shown.0 == Some(focus.entity) {
return;
}
shown.0 = Some(focus.entity);
let following = panes
.iter_mut()
.filter(|(pane, _)| matches!(pane.source, TranscriptSource::Shown));
for (_, mut state) in following {
state.scroll.follow();
}
view.completion = None;
}
pub(crate) fn on_scroll_to(scroll: On<ScrollTo>, mut panes: Query<&mut PaneState>) {
let Ok(mut state) = panes.get_mut(scroll.pane) else {
return;
};
match &scroll.item {
Some(item) => state.scroll.to(item.clone(), scroll.row),
None => state.scroll.follow(),
}
}
pub(crate) fn collect_notices(
notices: Res<Messages<Notice>>,
agents: Query<(&AgentId, &Conversation)>,
shown: Res<ShownAgent>,
mut kept: ResMut<Notices>,
) {
if kept.read.is_empty(¬ices) {
return;
}
let mut read = std::mem::take(&mut kept.read);
for notice in read.read(¬ices) {
let about = match notice.agent {
Some(agent) => match agents.get(agent) {
Ok(about) => Some(about),
Err(_) => continue,
},
None => None,
};
let written = about.or_else(|| shown.0.and_then(|agent| agents.get(agent).ok()));
kept.keep(ShownNotice {
agent: about.map(|(id, _)| id.clone()),
at: written.and_then(|(id, conversation)| anchor(id, conversation)),
level: notice.level,
text: notice.text.clone(),
});
}
kept.read = read;
}
pub(crate) fn recall_messages(
mut recalled: MessageReader<Recalled>,
agents: Query<(&AgentId, &Conversation)>,
shown: Res<ShownAgent>,
(mut view, mut notices): (ResMut<TuiView>, ResMut<Notices>),
mut input: Query<&mut Editor, With<MainEditor>>,
) {
for recalled in recalled.read() {
if shown.0 != Some(recalled.agent) {
continue;
}
if let Some(why) = &recalled.why {
let agent = agents.get(recalled.agent).ok();
notices.keep(ShownNotice {
agent: agent.map(|(id, _)| id.clone()),
at: agent.and_then(|(id, conversation)| anchor(id, conversation)),
level: NoticeLevel::Error,
text: format!("{why} Enter sends it to the model as it is."),
});
view.refused = Some(recalled.text.clone());
}
let Ok(mut input) = input.single_mut() else {
continue;
};
let typed = input.take();
let text = if typed.trim().is_empty() {
recalled.text.clone()
} else {
format!("{}\n\n{typed}", recalled.text)
};
input.set(text);
}
}
#[cfg(test)]
mod tests;