llm 1.3.8

A Rust library unifying multiple LLM backends.
Documentation
use super::theme::Theme;
use crate::conversation::{ConversationMessage, MessageId};
use crate::runtime::{CollapsibleState, ScrollState, TOOL_COLLAPSE_LINES};
use measure::wrapped_height;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame;
use std::collections::HashMap;
use text::render_message_text;
mod measure;
mod text;
#[derive(Default)]
pub struct MessageRenderer {
    cache: HashMap<MessageId, RenderCacheEntry>,
}

struct RenderCacheEntry {
    width: u16,
    version: u64,
    text: Text<'static>,
    height: u16,
    collapsed: bool,
}

impl MessageRenderer {
    fn render(
        &mut self,
        message: &ConversationMessage,
        width: u16,
        theme: &Theme,
        collapse: &CollapsibleState,
    ) -> &RenderCacheEntry {
        let entry = self.cache.entry(message.id);
        let collapsed = collapse_for(message, collapse);
        match entry {
            std::collections::hash_map::Entry::Occupied(mut occupied) => {
                if needs_update(occupied.get(), width, message.version, collapsed) {
                    let updated = build_entry(message, width, theme, collapse);
                    occupied.insert(updated);
                }
                occupied.into_mut()
            }
            std::collections::hash_map::Entry::Vacant(vacant) => {
                vacant.insert(build_entry(message, width, theme, collapse))
            }
        }
    }
}
pub struct MessageRenderProps<'a> {
    pub area: Rect,
    pub messages: &'a [ConversationMessage],
    pub theme: &'a Theme,
    pub scroll: ScrollState,
    pub selected: Option<MessageId>,
    pub collapse: &'a CollapsibleState,
}
pub fn render_messages(
    frame: &mut Frame<'_>,
    renderer: &mut MessageRenderer,
    props: MessageRenderProps<'_>,
) {
    let segments = collect_segments(renderer, &props);
    let total_height: u16 = segments.iter().map(|seg| seg.height).sum();
    let start_y = props
        .area
        .y
        .saturating_add(props.area.height.saturating_sub(total_height));
    let mut y = start_y;
    for segment in segments.into_iter().rev() {
        let area = Rect::new(props.area.x, y, props.area.width, segment.height);
        let mut paragraph = Paragraph::new(segment.text).wrap(Wrap { trim: false });
        paragraph = paragraph.scroll((segment.scroll, 0));
        if props.selected == Some(segment.id) {
            paragraph = paragraph.style(Style::default().add_modifier(Modifier::REVERSED));
        }
        frame.render_widget(paragraph, area);
        y = y.saturating_add(segment.height);
    }
}
struct Segment {
    id: MessageId,
    text: Text<'static>,
    height: u16,
    scroll: u16,
}
fn collect_segments(
    renderer: &mut MessageRenderer,
    props: &MessageRenderProps<'_>,
) -> Vec<Segment> {
    let total_height = total_message_height(
        renderer,
        props.messages,
        props.area.width,
        props.theme,
        props.collapse,
    );
    let mut state = SegmentState::new(props.area.height, props.scroll, total_height);
    let mut segments = Vec::new();
    for message in props.messages.iter().rev() {
        if state.is_full() {
            break;
        }
        if let Some(segment) = state.segment_for(
            message,
            renderer,
            props.theme,
            props.area.width,
            props.collapse,
        ) {
            segments.push(segment);
        }
    }
    segments
}
fn total_message_height(
    renderer: &mut MessageRenderer,
    messages: &[ConversationMessage],
    width: u16,
    theme: &Theme,
    collapse: &CollapsibleState,
) -> i32 {
    messages
        .iter()
        .map(|msg| renderer.render(msg, width, theme, collapse).height as i32)
        .sum::<i32>()
        .max(0)
}
struct SegmentState {
    remaining: i32,
    skip: i32,
}
impl SegmentState {
    fn new(area_height: u16, scroll: ScrollState, total_height: i32) -> Self {
        let remaining = area_height as i32;
        let max_offset = total_height.saturating_sub(remaining);
        let skip = (scroll.offset() as i32).min(max_offset);
        Self { remaining, skip }
    }
    fn is_full(&self) -> bool {
        self.remaining <= 0
    }
    fn segment_for(
        &mut self,
        message: &ConversationMessage,
        renderer: &mut MessageRenderer,
        theme: &Theme,
        width: u16,
        collapse: &CollapsibleState,
    ) -> Option<Segment> {
        let entry = renderer.render(message, width, theme, collapse);
        let height = entry.height as i32;
        if self.skip >= height {
            self.skip -= height;
            return None;
        }
        let available = height - self.skip;
        let take = available.min(self.remaining);
        let scroll_top = (available - take) as u16;
        self.remaining -= take;
        self.skip = 0;
        Some(Segment {
            id: message.id,
            text: entry.text.clone(),
            height: take as u16,
            scroll: scroll_top,
        })
    }
}
fn needs_update(entry: &RenderCacheEntry, width: u16, version: u64, collapsed: bool) -> bool {
    entry.width != width || entry.version != version || entry.collapsed != collapsed
}
fn build_entry(
    message: &ConversationMessage,
    width: u16,
    theme: &Theme,
    collapse: &CollapsibleState,
) -> RenderCacheEntry {
    let collapsed = collapse_for(message, collapse);
    let text = render_message_text(message, theme, collapsed);
    let height = wrapped_height(&text, width);
    RenderCacheEntry {
        width,
        version: message.version,
        text,
        height: height.max(1),
        collapsed,
    }
}
fn collapse_for(message: &ConversationMessage, collapse: &CollapsibleState) -> bool {
    let crate::conversation::MessageKind::ToolResult(result) = &message.kind else {
        return false;
    };
    if result.output.lines().count() <= TOOL_COLLAPSE_LINES {
        return false;
    }
    !collapse.is_expanded(message.id)
}