Introduce `McpCommand` enum and handlers in `owlen-cli` to manage MCP server registrations, including adding, listing, and removing servers across configuration scopes. Add scoped configuration support (`ScopedMcpServer`, `McpConfigScope`) and OAuth token handling in core config, alongside runtime refresh of MCP servers. Implement toast notifications in the TUI (`render_toasts`, `Toast`, `ToastLevel`) and integrate async handling for session events. Update config loading, validation, and schema versioning to accommodate new MCP scopes and resources. Add `httpmock` as a dev dependency for testing.
4407 lines
152 KiB
Rust
4407 lines
152 KiB
Rust
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;
|
||
|
||
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 full_area = frame.area();
|
||
frame.render_widget(background_block, full_area);
|
||
|
||
let (file_area, main_area) = if app.is_file_panel_collapsed() || full_area.width < 40 {
|
||
(None, full_area)
|
||
} else {
|
||
let max_sidebar = full_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(full_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::Length(4), // Header
|
||
Constraint::Min(8), // Messages
|
||
];
|
||
|
||
if thinking_height > 0 {
|
||
constraints.push(Constraint::Length(thinking_height)); // Thinking
|
||
}
|
||
// Insert agent actions panel after thinking (if any)
|
||
if actions_height > 0 {
|
||
constraints.push(Constraint::Length(actions_height)); // Agent actions
|
||
}
|
||
|
||
constraints.push(Constraint::Length(input_height)); // Input
|
||
constraints.push(Constraint::Length(5)); // System/Status output (3 lines content + 2 borders)
|
||
constraints.push(Constraint::Length(3)); // Mode and shortcuts bar
|
||
|
||
let layout = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints(constraints)
|
||
.split(chat_area);
|
||
|
||
let mut idx = 0;
|
||
render_header(frame, layout[idx], app);
|
||
idx += 1;
|
||
|
||
render_messages(frame, layout[idx], app);
|
||
idx += 1;
|
||
|
||
if thinking_height > 0 {
|
||
render_thinking(frame, layout[idx], app);
|
||
idx += 1;
|
||
}
|
||
// Render agent actions panel if present
|
||
if actions_height > 0 {
|
||
render_agent_actions(frame, layout[idx], app);
|
||
idx += 1;
|
||
}
|
||
|
||
render_input(frame, layout[idx], app);
|
||
idx += 1;
|
||
|
||
render_system_output(frame, layout[idx], app);
|
||
idx += 1;
|
||
|
||
render_status(frame, layout[idx], app);
|
||
|
||
// Render consent dialog with highest priority (always on top)
|
||
if app.has_pending_consent() {
|
||
render_consent_dialog(frame, app);
|
||
} else {
|
||
match app.mode() {
|
||
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
||
InputMode::ModelSelection => render_model_selector(frame, app),
|
||
InputMode::Help => render_help(frame, app),
|
||
InputMode::SessionBrowser => render_session_browser(frame, app),
|
||
InputMode::ThemeBrowser => render_theme_browser(frame, app),
|
||
InputMode::Command => render_command_suggestions(frame, app),
|
||
InputMode::RepoSearch => render_repo_search(frame, app),
|
||
InputMode::SymbolSearch => render_symbol_search(frame, app),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if app.is_model_info_visible() {
|
||
let panel_width = full_area
|
||
.width
|
||
.saturating_div(3)
|
||
.max(30)
|
||
.min(full_area.width.saturating_sub(20).max(30));
|
||
let x = full_area.x + full_area.width.saturating_sub(panel_width);
|
||
let area = Rect::new(x, full_area.y, panel_width, full_area.height);
|
||
frame.render_widget(Clear, area);
|
||
let viewport_height = area.height.saturating_sub(2) as usize;
|
||
app.set_model_info_viewport_height(viewport_height);
|
||
app.model_info_panel_mut().render(frame, area, &theme);
|
||
}
|
||
|
||
if let Some(area) = code_area {
|
||
render_code_workspace(frame, area, app);
|
||
}
|
||
|
||
render_toasts(frame, app, full_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 render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||
let theme = app.theme();
|
||
let title_span = Span::styled(
|
||
" 🦉 OWLEN - AI Assistant ",
|
||
Style::default()
|
||
.fg(theme.focused_panel_border)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
let model_label = app.active_model_label();
|
||
let model_with_provider_span = Span::styled(
|
||
format!("{} ({})", model_label, app.current_provider()),
|
||
Style::default()
|
||
.fg(theme.user_message_role)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
|
||
let header_block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(theme.unfocused_panel_border))
|
||
.style(Style::default().bg(theme.background).fg(theme.text))
|
||
.title(Line::from(vec![title_span]));
|
||
|
||
let inner_area = header_block.inner(area);
|
||
|
||
let header_text = vec![
|
||
Line::default(),
|
||
Line::from(vec![
|
||
Span::raw(" "),
|
||
Span::styled("Model (Provider): ", Style::default().fg(theme.placeholder)),
|
||
model_with_provider_span,
|
||
]),
|
||
];
|
||
|
||
let paragraph = Paragraph::new(header_text)
|
||
.style(Style::default().bg(theme.background).fg(theme.text))
|
||
.alignment(Alignment::Left);
|
||
|
||
frame.render_widget(header_block, area);
|
||
frame.render_widget(paragraph, inner_area);
|
||
}
|
||
|
||
fn apply_visual_selection<'a>(
|
||
lines: Vec<Line<'a>>,
|
||
selection: Option<((usize, usize), (usize, usize))>,
|
||
theme: &owlen_core::theme::Theme,
|
||
) -> Vec<Line<'a>> {
|
||
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
|
||
// Normalize selection (ensure start is before end)
|
||
let ((start_r, start_c), (end_r, end_c)) =
|
||
if start_row < end_row || (start_row == end_row && start_col <= end_col) {
|
||
((start_row, start_col), (end_row, end_col))
|
||
} else {
|
||
((end_row, end_col), (start_row, start_col))
|
||
};
|
||
|
||
lines
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(idx, line)| {
|
||
if idx < start_r || idx > end_r {
|
||
// Line not in selection
|
||
return line;
|
||
}
|
||
|
||
// Convert line to plain text for character indexing
|
||
let line_text = line.to_string();
|
||
let char_count = line_text.chars().count();
|
||
|
||
if idx == start_r && idx == end_r {
|
||
// Selection within single line
|
||
let sel_start = start_c.min(char_count);
|
||
let sel_end = end_c.min(char_count);
|
||
|
||
if sel_start >= sel_end {
|
||
return line;
|
||
}
|
||
|
||
let start_byte = char_to_byte_index(&line_text, sel_start);
|
||
let end_byte = char_to_byte_index(&line_text, sel_end);
|
||
|
||
let mut spans = Vec::new();
|
||
if start_byte > 0 {
|
||
spans.push(Span::styled(
|
||
line_text[..start_byte].to_string(),
|
||
Style::default().fg(theme.text),
|
||
));
|
||
}
|
||
spans.push(Span::styled(
|
||
line_text[start_byte..end_byte].to_string(),
|
||
Style::default()
|
||
.bg(theme.selection_bg)
|
||
.fg(theme.selection_fg),
|
||
));
|
||
if end_byte < line_text.len() {
|
||
spans.push(Span::styled(
|
||
line_text[end_byte..].to_string(),
|
||
Style::default().fg(theme.text),
|
||
));
|
||
}
|
||
Line::from(spans)
|
||
} else if idx == start_r {
|
||
// First line of multi-line selection
|
||
let sel_start = start_c.min(char_count);
|
||
let start_byte = char_to_byte_index(&line_text, sel_start);
|
||
|
||
let mut spans = Vec::new();
|
||
if start_byte > 0 {
|
||
spans.push(Span::styled(
|
||
line_text[..start_byte].to_string(),
|
||
Style::default().fg(theme.text),
|
||
));
|
||
}
|
||
spans.push(Span::styled(
|
||
line_text[start_byte..].to_string(),
|
||
Style::default()
|
||
.bg(theme.selection_bg)
|
||
.fg(theme.selection_fg),
|
||
));
|
||
Line::from(spans)
|
||
} else if idx == end_r {
|
||
// Last line of multi-line selection
|
||
let sel_end = end_c.min(char_count);
|
||
let end_byte = char_to_byte_index(&line_text, sel_end);
|
||
|
||
let mut spans = Vec::new();
|
||
spans.push(Span::styled(
|
||
line_text[..end_byte].to_string(),
|
||
Style::default()
|
||
.bg(theme.selection_bg)
|
||
.fg(theme.selection_fg),
|
||
));
|
||
if end_byte < line_text.len() {
|
||
spans.push(Span::styled(
|
||
line_text[end_byte..].to_string(),
|
||
Style::default().fg(theme.text),
|
||
));
|
||
}
|
||
Line::from(spans)
|
||
} else {
|
||
// Middle line - fully selected
|
||
let styled_spans: Vec<Span> = line
|
||
.spans
|
||
.into_iter()
|
||
.map(|span| {
|
||
Span::styled(
|
||
span.content,
|
||
span.style.bg(theme.selection_bg).fg(theme.selection_fg),
|
||
)
|
||
})
|
||
.collect();
|
||
Line::from(styled_spans)
|
||
}
|
||
})
|
||
.collect()
|
||
} else {
|
||
lines
|
||
}
|
||
}
|
||
|
||
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
|
||
if char_idx == 0 {
|
||
return 0;
|
||
}
|
||
|
||
let mut iter = s.char_indices();
|
||
for (i, (byte_idx, _)) in iter.by_ref().enumerate() {
|
||
if i == char_idx {
|
||
return byte_idx;
|
||
}
|
||
}
|
||
s.len()
|
||
}
|
||
|
||
fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||
let theme = app.theme().clone();
|
||
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 0‑5
|
||
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("'", "'")
|
||
.replace(""", "\"");
|
||
|
||
// Truncate if too long
|
||
let truncated = if clean_snippet.len() > 200 {
|
||
format!("{}...", &clean_snippet[..197])
|
||
} else {
|
||
clean_snippet
|
||
};
|
||
output.push_str(&format!(" {}\n", truncated));
|
||
}
|
||
|
||
// URL (shortened if too long)
|
||
if let Some(url) = result.get("url").and_then(|v| v.as_str()) {
|
||
let display_url = if url.len() > 80 {
|
||
format!("{}...", &url[..77])
|
||
} else {
|
||
url.to_string()
|
||
};
|
||
output.push_str(&format!(" 🔗 {}\n", display_url));
|
||
}
|
||
|
||
output.push('\n');
|
||
}
|
||
|
||
// Add total count
|
||
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
|
||
output.push_str(&format!("Found {} result(s)", total));
|
||
}
|
||
} else if let Some(result) = json.get("result").and_then(|v| v.as_str()) {
|
||
content_found = true;
|
||
output.push_str(result);
|
||
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
|
||
content_found = true;
|
||
// Handle error results
|
||
output.push_str(&format!("❌ Error: {}", error));
|
||
}
|
||
|
||
if content_found {
|
||
output
|
||
} else {
|
||
content.to_string()
|
||
}
|
||
} else {
|
||
// If not JSON, return as-is
|
||
content.to_string()
|
||
}
|
||
}
|