use std::collections::{BTreeMap, HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::ops::Range;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use bevy_ecs::prelude::*;
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::Line;
use rig_core::completion::{AssistantContent, Message};
use rig_core::conversation;
use rig_core::error::ErrorReport;
use rig_core::message::{CallId, ToolCall, ToolFunction, ToolName, ToolResult, UserContent};
use rig_core::streaming::{PartKind, StreamEvent, parse_partial_arguments};
use rig_core::tool::ToolResultContext;
use super::markdown;
use super::render::{Lights, light, lit};
use super::renderers::{
AsideView, CallState, ChildCall, ContentBlock, ContentView, DecoratedMessage, EntryView,
MessageView, Renderers, Role, ToolCallView, plugin_name, result_style, result_text, text_rows,
};
use super::theme::Theme;
use super::view::{
DrawnRow, DrawnTranscript, Highlight, ItemRow, MarkIn, RowWrap, ShownNotice, TextMark,
TranscriptItem,
};
use super::wrap::wrap;
use rig_core::effect::{self, EffectId, EffectKind, EffectRecord};
use rig_ecs::agent::{AgentId, NoticeLevel, Partial};
use rig_ecs::prelude::{Aside, AsideId, Author, AuthorKind, MessageId};
use rig_ecs::stage::{EffectRecorded, Outcome};
use rig_ecs::tools::ToolRequest;
use rig_ecs::turn::{ModelRequest, SentTo, StreamDelta};
use rig_tools::attach::attached_file;
pub(crate) type Marks = Vec<(TranscriptItem, Range<usize>)>;
#[derive(Resource, Default)]
pub(crate) struct CallFacts {
run: Settled,
past: Arc<Settled>,
names: HashMap<String, String>,
changes: u64,
children: Mutex<HashMap<String, Arc<CallsUnder>>>,
}
pub(crate) type CallsUnder = BTreeMap<CallId, Vec<ChildCall>>;
impl CallFacts {
fn of(&self, call: &CallId) -> (Option<&ToolResultContext>, Option<Duration>) {
let facts = self.run.facts.get(call);
let facts = facts.or_else(|| self.past.facts.get(call));
facts.map_or((None, None), |(facts, took)| (facts.as_ref(), *took))
}
fn note(&mut self, record: &EffectRecord) {
if self.run.note(record) {
self.changed();
}
}
fn take_past(&mut self, past: Arc<Settled>) {
self.past = past;
self.changed();
}
pub(crate) fn name(&mut self, agent: &str, name: &str) -> bool {
if self.names.get(agent).is_some_and(|known| known == name) {
return false;
}
self.names.insert(agent.to_owned(), name.to_owned());
self.changed();
true
}
fn changed(&mut self) {
self.changes = self.changes.wrapping_add(1);
self.children
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
pub(crate) fn children(&self, live: &CallTree, agent: &str) -> ChildCalls {
let settled = self.settled_children(agent);
let trees = [&self.past.calls, &self.run.calls, live];
let mut above = HashSet::new();
for &effect in live.parents.keys() {
let mut at = Some(effect);
while let Some(effect) = at.filter(|&effect| above.insert(effect)) {
at = trees
.iter()
.find_map(|tree| tree.parents.get(&effect).copied());
}
}
let mut live = CallsUnder::new();
for parent in above {
let starts_any = trees.iter().any(|tree| tree.children.contains_key(&parent));
match tool_call(&trees, parent) {
Some((of, call)) if **of == *agent && starts_any && !live.contains_key(call) => {
live.insert(call.clone(), self.below(&trees, parent));
}
_ => {}
}
}
ChildCalls { settled, live }
}
fn settled_children(&self, agent: &str) -> Arc<CallsUnder> {
let mut known = self.children.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(found) = known.get(agent) {
return Arc::clone(found);
}
let trees = [&self.past.calls, &self.run.calls];
let mut found = CallsUnder::new();
for parent in trees.iter().flat_map(|tree| tree.children.keys()) {
match tool_call(&trees, *parent) {
Some((of, call)) if **of == *agent && !found.contains_key(call) => {
found.insert(call.clone(), self.below(&trees, *parent));
}
_ => {}
}
}
let found = Arc::new(found);
known.insert(agent.to_owned(), Arc::clone(&found));
found
}
fn below(&self, trees: &[&CallTree], parent: EffectId) -> Vec<ChildCall> {
let mut out = Vec::new();
self.add_below(trees, parent, 0, &mut HashSet::from([parent]), &mut out);
out
}
fn add_below(
&self,
trees: &[&CallTree],
parent: EffectId,
depth: usize,
seen: &mut HashSet<EffectId>,
out: &mut Vec<ChildCall>,
) {
let children = trees.iter().filter_map(|tree| tree.children.get(&parent));
let mut children: Vec<&Child> = children.flatten().collect();
children.sort_by_key(|child| child.id);
for child in children {
if !seen.insert(child.id) {
continue;
}
let Some(call) = &child.call else {
self.add_below(trees, child.id, depth, seen, out);
continue;
};
let name = child
.agent
.as_deref()
.and_then(|agent| self.names.get(agent));
out.push(ChildCall {
depth,
agent: name.cloned().or_else(|| call.agent.clone()),
..call.clone()
});
self.add_below(trees, child.id, depth + 1, seen, out);
}
}
}
fn tool_call<'a>(trees: &[&'a CallTree], id: EffectId) -> Option<&'a (Arc<str>, CallId)> {
trees.iter().find_map(|tree| tree.tool_calls.get(&id))
}
#[derive(Default)]
pub(crate) struct ChildCalls {
settled: Arc<CallsUnder>,
live: CallsUnder,
}
impl ChildCalls {
pub(crate) fn get(&self, call: &CallId) -> Option<&Vec<ChildCall>> {
self.live.get(call).or_else(|| self.settled.get(call))
}
}
#[derive(Clone, Default)]
pub(crate) struct Settled {
facts: HashMap<CallId, (Option<ToolResultContext>, Option<Duration>)>,
calls: CallTree,
replies: HashMap<EffectId, Vec<ToolCall>>,
}
impl Settled {
fn note(&mut self, record: &EffectRecord) -> bool {
if !self.calls.known.insert(record.id) {
return false;
}
let outcome = record.reviewed.as_ref().unwrap_or(&record.outcome);
let child = match &record.kind {
EffectKind::Completion { .. } => {
let reply = match outcome {
Ok(effect::Answered::Completion(reply)) => Some(reply),
_ => None,
};
let calls = reply.into_iter().flat_map(|reply| &reply.choice);
let calls: Vec<ToolCall> = calls
.filter_map(|content| match content {
AssistantContent::ToolCall(call) => Some(call.clone()),
_ => None,
})
.collect();
if !calls.is_empty() {
self.replies.insert(record.id, calls);
}
let model = record.key.as_str().strip_prefix("model:");
let (failed, answer) = answer(Some(outcome), false);
Some(ChildCall {
depth: 0,
call: None,
name: model.unwrap_or("model").to_owned(),
agent: None,
state: CallState::Done,
failed,
answer,
})
}
EffectKind::ToolCall { name: tool, args } => {
let call = self.claim(record, tool, args);
if let (Some(call), Some(agent)) = (&call, &record.scope) {
self.calls.tool_call(record.id, agent, call.id.clone());
let took = record.finished_ms.zip(record.started_ms);
let took =
took.map(|(end, start)| Duration::from_millis(end.saturating_sub(start)));
self.facts
.insert(call.id.clone(), (record.published.clone(), took));
}
let (failed, answer) = answer(Some(outcome), true);
Some(ChildCall {
depth: 0,
call,
name: tool.clone(),
agent: None,
state: CallState::Done,
failed,
answer,
})
}
_ => None,
};
let agent = record.scope.clone();
self.calls.add(record.parent, record.id, child, agent);
true
}
fn claim(&mut self, record: &EffectRecord, tool: &str, args: &str) -> Option<ToolCall> {
let parent = record.parent;
let reply = parent.and_then(|parent| self.replies.get_mut(&parent));
if let Some(calls) = reply {
let named = |call: &ToolCall| call.function.name.as_str() == tool;
let same = calls
.iter()
.position(|call| named(call) && call.function.raw_arguments() == args);
let found = same.or_else(|| calls.iter().position(named));
let call = found.map(|at| calls.remove(at));
if calls.is_empty()
&& let Some(parent) = parent
{
self.replies.remove(&parent);
}
if call.is_some() {
return call;
}
}
let name = ToolName::new(tool).ok()?;
let args = serde_json::from_str(args).unwrap_or_default();
let id = CallId::from_wire(format!("effect-{}", record.id));
Some(ToolCall::new(id, ToolFunction::new(name, args)))
}
}
#[derive(Clone, Default)]
pub(crate) struct CallTree {
children: HashMap<EffectId, Vec<Child>>,
parents: HashMap<EffectId, EffectId>,
tool_calls: HashMap<EffectId, (Arc<str>, CallId)>,
known: HashSet<EffectId>,
}
#[derive(Clone)]
struct Child {
id: EffectId,
call: Option<ChildCall>,
agent: Option<Arc<str>>,
}
impl CallTree {
pub(crate) fn add(
&mut self,
parent: Option<EffectId>,
id: EffectId,
call: Option<ChildCall>,
agent: Option<Arc<str>>,
) {
if let Some(parent) = parent {
self.parents.insert(id, parent);
let child = Child { id, call, agent };
self.children.entry(parent).or_default().push(child);
}
}
pub(crate) fn tool_call(&mut self, id: EffectId, agent: &str, call: CallId) {
self.tool_calls.insert(id, (Arc::from(agent), call));
}
}
pub(crate) fn answer(
outcome: Option<&Result<effect::Answered, ErrorReport>>,
tool: bool,
) -> (bool, Option<String>) {
match (outcome, tool) {
(None, _) => (false, None),
(Some(outcome), true) => {
let result = Outcome(outcome.clone()).tool_result();
let failed = result.is_error() || result.is_refused();
(failed, Some(result.output().render()))
}
(Some(Err(report)), false) => (true, Some(report.message.clone())),
(Some(Ok(effect::Answered::Completion(reply))), false) => (false, Some(reply.text())),
(Some(Ok(_)), false) => (false, None),
}
}
pub(crate) fn live_call(
(request, model, sent): (Option<&ToolRequest>, Option<&ModelRequest>, Option<&SentTo>),
(agent, outcome): (Option<&Name>, Option<&Outcome>),
state: CallState,
) -> Option<ChildCall> {
let connection = sent.map(|SentTo(connection)| connection);
let connection = connection.or(model.and_then(|model| model.connection.as_ref()));
let name = match (request, model) {
(Some(request), _) => request.call.function.name.to_string(),
(None, Some(_)) => connection.map_or("model".to_owned(), |c| c.spec.reference()),
(None, None) => return None,
};
let (failed, answer) = answer(outcome.map(|Outcome(outcome)| outcome), request.is_some());
Some(ChildCall {
depth: 0,
call: request.map(|request| request.call.clone()),
name,
agent: agent.map(|name| name.as_str().to_owned()),
state,
failed,
answer,
})
}
#[derive(Message, Clone)]
pub struct PastEffects(Arc<Settled>);
impl PastEffects {
#[must_use]
pub fn new(records: impl IntoIterator<Item = EffectRecord>) -> Self {
let mut settled = Settled::default();
for record in records {
settled.note(&record);
}
Self(Arc::new(settled))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.calls.known.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.calls.known.is_empty()
}
}
pub(crate) fn note_effects(
mut recorded: MessageReader<EffectRecorded>,
mut past: MessageReader<PastEffects>,
mut facts: ResMut<CallFacts>,
) {
if recorded.is_empty() && past.is_empty() {
return;
}
for PastEffects(settled) in past.read() {
facts.take_past(Arc::clone(settled));
}
for EffectRecorded(record) in recorded.read() {
facts.note(record);
}
}
pub(crate) fn note_agent_name(
named: On<Insert<Name>>,
agents: Query<(&AgentId, &Name)>,
mut facts: ResMut<CallFacts>,
) {
if let Ok((id, name)) = agents.get(named.entity)
&& facts
.bypass_change_detection()
.name(id.0.as_str(), name.as_str())
{
facts.set_changed();
}
}
#[derive(Component, Default)]
pub(crate) struct StreamingCalls(Vec<(rig_core::streaming::Part, ToolCall, String)>);
impl StreamingCalls {
pub(crate) fn calls(&self) -> impl Iterator<Item = ToolCall> {
self.0.iter().map(|(_, call, json)| {
let mut call = call.clone();
if !json.is_empty() {
call.function.arguments = parse_partial_arguments(json);
}
call
})
}
}
pub(crate) fn follow_streams(
mut deltas: MessageReader<StreamDelta>,
mut streams: Query<&mut StreamingCalls, With<Partial>>,
) {
for delta in deltas.read() {
let Ok(mut calls) = streams.get_mut(delta.effect) else {
continue;
};
match &delta.event {
StreamEvent::Start {
part,
kind: PartKind::ToolCall,
name: Some(name),
} => {
let id = CallId::from_wire(format!("streaming-{}", part.index()));
let call = ToolCall::new(id, ToolFunction::new(name.clone(), Default::default()));
calls.0.push((*part, call, String::new()));
}
StreamEvent::Arguments { part, json } => {
if let Some((.., text)) = calls.0.iter_mut().find(|(at, ..)| at == part) {
text.push_str(json);
}
}
StreamEvent::End {
part,
content: AssistantContent::ToolCall(done),
} => {
if let Some((_, call, text)) = calls.0.iter_mut().find(|(at, ..)| at == part) {
*call = done.clone();
text.clear();
}
}
_ => {}
}
}
}
pub(crate) fn forget_streams(gone: On<Remove<Partial>>, mut streams: Query<&mut StreamingCalls>) {
if let Ok(mut calls) = streams.get_mut(gone.entity) {
calls.0.clear();
}
}
pub(crate) struct Look<'a> {
pub(crate) renderers: &'a Renderers<'a>,
pub(crate) calls: &'a BTreeMap<CallId, CallState>,
pub(crate) outputs: &'a BTreeMap<CallId, String>,
pub(crate) inside: HashMap<usize, Vec<(usize, Inside<'a>)>>,
pub(crate) facts: &'a CallFacts,
pub(crate) theme: &'a Theme,
pub(crate) children: &'a ChildCalls,
pub(crate) marks: &'a [TextMark],
}
#[derive(Clone, Copy)]
pub(crate) enum Inside<'a> {
Notice(usize, &'a ShownNotice),
Aside(&'a ShownAside<'a>),
}
pub(crate) struct ShownAside<'a> {
pub(crate) id: AsideId,
pub(crate) aside: &'a Aside,
}
impl ShownAside<'_> {
pub(crate) fn item(&self) -> TranscriptItem {
TranscriptItem::Aside(self.id)
}
pub(crate) fn view(&self, theme: Theme) -> AsideView<'_> {
AsideView {
id: self.id,
aside: self.aside,
theme,
}
}
pub(crate) fn lines(&self, renderers: &Renderers<'_>, theme: &Theme) -> Vec<Line<'static>> {
renderers.aside(&self.view(*theme)).unwrap_or_default()
}
}
impl Look<'_> {
pub(crate) fn marks_in(&self, text: MarkIn) -> Vec<(usize, ratatui::text::Span<'static>)> {
let marks = self.marks.iter().filter(|mark| mark.r#in == text);
let span = |mark: &TextMark| ratatui::text::Span::styled(mark.label.clone(), mark.style);
marks.map(|mark| (mark.at, span(mark))).collect()
}
fn inside(&self, index: usize) -> &[(usize, Inside<'_>)] {
self.inside.get(&index).map_or(&[], Vec::as_slice)
}
fn hash_inside(&self, index: usize, hasher: &mut DefaultHasher) {
for (item, inside) in self.inside(index) {
item.hash(hasher);
match inside {
Inside::Notice(at, notice) => {
at.hash(hasher);
level_code(notice.level).hash(hasher);
notice.text.hash(hasher);
}
Inside::Aside(shown) => {
shown.id.hash(hasher);
(&shown.aside.kind, plugin_name(&shown.aside.author)).hash(hasher);
serde_json::to_vec(&shown.aside.value)
.unwrap_or_default()
.hash(hasher);
}
}
}
}
fn state(&self, call: &CallId, result: Option<&ToolResult>) -> CallState {
match self.calls.get(call) {
Some(state) => state.clone(),
None if result.is_some() => CallState::Done,
None => CallState::Running(None),
}
}
fn hash_message(&self, (index, id): (usize, Option<MessageId>), message: &Message) -> u64 {
let hasher = &mut DefaultHasher::new();
self.hash_inside(index, hasher);
let marks = self.marks.iter();
marks
.filter(|mark| matches!(mark.r#in, MarkIn::Message { id: of, .. } if Some(of) == id))
.for_each(|mark| mark.hash(hasher));
if let Message::Assistant(assistant) = message {
for item in assistant.content.iter() {
if let AssistantContent::ToolCall(call) = item {
self.calls.get(&call.id).hash(hasher);
self.outputs.get(&call.id).hash(hasher);
self.facts.of(&call.id).1.hash(hasher);
hash_children(self.children.get(&call.id), hasher);
}
}
}
hasher.finish()
}
fn digest(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut indices: Vec<&usize> = self.inside.keys().collect();
indices.sort();
for index in indices {
index.hash(&mut hasher);
self.hash_inside(*index, &mut hasher);
}
self.calls.hash(&mut hasher);
self.outputs.hash(&mut hasher);
self.marks.hash(&mut hasher);
self.theme.hash(&mut hasher);
self.facts.changes.hash(&mut hasher);
for (call, children) in &self.children.live {
call.hash(&mut hasher);
hash_children(Some(children), &mut hasher);
}
hasher.finish()
}
}
fn hash_children(children: Option<&Vec<ChildCall>>, hasher: &mut DefaultHasher) {
for child in children.into_iter().flatten() {
(child.depth, &child.name, &child.agent, &child.state).hash(hasher);
(child.failed, &child.answer).hash(hasher);
child.call.as_ref().map(|call| &call.id).hash(hasher);
}
}
fn level_code(level: NoticeLevel) -> u8 {
match level {
NoticeLevel::Info => 0,
NoticeLevel::Warn => 1,
NoticeLevel::Error => 2,
}
}
struct Cached {
message: Arc<Message>,
look: u64,
lines: Vec<Line<'static>>,
line_marks: Marks,
wrapped: Wrapped,
fitted: bool,
}
#[derive(Default)]
pub(crate) struct Wrapped {
rows: Vec<Line<'static>>,
continues: Vec<Option<RowWrap>>,
marks: Marks,
}
#[derive(Default)]
pub(crate) struct Transcript {
agent: Option<Entity>,
width: u16,
look: u64,
messages: Vec<Cached>,
}
pub(crate) enum Part {
Message(usize),
Rows(Wrapped),
}
impl Part {
pub(crate) fn rows(
lines: &[Line<'static>],
marks: &[(TranscriptItem, Range<usize>)],
width: usize,
) -> Self {
Self::Rows(wrap_marked(lines, marks, width))
}
}
pub(crate) struct Visible {
pub(crate) rows: Vec<Line<'static>>,
pub(crate) below: Below,
pub(crate) first: usize,
pub(crate) top: Option<(TranscriptItem, usize)>,
}
impl Transcript {
pub(crate) fn update(
&mut self,
agent: Entity,
conversation: &conversation::Conversation,
changed: bool,
look: &Look<'_>,
width: u16,
) {
if self.agent != Some(agent) {
self.agent = Some(agent);
self.messages.clear();
}
let usable = usize::from(width.max(1));
let resized = self.width != width;
if resized {
self.width = width;
for cached in &mut self.messages {
cached.wrapped = wrap_marked(&cached.lines, &cached.line_marks, usable);
}
}
let refit = resized && self.messages.iter().any(|cached| cached.fitted);
let messages = conversation.messages();
let digest = look.digest();
let looks_alike = std::mem::replace(&mut self.look, digest) == digest;
if !changed && !refit && looks_alike && self.messages.len() == messages.len() {
return;
}
let same: Vec<bool> = (0..=messages.len())
.map(|at| match (self.messages.get(at), messages.get(at)) {
(Some(cached), Some(message)) => Arc::ptr_eq(&cached.message, message),
(cached, message) => cached.is_none() && message.is_none(),
})
.collect();
self.messages.truncate(messages.len());
for (index, message) in messages.iter().enumerate() {
let previous = index.checked_sub(1).and_then(|before| messages.get(before));
let (previous, next) = (
previous.map(|m| &**m),
messages.get(index + 1).map(|m| &**m),
);
let look_hash = look.hash_message((index, conversation.id(index)), message);
if self
.messages
.get(index)
.is_some_and(|cached| cached.look == look_hash && !(resized && cached.fitted))
&& (index.saturating_sub(1)..=index + 1).all(|at| same.get(at) == Some(&true))
{
continue;
}
let (lines, line_marks) = message_lines(
message,
(conversation, index),
(previous, next),
(look, usable),
);
let wrapped = wrap_marked(&lines, &line_marks, usable);
let cached = Cached {
message: message.clone(),
look: look_hash,
lines,
line_marks,
wrapped,
fitted: fitted(message),
};
match self.messages.get_mut(index) {
Some(slot) => *slot = cached,
None => self.messages.push(cached),
}
}
}
pub(crate) fn clear(&mut self) {
self.messages.clear();
}
fn wrapped<'a>(&'a self, part: &'a Part) -> Option<&'a Wrapped> {
match part {
Part::Message(index) => self.messages.get(*index).map(|cached| &cached.wrapped),
Part::Rows(wrapped) => Some(wrapped),
}
}
fn rows<'a>(&'a self, part: &'a Part) -> &'a [Line<'static>] {
let wrapped = self.wrapped(part);
wrapped
.map(|wrapped| wrapped.rows.as_slice())
.unwrap_or_default()
}
fn marks<'a>(&'a self, part: &'a Part) -> &'a [(TranscriptItem, Range<usize>)] {
let wrapped = self.wrapped(part);
wrapped
.map(|wrapped| wrapped.marks.as_slice())
.unwrap_or_default()
}
pub(crate) fn marks_in<'a>(
&'a self,
parts: &'a [Part],
) -> impl Iterator<Item = (&'a TranscriptItem, Range<usize>)> + 'a {
let mut start = 0;
parts.iter().flat_map(move |part| {
let at = start;
start += self.rows(part).len();
let marks = self.marks(part).iter();
marks.map(move |(item, rows)| (item, rows.start + at..rows.end + at))
})
}
pub(crate) fn rows_of(&self, parts: &[Part], item: &TranscriptItem) -> Option<Range<usize>> {
let mut marks = self.marks_in(parts);
marks
.find(|(marked, _)| *marked == item)
.map(|(_, rows)| rows)
}
pub(crate) fn visible(
&self,
parts: &[Part],
(width, height): (u16, usize),
scroll: &mut Scroll,
highlights: &[Highlight],
) -> Visible {
let counts: Vec<usize> = parts.iter().map(|part| self.rows(part).len()).collect();
let lights = self.lights(parts, highlights);
scroll.find(|item| self.rows_of(parts, item));
let (mut top, mut below) = scroll.place(&counts, height, width);
if let Some((item, to)) = scroll.jump.take() {
let row = self.rows_of(parts, &item).map(|rows| {
let lit = match to {
ItemRow::Lit(n) => self.lit_row(parts, &rows, &lights, n),
ItemRow::Top(_) | ItemRow::Reveal => None,
};
lit.unwrap_or(rows.start)
});
if let Some(row) = row.filter(|row| !(top..top + height).contains(row)) {
scroll.at = Some(row);
(top, below) = scroll.place(&counts, height, width);
}
}
let mut shown = Vec::with_capacity(height);
let mut start = 0;
for (part, count) in parts.iter().zip(&counts) {
let end = start + count;
if end > top && start < top + height {
let from = top.saturating_sub(start);
let to = (top + height - start).min(*count);
shown.extend(self.lit_rows(part, start, from..to, &lights));
}
start = end;
if start >= top + height {
break;
}
}
let at_top = scroll.held.is_some().then(|| {
let holding = self.marks_in(parts).filter(|(_, rows)| rows.contains(&top));
let item = holding.max_by_key(|(_, rows)| rows.start);
item.map(|(item, rows)| (item.clone(), top - rows.start))
});
Visible {
rows: shown,
below,
first: top,
top: at_top.flatten(),
}
}
pub(crate) fn laid_out(
&self,
agent: Option<Entity>,
parts: &[Part],
highlights: &[Highlight],
on_screen: Range<usize>,
) -> DrawnTranscript {
let items = self.marks_in(parts);
let items = items.map(|(item, rows)| (item.clone(), rows)).collect();
let lights = self.lights(parts, highlights);
let counts = if lights.any() {
lit(self.joined(parts), 0, &lights)
} else {
Vec::new()
};
let mut counts = counts.into_iter().map(|row| row.matches);
let rows = self.joined(parts).map(|(row, continues)| DrawnRow {
text: row.to_string(),
continues: continues.cloned(),
lit: counts.next().unwrap_or_default(),
});
DrawnTranscript::new(agent, rows.collect(), items, on_screen)
}
fn lights<'a>(&self, parts: &[Part], highlights: &'a [Highlight]) -> Lights<'a> {
let mut lights = Lights {
highlights,
placed: HashMap::new(),
};
if highlights
.iter()
.all(|highlight| highlight.spans.is_empty())
{
return lights;
}
let starts: Vec<usize> = self
.joined(parts)
.enumerate()
.filter(|(_, (_, joint))| joint.is_none())
.map(|(row, _)| row)
.collect();
let items: HashMap<&TranscriptItem, Range<usize>> = self.marks_in(parts).collect();
for (index, highlight) in highlights.iter().enumerate() {
for span in &highlight.spans {
let rows = match &span.item {
Some(item) => items.get(item).cloned(),
None => Some(0..usize::MAX),
};
let Some(rows) = rows else { continue };
let first = starts.partition_point(|&start| start < rows.start);
let line = starts
.get(first.saturating_add(span.line))
.filter(|row| rows.contains(row));
if let Some(&row) = line {
let placed = lights.placed.entry((index, row)).or_default();
placed.push(span.bytes.clone());
}
}
}
lights
}
fn lit_row(
&self,
parts: &[Part],
rows: &Range<usize>,
lights: &Lights<'_>,
n: usize,
) -> Option<usize> {
let drawn = self.joined(parts).skip(rows.start).take(rows.len());
let mut before = 0;
for (at, row) in rows.clone().zip(lit(drawn, rows.start, lights)) {
before += row.matches.iter().sum::<usize>();
if before > n {
return Some(at);
}
}
None
}
fn joined<'a>(
&'a self,
parts: &'a [Part],
) -> impl Iterator<Item = (&'a Line<'static>, Option<&'a RowWrap>)> + 'a {
let wrapped = parts.iter().filter_map(|part| self.wrapped(part));
let rows = wrapped.flat_map(|wrapped| wrapped.rows.iter().zip(&wrapped.continues));
rows.map(|(row, continues)| (row, continues.as_ref()))
}
fn lit_rows(
&self,
part: &Part,
start: usize,
range: Range<usize>,
lights: &Lights<'_>,
) -> Vec<Line<'static>> {
let Some(wrapped) = self.wrapped(part) else {
return Vec::new();
};
if !lights.any() {
return wrapped.rows.get(range).unwrap_or_default().to_vec();
}
let continues = |row: usize| wrapped.continues.get(row).is_some_and(Option::is_some);
let mut first = range.start;
while first > 0 && continues(first) {
first -= 1;
}
let mut end = range.end;
while continues(end) {
end += 1;
}
let rows = wrapped.rows.get(first..end).unwrap_or_default();
let joints = wrapped.continues.get(first..end).unwrap_or_default();
let lit = light(rows, joints, start + first, lights);
let shown = lit.into_iter().skip(range.start - first);
shown.take(range.len()).collect()
}
}
fn wrap_marked(
lines: &[Line<'static>],
marks: &[(TranscriptItem, Range<usize>)],
width: usize,
) -> Wrapped {
let mut rows = Vec::with_capacity(lines.len());
let mut continues = Vec::with_capacity(lines.len());
let mut starts = Vec::with_capacity(lines.len() + 1);
for line in lines {
starts.push(rows.len());
for (row, joint) in wrap(line, width) {
rows.push(row);
continues.push(joint);
}
}
starts.push(rows.len());
let row = |line: usize| starts.get(line).copied().unwrap_or(rows.len());
let marks = marks
.iter()
.map(|(item, lines)| (item.clone(), row(lines.start)..row(lines.end)))
.collect();
Wrapped {
rows,
continues,
marks,
}
}
#[derive(Default)]
pub(crate) struct Scroll {
held: Option<Held>,
moved: isize,
jump: Option<(TranscriptItem, ItemRow)>,
at: Option<usize>,
}
struct Held {
top: usize,
part: usize,
row: usize,
rows: usize,
width: u16,
total: usize,
grew: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Below {
#[default]
Nothing,
More,
New,
}
impl Scroll {
pub(crate) fn up(&mut self, rows: usize) {
self.moved = self
.moved
.saturating_add(isize::try_from(rows).unwrap_or(isize::MAX));
}
pub(crate) fn down(&mut self, rows: usize) {
self.moved = self
.moved
.saturating_sub(isize::try_from(rows).unwrap_or(isize::MAX));
}
pub(crate) fn follow(&mut self) {
*self = Self::default();
}
pub(crate) fn to(&mut self, item: TranscriptItem, row: ItemRow) {
self.jump = Some((item, row));
}
fn find(&mut self, rows_of: impl Fn(&TranscriptItem) -> Option<Range<usize>>) {
if let Some((item, ItemRow::Top(line))) = &self.jump {
self.at = rows_of(item).map(|rows| {
let last = rows.end.saturating_sub(1).max(rows.start);
(rows.start + line).min(last)
});
self.jump = None;
}
}
fn place(&mut self, counts: &[usize], height: usize, width: u16) -> (usize, Below) {
let total: usize = counts.iter().sum();
let bottom = total.saturating_sub(height);
let moved = std::mem::take(&mut self.moved);
let (top, grew) = match (self.at.take(), &self.held) {
(Some(at), _) => (at, false),
(None, None) => (bottom, false),
(None, Some(held)) if held.width == width => {
(held.top, held.grew || total > held.total)
}
(None, Some(held)) => {
let start: usize = counts.iter().take(held.part).sum();
let rows = counts.get(held.part).copied().unwrap_or(0);
let row = (held.row * rows / held.rows.max(1)).min(rows.saturating_sub(1));
(start + row, held.grew)
}
};
let top = top.saturating_add_signed(moved.saturating_neg());
if top >= bottom {
self.held = None;
return (bottom, Below::Nothing);
}
let (mut part, mut start) = (0, 0);
for (index, count) in counts.iter().enumerate() {
part = index;
if start + count > top {
break;
}
start += count;
}
self.held = Some(Held {
top,
part,
row: top - start,
rows: counts.get(part).copied().unwrap_or(0),
width,
total,
grew,
});
(top, if grew { Below::New } else { Below::More })
}
}
fn tool_results(message: Option<&Message>) -> impl Iterator<Item = &ToolResult> {
let content = match message {
Some(Message::User { content }) => Some(content.iter()),
_ => None,
};
content.into_iter().flatten().filter_map(|item| match item {
UserContent::ToolResult(result) => Some(result),
_ => None,
})
}
fn answers(message: Option<&Message>, result: &ToolResult) -> bool {
match message {
Some(Message::Assistant(assistant)) => assistant
.content
.iter()
.any(|item| matches!(item, AssistantContent::ToolCall(call) if call.id == result.call)),
_ => false,
}
}
fn message_lines(
message: &Message,
(conversation, at): (&conversation::Conversation, usize),
(previous, next): (Option<&Message>, Option<&Message>),
(look, width): (&Look<'_>, usize),
) -> (Vec<Line<'static>>, Marks) {
let mut lines = Vec::new();
let mut marks = Marks::new();
let id = conversation.id(at);
let author = conversation.author(at);
let stopped = conversation
.entries()
.nth(at)
.is_some_and(|entry| entry.is_stop_marker());
let inside = look.inside(at);
let inside_before = |item: usize, lines: &mut Vec<Line<'static>>, marks: &mut Marks| {
for (_, inside) in inside.iter().filter(|(before, _)| *before == item) {
match inside {
Inside::Notice(at, notice) => notice_lines((*at, notice), lines, marks),
Inside::Aside(shown) => {
let start = lines.len();
lines.push(Line::default());
lines.extend(on_band(
shown.lines(look.renderers, look.theme),
look.theme.tool,
));
marks.push((shown.item(), start..lines.len()));
}
}
}
};
match message {
Message::System { .. } => {}
Message::User { content } => {
for (item_at, item) in content.iter().enumerate() {
inside_before(item_at, &mut lines, &mut marks);
match item {
UserContent::Text(text) => {
if let Some((label, body)) = attached_file(&text.text) {
let count = body.lines().count();
let header = Line::from(format!(" [{label}, {count} lines]")).dim();
let body = text_rows(body, Style::new().dim());
lines.extend(on_band(
std::iter::once(header).chain(body),
look.theme.user,
));
continue;
}
if stopped {
let stop = Line::from(" [stopped before it was answered]").dim();
lines.extend(on_band([stop], look.theme.user));
continue;
}
lines.push(Line::default());
let other = |author: &&Author| {
author.kind != AuthorKind::User || author.title.is_some()
};
let results = content
.iter()
.any(|part| matches!(part, UserContent::ToolResult(_)));
match author.filter(other) {
Some(author) => lines.extend(on_band(
delivered_lines(author, &text.text, look),
look.theme.tool,
)),
None if results => lines.extend(on_band(
text_rows(&text.text, Style::new().dim()),
look.theme.tool,
)),
None => lines.extend(on_band(
MessageView {
role: Role::User,
message: id,
block: item_at,
text: &text.text,
marks: &[],
width,
theme: *look.theme,
}
.default_lines(),
look.theme.user,
)),
}
}
UserContent::ToolResult(result) if answers(previous, result) => {}
UserContent::ToolResult(result) => {
let style = result_style(result.is_error);
let rows = text_rows(&result_text(result), style);
lines.extend(on_band(rows, look.theme.tool));
}
other => {
if let Some(block) = ContentBlock::of_user(other) {
let view = (id, item_at, block, false);
content_lines(view, look, (&mut lines, &mut marks));
}
}
}
}
}
Message::Assistant(assistant) => {
for (item_at, item) in assistant.content.iter().enumerate() {
inside_before(item_at, &mut lines, &mut marks);
match item {
AssistantContent::Text(text) => {
lines.push(Line::default());
let marks =
id.map(|id| look.marks_in(MarkIn::Message { id, block: item_at }));
lines.extend(
MessageView {
role: Role::Assistant,
message: id,
block: item_at,
text: &text.text,
marks: &marks.unwrap_or_default(),
width,
theme: *look.theme,
}
.default_lines(),
);
}
AssistantContent::Reasoning(reasoning) => {
lines.extend(
MessageView {
role: Role::Reasoning,
message: id,
block: item_at,
text: &reasoning.text,
marks: &[],
width,
theme: *look.theme,
}
.default_lines(),
);
}
AssistantContent::ToolCall(call) => {
let result = tool_results(next).find(|result| result.call == call.id);
let (facts, took) = look.facts.of(&call.id);
let item = TranscriptItem::Call(call.id.clone());
let view = ToolCallView {
call,
result,
state: look.state(&call.id, result),
theme: *look.theme,
facts,
took,
output: look.outputs.get(&call.id).map(String::as_str),
children: look.children.get(&call.id).map_or(&[], Vec::as_slice),
};
let (drawn, decorated) = look.renderers.call_decorated(&view);
let start = lines.len();
lines.extend(on_band(drawn, look.theme.tool));
marks.push((item, start..lines.len()));
for (name, rows) in decorated {
let decoration = TranscriptItem::Decoration(call.id.clone(), name);
marks.push((decoration, start + rows.start..start + rows.end));
}
}
other => {
if let Some(block) = ContentBlock::of_assistant(other) {
let view = (id, item_at, block, true);
content_lines(view, look, (&mut lines, &mut marks));
}
}
}
}
}
}
if !matches!(message, Message::System { .. }) {
let decorated = DecoratedMessage {
message,
id,
author,
};
let (drawn, decorations) = look.renderers.message_decorations(&decorated);
let start = lines.len();
lines.extend(drawn);
for (name, rows) in decorations {
if let Some(id) = id {
let decoration = TranscriptItem::MessageDecoration(id, name);
marks.push((decoration, start + rows.start..start + rows.end));
}
}
}
if let Some(id) = id {
marks.insert(0, (TranscriptItem::Message(id), 0..lines.len()));
}
(lines, marks)
}
fn content_lines(
(id, index, block, assistant): (Option<MessageId>, usize, ContentBlock<'_>, bool),
look: &Look<'_>,
(lines, marks): (&mut Vec<Line<'static>>, &mut Marks),
) {
let view = ContentView {
message: id,
index,
block,
assistant,
};
let start = lines.len();
lines.extend(look.renderers.content(&view));
if let Some(id) = id {
marks.push((TranscriptItem::Content(id, index), start..lines.len()));
}
}
fn delivered_lines(author: &Author, text: &str, look: &Look<'_>) -> Vec<Line<'static>> {
let body = author
.header()
.and_then(|header| text.strip_prefix(&header))
.map_or(text, |body| body.trim_start_matches('\n'));
let label = author.label();
let view = EntryView {
plugin: plugin_name(author),
title: &label,
text: body,
};
look.renderers.entry(&view)
}
pub(crate) fn notice_lines(
(at, notice): (usize, &ShownNotice),
lines: &mut Vec<Line<'static>>,
marks: &mut Marks,
) {
let style = match notice.level {
NoticeLevel::Info => Style::new().fg(Color::Magenta),
NoticeLevel::Warn => Style::new().fg(Color::Yellow),
NoticeLevel::Error => Style::new().fg(Color::Red),
};
let start = lines.len();
lines.extend(plain_lines(¬ice.text, style));
marks.push((TranscriptItem::Notice(at), start..lines.len()));
}
pub(crate) fn on_band(
lines: impl IntoIterator<Item = Line<'static>>,
band: Color,
) -> impl Iterator<Item = Line<'static>> {
lines.into_iter().map(move |mut line| {
line.style.bg = line.style.bg.or(Some(band));
line
})
}
fn fitted(message: &Message) -> bool {
let Message::Assistant(assistant) = message else {
return false;
};
let texts = assistant.content.iter().filter_map(|item| match item {
AssistantContent::Text(text) => Some(text.text.as_str()),
_ => None,
});
texts.into_iter().any(markdown::has_table)
}
pub(crate) fn plain_lines(text: &str, style: Style) -> Vec<Line<'static>> {
text.lines()
.map(|line| Line::styled(line.replace('\t', " "), style))
.collect()
}
#[cfg(test)]
mod tests;