mod render;
mod state;
pub mod types;
use types::MessageSource;
pub use types::{ConversationMessage, ConversationRole, MessageBlock, MessageHandle};
use std::collections::{HashMap, HashSet};
use std::marker::PhantomData;
use ratatui::prelude::*;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
impl MessageSource for ConversationViewState {
fn source_messages(&self) -> &[ConversationMessage] {
&self.messages
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ConversationViewMessage {
ScrollUp,
ScrollDown,
ScrollToTop,
ScrollToBottom,
PageUp,
PageDown,
ToggleCollapse(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ConversationViewOutput {
ScrollChanged {
offset: usize,
},
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ConversationViewState {
pub(super) messages: Vec<ConversationMessage>,
pub(super) scroll: ScrollState,
pub(super) auto_scroll: bool,
pub(super) max_messages: usize,
pub(super) show_timestamps: bool,
pub(super) show_role_labels: bool,
pub(super) markdown_enabled: bool,
pub(super) last_known_width: usize,
pub(super) title: Option<String>,
pub(super) collapsed_blocks: HashSet<String>,
pub(super) status: Option<String>,
#[cfg_attr(feature = "serialization", serde(skip, default))]
pub(super) next_id: u64,
#[cfg_attr(feature = "serialization", serde(skip, default))]
pub(super) role_style_overrides: HashMap<ConversationRole, Style>,
}
impl Default for ConversationViewState {
fn default() -> Self {
Self {
messages: Vec::new(),
scroll: ScrollState::default(),
auto_scroll: true,
max_messages: 1000,
show_timestamps: false,
show_role_labels: true,
markdown_enabled: false,
last_known_width: 80,
title: None,
collapsed_blocks: HashSet::new(),
status: None,
next_id: 1,
role_style_overrides: HashMap::new(),
}
}
}
impl PartialEq for ConversationViewState {
fn eq(&self, other: &Self) -> bool {
self.messages == other.messages
&& self.scroll == other.scroll
&& self.auto_scroll == other.auto_scroll
&& self.max_messages == other.max_messages
&& self.show_timestamps == other.show_timestamps
&& self.show_role_labels == other.show_role_labels
&& self.title == other.title
&& self.collapsed_blocks == other.collapsed_blocks
&& self.status == other.status
&& self.role_style_overrides == other.role_style_overrides
}
}
pub struct ConversationView(PhantomData<()>);
impl Component for ConversationView {
type State = ConversationViewState;
type Message = ConversationViewMessage;
type Output = ConversationViewOutput;
fn init() -> Self::State {
ConversationViewState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
match key.code {
Key::Up | Key::Char('k') => Some(ConversationViewMessage::ScrollUp),
Key::Down | Key::Char('j') => Some(ConversationViewMessage::ScrollDown),
Key::Char('g') if key.modifiers.shift() => {
Some(ConversationViewMessage::ScrollToBottom)
}
Key::Home | Key::Char('g') => Some(ConversationViewMessage::ScrollToTop),
Key::End => Some(ConversationViewMessage::ScrollToBottom),
Key::PageUp => Some(ConversationViewMessage::PageUp),
Key::PageDown => Some(ConversationViewMessage::PageDown),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
ConversationViewMessage::ScrollUp => {
state.update_scroll_content_length();
state.scroll.scroll_up();
state.auto_scroll = false;
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::ScrollDown => {
state.update_scroll_content_length();
state.scroll.scroll_down();
if state.scroll.at_end() {
state.auto_scroll = true;
}
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::ScrollToTop => {
state.update_scroll_content_length();
state.scroll.scroll_to_start();
state.auto_scroll = false;
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::ScrollToBottom => {
state.update_scroll_content_length();
state.scroll.scroll_to_end();
state.auto_scroll = true;
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::PageUp => {
state.update_scroll_content_length();
let page_size = state.scroll.viewport_height().max(1);
state.scroll.page_up(page_size);
state.auto_scroll = false;
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::PageDown => {
state.update_scroll_content_length();
let page_size = state.scroll.viewport_height().max(1);
state.scroll.page_down(page_size);
if state.scroll.at_end() {
state.auto_scroll = true;
}
Some(ConversationViewOutput::ScrollChanged {
offset: state.scroll.offset(),
})
}
ConversationViewMessage::ToggleCollapse(key) => {
state.toggle_collapse(&key);
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height < 3 || ctx.area.width < 5 {
return;
}
crate::annotation::with_registry(|reg| {
reg.open(
ctx.area,
crate::annotation::Annotation::container("conversation_view")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
render::render(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
crate::annotation::with_registry(|reg| {
reg.close();
});
}
}
#[cfg(test)]
mod render_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;