use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; use textwrap::wrap; use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; use owlen_core::model::DetailedModelInfo; use owlen_core::theme::Theme; use owlen_core::types::{ModelInfo, Role}; use owlen_core::ui::{FocusedPanel, InputMode}; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; 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)); let full_area = frame.area(); frame.render_widget(background_block, full_area); let (chat_area, code_area) = if app.should_show_code_view() { let segments = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) .split(full_area); (segments[0], Some(segments[1])) } else { (full_area, None) }; // Calculate dynamic input height based on textarea content let available_width = chat_area.width; let max_input_rows = usize::from(app.input_max_rows()).max(1); let visual_lines = if matches!(app.mode(), InputMode::Editing | InputMode::Visual) { calculate_wrapped_line_count( app.textarea().lines().iter().map(|s| s.as_str()), available_width, ) } else { let buffer_text = app.input_buffer().text(); let lines: Vec<&str> = if buffer_text.is_empty() { vec![""] } else { buffer_text.split('\n').collect() }; calculate_wrapped_line_count(lines, available_width) }; let visible_rows = visual_lines.max(1).min(max_input_rows); let input_height = visible_rows as u16 + 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 }; // Calculate agent actions panel height (similar to thinking) let actions_height = if let Some(actions) = app.agent_actions() { let content_width = available_width.saturating_sub(4); let visual_lines = calculate_wrapped_line_count(actions.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 } // Insert agent actions panel after thinking (if any) if actions_height > 0 { constraints.push(Constraint::Length(actions_height)); // Agent actions } constraints.push(Constraint::Length(input_height)); // Input constraints.push(Constraint::Length(5)); // System/Status output (3 lines content + 2 borders) constraints.push(Constraint::Length(3)); // Mode and shortcuts bar let layout = Layout::default() .direction(Direction::Vertical) .constraints(constraints) .split(chat_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 agent actions panel if present if actions_height > 0 { render_agent_actions(frame, layout[idx], app); idx += 1; } render_input(frame, layout[idx], app); idx += 1; render_system_output(frame, layout[idx], app); idx += 1; render_status(frame, layout[idx], app); // Render consent dialog with highest priority (always on top) if app.has_pending_consent() { render_consent_dialog(frame, app); } else { 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), _ => {} } } if app.is_model_info_visible() { let panel_width = full_area .width .saturating_div(3) .max(30) .min(full_area.width.saturating_sub(20).max(30)); let x = full_area.x + full_area.width.saturating_sub(panel_width); let area = Rect::new(x, full_area.y, panel_width, full_area.height); frame.render_widget(Clear, area); let viewport_height = area.height.saturating_sub(2) as usize; app.set_model_info_viewport_height(viewport_height); app.model_info_panel_mut().render(frame, area, &theme); } if let Some(area) = code_area { render_code_view(frame, area, app); } } fn render_editable_textarea( frame: &mut Frame<'_>, area: Rect, textarea: &mut TextArea<'static>, mut wrap_lines: bool, theme: &Theme, ) { 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(theme.placeholder)); 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(metrics) = metrics .as_ref() .filter(|metrics| metrics.scroll_top > 0 || metrics.scroll_left > 0) { paragraph = paragraph.scroll((metrics.scroll_top, metrics.scroll_left)); } 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, scroll_left: 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; let mut cursor_line_total_width = 0usize; 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 { cursor_line_total_width = segments .iter() .map(|segment| UnicodeWidthStr::width(segment.as_str())) .sum(); let mut remaining = cursor_col; let mut segment_base_row = total_visual_rows; 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; segment_base_row += 1; continue; } if remaining == segment_len && !is_last_segment { cursor_visual_row = segment_base_row + 1; cursor_col_width = 0; cursor_found = true; break; } let prefix_byte = char_to_byte_idx(segment, remaining); let prefix = &segment[..prefix_byte]; cursor_visual_row = segment_base_row; cursor_col_width = UnicodeWidthStr::width(prefix); cursor_found = true; break; } if !cursor_found && let Some(last_segment) = segments.last() { cursor_visual_row = segment_base_row + 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 mut scroll_left = 0usize; if !wrap_lines && content_width > 0 { let max_scroll_left = cursor_line_total_width.saturating_sub(content_width); if cursor_col_width + 1 > content_width { scroll_left = cursor_col_width + 1 - content_width; } if scroll_left > max_scroll_left { scroll_left = max_scroll_left; } } let visible_cursor_col = cursor_col_width.saturating_sub(scroll_left); let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top); let max_x = content_width.saturating_sub(1); let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16; let cursor_x = inner.x + visible_cursor_col.min(max_x) as u16; Some(CursorMetrics { cursor_x, cursor_y, scroll_top: scroll_top as u16, scroll_left: scroll_left.min(u16::MAX as usize) 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 grapheme in line.graphemes(true) { let grapheme_width = UnicodeWidthStr::width(grapheme); // If adding this character would exceed width, wrap to next line if current_width + grapheme_width > width && !current.is_empty() { result.push(current); current = String::new(); current_width = 0; } // If even a single grapheme is too wide, add it as its own line if grapheme_width > width { result.push(grapheme.to_string()); continue; } current.push_str(grapheme); current_width += grapheme_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_label = app.active_model_label(); let model_with_provider_span = Span::styled( format!("{} ({})", model_label, app.current_provider()), Style::default() .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), ); 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::default(), Line::from(vec![ Span::raw(" "), Span::styled("Model (Provider): ", Style::default().fg(theme.placeholder)), model_with_provider_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>, selection: Option<((usize, usize), (usize, usize))>, theme: &owlen_core::theme::Theme, ) -> 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::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 = 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 total_messages = app.message_count(); 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 using cached rendering let mut lines: Vec> = Vec::new(); let show_role_labels = formatter.show_role_labels(); for message_index in 0..total_messages { let is_streaming = { let conversation = app.conversation(); conversation.messages[message_index] .metadata .get("streaming") .and_then(|v| v.as_bool()) .unwrap_or(false) }; let message_lines = app.render_message_lines_cached( message_index, MessageRenderContext::new( &mut formatter, show_role_labels, content_width as usize, message_index + 1 == total_messages, is_streaming, app.get_loading_indicator(), &theme, ), ); lines.extend(message_lines); if message_index + 1 < total_messages { lines.push(Line::from(String::new())); } } // 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 = if total_messages == 0 { true } else { let conversation = app.conversation(); 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.")); } let scrollback_limit = app.scrollback_limit(); if scrollback_limit != usize::MAX && lines.len() > scrollback_limit { let removed = lines.len() - scrollback_limit; lines = lines.into_iter().skip(removed).collect(); app.apply_chat_scrollback_trim(removed, lines.len()); } else { app.apply_chat_scrollback_trim(0, lines.len()); } // 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) && 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); if app.has_new_message_alert() { let badge_text = "โ†“ New messages (press G)"; let text_width = badge_text.chars().count() as u16; let badge_width = text_width.saturating_add(2); if area.width > badge_width + 1 && area.height > 2 { let badge_x = area.x + area.width.saturating_sub(badge_width + 1); let badge_y = area.y + 1; let badge_area = Rect::new(badge_x, badge_y, badge_width, 1); frame.render_widget(Clear, badge_area); let badge_line = Line::from(Span::styled( format!(" {badge_text} "), Style::default() .fg(theme.background) .bg(theme.info) .add_modifier(Modifier::BOLD), )); frame.render_widget( Paragraph::new(badge_line) .style(Style::default().bg(theme.info).fg(theme.background)) .alignment(Alignment::Center), badge_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) { 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 = 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) && 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 = 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)); } } } } // Render a panel displaying the latest ReAct agent actions (thought/action/observation). // Color-coded: THOUGHT (blue), ACTION (yellow), OBSERVATION (green) fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); if let Some(actions) = app.agent_actions().cloned() { let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders let content_width = area.width.saturating_sub(4); // Parse and color-code ReAct components let mut lines: Vec = Vec::new(); for line in actions.lines() { let line_trimmed = line.trim(); // Detect ReAct components and apply color coding if line_trimmed.starts_with("THOUGHT:") { let thought_color = theme.agent_thought; let thought_content = line_trimmed.strip_prefix("THOUGHT:").unwrap_or("").trim(); let wrapped = wrap(thought_content, content_width as usize); // First line with label if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ Span::styled( "THOUGHT: ", Style::default() .fg(thought_color) .add_modifier(Modifier::BOLD), ), Span::styled(first.to_string(), Style::default().fg(thought_color)), ])); } // Continuation lines for chunk in wrapped.iter().skip(1) { lines.push(Line::from(Span::styled( format!(" {}", chunk), Style::default().fg(thought_color), ))); } } else if line_trimmed.starts_with("ACTION:") { let action_color = theme.agent_action; let action_content = line_trimmed.strip_prefix("ACTION:").unwrap_or("").trim(); lines.push(Line::from(vec![ Span::styled( "ACTION: ", Style::default() .fg(action_color) .add_modifier(Modifier::BOLD), ), Span::styled( action_content, Style::default() .fg(action_color) .add_modifier(Modifier::BOLD), ), ])); } else if line_trimmed.starts_with("ACTION_INPUT:") { let input_color = theme.agent_action_input; let input_content = line_trimmed .strip_prefix("ACTION_INPUT:") .unwrap_or("") .trim(); let wrapped = wrap(input_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ Span::styled( "ACTION_INPUT: ", Style::default() .fg(input_color) .add_modifier(Modifier::BOLD), ), Span::styled(first.to_string(), Style::default().fg(input_color)), ])); } for chunk in wrapped.iter().skip(1) { lines.push(Line::from(Span::styled( format!(" {}", chunk), Style::default().fg(input_color), ))); } } else if line_trimmed.starts_with("OBSERVATION:") { let observation_color = theme.agent_observation; let obs_content = line_trimmed .strip_prefix("OBSERVATION:") .unwrap_or("") .trim(); let wrapped = wrap(obs_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ Span::styled( "OBSERVATION: ", Style::default() .fg(observation_color) .add_modifier(Modifier::BOLD), ), Span::styled(first.to_string(), Style::default().fg(observation_color)), ])); } for chunk in wrapped.iter().skip(1) { lines.push(Line::from(Span::styled( format!(" {}", chunk), Style::default().fg(observation_color), ))); } } else if line_trimmed.starts_with("FINAL_ANSWER:") { let answer_color = theme.agent_final_answer; let answer_content = line_trimmed .strip_prefix("FINAL_ANSWER:") .unwrap_or("") .trim(); let wrapped = wrap(answer_content, content_width as usize); if let Some(first) = wrapped.first() { lines.push(Line::from(vec![ Span::styled( "FINAL_ANSWER: ", Style::default() .fg(answer_color) .add_modifier(Modifier::BOLD), ), Span::styled( first.to_string(), Style::default() .fg(answer_color) .add_modifier(Modifier::BOLD), ), ])); } for chunk in wrapped.iter().skip(1) { lines.push(Line::from(Span::styled( format!(" {}", chunk), Style::default().fg(answer_color), ))); } } else if !line_trimmed.is_empty() { // Regular text let wrapped = wrap(line_trimmed, content_width as usize); for chunk in wrapped { lines.push(Line::from(Span::styled( chunk.into_owned(), Style::default().fg(theme.text), ))); } } else { // Empty line lines.push(Line::from("")); } } // Highlight border if this panel is focused let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { // Reuse the same focus logic; could add a dedicated enum variant later. 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( " ๐Ÿค– Agent Actions ", 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)), ) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); _ = viewport_height; } } fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); 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, &theme); } 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, &theme); } 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 = 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 render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); let system_status = app.system_status(); // Priority: system_status > error > status > "Ready" let display_message = if !system_status.is_empty() { system_status.to_string() } else if let Some(error) = app.error_message() { format!("Error: {}", error) } else { let status = app.status_message(); if status.is_empty() || status == "Ready" { "Ready".to_string() } else { status.to_string() } }; // Create a simple paragraph with wrapping enabled let line = Line::from(Span::styled( display_message, Style::default().fg(theme.info), )); let paragraph = Paragraph::new(line) .style(Style::default().bg(theme.background)) .block( Block::default() .title(Span::styled( " System/Status ", Style::default().fg(theme.info).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)), ) .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) as usize; // subtract block borders let mut total = 0usize; let mut seen = false; for line in lines.into_iter() { seen = true; if content_width == 0 || line.is_empty() { total += 1; continue; } total += wrap_line_segments(line, content_width).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 help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit"; let mut spans = vec![Span::styled( format!(" {} ", mode_text), Style::default() .fg(theme.background) .bg(mode_bg_color) .add_modifier(Modifier::BOLD), )]; // Add agent status indicator if agent mode is active if app.is_agent_running() { spans.push(Span::styled( " ๐Ÿค– AGENT RUNNING ", Style::default() .fg(theme.agent_badge_running_fg) .bg(theme.agent_badge_running_bg) .add_modifier(Modifier::BOLD), )); } else if app.is_agent_mode() { spans.push(Span::styled( " ๐Ÿค– AGENT MODE ", Style::default() .fg(theme.agent_badge_idle_fg) .bg(theme.agent_badge_idle_bg) .add_modifier(Modifier::BOLD), )); } // Add operating mode indicator let operating_mode = app.get_mode(); let (op_mode_text, op_mode_fg, op_mode_bg) = match operating_mode { owlen_core::mode::Mode::Chat => { (" ๐Ÿ’ฌ CHAT", theme.operating_chat_fg, theme.operating_chat_bg) } owlen_core::mode::Mode::Code => { (" ๐Ÿ’ป CODE", theme.operating_code_fg, theme.operating_code_bg) } }; spans.push(Span::styled( op_mode_text, Style::default() .fg(op_mode_fg) .bg(op_mode_bg) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled(" ", Style::default().fg(theme.text))); spans.push(Span::styled( "Provider: ", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), )); spans.push(Span::styled( app.current_provider().to_string(), Style::default().fg(theme.text), )); spans.push(Span::styled( " i:Insert m:Model ?:Help : Command", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), )); spans.push(Span::styled(" ", Style::default().fg(theme.text))); spans.push(Span::styled( "Model: ", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), )); spans.push(Span::styled( app.selected_model().to_string(), Style::default() .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled(" ", Style::default().fg(theme.text))); spans.push(Span::styled(help_text, Style::default().fg(theme.info))); let paragraph = Paragraph::new(Line::from(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)), ); frame.render_widget(paragraph, area); } fn render_code_view(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let path = match app.code_view_path() { Some(p) => p.to_string(), None => { frame.render_widget(Clear, area); return; } }; let theme = app.theme().clone(); frame.render_widget(Clear, area); let viewport_height = area.height.saturating_sub(2) as usize; app.set_code_view_viewport_height(viewport_height); let mut lines: Vec = Vec::new(); if app.code_view_lines().is_empty() { lines.push(Line::from(Span::styled( "(empty file)", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), ))); } else { for (idx, content) in app.code_view_lines().iter().enumerate() { let number = format!("{:>4} ", idx + 1); let spans = vec![ Span::styled( number, Style::default() .fg(theme.placeholder) .add_modifier(Modifier::DIM), ), Span::styled(content.clone(), Style::default().fg(theme.text)), ]; lines.push(Line::from(spans)); } } let scroll_state = app.code_view_scroll_mut(); scroll_state.content_len = lines.len(); scroll_state.on_viewport(viewport_height); let scroll_position = scroll_state.scroll.min(u16::MAX as usize) as u16; let border_color = if matches!(app.focused_panel(), FocusedPanel::Code) { theme.focused_panel_border } else { theme.unfocused_panel_border }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style(Style::default().bg(theme.background).fg(theme.text)) .title(Span::styled( path, Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )); let paragraph = Paragraph::new(lines) .style(Style::default().bg(theme.background).fg(theme.text)) .block(block) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } 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 = 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 model_badge_icons(model: &ModelInfo) -> Vec<&'static str> { let mut badges = Vec::new(); if model.supports_tools { badges.push("๐Ÿ”ง"); } if model_has_feature(model, &["think", "reason"]) { badges.push("๐Ÿง "); } if model_has_feature(model, &["vision", "multimodal", "image"]) { badges.push("๐Ÿ‘๏ธ"); } if model_has_feature(model, &["audio", "speech", "voice"]) { badges.push("๐ŸŽง"); } badges } fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool { let name_lower = model.name.to_ascii_lowercase(); if keywords.iter().any(|kw| name_lower.contains(kw)) { return true; } if let Some(description) = &model.description { let description_lower = description.to_ascii_lowercase(); if keywords.iter().any(|kw| description_lower.contains(kw)) { return true; } } model.capabilities.iter().any(|cap| { let lower = cap.to_ascii_lowercase(); keywords.iter().any(|kw| lower.contains(kw)) }) } 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 = app .model_selector_items() .iter() .map(|item| match item.kind() { ModelSelectorItemKind::Header { provider, expanded } => { let marker = if *expanded { "โ–ผ" } else { "โ–ถ" }; let label = format!("{} {}", marker, provider); ListItem::new(Span::styled( label, Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )) } ModelSelectorItemKind::Model { model_index, .. } => { if let Some(model) = app.model_info_by_index(*model_index) { let badges = model_badge_icons(model); let detail = app.cached_model_detail(&model.id); let label = build_model_selector_label(model, detail, &badges); ListItem::new(Span::styled( label, Style::default() .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), )) } else { ListItem::new(Span::styled( " ", Style::default().fg(theme.error), )) } } ModelSelectorItemKind::Empty { provider } => ListItem::new(Span::styled( format!(" (no models configured for {provider})"), Style::default() .fg(theme.unfocused_panel_border) .add_modifier(Modifier::ITALIC), )), }) .collect(); let list = List::new(items) .block( Block::default() .title(Span::styled( "Select Model โ€” ๐Ÿ”ง tools โ€ข ๐Ÿง  thinking โ€ข ๐Ÿ‘๏ธ vision โ€ข ๐ŸŽง audio", 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_item()); frame.render_stateful_widget(list, area, &mut state); } fn build_model_selector_label( model: &ModelInfo, detail: Option<&DetailedModelInfo>, badges: &[&'static str], ) -> String { let mut parts = vec![model.id.clone()]; if let Some(detail) = detail { if let Some(parameters) = detail .parameter_size .as_ref() .or(detail.parameters.as_ref()) && !parameters.trim().is_empty() { parts.push(parameters.trim().to_string()); } if let Some(size) = detail.size { parts.push(format_short_size(size)); } if let Some(ctx) = detail.context_length { parts.push(format!("ctx {}", ctx)); } } let mut label = format!(" {}", parts.join(" โ€ข ")); if !badges.is_empty() { label.push(' '); label.push_str(&badges.join(" ")); } label } fn format_short_size(bytes: u64) -> String { if bytes >= 1_000_000_000 { format!("{:.1} GB", bytes as f64 / 1_000_000_000_f64) } else if bytes >= 1_000_000 { format!("{:.1} MB", bytes as f64 / 1_000_000_f64) } else if bytes >= 1_000 { format!("{:.1} KB", bytes as f64 / 1_000_f64) } else { format!("{} B", bytes) } } fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); // Get consent dialog state let consent_state = match app.consent_dialog() { Some(state) => state, None => return, }; // Create centered modal area let area = centered_rect(70, 50, frame.area()); frame.render_widget(Clear, area); // Build consent dialog content let mut lines = vec![ Line::from(vec![ Span::styled("๐Ÿ”’ ", Style::default().fg(theme.focused_panel_border)), Span::styled( "Consent Required", Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), ), ]), Line::from(""), Line::from(vec![ Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( consent_state.tool_name.clone(), Style::default().fg(theme.user_message_role), ), ]), Line::from(""), ]; // Add data types if any if !consent_state.data_types.is_empty() { lines.push(Line::from(Span::styled( "Data Access:", Style::default().add_modifier(Modifier::BOLD), ))); for data_type in &consent_state.data_types { lines.push(Line::from(vec![ Span::raw(" โ€ข "), Span::styled(data_type, Style::default().fg(theme.text)), ])); } lines.push(Line::from("")); } // Add endpoints if any if !consent_state.endpoints.is_empty() { lines.push(Line::from(Span::styled( "Endpoints:", Style::default().add_modifier(Modifier::BOLD), ))); for endpoint in &consent_state.endpoints { lines.push(Line::from(vec![ Span::raw(" โ€ข "), Span::styled(endpoint, Style::default().fg(theme.text)), ])); } lines.push(Line::from("")); } // Add prompt lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( "Choose consent scope:", Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )])); lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled( "[1] ", Style::default() .fg(theme.mode_provider_selection) .add_modifier(Modifier::BOLD), ), Span::raw("Allow once "), Span::styled( "- Grant only for this operation", Style::default().fg(theme.placeholder), ), ])); lines.push(Line::from(vec![ Span::styled( "[2] ", Style::default() .fg(theme.mode_editing) .add_modifier(Modifier::BOLD), ), Span::raw("Allow session "), Span::styled( "- Grant for current session", Style::default().fg(theme.placeholder), ), ])); lines.push(Line::from(vec![ Span::styled( "[3] ", Style::default() .fg(theme.mode_model_selection) .add_modifier(Modifier::BOLD), ), Span::raw("Allow always "), Span::styled( "- Grant permanently", Style::default().fg(theme.placeholder), ), ])); lines.push(Line::from(vec![ Span::styled( "[4] ", Style::default() .fg(theme.error) .add_modifier(Modifier::BOLD), ), Span::raw("Deny "), Span::styled( "- Reject this operation", Style::default().fg(theme.placeholder), ), ])); lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled( "[Esc] ", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::BOLD), ), Span::raw("Cancel"), ])); let paragraph = Paragraph::new(lines) .block( Block::default() .title(Span::styled( " Consent Dialog ", Style::default() .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(theme.focused_panel_border)) .style(Style::default().bg(theme.background)), ) .alignment(Alignment::Left) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, area); } #[cfg(test)] mod tests { use super::*; fn model_with(capabilities: Vec<&str>, description: Option<&str>) -> ModelInfo { ModelInfo { id: "model".into(), name: "model".into(), description: description.map(|s| s.to_string()), provider: "test".into(), context_window: None, capabilities: capabilities.into_iter().map(|s| s.to_string()).collect(), supports_tools: false, } } #[test] fn badges_include_tool_icon() { let model = ModelInfo { id: "tool-model".into(), name: "tool-model".into(), description: None, provider: "test".into(), context_window: None, capabilities: vec![], supports_tools: true, }; assert!(model_badge_icons(&model).contains(&"๐Ÿ”ง")); } #[test] fn badges_detect_thinking_capability() { let model = model_with(vec!["Thinking"], None); let icons = model_badge_icons(&model); assert!(icons.contains(&"๐Ÿง ")); } #[test] fn badges_detect_vision_from_description() { let model = model_with(vec!["chat"], Some("Supports multimodal vision")); let icons = model_badge_icons(&model); assert!(icons.contains(&"๐Ÿ‘๏ธ")); } #[test] fn badges_detect_audio_from_name() { let model = ModelInfo { id: "voice-specialist".into(), name: "Voice-Specialist".into(), description: None, provider: "test".into(), context_window: None, capabilities: vec![], supports_tools: false, }; let icons = model_badge_icons(&model); assert!(icons.contains(&"๐ŸŽง")); } } fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); let config = app.config(); let block = Block::default() .title("Privacy Settings") .borders(Borders::ALL) .border_style(Style::default().fg(theme.unfocused_panel_border)) .style(Style::default().bg(theme.background).fg(theme.text)); let inner = block.inner(area); frame.render_widget(block, area); let remote_search_enabled = config.privacy.enable_remote_search && config.tools.web_search.enabled; let code_exec_enabled = config.tools.code_exec.enabled; let history_days = config.privacy.retain_history_days; let cache_results = config.privacy.cache_web_results; let consent_required = config.privacy.require_consent_per_session; let encryption_enabled = config.privacy.encrypt_local_data; let status_line = |label: &str, enabled: bool| { let status_text = if enabled { "Enabled" } else { "Disabled" }; let status_style = if enabled { Style::default().fg(theme.selection_fg) } else { Style::default().fg(theme.error) }; Line::from(vec![ Span::raw(format!(" {label}: ")), Span::styled(status_text, status_style), ]) }; let mut lines = Vec::new(); lines.push(Line::from(vec![Span::styled( "Privacy Configuration", Style::default().fg(theme.info).add_modifier(Modifier::BOLD), )])); lines.push(Line::raw("")); lines.push(Line::from("Network Access:")); lines.push(status_line("Web Search", remote_search_enabled)); lines.push(status_line("Code Execution", code_exec_enabled)); lines.push(Line::raw("")); lines.push(Line::from("Data Retention:")); lines.push(Line::from(format!( " History retention: {} day(s)", history_days ))); lines.push(Line::from(format!( " Cache web results: {}", if cache_results { "Yes" } else { "No" } ))); lines.push(Line::raw("")); lines.push(Line::from("Safeguards:")); lines.push(status_line("Consent required", consent_required)); lines.push(status_line("Encrypted storage", encryption_enabled)); lines.push(Line::raw("")); lines.push(Line::from("Commands:")); lines.push(Line::from(" :privacy-enable - Enable tool")); lines.push(Line::from(" :privacy-disable - Disable tool")); lines.push(Line::from(" :privacy-clear - Clear all data")); let paragraph = Paragraph::new(lines) .wrap(Wrap { trim: true }) .style(Style::default().bg(theme.background).fg(theme.text)); frame.render_widget(paragraph, inner); } 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 = [ "Navigation", "Editing", "Visual", "Commands", "Sessions", "Browsers", "Privacy", ]; // 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 mut 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( "ENTER KEY BEHAVIOUR", Style::default().add_modifier(Modifier::BOLD).fg(theme.info), )]), Line::from(" Normal mode โ†’ press Enter to send the current message"), Line::from(" Insert mode โ†’ Enter sends ยท Shift+Enter inserts newline"), 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 โ†’ 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 โ†’ browse and load saved sessions"), Line::from(" :sessions, :ls โ†’ browse saved sessions"), Line::from(""), Line::from(vec![Span::styled( "CODE VIEW", Style::default() .add_modifier(Modifier::BOLD) .fg(theme.user_message_role), )]), Line::from(" :open โ†’ open file in code side panel"), Line::from(" :close โ†’ close the code side panel"), // New mode and tool commands added in phases 0โ€‘5 Line::from(" :code โ†’ switch to code mode (CLI: owlen --code)"), Line::from(" :mode โ†’ change current mode explicitly"), Line::from(" :tools โ†’ list tools available in the current mode"), Line::from(" :agent status โ†’ show agent configuration and iteration info"), Line::from(" :stop-agent โ†’ abort a running ReAct agent loop"), ], 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 โ†’ 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"), ], 6 => vec![], _ => vec![], }; help_text.insert( 0, Line::from(vec![ Span::styled( "Current Theme: ", Style::default() .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), ), Span::styled( theme.name.clone(), Style::default() .fg(theme.mode_model_selection) .add_modifier(Modifier::BOLD), ), ]), ); help_text.insert(1, Line::from("")); // 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 if tab_index == PRIVACY_TAB_INDEX { render_privacy_settings(frame, layout[1], app); } else { 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( format!("1-{}", HELP_TAB_COUNT), 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 = 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 && !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 = 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 = 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] } /// Format tool output JSON into a nice human-readable format pub(crate) fn format_tool_output(content: &str) -> String { // Try to parse as JSON if let Ok(json) = serde_json::from_str::(content) { let mut output = String::new(); let mut content_found = false; // Extract query if present if let Some(query) = json.get("query").and_then(|v| v.as_str()) { output.push_str(&format!("Query: \"{}\"\n\n", query)); content_found = true; } // Extract results array if let Some(results) = json.get("results").and_then(|v| v.as_array()) { content_found = true; if results.is_empty() { output.push_str("No results found"); return output; } for (i, result) in results.iter().enumerate() { // Title if let Some(title) = result.get("title").and_then(|v| v.as_str()) { // Strip HTML tags from title let clean_title = title.replace("", "").replace("", ""); output.push_str(&format!("{}. {}\n", i + 1, clean_title)); } // Source and date (if available) let mut meta = Vec::new(); if let Some(source) = result.get("source").and_then(|v| v.as_str()) { meta.push(format!("๐Ÿ“ฐ {}", source)); } if let Some(date) = result.get("date").and_then(|v| v.as_str()) { // Simplify date format if let Some(simple_date) = date.split('T').next() { meta.push(format!("๐Ÿ“… {}", simple_date)); } } if !meta.is_empty() { output.push_str(&format!(" {}\n", meta.join(" โ€ข "))); } // Snippet (truncated if too long) if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str()) && !snippet.is_empty() { // Strip HTML tags let clean_snippet = snippet .replace("", "") .replace("", "") .replace("'", "'") .replace(""", "\""); // Truncate if too long let truncated = if clean_snippet.len() > 200 { format!("{}...", &clean_snippet[..197]) } else { clean_snippet }; output.push_str(&format!(" {}\n", truncated)); } // URL (shortened if too long) if let Some(url) = result.get("url").and_then(|v| v.as_str()) { let display_url = if url.len() > 80 { format!("{}...", &url[..77]) } else { url.to_string() }; output.push_str(&format!(" ๐Ÿ”— {}\n", display_url)); } output.push('\n'); } // Add total count if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) { output.push_str(&format!("Found {} result(s)", total)); } } else if let Some(result) = json.get("result").and_then(|v| v.as_str()) { content_found = true; output.push_str(result); } else if let Some(error) = json.get("error").and_then(|v| v.as_str()) { content_found = true; // Handle error results output.push_str(&format!("โŒ Error: {}", error)); } if content_found { output } else { content.to_string() } } else { // If not JSON, return as-is content.to_string() } }