Files
owlen/crates/owlen-tui/src/ui.rs
vikingowl 96e2482782
Some checks failed
ci/someci/tag/woodpecker/5 Pipeline is pending
ci/someci/tag/woodpecker/6 Pipeline is pending
ci/someci/tag/woodpecker/7 Pipeline is pending
ci/someci/tag/woodpecker/1 Pipeline failed
ci/someci/tag/woodpecker/2 Pipeline failed
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
Add built-in theme support with various pre-defined themes
- Introduce multiple built-in themes (`default_dark`, `default_light`, `gruvbox`, `dracula`, `solarized`, `midnight-ocean`, `rose-pine`, `monokai`, `material-dark`, `material-light`).
- Implement theming system with customizable color schemes for all UI components in the TUI.
- Include documentation for themes in `themes/README.md`.
- Add fallback mechanisms for default themes in case of parsing errors.
- Support custom themes with overrides via configuration.
2025-10-03 07:44:11 +02:00

1803 lines
66 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;
use owlen_core::types::Role;
use owlen_core::ui::{FocusedPanel, InputMode};
pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Update thinking content from last message
app.update_thinking_from_last_message();
// Set terminal background color
let theme = app.theme().clone();
let background_block = Block::default().style(Style::default().bg(theme.background));
frame.render_widget(background_block, frame.area());
// 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, available_width);
(visual_lines as u16).min(10) + 2 // +2 for borders
};
// Calculate thinking section height
let thinking_height = if let Some(thinking) = app.current_thinking() {
let content_width = available_width.saturating_sub(4);
let visual_lines = calculate_wrapped_line_count(thinking.lines(), content_width);
(visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines
} else {
0
};
let mut constraints = vec![
Constraint::Length(4), // Header
Constraint::Min(8), // Messages
];
if thinking_height > 0 {
constraints.push(Constraint::Length(thinking_height)); // Thinking
}
constraints.push(Constraint::Length(input_height)); // Input
constraints.push(Constraint::Length(3)); // Status
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
let mut idx = 0;
render_header(frame, layout[idx], app);
idx += 1;
render_messages(frame, layout[idx], app);
idx += 1;
if thinking_height > 0 {
render_thinking(frame, layout[idx], app);
idx += 1;
}
render_input(frame, layout[idx], app);
idx += 1;
render_status(frame, layout[idx], app);
match app.mode() {
InputMode::ProviderSelection => render_provider_selector(frame, app),
InputMode::ModelSelection => render_model_selector(frame, app),
InputMode::Help => render_help(frame, app),
InputMode::SessionBrowser => render_session_browser(frame, app),
InputMode::ThemeBrowser => render_theme_browser(frame, app),
InputMode::Command => render_command_suggestions(frame, app),
_ => {}
}
}
fn render_editable_textarea(
frame: &mut Frame<'_>,
area: Rect,
textarea: &mut TextArea<'static>,
mut 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();
// Disable wrapping when there's an active selection to preserve highlighting
if selection_range.is_some() {
wrap_lines = false;
}
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());
}
// If wrapping is enabled, we need to manually wrap the lines
// This ensures consistency with cursor calculation
if wrap_lines {
let content_width = inner.width as usize;
let mut wrapped_lines: Vec<Line> = Vec::new();
for (row_idx, line) in render_lines.iter().enumerate() {
let line_text = line.to_string();
let segments = wrap_line_segments(&line_text, content_width);
for (seg_idx, segment) in segments.into_iter().enumerate() {
// For the line with the cursor, preserve the cursor line style
if row_idx == cursor.0 && seg_idx == 0 {
wrapped_lines.push(Line::from(segment).patch_style(cursor_line_style));
} else {
wrapped_lines.push(Line::from(segment));
}
}
}
render_lines = wrapped_lines;
}
let mut paragraph = Paragraph::new(render_lines).style(base_style);
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(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()];
}
if line.is_empty() {
return vec![String::new()];
}
// Manual wrapping that preserves all characters including spaces
let mut result = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for ch in line.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
// If adding this character would exceed width, wrap to next line
if current_width + ch_width > width {
if !current.is_empty() {
result.push(current);
current = String::new();
current_width = 0;
}
// If even a single character is too wide, add it anyway to avoid infinite loop
if ch_width > width {
current.push(ch);
result.push(current);
current = String::new();
current_width = 0;
continue;
}
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
result.push(current);
}
if result.is_empty() {
result.push(String::new());
}
result
}
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let title_span = Span::styled(
" 🦉 OWLEN - AI Assistant ",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
);
let model_span = Span::styled(
format!("Model: {}", app.selected_model()),
Style::default().fg(theme.user_message_role),
);
let header_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text))
.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)
.style(Style::default().bg(theme.background).fg(theme.text))
.alignment(Alignment::Left);
frame.render_widget(header_block, area);
frame.render_widget(paragraph, inner_area);
}
fn apply_visual_selection<'a>(
lines: Vec<Line<'a>>,
selection: Option<((usize, usize), (usize, usize))>,
theme: &owlen_core::theme::Theme,
) -> Vec<Line<'a>> {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
// Normalize selection (ensure start is before end)
let ((start_r, start_c), (end_r, end_c)) =
if start_row < end_row || (start_row == end_row && start_col <= end_col) {
((start_row, start_col), (end_row, end_col))
} else {
((end_row, end_col), (start_row, start_col))
};
lines
.into_iter()
.enumerate()
.map(|(idx, line)| {
if idx < start_r || idx > end_r {
// Line not in selection
return line;
}
// Convert line to plain text for character indexing
let line_text = line.to_string();
let char_count = line_text.chars().count();
if idx == start_r && idx == end_r {
// Selection within single line
let sel_start = start_c.min(char_count);
let sel_end = end_c.min(char_count);
if sel_start >= sel_end {
return line;
}
let start_byte = char_to_byte_index(&line_text, sel_start);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::styled(
line_text[..start_byte].to_string(),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
line_text[start_byte..end_byte].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
if end_byte < line_text.len() {
spans.push(Span::styled(
line_text[end_byte..].to_string(),
Style::default().fg(theme.text),
));
}
Line::from(spans)
} else if idx == start_r {
// First line of multi-line selection
let sel_start = start_c.min(char_count);
let start_byte = char_to_byte_index(&line_text, sel_start);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::styled(
line_text[..start_byte].to_string(),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
line_text[start_byte..].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
Line::from(spans)
} else if idx == end_r {
// Last line of multi-line selection
let sel_end = end_c.min(char_count);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
spans.push(Span::styled(
line_text[..end_byte].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
if end_byte < line_text.len() {
spans.push(Span::styled(
line_text[end_byte..].to_string(),
Style::default().fg(theme.text),
));
}
Line::from(spans)
} else {
// Middle line - fully selected
let styled_spans: Vec<Span> = line
.spans
.into_iter()
.map(|span| {
Span::styled(
span.content,
span.style.bg(theme.selection_bg).fg(theme.selection_fg),
)
})
.collect();
Line::from(styled_spans)
}
})
.collect()
} else {
lines
}
}
fn char_to_byte_index(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()
}
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
// Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4).max(20);
app.set_viewport_dimensions(viewport_height, usize::from(content_width));
let conversation = app.conversation();
let mut formatter = app.formatter().clone();
// Reserve space for borders and the message indent so text fits within the block
formatter.set_wrap_width(usize::from(content_width));
// Build the lines for messages
let mut lines: Vec<Line> = Vec::new();
for (message_index, message) in conversation.messages.iter().enumerate() {
let role = &message.role;
let (emoji, name) = match role {
Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "),
};
// Extract content without thinking tags for assistant messages
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) = formatter.extract_thinking(&message.content);
content_without_think
} else {
message.content.clone()
};
let formatted: Vec<String> = content_to_display
.trim()
.lines()
.map(|s| s.to_string())
.collect();
let is_streaming = message
.metadata
.get("streaming")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let show_role_labels = formatter.show_role_labels();
if show_role_labels {
// Role name line
let mut role_line_spans = vec![
Span::raw(emoji),
Span::styled(name, role_color(role, &theme).add_modifier(Modifier::BOLD)),
];
// Add loading indicator if applicable
if matches!(role, Role::Assistant)
&& app.get_loading_indicator() != ""
&& message_index == conversation.messages.len() - 1
&& is_streaming
{
role_line_spans.push(Span::styled(
format!(" {}", app.get_loading_indicator()),
Style::default().fg(theme.info),
));
}
lines.push(Line::from(role_line_spans));
// Join all formatted lines into single content string
let content = formatted.join("\n");
// Wrap content with available width minus indent (2 spaces)
let indent = " ";
let available_width = (content_width as usize).saturating_sub(2);
let chunks = if available_width > 0 {
wrap(&content, available_width)
} else {
vec![]
};
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(format!("{indent}{}", seg))];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
lines.push(Line::from(spans));
}
} else {
// No role labels - just show content
let content = formatted.join("\n");
let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(seg.into_owned())];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
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(""));
}
}
// Add loading indicator ONLY if we're loading and there are no messages at all,
// or if the last message is from the user (no Assistant response started yet)
let last_message_is_user = conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true);
if app.get_loading_indicator() != "" && last_message_is_user {
let loading_spans = vec![
Span::raw("🤖 "),
Span::styled(
"Assistant:",
Style::default()
.fg(theme.assistant_message_role)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", app.get_loading_indicator()),
Style::default().fg(theme.info),
),
];
lines.push(Line::from(loading_spans));
}
if lines.is_empty() {
lines.push(Line::from("No messages yet. Press 'i' to start typing."));
}
// Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat)
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection), &theme);
}
}
// Update AutoScroll state with accurate content length
let auto_scroll = app.auto_scroll_mut();
auto_scroll.content_len = lines.len();
auto_scroll.on_viewport(viewport_height);
let scroll_position = app.scroll().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) {
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.scroll((scroll_position, 0));
frame.render_widget(paragraph, area);
// Render cursor if Chat panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal)
{
let cursor = app.chat_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize
&& cursor_row < (scroll_position as usize + viewport_height)
{
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
// Get the rendered line and calculate display width
let rendered_lines = app.get_rendered_lines();
if let Some(line_text) = rendered_lines.get(cursor_row) {
let chars: Vec<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
if let Some(thinking) = app.current_thinking().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4);
app.set_thinking_viewport_height(viewport_height);
let chunks = wrap(&thinking, content_width as usize);
let mut lines: Vec<Line> = chunks
.into_iter()
.map(|seg| {
Line::from(Span::styled(
seg.into_owned(),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
))
})
.collect();
// Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection), &theme);
}
}
// Update AutoScroll state with accurate content length
let thinking_scroll = app.thinking_scroll_mut();
thinking_scroll.content_len = lines.len();
thinking_scroll.on_viewport(viewport_height);
let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) {
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" 💭 Thinking ",
Style::default()
.fg(theme.thinking_panel_title)
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.scroll((scroll_position, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Thinking)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.thinking_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize
&& cursor_row < (scroll_position as usize + viewport_height)
{
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
// Calculate actual display width by measuring characters up to cursor
let line_text = thinking.lines().nth(cursor_row).unwrap_or("");
let chars: Vec<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme();
let title = match app.mode() {
InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ",
InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ",
InputMode::Command => " Command Mode (Enter=execute · Esc=cancel) ",
_ => " Input (Press 'i' to start typing) ",
};
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) {
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let input_block = Block::default()
.title(Span::styled(
title,
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text));
if matches!(app.mode(), InputMode::Editing) {
// Use the textarea directly to preserve selection state
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true);
} else if matches!(app.mode(), InputMode::Visual) {
// In visual mode, render textarea in read-only mode with selection
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true);
} else if matches!(app.mode(), InputMode::Command) {
// In command mode, show the command buffer with : prefix
let command_text = format!(":{}", app.command_buffer());
let lines = vec![Line::from(Span::styled(
command_text,
Style::default()
.fg(theme.mode_command)
.add_modifier(Modifier::BOLD),
))];
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(input_block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
} 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(Span::styled(
"Press 'i' to start typing",
Style::default().fg(theme.placeholder),
))]
} else {
input_text
.lines()
.map(|l| Line::from(Span::styled(l, Style::default().fg(theme.text))))
.collect()
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.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 theme = app.theme();
let (mode_text, mode_bg_color) = match app.mode() {
InputMode::Normal => (" NORMAL", theme.mode_normal),
InputMode::Editing => (" INPUT", theme.mode_editing),
InputMode::ModelSelection => (" MODEL", theme.mode_model_selection),
InputMode::ProviderSelection => (" PROVIDER", theme.mode_provider_selection),
InputMode::Help => (" HELP", theme.mode_help),
InputMode::Visual => (" VISUAL", theme.mode_visual),
InputMode::Command => (" COMMAND", theme.mode_command),
InputMode::SessionBrowser => (" SESSIONS", theme.mode_command),
InputMode::ThemeBrowser => (" THEMES", theme.mode_help),
};
let status_message = if let Some(error) = app.error_message() {
format!("Error: {}", error)
} else {
app.status_message().to_string()
};
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
let left_spans = vec![
Span::styled(
format!(" {} ", mode_text),
Style::default()
.fg(theme.background)
.bg(mode_bg_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" | {} ", status_message),
Style::default().fg(theme.text),
),
];
let right_spans = vec![
Span::styled(" Help: ", Style::default().fg(theme.text)),
Span::styled(help_text, Style::default().fg(theme.info)),
];
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)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.status_background).fg(theme.text)),
);
let right_paragraph = Paragraph::new(Line::from(right_spans))
.alignment(Alignment::Right)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.status_background).fg(theme.text)),
);
frame.render_widget(left_paragraph, layout[0]);
frame.render_widget(right_paragraph, layout[1]);
}
fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
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(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(Span::styled(
"Select Provider",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.highlight_style(
Style::default()
.fg(theme.focused_panel_border)
.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 theme = app.theme();
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(theme.user_message_role)
.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(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.highlight_style(
Style::default()
.fg(theme.focused_panel_border)
.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<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(75, 70, frame.area());
frame.render_widget(Clear, area);
let tab_index = app.help_tab_index();
let tabs = vec!["Navigation", "Editing", "Visual", "Commands", "Sessions", "Browsers"];
// Build tab line
let mut tab_spans = Vec::new();
for (i, tab_name) in tabs.iter().enumerate() {
if i == tab_index {
tab_spans.push(Span::styled(
format!(" {} ", tab_name),
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD),
));
} else {
tab_spans.push(Span::styled(
format!(" {} ", tab_name),
Style::default().fg(theme.placeholder),
));
}
if i < tabs.len() - 1 {
tab_spans.push(Span::raw(""));
}
}
let help_text = match tab_index {
0 => vec![
// Navigation
Line::from(""),
Line::from(vec![Span::styled(
"PANEL NAVIGATION",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" Tab → cycle panels forward"),
Line::from(" Shift+Tab → cycle panels backward"),
Line::from(" (Panels: Chat, Thinking, Input)"),
Line::from(""),
Line::from(vec![Span::styled(
"CURSOR MOVEMENT",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" h/← l/→ → move left/right by character"),
Line::from(" j/↓ k/↑ → move down/up by line"),
Line::from(" w → forward to next word start"),
Line::from(" e → forward to word end"),
Line::from(" b → backward to previous word"),
Line::from(" 0 / Home → start of line"),
Line::from(" ^ → first non-blank character"),
Line::from(" $ / End → end of line"),
Line::from(" gg → jump to top"),
Line::from(" G → jump to bottom"),
Line::from(""),
Line::from(vec![Span::styled(
"SCROLLING",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" Ctrl+d/u → scroll half page down/up"),
Line::from(" Ctrl+f/b → scroll full page down/up"),
Line::from(" PageUp/Down → scroll full page"),
],
1 => vec![
// Editing
Line::from(""),
Line::from(vec![Span::styled(
"ENTERING INSERT MODE",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" i / Enter → enter insert mode at cursor"),
Line::from(" a → append after cursor"),
Line::from(" A → append at end of line"),
Line::from(" I → insert at start of line"),
Line::from(" o → insert line below and enter insert mode"),
Line::from(" O → insert line above and enter insert mode"),
Line::from(""),
Line::from(vec![Span::styled(
"INSERT MODE KEYS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" Enter → send message"),
Line::from(" Ctrl+J → insert newline (multiline message)"),
Line::from(" Ctrl+↑/↓ → navigate input history"),
Line::from(" Ctrl+A → jump to start of line"),
Line::from(" Ctrl+E → jump to end of line"),
Line::from(" Ctrl+W → word forward"),
Line::from(" Ctrl+B → word backward"),
Line::from(" Ctrl+R → redo"),
Line::from(" Esc → return to normal mode"),
Line::from(""),
Line::from(vec![Span::styled(
"NORMAL MODE OPERATIONS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" dd → clear input buffer"),
Line::from(" p → paste from clipboard to input"),
],
2 => vec![
// Visual
Line::from(""),
Line::from(vec![Span::styled(
"VISUAL MODE",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" v → enter visual mode at cursor"),
Line::from(""),
Line::from(vec![Span::styled(
"SELECTION MOVEMENT",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" h/j/k/l → extend selection left/down/up/right"),
Line::from(" w → extend to next word start"),
Line::from(" e → extend to word end"),
Line::from(" b → extend backward to previous word"),
Line::from(" 0 → extend to line start"),
Line::from(" ^ → extend to first non-blank"),
Line::from(" $ → extend to line end"),
Line::from(""),
Line::from(vec![Span::styled(
"VISUAL MODE OPERATIONS",
Style::default().add_modifier(Modifier::BOLD).fg(theme.info),
)]),
Line::from(" y → yank (copy) selection to clipboard"),
Line::from(" d / Delete → cut selection (Input panel only)"),
Line::from(" v / Esc → exit visual mode"),
Line::from(""),
Line::from(vec![Span::styled(
"NOTES",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" • Visual mode works across all panels (Chat, Thinking, Input)"),
Line::from(" • Yanked text is available for paste with 'p' in normal mode"),
],
3 => vec![
// Commands
Line::from(""),
Line::from(vec![
Span::styled("COMMAND MODE", Style::default().add_modifier(Modifier::BOLD).fg(theme.info))
]),
Line::from(" Press ':' to enter command mode, then type one of:"),
Line::from(""),
Line::from(vec![
Span::styled("KEYBINDINGS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" Enter → execute command"),
Line::from(" Esc → exit command mode"),
Line::from(" Tab → autocomplete suggestion"),
Line::from(" ↑/↓ → navigate suggestions"),
Line::from(" Backspace → delete character"),
Line::from(""),
Line::from(vec![
Span::styled("GENERAL", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :h, :help → show this help"),
Line::from(" :q, :quit → quit application"),
Line::from(" :reload → reload configuration and themes"),
Line::from(""),
Line::from(vec![
Span::styled("CONVERSATION", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :n, :new → start new conversation"),
Line::from(" :c, :clear → clear current conversation"),
Line::from(""),
Line::from(vec![
Span::styled("MODEL & THEME", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :m, :model → open model selector"),
Line::from(" :themes → open theme selector"),
Line::from(" :theme <name> → switch to a specific theme"),
Line::from(""),
Line::from(vec![
Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :save [name] → save current session (with optional name)"),
Line::from(" :w [name] → alias for :save"),
Line::from(" :load, :o, :open → browse and load saved sessions"),
Line::from(" :sessions, :ls → browse saved sessions"),
],
4 => vec![ // Sessions
Line::from(""),
Line::from(vec![
Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(theme.info))
]),
Line::from(""),
Line::from(vec![
Span::styled("SAVING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :save → save with auto-generated name"),
Line::from(" :save my-session → save with custom name"),
Line::from(" • AI generates description automatically (configurable)"),
Line::from(" • Sessions stored in platform-specific directories"),
Line::from(""),
Line::from(vec![
Span::styled("LOADING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" :load, :o, :open → browse and select session"),
Line::from(" :sessions, :ls → browse saved sessions"),
Line::from(""),
Line::from(vec![
Span::styled("SESSION BROWSER KEYS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" j/k or ↑/↓ → navigate sessions"),
Line::from(" Enter → load selected session"),
Line::from(" d → delete selected session"),
Line::from(" Esc → close browser"),
Line::from(""),
Line::from(vec![
Span::styled("STORAGE LOCATIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role))
]),
Line::from(" Linux → ~/.local/share/owlen/sessions"),
Line::from(" Windows → %APPDATA%\\owlen\\sessions"),
Line::from(" macOS → ~/Library/Application Support/owlen/sessions"),
Line::from(""),
Line::from(vec![
Span::styled("CONTEXT PRESERVATION", Style::default().add_modifier(Modifier::BOLD).fg(theme.assistant_message_role))
]),
Line::from(" • Full conversation history is preserved when saving"),
Line::from(" • All context is restored when loading a session"),
Line::from(" • Continue conversations seamlessly across restarts"),
],
5 => vec![ // Browsers
Line::from(""),
Line::from(vec![
Span::styled("PROVIDER & MODEL BROWSERS", Style::default().add_modifier(Modifier::BOLD).fg(theme.info))
]),
Line::from(" Enter → select item"),
Line::from(" Esc → close browser"),
Line::from(" ↑/↓ or j/k → navigate items"),
Line::from(""),
Line::from(vec![
Span::styled("THEME BROWSER", Style::default().add_modifier(Modifier::BOLD).fg(theme.info))
]),
Line::from(" Enter → apply theme"),
Line::from(" Esc / q → close browser"),
Line::from(" ↑/↓ or j/k → navigate themes"),
Line::from(" g / Home → jump to top"),
Line::from(" G / End → jump to bottom"),
],
_ => vec![],
};
// Create layout for tabs and content
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tab bar
Constraint::Min(0), // Content
Constraint::Length(2), // Navigation hint
])
.split(area);
// Render tabs
let tabs_block = Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let tabs_para = Paragraph::new(Line::from(tab_spans))
.style(Style::default().bg(theme.background))
.block(tabs_block);
frame.render_widget(tabs_para, layout[0]);
// Render content
let content_block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let content_para = Paragraph::new(help_text)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(content_block);
frame.render_widget(content_para, layout[1]);
// Render navigation hint
let nav_hint = Line::from(vec![
Span::raw(" "),
Span::styled(
"Tab/h/l",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
),
Span::raw(":Switch "),
Span::styled("1-6", Style::default().fg(theme.focused_panel_border).add_modifier(Modifier::BOLD)),
Span::raw(":Jump "),
Span::styled(
"Esc/q",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
),
Span::raw(":Close "),
]);
let nav_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let nav_para = Paragraph::new(nav_hint)
.style(Style::default().bg(theme.background))
.block(nav_block)
.alignment(Alignment::Center);
frame.render_widget(nav_para, layout[2]);
}
fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, area);
let sessions = app.saved_sessions();
if sessions.is_empty() {
let text = vec![
Line::from(""),
Line::from("No saved sessions found."),
Line::from(""),
Line::from("Save your current session with :save [name]"),
Line::from(""),
Line::from("Press Esc to close."),
];
let paragraph = Paragraph::new(text)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(
Block::default()
.title(Span::styled(
" Saved Sessions ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
return;
}
let items: Vec<ListItem> = sessions
.iter()
.enumerate()
.map(|(idx, session)| {
let name = session.name.as_deref().unwrap_or("Unnamed session");
let created = session
.created_at
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_hours = (now - created) / 3600;
let age_str = if age_hours < 1 {
"< 1h ago".to_string()
} else if age_hours < 24 {
format!("{}h ago", age_hours)
} else {
format!("{}d ago", age_hours / 24)
};
let info = format!(
"{} messages · {} · {}",
session.message_count, session.model, age_str
);
let is_selected = idx == app.selected_session_index();
let style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let info_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
} else {
Style::default().fg(theme.placeholder)
};
let desc_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC)
};
let mut lines = vec![Line::from(Span::styled(name, style))];
// Add description if available and not empty
if let Some(description) = &session.description {
if !description.is_empty() {
lines.push(Line::from(Span::styled(
format!(" \"{}\"", description),
desc_style,
)));
}
}
// Add metadata line
lines.push(Line::from(Span::styled(format!(" {}", info), info_style)));
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Saved Sessions ({}) ", sessions.len()),
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}
fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(60, 70, frame.area());
frame.render_widget(Clear, area);
let themes = app.available_themes();
let current_theme_name = &app.theme().name;
if themes.is_empty() {
let text = vec![
Line::from(""),
Line::from("No themes available."),
Line::from(""),
Line::from("Press Esc to close."),
];
let paragraph = Paragraph::new(text)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" Themes ",
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
return;
}
// Get theme metadata to show built-in vs custom
let all_themes = owlen_core::theme::load_all_themes();
let built_in = owlen_core::theme::built_in_themes();
let items: Vec<ListItem> = themes
.iter()
.enumerate()
.map(|(idx, theme_name)| {
let is_current = theme_name == current_theme_name;
let is_selected = idx == app.selected_theme_index();
let is_built_in = built_in.contains_key(theme_name);
// Build display name
let mut display = theme_name.clone();
if is_current {
display.push_str("");
}
let type_indicator = if is_built_in { "built-in" } else { "custom" };
let name_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let info_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
} else {
Style::default().fg(theme.placeholder)
};
// Try to get theme description or show type
let info_text = if all_themes.contains_key(theme_name) {
format!(" {} · {}", type_indicator, theme_name)
} else {
format!(" {}", type_indicator)
};
let lines = vec![
Line::from(Span::styled(display, name_style)),
Line::from(Span::styled(info_text, info_style)),
];
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Themes ({}) ", themes.len()),
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc/q: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}
fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let suggestions = app.command_suggestions();
// Only show suggestions if there are any
if suggestions.is_empty() {
return;
}
// Create a small popup near the status bar (bottom of screen)
let frame_height = frame.area().height;
let suggestion_count = suggestions.len().min(8); // Show max 8 suggestions
let popup_height = (suggestion_count as u16) + 2; // +2 for borders
// Position the popup above the status bar
let popup_area = Rect {
x: 1,
y: frame_height.saturating_sub(popup_height + 3), // 3 for status bar height
width: 40.min(frame.area().width - 2),
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.map(|(idx, cmd)| {
let is_selected = idx == app.selected_suggestion();
let style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
ListItem::new(Span::styled(cmd.to_string(), style))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
" Commands (Tab to complete) ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
frame.render_widget(list, popup_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, theme: &owlen_core::theme::Theme) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.info),
}
}