Files
owlen/crates/owlen-tui/src/ui.rs
vikingowl 0bd560b408 feat(tui): display key hints in status bar and bind “?” to open help
- Add placeholder span showing shortcuts (i:Insert, m:Model, ?:Help, : Command) in the UI footer.
- Insert help section describing Enter key behavior in normal and insert modes.
- Extend F1 help shortcut to also trigger on “?” key (with no or Shift modifier).
2025-10-12 15:22:08 +02:00

2774 lines
99 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Line> = 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<Line> = Vec::new();
for (row_idx, line) in render_lines.iter().enumerate() {
let line_text = line.to_string();
let segments = wrap_line_segments(&line_text, content_width);
for (seg_idx, segment) in segments.into_iter().enumerate() {
// For the line with the cursor, preserve the cursor line style
if row_idx == cursor.0 && seg_idx == 0 {
wrapped_lines.push(Line::from(segment).patch_style(cursor_line_style));
} else {
wrapped_lines.push(Line::from(segment));
}
}
}
render_lines = wrapped_lines;
}
let mut paragraph = Paragraph::new(render_lines).style(base_style);
let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines);
if let Some(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<Span<'static>> {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
if row_idx < start_row || row_idx > end_row {
return vec![Span::raw(display_line.to_string())];
}
let char_count = display_line.chars().count();
let start = if row_idx == start_row {
start_col.min(char_count)
} else {
0
};
let end = if row_idx == end_row {
end_col.min(char_count)
} else {
char_count
};
if start >= end {
return vec![Span::raw(display_line.to_string())];
}
let start_byte = char_to_byte_idx(display_line, start);
let end_byte = char_to_byte_idx(display_line, end);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(display_line[..start_byte].to_string()));
}
spans.push(Span::styled(
display_line[start_byte..end_byte].to_string(),
selection_style,
));
if end_byte < display_line.len() {
spans.push(Span::raw(display_line[end_byte..].to_string()));
}
if spans.is_empty() {
spans.push(Span::raw(String::new()));
}
spans
} else {
vec![Span::raw(display_line.to_string())]
}
}
fn char_to_byte_idx(s: &str, char_idx: usize) -> usize {
if char_idx == 0 {
return 0;
}
let mut iter = s.char_indices();
for (i, (byte_idx, _)) in iter.by_ref().enumerate() {
if i == char_idx {
return byte_idx;
}
}
s.len()
}
struct CursorMetrics {
cursor_x: u16,
cursor_y: u16,
scroll_top: u16,
scroll_left: u16,
}
fn compute_cursor_metrics(
lines: &[String],
cursor: (usize, usize),
mask_char: Option<char>,
inner: Rect,
wrap_lines: bool,
) -> Option<CursorMetrics> {
if inner.width == 0 || inner.height == 0 {
return None;
}
let content_width = inner.width as usize;
let visible_height = inner.height as usize;
if content_width == 0 || visible_height == 0 {
return None;
}
let cursor_row = cursor.0.min(lines.len().saturating_sub(1));
let cursor_col = cursor.1;
let mut total_visual_rows = 0usize;
let mut cursor_visual_row = 0usize;
let mut cursor_col_width = 0usize;
let mut cursor_found = false;
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<String> {
if width == 0 {
return vec![String::new()];
}
if line.is_empty() {
return vec![String::new()];
}
// Manual wrapping that preserves all characters including spaces
let mut result = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for 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<Line<'a>>,
selection: Option<((usize, usize), (usize, usize))>,
theme: &owlen_core::theme::Theme,
) -> Vec<Line<'a>> {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
// Normalize selection (ensure start is before end)
let ((start_r, start_c), (end_r, end_c)) =
if start_row < end_row || (start_row == end_row && start_col <= end_col) {
((start_row, start_col), (end_row, end_col))
} else {
((end_row, end_col), (start_row, start_col))
};
lines
.into_iter()
.enumerate()
.map(|(idx, line)| {
if idx < start_r || idx > end_r {
// Line not in selection
return line;
}
// Convert line to plain text for character indexing
let line_text = line.to_string();
let char_count = line_text.chars().count();
if idx == start_r && idx == end_r {
// Selection within single line
let sel_start = start_c.min(char_count);
let sel_end = end_c.min(char_count);
if sel_start >= sel_end {
return line;
}
let start_byte = char_to_byte_index(&line_text, sel_start);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::styled(
line_text[..start_byte].to_string(),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
line_text[start_byte..end_byte].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
if end_byte < line_text.len() {
spans.push(Span::styled(
line_text[end_byte..].to_string(),
Style::default().fg(theme.text),
));
}
Line::from(spans)
} else if idx == start_r {
// First line of multi-line selection
let sel_start = start_c.min(char_count);
let start_byte = char_to_byte_index(&line_text, sel_start);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::styled(
line_text[..start_byte].to_string(),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
line_text[start_byte..].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
Line::from(spans)
} else if idx == end_r {
// Last line of multi-line selection
let sel_end = end_c.min(char_count);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
spans.push(Span::styled(
line_text[..end_byte].to_string(),
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg),
));
if end_byte < line_text.len() {
spans.push(Span::styled(
line_text[end_byte..].to_string(),
Style::default().fg(theme.text),
));
}
Line::from(spans)
} else {
// Middle line - fully selected
let styled_spans: Vec<Span> = line
.spans
.into_iter()
.map(|span| {
Span::styled(
span.content,
span.style.bg(theme.selection_bg).fg(theme.selection_fg),
)
})
.collect();
Line::from(styled_spans)
}
})
.collect()
} else {
lines
}
}
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
if char_idx == 0 {
return 0;
}
let mut iter = s.char_indices();
for (i, (byte_idx, _)) in iter.by_ref().enumerate() {
if i == char_idx {
return byte_idx;
}
}
s.len()
}
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
// Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4).max(20);
app.set_viewport_dimensions(viewport_height, usize::from(content_width));
let 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<Line<'static>> = 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<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
if let Some(thinking) = app.current_thinking().cloned() {
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let content_width = area.width.saturating_sub(4);
app.set_thinking_viewport_height(viewport_height);
let chunks = wrap(&thinking, content_width as usize);
let mut lines: Vec<Line> = chunks
.into_iter()
.map(|seg| {
Line::from(Span::styled(
seg.into_owned(),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
))
})
.collect();
// Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
&& let Some(selection) = app.visual_selection()
{
lines = apply_visual_selection(lines, Some(selection), &theme);
}
// Update AutoScroll state with accurate content length
let thinking_scroll = app.thinking_scroll_mut();
thinking_scroll.content_len = lines.len();
thinking_scroll.on_viewport(viewport_height);
let scroll_position = app.thinking_scroll_position().min(u16::MAX as usize) as u16;
// Highlight border if this panel is focused
let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) {
theme.focused_panel_border
} else {
theme.unfocused_panel_border
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" 💭 Thinking ",
Style::default()
.fg(theme.thinking_panel_title)
.add_modifier(Modifier::ITALIC),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.scroll((scroll_position, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Thinking)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.thinking_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize
&& cursor_row < (scroll_position as usize + viewport_height)
{
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
// Calculate actual display width by measuring characters up to cursor
let line_text = thinking.lines().nth(cursor_row).unwrap_or("");
let chars: Vec<char> = line_text.chars().collect();
let text_before_cursor: String = chars.iter().take(cursor_col).collect();
let display_width = UnicodeWidthStr::width(text_before_cursor.as_str());
let cursor_x = area.x + 1 + display_width as u16; // +1 for border only
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
// 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<Line> = 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<Line> = if input_text.is_empty() {
vec![Line::from(Span::styled(
"Press 'i' to start typing",
Style::default().fg(theme.placeholder),
))]
} else {
input_text
.lines()
.map(|l| Line::from(Span::styled(l, Style::default().fg(theme.text))))
.collect()
};
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(input_block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
}
fn 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<Item = &'a str>,
{
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<Line> = 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<ListItem> = app
.available_providers
.iter()
.map(|provider| {
ListItem::new(Span::styled(
provider.to_string(),
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(Span::styled(
"Select Provider",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.highlight_style(
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
let mut state = ListState::default();
state.select(Some(app.selected_provider_index));
frame.render_stateful_widget(list, area, &mut state);
}
fn 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<ListItem> = 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(
" <model unavailable>",
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 <tool> - Enable tool"));
lines.push(Line::from(" :privacy-disable <tool> - 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 <name> → switch to a specific theme"),
Line::from(""),
Line::from(vec![Span::styled(
"SESSION MANAGEMENT",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :save [name] → save current session (with optional name)"),
Line::from(" :w [name] → alias for :save"),
Line::from(" :load, :o → 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 <path> → open file in code side panel"),
Line::from(" :close → close the code side panel"),
// New mode and tool commands added in phases 05
Line::from(" :code → switch to code mode (CLI: owlen --code)"),
Line::from(" :mode <chat|code> → 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<ListItem> = sessions
.iter()
.enumerate()
.map(|(idx, session)| {
let name = session.name.as_deref().unwrap_or("Unnamed session");
let created = session
.created_at
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_hours = (now - created) / 3600;
let age_str = if age_hours < 1 {
"< 1h ago".to_string()
} else if age_hours < 24 {
format!("{}h ago", age_hours)
} else {
format!("{}d ago", age_hours / 24)
};
let info = format!(
"{} messages · {} · {}",
session.message_count, session.model, age_str
);
let is_selected = idx == app.selected_session_index();
let style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let info_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
} else {
Style::default().fg(theme.placeholder)
};
let desc_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC)
};
let mut lines = vec![Line::from(Span::styled(name, style))];
// Add description if available and not empty
if let Some(description) = &session.description
&& !description.is_empty()
{
lines.push(Line::from(Span::styled(
format!(" \"{}\"", description),
desc_style,
)));
}
// Add metadata line
lines.push(Line::from(Span::styled(format!(" {}", info), info_style)));
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Saved Sessions ({}) ", sessions.len()),
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}
fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let area = centered_rect(60, 70, frame.area());
frame.render_widget(Clear, area);
let themes = app.available_themes();
let current_theme_name = &app.theme().name;
if themes.is_empty() {
let text = vec![
Line::from(""),
Line::from("No themes available."),
Line::from(""),
Line::from("Press Esc to close."),
];
let paragraph = Paragraph::new(text)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Span::styled(
" Themes ",
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
return;
}
// Get theme metadata to show built-in vs custom
let all_themes = owlen_core::theme::load_all_themes();
let built_in = owlen_core::theme::built_in_themes();
let items: Vec<ListItem> = themes
.iter()
.enumerate()
.map(|(idx, theme_name)| {
let is_current = theme_name == current_theme_name;
let is_selected = idx == app.selected_theme_index();
let is_built_in = built_in.contains_key(theme_name);
// Build display name
let mut display = theme_name.clone();
if is_current {
display.push_str("");
}
let type_indicator = if is_built_in { "built-in" } else { "custom" };
let name_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let info_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
} else {
Style::default().fg(theme.placeholder)
};
// Try to get theme description or show type
let info_text = if all_themes.contains_key(theme_name) {
format!(" {} · {}", type_indicator, theme_name)
} else {
format!(" {}", type_indicator)
};
let lines = vec![
Line::from(Span::styled(display, name_style)),
Line::from(Span::styled(info_text, info_style)),
];
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Themes ({}) ", themes.len()),
Style::default()
.fg(theme.mode_help)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.mode_help))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc/q: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(theme.placeholder).bg(theme.background));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(3)])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}
fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
let suggestions = app.command_suggestions();
// Only show suggestions if there are any
if suggestions.is_empty() {
return;
}
// Create a small popup near the status bar (bottom of screen)
let frame_height = frame.area().height;
let suggestion_count = suggestions.len().min(8); // Show max 8 suggestions
let popup_height = (suggestion_count as u16) + 2; // +2 for borders
// Position the popup above the status bar
let popup_area = Rect {
x: 1,
y: frame_height.saturating_sub(popup_height + 3), // 3 for status bar height
width: 40.min(frame.area().width - 2),
height: popup_height,
};
frame.render_widget(Clear, popup_area);
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.map(|(idx, cmd)| {
let is_selected = idx == app.selected_suggestion();
let style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
ListItem::new(Span::styled(cmd.to_string(), style))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
" Commands (Tab to complete) ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
frame.render_widget(list, popup_area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(vertical[1])[1]
}
/// 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::<serde_json::Value>(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("<b>", "").replace("</b>", "");
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("<b>", "")
.replace("</b>", "")
.replace("&#x27;", "'")
.replace("&quot;", "\"");
// 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()
}
}