use std::{
cmp::max,
iter::{self},
};
use directories::ProjectDirs;
use lazy_static::lazy_static;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, StatefulWidget, Widget},
};
use rig::message::{ProviderCallId, ToolCallId};
use syntect::{
easy::HighlightLines,
highlighting::{FontStyle, Theme, ThemeSet},
parsing::SyntaxSet,
};
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::{
config::ViewMode,
core::conversation::{
Block, ConversationItem, ConversationItemKind, Document, Inline, ListItem,
ListItemMarker::{self, Bullet},
MessageState, NotificationType, TableCell, TableRow, ToolInvocationContent,
ToolOutputContent,
},
};
type SyntectStyle = syntect::highlighting::Style;
const PAGE_SIZE: u64 = 40;
lazy_static! {
static ref GUTTER_WIDTH: u16 = UnicodeWidthStr::width("▌ ") as u16;
}
const LIGHT_BLUE: Color = Color::Rgb(0, 122, 204);
const PURPLE: Color = Color::Rgb(197, 134, 192);
const LIGHT_GRAY: Color = Color::Rgb(205, 205, 205);
const DARK_GRAY2: Color = Color::Rgb(139, 148, 158);
const DARK_GRAY3: Color = Color::Rgb(40, 40, 40);
const NEAR_BLACK: Color = Color::Rgb(20, 20, 20);
const NEAR_BLACK2: Color = Color::Rgb(30, 30, 30);
const METALLIC_BLUE: Color = Color::Rgb(101, 154, 211);
const LIGHT_TOURQUOISE: Color = Color::Rgb(168, 218, 216);
const TOURQUOISE: Color = Color::Rgb(82, 197, 176);
const DARK_PURPLE: Color = Color::Rgb(97, 40, 119);
const DARK_BLUE: Color = Color::Rgb(20, 56, 92);
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ScrollingPosition {
#[default]
Auto,
Fixed(u64),
}
#[derive(Clone, Debug)]
struct TextStyle {
fg_color: Color,
bg_color: Color,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
}
impl Default for TextStyle {
fn default() -> Self {
Self {
fg_color: LIGHT_GRAY,
bg_color: NEAR_BLACK2,
bold: false,
italic: false,
underline: false,
strikethrough: false,
}
}
}
impl From<TextStyle> for Style {
fn from(val: TextStyle) -> Self {
let mut style = Style::new().fg(val.fg_color).bg(val.bg_color);
style = if val.bold { style.bold() } else { style };
style = if val.italic { style.italic() } else { style };
style = if val.underline {
style.underlined()
} else {
style
};
style = if val.strikethrough {
style.crossed_out()
} else {
style
};
style
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedTableCell {
content: Vec<Line<'static>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedTableRow {
content: Vec<RenderedTableCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedTable {
content: Vec<RenderedTableRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InlineFragment {
Text(Span<'static>),
LineBreak,
}
#[derive(Clone, Debug, Default)]
pub struct HighlightConfig {
syntax_set: SyntaxSet,
theme: Theme,
}
impl HighlightConfig {
pub fn new() -> Self {
let syn_set = SyntaxSet::load_defaults_newlines();
let mut theme_set = ThemeSet::load_defaults();
if let Some(proj_dirs) = ProjectDirs::from("org", "codemonk", "monothink") {
let mut share_dir = proj_dirs.data_dir().to_path_buf();
share_dir.push("themes");
let share_path = share_dir.as_path();
theme_set
.add_from_folder(share_path)
.expect("Failed to load theme folder.");
}
let theme = theme_set.themes.get("darkplus").unwrap_or_else(|| {
theme_set
.themes
.get("base16-ocean.dark")
.expect("Cannot load default theme")
});
Self {
syntax_set: syn_set,
theme: theme.clone(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SymbolConfig {
pub assistant_message: String,
pub assistant_reasoning: String,
pub tool_message: String,
pub message_state_final: String,
pub message_state_intermediate: String,
pub message_notification_debug: String,
pub message_notification_error: String,
pub message_notification_info: String,
pub message_notification_warning: String,
pub status_failed: String,
pub status_success: String,
pub system_message: String,
pub user_message: String,
}
#[derive(Clone, Debug, Default)]
pub struct ConversationViewModel {
conversation_view: Vec<Line<'static>>,
pub conversation_items: Vec<ConversationItem>,
dirty: bool,
height: Option<u64>,
scrolling_position: ScrollingPosition,
highlight_cfg: HighlightConfig,
symbol_cfg: SymbolConfig,
view_mode: ViewMode,
}
#[derive(Debug, Clone, Default)]
pub struct ConversationView;
impl StatefulWidget for ConversationView {
type State = ConversationViewModel;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
state.height = Some(area.height as u64);
let height = state
.height
.expect("Height must be set after assignment above");
if state.dirty {
state.conversation_view = Self::render_conversation(
&state.conversation_items,
area.width,
&state.highlight_cfg,
&state.symbol_cfg,
state.view_mode,
);
state.dirty = false;
}
let total_lines = state.conversation_view.len();
let scroll_offset = state.scroll_offset(height, total_lines);
if total_lines == 0 {
return;
}
let viewport_start = scroll_offset as usize;
let viewport_end = std::cmp::min(viewport_start + height as usize, total_lines);
let visible_lines = state.conversation_view[viewport_start..viewport_end].to_vec();
Paragraph::new(visible_lines)
.style(TextStyle::default())
.render(area, buf);
}
}
impl ConversationViewModel {
pub fn new(highlight_cfg: HighlightConfig, symbol_cfg: SymbolConfig) -> Self {
Self {
highlight_cfg,
symbol_cfg,
..Default::default()
}
}
pub fn current_line(&self) -> u64 {
if let Some(height) = self.height {
self.scroll_offset(height, self.conversation_view.len())
} else {
0
}
}
pub fn total_lines(&self) -> u64 {
self.conversation_view.len() as u64
}
pub fn upsert_item(&mut self, item: ConversationItem) {
self.dirty = true;
let maybe_outdated_item = self
.conversation_items
.iter_mut()
.rev()
.find(|it| it.id == item.id);
if let Some(outdated_item) = maybe_outdated_item {
outdated_item.content = item.content;
} else {
self.conversation_items.push(item);
}
}
pub fn move_line_up(&mut self) {
self.move_up(1);
}
pub fn move_line_down(&mut self) {
self.move_down(1)
}
pub fn move_page_up(&mut self) {
self.move_up(PAGE_SIZE);
}
pub fn move_page_down(&mut self) {
self.move_down(PAGE_SIZE)
}
pub fn set_view_mode(&mut self, view_mode: ViewMode) {
self.dirty = true;
self.view_mode = view_mode;
}
fn move_down(&mut self, step: u64) {
if !self.can_scroll() {
return;
}
let total_lines = self.conversation_view.len() as u64;
if let Some(height) = self.height {
let max_offset = total_lines.saturating_sub(height);
let new_offset = self
.scroll_offset(height, total_lines as usize)
.saturating_add(step);
if new_offset < max_offset {
self.scrolling_position = ScrollingPosition::Fixed(new_offset);
} else {
self.scrolling_position = ScrollingPosition::Auto;
}
}
}
fn move_up(&mut self, step: u64) {
let p = self.scroll_offset(self.height.unwrap_or(0), self.conversation_view.len());
if p >= step {
self.scrolling_position = ScrollingPosition::Fixed(p - step);
} else {
self.scrolling_position = ScrollingPosition::Fixed(0);
}
}
fn can_scroll(&self) -> bool {
if let Some(height) = self.height {
(height as usize) < self.conversation_view.len()
} else {
false
}
}
fn scroll_offset(&self, viewport_height: u64, total_lines: usize) -> u64 {
let total_lines = total_lines as u64;
if total_lines <= viewport_height || viewport_height == 0 {
return 0;
}
match self.scrolling_position {
ScrollingPosition::Auto => {
total_lines.saturating_sub(viewport_height)
}
ScrollingPosition::Fixed(offset) => {
std::cmp::min(offset, total_lines.saturating_sub(viewport_height))
}
}
}
}
impl ConversationView {
pub fn render_conversation(
items: &[ConversationItem],
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
view_mode: ViewMode,
) -> Vec<Line<'static>> {
match view_mode {
ViewMode::Debug => items
.iter()
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect(),
ViewMode::Full => items
.iter()
.filter(|item| !item.is_notification(Some(NotificationType::Debug)))
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect(),
ViewMode::Detailed => {
let mut output = Vec::new();
for interaction in Self::interactions(items) {
let (user_messages, mut tool_invocations, mut notifications) =
Self::render_interaction_parts(
&interaction,
width,
highlight_cfg,
symbols_cfg,
);
let assistant_output: Vec<_> = interaction
.iter()
.filter(|msg| msg.is_reasoning() || msg.is_assistant_message(None))
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect();
Self::conditionally_add_separator(&mut tool_invocations);
Self::conditionally_add_separator(&mut notifications);
output.extend(user_messages);
output.extend(tool_invocations);
output.extend(notifications);
output.extend(assistant_output);
}
output
}
ViewMode::Compact => {
let mut output = Vec::new();
for interaction in Self::interactions(items) {
let (user_messages, mut tool_invocations, mut notifications) =
Self::render_interaction_parts(
&interaction,
width,
highlight_cfg,
symbols_cfg,
);
let assistant_output = Self::render_final_or_intermediate_assistant(
width,
highlight_cfg,
symbols_cfg,
&interaction,
);
Self::conditionally_add_separator(&mut tool_invocations);
Self::conditionally_add_separator(&mut notifications);
output.extend(user_messages);
output.extend(tool_invocations);
output.extend(notifications);
output.extend(assistant_output);
}
output
}
ViewMode::Minimal => {
let mut output = Vec::new();
for interaction in Self::interactions(items) {
let user_messages = interaction
.iter()
.filter(|item| item.is_user_message())
.flat_map(|item| {
Self::render_item(item, width, highlight_cfg, symbols_cfg)
});
let assistant_output = Self::render_final_or_intermediate_assistant(
width,
highlight_cfg,
symbols_cfg,
&interaction,
);
output.extend(user_messages.chain(assistant_output));
}
output
}
}
}
pub fn render_item(
item: &ConversationItem,
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
let mut lines = match &item.content {
ConversationItemKind::UserMessage(msg) => {
Self::render_user(msg.content(), width, highlight_cfg, symbols_cfg)
}
ConversationItemKind::AssistantMessage(msg, kind) => {
Self::render_assistant(msg.content(), width, highlight_cfg, symbols_cfg, kind)
}
ConversationItemKind::AssistantReasoning(msg) => {
Self::render_reasoning(msg.content(), width, highlight_cfg, symbols_cfg)
}
ConversationItemKind::Notification(notification_type, content) => {
Self::render_notification(notification_type.to_owned(), content, width, symbols_cfg)
}
ConversationItemKind::ToolInvocation(ToolInvocationContent {
name,
arguments,
call_id,
provider_id,
}) => Self::render_tool_invocation(name, arguments, call_id, provider_id, symbols_cfg),
ConversationItemKind::ToolOutput(ToolOutputContent {
name,
output,
success,
call_id,
provider_id,
}) => Self::render_tool_result(
name,
output,
*success,
call_id,
provider_id,
width,
symbols_cfg,
),
};
Self::clip_lines(width, &mut lines);
lines.push(Line::raw(""));
lines
}
fn render_assistant(
msg: &Document,
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
kind: &MessageState,
) -> Vec<Line<'static>> {
let kind_icon = match kind {
MessageState::Intermediate => symbols_cfg.message_state_intermediate.clone(),
MessageState::Final => symbols_cfg.message_state_final.clone(),
};
Self::render_message(
|w| {
let mut lines = Vec::new();
for block in &msg.blocks {
lines.extend(Self::render_block(
block,
w,
TextStyle::default(),
highlight_cfg,
));
}
lines
},
LIGHT_BLUE,
DARK_BLUE,
width,
&format!(
"{} Assistant ({})",
symbols_cfg.assistant_message, kind_icon
),
)
}
fn render_reasoning(
msg: &Document,
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
Self::render_message(
|w| {
let mut lines = Vec::new();
for block in &msg.blocks {
lines.extend(Self::render_block(
block,
w,
TextStyle::default(),
highlight_cfg,
));
}
lines
},
LIGHT_TOURQUOISE,
TOURQUOISE,
width,
&format!("{} Reasoning", symbols_cfg.assistant_reasoning),
)
}
fn render_user(
msg: &Document,
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
Self::render_message(
|w| {
let mut lines = Vec::new();
for block in &msg.blocks {
lines.extend(Self::render_block(
block,
w,
TextStyle::default(),
highlight_cfg,
));
}
lines
},
PURPLE,
DARK_PURPLE,
width,
&format!("{} You", symbols_cfg.user_message),
)
}
fn render_block(
block: &Block,
width: u16,
style: TextStyle,
highlight_cfg: &HighlightConfig,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
match block {
Block::Code {
lang,
content,
copy_id,
} => lines.extend(Self::render_code(
content,
lang,
*copy_id,
width,
style.clone(),
highlight_cfg,
)),
Block::Heading { depth, content } => {
lines.extend(Self::render_heading(content, *depth, width, style.clone()))
}
Block::List { content } => lines.extend(Self::render_list(
content,
width,
style.clone(),
highlight_cfg,
)),
Block::Paragraph(inlines) => {
lines.extend(Self::render_paragraph(width, inlines, style.clone()))
}
Block::Quote(blocks) => lines.extend(Self::render_quote(
blocks,
width,
style.clone(),
highlight_cfg,
)),
Block::Table { content } => {
lines.extend(Self::render_table(content, width, style.clone()))
}
Block::ThematicBreak => {
let delim_style = TextStyle {
fg_color: DARK_GRAY2,
..style
};
lines.push(Line::styled("────────────────", delim_style))
}
Block::Unsupported(s) => {
let warning_style = TextStyle {
fg_color: Color::Red,
..style
};
lines.push(Line::from(Span::styled(
format!("Warning: Unsupported block in markdown: {s}"),
warning_style,
)))
}
}
lines.push(Line::raw(" "));
lines
}
fn render_final_or_intermediate_assistant(
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
interaction: &[ConversationItem],
) -> Vec<Line<'static>> {
let assistant_output: Vec<_> = if interaction
.iter()
.any(|item| item.is_assistant_message(Some(MessageState::Final)))
{
interaction
.iter()
.filter(|msg| msg.is_assistant_message(Some(MessageState::Final)))
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect()
} else {
interaction
.iter()
.filter(|msg| {
if let ConversationItemKind::AssistantMessage(msg_content, _) = &msg.content {
!msg_content.raw().trim().is_empty()
} else {
false
}
})
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect()
};
assistant_output
}
fn render_inline(inline: &Inline, style: TextStyle) -> Vec<InlineFragment> {
let mut fragments = Vec::new();
match inline {
Inline::Code(code) => {
let mut s = style.clone();
s.bg_color = NEAR_BLACK;
fragments.push(InlineFragment::Text(Span::styled(code.to_owned(), s)))
}
Inline::Emphasis(inlines) => fragments.extend(
inlines
.iter()
.flat_map(|inline| {
let mut s = style.clone();
s.italic = true;
Self::render_inline(inline, s)
})
.collect::<Vec<InlineFragment>>(),
),
Inline::StrongEmphasis(inlines) => fragments.extend(
inlines
.iter()
.flat_map(|inline| {
let mut s = style.clone();
s.bold = true;
Self::render_inline(inline, s)
})
.collect::<Vec<InlineFragment>>(),
),
Inline::Link { url, title, label } => fragments.push(InlineFragment::Text(
Span::styled(url.to_owned(), Style::from(style)),
)),
Inline::Image(_) => {}
Inline::LineBreak => fragments.push(InlineFragment::LineBreak),
Inline::Text(text) => fragments.push(InlineFragment::Text(Span::styled(
text.to_owned(),
Style::from(style),
))),
Inline::Unsupported(_) => {
}
}
fragments
}
fn render_interaction_parts(
interaction: &[ConversationItem],
width: u16,
highlight_cfg: &HighlightConfig,
symbols_cfg: &SymbolConfig,
) -> (Vec<Line<'static>>, Vec<Line<'static>>, Vec<Line<'static>>) {
let user_messages = interaction
.iter()
.filter(|item| item.is_user_message())
.flat_map(|item| Self::render_item(item, width, highlight_cfg, symbols_cfg))
.collect();
let tool_invocations =
Self::render_tool_invocations(&tool_invocations(interaction), width, symbols_cfg);
let notifications = Self::render_notifications(
interaction.iter().filter(|msg| {
msg.is_notification(None) && !msg.is_notification(Some(NotificationType::Debug))
}),
symbols_cfg,
);
(user_messages, tool_invocations, notifications)
}
fn render_message<T: FnOnce(u16) -> Vec<Line<'static>>>(
body: T,
text_color: Color,
gutter_color: Color,
width: u16,
header: &str,
) -> Vec<Line<'static>> {
let mut lines = vec![Self::render_header(header, text_color), Line::raw(" ")];
lines.extend(body(width.saturating_sub(*GUTTER_WIDTH)));
Self::add_gutter(lines, gutter_color, None)
}
fn add_gutter(
lines: Vec<Line<'static>>,
color: Color,
bg_color: Option<Color>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let mut style = Style::default().fg(color);
style.bg = bg_color;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled("▌ ", style));
spans.extend(line.spans);
Line::from(spans)
})
.collect()
}
fn clip_lines(width: u16, lines: &mut Vec<Line<'_>>) {
for line in lines {
if line.width() > width as usize {
let mut total_width = 0;
let mut new_spans = Vec::new();
for span in &mut line.spans {
let span_width = span.width();
if total_width + span_width <= width as usize {
total_width += span_width;
new_spans.push(span.clone());
} else if total_width < width as usize {
let new_text: String = span
.content
.chars()
.take_while(|c| {
total_width += c.width().unwrap_or_default();
total_width <= width as usize
})
.collect();
span.content = new_text.into();
new_spans.push(span.clone());
break;
} else {
break;
}
}
line.spans = new_spans;
}
}
}
fn conditionally_add_separator(lines: &mut Vec<Line<'_>>) {
if !lines.is_empty() {
lines.push(Line::raw(" "));
}
}
fn render_header(label: &str, color: Color) -> Line<'static> {
Line::from(vec![Span::styled(
label.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)])
}
fn render_tool_invocation(
name: &str,
args: &serde_json::Value,
_call_id: &ToolCallId,
_provider_id: &Option<ProviderCallId>,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
let lines = vec![
Self::render_header(
&format!("{} Tool: {}", symbols_cfg.tool_message, name),
Color::Rgb(220, 220, 170),
),
Line::raw(" "),
Line::from(Span::styled(
format!("> {}", args),
Style::default().fg(Color::DarkGray),
)),
];
Self::add_gutter(lines, Color::Yellow, None)
}
fn render_tool_invocations(
invocations: &[(ToolInvocationContent, ToolOutputContent)],
width: u16,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
if !invocations.is_empty() {
let mut lines = vec![
Self::render_header(
&format!("{} Tools", symbols_cfg.tool_message),
Color::Rgb(220, 220, 170),
),
Line::raw(" "),
];
let mut rows = vec![TableRow {
content: vec![
TableCell {
content: vec![Inline::Text("Invocation".to_string())],
},
TableCell {
content: vec![Inline::Text("Result".to_string())],
},
],
}];
for (invocation, result) in invocations {
let success_icon = if result.success {
&symbols_cfg.status_success
} else {
&symbols_cfg.status_failed
};
rows.push(TableRow {
content: vec![
TableCell {
content: vec![Inline::Text(format!(
" {}: {}",
invocation.name, invocation.arguments
))],
},
TableCell {
content: vec![Inline::Text(success_icon.to_string())],
},
],
});
}
lines.extend(Self::render_table(&rows, width, TextStyle::default()));
Self::add_gutter(lines, Color::Yellow, None)
} else {
Vec::new()
}
}
fn render_tool_result(
name: &str,
output: &str,
success: bool,
_call_id: &ToolCallId,
_provider_id: &Option<ProviderCallId>,
width: u16,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
let mut lines = vec![
Self::render_header(
&format!("{} Tool Output", symbols_cfg.tool_message),
Color::Rgb(220, 220, 170),
),
Line::raw(" "),
];
for line in output.lines() {
lines.push(Line::from(Span::raw(line.to_string())));
}
Self::add_gutter(lines, Color::Yellow, None)
}
fn render_notification(
notification_type: NotificationType,
msg: &str,
width: u16,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>> {
let lines = vec![
Self::render_header(
&format!("{} System", symbols_cfg.system_message),
Color::Green,
),
Line::raw(format!(
" {}",
notification_icon(notification_type, symbols_cfg)
)),
Line::from(Span::raw(msg.to_string())),
];
Self::add_gutter(lines, Color::Rgb(62, 143, 126), None)
}
fn render_notifications<'a, I>(
notifications: I,
symbols_cfg: &SymbolConfig,
) -> Vec<Line<'static>>
where
I: Iterator<Item = &'a ConversationItem>,
{
let mut is_empty = true;
let mut lines = vec![Self::render_header(
&format!("{} System", symbols_cfg.system_message),
Color::Green,
)];
for notification in notifications {
if let ConversationItemKind::Notification(notification_type, msg) =
¬ification.content
{
let spans = vec![
Span::raw(format!(
" {} ",
notification_icon(*notification_type, symbols_cfg)
)),
Span::raw(msg.to_string()),
];
lines.push(Line::from(spans));
is_empty = false;
}
}
if !is_empty { lines } else { Vec::new() }
}
fn wrap_lines(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
lines
.into_iter()
.flat_map(|line| Self::wrap_line(line, width, 0))
.collect()
}
fn wrap_line(line: Line<'static>, width: u16, position: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut position = position;
current_line.clear();
for span in line.spans {
let mut current_span = Some(span);
while let Some(s) = current_span {
match Self::wrap_span(s, width, position) {
(Some(span), None) => {
let increment = span.content.width();
current_line.push(span);
position += increment;
current_span = None; }
(Some(span1), Some(span2)) => {
current_line.push(span1);
lines.push(Line::from(current_line.clone()));
current_line.clear();
position = 0;
current_span = Some(span2);
}
(None, Some(span)) => {
lines.push(Line::from(current_line.clone()));
current_line.clear();
position = 0;
current_span = Some(span);
}
(None, None) => {
current_span = None;
}
}
}
}
if !current_line.is_empty() {
lines.push(Line::from(current_line));
}
lines
}
fn wrap_span(
span: Span<'static>,
width: u16,
position: usize,
) -> (Option<Span<'static>>, Option<Span<'static>>) {
let width: usize = width.into();
let style = span.style;
let mut pos = position;
let mut first = Vec::new();
let mut second = Vec::new();
for chunk in span.content.split_inclusive(char::is_whitespace) {
let w = UnicodeWidthStr::width(chunk);
pos += w;
if pos <= width {
first.push(chunk);
} else {
second.push(chunk);
}
}
if let Some(w) = second.first()
&& UnicodeWidthStr::width(*w) >= width
{
let first_width = first.iter().fold(0, |count, text| count + text.width());
let target_display_width = width.saturating_sub(first_width);
let mut current_width = 0;
let mut split_char_index = 0;
for (byte_idx, ch) in w.char_indices() {
let ch_width = ch.width().unwrap_or_default();
if current_width + ch_width > target_display_width {
break;
}
current_width += ch_width;
split_char_index = byte_idx + ch.len_utf8();
}
let (w1, w2) = w.split_at(split_char_index);
first.push(w1);
second.insert(0, w2);
}
let res1 = if first.is_empty() {
None
} else {
Some(Span::from(first.join("")).style(style))
};
let res2 = if second.is_empty() {
None
} else {
Some(Span::from(second.join("")).style(style))
};
(res1, res2)
}
fn render_heading(
content: &[Inline],
depth: u8,
width: u16,
style: TextStyle,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let marker = match depth {
0 => "█ ",
1 => "▊ ",
2 => "▌ ",
_ => "▎ ",
};
let heading_style = TextStyle {
bold: true,
fg_color: METALLIC_BLUE,
..style
};
let mut fragments = Vec::new();
for inline in content {
fragments.extend(Self::render_inline(inline, heading_style.clone()));
}
let mut content_lines = Self::wrap_lines(Self::fragments_to_lines(&fragments), width);
if let Some(first) = content_lines.get_mut(0) {
first
.spans
.insert(0, Span::styled(marker, heading_style.clone()));
}
for line in content_lines.iter_mut().skip(1) {
line.spans.insert(
0,
Span::styled(" ".repeat(marker.width()), heading_style.clone()),
);
}
if let Some(last) = content_lines.last_mut() {
let delim_style = TextStyle {
fg_color: DARK_GRAY2,
..style
};
last.push_span(Span::styled(
" ".to_owned() + &"─".repeat(7),
delim_style,
));
}
lines.extend(content_lines);
lines
}
fn render_list(
content: &[ListItem],
width: u16,
style: TextStyle,
highlight_cfg: &HighlightConfig,
) -> Vec<Line<'static>> {
let marker_indend = content.iter().fold(2, |current, item| {
let candidate = match item.marker {
ListItemMarker::Bullet => 2, ListItemMarker::Checkbox(_) => 2, ListItemMarker::Number(index) => index.to_string().len() + 2,
};
max(current, candidate)
});
content
.iter()
.flat_map(|item| {
Self::render_list_item(item, width, style.clone(), highlight_cfg, marker_indend)
})
.collect()
}
fn render_list_item(
item: &ListItem,
width: u16,
style: TextStyle,
highlight_cfg: &HighlightConfig,
marker_indend: usize,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = item
.content
.iter()
.flat_map(|block| {
Self::render_block(
block,
width.saturating_sub(marker_indend as u16),
style.clone(),
highlight_cfg,
)
})
.collect();
if let Some(first_line) = lines.first_mut() {
let marker = match item.marker {
Bullet => {
let marker = "• ".to_string();
Span::styled(
format!(
"{}{}",
marker,
" ".repeat(marker_indend.saturating_sub(marker.width()))
),
style.clone(),
)
}
ListItemMarker::Checkbox(checked) => {
let marker = (if checked { "☑ " } else { "☐ " }).to_string();
Span::styled(
format!(
"{}{}",
marker,
" ".repeat(marker_indend.saturating_sub(marker.width()))
),
style.clone(),
)
}
ListItemMarker::Number(n) => Span::styled(
format!(
"{}. {}",
n,
" ".repeat(marker_indend.saturating_sub(n as usize + 1))
),
style.clone(),
),
};
first_line.spans.insert(0, marker);
}
for line in &mut lines.iter_mut().skip(1) {
line.spans
.insert(0, Span::styled(" ".repeat(marker_indend), style.clone()));
}
lines
}
fn render_paragraph(width: u16, inlines: &[Inline], style: TextStyle) -> Vec<Line<'static>> {
let mut lines = Self::fragments_to_lines(
&inlines
.iter()
.flat_map(|inline| Self::render_inline(inline, style.clone()))
.collect::<Vec<InlineFragment>>(),
);
lines = Self::wrap_lines(lines, width);
lines
}
fn render_quote(
content: &[Block],
width: u16,
style: TextStyle,
highlight_cfg: &HighlightConfig,
) -> Vec<Line<'static>> {
let lines = content
.iter()
.flat_map(|block| {
Self::render_block(
block,
width.saturating_sub(*GUTTER_WIDTH),
style.clone(),
highlight_cfg,
)
})
.collect();
Self::add_gutter(lines, DARK_GRAY2, None)
}
fn render_table(content: &[TableRow], width: u16, style: TextStyle) -> Vec<Line<'static>> {
let separator_prefill = 1;
let separator_postfill = 1;
let separator = format!(
"{}│{}",
" ".repeat(separator_prefill),
" ".repeat(separator_postfill)
);
let indend = " ";
let num_columns = content
.iter()
.fold(0, |count, row| max(count, row.content.len()));
let rows: Vec<_> = content
.iter()
.map(|row| {
let mut cells: Vec<_> = row
.content
.iter()
.map(|cell| {
let fragments = cell
.content
.iter()
.flat_map(|inline| Self::render_inline(inline, style.clone()))
.collect::<Vec<_>>();
RenderedTableCell {
content: Self::fragments_to_lines(&fragments),
}
})
.collect();
while cells.len() < num_columns {
cells.push(RenderedTableCell {
content: vec![Line::raw("")],
});
}
RenderedTableRow { content: cells }
})
.collect();
let mut prerendered_table = RenderedTable { content: rows };
let mut max_column_widths = vec![0; num_columns];
for row in prerendered_table.content.iter() {
for (idx, cell) in row.content.iter().enumerate() {
let content_width = cell.content.iter().fold(0, |w, s| max(w, s.width()));
max_column_widths[idx] = max(content_width, max_column_widths[idx]);
}
}
let mut max_row_height = vec![0; prerendered_table.content.len()];
for (idx, row) in prerendered_table.content.iter().enumerate() {
for cell in row.content.iter() {
let content_height = cell.content.len();
if content_height > max_row_height[idx] {
max_row_height[idx] = content_height;
}
}
}
for (idx, row) in prerendered_table.content.iter_mut().enumerate() {
for cell in row.content.iter_mut() {
while cell.content.len() < max_row_height[idx] {
cell.content.push(Line::raw(""));
}
}
}
let mut lines = Vec::new();
if let Some(header) = prerendered_table.content.first() {
lines.extend(Self::render_table_row(
&header.content,
&max_column_widths,
indend,
&separator,
style.clone(),
));
}
let mut spans = Vec::new();
if let Some(column) = max_column_widths.first() {
spans.push(Span::styled(
format!("{}{}", indend, "─".repeat(*column)),
style.clone(),
));
}
spans.extend(max_column_widths.iter().skip(1).map(|l| {
Span::styled(
format!(
"{}┼{}",
"─".repeat(separator_prefill),
"─".repeat(l + separator_postfill)
),
style.clone(),
)
}));
lines.push(Line::from(spans));
lines.extend(prerendered_table.content.iter().skip(1).flat_map(|row| {
Self::render_table_row(
&row.content,
&max_column_widths,
indend,
&separator,
style.clone(),
)
}));
lines
}
fn render_table_row(
cells: &[RenderedTableCell],
column_widths: &[usize],
prefix: &str,
infix: &str,
style: TextStyle,
) -> Vec<Line<'static>> {
let mut sep = prefix;
if let Some(cell) = cells.first() {
let len = cell.content.len();
(0..len)
.map(|i| {
Line::from(
cells
.iter()
.enumerate()
.flat_map(|(j, cell)| {
let mut line = vec![Span::styled(sep.to_string(), style.clone())];
sep = infix;
let content_width =
cell.content[i].spans.iter().fold(0, |l, s| l + s.width());
let num_fill_chars = column_widths
.get(j)
.unwrap_or(&0)
.saturating_sub(content_width);
line.extend(cell.content[i].spans.clone());
line.push(Span::raw(" ".repeat(num_fill_chars)));
line
})
.collect::<Vec<_>>(),
)
})
.collect()
} else {
Vec::new()
}
}
fn render_code(
content: &str,
lang: &str,
copy_id: u32,
width: u16,
style: TextStyle,
highlight_cfg: &HighlightConfig,
) -> Vec<Line<'static>> {
let mut code_style = style.clone();
code_style.bg_color = DARK_GRAY3;
let mut header_style = style.clone();
header_style.bg_color = DARK_GRAY3;
header_style.italic = true;
let mut content_lines =
Self::highlight_code(content, lang, code_style.clone(), highlight_cfg); let mut max_width = content_lines
.iter()
.fold(0, |max_width, line| std::cmp::max(max_width, line.width()));
max_width += 1; max_width = std::cmp::min(max_width, width as usize);
for line in content_lines.iter_mut() {
line.push_span(Span::styled(
" ".repeat(max_width.saturating_sub(line.width())),
code_style.clone(),
))
}
let lang_marker = format!(" {}", lang);
let copy_marker = format!(" {} ", copy_id);
let header = iter::once(Line::from(Span::styled(
format!(
"{}{}{}",
lang_marker,
" ".repeat(max_width.saturating_sub(lang_marker.width() + copy_marker.width())),
copy_marker
),
Style::from(header_style),
)));
let lines = header.chain(content_lines).collect();
Self::add_gutter(lines, DARK_GRAY2, Some(DARK_GRAY3))
}
fn highlight_code(
code: &str,
lang: &str,
style: TextStyle,
highlight_cfg: &HighlightConfig,
) -> Vec<Line<'static>> {
let syntax = highlight_cfg
.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| highlight_cfg.syntax_set.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, &highlight_cfg.theme);
code.lines()
.map(|line| {
let ranges = highlighter
.highlight_line(line, &highlight_cfg.syntax_set)
.unwrap();
Line::from(Self::ranges_to_spans(ranges, style.clone()))
})
.collect()
}
fn ranges_to_spans(
ranges: Vec<(SyntectStyle, &str)>,
base_style: TextStyle,
) -> Vec<Span<'static>> {
ranges
.into_iter()
.map(|(style, text)| {
Span::styled(
text.to_string(),
Self::merge_syntect_style(style, base_style.clone()),
)
})
.collect()
}
fn merge_syntect_style(style: SyntectStyle, base_style: TextStyle) -> Style {
let mut result = Style::default()
.fg(Color::Rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
))
.bg(base_style.bg_color);
if style.font_style.contains(FontStyle::BOLD) {
result = result.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
result = result.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
result = result.add_modifier(Modifier::UNDERLINED);
}
result
}
fn fragments_to_lines(fragments: &[InlineFragment]) -> Vec<Line<'static>> {
fragments
.split(|fragment| *fragment == InlineFragment::LineBreak)
.map(|slice| {
slice
.iter()
.filter_map(|f| match f {
InlineFragment::Text(span) => Some(span.to_owned()),
InlineFragment::LineBreak => None, })
.collect::<Vec<Span<'static>>>()
})
.map(Line::from)
.collect()
}
fn interactions(items: &[ConversationItem]) -> Vec<Vec<ConversationItem>> {
let mut result = Vec::new();
if items.is_empty() {
return result;
}
let mut chunk = vec![items.first().unwrap().to_owned()];
for item in items.iter().skip(1) {
if item.is_user_message() {
result.push(chunk);
chunk = vec![item.to_owned()];
} else {
chunk.push(item.to_owned());
}
}
if !chunk.is_empty() {
result.push(chunk);
}
result
}
}
fn notification_icon(notification_type: NotificationType, symbols_cfg: &SymbolConfig) -> String {
match notification_type {
NotificationType::Debug => symbols_cfg.message_notification_debug.clone(),
NotificationType::Info => symbols_cfg.message_notification_info.clone(),
NotificationType::Warn => symbols_cfg.message_notification_warning.clone(),
NotificationType::Error => symbols_cfg.message_notification_error.clone(),
}
}
fn tool_invocations(
interaction: &[ConversationItem],
) -> Vec<(ToolInvocationContent, ToolOutputContent)> {
let tool_results: Vec<_> = interaction
.iter()
.filter_map(|item| {
if let ConversationItem {
content: ConversationItemKind::ToolOutput(output),
id: _,
} = item
{
Some(output)
} else {
None
}
})
.collect();
interaction
.iter()
.filter_map(|item| {
if let ConversationItem {
content: ConversationItemKind::ToolInvocation(invocation),
id: _,
} = item
{
tool_results
.iter()
.find(|res| res.call_id == invocation.call_id)
.map(|result| (invocation.to_owned(), (*result).clone()))
} else {
None
}
})
.collect()
}