697 lines
22 KiB
Rust
697 lines
22 KiB
Rust
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
|
use ratatui::Frame;
|
|
use textwrap::{wrap, Options};
|
|
use tui_textarea::TextArea;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::chat_app::{ChatApp, InputMode};
|
|
use owlen_core::types::Role;
|
|
|
|
pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) {
|
|
// Calculate dynamic input height based on textarea content
|
|
let available_width = frame.area().width;
|
|
let input_height = if matches!(app.mode(), InputMode::Editing) {
|
|
let visual_lines = calculate_wrapped_line_count(
|
|
app.textarea().lines().iter().map(|s| s.as_str()),
|
|
available_width,
|
|
);
|
|
(visual_lines as u16).min(10) + 2 // +2 for borders
|
|
} else {
|
|
let buffer_text = app.input_buffer().text();
|
|
let lines: Vec<&str> = if buffer_text.is_empty() {
|
|
vec![""]
|
|
} else {
|
|
buffer_text.lines().collect()
|
|
};
|
|
let visual_lines = calculate_wrapped_line_count(lines.into_iter(), available_width);
|
|
(visual_lines as u16).min(10) + 2 // +2 for borders
|
|
};
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(4), // Header
|
|
Constraint::Min(8), // Messages
|
|
Constraint::Length(input_height), // Input
|
|
Constraint::Length(3), // Status
|
|
])
|
|
.split(frame.area());
|
|
|
|
render_header(frame, layout[0], app);
|
|
render_messages(frame, layout[1], app);
|
|
render_input(frame, layout[2], app);
|
|
render_status(frame, layout[3], app);
|
|
|
|
match app.mode() {
|
|
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
|
InputMode::ModelSelection => render_model_selector(frame, app),
|
|
InputMode::Help => render_help(frame),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn render_editable_textarea(
|
|
frame: &mut Frame<'_>,
|
|
area: Rect,
|
|
textarea: &mut TextArea<'static>,
|
|
wrap_lines: bool,
|
|
) {
|
|
let block = textarea.block().cloned();
|
|
let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area);
|
|
let base_style = textarea.style();
|
|
let cursor_line_style = textarea.cursor_line_style();
|
|
let selection_style = textarea.selection_style();
|
|
let selection_range = textarea.selection_range();
|
|
let cursor = textarea.cursor();
|
|
let mask_char = textarea.mask_char();
|
|
let is_empty = textarea.is_empty();
|
|
let placeholder_text = textarea.placeholder_text().to_string();
|
|
let placeholder_style = textarea.placeholder_style();
|
|
let lines_slice = textarea.lines();
|
|
|
|
let mut render_lines: Vec<Line> = Vec::new();
|
|
|
|
if is_empty {
|
|
if !placeholder_text.is_empty() {
|
|
let style = placeholder_style.unwrap_or_else(|| Style::default().fg(Color::DarkGray));
|
|
render_lines.push(Line::from(vec![Span::styled(placeholder_text, style)]));
|
|
} else {
|
|
render_lines.push(Line::default());
|
|
}
|
|
} else {
|
|
for (row_idx, raw_line) in lines_slice.iter().enumerate() {
|
|
let display_line = mask_char
|
|
.map(|mask| mask_line(raw_line, mask))
|
|
.unwrap_or_else(|| raw_line.clone());
|
|
|
|
let spans = build_line_spans(&display_line, row_idx, selection_range, selection_style);
|
|
|
|
let mut line = Line::from(spans);
|
|
if row_idx == cursor.0 {
|
|
line = line.patch_style(cursor_line_style);
|
|
}
|
|
render_lines.push(line);
|
|
}
|
|
}
|
|
|
|
if render_lines.is_empty() {
|
|
render_lines.push(Line::default());
|
|
}
|
|
|
|
let mut paragraph = Paragraph::new(render_lines).style(base_style);
|
|
|
|
if wrap_lines {
|
|
paragraph = paragraph.wrap(Wrap { trim: false });
|
|
}
|
|
|
|
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
|
|
|
|
if let Some(ref metrics) = metrics {
|
|
if metrics.scroll_top > 0 {
|
|
paragraph = paragraph.scroll((metrics.scroll_top, 0));
|
|
}
|
|
}
|
|
|
|
if let Some(block) = block {
|
|
paragraph = paragraph.block(block);
|
|
}
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
if let Some(metrics) = metrics {
|
|
frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y));
|
|
}
|
|
}
|
|
|
|
fn mask_line(line: &str, mask: char) -> String {
|
|
line.chars().map(|_| mask).collect()
|
|
}
|
|
|
|
fn build_line_spans(
|
|
display_line: &str,
|
|
row_idx: usize,
|
|
selection: Option<((usize, usize), (usize, usize))>,
|
|
selection_style: Style,
|
|
) -> Vec<Span<'static>> {
|
|
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
|
|
if row_idx < start_row || row_idx > end_row {
|
|
return vec![Span::raw(display_line.to_string())];
|
|
}
|
|
|
|
let char_count = display_line.chars().count();
|
|
let start = if row_idx == start_row {
|
|
start_col.min(char_count)
|
|
} else {
|
|
0
|
|
};
|
|
let end = if row_idx == end_row {
|
|
end_col.min(char_count)
|
|
} else {
|
|
char_count
|
|
};
|
|
|
|
if start >= end {
|
|
return vec![Span::raw(display_line.to_string())];
|
|
}
|
|
|
|
let start_byte = char_to_byte_idx(display_line, start);
|
|
let end_byte = char_to_byte_idx(display_line, end);
|
|
|
|
let mut spans = Vec::new();
|
|
if start_byte > 0 {
|
|
spans.push(Span::raw(display_line[..start_byte].to_string()));
|
|
}
|
|
spans.push(Span::styled(
|
|
display_line[start_byte..end_byte].to_string(),
|
|
selection_style,
|
|
));
|
|
if end_byte < display_line.len() {
|
|
spans.push(Span::raw(display_line[end_byte..].to_string()));
|
|
}
|
|
if spans.is_empty() {
|
|
spans.push(Span::raw(String::new()));
|
|
}
|
|
spans
|
|
} else {
|
|
vec![Span::raw(display_line.to_string())]
|
|
}
|
|
}
|
|
|
|
fn char_to_byte_idx(s: &str, char_idx: usize) -> usize {
|
|
if char_idx == 0 {
|
|
return 0;
|
|
}
|
|
|
|
let mut iter = s.char_indices();
|
|
for (i, (byte_idx, _)) in iter.by_ref().enumerate() {
|
|
if i == char_idx {
|
|
return byte_idx;
|
|
}
|
|
}
|
|
s.len()
|
|
}
|
|
|
|
struct CursorMetrics {
|
|
cursor_x: u16,
|
|
cursor_y: u16,
|
|
scroll_top: u16,
|
|
}
|
|
|
|
fn compute_cursor_metrics(
|
|
lines: &[String],
|
|
cursor: (usize, usize),
|
|
mask_char: Option<char>,
|
|
inner: Rect,
|
|
wrap_lines: bool,
|
|
) -> Option<CursorMetrics> {
|
|
if inner.width == 0 || inner.height == 0 {
|
|
return None;
|
|
}
|
|
|
|
let content_width = inner.width as usize;
|
|
let visible_height = inner.height as usize;
|
|
if content_width == 0 || visible_height == 0 {
|
|
return None;
|
|
}
|
|
|
|
let cursor_row = cursor.0.min(lines.len().saturating_sub(1));
|
|
let cursor_col = cursor.1;
|
|
|
|
let mut total_visual_rows = 0usize;
|
|
let mut cursor_visual_row = 0usize;
|
|
let mut cursor_col_width = 0usize;
|
|
let mut cursor_found = false;
|
|
|
|
for (row_idx, line) in lines.iter().enumerate() {
|
|
let display_owned = mask_char.map(|mask| mask_line(line, mask));
|
|
let display_line = display_owned.as_deref().unwrap_or_else(|| line.as_str());
|
|
|
|
let mut segments = if wrap_lines {
|
|
wrap_line_segments(display_line, content_width)
|
|
} else {
|
|
vec![display_line.to_string()]
|
|
};
|
|
|
|
if segments.is_empty() {
|
|
segments.push(String::new());
|
|
}
|
|
|
|
if row_idx == cursor_row && !cursor_found {
|
|
let mut remaining = cursor_col;
|
|
for (segment_idx, segment) in segments.iter().enumerate() {
|
|
let segment_len = segment.chars().count();
|
|
let is_last_segment = segment_idx + 1 == segments.len();
|
|
|
|
if remaining > segment_len {
|
|
remaining -= segment_len;
|
|
continue;
|
|
}
|
|
|
|
if remaining == segment_len && !is_last_segment {
|
|
cursor_visual_row = total_visual_rows + segment_idx + 1;
|
|
cursor_col_width = 0;
|
|
cursor_found = true;
|
|
break;
|
|
}
|
|
|
|
let prefix: String = segment.chars().take(remaining).collect();
|
|
cursor_visual_row = total_visual_rows + segment_idx;
|
|
cursor_col_width = UnicodeWidthStr::width(prefix.as_str());
|
|
cursor_found = true;
|
|
break;
|
|
}
|
|
|
|
if !cursor_found {
|
|
if let Some(last_segment) = segments.last() {
|
|
cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1);
|
|
cursor_col_width = UnicodeWidthStr::width(last_segment.as_str());
|
|
cursor_found = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
total_visual_rows += segments.len();
|
|
}
|
|
|
|
if !cursor_found {
|
|
cursor_visual_row = total_visual_rows.saturating_sub(1);
|
|
cursor_col_width = 0;
|
|
}
|
|
|
|
let mut scroll_top = 0usize;
|
|
if cursor_visual_row + 1 > visible_height {
|
|
scroll_top = cursor_visual_row + 1 - visible_height;
|
|
}
|
|
|
|
let max_scroll = total_visual_rows.saturating_sub(visible_height);
|
|
if scroll_top > max_scroll {
|
|
scroll_top = max_scroll;
|
|
}
|
|
|
|
let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top);
|
|
let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16;
|
|
let cursor_x = inner.x + cursor_col_width.min(content_width.saturating_sub(1)) as u16;
|
|
|
|
Some(CursorMetrics {
|
|
cursor_x,
|
|
cursor_y,
|
|
scroll_top: scroll_top as u16,
|
|
})
|
|
}
|
|
|
|
fn wrap_line_segments(line: &str, width: usize) -> Vec<String> {
|
|
if width == 0 {
|
|
return vec![String::new()];
|
|
}
|
|
|
|
let wrapped = wrap(line, Options::new(width).break_words(false));
|
|
if wrapped.is_empty() {
|
|
vec![String::new()]
|
|
} else {
|
|
wrapped
|
|
.into_iter()
|
|
.map(|segment| segment.into_owned())
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|
let title_span = Span::styled(
|
|
" 🦉 OWLEN - AI Assistant ",
|
|
Style::default()
|
|
.fg(Color::LightMagenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
let model_span = Span::styled(
|
|
format!("Model: {}", app.selected_model()),
|
|
Style::default().fg(Color::LightBlue),
|
|
);
|
|
|
|
let header_block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)))
|
|
.title(Line::from(vec![title_span]));
|
|
|
|
let inner_area = header_block.inner(area);
|
|
|
|
let header_text = vec![Line::from(""), Line::from(format!(" {model_span} "))];
|
|
|
|
let paragraph = Paragraph::new(header_text).alignment(Alignment::Left);
|
|
|
|
frame.render_widget(header_block, area);
|
|
frame.render_widget(paragraph, inner_area);
|
|
}
|
|
|
|
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|
let conversation = app.conversation();
|
|
let mut formatter = app.formatter().clone();
|
|
|
|
// Reserve space for borders and the message indent so text fits within the block
|
|
let content_width = area.width.saturating_sub(4).max(20);
|
|
formatter.set_wrap_width(usize::from(content_width));
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
for (message_index, message) in conversation.messages.iter().enumerate() {
|
|
let role = &message.role;
|
|
let prefix = match role {
|
|
Role::User => "👤 You:",
|
|
Role::Assistant => "🤖 Assistant:",
|
|
Role::System => "⚙️ System:",
|
|
};
|
|
|
|
let formatted = formatter.format_message(message);
|
|
let is_streaming = message
|
|
.metadata
|
|
.get("streaming")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let show_role_labels = formatter.show_role_labels();
|
|
let indent = if show_role_labels { " " } else { "" };
|
|
|
|
if show_role_labels {
|
|
lines.push(Line::from(Span::styled(
|
|
prefix,
|
|
role_color(role).add_modifier(Modifier::BOLD),
|
|
)));
|
|
}
|
|
|
|
for (i, line) in formatted.iter().enumerate() {
|
|
let mut spans = Vec::new();
|
|
spans.push(Span::raw(format!("{indent}{line}")));
|
|
if i == formatted.len() - 1 && is_streaming {
|
|
spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta)));
|
|
}
|
|
lines.push(Line::from(spans));
|
|
}
|
|
// Add an empty line after each message, except the last one
|
|
if message_index < conversation.messages.len() - 1 {
|
|
lines.push(Line::from(""));
|
|
}
|
|
}
|
|
|
|
if lines.is_empty() {
|
|
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
|
|
}
|
|
|
|
let mut paragraph = Paragraph::new(lines)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
)
|
|
.wrap(Wrap { trim: false });
|
|
|
|
let scroll = app.scroll().min(u16::MAX as usize) as u16;
|
|
paragraph = paragraph.scroll((scroll, 0));
|
|
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|
let title = match app.mode() {
|
|
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ",
|
|
_ => " Input (Press 'i' to start typing) ",
|
|
};
|
|
|
|
let input_block = Block::default()
|
|
.title(Span::styled(
|
|
title,
|
|
Style::default()
|
|
.fg(Color::LightMagenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
|
|
|
if matches!(app.mode(), InputMode::Editing) {
|
|
let mut textarea = app.textarea().clone();
|
|
textarea.set_block(input_block.clone());
|
|
render_editable_textarea(frame, area, &mut textarea, true);
|
|
} else {
|
|
// In non-editing mode, show the current input buffer content as read-only
|
|
let input_text = app.input_buffer().text();
|
|
let lines: Vec<Line> = if input_text.is_empty() {
|
|
vec![Line::from("Press 'i' to start typing")]
|
|
} else {
|
|
input_text.lines().map(|line| Line::from(line)).collect()
|
|
};
|
|
|
|
let paragraph = Paragraph::new(lines)
|
|
.block(input_block)
|
|
.wrap(Wrap { trim: false });
|
|
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
}
|
|
|
|
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
|
where
|
|
I: IntoIterator<Item = &'a str>,
|
|
{
|
|
let content_width = available_width.saturating_sub(2); // subtract block borders
|
|
if content_width == 0 {
|
|
let mut count = 0;
|
|
for _ in lines.into_iter() {
|
|
count += 1;
|
|
}
|
|
return count.max(1);
|
|
}
|
|
|
|
let options = Options::new(content_width as usize).break_words(false);
|
|
|
|
let mut total = 0usize;
|
|
let mut seen = false;
|
|
for line in lines.into_iter() {
|
|
seen = true;
|
|
if line.is_empty() {
|
|
total += 1;
|
|
continue;
|
|
}
|
|
let wrapped = wrap(line, &options);
|
|
total += wrapped.len().max(1);
|
|
}
|
|
|
|
if !seen {
|
|
1
|
|
} else {
|
|
total.max(1)
|
|
}
|
|
}
|
|
|
|
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|
let (mode_text, mode_bg_color) = match app.mode() {
|
|
InputMode::Normal => (" NORMAL", Color::LightBlue),
|
|
InputMode::Editing => (" INPUT", Color::LightGreen),
|
|
InputMode::ModelSelection => (" MODEL", Color::LightYellow),
|
|
InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan),
|
|
InputMode::Help => (" HELP", Color::LightMagenta),
|
|
};
|
|
|
|
let status_message = if app.streaming_count() > 0 {
|
|
format!("Streaming... ({})", app.streaming_count())
|
|
} else if let Some(error) = app.error_message() {
|
|
format!("Error: {}", error)
|
|
} else {
|
|
"Ready".to_string()
|
|
};
|
|
|
|
let help_text = "i:Input m:Model c:Clear q:Quit";
|
|
|
|
let left_spans = vec![
|
|
Span::styled(
|
|
format!(" {} ", mode_text),
|
|
Style::default()
|
|
.fg(Color::Black)
|
|
.bg(mode_bg_color)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(format!(" | {} ", status_message)),
|
|
];
|
|
|
|
let right_spans = vec![
|
|
Span::raw(" Help: "),
|
|
Span::styled(help_text, Style::default().fg(Color::LightBlue)),
|
|
];
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
.split(area);
|
|
|
|
let left_paragraph = Paragraph::new(Line::from(left_spans))
|
|
.alignment(Alignment::Left)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
);
|
|
|
|
let right_paragraph = Paragraph::new(Line::from(right_spans))
|
|
.alignment(Alignment::Right)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
);
|
|
|
|
frame.render_widget(left_paragraph, layout[0]);
|
|
frame.render_widget(right_paragraph, layout[1]);
|
|
}
|
|
|
|
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
|
let area = centered_rect(60, 60, frame.area());
|
|
frame.render_widget(Clear, area);
|
|
|
|
let items: Vec<ListItem> = app
|
|
.available_providers
|
|
.iter()
|
|
.map(|provider| {
|
|
ListItem::new(Span::styled(
|
|
provider.to_string(),
|
|
Style::default()
|
|
.fg(Color::LightBlue)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.title(Span::styled(
|
|
"Select Provider",
|
|
Style::default()
|
|
.fg(Color::LightMagenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Magenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
|
|
let mut state = ListState::default();
|
|
state.select(Some(app.selected_provider_index));
|
|
frame.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
|
let area = centered_rect(60, 60, frame.area());
|
|
frame.render_widget(Clear, area);
|
|
|
|
let items: Vec<ListItem> = app
|
|
.models()
|
|
.iter()
|
|
.map(|model| {
|
|
let label = if model.name.is_empty() {
|
|
model.id.clone()
|
|
} else {
|
|
format!("{} — {}", model.id, model.name)
|
|
};
|
|
ListItem::new(Span::styled(
|
|
label,
|
|
Style::default()
|
|
.fg(Color::LightBlue)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.title(Span::styled(
|
|
format!("Select Model ({})", app.selected_provider),
|
|
Style::default()
|
|
.fg(Color::LightMagenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Magenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
|
|
let mut state = ListState::default();
|
|
state.select(app.selected_model_index());
|
|
frame.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn render_help(frame: &mut Frame<'_>) {
|
|
let area = centered_rect(70, 60, frame.area());
|
|
frame.render_widget(Clear, area);
|
|
|
|
let help_text = vec![
|
|
Line::from("Controls:"),
|
|
Line::from(" i / Enter → start typing"),
|
|
Line::from(" Enter → send message"),
|
|
Line::from(" Ctrl+J → newline"),
|
|
Line::from(" m → select model"),
|
|
Line::from(" n → new conversation"),
|
|
Line::from(" c → clear conversation"),
|
|
Line::from(" q → quit"),
|
|
Line::from(""),
|
|
Line::from("Press Esc to close this help."),
|
|
];
|
|
|
|
let paragraph = Paragraph::new(help_text).block(
|
|
Block::default()
|
|
.title(Span::styled(
|
|
"Help",
|
|
Style::default()
|
|
.fg(Color::LightMagenta)
|
|
.add_modifier(Modifier::BOLD),
|
|
))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
|
);
|
|
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|
let vertical = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
]
|
|
.as_ref(),
|
|
)
|
|
.split(area);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(
|
|
[
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
]
|
|
.as_ref(),
|
|
)
|
|
.split(vertical[1])[1]
|
|
}
|
|
|
|
fn role_color(role: &Role) -> Style {
|
|
match role {
|
|
Role::User => Style::default().fg(Color::LightBlue),
|
|
Role::Assistant => Style::default().fg(Color::LightMagenta),
|
|
Role::System => Style::default().fg(Color::Cyan),
|
|
}
|
|
}
|