Files
owlen/crates/owlen-tui/src/ui.rs
vikingowl ae0dd3fc51 feat(ui): shrink system/status output height and improve file panel toggle feedback
- Adjust layout constraint from 5 to 4 lines to match 2 lines of content plus borders.
- Refactor file focus key handling to toggle the file panel state and set status messages (“Files panel shown” / “Files panel hidden”) instead of always expanding and using a static status.
2025-10-13 19:18:50 +02:00

4383 lines
152 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 pathdiff::diff_paths;
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 std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind};
use crate::state::{
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
RepoSearchRowKind, SplitAxis, VisibleFileEntry,
};
use crate::toast::{Toast, ToastLevel};
use owlen_core::model::DetailedModelInfo;
use owlen_core::theme::Theme;
use owlen_core::types::{ModelInfo, Role};
use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
use textwrap::wrap;
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
fn focus_beacon_span(is_active: bool, is_focused: bool, theme: &Theme) -> Span<'static> {
if !is_active {
return Span::styled(" ", Style::default().fg(theme.unfocused_beacon_fg));
}
if is_focused {
Span::styled(
"",
Style::default()
.fg(theme.focus_beacon_fg)
.bg(theme.focus_beacon_bg),
)
} else {
Span::styled("", Style::default().fg(theme.unfocused_beacon_fg))
}
}
fn panel_title_spans(
label: impl Into<String>,
is_active: bool,
is_focused: bool,
theme: &Theme,
) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(focus_beacon_span(is_active, is_focused, theme));
spans.push(Span::raw(" "));
let mut label_style = Style::default().fg(theme.pane_header_inactive);
if is_active {
label_style = Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD);
if !is_focused {
label_style = label_style.add_modifier(Modifier::DIM);
}
} else {
label_style = label_style.add_modifier(Modifier::DIM);
}
spans.push(Span::styled(label.into(), label_style));
spans
}
fn panel_hint_style(is_focused: bool, theme: &Theme) -> Style {
let mut style = Style::default().fg(theme.pane_hint_text);
if !is_focused {
style = style.add_modifier(Modifier::DIM);
}
style
}
fn panel_border_style(is_active: bool, is_focused: bool, theme: &Theme) -> Style {
if is_active && is_focused {
Style::default().fg(theme.focused_panel_border)
} else if is_active {
Style::default().fg(theme.unfocused_panel_border)
} else {
Style::default()
.fg(theme.unfocused_panel_border)
.add_modifier(Modifier::DIM)
}
}
#[cfg(test)]
mod focus_tests {
use super::*;
use ratatui::style::{Modifier, Style};
use std::path::Path;
fn theme() -> Theme {
Theme::default()
}
#[test]
fn beacon_blank_when_inactive() {
let theme = theme();
let span = focus_beacon_span(false, false, &theme);
assert_eq!(span.content.as_ref(), " ");
assert_eq!(span.style.fg, Some(theme.unfocused_beacon_fg));
assert_eq!(span.style.bg, None);
}
#[test]
fn beacon_highlighted_when_active_and_focused() {
let theme = theme();
let span = focus_beacon_span(true, true, &theme);
assert_eq!(span.content.as_ref(), "");
assert_eq!(span.style.fg, Some(theme.focus_beacon_fg));
assert_eq!(span.style.bg, Some(theme.focus_beacon_bg));
}
#[test]
fn panel_title_spans_apply_active_styles() {
let theme = theme();
let spans = panel_title_spans("Chat", true, true, &theme);
assert_eq!(spans[0].content.as_ref(), "");
assert_eq!(
spans[2].style,
Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD)
);
}
#[test]
fn panel_title_spans_dim_when_unfocused() {
let theme = theme();
let spans = panel_title_spans("Chat", true, false, &theme);
assert_eq!(spans[0].content.as_ref(), "");
assert_eq!(
spans[2].style,
Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::DIM)
);
}
#[test]
fn panel_hint_style_dims_when_inactive() {
let theme = theme();
let style = panel_hint_style(false, &theme);
assert_eq!(style.fg, Some(theme.pane_hint_text));
assert!(style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn panel_hint_style_keeps_highlights_when_focused() {
let theme = theme();
let style = panel_hint_style(true, &theme);
assert_eq!(style.fg, Some(theme.pane_hint_text));
assert!(style.add_modifier.is_empty());
}
#[test]
fn border_style_matches_focus_state() {
let theme = theme();
let focused = panel_border_style(true, true, &theme);
let active_unfocused = panel_border_style(true, false, &theme);
let inactive = panel_border_style(false, false, &theme);
assert_eq!(focused.fg, Some(theme.focused_panel_border));
assert_eq!(active_unfocused.fg, Some(theme.unfocused_panel_border));
assert_eq!(inactive.fg, Some(theme.unfocused_panel_border));
assert!(inactive.add_modifier.contains(Modifier::DIM));
}
#[test]
fn breadcrumbs_include_repo_segments() {
let crumb = build_breadcrumbs("repo", Path::new("src/lib.rs"));
assert_eq!(crumb, "repo > src > lib.rs");
}
}
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 frame_area = frame.area();
frame.render_widget(background_block, frame_area);
let title_line = Line::from(vec![Span::styled(
format!(" 🦉 OWLEN v{} AI Assistant ", APP_VERSION),
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)]);
let main_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(title_line);
let content_area = main_block.inner(frame_area);
frame.render_widget(main_block, frame_area);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let (file_area, main_area) = if app.is_file_panel_collapsed() || content_area.width < 40 {
(None, content_area)
} else {
let max_sidebar = content_area.width.saturating_sub(30).max(10);
let sidebar_width = app.file_panel_width().min(max_sidebar).max(10);
let segments = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(sidebar_width), Constraint::Min(30)])
.split(content_area);
(Some(segments[0]), segments[1])
};
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(main_area);
(segments[0], Some(segments[1]))
} else {
(main_area, None)
};
if let Some(file_area) = file_area {
render_file_tree(frame, file_area, app);
}
// 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::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(4)); // System/Status output (2 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_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),
InputMode::RepoSearch => render_repo_search(frame, app),
InputMode::SymbolSearch => render_symbol_search(frame, app),
_ => {}
}
}
if app.is_model_info_visible() {
let panel_width = content_area
.width
.saturating_div(3)
.max(30)
.min(content_area.width.saturating_sub(20).max(30))
.min(content_area.width);
let x = content_area
.x
.saturating_add(content_area.width.saturating_sub(panel_width));
let area = Rect::new(x, content_area.y, panel_width, content_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_workspace(frame, area, app);
}
render_toasts(frame, app, content_area);
}
fn toast_palette(level: ToastLevel, theme: &Theme) -> (&'static str, Style, Style) {
let (label, color) = match level {
ToastLevel::Info => ("INFO", theme.info),
ToastLevel::Success => ("OK", theme.agent_badge_idle_bg),
ToastLevel::Warning => ("WARN", theme.agent_action),
ToastLevel::Error => ("ERROR", theme.error),
};
let badge_style = Style::default()
.fg(theme.background)
.bg(color)
.add_modifier(Modifier::BOLD);
let border_style = Style::default().fg(color);
(label, badge_style, border_style)
}
fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
let toasts: Vec<&Toast> = app.toasts().collect();
if toasts.is_empty() {
return;
}
let theme = app.theme();
let available_width = usize::from(full_area.width.saturating_sub(2));
if available_width == 0 {
return;
}
let max_text_width = toasts
.iter()
.map(|toast| UnicodeWidthStr::width(toast.message.as_str()))
.max()
.unwrap_or(0);
let mut width = max_text_width.saturating_add(6); // padding + badge
width = width.clamp(14, available_width);
width = width.min(48);
if width == 0 {
return;
}
let width = width as u16;
let offset_x = full_area
.x
.saturating_add(full_area.width.saturating_sub(width + 1));
let mut offset_y = full_area.y.saturating_add(1);
let frame_bottom = full_area.y.saturating_add(full_area.height);
for toast in toasts {
let (label, badge_style, border_style) = toast_palette(toast.level, theme);
let badge_text = format!(" {} ", label);
let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 1;
let indent = " ".repeat(indent_width);
let content_width = width.saturating_sub(4).max(1) as usize;
let wrapped_lines = wrap(toast.message.as_str(), content_width);
let lines: Vec<String> = if wrapped_lines.is_empty() {
vec![String::new()]
} else {
wrapped_lines
.into_iter()
.map(|cow| cow.into_owned())
.collect()
};
let text_style = Style::default().fg(theme.text);
let mut paragraph_lines = Vec::with_capacity(lines.len());
if let Some((first, rest)) = lines.split_first() {
paragraph_lines.push(Line::from(vec![
Span::styled(badge_text.clone(), badge_style),
Span::raw(" "),
Span::styled(first.clone(), text_style),
]));
for line in rest {
paragraph_lines.push(Line::from(vec![
Span::raw(indent.clone()),
Span::styled(line.clone(), text_style),
]));
}
}
let height = (paragraph_lines.len() as u16).saturating_add(2);
if offset_y.saturating_add(height) > frame_bottom {
break;
}
let area = Rect::new(offset_x, offset_y, width, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(theme.background));
let paragraph = Paragraph::new(paragraph_lines)
.block(block)
.alignment(Alignment::Left)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
offset_y = offset_y.saturating_add(height + 1);
if offset_y >= frame_bottom {
break;
}
}
}
#[derive(Debug, Clone)]
struct TreeLineRenderInfo {
ancestor_has_sibling: Vec<bool>,
is_last_sibling: bool,
}
fn compute_tree_line_info(
entries: &[VisibleFileEntry],
nodes: &[FileNode],
) -> Vec<TreeLineRenderInfo> {
let mut info = Vec::with_capacity(entries.len());
let mut sibling_stack: Vec<bool> = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let depth = entry.depth;
if sibling_stack.len() >= depth {
sibling_stack.truncate(depth);
}
let is_last = is_last_visible_sibling(entries, nodes, index);
info.push(TreeLineRenderInfo {
ancestor_has_sibling: sibling_stack.clone(),
is_last_sibling: is_last,
});
sibling_stack.push(!is_last);
}
info
}
fn is_last_visible_sibling(
entries: &[VisibleFileEntry],
nodes: &[FileNode],
position: usize,
) -> bool {
let depth = entries[position].depth;
let node_index = entries[position].index;
let parent = nodes[node_index].parent;
for next in entries.iter().skip(position + 1) {
if next.depth < depth {
break;
}
if next.depth == depth && nodes[next.index].parent == parent {
return false;
}
}
true
}
fn collect_unsaved_relative_paths(app: &ChatApp, root: &Path) -> HashSet<PathBuf> {
let mut set = HashSet::new();
for pane in app.workspace().panes() {
if !pane.is_dirty {
continue;
}
if let Some(abs) = pane.absolute_path()
&& let Some(rel) = diff_paths(abs, root)
{
set.insert(rel);
continue;
}
if let Some(display) = pane.display_path() {
let display_path = PathBuf::from(display);
if display_path.is_relative() {
set.insert(display_path);
}
}
}
set
}
fn build_breadcrumbs(repo_name: &str, path: &Path) -> String {
let mut parts = vec![repo_name.to_string()];
for component in path.components() {
if let Component::Normal(segment) = component
&& !segment.is_empty()
{
parts.push(segment.to_string_lossy().into_owned());
}
}
parts.join(" > ")
}
fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Files);
let (repo_name, filter_query, filter_mode, show_hidden) = {
let tree = app.file_tree();
(
tree.repo_name().to_string(),
tree.filter_query().to_string(),
tree.filter_mode(),
tree.show_hidden(),
)
};
let mut title_spans =
panel_title_spans(format!("Files ▸ {}", repo_name), true, has_focus, &theme);
if !filter_query.is_empty() {
let mode_label = match filter_mode {
FileFilterMode::Glob => "glob",
FileFilterMode::Fuzzy => "fuzzy",
};
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
format!("{}:{}", mode_label, filter_query),
Style::default().fg(theme.info),
));
}
if show_hidden {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"hidden:on",
Style::default()
.fg(theme.pane_hint_text)
.add_modifier(Modifier::ITALIC),
));
}
title_spans.push(Span::styled(
" ↩ open · o split↓ · O split→ · t tab · y abs · Y rel · a file · A dir · r ren · m move · d del · . $EDITOR · gh hidden · / mode",
panel_hint_style(has_focus, &theme),
));
let block = Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(panel_border_style(true, has_focus, &theme))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(area);
let viewport_height = inner.height as usize;
if viewport_height == 0 || inner.width == 0 {
frame.render_widget(block, area);
return;
}
{
let tree = app.file_tree_mut();
tree.set_viewport_height(viewport_height);
}
let root_path = {
let tree = app.file_tree();
tree.root().to_path_buf()
};
let unsaved_paths = collect_unsaved_relative_paths(app, &root_path);
let tree = app.file_tree();
let entries = tree.visible_entries();
let render_info = compute_tree_line_info(entries, tree.nodes());
let icon_resolver = app.file_icons();
let start = tree.scroll_top().min(entries.len());
let end = (start + viewport_height).min(entries.len());
let error_message = tree.last_error().map(|msg| msg.to_string());
let mut items = Vec::new();
if let Some((prompt_text, is_destructive)) = app.file_panel_prompt_text() {
let prompt_style = if is_destructive {
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD | Modifier::ITALIC)
} else {
Style::default()
.fg(theme.info)
.add_modifier(Modifier::ITALIC)
};
items.push(ListItem::new(Line::from(vec![Span::styled(
prompt_text,
prompt_style,
)])));
}
if start >= end {
items.push(
ListItem::new(Line::from(vec![Span::styled(
"No files",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)]))
.style(Style::default()),
);
} else {
let mut guide_style = Style::default().fg(theme.placeholder);
if !has_focus {
guide_style = guide_style.add_modifier(Modifier::DIM);
}
for (offset, entry) in entries[start..end].iter().enumerate() {
let absolute_idx = start + offset;
let is_selected = absolute_idx == tree.cursor();
let node = &tree.nodes()[entry.index];
let info = &render_info[absolute_idx];
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(focus_beacon_span(is_selected, has_focus, &theme));
spans.push(Span::raw(" "));
for &has_more in &info.ancestor_has_sibling {
let glyph = if has_more { "" } else { " " };
spans.push(Span::styled(format!("{glyph} "), guide_style));
}
if entry.depth > 0 {
let branch = if info.is_last_sibling {
"└─"
} else {
"├─"
};
spans.push(Span::styled(branch.to_string(), guide_style));
}
let toggle_symbol = if node.is_dir {
if node.children.is_empty() {
" "
} else if node.is_expanded {
""
} else {
""
}
} else {
" "
};
spans.push(Span::styled(toggle_symbol.to_string(), guide_style));
let mut icon_style = if node.is_dir {
Style::default().fg(theme.info)
} else {
Style::default().fg(theme.text)
};
if !has_focus && !is_selected {
icon_style = icon_style.add_modifier(Modifier::DIM);
}
if node.is_hidden {
icon_style = icon_style.add_modifier(Modifier::DIM);
}
let icon = icon_resolver.icon_for(node);
spans.push(Span::styled(format!("{icon} "), icon_style));
let is_unsaved = !node.is_dir && unsaved_paths.contains(&node.path);
let mut name_style = Style::default().fg(theme.text);
if node.is_dir {
name_style = name_style.add_modifier(Modifier::BOLD);
}
if node.is_hidden {
name_style = name_style.add_modifier(Modifier::DIM);
}
if is_unsaved {
name_style = name_style.add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(node.name.clone(), name_style));
let mut marker_spans: Vec<Span<'static>> = Vec::new();
if node.git.cleanliness != '✓' {
marker_spans.push(Span::styled("*", Style::default().fg(theme.info)));
}
if is_unsaved {
marker_spans.push(Span::styled(
"~",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
));
}
if node.is_hidden && show_hidden {
marker_spans.push(Span::styled(
"gh",
Style::default()
.fg(theme.pane_hint_text)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
));
}
if let Some(badge) = node.git.badge {
marker_spans.push(Span::styled(
badge.to_string(),
Style::default().fg(theme.info),
));
}
if !marker_spans.is_empty() {
spans.push(Span::raw(" "));
for (idx, marker) in marker_spans.into_iter().enumerate() {
if idx > 0 {
spans.push(Span::raw(" "));
}
spans.push(marker);
}
}
let mut line_style = Style::default();
if is_selected {
line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg);
} else if !has_focus {
line_style = line_style.fg(theme.text).add_modifier(Modifier::DIM);
}
items.push(ListItem::new(Line::from(spans)).style(line_style));
}
}
if let Some(err) = error_message {
items.insert(
0,
ListItem::new(Line::from(vec![Span::styled(
format!("{err}"),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
)]))
.style(Style::default()),
);
}
let list = List::new(items).block(block);
frame.render_widget(list, area);
}
fn render_editable_textarea(
frame: &mut Frame<'_>,
area: Rect,
textarea: &mut TextArea<'static>,
mut wrap_lines: bool,
show_cursor: 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.filter(|_| show_cursor) {
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 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();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Chat);
// Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders
let card_width = usize::from(area.width.saturating_sub(4).max(20));
let body_width = card_width.saturating_sub(4).max(12);
app.set_viewport_dimensions(viewport_height, body_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(body_width);
// Build the lines for messages using cached rendering
let mut lines: Vec<Line<'static>> = Vec::new();
let role_label_mode = formatter.role_label_mode();
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,
role_label_mode,
body_width,
card_width,
is_streaming,
app.get_loading_indicator(),
&theme,
app.should_highlight_code(),
),
);
lines.extend(message_lines);
}
// 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 {
match role_label_mode {
RoleLabelDisplay::Inline => {
let (emoji, title) = crate::chat_app::role_label_parts(&Role::Assistant);
let inline_label = format!("{emoji} {title}:");
let label_width = UnicodeWidthStr::width(inline_label.as_str());
let max_label_width = crate::chat_app::max_inline_label_width();
let padding = max_label_width.saturating_sub(label_width);
let mut loading_spans = vec![
Span::raw(format!("{emoji} ")),
Span::styled(
format!("{title}:"),
Style::default()
.fg(theme.assistant_message_role)
.add_modifier(Modifier::BOLD),
),
];
if padding > 0 {
loading_spans.push(Span::raw(" ".repeat(padding)));
}
loading_spans.push(Span::raw(" "));
loading_spans.push(Span::styled(
app.get_loading_indicator().to_string(),
Style::default().fg(theme.info),
));
lines.push(Line::from(loading_spans));
}
_ => {
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;
let mut title_spans = panel_title_spans("Chat", true, has_focus, &theme);
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"PgUp/PgDn scroll · g/G jump · s save",
panel_hint_style(has_focus, &theme),
));
let chat_block = Block::default()
.borders(Borders::ALL)
.border_style(panel_border_style(true, has_focus, &theme))
.style(Style::default().bg(theme.background).fg(theme.text))
.title(Line::from(title_spans));
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(chat_block)
.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 app.cursor_should_be_visible()
&& 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 = crate::chat_app::wrap_unicode(&thinking, content_width as usize);
let mut lines: Vec<Line> = chunks
.into_iter()
.map(|seg| {
Line::from(Span::styled(
seg,
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;
let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking);
let mut title_spans = panel_title_spans("💭 Thinking", true, has_focus, &theme);
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"Esc close",
panel_hint_style(has_focus, &theme),
));
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(panel_border_style(true, has_focus, &theme))
.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 app.cursor_should_be_visible() && has_focus && 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();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Thinking);
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 =
crate::chat_app::wrap_unicode(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 = crate::chat_app::wrap_unicode(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 = crate::chat_app::wrap_unicode(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 = crate::chat_app::wrap_unicode(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 = crate::chat_app::wrap_unicode(line_trimmed, content_width as usize);
for chunk in wrapped {
lines.push(Line::from(Span::styled(
chunk,
Style::default().fg(theme.text),
)));
}
} else {
// Empty line
lines.push(Line::from(""));
}
}
let mut title_spans = panel_title_spans("🤖 Agent Actions", true, has_focus, &theme);
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"Pause ▸ p · Resume ▸ r",
panel_hint_style(has_focus, &theme),
));
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background))
.block(
Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(panel_border_style(true, has_focus, &theme))
.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 has_focus = matches!(app.focused_panel(), FocusedPanel::Input);
let (label, hint) = match app.mode() {
InputMode::Editing => (
"Input",
Some("Enter send · Shift+Enter newline · Esc normal"),
),
InputMode::Visual => ("Visual Select", Some("y yank · d cut · Esc cancel")),
InputMode::Command => ("Command", Some("Enter run · Esc cancel")),
InputMode::RepoSearch => (
"Repo Search",
Some("Enter run · Alt+Enter scratch · Esc close"),
),
InputMode::SymbolSearch => ("Symbol Search", Some("Type @name · Esc close")),
_ => ("Input", Some("Press i to start typing")),
};
let is_active = matches!(
app.mode(),
InputMode::Editing
| InputMode::Visual
| InputMode::Command
| InputMode::RepoSearch
| InputMode::SymbolSearch
);
let mut title_spans = panel_title_spans(label, is_active, has_focus, &theme);
if let Some(hint_text) = hint {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
hint_text.to_string(),
panel_hint_style(has_focus, &theme),
));
}
let input_block = Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(panel_border_style(is_active, has_focus, &theme))
.style(Style::default().bg(theme.background).fg(theme.text));
if matches!(app.mode(), InputMode::Editing) {
// Use the textarea directly to preserve selection state
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true, show_cursor, &theme);
} else if matches!(app.mode(), InputMode::Visual) {
// In visual mode, render textarea in read-only mode with selection
let show_cursor = app.cursor_should_be_visible();
let textarea = app.textarea_mut();
textarea.set_block(input_block.clone());
textarea.set_hard_tab_indent(false);
render_editable_textarea(frame, area, textarea, true, show_cursor, &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 block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.status_background));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30),
Constraint::Percentage(40),
Constraint::Percentage(30),
])
.split(inner);
let (mode_label, mode_color) = match app.mode() {
InputMode::Normal => ("NORMAL", theme.mode_normal),
InputMode::Editing => ("INSERT", 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),
InputMode::RepoSearch => ("SEARCH", theme.mode_command),
InputMode::SymbolSearch => ("SYMBOLS", theme.mode_command),
};
let mode_badge_style = if app.mode_flash_active() {
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.bg(mode_color)
.fg(theme.background)
.add_modifier(Modifier::BOLD)
};
let (op_label, op_fg, op_bg) = match app.get_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),
};
let focus_label = match app.focused_panel() {
FocusedPanel::Files => "FILES",
FocusedPanel::Chat => "CHAT",
FocusedPanel::Thinking => "THINK",
FocusedPanel::Input => "INPUT",
FocusedPanel::Code => "CODE",
};
let mut left_spans = vec![
Span::styled(format!(" {} ", mode_label), mode_badge_style),
Span::styled(
"",
Style::default()
.fg(theme.unfocused_panel_border)
.add_modifier(Modifier::DIM),
),
Span::styled(
format!(" {} ", op_label),
Style::default()
.bg(op_bg)
.fg(op_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}", focus_label),
Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
),
];
if app.is_agent_running() {
left_spans.push(Span::styled(
" 🤖 RUN",
Style::default()
.fg(theme.agent_badge_running_fg)
.bg(theme.agent_badge_running_bg)
.add_modifier(Modifier::BOLD),
));
} else if app.is_agent_mode() {
left_spans.push(Span::styled(
" 🤖 ARM",
Style::default()
.fg(theme.agent_badge_idle_fg)
.bg(theme.agent_badge_idle_bg)
.add_modifier(Modifier::BOLD),
));
}
let left_paragraph = Paragraph::new(Line::from(left_spans))
.alignment(Alignment::Left)
.style(Style::default().bg(theme.status_background).fg(theme.text));
frame.render_widget(left_paragraph, columns[0]);
let file_tree = app.file_tree();
let repo_label = if let Some(branch) = file_tree.git_branch() {
format!("{}@{}", branch, file_tree.repo_name())
} else {
file_tree.repo_name().to_string()
};
let current_path = if let Some(path) = app.code_view_path() {
Some(path.to_string())
} else if let Some(node) = file_tree.selected_node() {
if node.path.as_os_str().is_empty() {
None
} else {
Some(node.path.to_string_lossy().into_owned())
}
} else {
None
};
let position_label = status_cursor_position(app);
let encoding_label = "UTF-8 LF";
let language_label = language_label_for_path(current_path.as_deref());
let mut mid_parts = vec![repo_label];
if let Some(path) = current_path.as_ref() {
mid_parts.push(path.clone());
}
mid_parts.push(position_label);
mid_parts.push(encoding_label.to_string());
mid_parts.push(language_label.to_string());
let mid_paragraph = Paragraph::new(mid_parts.join(" · "))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.status_background).fg(theme.text));
frame.render_widget(mid_paragraph, columns[1]);
let provider = app.current_provider();
let model_label = app.active_model_label();
let mut right_spans = vec![Span::styled(
format!("{}{}", provider, model_label),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)];
if app.is_loading() || app.is_streaming() {
let spinner = app.get_loading_indicator();
let spinner = if spinner.is_empty() { "" } else { spinner };
right_spans.push(Span::styled(
format!(" · {} streaming", spinner),
Style::default().fg(theme.info),
));
right_spans.push(Span::styled(
" · p:Pause r:Resume s:Stop",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
}
right_spans.push(Span::styled(
" · LSP:✓",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
));
let right_paragraph = Paragraph::new(Line::from(right_spans))
.alignment(Alignment::Right)
.style(Style::default().bg(theme.status_background).fg(theme.text));
frame.render_widget(right_paragraph, columns[2]);
}
fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone();
frame.render_widget(Clear, area);
if area.width == 0 || area.height == 0 {
return;
}
if app.workspace().tabs().is_empty() {
render_empty_workspace(frame, area, &theme);
return;
}
let (repo_name, repo_root) = {
let tree = app.file_tree();
(tree.repo_name().to_string(), tree.root().to_path_buf())
};
let show_tab_bar = area.height > 2;
let content_area = if show_tab_bar {
let segments = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(area);
render_code_tab_bar(frame, segments[0], app, &theme);
segments[1]
} else {
area
};
render_code_tab_content(
frame,
content_area,
app,
&theme,
&repo_name,
repo_root.as_path(),
);
}
fn render_code_tab_bar(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let tabs = app.workspace().tabs();
if tabs.is_empty() {
return;
}
let active_index = app.workspace().active_tab_index();
let mut spans: Vec<Span<'_>> = Vec::new();
for (index, tab) in tabs.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(
" ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
));
}
let dirty_marker = tab
.panes
.get(&tab.active)
.map(|pane| pane.is_dirty)
.unwrap_or(false);
let dirty_char = if dirty_marker { "" } else { " " };
let label = format!(" {} {} {} ", index + 1, dirty_char, tab.title);
let style = if index == active_index {
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM)
};
spans.push(Span::styled(label, style));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line)
.alignment(Alignment::Left)
.style(Style::default().bg(theme.status_background).fg(theme.text))
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.unfocused_panel_border)),
);
frame.render_widget(paragraph, area);
}
fn render_code_tab_content(
frame: &mut Frame<'_>,
area: Rect,
app: &mut ChatApp,
theme: &Theme,
repo_name: &str,
repo_root: &Path,
) {
if area.width == 0 || area.height == 0 {
return;
}
let has_focus = matches!(app.focused_panel(), FocusedPanel::Code);
let active_index = app.workspace().active_tab_index();
let workspace = app.workspace_mut();
let tabs = workspace.tabs_mut();
if let Some(tab) = tabs.get_mut(active_index) {
let EditorTab {
root,
panes,
active,
..
} = tab;
if panes.is_empty() {
render_empty_workspace(frame, area, theme);
return;
}
let active_pane = *active;
render_workspace_node(
frame,
area,
root,
panes,
active_pane,
theme,
has_focus,
repo_name,
repo_root,
);
} else {
render_empty_workspace(frame, area, theme);
}
}
#[allow(clippy::too_many_arguments)]
fn render_workspace_node(
frame: &mut Frame<'_>,
area: Rect,
node: &mut LayoutNode,
panes: &mut HashMap<PaneId, CodePane>,
active_pane: PaneId,
theme: &Theme,
has_focus: bool,
repo_name: &str,
repo_root: &Path,
) {
if area.width == 0 || area.height == 0 {
return;
}
match node {
LayoutNode::Leaf(id) => {
if let Some(pane) = panes.get_mut(id) {
let is_active = *id == active_pane;
render_code_pane(
frame, area, pane, theme, has_focus, is_active, repo_name, repo_root,
);
} else {
render_empty_workspace(frame, area, theme);
}
}
LayoutNode::Split {
axis,
ratio,
first,
second,
} => {
let (first_area, second_area) = split_rect(area, *axis, *ratio);
if first_area.width > 0 && first_area.height > 0 {
render_workspace_node(
frame,
first_area,
first.as_mut(),
panes,
active_pane,
theme,
has_focus,
repo_name,
repo_root,
);
}
if second_area.width > 0 && second_area.height > 0 {
render_workspace_node(
frame,
second_area,
second.as_mut(),
panes,
active_pane,
theme,
has_focus,
repo_name,
repo_root,
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_code_pane(
frame: &mut Frame<'_>,
area: Rect,
pane: &mut CodePane,
theme: &Theme,
has_focus: bool,
is_active: bool,
repo_name: &str,
repo_root: &Path,
) {
if area.width == 0 || area.height == 0 {
return;
}
let viewport_height = area.height.saturating_sub(2) as usize;
pane.set_viewport_height(viewport_height);
let mut lines: Vec<Line> = Vec::new();
if pane.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 pane.lines.iter().enumerate() {
let number = format!("{:>4} ", idx + 1);
let mut spans = vec![Span::styled(
number,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)];
let mut line_style = Style::default().fg(theme.text);
if !is_active {
line_style = line_style.add_modifier(Modifier::DIM);
}
spans.push(Span::styled(content.clone(), line_style));
lines.push(Line::from(spans));
}
}
pane.scroll.content_len = lines.len();
pane.scroll.on_viewport(viewport_height);
let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16;
let fallback_title = pane
.display_path()
.map(|s| s.to_string())
.or_else(|| {
pane.absolute_path()
.map(|p| p.to_string_lossy().into_owned())
})
.unwrap_or_else(|| pane.title.clone());
let breadcrumb = pane
.absolute_path()
.and_then(|abs| {
diff_paths(abs, repo_root)
.or_else(|| abs.strip_prefix(repo_root).ok().map(PathBuf::from))
.map(|rel| build_breadcrumbs(repo_name, rel.as_path()))
})
.or_else(|| {
pane.display_path()
.map(|display| build_breadcrumbs(repo_name, Path::new(display)))
});
let header_label = breadcrumb.unwrap_or_else(|| fallback_title.clone());
let mut title_spans = panel_title_spans(header_label, is_active, has_focus && is_active, theme);
if is_active {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
"Ctrl+W split · :w save",
panel_hint_style(has_focus && is_active, theme),
));
}
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(panel_border_style(is_active, has_focus && is_active, theme))
.title(Line::from(title_spans)),
)
.scroll((scroll_position, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_empty_workspace(frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.placeholder))
.title(Span::styled(
"No file open",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
let paragraph = Paragraph::new(Line::from(Span::styled(
"Open a file from the tree or palette",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)))
.alignment(Alignment::Center)
.block(block);
frame.render_widget(paragraph, area);
}
fn split_rect(area: Rect, axis: SplitAxis, ratio: f32) -> (Rect, Rect) {
if area.width == 0 || area.height == 0 {
return (area, Rect::new(area.x, area.y, 0, 0));
}
let ratio = ratio.clamp(0.1, 0.9);
match axis {
SplitAxis::Horizontal => {
let (first, _) = split_lengths(area.height, ratio);
let first_rect = Rect::new(area.x, area.y, area.width, first);
let second_rect = Rect::new(
area.x,
area.y.saturating_add(first),
area.width,
area.height.saturating_sub(first),
);
(first_rect, second_rect)
}
SplitAxis::Vertical => {
let (first, _) = split_lengths(area.width, ratio);
let first_rect = Rect::new(area.x, area.y, first, area.height);
let second_rect = Rect::new(
area.x.saturating_add(first),
area.y,
area.width.saturating_sub(first),
area.height,
);
(first_rect, second_rect)
}
}
}
fn split_lengths(total: u16, ratio: f32) -> (u16, u16) {
if total <= 1 {
return (total, 0);
}
let mut first = ((total as f32) * ratio).round() as u16;
first = first.max(1).min(total - 1);
let second = total - first;
(first, second)
}
fn status_cursor_position(app: &ChatApp) -> String {
let (line, col) = match app.focused_panel() {
FocusedPanel::Chat => {
let (row, col) = app.chat_cursor();
(row + 1, col + 1)
}
FocusedPanel::Thinking => {
let (row, col) = app.thinking_cursor();
(row + 1, col + 1)
}
FocusedPanel::Input => {
let (row, col) = app.textarea().cursor();
(row + 1, col + 1)
}
FocusedPanel::Code => {
let row = app
.code_view_scroll()
.map(|scroll| scroll.scroll + 1)
.unwrap_or(1);
(row, 1)
}
FocusedPanel::Files => (app.file_tree().cursor() + 1, 1),
};
format!("{}:{}", line, col)
}
fn language_label_for_path(path: Option<&str>) -> &'static str {
let Some(path) = path else {
return "Plain Text";
};
let Some(ext) = Path::new(path).extension().and_then(|ext| ext.to_str()) else {
return "Plain Text";
};
match ext.to_ascii_lowercase().as_str() {
"rs" => "Rust 2024",
"py" => "Python 3",
"ts" => "TypeScript",
"tsx" => "TypeScript",
"js" => "JavaScript",
"jsx" => "JavaScript",
"go" => "Go",
"java" => "Java",
"kt" => "Kotlin",
"sh" => "Shell",
"bash" => "Shell",
"md" => "Markdown",
"toml" => "TOML",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"html" => "HTML",
"css" => "CSS",
_ => "Plain Text",
}
}
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: Files, Chat, Thinking, Actions, Input, Code)"),
Line::from(" ▌ beacon marks the active entry; bright when the pane has focus"),
Line::from(" Status bar highlights MODE, CONTEXT, and current FOCUS target"),
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(" Ctrl+P → open command palette"),
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(" :layout save/load → persist or restore pane layout"),
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(
"AGENT",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(theme.user_message_role),
)]),
Line::from(" :agent start → arm the agent for the next request"),
Line::from(" :agent stop → stop or disarm the agent"),
Line::from(" :agent status → show current agent state"),
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();
let buffer = app.command_buffer();
let area = frame.area();
if area.width == 0 || area.height == 0 {
return;
}
let visible_count = suggestions.len().clamp(1, 8) as u16;
let mut height = visible_count.saturating_mul(2).saturating_add(6);
height = height.clamp(6, area.height);
let mut width = area.width.saturating_sub(10);
if width < 50 {
width = area.width.saturating_sub(4);
}
if width == 0 {
width = area.width;
}
let width = width.min(area.width);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 3;
let popup_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, popup_area);
let header = Line::from(vec![
Span::styled(
" Command Palette ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
),
Span::styled(
"Ctrl+P",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
]);
let block = Block::default()
.title(header)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.info))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.width == 0 || inner.height == 0 {
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
let input = Paragraph::new(Line::from(vec![
Span::styled(
":",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::BOLD),
),
Span::raw(buffer),
]))
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(input, layout[0]);
let selected_index = if suggestions.is_empty() {
None
} else {
Some(
app.selected_suggestion()
.min(suggestions.len().saturating_sub(1)),
)
};
if suggestions.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
"No matches — keep typing",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(placeholder, layout[1]);
} else {
let highlight = Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg);
let mut items: Vec<ListItem> = Vec::new();
let mut previous_group: Option<PaletteGroup> = None;
for (idx, suggestion) in suggestions.iter().enumerate() {
let mut lines: Vec<Line> = Vec::new();
if previous_group != Some(suggestion.group) {
lines.push(Line::from(Span::styled(
palette_group_label(suggestion.group),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::BOLD),
)));
previous_group = Some(suggestion.group);
}
let label_line = Line::from(vec![
Span::styled(
if Some(idx) == selected_index {
""
} else {
" "
},
Style::default().fg(theme.placeholder),
),
Span::raw(" "),
Span::styled(
suggestion.label.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]);
lines.push(label_line);
if let Some(detail) = &suggestion.detail {
lines.push(Line::from(Span::styled(
format!(" {}", detail),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)));
}
let item = ListItem::new(lines);
items.push(item);
}
let mut list_state = ListState::default();
list_state.select(selected_index);
let list = List::new(items)
.highlight_style(highlight)
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_stateful_widget(list, layout[1], &mut list_state);
}
let instructions = "Enter: run · Tab: autocomplete · Esc: cancel";
let detail_text = selected_index
.and_then(|idx| suggestions.get(idx))
.and_then(|item| item.detail.as_deref())
.map(|detail| format!("{detail}{instructions}"))
.unwrap_or_else(|| instructions.to_string());
let footer = Paragraph::new(Line::from(Span::styled(
detail_text,
Style::default().fg(theme.placeholder),
)))
.alignment(Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder));
frame.render_widget(footer, layout[2]);
}
fn palette_group_label(group: PaletteGroup) -> &'static str {
match group {
PaletteGroup::History => "History",
PaletteGroup::Command => "Commands",
PaletteGroup::Model => "Models",
PaletteGroup::Provider => "Providers",
}
}
fn render_repo_search(frame: &mut Frame<'_>, app: &mut ChatApp) {
let theme = app.theme().clone();
let popup = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, popup);
let block = Block::default()
.title(Span::styled(
" Repo Search · ripgrep ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.focused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(block.clone(), popup);
let inner = block.inner(popup);
if inner.width == 0 || inner.height == 0 {
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner);
let (query, running, dirty, status_line, error_line) = {
let state = app.repo_search();
(
state.query_input().to_string(),
state.running(),
state.dirty(),
state.status().cloned(),
state.error().cloned(),
)
};
{
let viewport = layout[2].height.saturating_sub(1) as usize;
app.repo_search_mut().set_viewport_height(viewport.max(1));
}
let state = app.repo_search();
let mut query_spans = vec![Span::styled(
"Pattern: ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)];
let mut query_style = Style::default().fg(theme.text);
if dirty {
query_style = query_style.add_modifier(Modifier::ITALIC);
}
if query.is_empty() {
query_spans.push(Span::styled(
"<empty>",
query_style.add_modifier(Modifier::DIM),
));
} else {
query_spans.push(Span::styled(query.clone(), query_style));
}
if running {
query_spans.push(Span::styled(
" ⟳ searching…",
Style::default()
.fg(theme.info)
.add_modifier(Modifier::ITALIC),
));
}
let query_para = Paragraph::new(Line::from(query_spans)).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.unfocused_panel_border)),
);
frame.render_widget(query_para, layout[0]);
let status_span = if let Some(err) = error_line {
Span::styled(err, Style::default().fg(theme.error))
} else if let Some(status) = status_line {
Span::styled(status, Style::default().fg(theme.placeholder))
} else {
Span::styled(
"Enter=search Alt+Enter=scratch Esc=cancel",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)
};
let status_para = Paragraph::new(Line::from(status_span))
.alignment(Alignment::Left)
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(status_para, layout[1]);
let rows = state.visible_rows();
let files = state.files();
let selected_row = state.selected_row_index();
let mut items: Vec<ListItem> = Vec::new();
for (offset, row) in rows.iter().enumerate() {
let absolute_index = state.scroll_top() + offset;
match &row.kind {
RepoSearchRowKind::FileHeader => {
let file = &files[row.file_index];
let mut spans = vec![Span::styled(
file.display.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)];
if !file.matches.is_empty() {
spans.push(Span::styled(
format!(" ({} matches)", file.matches.len()),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
));
}
items.push(
ListItem::new(Line::from(spans))
.style(Style::default().bg(theme.background).fg(theme.text)),
);
}
RepoSearchRowKind::Match { match_index } => {
let file = &files[row.file_index];
if let Some(m) = file.matches.get(*match_index) {
let is_selected = absolute_index == selected_row;
let prefix_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM)
};
let mut spans = vec![Span::styled(
format!(" {:>6}:{:<3} ", m.line_number, m.column),
prefix_style,
)];
if is_selected {
spans.push(Span::styled(
m.preview.clone(),
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD),
));
} else if let Some(matched) = &m.matched {
if let Some(idx) = m.preview.find(matched) {
let head = &m.preview[..idx];
let tail = &m.preview[idx + matched.len()..];
if !head.is_empty() {
spans.push(Span::styled(
head.to_string(),
Style::default().fg(theme.text),
));
}
spans.push(Span::styled(
matched.to_string(),
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
));
if !tail.is_empty() {
spans.push(Span::styled(
tail.to_string(),
Style::default().fg(theme.text),
));
}
} else {
spans.push(Span::styled(
m.preview.clone(),
Style::default().fg(theme.text),
));
}
} else {
spans.push(Span::styled(
m.preview.clone(),
Style::default().fg(theme.text),
));
}
let item_style = if is_selected {
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(theme.background).fg(theme.text)
};
items.push(ListItem::new(Line::from(spans)).style(item_style));
}
}
}
}
if items.is_empty() {
let placeholder = if state.running() {
"Searching…"
} else if state.query_input().is_empty() {
"Type a pattern and press Enter"
} else {
"No results"
};
items.push(
ListItem::new(Line::from(vec![Span::styled(
placeholder,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
)]))
.style(Style::default().bg(theme.background)),
);
}
let list = List::new(items).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.unfocused_panel_border)),
);
frame.render_widget(list, layout[2]);
}
fn render_symbol_search(frame: &mut Frame<'_>, app: &mut ChatApp) {
let theme = app.theme().clone();
let area = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(Span::styled(
" Symbol Search · tree-sitter ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.focused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(block.clone(), area);
let inner = block.inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner);
let (query, running, status_text, error_text, filtered, total) = {
let state = app.symbol_search();
(
state.query().to_string(),
state.is_running(),
state.status().cloned(),
state.error().cloned(),
state.filtered_len(),
state.items().len(),
)
};
{
let viewport = layout[2].height.saturating_sub(1) as usize;
app.symbol_search_mut().set_viewport_height(viewport.max(1));
}
let state = app.symbol_search();
let mut query_spans = vec![Span::styled(
"Filter: ",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)];
if query.is_empty() {
query_spans.push(Span::styled(
"<all symbols>",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
} else {
query_spans.push(Span::styled(query.clone(), Style::default().fg(theme.text)));
}
if running {
query_spans.push(Span::styled(
" ⟳ indexing…",
Style::default()
.fg(theme.info)
.add_modifier(Modifier::ITALIC),
));
}
let query_para = Paragraph::new(Line::from(query_spans)).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.unfocused_panel_border)),
);
frame.render_widget(query_para, layout[0]);
let mut status_spans = Vec::new();
if let Some(err) = error_text.as_ref() {
status_spans.push(Span::styled(err, Style::default().fg(theme.error)));
} else if let Some(status) = status_text.as_ref() {
status_spans.push(Span::styled(status, Style::default().fg(theme.placeholder)));
} else {
status_spans.push(Span::styled(
"Type to filter · Enter=jump · Esc=close",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
));
}
if error_text.is_none() {
status_spans.push(Span::styled(
format!(" {} of {} symbols", filtered, total),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
));
}
let status_para = Paragraph::new(Line::from(status_spans))
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(status_para, layout[1]);
let visible = state.visible_indices();
let items = state.items();
let selected_idx = state.selected_filtered_index();
let mut list_items: Vec<ListItem> = Vec::new();
for &item_index in visible.iter() {
if let Some(entry) = items.get(item_index) {
let is_selected = selected_idx == Some(item_index);
let mut spans = Vec::new();
let icon_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.info)
};
spans.push(Span::styled(format!(" {} ", entry.kind.icon()), icon_style));
let name_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
spans.push(Span::styled(entry.name.clone(), name_style));
spans.push(Span::raw(" "));
let path_style = if is_selected {
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM)
};
spans.push(Span::styled(
format!("{}:{}", entry.display_path, entry.line),
path_style,
));
let item_style = if is_selected {
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(theme.background).fg(theme.text)
};
list_items.push(ListItem::new(Line::from(spans)).style(item_style));
}
}
if list_items.is_empty() {
let placeholder = if running {
"Indexing symbols…"
} else if query.is_empty() {
"No symbols discovered"
} else {
"No symbols match filter"
};
list_items.push(
ListItem::new(Line::from(vec![Span::styled(
placeholder,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
)]))
.style(Style::default().bg(theme.background)),
);
}
let list = List::new(list_items).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.unfocused_panel_border)),
);
frame.render_widget(list, layout[2]);
}
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()
}
}