#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] //! Top-level TUI render pipeline: layouts the terminal into chat / input //! / status regions, dispatches overlay rendering with glassmorphism-style //! centered panels, and floats toast notifications over the top-right corner. //! //! Design: dark background with vibrant accent-colored overlays. Each overlay //! variant gets a surface-colored centered panel with proper padding, //! a title bar with accent border, and consistent typographic hierarchy. pub mod chat; pub mod markdown; pub mod sidebar; pub mod status; pub mod theme; pub mod workflow; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use theme::Theme; /// Minimum terminal width (columns) at which the persistent dashboard /// sidebar is shown; below this, chat reclaims the full width. const SIDEBAR_MIN_WIDTH: u16 = 90; /// Top-level render entry point called once per TUI frame. pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let area = frame.area(); // ── Determine if the terminal is wide enough for the persistent // dashboard sidebar (Workflow / Tasks / Usage). Below this, chat // reclaims the full width — same width-driven-collapse pattern the // old single-widget todo panel used, just with a wider threshold // since this sidebar holds three stacked widgets, not one. let show_sidebar = area.width > SIDEBAR_MIN_WIDTH; let (main_area, sidebar_area) = if show_sidebar { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(40), Constraint::Length(30), ]) .split(area); (h_chunks[0], Some(h_chunks[1])) } else { (area, None) }; // ── Vertical layout: chat / input / status ─────────────────────────── let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(3), Constraint::Length(3), Constraint::Length(1), ]) .split(main_area); let chat_area = chunks[0]; let input_area = chunks[1]; let status_area = chunks[2]; // ── Render main area (overlay or chat) ─────────────────────────────── if state.misc.overlay.is_active() { let overlay = state.misc.overlay; render_overlay(frame, chat_area, overlay, state); } else { render_main_panel(frame, chat_area, state); } // ── Input bar ──────────────────────────────────────────────────────── render_input_bar(frame, input_area, state); // ── Status bar ─────────────────────────────────────────────────────── status::draw_status_bar(frame, status_area, state); // ── Dashboard sidebar ──────────────────────────────────────────────── if let Some(sidebar_rect) = sidebar_area { sidebar::draw_sidebar(frame, sidebar_rect, state); } // ── Toasts (top-right floating) ────────────────────────────────────── render_toasts(frame, state); } // ──────────────────────────────────────────────────────────────────────────── // Panel helpers // ──────────────────────────────────────────────────────────────────────────── fn render_main_panel( frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest, ) { chat::draw_chat(frame, area, state); } // ──────────────────────────────────────────────────────────────────────────── // Overlay rendering // ──────────────────────────────────────────────────────────────────────────── /// Render the active modal overlay as a centered panel. /// /// Each overlay gets a surface-colored panel with: /// - A top accent border strip (colored per variant) /// - A title line with icon /// - Content area with proper spacing #[allow(clippy::too_many_lines)] fn render_overlay( frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest, ) { let overlay_area = centered_rect(area, 75, 70); // Clear the area behind the overlay (semi-transparent effect) frame.render_widget(Clear, overlay_area); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .style(Style::default().bg(Theme::BG)); match overlay { crate::app::state::types::Overlay::None => {} // ── Help ────────────────────────────────────────────────────── crate::app::state::types::Overlay::Help => { let block = block .title(Span::styled(" ❓ Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::INFO)); let content = crate::resources::HELP_TEXT; let paragraph = Paragraph::new(content) .block(block) .style(Style::default().bg(Theme::BG)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, overlay_area); } // ── Settings ────────────────────────────────────────────────── crate::app::state::types::Overlay::Settings => { let block = block .title(Span::styled(" ⚙ Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::PRIMARY)); let lines = vec![ Line::from(Span::styled( format!(" Provider: {}", state.settings.provider), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Model: {}", state.settings.model), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Max tokens: {}", state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Temperature: {}", state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Internet: {:?}", state.settings.internet_mode), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Review: {}", state.settings.review_enabled), Style::default().fg(Theme::TEXT), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Bash ────────────────────────────────────────────────────── crate::app::state::types::Overlay::Bash => { let block = block .title(Span::styled(" 💻 Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ACCENT_ORANGE)); let lines: Vec = state.session_runtime.as_ref().map(|r| { r.bash_jobs.iter().map(|job| { Line::from(Span::styled( format!(" [{}] {} — {}", job.id, job.command, if job.running { "running" } else { "done" }, ), Style::default().fg(Theme::TEXT), )) }).collect() }).unwrap_or_default(); let paragraph = if lines.is_empty() { Paragraph::new(Line::from(Span::styled( " No active bash jobs.", Style::default().fg(Theme::TEXT_DIM), ))).block(block) } else { Paragraph::new(lines).block(block) }; frame.render_widget(paragraph, overlay_area); } // ── Quit Confirm ────────────────────────────────────────────── crate::app::state::types::Overlay::QuitConfirm => { let block = block .title(Span::styled(" 🚪 Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ERROR)); let lines = vec![ Line::from(Span::styled( " Are you sure you want to quit?", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD), )), Line::from(Span::raw("")), Line::from(Span::styled( " Press Enter to confirm, Esc to cancel.", Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Workflow ────────────────────────────────────────────────── crate::app::state::types::Overlay::Workflow => { workflow::draw_workflow_panel(frame, overlay_area, state); } // ── Key Input ───────────────────────────────────────────────── crate::app::state::types::Overlay::KeyInput => { let block = block .title(Span::styled(" 🔑 API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::WARNING)); let input_text = &state.input.buffer; let display = if input_text.is_empty() { " Type your API key..." } else { // Mask the key for display if input_text.len() > 8 { &input_text[..4] } else { input_text.as_str() } }; let masked = if input_text.is_empty() { display.to_string() } else { let suffix = if input_text.len() > 8 { "****" } else { "" }; format!("{display}{suffix}") }; let lines = vec![ Line::from(Span::styled( " Enter API key for authentication:", Style::default().fg(Theme::TEXT), )), Line::from(Span::raw("")), Line::from(vec![ Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(masked, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)), ]), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Editor ──────────────────────────────────────────────────── crate::app::state::types::Overlay::Editor => { let block = block .title(Span::styled(" ✏️ Editor ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::PRIMARY)); let lines = vec![ Line::from(Span::styled( " Editor Mode — Ctrl+S save, Esc dismiss", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), )), Line::from(Span::raw("")), Line::from(Span::styled( " Buffer:", Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( format!(" {}", state.input.buffer), Style::default().fg(Theme::TEXT), )), Line::from(Span::raw("")), Line::from(Span::styled( format!(" Cursor: pos {} / {}", state.input.cursor, state.input.buffer.len()), Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Effort ──────────────────────────────────────────────────── crate::app::state::types::Overlay::Effort => { let block = block .title(Span::styled(" 🎯 Effort Level ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); let levels = crate::app::mode::effort::EFFORT_LEVELS; let current_idx = crate::app::mode::effort::current_effort(state); let mut lines: Vec = vec![ Line::from(Span::styled( " Use ↑↓ to change effort level", Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::raw("")), ]; for (i, l) in levels.iter().enumerate() { let selected = i == current_idx; lines.push(Line::from(Span::styled( if selected { format!(" ▸ {l} (active)") } else { format!(" {l}") }, if selected { Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }, ))); } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── MCP ─────────────────────────────────────────────────────── crate::app::state::types::Overlay::Mcp => { let block = block .title(Span::styled(" 🔌 MCP Servers ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::INFO)); let lines = vec![ Line::from(Span::styled( " MCP Server Management", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::raw("")), Line::from(Span::styled( format!(" Session dir: {}", state.session_dir.display()), Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( " No MCP servers configured.", Style::default().fg(Theme::TEXT_MUTED), )), Line::from(Span::raw("")), Line::from(Span::styled( " Press Ctrl+P to configure provider settings.", Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Todo ────────────────────────────────────────────────────── crate::app::state::types::Overlay::Todo => { let block = block .title(Span::styled(" 📋 Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); let msg_count = state.transcript_cache.messages.len(); let lines = vec![ Line::from(Span::styled( " Session Activity", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::raw("")), Line::from(Span::styled( format!(" Messages: {msg_count}"), Style::default().fg(Theme::INFO), )), Line::from(Span::styled( format!(" Overlay: {:?}", state.misc.overlay), Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Rewind ──────────────────────────────────────────────────── crate::app::state::types::Overlay::Rewind => { let block = block .title(Span::styled(" ⏪ Rewind ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ACCENT_ORANGE)); let mut lines: Vec = vec![ Line::from(Span::styled( " Use ↑↓ to navigate, Enter to rewind to that point", Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::raw("")), ]; let messages = &state.transcript_cache.messages; if messages.is_empty() { lines.push(Line::from(Span::styled( " No messages in current session.", Style::default().fg(Theme::TEXT_DIM), ))); } else { let start = if messages.len() > 8 { messages.len() - 8 } else { 0 }; for msg in &messages[start..] { let role_str = match msg.role { crate::dto::chat::message::Role::User => "User", crate::dto::chat::message::Role::Assistant => "Asst", crate::dto::chat::message::Role::System => "Sys", crate::dto::chat::message::Role::Tool => "Tool", }; let preview: String = msg.content.chars().take(70).collect(); lines.push(Line::from(Span::styled( format!(" [{role_str}] {preview}"), Style::default().fg( if matches!(msg.role, crate::dto::chat::message::Role::User) { Theme::INFO } else { Theme::TEXT }, ), ))); } if messages.len() > 8 { lines.push(Line::from(Span::styled( format!(" ... and {} more messages", messages.len() - 8), Style::default().fg(Theme::TEXT_DIM), ))); } } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Learning ────────────────────────────────────────────────── crate::app::state::types::Overlay::Learning => { let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(40), Constraint::Percentage(60), ]) .split(overlay_area); let left_block = Block::default() .title(Span::styled(" 📚 Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .style(Style::default().bg(Theme::BG)); let right_block = Block::default() .title(Span::styled(" Details ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .style(Style::default().bg(Theme::BG)); let items = crate::app::mode::learning::get_learning_items(state); let mut left_lines = Vec::new(); if items.is_empty() { left_lines.push(Line::from(Span::styled( " No lessons found.", Style::default().fg(Theme::TEXT_DIM), ))); } else { for (i, item) in items.iter().enumerate() { let is_selected = i == state.misc.selected_index; let prefix = if is_selected { " ▸ " } else { " " }; let (label, style) = match item { crate::app::mode::learning::LearningItem::Pending { name, .. } => { ( format!("{prefix}[Pending] {name}"), if is_selected { Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::WARNING) }, ) } crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => { let status = if lifecycle == "stale" { "Stale" } else { "Active" }; ( format!("{prefix}[{status}] {name}"), if is_selected { Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }, ) } }; left_lines.push(Line::from(Span::styled(label, style))); } } // Scroll the left list let max_lines = h_chunks[0].height.saturating_sub(2) as usize; let selected = state.misc.selected_index; let start_idx = if selected >= max_lines { selected - max_lines + 1 } else { 0 }; let end_idx = (start_idx + max_lines).min(left_lines.len()); let visible_lines = if left_lines.is_empty() { Vec::new() } else { left_lines[start_idx..end_idx].to_vec() }; let left_paragraph = Paragraph::new(visible_lines).block(left_block); frame.render_widget(left_paragraph, h_chunks[0]); // Right pane: details let mut right_lines = Vec::new(); if let Some(item) = items.get(selected) { match item { crate::app::mode::learning::LearningItem::Pending { name, content, scope, confidence, } => { right_lines.push(Line::from(Span::styled( " Name:", Style::default().fg(Theme::TEXT_DIM), ))); right_lines.push(Line::from(Span::styled( format!(" {name}"), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), ))); right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::styled( " Status: Pending Approval", Style::default().fg(Theme::WARNING), ))); right_lines.push(Line::from(Span::styled( format!(" Scope: {scope}"), Style::default().fg(Theme::TEXT), ))); right_lines.push(Line::from(Span::styled( format!(" Confidence: {confidence}"), Style::default().fg(Theme::TEXT), ))); right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::styled( " Content:", Style::default().fg(Theme::TEXT_DIM), ))); for line in content.lines() { right_lines.push(Line::from(Span::styled( format!(" {line}"), Style::default().fg(Theme::TEXT), ))); } right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::styled( " [Enter]/[a] Accept · [r]/[Del] Reject", Style::default().fg(Theme::TEXT_DIM), ))); } crate::app::mode::learning::LearningItem::Stored { name, content, lifecycle, scope, description, } => { right_lines.push(Line::from(Span::styled( " Name:", Style::default().fg(Theme::TEXT_DIM), ))); right_lines.push(Line::from(Span::styled( format!(" {name}"), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), ))); right_lines.push(Line::from(Span::raw(""))); let status_color = if lifecycle == "stale" { Theme::WARNING } else { Theme::SUCCESS }; right_lines.push(Line::from(Span::styled( format!(" Status: {lifecycle}"), Style::default().fg(status_color), ))); right_lines.push(Line::from(Span::styled( format!(" Scope: {scope}"), Style::default().fg(Theme::TEXT), ))); right_lines.push(Line::from(Span::styled( format!(" Description: {description}"), Style::default().fg(Theme::TEXT), ))); right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::styled( " Content:", Style::default().fg(Theme::TEXT_DIM), ))); for line in content.lines() { right_lines.push(Line::from(Span::styled( format!(" {line}"), Style::default().fg(Theme::TEXT), ))); } right_lines.push(Line::from(Span::raw(""))); right_lines.push(Line::from(Span::styled( " [d]/[Del] Delete Lesson", Style::default().fg(Theme::TEXT_DIM), ))); } } } else { right_lines.push(Line::from(Span::styled( " Select a lesson on the left.", Style::default().fg(Theme::TEXT_DIM), ))); } let right_paragraph = Paragraph::new(right_lines) .block(right_block) .wrap(Wrap { trim: false }); frame.render_widget(right_paragraph, h_chunks[1]); } // ── Usage ──────────────────────────────────────────────────── crate::app::state::types::Overlay::Usage => { let block = block .title(Span::styled(" 📊 Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::INFO)); let runtime = state.session_runtime.as_ref(); let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime .map_or((0, 0, 0, 0, 0), |r| { ( r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start, ) }); let (edit_count, lesson_count, review_count, consec_empty) = runtime .map_or((0, 0, 0, 0), |r| { ( r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews, ) }); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let hours = elapsed_ms / 3_600_000; let minutes = (elapsed_ms % 3_600_000) / 60_000; let seconds = (elapsed_ms % 60000) / 1000; let total_tokens = tokens_in.saturating_add(tokens_out); let self_learning_total = review_tokens; let main_tokens = total_tokens.saturating_sub(self_learning_total); let lines = vec![ Line::from(Span::styled( " Token Usage", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), Line::from(Span::raw("")), Line::from(Span::styled( format!(" Main agent: {main_tokens} tokens"), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Self-learning: {self_learning_total} tokens"), Style::default().fg(Theme::TEXT_MUTED), )), Line::from(Span::styled( format!(" Total: {total_tokens} tokens"), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( format!(" API calls: {api_calls}"), Style::default().fg(Theme::TEXT), )), Line::from(Span::raw("")), Line::from(Span::styled( " Activity", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), Line::from(Span::styled( format!(" Edits: {edit_count}"), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Reviews: {review_count}"), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( format!(" Lessons: {lesson_count}"), Style::default().fg(Theme::TEXT_MUTED), )), Line::from(Span::styled( format!(" Empty reviews: {}", if consec_empty > 3 { format!("{consec_empty} ⚠") } else { consec_empty.to_string() }, ), Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }), )), Line::from(Span::raw("")), Line::from(Span::styled( format!(" Session: {hours}h {minutes}m {seconds}s"), Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Loading ────────────────────────────────────────────────── crate::app::state::types::Overlay::Loading => { let block = block .title(Span::styled(" ⏳ Loading ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::WARNING)); let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame_idx = (state.misc.tick_count as usize) % spinner.len(); let content = format!(" {} Processing, please wait...", spinner[frame_idx]); let paragraph = Paragraph::new(content) .block(block); frame.render_widget(paragraph, overlay_area); } // ── Model Selector ─────────────────────────────────────────── crate::app::state::types::Overlay::ModelSelector => { let block = block .title(Span::styled(" 🧠 Model Selector ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::ACCENT_PURPLE)); let mut lines: Vec = vec![ Line::from(Span::styled( format!(" Current: {} / {}", state.settings.provider, state.settings.model), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), Line::from(Span::raw("")), Line::from(Span::styled( " Providers:", Style::default().fg(Theme::TEXT_DIM), )), ]; let providers: Vec<(&String, &crate::model::app_config::ProviderConfig)> = state.app_config.providers.iter().collect(); for (i, (name, cfg)) in providers.iter().enumerate() { let is_current = *name == &state.settings.provider; let is_selected = i == state.misc.selected_index; let prefix = if is_selected { " ▸ " } else { " " }; let model_str = cfg.default_model.as_deref().unwrap_or("(any)"); let label = format!("{prefix}{name} ({model_str})"); let style = if is_current { Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else if is_selected { Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) } else { Style::default().fg(Theme::TEXT) }; lines.push(Line::from(Span::styled(label, style))); } lines.push(Line::from(Span::raw(""))); lines.push(Line::from(Span::styled( " ↑↓ navigate · Enter select · Esc close", Style::default().fg(Theme::TEXT_DIM), ))); let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } // ── Clear Confirm ──────────────────────────────────────────── crate::app::state::types::Overlay::ClearConfirm => { let block = block .title(Span::styled(" 🗑️ Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))) .border_style(Style::default().fg(Theme::WARNING)); let lines = vec![ Line::from(Span::styled( " Clear all messages from the transcript?", Style::default().fg(Theme::TEXT), )), Line::from(Span::raw("")), Line::from(Span::styled( " Enter to confirm · Esc to cancel", Style::default().fg(Theme::TEXT_DIM), )), ]; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } } } // ──────────────────────────────────────────────────────────────────────────── // Input bar with autocomplete // ──────────────────────────────────────────────────────────────────────────── /// Render the bottom input bar including the autocomplete dropdown above it. /// /// The bar has a subtle top border, a `❯` prompt, the user's buffer with /// a highlighted cursor position, and placeholder text when empty. fn render_input_bar( frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest, ) { // ── Autocomplete dropdown ──────────────────────────────────────────── if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { let n = state.input.autocomplete_candidates.len().min(10) as u16; let dropdown_height = n + 2; let dropdown_area = Rect { x: area.x, y: area.y.saturating_sub(dropdown_height), width: area.width.min(45), height: dropdown_height, }; let dropdown_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled( " ⌘ Commands ", Style::default().fg(Theme::PRIMARY), )) .style(Style::default().bg(Theme::SURFACE_ELEVATED)); let mut lines: Vec = Vec::new(); let selected = state.input.autocomplete_idx; for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) { let prefix = if i == selected { " ▸ " } else { " " }; let style = if i == selected { Style::default() .fg(Theme::TEXT) .bg(Theme::HIGHLIGHT_DIM) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }; let label = format!("{prefix}{candidate}"); lines.push(Line::from(Span::styled(label, style))); } let dropdown = Paragraph::new(lines).block(dropdown_block); frame.render_widget(dropdown, dropdown_area); } // ── Input bar ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Theme::BORDER)) .style(Style::default().bg(Theme::SURFACE)); let input_text = &state.input.buffer; let cursor_pos = state.input.cursor; let prompt = Span::styled( " ❯ ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), ); let mut spans = vec![prompt]; if input_text.is_empty() { spans.push(Span::styled( "Type a message or /command...", Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), )); } else { let (before, after) = input_text.split_at(cursor_pos); spans.push(Span::raw(before.to_string())); let cursor_char = if after.is_empty() { " " } else { &after[..1] }; // Cursor highlight spans.push(Span::styled( cursor_char, Style::default() .bg(Theme::HIGHLIGHT) .fg(Theme::BG) .add_modifier(Modifier::BOLD), )); if after.len() > 1 { spans.push(Span::raw(after[1..].to_string())); } } let line = Line::from(spans); let paragraph = Paragraph::new(line).block(block); frame.render_widget(paragraph, area); } // ──────────────────────────────────────────────────────────────────────────── // Toast notifications // ──────────────────────────────────────────────────────────────────────────── /// Render active toasts as a floating stack at top-right of the terminal. /// Each toast auto-expires after its `lifetime_ms`. Max 4 visible at once. /// /// Toasts are stacked vertically with a 1-line gap. Each has a colored /// left border and a subtle background. fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let now_ms = chrono::Utc::now().timestamp_millis(); let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter() .filter(|t| !t.expired(now_ms)) .collect(); if active.is_empty() { return; } let area = frame.area(); let toast_w: u16 = 48; let x = area.width.saturating_sub(toast_w).saturating_sub(2); let mut y: u16 = 1; for toast in active.iter().rev().take(4) { let line_count = toast.message.lines().count().max(1) as u16; let h = line_count + 2; let toast_area = Rect { x, y, width: toast_w, height: h, }; if toast_area.bottom() > area.height { break; } frame.render_widget(Clear, toast_area); let (border_color, icon) = match toast.kind { crate::app::state::types::ToastKind::Success => (Theme::SUCCESS, " ✓ "), crate::app::state::types::ToastKind::Warning => (Theme::WARNING, " ⚠ "), crate::app::state::types::ToastKind::Error => (Theme::ERROR, " ✗ "), crate::app::state::types::ToastKind::Info => (Theme::INFO, " ℹ "), crate::app::state::types::ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "), }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .title(Span::styled(icon, Style::default().fg(border_color))) .style(Style::default().bg(Theme::SURFACE_ELEVATED)); let paragraph = Paragraph::new(toast.message.as_str()) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, toast_area); y = y.saturating_add(h).saturating_add(1); } } // ──────────────────────────────────────────────────────────────────────────── // Layout helpers // ──────────────────────────────────────────────────────────────────────────── /// Compute a centered rectangle within `area` at the given percentage width /// and height. The result is always at least 40 cols wide and 10 rows tall. fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2; let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2; Rect { x: area.x.saturating_add(x_pad), y: area.y.saturating_add(y_pad), width: area.width.saturating_sub(x_pad * 2).max(40), height: area.height.saturating_sub(y_pad * 2).max(10), } } /// Split `items` into the slice that fits within `max_visible` entries and /// the count of items hidden beyond that limit. /// /// Used by sidebar widgets (Workflow, Tasks) to cap their content to the /// available panel height instead of overflowing it. /// /// Return: `(visible_slice, hidden_count)` — `hidden_count` is `0` when /// everything fits. pub(crate) fn split_for_display(items: &[T], max_visible: usize) -> (&[T], usize) { if items.len() <= max_visible { (items, 0) } else { (&items[..max_visible], items.len() - max_visible) } } /// Build the dim trailing hint line a sidebar widget shows when its /// content is truncated, pointing at the slash command that opens the /// full "expand" overlay for that widget (e.g. `"/workflow"`, `"/todo"`). pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> { Line::from(Span::styled( format!(" +{hidden} more — {command}"), Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), )) } #[cfg(test)] mod tests { use super::*; #[test] fn split_for_display_returns_everything_when_it_fits() { let items = vec![1, 2, 3]; let (visible, hidden) = split_for_display(&items, 5); assert_eq!(visible, &[1, 2, 3]); assert_eq!(hidden, 0); } #[test] fn split_for_display_truncates_and_counts_hidden() { let items = vec![1, 2, 3, 4, 5]; let (visible, hidden) = split_for_display(&items, 2); assert_eq!(visible, &[1, 2]); assert_eq!(hidden, 3); } #[test] fn overflow_hint_line_mentions_hidden_count_and_command() { let line = overflow_hint_line(3, "/todo"); let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert!(text.contains("+3 more")); assert!(text.contains("/todo")); } }