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(); // 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), _ => {} } } 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 = 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 = 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> { 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, inner: Rect, wrap_lines: bool, ) -> Option { 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 { 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 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 apply_visual_selection( lines: Vec, selection: Option<((usize, usize), (usize, usize))>, ) -> Vec { 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::raw(line_text[..start_byte].to_string())); } spans.push(Span::styled( line_text[start_byte..end_byte].to_string(), Style::default().bg(Color::LightBlue).fg(Color::Black), )); if end_byte < line_text.len() { spans.push(Span::raw(line_text[end_byte..].to_string())); } 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::raw(line_text[..start_byte].to_string())); } spans.push(Span::styled( line_text[start_byte..].to_string(), Style::default().bg(Color::LightBlue).fg(Color::Black), )); 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(Color::LightBlue).fg(Color::Black), )); if end_byte < line_text.len() { spans.push(Span::raw(line_text[end_byte..].to_string())); } Line::from(spans) } else { // Middle line - fully selected let styled_spans: Vec = line .spans .into_iter() .map(|span| { Span::styled( span.content, span.style.bg(Color::LightBlue).fg(Color::Black), ) }) .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) { // 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 = 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 = 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).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(Color::Yellow), )); } 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(Color::Magenta))); } 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(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("")); } } // 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(Color::LightMagenta) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {}", app.get_loading_indicator()), Style::default().fg(Color::Yellow), ), ]; 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)); } } // 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) { Color::LightMagenta } else { Color::Rgb(95, 20, 135) }; let paragraph = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)), ) .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 = 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) { 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 = chunks .into_iter() .map(|seg| { Line::from(Span::styled( seg.into_owned(), Style::default() .fg(Color::DarkGray) .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)); } } // 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) { Color::LightMagenta } else { Color::DarkGray }; let paragraph = Paragraph::new(lines) .block( Block::default() .title(Span::styled( " ๐Ÿ’ญ Thinking ", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), )) .borders(Borders::ALL) .border_style(Style::default().fg(border_color)), ) .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 = 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 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) { Color::LightMagenta } else { Color::Rgb(95, 20, 135) }; 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(border_color)); 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(Color::Yellow) .add_modifier(Modifier::BOLD), ))]; let paragraph = Paragraph::new(lines) .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 = if input_text.is_empty() { vec![Line::from("Press 'i' to start typing")] } else { input_text.lines().map(Line::from).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, { 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), InputMode::Visual => (" VISUAL", Color::Magenta), InputMode::Command => (" COMMAND", Color::Yellow), }; 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 :n:New :c:Clear :h:Help 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 = 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 = 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("MODES:"), Line::from(" Normal โ†’ default mode for navigation"), Line::from(" Insert โ†’ editing input text"), Line::from(" Visual โ†’ selecting text"), Line::from(" Command โ†’ executing commands (: prefix)"), Line::from(""), Line::from("PANEL NAVIGATION:"), Line::from(" Tab โ†’ cycle panels forward"), Line::from(" Shift+Tab โ†’ cycle panels backward"), Line::from(" (Panels: Chat, Thinking, Input)"), Line::from(""), Line::from("CURSOR MOVEMENT (Normal mode, Chat/Thinking panels):"), 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(" 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"), Line::from(""), Line::from("EDITING (Normal mode):"), 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(" dd โ†’ clear input buffer"), Line::from(" p โ†’ paste from clipboard to input"), Line::from(" Esc โ†’ return to normal mode"), Line::from(""), Line::from("INSERT MODE:"), Line::from(" Enter โ†’ send message"), Line::from(" Ctrl+J โ†’ insert newline"), Line::from(" Ctrl+โ†‘/โ†“ โ†’ navigate input history"), Line::from(" Ctrl+A โ†’ start of line"), Line::from(" Ctrl+E โ†’ end of line"), Line::from(" Ctrl+W โ†’ word forward"), Line::from(" Ctrl+B โ†’ word backward"), Line::from(" Esc โ†’ return to normal mode"), Line::from(""), Line::from("VISUAL MODE (all panels):"), Line::from(" v โ†’ enter visual mode at cursor"), Line::from(" h/j/k/l โ†’ extend selection left/down/up/right"), Line::from(" w / e / b โ†’ extend by word (start/end/back)"), Line::from(" 0 / ^ / $ โ†’ extend to line start/first char/end"), Line::from(" y โ†’ yank (copy) selection"), Line::from(" d โ†’ yank selection (delete in Input)"), Line::from(" v / Esc โ†’ exit visual mode"), Line::from(""), Line::from("COMMANDS (press : then type):"), Line::from(" :h, :help โ†’ show this help"), Line::from(" :m, :model โ†’ select model"), Line::from(" :n, :new โ†’ start new conversation"), Line::from(" :c, :clear โ†’ clear current conversation"), Line::from(" :q, :quit โ†’ quit application"), Line::from(""), Line::from("QUICK KEYS:"), Line::from(" q โ†’ quit (from normal mode)"), Line::from(" Ctrl+C โ†’ quit"), Line::from(""), Line::from("Press Esc or Enter 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), } }