diff --git a/apps/interfaces/tui/src/components/component.rs b/apps/interfaces/tui/src/components/component.rs new file mode 100644 index 0000000..f1b232f --- /dev/null +++ b/apps/interfaces/tui/src/components/component.rs @@ -0,0 +1,21 @@ +//! Base trait for all TUI components. + +use crossterm::event::Event; +use ratatui::layout::Rect; +use ratatui::Frame; +use crate::action::Action; +use crate::state::AppStateRest; + +/// Base trait for modular UI components in the TUI. +pub trait Component { + /// Draw the component to the screen. + fn draw(&mut self, f: &mut Frame, area: Rect, state: &AppStateRest); + + /// Optional: Pre-render caching phase called before draw. + fn pre_render(&mut self, _state: &mut AppStateRest) {} + + /// Handle an input event. Return an optional Action to dispatch. + fn handle_event(&mut self, _event: Event, _state: &mut AppStateRest) -> Option { + None + } +} diff --git a/apps/interfaces/tui/src/components/mod.rs b/apps/interfaces/tui/src/components/mod.rs index dd6fd8a..07ec57a 100644 --- a/apps/interfaces/tui/src/components/mod.rs +++ b/apps/interfaces/tui/src/components/mod.rs @@ -2,3 +2,6 @@ //! //! This module will grow as shared widgets (buttons, input fields, etc.) //! are extracted from individual overlay and view modules. + +pub mod component; +pub use component::Component; diff --git a/apps/interfaces/tui/src/view/chat.rs b/apps/interfaces/tui/src/view/chat.rs index 4183c88..eb84f8e 100644 --- a/apps/interfaces/tui/src/view/chat.rs +++ b/apps/interfaces/tui/src/view/chat.rs @@ -7,6 +7,7 @@ //! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan use super::theme::Theme; +use crate::components::Component; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -14,6 +15,9 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; use zesdex_domain::core::Role; +#[derive(Default)] +pub struct ChatComponent; + const PREFIX_WIDTH: usize = 15; fn role_accent_color(role: &Role) -> Color { @@ -160,14 +164,8 @@ fn render_one_message( lines } -/// Pre-render hook: update cache secara incremental. -/// -/// Strategi: -/// - Cache menyimpan jumlah pesan saat terakhir di-render (`cached_msg_count`) -/// - Jika pesan bertambah β†’ hanya render pesan BARU, append ke cache -/// - Jika pesan berkurang (eviction) atau width berubah β†’ full rebuild -/// - Token count dihitung lazily hanya jika `token_count_dirty` -pub fn pre_render_chat(state: &mut crate::state::AppStateRest) { +impl Component for ChatComponent { + fn pre_render(&mut self, state: &mut crate::state::AppStateRest) { let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2); let msg_count = state.transcript_cache.messages.len(); let cached_count = state.cached_msg_count; @@ -220,11 +218,7 @@ pub fn pre_render_chat(state: &mut crate::state::AppStateRest) { } } -/// Render chat transcript. -/// -/// TIDAK melakukan clone seluruh cache β€” hanya mengambil slice window -/// yang visible (biasanya 30-50 baris) via reference langsung ke cache. -pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { + fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); @@ -300,6 +294,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe .style(Style::default().bg(Theme::BG)); frame.render_widget(paragraph, area); + } } fn split_spans_into_lines(spans: Vec>) -> Vec> { diff --git a/apps/interfaces/tui/src/view/input.rs b/apps/interfaces/tui/src/view/input.rs new file mode 100644 index 0000000..1ebb803 --- /dev/null +++ b/apps/interfaces/tui/src/view/input.rs @@ -0,0 +1,125 @@ +//! Chat input box and autocomplete dropdown rendering. +//! Implements the Component trait. + +use super::theme::Theme; +use crate::components::Component; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::Frame; + +#[derive(Default)] +pub struct InputComponent; + +impl Component for InputComponent { + fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { + 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_title = match state.input.autocomplete_kind { + crate::state::AutocompleteKind::Command => " ⌘ Commands ", + crate::state::AutocompleteKind::FileMention => " πŸ“ Files ", + }; + let dropdown_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER)) + .title(Span::styled( + dropdown_title, + Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), + )) + .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(ratatui::widgets::Clear, dropdown_area); + frame.render_widget(dropdown, dropdown_area); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER_FOCUSED)) + .style(Style::default().bg(Theme::BG)); + + 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, @file, 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, after_char) = if after.is_empty() { + (" ".to_string(), "") + } else { + let c = after.chars().next().unwrap(); + let char_len = c.len_utf8(); + (c.to_string(), &after[char_len..]) + }; + + // Blinking cursor logic based on tick count + let cursor_style = if state.misc.tick_count % 10 < 5 { + Style::default() + .bg(Theme::PRIMARY) + .fg(Theme::BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .bg(Theme::TEXT_DIM) + .fg(Theme::BG) + .add_modifier(Modifier::BOLD) + }; + + spans.push(Span::styled(cursor_char, cursor_style)); + if !after_char.is_empty() { + spans.push(Span::raw(after_char.to_string())); + } + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).block(block); + frame.render_widget(paragraph, area); + } +} diff --git a/apps/interfaces/tui/src/view/mod.rs b/apps/interfaces/tui/src/view/mod.rs index 9442a8f..6882879 100644 --- a/apps/interfaces/tui/src/view/mod.rs +++ b/apps/interfaces/tui/src/view/mod.rs @@ -3,13 +3,15 @@ //! centered panels, and floats toast notifications over the top-right corner. pub mod chat; +pub mod input; pub mod markdown; +pub mod overlays; pub mod sidebar; pub mod status; pub mod theme; pub mod workflow; -pub mod overlays; +use crate::components::Component; use crate::state::AppStateRest; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -27,7 +29,8 @@ const SIDEBAR_MIN_WIDTH: u16 = 90; /// This separates cache mutation from rendering so `draw` can take /// `&AppStateRest` (required by `terminal.draw` closure constraints). pub fn pre_render(state: &mut AppStateRest) { - chat::pre_render_chat(state); + let mut chat_comp = chat::ChatComponent::default(); + chat_comp.pre_render(state); } /// Top-level render entry point called once per TUI frame. @@ -64,122 +67,24 @@ pub fn draw(frame: &mut Frame, state: &AppStateRest) { let overlay = state.misc.overlay; overlays::render_overlay(frame, chat_area, overlay, state); } else { - render_main_panel(frame, chat_area, state); + let mut chat_comp = chat::ChatComponent::default(); + chat_comp.draw(frame, chat_area, state); } - render_input_bar(frame, input_area, state); - status::draw_status_bar(frame, status_area, state); + let mut input_comp = input::InputComponent::default(); + input_comp.draw(frame, input_area, state); + + let mut status_comp = status::StatusBarComponent::default(); + status_comp.draw(frame, status_area, state); if let Some(sidebar_rect) = sidebar_area { - sidebar::draw_sidebar(frame, sidebar_rect, state); + let mut sidebar_comp = sidebar::SidebarComponent::default(); + sidebar_comp.draw(frame, sidebar_rect, state); } render_toasts(frame, state); } -fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) { - chat::draw_chat(frame, area, state); -} - -fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) { - 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_title = match state.input.autocomplete_kind { - crate::state::AutocompleteKind::Command => " ⌘ Commands ", - crate::state::AutocompleteKind::FileMention => " πŸ“ Files ", - }; - let dropdown_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - dropdown_title, - 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); - } - - 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, after_char) = if after.is_empty() { - (" ".to_string(), "") - } else { - let c = after.chars().next().unwrap(); - let char_len = c.len_utf8(); - (c.to_string(), &after[char_len..]) - }; - spans.push(Span::styled( - cursor_char, - Style::default() - .bg(Theme::HIGHLIGHT) - .fg(Theme::BG) - .add_modifier(Modifier::BOLD), - )); - if !after_char.is_empty() { - spans.push(Span::raw(after_char.to_string())); - } - } - - let line = Line::from(spans); - let paragraph = Paragraph::new(line).block(block); - frame.render_widget(paragraph, area); -} - fn render_toasts(frame: &mut Frame, state: &AppStateRest) { let now_ms = chrono::Utc::now().timestamp_millis(); let active: Vec<&zesdex_infrastructure::Toast> = state @@ -209,6 +114,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) { break; } + // Shadow/Dimming effect background frame.render_widget(Clear, toast_area); let (border_color, icon) = match toast.kind { @@ -221,6 +127,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) { let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border_color)) .title(Span::styled(icon, Style::default().fg(border_color))) .style(Style::default().bg(Theme::SURFACE_ELEVATED)); diff --git a/apps/interfaces/tui/src/view/overlays/mod.rs b/apps/interfaces/tui/src/view/overlays/mod.rs index d8e2496..ccd3c1a 100644 --- a/apps/interfaces/tui/src/view/overlays/mod.rs +++ b/apps/interfaces/tui/src/view/overlays/mod.rs @@ -71,8 +71,9 @@ pub fn render_overlay( let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)) - .style(Style::default().bg(Theme::BG)); + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER_FOCUSED)) + .style(Style::default().bg(Theme::SURFACE_ELEVATED)); match overlay { Overlay::None => {} diff --git a/apps/interfaces/tui/src/view/sidebar.rs b/apps/interfaces/tui/src/view/sidebar.rs index 53bcb84..1e665ad 100644 --- a/apps/interfaces/tui/src/view/sidebar.rs +++ b/apps/interfaces/tui/src/view/sidebar.rs @@ -1,51 +1,60 @@ //! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage -//! widgets stacked in three vertical thirds. +//! widgets stacked vertically. Implements the Component trait. use super::theme::Theme; +use crate::components::Component; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; -/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage. -pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { - let has_workflow = !state.workflow_engine.agents.is_empty(); +#[derive(Default)] +pub struct SidebarComponent; - let constraints = if has_workflow { - vec![ - Constraint::Ratio(1, 2), - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - ] - } else { - vec![ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - }; +impl Component for SidebarComponent { + fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { + let has_workflow = !state.workflow_engine.agents.is_empty(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); + let constraints = if has_workflow { + vec![ + Constraint::Ratio(1, 2), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ] + } else { + vec![ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + }; - super::workflow::draw_workflow_panel(frame, chunks[0], state); - draw_tasks_widget(frame, chunks[1], state); - draw_usage_widget(frame, chunks[2], state); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + super::workflow::draw_workflow_panel(frame, chunks[0], state); + draw_tasks_widget(frame, chunks[1], state); + draw_usage_widget(frame, chunks[2], state); + } } fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { let block = Block::default() .title(Span::styled( - " Tasks ", + " πŸ“ Tasks ", Style::default() .fg(Theme::ACCENT_PURPLE) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)); - let budget = (block.inner(area).height as usize).max(1); + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER)) + .style(Style::default().bg(Theme::SURFACE)); + + let inner_area = block.inner(area); + let budget = (inner_area.height as usize).max(1); let content = &state.misc.todo_content; let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); @@ -53,7 +62,7 @@ fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta let lines: Vec = if task_lines.is_empty() { vec![Line::from(Span::styled( " No tasks yet.", - Style::default().fg(Theme::TEXT_DIM), + Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), ))] } else { let show_hint = task_lines.len() > budget; @@ -85,29 +94,29 @@ fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { let block = Block::default() .title(Span::styled( - " Usage ", + " πŸ“Š Usage ", Style::default() .fg(Theme::INFO) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::BORDER)); + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Theme::BORDER)) + .style(Style::default().bg(Theme::SURFACE)); let lines: Vec = if let Some(ref rt) = state.session_runtime { let now_ms = chrono::Utc::now().timestamp_millis(); let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms); let max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings); let current_tokens = if rt.usage.last_tokens_in > 0 { - // Actual context window used by the LLM (includes system prompt + tree) rt.usage.last_tokens_in as usize } else { - // Fallback for brand new sessions before the first API call state.cached_token_count }; let mut items = vec![ Line::from(Span::styled( - format!(" {:>6}: {} tok", "total", summary.total_tokens), + format!(" {:<6}: {} tok", "Total", summary.total_tokens), Style::default() .fg(Theme::TEXT) .add_modifier(Modifier::BOLD), @@ -116,36 +125,36 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta if summary.self_learning_tokens > 0 { items.push(Line::from(Span::styled( - format!(" {:>6}: {} tok", "main", summary.main_tokens), + format!(" {:<6}: {} tok", "Main", summary.main_tokens), Style::default().fg(Theme::TEXT_DIM), ))); items.push(Line::from(Span::styled( - format!(" {:>6}: {} tok", "learn", summary.self_learning_tokens), + format!(" {:<6}: {} tok", "Learn", summary.self_learning_tokens), Style::default().fg(Theme::TEXT_DIM), ))); } items.extend(vec![ Line::from(Span::styled( - format!(" {:>6}: {}/{}", "ctx", current_tokens, max_tokens), + format!(" {:<6}: {}/{}", "Ctx", current_tokens, max_tokens), Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( - format!(" {:>6}: {}", "prov", state.settings.provider), + format!(" {:<6}: {}", "Prov", state.settings.provider), Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( - format!(" {:>6}: {}", "model", state.settings.model), + format!(" {:<6}: {}", "Model", state.settings.model), Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( - format!(" {:>6}: {}", "calls", summary.api_calls), + format!(" {:<6}: {}", "Calls", summary.api_calls), Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( format!( - " {:>6}: {}h {:02}m {:02}s", - "time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds + " {:<6}: {}h {:02}m {:02}s", + "Time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds ), Style::default().fg(Theme::TEXT_DIM), )), @@ -155,7 +164,7 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta } else { vec![Line::from(Span::styled( " No active session.", - Style::default().fg(Theme::TEXT_DIM), + Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), ))] }; @@ -163,10 +172,6 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta frame.render_widget(paragraph, area); } -/// Aggregated usage statistics for the current session. -/// -/// Separates main (chat) and self-learning (review) token counts from -/// the raw `UsageStats` and adds a human-readable elapsed-time breakdown. pub(crate) struct UsageSummary { pub main_tokens: u64, pub self_learning_tokens: u64, @@ -177,10 +182,6 @@ pub(crate) struct UsageSummary { pub elapsed_seconds: i64, } -/// Compute a human-friendly usage summary from raw `UsageStats`. -/// -/// Fields: total_tokens = tokens_in + tokens_out, self_learning = review_tokens, -/// main = total - self_learning. Elapsed time is broken into hours/minutes/seconds. #[tracing::instrument] pub(crate) fn compute_usage_summary( usage: &zesdex_domain::core::UsageStats, diff --git a/apps/interfaces/tui/src/view/status.rs b/apps/interfaces/tui/src/view/status.rs index c73989f..c13a733 100644 --- a/apps/interfaces/tui/src/view/status.rs +++ b/apps/interfaces/tui/src/view/status.rs @@ -1,93 +1,90 @@ //! Status bar rendering for the TUI β€” modern segmented bar design. -//! -//! Flow: `draw_status_bar` reads live connection/turn state off -//! `AppStateRest` every frame and paints a single-line bar at the -//! bottom of the screen with three visual segments. +//! Implements the Component trait. use super::theme::Theme; +use crate::components::Component; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Block; use ratatui::Frame; -use tracing::instrument; -/// Render the single-line status bar at the bottom of the terminal. -/// -/// Three visual segments: left (app name + PROG/READY/NOAPI badge), -/// center (lesson indicator), right (token count, provider, model). -#[instrument(skip_all)] -pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { - use ratatui::layout::{Alignment, Constraint, Direction, Layout}; - let spinner_frames = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"]; +#[derive(Default)] +pub struct StatusBarComponent; - let (status_text, badge_bg, status_fg) = if state.turn_in_flight() { - let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; - (format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG) - } else if state.misc.api_connected { - (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG) - } else { - (" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG) - }; +impl Component for StatusBarComponent { + fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { + use ratatui::layout::{Alignment, Constraint, Direction, Layout}; + let spinner_frames = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"]; - let status_badge = Span::styled( - status_text, - Style::default() - .fg(status_fg) - .bg(badge_bg) - .add_modifier(Modifier::BOLD), - ); + let (status_text, badge_bg, status_fg) = if state.turn_in_flight() { + let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; + (format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG) + } else if state.misc.api_connected { + (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG) + } else { + (" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG) + }; - let left_spans = vec![ - Span::styled( - " ⚑zesdex ", + let status_badge = Span::styled( + status_text, Style::default() - .fg(Theme::TEXT) + .fg(status_fg) + .bg(badge_bg) .add_modifier(Modifier::BOLD), - ), - status_badge, - ]; + ); - let right_str = String::new(); + let left_spans = vec![ + Span::styled( + " ⚑zesdex ", + Style::default() + .fg(Theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + status_badge, + ]; - let left_line = Line::from(left_spans); - let right_line = Line::from(Span::styled( - right_str, - Style::default().fg(Theme::TEXT_MUTED), - )); + let right_str = format!(" Provider: {} | Model: {} ", state.settings.provider, state.settings.model); - let center_line = if state.misc.lesson_running { - Line::from(vec![Span::styled( - " πŸ“˜ Generating Lesson... ", - Style::default() - .fg(Theme::MODE_YOLO) - .add_modifier(Modifier::BOLD), - )]) - } else { - Line::from("") - }; + let left_line = Line::from(left_spans); + let right_line = Line::from(Span::styled( + right_str, + Style::default().fg(Theme::TEXT_MUTED), + )); - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(25), - Constraint::Min(10), - Constraint::Length(60), - ]) - .split(area); + let center_line = if state.misc.lesson_running { + Line::from(vec![Span::styled( + " πŸ“˜ Generating Lesson... ", + Style::default() + .fg(Theme::MODE_YOLO) + .add_modifier(Modifier::BOLD), + )]) + } else { + Line::from("") + }; - let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT)); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(25), + Constraint::Min(10), + Constraint::Length(60), + ]) + .split(area); - let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone()); - frame.render_widget(left_para, chunks[0]); + let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT)); - let center_para = ratatui::widgets::Paragraph::new(center_line) - .block(block.clone()) - .alignment(Alignment::Center); - frame.render_widget(center_para, chunks[1]); + let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone()); + frame.render_widget(left_para, chunks[0]); - let right_para = ratatui::widgets::Paragraph::new(right_line) - .block(block) - .alignment(Alignment::Right); - frame.render_widget(right_para, chunks[2]); + let center_para = ratatui::widgets::Paragraph::new(center_line) + .block(block.clone()) + .alignment(Alignment::Center); + frame.render_widget(center_para, chunks[1]); + + let right_para = ratatui::widgets::Paragraph::new(right_line) + .block(block) + .alignment(Alignment::Right); + frame.render_widget(right_para, chunks[2]); + } } diff --git a/apps/interfaces/tui/src/view/theme.rs b/apps/interfaces/tui/src/view/theme.rs index 33e2a8b..9898d85 100644 --- a/apps/interfaces/tui/src/view/theme.rs +++ b/apps/interfaces/tui/src/view/theme.rs @@ -10,47 +10,49 @@ pub struct Theme; impl Theme { // ── Base surface colors ────────────────────────────────────────────── - pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26); - pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35); - pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42); + pub const BG: Color = Color::Rgb(0x1e, 0x1e, 0x2e); // Catppuccin Mocha Base + pub const SURFACE: Color = Color::Rgb(0x31, 0x32, 0x44); // Catppuccin Mocha Surface0 + pub const SURFACE_ELEVATED: Color = Color::Rgb(0x45, 0x47, 0x5a); // Catppuccin Mocha Surface1 // ── Text colors ────────────────────────────────────────────────────── - pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5); - pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6); - pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89); + pub const TEXT: Color = Color::Rgb(0xcd, 0xd6, 0xf4); // Text + pub const TEXT_MUTED: Color = Color::Rgb(0xba, 0xc2, 0xde); // Subtext1 + pub const TEXT_DIM: Color = Color::Rgb(0xa6, 0xad, 0xc8); // Subtext0 // ── Accent colors ──────────────────────────────────────────────────── - pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7); - pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a); - pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68); - pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e); - pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff); + pub const PRIMARY: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue + pub const SUCCESS: Color = Color::Rgb(0xa6, 0xe3, 0xa1); // Green + pub const WARNING: Color = Color::Rgb(0xf9, 0xe2, 0xaf); // Yellow + pub const ERROR: Color = Color::Rgb(0xf3, 0x8b, 0xa8); // Red + pub const INFO: Color = Color::Rgb(0x89, 0xdc, 0xeb); // Sky // ── Extended accent palette ────────────────────────────────────────── - pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7); - pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64); - pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca); + pub const ACCENT_PURPLE: Color = Color::Rgb(0xcb, 0xa6, 0xf7); // Mauve + pub const ACCENT_ORANGE: Color = Color::Rgb(0xfa, 0xb3, 0x87); // Peach + pub const ACCENT_TEAL: Color = Color::Rgb(0x94, 0xe2, 0xd5); // Teal + pub const ACCENT_PINK: Color = Color::Rgb(0xf5, 0xc2, 0xe7); // Pink // ── Border colors ──────────────────────────────────────────────────── - pub const BORDER: Color = Color::Rgb(0x3b, 0x42, 0x61); + pub const BORDER: Color = Color::Rgb(0x58, 0x5b, 0x70); // Surface2 + pub const BORDER_FOCUSED: Color = Color::Rgb(0xcb, 0xa6, 0xf7); // Mauve // ── Role badge colors ──────────────────────────────────────────────── - pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a); - pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7); - pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff); - pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68); + pub const ROLE_USER: Color = Color::Rgb(0xa6, 0xe3, 0xa1); // Green + pub const ROLE_ASSISTANT: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue + pub const ROLE_SYSTEM: Color = Color::Rgb(0x89, 0xdc, 0xeb); // Sky + pub const ROLE_TOOL: Color = Color::Rgb(0xf9, 0xe2, 0xaf); // Yellow // ── Status colors ──────────────────────────────────────────────────── - pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e); - pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a); - pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e); + pub const STATUS_BAR_BG: Color = Color::Rgb(0x18, 0x18, 0x25); // Mantle + pub const MODE_AUTO: Color = Color::Rgb(0xa6, 0xe3, 0xa1); + pub const MODE_YOLO: Color = Color::Rgb(0xf3, 0x8b, 0xa8); // ── Code / markdown ────────────────────────────────────────────────── - pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e); - pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42); - pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff); + pub const CODE_BG: Color = Color::Rgb(0x11, 0x11, 0x1b); // Crust + pub const CODE_BAR: Color = Color::Rgb(0x31, 0x32, 0x44); // Surface0 + pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0xcb, 0xa6, 0xf7); // Mauve // ── Misc ───────────────────────────────────────────────────────────── - pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1); - pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42); + pub const HIGHLIGHT: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue + pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x31, 0x32, 0x44); // Surface0 } diff --git a/apps/interfaces/tui/src/view/workflow.rs b/apps/interfaces/tui/src/view/workflow.rs index d15b64e..003df86 100644 --- a/apps/interfaces/tui/src/view/workflow.rs +++ b/apps/interfaces/tui/src/view/workflow.rs @@ -58,7 +58,9 @@ pub fn draw_workflow_panel( let block = Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) + .style(Style::default().bg(Theme::SURFACE)) .title(title); let inner = block.inner(area);