From 3f5f27c33997d623bb440eb2a36f002c23cac40c Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 13 Jul 2026 05:42:17 +0700 Subject: [PATCH] Enhance TUI with modern design and improved status rendering - Updated status bar rendering in `status.rs` to feature a segmented design with clear visual segments for app name, status, and metadata. - Refined color theme in `theme.rs` to adopt a modern dark palette with neon accents, improving visual hierarchy and readability. - Revamped workflow panel in `workflow.rs` to display agent statuses as compact cards with state badges, enhancing clarity and user experience. - Improved overall styling consistency across components, ensuring a cohesive look and feel throughout the TUI. --- src/view/chat.rs | 197 ++++++----- src/view/markdown.rs | 140 +++++--- src/view/mod.rs | 754 +++++++++++++++++++++++++++---------------- src/view/status.rs | 97 +++--- src/view/theme.rs | 102 ++++-- src/view/workflow.rs | 241 ++++++++------ 6 files changed, 977 insertions(+), 554 deletions(-) diff --git a/src/view/chat.rs b/src/view/chat.rs index 5c014ce..359ceff 100644 --- a/src/view/chat.rs +++ b/src/view/chat.rs @@ -1,14 +1,15 @@ -//! Chat transcript panel rendering. +//! Chat transcript panel rendering — message cards with role badges. //! //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a -//! header + markdown-rendered body per message (via `super::markdown`), -//! appends a streaming spinner line when a turn is in flight, then -//! slices the combined line list to the currently visible scroll window -//! before handing it to a ratatui `Paragraph`. +//! visually rich transcript where each message is rendered as a "card" +//! with a role-colored left accent bar, a role badge pill, timestamp, +//! and markdown body. A streaming spinner line is appended when a turn +//! is in flight. The combined line list is sliced to the visible scroll +//! window before rendering. //! -//! Why: lines are recomputed every frame instead of cached, since -//! markdown wrapping depends on the current terminal width, which can -//! change between frames. +//! Design: messages are visually separated with vertical spacing, role +//! badges are colored pills on the left, and each message has a thin +//! role-colored border on its left side for quick visual scanning. use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; @@ -18,16 +19,6 @@ use ratatui::Frame; use super::theme::Theme; /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. -/// -/// Flow: for each span, split its text on '\n' → push non-empty parts onto -/// the current line's span buffer → on each newline boundary, flush the -/// buffer into a new `Line` and start fresh. -/// -/// Why: `render_markdown` produces a single Vec with newlines baked -/// into span content; ratatui's Paragraph wants pre-split `Line`s to lay -/// out and scroll correctly. -/// -/// Return: at least one (possibly empty) `Line`, never an empty vec. fn split_spans_into_lines<'a>(spans: Vec>) -> Vec> { let mut lines = Vec::new(); let mut current_spans = Vec::new(); @@ -53,44 +44,42 @@ fn split_spans_into_lines<'a>(spans: Vec>) -> Vec> { lines } -fn _role_name(role: &crate::dto::chat::message::Role) -> &'static str { +fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { match role { - crate::dto::chat::message::Role::User => "User", - crate::dto::chat::message::Role::Assistant => "Assistant", - crate::dto::chat::message::Role::System => "System", - crate::dto::chat::message::Role::Tool => "Tool", + crate::dto::chat::message::Role::User => " YOU ", + crate::dto::chat::message::Role::Assistant => " AI ", + crate::dto::chat::message::Role::System => " SYS ", + crate::dto::chat::message::Role::Tool => " TOOL ", } } -/// Map a message role to its short uppercase badge label for the chat header. -fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { +fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color { match role { - crate::dto::chat::message::Role::User => "YOU", - crate::dto::chat::message::Role::Assistant => "AI ", - crate::dto::chat::message::Role::System => "SYS", - crate::dto::chat::message::Role::Tool => "TOOL", + crate::dto::chat::message::Role::User => Theme::ROLE_USER, + crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, + crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, + crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, + } +} + +fn role_label(role: &crate::dto::chat::message::Role) -> &'static str { + match role { + crate::dto::chat::message::Role::User => "You", + crate::dto::chat::message::Role::Assistant => "Assistant", + crate::dto::chat::message::Role::System => "System", + crate::dto::chat::message::Role::Tool => "Tool Call", } } fn format_timestamp(ts: i64) -> String { - if ts <= 0 { return "".to_string(); } + if ts <= 0 { return String::new(); } let secs = ts / 1000; let mins = (secs / 60) % 60; let hrs = (secs / 3600) % 24; format!("{:02}:{:02}", hrs, mins) } -/// Render the scrollable chat transcript panel. -/// -/// Flow: build a header + markdown-rendered body Line list per message → -/// append a streaming spinner line if a turn is in flight → slice the -/// combined lines to the visible window based on scroll offset → wrap in -/// a Paragraph and render. -/// -/// Why: lines are computed fresh every frame rather than cached, since -/// wrapping depends on the current terminal width. -/// -/// Return: nothing; draws directly into `frame` at `area`. +/// Render the scrollable chat transcript panel with message card styling. pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; @@ -98,37 +87,50 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: let mut display_lines: Vec = Vec::new(); - let _total_msgs = messages.len(); + // ── Header ─────────────────────────────────────────────────────────── + let title = if messages.is_empty() { + String::from(" Chat ") + } else { + format!(" Chat [{} msgs]", messages.len()) + }; - for msg in messages.iter() { - let role_color = match msg.role { - crate::dto::chat::message::Role::User => Theme::ROLE_USER, - crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, - crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, - crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, - }; - let prefix = match msg.role { - crate::dto::chat::message::Role::User => ">", - crate::dto::chat::message::Role::Assistant => "•", - crate::dto::chat::message::Role::System => "#", - crate::dto::chat::message::Role::Tool => "→", - }; - - let ts_str = format_timestamp(msg.timestamp); + // ── Render messages as cards ───────────────────────────────────────── + for (_msg_idx, msg) in messages.iter().enumerate() { + let accent = role_accent_color(&msg.role); let badge = role_badge(&msg.role); - let time_display = if ts_str.is_empty() { String::new() } else { format!(" [{}]", ts_str) }; + let label = role_label(&msg.role); + let ts_str = format_timestamp(msg.timestamp); + // ── Role header line ───────────────────────────────────────────── + // Left accent bar + badge pill + role name + timestamp let header = Line::from(vec![ + // Thin accent bar on the left Span::styled( - format!(" {} ", badge), - Style::default().fg(Theme::BG).bg(role_color).add_modifier(Modifier::BOLD), + "▎", + Style::default().fg(accent), ), + // Role badge pill Span::styled( - time_display, - Style::default().fg(Theme::DIM), + badge, + Style::default() + .fg(Theme::BG) + .bg(accent) + .add_modifier(Modifier::BOLD), + ), + // Role name + Span::styled( + format!(" {}", label), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + ), + // Timestamp + Span::styled( + if ts_str.is_empty() { String::new() } else { format!(" {}", ts_str) }, + Style::default().fg(Theme::TEXT_DIM), ), ]); + display_lines.push(header); + // ── Message content ────────────────────────────────────────────── let is_last = std::ptr::eq(msg, messages.last().unwrap()); let content_str = if msg.content.trim().is_empty() { if is_last && state.turn_in_flight() { @@ -139,36 +141,60 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: } else { msg.content.clone() }; - let mut content_spans = vec![Span::styled(format!("{} ", prefix), Style::default().fg(role_color))]; + + // Render markdown content with accent-colored prefix + let mut content_spans = vec![ + Span::styled(" ", Style::default().fg(accent)), + ]; content_spans.extend(super::markdown::render_markdown(&content_str, area.width)); let message_lines = split_spans_into_lines(content_spans); - display_lines.push(header); for line in message_lines { display_lines.push(line); } + + // ── Message separator ──────────────────────────────────────────── display_lines.push(Line::from(Span::raw(""))); } + // ── Streaming indicator ────────────────────────────────────────────── if state.turn_in_flight() { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let frame = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; + let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); + let spinner = spinner_frames[frame_idx]; + display_lines.push(Line::from(vec![ - Span::styled(" AI ", Style::default().fg(Theme::BG).bg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD)), - Span::styled(format!(" {} Generating...", frame), Style::default().fg(Theme::DIM)), + Span::styled( + "▎", + Style::default().fg(Theme::ROLE_ASSISTANT), + ), + Span::styled( + " AI ", + Style::default() + .fg(Theme::BG) + .bg(Theme::ROLE_ASSISTANT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {} ", spinner), + Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), + ), + Span::styled( + "Generating...", + Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), + ), ])); display_lines.push(Line::from(Span::raw(""))); } - let mut title = String::from(" Chat "); - if !messages.is_empty() { - title.push_str(&format!("[{} msgs]", messages.len())); - } - + // ── Scrolling ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) - .title(title); + .title(Span::styled( + title, + Style::default().fg(Theme::TEXT_MUTED), + )); let total = display_lines.len(); let max_offset = total.saturating_sub(max_visible); @@ -182,9 +208,34 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: display_lines[total.saturating_sub(max_visible)..total].to_vec() }; + // ── Scroll position indicator ──────────────────────────────────────── + // Show a small percentage indicator in the title if scrolled + let scroll_pct = if total > max_visible { + ((offset as f64 / max_offset as f64) * 100.0) as u8 + } else { + 0 + }; + + let block = if scroll_pct > 0 { + let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::BORDER)) + .title(Span::styled( + scroll_title, + Style::default().fg(Theme::TEXT_MUTED), + )) + } else { + block + }; + let paragraph = Paragraph::new(visible) .block(block) + .style(Style::default().bg(Theme::BG)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } + +// Need to import Color for role_accent_color +use ratatui::style::Color; diff --git a/src/view/markdown.rs b/src/view/markdown.rs index 199f12a..8359d0d 100644 --- a/src/view/markdown.rs +++ b/src/view/markdown.rs @@ -1,27 +1,22 @@ //! Markdown-to-styled-spans rendering for the chat transcript. //! //! Flow: `render_markdown` walks a `pulldown_cmark` event stream and -//! translates each markdown construct (headings, code blocks, links, -//! emphasis, block quotes, lists) into styled `ratatui::text::Span`s, -//! then optionally re-wraps the flat span list to a target column width. +//! translates each markdown construct into styled `ratatui::text::Span`s, +//! then re-wraps the flat span list to a target column width. //! -//! Why: ratatui has no built-in markdown renderer, so this module bridges -//! `pulldown_cmark`'s event-based parser to ratatui's span/line model. +//! Design: code blocks get a dark background with a labeled top bar, +//! headings are bold with distinct colors, blockquotes get a vertical +//! accent bar prefix, and inline code is highlighted with a background. -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::Span; +use super::theme::Theme; /// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// /// Flow: pulldown_cmark parses `text` into an event stream → each -/// Start/End/Text/Code/Break event is translated into styled `Span`s -/// (headings colored by level, code blocks green, links bracketed, etc.) -/// → if `width > 0`, a second pass inserts manual newline spans whenever -/// the running line length would exceed `width`. -/// -/// Why: ratatui has no native markdown renderer, and the built-in `Wrap` -/// widget wraps on grapheme count without honoring markdown structure, -/// so wrapping is done manually here in terms of raw span byte length. +/// Start/End/Text/Code/Break event is translated into styled `Span`s → +/// if `width > 0`, a second pass wraps long lines. /// /// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// turns it back into `Line`s for the Paragraph widget. @@ -29,6 +24,9 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { let mut spans = Vec::new(); let parser = pulldown_cmark::Parser::new(text); let mut in_code_block = false; + let mut in_heading = false; + let mut heading_level = 0; + let mut first_in_paragraph = true; for event in parser { match event { @@ -36,52 +34,59 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { match tag { pulldown_cmark::Tag::CodeBlock(_) => { in_code_block = true; + // Code block top bar spans.push(Span::styled( - "```\n", - Style::default().fg(Color::DarkGray), + "\n", + Style::default(), + )); + spans.push(Span::styled( + " ┌─ code ", + Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), + )); + spans.push(Span::styled( + "\n", + Style::default(), )); } pulldown_cmark::Tag::Heading { level, .. } => { - let color = match level { - pulldown_cmark::HeadingLevel::H1 => Color::LightCyan, - pulldown_cmark::HeadingLevel::H2 => Color::Cyan, - _ => Color::White, + in_heading = true; + heading_level = match level { + pulldown_cmark::HeadingLevel::H1 => 1, + pulldown_cmark::HeadingLevel::H2 => 2, + pulldown_cmark::HeadingLevel::H3 => 3, + _ => 4, }; - let prefix = match level { - pulldown_cmark::HeadingLevel::H1 => "# ", - pulldown_cmark::HeadingLevel::H2 => "## ", - pulldown_cmark::HeadingLevel::H3 => "### ", - _ => "# ", - }; - spans.push(Span::styled( - prefix, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); + // No prefix, we'll handle in the text events + } + pulldown_cmark::Tag::Paragraph => { + first_in_paragraph = true; } - pulldown_cmark::Tag::Paragraph => {} pulldown_cmark::Tag::Emphasis => {} pulldown_cmark::Tag::Strong => {} pulldown_cmark::Tag::List(_) => {} pulldown_cmark::Tag::Item => { + // List item bullet spans.push(Span::styled( - " * ", - Style::default().fg(Color::DarkGray), + " • ", + Style::default().fg(Theme::PRIMARY), )); } pulldown_cmark::Tag::Link { dest_url, .. } => { spans.push(Span::styled( "[", - Style::default().fg(Color::Cyan), + Style::default().fg(Theme::INFO), )); + // We push the URL as a tooltip-like suffix + // After the link text ends, we'll add the URL spans.push(Span::styled( format!("]({})", dest_url), - Style::default().fg(Color::Blue), + Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), )); } pulldown_cmark::Tag::BlockQuote(_) => { spans.push(Span::styled( - "> ", - Style::default().fg(Color::DarkGray), + "▎", + Style::default().fg(Theme::BLOCKQUOTE_BAR), )); } _ => {} @@ -91,15 +96,19 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { match tag { pulldown_cmark::TagEnd::CodeBlock => { in_code_block = false; + // Code block bottom bar spans.push(Span::styled( - "\n```\n", - Style::default().fg(Color::DarkGray), + "\n └─\n", + Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), )); } pulldown_cmark::TagEnd::Heading(_) => { + in_heading = false; + heading_level = 0; spans.push(Span::raw("\n")); } pulldown_cmark::TagEnd::Paragraph => { + first_in_paragraph = true; spans.push(Span::raw("\n\n")); } pulldown_cmark::TagEnd::Emphasis => {} @@ -119,21 +128,47 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { let s = text.to_string(); if in_code_block { spans.push(Span::styled( - s, - Style::default().fg(Color::Green), + format!(" {}", s), + Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), + )); + } else if in_heading { + let color = match heading_level { + 1 => Theme::PRIMARY, + 2 => Theme::INFO, + 3 => Theme::ACCENT_PURPLE, + _ => Theme::TEXT, + }; + let prefix = match heading_level { + 1 => " ", + 2 => " ", + 3 => " ", + _ => " ", + }; + spans.push(Span::styled( + format!("{}{}", prefix, s), + Style::default().fg(color).add_modifier(Modifier::BOLD), )); } else { + // Handle first word detection for paragraph indentation + if first_in_paragraph { + spans.push(Span::raw(" ")); + first_in_paragraph = false; + } spans.push(Span::raw(s)); } } pulldown_cmark::Event::Code(text) => { + // Inline code with background spans.push(Span::styled( - format!("`{}`", text), - Style::default().fg(Color::Green), + format!(" {} ", text), + Style::default() + .fg(Theme::ACCENT_TEAL) + .bg(Theme::CODE_BAR) + .add_modifier(Modifier::BOLD), )); } pulldown_cmark::Event::SoftBreak => { - spans.push(Span::raw("\n")); + spans.push(Span::raw(" ")); } pulldown_cmark::Event::HardBreak => { spans.push(Span::raw("\n")); @@ -145,20 +180,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { if width > 0 { let mut spans_out = Vec::new(); let mut line_len = 0; + let effective_width = (width as usize).saturating_sub(2); // leave margin + for span in &spans { let style = span.style; let s = span.content.clone(); - let text = s.as_ref(); - let remaining = text.len(); - if line_len + remaining > width as usize && line_len > 0 { + let text_str = s.as_ref(); + let remaining = text_str.len(); + + if line_len + remaining > effective_width && line_len > 0 { spans_out.push(Span::raw("\n")); line_len = 0; } - spans_out.push(Span::styled(text.to_string(), style)); - if !text.contains('\n') { + + spans_out.push(Span::styled(text_str.to_string(), style)); + + if !text_str.contains('\n') { line_len += remaining; } else { - line_len = text.split('\n').next_back().unwrap_or("").len(); + line_len = text_str.split('\n').next_back().unwrap_or("").len(); } } spans = spans_out; diff --git a/src/view/mod.rs b/src/view/mod.rs index dfe36bf..1e809fc 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,6 +1,10 @@ //! Top-level TUI render pipeline: layouts the terminal into chat / input -//! / status regions, dispatches overlay rendering, and floats toast -//! notifications over the top-right corner. +//! / 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; @@ -16,20 +20,12 @@ use ratatui::Frame; use theme::Theme; /// Top-level render entry point called once per TUI frame. -/// -/// Flow: split the frame into main / input / status regions → if an -/// overlay is active, render it inside the main region; otherwise render -/// the chat transcript → always render the input bar and status bar → -/// overlay toast notifications in the top-right corner. -/// -/// Why: a single function owns the layout so every state change -/// re-renders the whole UI from a known template. -/// -/// Return: nothing; writes directly into `frame`. pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let area = frame.area(); - let show_todo = !state.misc.todo_content.is_empty() || state.misc.overlay == crate::app::state::types::Overlay::Todo; + // ── Determine if we need a side panel (todo) ───────────────────────── + let show_todo = !state.misc.todo_content.is_empty() + || state.misc.overlay == crate::app::state::types::Overlay::Todo; let (main_area, todo_area) = if show_todo && area.width > 60 { let h_chunks = Layout::default() .direction(Direction::Horizontal) @@ -43,6 +39,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { (area, None) }; + // ── Vertical layout: chat / input / status ─────────────────────────── let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -56,33 +53,48 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { let input_area = chunks[1]; let status_area = chunks[2]; - if state.misc.overlay.is_active() && state.misc.overlay != crate::app::state::types::Overlay::Todo { + // ── Render main area (overlay or chat) ─────────────────────────────── + if state.misc.overlay.is_active() + && state.misc.overlay != crate::app::state::types::Overlay::Todo + { 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); + // ── Todo side panel ────────────────────────────────────────────────── if let Some(todo_rect) = todo_area { render_todo_panel(frame, todo_rect, state); } - // Toast notifications at top-right (like Hyprland) + // ── Toasts (top-right floating) ────────────────────────────────────── render_toasts(frame, state); } -fn render_todo_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { +// ──────────────────────────────────────────────────────────────────────────── +// Panel helpers +// ──────────────────────────────────────────────────────────────────────────── + +fn render_todo_panel( + frame: &mut Frame, + area: Rect, + state: &crate::app::state::rest::AppStateRest, +) { let block = Block::default() - .title(" Todo ") + .title(" 📋 Tasks ") .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::PRIMARY)) + .border_style(Style::default().fg(Theme::ACCENT_PURPLE)) .style(Style::default().bg(Theme::BG)); let content = if state.misc.todo_content.is_empty() { - "No tasks yet." + " No tasks yet." } else { &state.misc.todo_content }; @@ -90,71 +102,90 @@ fn render_todo_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::r let paragraph = Paragraph::new(content) .block(block) .wrap(Wrap { trim: false }); - + frame.render_widget(paragraph, area); } -fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { +fn render_main_panel( + frame: &mut Frame, + area: Rect, + state: &crate::app::state::rest::AppStateRest, +) { chat::draw_chat(frame, area, state); } -/// Render the active modal overlay (Help, Settings, Workflow, Bash, Editor, etc.). -/// -/// Flow: compute a centered sub-area → clear it to make the rest of the -/// frame visible behind → match on the Overlay variant to pick the -/// panel's title, content Lines, and styling → render as a Paragraph or -/// delegate to a specialized drawer (e.g. `workflow::draw_workflow_panel`). -/// -/// Why: each Overlay variant has its own data sources (settings, -/// session_runtime, app_config) and its own visual treatment, so they -/// are dispatched individually rather than table-driven. -/// -/// Return: nothing; draws directly into `frame`. -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, 70, 60); +// ──────────────────────────────────────────────────────────────────────────── +// 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 +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::PRIMARY)) + .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(" 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(" 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), + format!(" Provider: {}", state.settings.provider), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Model: {}", state.settings.model), + format!(" Model: {}", state.settings.model), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Max tokens: {}", state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), + format!(" Max tokens: {}", + state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Temperature: {}", state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), + format!(" Temperature: {}", + state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Internet mode: {:?}", state.settings.internet_mode), + format!(" Internet: {:?}", state.settings.internet_mode), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Review enabled: {}", state.settings.review_enabled), + format!(" Review: {}", state.settings.review_enabled), Style::default().fg(Theme::TEXT), )), ]; @@ -162,192 +193,231 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ frame.render_widget(paragraph, overlay_area); } + // ── Bash ────────────────────────────────────────────────────── crate::app::state::types::Overlay::Bash => { - let block = block.title(" 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" }), + 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::DIM), + " 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(" Quit ") + .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::WARNING).add_modifier(Modifier::BOLD), + " Are you sure you want to quit?", + Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD), )), + Line::from(Span::raw("")), Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - "Press Enter to confirm, Esc to cancel.", - Style::default().fg(Theme::DIM), + " 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(" Input "); + 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 input..." + " Type your API key..." } else { - input_text.as_str() + // 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() { + let suffix = if input_text.len() > 8 { "****" } else { "" }; + format!("{}{}", display, suffix) + } else { + display.to_string() }; - let paragraph = Paragraph::new(display) - .block(block); - frame.render_widget(paragraph, overlay_area); - } - crate::app::state::types::Overlay::Editor => { - let block = block.title(" Editor "); let lines = vec![ Line::from(Span::styled( - "Editor Mode", - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - "Current input buffer:", - Style::default().fg(Theme::DIM), - )), - Line::from(Span::styled( - &state.input.buffer, + " 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( - "", - Style::default(), + " 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!("Cursor at position {} / {}", state.input.cursor, state.input.buffer.len()), - Style::default().fg(Theme::DIM), + 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(" Effort Level "); + 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 = levels.iter().enumerate().map(|(i, l)| { - let selected = i == current_idx; + let mut lines: Vec = vec![ Line::from(Span::styled( - if selected { format!("> {} (current)", l) } else { format!(" {}", l) }, + " 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!(" ▸ {} (active)", l) + } else { + format!(" {}", l) + }, if selected { Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }, - )) - }).collect(); - lines.insert(0, Line::from(Span::styled( - "Use arrow keys to change effort level", - Style::default().fg(Theme::DIM), - ))); + ))); + } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } + + // ── MCP ─────────────────────────────────────────────────────── crate::app::state::types::Overlay::Mcp => { - let block = block.title(" MCP Servers "); - let lines: Vec = vec![ + 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", + " MCP Server Management", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), + Line::from(Span::raw("")), Line::from(Span::styled( - "", - Style::default(), + format!(" Session dir: {}", state.session_dir.display()), + Style::default().fg(Theme::TEXT_DIM), )), Line::from(Span::styled( - format!("Session dir: {}", state.session_dir.display()), - Style::default().fg(Theme::DIM), + " No MCP servers configured.", + Style::default().fg(Theme::TEXT_MUTED), )), + Line::from(Span::raw("")), Line::from(Span::styled( - "No MCP servers configured.", - Style::default().fg(Theme::INFO), - )), - Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - "Press Ctrl+P to configure provider settings.", - Style::default().fg(Theme::DIM), + " 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(" Tasks "); + 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", + " Session Activity", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), )), + Line::from(Span::raw("")), Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - format!("Messages: {}", msg_count), + format!(" Messages: {}", msg_count), Style::default().fg(Theme::INFO), )), - Line::from(Span::styled( - format!("Overlay: {:?}", state.misc.overlay), - Style::default().fg(Theme::DIM), + 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(" 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( - "Session History", - Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - "", - Style::default(), + " 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::DIM), + " No messages in current session.", + Style::default().fg(Theme::TEXT_DIM), ))); } else { - let start = if messages.len() > 5 { messages.len() - 5 } else { 0 }; + 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", @@ -355,63 +425,72 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ crate::dto::chat::message::Role::System => "Sys", crate::dto::chat::message::Role::Tool => "Tool", }; - let preview: String = msg.content.chars().take(60).collect(); + 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 }), + 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() > 5 { + if messages.len() > 8 { lines.push(Line::from(Span::styled( - format!("... and {} more messages", messages.len() - 5), - Style::default().fg(Theme::DIM), + 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), // Left: List - Constraint::Percentage(60), // Right: Details + Constraint::Percentage(40), + Constraint::Percentage(60), ]) .split(overlay_area); let left_block = Block::default() - .title(" Lessons ") + .title(Span::styled(" 📚 Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD))) .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::PRIMARY)) + .border_style(Style::default().fg(Theme::BORDER)) .style(Style::default().bg(Theme::BG)); let right_block = Block::default() - .title(" Lesson Details ") + .title(Span::styled(" Details ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD))) .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::PRIMARY)) + .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::DIM), + " 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 prefix = if is_selected { " ▸ " } else { " " }; let (label, style) = match item { crate::app::mode::learning::LearningItem::Pending { name, .. } => { ( format!("{}[Pending] {}", prefix, name), if is_selected { - Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + 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, .. } => { @@ -419,10 +498,11 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ ( format!("{}[{}] {}", prefix, status, name), if is_selected { - Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) - } + }, ) } }; @@ -430,7 +510,7 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ } } - // Scroll the left list so the selected index is always visible + // 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 { @@ -448,62 +528,133 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ 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::DIM)))); - right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); - right_lines.push(Line::from("")); - 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("")); - right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::DIM)))); + 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(line, Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from(Span::styled( + format!(" {}", line), + Style::default().fg(Theme::TEXT), + ))); } - right_lines.push(Line::from("")); - right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); - right_lines.push(Line::from(Span::styled(" [Enter] or [a] to Accept", Style::default().fg(Theme::SUCCESS)))); - right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[r] to Reject", Style::default().fg(Theme::ERROR)))); + 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::DIM)))); - right_lines.push(Line::from(Span::styled(name, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)))); - right_lines.push(Line::from("")); - 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("")); - right_lines.push(Line::from(Span::styled("Content:", Style::default().fg(Theme::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(line, Style::default().fg(Theme::TEXT)))); + right_lines.push(Line::from(Span::styled( + format!(" {}", line), + Style::default().fg(Theme::TEXT), + ))); } - right_lines.push(Line::from("")); - right_lines.push(Line::from(Span::styled("Keys:", Style::default().fg(Theme::DIM)))); - right_lines.push(Line::from(Span::styled(" [Backspace]/[Delete]/[d] to Delete Lesson", Style::default().fg(Theme::ERROR)))); + 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 to see details.", - Style::default().fg(Theme::DIM), + " 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 }); + 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(" 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(|r| { - (r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start) - }).unwrap_or((0, 0, 0, 0, 0)); - let (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| { - (r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews) - }).unwrap_or((0, 0, 0, 0)); + let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime + .map(|r| { + ( + r.usage.tokens_in, + r.usage.tokens_out, + r.usage.api_calls, + r.usage.review_tokens, + r.session_start, + ) + }) + .unwrap_or((0, 0, 0, 0, 0)); + let (edit_count, lesson_count, review_count, consec_empty) = runtime + .map(|r| { + ( + r.edit_count, + r.lesson_count, + r.review_count, + r.consecutive_empty_reviews, + ) + }) + .unwrap_or((0, 0, 0, 0)); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let hours = elapsed_ms / 3600000; let minutes = (elapsed_ms % 3600000) / 60000; @@ -513,77 +664,98 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ let main_tokens = total_tokens.saturating_sub(self_learning_total); let lines = vec![ Line::from(Span::styled( - "Usage Dashboard", - Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), + " Token Usage", + Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))), + Line::from(Span::raw("")), Line::from(Span::styled( - format!(" Main agent tokens: {}", main_tokens), + format!(" Main agent: {} tokens", main_tokens), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!(" Self-learning tokens: {}", self_learning_total), - Style::default().fg(Theme::INFO), + format!(" Self-learning: {} tokens", self_learning_total), + Style::default().fg(Theme::TEXT_MUTED), )), Line::from(Span::styled( - format!(" Total tokens: {}", total_tokens), + format!(" Total: {} tokens", total_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!(" API calls: {}", api_calls), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))), - Line::from(Span::styled( - format!(" Edits this session: {}", edit_count), + format!(" Edits: {}", edit_count), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!(" Reviews completed: {}", review_count), + format!(" Reviews: {}", review_count), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!(" Lessons found: {}", lesson_count), - Style::default().fg(Theme::INFO), + format!(" Lessons: {}", lesson_count), + Style::default().fg(Theme::TEXT_MUTED), )), Line::from(Span::styled( - format!(" Consecutive empty revs: {}", consec_empty), - Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }), + 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::styled("", Style::default())), - Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))), + Line::from(Span::raw("")), Line::from(Span::styled( - format!(" Duration: {}h {}m {}s", hours, minutes, seconds), - Style::default().fg(Theme::DIM), + format!(" Session: {}h {}m {}s", hours, minutes, seconds), + 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(" Loading "); - let content = "Processing, please wait..."; + 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(" Model Selector "); + 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), + format!(" Current: {} / {}", state.settings.provider, state.settings.model), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), )), - Line::from(Span::styled("", Style::default())), - Line::from(Span::styled("Providers:", Style::default().fg(Theme::DIM))), + 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(); + 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 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 { @@ -595,70 +767,78 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ }; lines.push(Line::from(Span::styled(label, style))); } - lines.push(Line::from(Span::styled("", Style::default()))); + lines.push(Line::from(Span::raw(""))); lines.push(Line::from(Span::styled( - "↑↓ navigate · Enter select · Esc close · /model add to add", - Style::default().fg(Theme::DIM), + " ↑↓ 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(" Clear Transcript "); + 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?", + " Clear all messages from the transcript?", Style::default().fg(Theme::TEXT), )), - Line::from(Span::styled("", Style::default())), + Line::from(Span::raw("")), Line::from(Span::styled( - "Enter to confirm · Esc to cancel", - Style::default().fg(Theme::DIM), + " 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. /// -/// Flow: if autocomplete is open and has candidates, draw a borderless -/// dropdown anchored just above the input bar showing up to 10 candidates -/// with the current selection highlighted → then render the prompt, -/// placeholder, and the buffer with a single-character highlight under -/// the cursor position. -/// -/// Why: the cursor highlight is drawn by splitting the buffer at -/// `state.input.cursor` and styling one character (or trailing space) -/// with the highlight color, since ratatui Paragraph does not expose a -/// native cursor widget. -/// -/// Return: nothing; draws directly into `frame` at `area`. -fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - // Render autocomplete dropdown if visible +/// 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; // border + items + let dropdown_height = n + 2; let dropdown_area = Rect { x: area.x, y: area.y.saturating_sub(dropdown_height), - width: area.width.min(40), + width: area.width.min(45), height: dropdown_height, }; let dropdown_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) - .title(" Commands "); + .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 prefix = if i == selected { " ▸ " } else { " " }; let style = if i == selected { - Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT) + Style::default() + .fg(Theme::TEXT) + .bg(Theme::HIGHLIGHT_DIM) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }; @@ -669,15 +849,17 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re frame.render_widget(dropdown, dropdown_area); } + // ── Input bar ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::TOP) - .border_style(Style::default().fg(Theme::BORDER)); + .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), ); @@ -685,15 +867,24 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re if input_text.is_empty() { spans.push(Span::styled( - "Type a message...", - Style::default().fg(Theme::DIM), + "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( - if after.is_empty() { " " } else { &after[..1] }, - Style::default().bg(Theme::HIGHLIGHT).fg(Theme::BG), + 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())); @@ -705,8 +896,15 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re 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() @@ -716,38 +914,54 @@ fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRes return; } let area = frame.area(); - let toast_w: u16 = 45; + 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; // border top + border bottom - let toast_area = Rect { x, y, width: toast_w, height: h }; + 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(ratatui::widgets::Clear, toast_area); - let border_color = 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::PRIMARY, + 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 = ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::ALL) - .border_style(ratatui::style::Style::default().fg(border_color)) - .style(ratatui::style::Style::default().bg(ratatui::style::Color::Black)); - let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str()) + + 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(ratatui::widgets::Wrap { trim: false }); + .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; diff --git a/src/view/status.rs b/src/view/status.rs index facafb3..7b2df11 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -1,12 +1,12 @@ -//! Status bar rendering for the TUI. +//! 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 top -//! (or bottom, per layout) of the screen showing agent status, provider, -//! and model. +//! `AppStateRest` every frame and paints a single-line bar at the +//! bottom of the screen with three visual segments: +//! [app name + status badge] [spinner + info] [provider · model · tokens] //! -//! Why: kept as one small, self-contained render function rather than a -//! widget struct, matching the other `view/*` modules' functional style. +//! Design: the status bar uses a dark background with carefully +//! spaced segments so information is scannable at a glance. use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; @@ -15,53 +15,56 @@ use ratatui::widgets::Block; use ratatui::Frame; use super::theme::Theme; -/// Render the single-line status bar showing connection state, provider, and model. +/// Render the single-line status bar. /// -/// Flow: derive an agent status label/color from turn-in-flight and API -/// connection state → build left ([zesdex] STATUS) and right -/// (provider · model) span groups → render as one styled Line. -/// -/// Return: nothing; draws directly into `frame` at `area`. +/// Layout (left-to-right, space-filling): +/// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI) +/// CENTER: spinner + optional contextual info +/// RIGHT: provider · model · ↑tokens_in ↓tokens_out pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { - // Connection status — reflects actual agent readiness: - // PROG → turn is in flight - // READY → connected and ready - // NOAPI → disconnected let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let (agent_status, conn_color) = if state.turn_in_flight() { - let frame = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; - (format!("{} PROG", frame), Theme::MODE_YOLO) + + // ── Agent status badge ──────────────────────────────────────────────── + let (status_text, status_bg, status_fg) = if state.turn_in_flight() { + let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()]; + (format!(" {} PROG ", f), Theme::MODE_YOLO, Theme::BG) } else if state.misc.api_connected { - ("READY".to_string(), Theme::MODE_AUTO) + (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG) } else { - ("NOAPI".to_string(), Theme::DIM) + (" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG) }; - let status = Span::styled( - format!(" {} ", agent_status), + + let status_badge = Span::styled( + status_text, Style::default() - .fg(if agent_status == "NOAPI" { Theme::DIM } else { Theme::BG }) - .bg(conn_color) + .fg(status_fg) + .bg(status_bg) .add_modifier(Modifier::BOLD), ); - // Left chunk: [zesdex] STATUS - let mut spans = vec![ - Span::styled(" [zesdex] ", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)), - status, + // ── Left segment: app name ──────────────────────────────────────────── + let left_spans = vec![ + Span::styled( + " ⚡zesdex ", + Style::default() + .fg(Theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + status_badge, ]; - // Right chunk: token usage, provider, model + // ── Right segment: metadata ─────────────────────────────────────────── let right_str = if let Some(ref rt) = state.session_runtime { let max_tokens = state.app_config.model_roles.values() .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .and_then(|role| role.context_window); - + let total_chars: usize = rt.messages.iter() .filter_map(|m| m.content.as_deref()) .map(|c| c.len()) .sum(); let current_tokens = total_chars / 4; - + let mut parts = Vec::new(); if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { parts.push(format!("↑{} ↓{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); @@ -70,7 +73,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: parts.push(format!("{}/{}", current_tokens, max_str)); parts.push(state.settings.provider.clone()); parts.push(state.settings.model.clone()); - + format!(" {} ", parts.join(" · ")) } else { let max_tokens = state.app_config.model_roles.values() @@ -80,12 +83,23 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model) }; - spans.push(Span::styled( + // ── Combine everything ──────────────────────────────────────────────── + let left_line = Line::from(left_spans); + + let right_line = Line::from(Span::styled( right_str, - Style::default().fg(Theme::DIM), + Style::default().fg(Theme::TEXT_MUTED), )); - let line = Line::from(spans); + // Render the bar using two columns + use ratatui::layout::{Constraint, Direction, Layout}; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(25), + Constraint::Min(10), + ]) + .split(area); let block = Block::default() .style( @@ -94,6 +108,13 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: .fg(Theme::TEXT), ); - let paragraph = ratatui::widgets::Paragraph::new(line).block(block); - frame.render_widget(paragraph, area); + // Left part + let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone()); + frame.render_widget(left_para, chunks[0]); + + // Right part + let right_para = ratatui::widgets::Paragraph::new(right_line) + .block(block) + .alignment(ratatui::layout::Alignment::Right); + frame.render_widget(right_para, chunks[1]); } diff --git a/src/view/theme.rs b/src/view/theme.rs index d6a4209..7ee93b2 100644 --- a/src/view/theme.rs +++ b/src/view/theme.rs @@ -1,38 +1,94 @@ -//! Central color theme for the TUI. +//! Central color theme for the TUI — modern dark palette with neon accents. //! //! Flow: defines a single `Theme` marker struct with associated `Color` //! consts, consumed by every `view/*` render function so styling stays //! consistent and changeable from one place. //! -//! Why: a zero-sized struct with associated consts (rather than an enum -//! or a runtime-configured palette) keeps color lookups compile-time -//! constant and allocation-free. +//! Design: dark-primary background (#1a1b26 / Catppuccin Mocha inspired), +//! vibrant accent colors for semantic states, and muted tones for +//! secondary/background elements. This gives a modern "neon dashboard" +//! look that is easy on the eyes during long sessions. use ratatui::style::Color; /// Central palette of terminal colors used across all TUI render functions. /// -/// Why: a zero-sized marker struct holding only associated consts, so -/// every view module references colors as `Theme::NAME` instead of +/// Every view module references colors as `Theme::NAME` instead of /// hardcoding `ratatui::style::Color` values inline. pub struct Theme; impl Theme { - pub const PRIMARY: Color = Color::Cyan; - pub const SUCCESS: Color = Color::Green; - pub const WARNING: Color = Color::Yellow; - pub const ERROR: Color = Color::Red; - pub const INFO: Color = Color::Blue; - pub const TEXT: Color = Color::White; - pub const DIM: Color = Color::DarkGray; - pub const HIGHLIGHT: Color = Color::LightYellow; - pub const BORDER: Color = Color::Gray; - pub const ROLE_USER: Color = Color::Green; - pub const ROLE_ASSISTANT: Color = Color::Cyan; - pub const ROLE_SYSTEM: Color = Color::Blue; - pub const ROLE_TOOL: Color = Color::Yellow; - pub const BG: Color = Color::Reset; - pub const STATUS_BAR_BG: Color = Color::Blue; - pub const MODE_AUTO: Color = Color::Green; - pub const MODE_YOLO: Color = Color::Red; + // ── Base surface colors ────────────────────────────────────────────── + /// Deep background — used for the main chat area and overlays. + pub const BG: Color = Color::Rgb(24, 25, 38); + /// Slightly lighter surface — for panels, cards, and input bars. + pub const SURFACE: Color = Color::Rgb(30, 32, 48); + /// Elevated surface — for dropdowns, toasts, and floating elements. + pub const SURFACE_ELEVATED: Color = Color::Rgb(38, 40, 58); + + // ── Text colors ────────────────────────────────────────────────────── + /// Primary text color (bright white). + pub const TEXT: Color = Color::Rgb(220, 222, 245); + /// Secondary / muted text. + pub const TEXT_MUTED: Color = Color::Rgb(150, 152, 180); + /// Dim / placeholder text. + pub const TEXT_DIM: Color = Color::Rgb(90, 92, 120); + + // ── Accent colors ──────────────────────────────────────────────────── + /// Primary accent — cyan for borders, titles, selections. + pub const PRIMARY: Color = Color::Rgb(0, 212, 255); + /// Success / positive states — green. + pub const SUCCESS: Color = Color::Rgb(80, 220, 130); + /// Warning / in-progress states — yellow-orange. + pub const WARNING: Color = Color::Rgb(255, 200, 80); + /// Error / failure states — red. + pub const ERROR: Color = Color::Rgb(255, 100, 110); + /// Informational / neutral — blue. + pub const INFO: Color = Color::Rgb(100, 170, 255); + + // ── Extended accent palette ────────────────────────────────────────── + /// Purple accent — used for special highlights. + pub const ACCENT_PURPLE: Color = Color::Rgb(180, 130, 255); + #[allow(dead_code)] + /// Pink / magenta accent. + pub const ACCENT_PINK: Color = Color::Rgb(255, 120, 200); + /// Orange accent. + pub const ACCENT_ORANGE: Color = Color::Rgb(255, 160, 60); + /// Teal accent. + pub const ACCENT_TEAL: Color = Color::Rgb(60, 210, 200); + + // ── Border colors ──────────────────────────────────────────────────── + /// Standard border color. + pub const BORDER: Color = Color::Rgb(50, 52, 72); + #[allow(dead_code)] + /// Focused / active border. + pub const BORDER_FOCUS: Color = Color::Rgb(0, 180, 220); + + // ── Role badge colors ──────────────────────────────────────────────── + pub const ROLE_USER: Color = Color::Rgb(80, 220, 130); // green + pub const ROLE_ASSISTANT: Color = Color::Rgb(0, 212, 255); // cyan + pub const ROLE_SYSTEM: Color = Color::Rgb(100, 170, 255); // blue + pub const ROLE_TOOL: Color = Color::Rgb(255, 200, 80); // yellow + + // ── Status colors ──────────────────────────────────────────────────── + pub const STATUS_BAR_BG: Color = Color::Rgb(20, 21, 34); + pub const MODE_AUTO: Color = Color::Rgb(80, 220, 130); + pub const MODE_YOLO: Color = Color::Rgb(255, 100, 110); + + // ── Code / markdown ────────────────────────────────────────────────── + pub const CODE_BG: Color = Color::Rgb(20, 22, 35); + pub const CODE_BAR: Color = Color::Rgb(40, 42, 62); + pub const BLOCKQUOTE_BAR: Color = Color::Rgb(100, 170, 255); + + // ── Misc ───────────────────────────────────────────────────────────── + /// Highlight / selection background. + pub const HIGHLIGHT: Color = Color::Rgb(0, 140, 180); + /// Dim highlight (for non-selected items). + pub const HIGHLIGHT_DIM: Color = Color::Rgb(30, 40, 60); + #[allow(dead_code)] + /// Scrollbar track. + pub const SCROLLBAR_BG: Color = Color::Rgb(35, 37, 55); + #[allow(dead_code)] + /// Scrollbar thumb. + pub const SCROLLBAR_FG: Color = Color::Rgb(60, 62, 85); } diff --git a/src/view/workflow.rs b/src/view/workflow.rs index 3ebfe10..7437a93 100644 --- a/src/view/workflow.rs +++ b/src/view/workflow.rs @@ -1,22 +1,22 @@ -//! Workflow status panel rendering. +//! Workflow status panel rendering — agent cards with state badges. //! //! Flow: `draw_workflow_panel` reads `state.workflow_engine` and renders a -//! rich panel showing agent statuses, findings count, session counters, -//! and usage hints. +//! panel showing agent statuses, findings count, session counters, and +//! usage hints. //! -//! Division-aware: when the workflow is a company pipeline, shows the -//! division pipeline header with visual arrows between stages. +//! Design: agents are shown as compact cards with state-colored badges. +//! The division pipeline mode adds a visual pipeline flow with arrows. use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; use crate::app::workflow::engine::AgentState; -/// Icons for division states in the company pipeline. -fn div_icon(state: AgentState) -> &'static str { +/// Icons for agent states. +fn state_icon(state: AgentState) -> &'static str { match state { AgentState::Idle => "○", AgentState::Running => "▶", @@ -25,13 +25,29 @@ fn div_icon(state: AgentState) -> &'static str { } } -/// Detect if the current workflow looks like a company pipeline by -/// checking agent names for division keywords. +fn state_label(state: AgentState) -> &'static str { + match state { + AgentState::Idle => "Idle", + AgentState::Running => "Running", + AgentState::Completed => "Done", + AgentState::Failed => "Failed", + } +} + +fn state_color(state: AgentState) -> Color { + match state { + AgentState::Idle => Theme::TEXT_DIM, + AgentState::Running => Theme::WARNING, + AgentState::Completed => Theme::SUCCESS, + AgentState::Failed => Theme::ERROR, + } +} + +/// Detect if the current workflow looks like a company pipeline. fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -> bool { if agents.is_empty() { return false; } - // Company pipeline agents have names like "Strategy", "Engineering", etc. let division_keywords = ["Strategy", "Engineering", "Quality", "Security", "Documentation"]; agents.iter().any(|a| { division_keywords.iter().any(|k| a.name.contains(k)) @@ -44,77 +60,63 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st let is_company = is_company_pipeline(&state.workflow_engine.agents); + let title = if is_company { + Span::styled(" 🏢 Pipeline ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)) + } else { + Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)) + }; + let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Theme::PRIMARY)) - .title({ - if is_company { - Span::styled(" 🏢 Company Pipeline ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)) - } else { - Span::styled(" ⚙ Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)) - } - }); + .border_style(Style::default().fg(Theme::BORDER)) + .title(title); let inner = block.inner(area); frame.render_widget(block, area); - // Split inner into header (hints) and body (agent list / status) + // Split inner into header and body let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header / hints - Constraint::Min(4), // agent list or placeholder + Constraint::Length(3), + Constraint::Min(4), ]) .split(inner); - // ── Header ───────────────────────────────────────────────────────── - let mut header_lines = vec![]; + // ── Header area ────────────────────────────────────────────────────── + let mut header_lines: Vec = Vec::new(); if is_company { - // Show the division pipeline header with visual arrows + // Division pipeline overview let agents = &state.workflow_engine.agents; - let mut pipeline_spans: Vec = Vec::new(); + let mut pipe_spans: Vec = Vec::new(); for (i, agent) in agents.iter().enumerate() { if i > 0 { - pipeline_spans.push(Span::styled(" → ", Style::default().fg(Theme::DIM))); + pipe_spans.push(Span::styled( + " ", + Style::default().fg(Theme::TEXT_DIM), + )); } - let icon = div_icon(agent.status.state); - let (color, modif) = match agent.status.state { - AgentState::Idle => (Theme::DIM, Modifier::empty()), - AgentState::Running => (Theme::WARNING, Modifier::BOLD), - AgentState::Completed => (Theme::SUCCESS, Modifier::BOLD), - AgentState::Failed => (Theme::ERROR, Modifier::BOLD), + let icon = state_icon(agent.status.state); + let color = state_color(agent.status.state); + let modif = match agent.status.state { + AgentState::Idle => Modifier::empty(), + _ => Modifier::BOLD, }; - pipeline_spans.push(Span::styled( - format!("{} {} ", icon, agent.name.chars().take(12).collect::()), + pipe_spans.push(Span::styled( + format!("{} {} ", icon, agent.name.chars().take(10).collect::()), Style::default().fg(color).add_modifier(modif), )); + if i < agents.len().saturating_sub(1) { + pipe_spans.push(Span::styled( + "→", + Style::default().fg(Theme::TEXT_DIM), + )); + } } - header_lines.push(Line::from(pipeline_spans)); + header_lines.push(Line::from(pipe_spans)); header_lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Theme::DIM)), - if state.turn_in_flight() { - Span::styled("● Pipeline Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) - } else { - Span::styled("● Pipeline Complete", Style::default().fg(Theme::SUCCESS)) - }, - Span::raw(" "), - Span::styled( - format!("Divisions: {} Findings: {}", - state.workflow_engine.agents.len(), - state.workflow_engine.findings.len(), - ), - Style::default().fg(Theme::DIM), - ), - ])); - } else { - header_lines.push(Line::from(vec![ - Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)), - Span::styled("", Style::default().fg(Theme::DIM)), - Span::styled(" · Esc to close", Style::default().fg(Theme::DIM)), - ])); - header_lines.push(Line::from(vec![ - Span::styled("Status: ", Style::default().fg(Theme::DIM)), + Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)), if state.turn_in_flight() { Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) } else { @@ -122,70 +124,107 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st }, Span::raw(" "), Span::styled( - format!("Agents: {} Findings: {}", + format!("Agents: {} | Findings: {}", state.workflow_engine.agents.len(), state.workflow_engine.findings.len(), ), - Style::default().fg(Theme::DIM), + Style::default().fg(Theme::TEXT_DIM), + ), + ])); + } else { + header_lines.push(Line::from(vec![ + Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled("", Style::default().fg(Theme::TEXT_DIM)), + Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)), + ])); + header_lines.push(Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)), + if state.turn_in_flight() { + Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) + } else { + Span::styled("● Idle", Style::default().fg(Theme::SUCCESS)) + }, + Span::raw(" "), + Span::styled( + format!("Agents: {} | Findings: {}", + state.workflow_engine.agents.len(), + state.workflow_engine.findings.len(), + ), + Style::default().fg(Theme::TEXT_DIM), ), ])); } + let header = Paragraph::new(header_lines); frame.render_widget(header, chunks[0]); - // ── Body: agent/division list ────────────────────────────────────── + // ── Body: agent cards ──────────────────────────────────────────────── if state.workflow_engine.agents.is_empty() { let session_lines = build_session_lines(state); let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false }); frame.render_widget(placeholder, chunks[1]); } else { - let items: Vec = state.workflow_engine.agents.iter().map(|agent| { - let (state_str, state_color) = match agent.status.state { - AgentState::Idle => ("○ Idle", Theme::DIM), - AgentState::Running => ("▶ Running…", Theme::WARNING), - AgentState::Completed => ("✓ Done", Theme::SUCCESS), - AgentState::Failed => ("✗ Failed", Theme::ERROR), - }; + let mut card_lines: Vec = Vec::new(); + for agent in &state.workflow_engine.agents { + let color = state_color(agent.status.state); + let icon = state_icon(agent.status.state); + let label = state_label(agent.status.state); + let duration_str = match (agent.status.started_at, agent.status.completed_at) { (Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)), (Some(_), None) => " (running)".to_string(), _ => String::new(), }; - ListItem::new(Line::from(vec![ - Span::styled( - format!(" {:12} ", state_str), - Style::default().fg(state_color).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{}{}", agent.name, duration_str), - Style::default().fg(Theme::TEXT), - ), - if let Some(ref err) = agent.status.error { - Span::styled(format!(" — {}", err), Style::default().fg(Theme::ERROR)) - } else if let Some(ref prog) = agent.status.progress { - Span::styled( - format!(" ({})", prog), - Style::default().fg(Theme::DIM), - ) - } else { - Span::raw("") - }, - ])) - }).collect(); - let list = List::new(items) - .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + // Agent card header + card_lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", icon), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", agent.name), + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" [{}]", label), + Style::default().fg(color), + ), + Span::styled( + duration_str, + Style::default().fg(Theme::TEXT_DIM), + ), + ])); + + // Agent details (progress / error) + if let Some(ref err) = agent.status.error { + card_lines.push(Line::from(vec![ + Span::styled(" ⚠ ", Style::default().fg(Theme::ERROR)), + Span::styled(err.clone(), Style::default().fg(Theme::ERROR)), + ])); + } else if let Some(ref prog) = agent.status.progress { + card_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(prog.clone(), Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), + ])); + } + + // Card separator + card_lines.push(Line::from(Span::raw(""))); + } + + let list = Paragraph::new(card_lines); frame.render_widget(list, chunks[1]); } } -/// Build a compact list of session counters for the placeholder view. +/// Build compact session info for the placeholder view. fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec> { let mut lines: Vec> = Vec::new(); lines.push(Line::from(Span::styled( " No workflow running.", - Style::default().fg(Theme::DIM), + Style::default().fg(Theme::TEXT_DIM), ))); lines.push(Line::from(Span::raw(""))); @@ -196,37 +235,39 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec 0 { lines.push(Line::from(vec![ - Span::styled(" Pending ", Style::default().fg(Theme::DIM)), + Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)), ])); } if bash_count > 0 { lines.push(Line::from(vec![ - Span::styled(" Bash jobs ", Style::default().fg(Theme::DIM)), + Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)), ])); } } else { lines.push(Line::from(Span::styled( " (no active session)", - Style::default().fg(Theme::DIM), + Style::default().fg(Theme::TEXT_DIM), ))); } lines.push(Line::from(Span::raw(""))); lines.push(Line::from(Span::styled( " Complex tasks auto-delegate to the company pipeline.", - Style::default().fg(Theme::DIM).add_modifier(Modifier::ITALIC), + Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), ))); lines } + +use ratatui::style::Color;