feat: implement Component trait for modular UI components and refactor TUI views to use it
This commit is contained in:
@@ -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<Action> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! This module will grow as shared widgets (buttons, input fields, etc.)
|
//! This module will grow as shared widgets (buttons, input fields, etc.)
|
||||||
//! are extracted from individual overlay and view modules.
|
//! are extracted from individual overlay and view modules.
|
||||||
|
|
||||||
|
pub mod component;
|
||||||
|
pub use component::Component;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan
|
//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan
|
||||||
|
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
|
use crate::components::Component;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -14,6 +15,9 @@ use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use zesdex_domain::core::Role;
|
use zesdex_domain::core::Role;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChatComponent;
|
||||||
|
|
||||||
const PREFIX_WIDTH: usize = 15;
|
const PREFIX_WIDTH: usize = 15;
|
||||||
|
|
||||||
fn role_accent_color(role: &Role) -> Color {
|
fn role_accent_color(role: &Role) -> Color {
|
||||||
@@ -160,14 +164,8 @@ fn render_one_message(
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-render hook: update cache secara incremental.
|
impl Component for ChatComponent {
|
||||||
///
|
fn pre_render(&mut self, state: &mut crate::state::AppStateRest) {
|
||||||
/// 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) {
|
|
||||||
let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2);
|
let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2);
|
||||||
let msg_count = state.transcript_cache.messages.len();
|
let msg_count = state.transcript_cache.messages.len();
|
||||||
let cached_count = state.cached_msg_count;
|
let cached_count = state.cached_msg_count;
|
||||||
@@ -220,11 +218,7 @@ pub fn pre_render_chat(state: &mut crate::state::AppStateRest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render chat transcript.
|
fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||||
///
|
|
||||||
/// 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) {
|
|
||||||
let messages = &state.transcript_cache.messages;
|
let messages = &state.transcript_cache.messages;
|
||||||
let scroll_offset = state.scroll.offset;
|
let scroll_offset = state.scroll.offset;
|
||||||
let max_visible = (area.height as usize).saturating_sub(3);
|
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));
|
.style(Style::default().bg(Theme::BG));
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
||||||
|
|||||||
@@ -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<Line> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
//! centered panels, and floats toast notifications over the top-right corner.
|
//! centered panels, and floats toast notifications over the top-right corner.
|
||||||
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
|
pub mod input;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
pub mod overlays;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
pub mod overlays;
|
|
||||||
|
|
||||||
|
use crate::components::Component;
|
||||||
use crate::state::AppStateRest;
|
use crate::state::AppStateRest;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
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
|
/// This separates cache mutation from rendering so `draw` can take
|
||||||
/// `&AppStateRest` (required by `terminal.draw` closure constraints).
|
/// `&AppStateRest` (required by `terminal.draw` closure constraints).
|
||||||
pub fn pre_render(state: &mut AppStateRest) {
|
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.
|
/// 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;
|
let overlay = state.misc.overlay;
|
||||||
overlays::render_overlay(frame, chat_area, overlay, state);
|
overlays::render_overlay(frame, chat_area, overlay, state);
|
||||||
} else {
|
} 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);
|
let mut input_comp = input::InputComponent::default();
|
||||||
status::draw_status_bar(frame, status_area, state);
|
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 {
|
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);
|
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<Line> = 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) {
|
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let active: Vec<&zesdex_infrastructure::Toast> = state
|
let active: Vec<&zesdex_infrastructure::Toast> = state
|
||||||
@@ -209,6 +114,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shadow/Dimming effect background
|
||||||
frame.render_widget(Clear, toast_area);
|
frame.render_widget(Clear, toast_area);
|
||||||
|
|
||||||
let (border_color, icon) = match toast.kind {
|
let (border_color, icon) = match toast.kind {
|
||||||
@@ -221,6 +127,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
|
|||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
.border_type(ratatui::widgets::BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(border_color))
|
.border_style(Style::default().fg(border_color))
|
||||||
.title(Span::styled(icon, Style::default().fg(border_color)))
|
.title(Span::styled(icon, Style::default().fg(border_color)))
|
||||||
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ pub fn render_overlay(
|
|||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_type(ratatui::widgets::BorderType::Rounded)
|
||||||
.style(Style::default().bg(Theme::BG));
|
.border_style(Style::default().fg(Theme::BORDER_FOCUSED))
|
||||||
|
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
|
||||||
|
|
||||||
match overlay {
|
match overlay {
|
||||||
Overlay::None => {}
|
Overlay::None => {}
|
||||||
|
|||||||
@@ -1,51 +1,60 @@
|
|||||||
//! Persistent right-hand dashboard sidebar: Workflow, Tasks, and Usage
|
//! 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 super::theme::Theme;
|
||||||
|
use crate::components::Component;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
/// Render the persistent right-hand dashboard: Workflow, Tasks, and Usage.
|
#[derive(Default)]
|
||||||
pub fn draw_sidebar(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
pub struct SidebarComponent;
|
||||||
let has_workflow = !state.workflow_engine.agents.is_empty();
|
|
||||||
|
|
||||||
let constraints = if has_workflow {
|
impl Component for SidebarComponent {
|
||||||
vec![
|
fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||||
Constraint::Ratio(1, 2),
|
let has_workflow = !state.workflow_engine.agents.is_empty();
|
||||||
Constraint::Ratio(1, 4),
|
|
||||||
Constraint::Ratio(1, 4),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let constraints = if has_workflow {
|
||||||
.direction(Direction::Vertical)
|
vec![
|
||||||
.constraints(constraints)
|
Constraint::Ratio(1, 2),
|
||||||
.split(area);
|
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);
|
let chunks = Layout::default()
|
||||||
draw_tasks_widget(frame, chunks[1], state);
|
.direction(Direction::Vertical)
|
||||||
draw_usage_widget(frame, chunks[2], state);
|
.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) {
|
fn draw_tasks_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" Tasks ",
|
" 📝 Tasks ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::ACCENT_PURPLE)
|
.fg(Theme::ACCENT_PURPLE)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Theme::BORDER));
|
.border_type(BorderType::Rounded)
|
||||||
let budget = (block.inner(area).height as usize).max(1);
|
.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 content = &state.misc.todo_content;
|
||||||
let task_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
|
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<Line> = if task_lines.is_empty() {
|
let lines: Vec<Line> = if task_lines.is_empty() {
|
||||||
vec![Line::from(Span::styled(
|
vec![Line::from(Span::styled(
|
||||||
" No tasks yet.",
|
" No tasks yet.",
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
|
||||||
))]
|
))]
|
||||||
} else {
|
} else {
|
||||||
let show_hint = task_lines.len() > budget;
|
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) {
|
fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
" Usage ",
|
" 📊 Usage ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::INFO)
|
.fg(Theme::INFO)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.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<Line> = if let Some(ref rt) = state.session_runtime {
|
let lines: Vec<Line> = if let Some(ref rt) = state.session_runtime {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let summary = compute_usage_summary(&rt.usage, rt.session_start, now_ms);
|
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 max_tokens = crate::state::resolve_context_window(&state.app_config, &state.settings);
|
||||||
let current_tokens = if rt.usage.last_tokens_in > 0 {
|
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
|
rt.usage.last_tokens_in as usize
|
||||||
} else {
|
} else {
|
||||||
// Fallback for brand new sessions before the first API call
|
|
||||||
state.cached_token_count
|
state.cached_token_count
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = vec![
|
let mut items = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" {:>6}: {} tok", "total", summary.total_tokens),
|
format!(" {:<6}: {} tok", "Total", summary.total_tokens),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::TEXT)
|
.fg(Theme::TEXT)
|
||||||
.add_modifier(Modifier::BOLD),
|
.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 {
|
if summary.self_learning_tokens > 0 {
|
||||||
items.push(Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
items.push(Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
items.extend(vec![
|
items.extend(vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" {:>6}: {}/{}", "ctx", current_tokens, max_tokens),
|
format!(" {:<6}: {}/{}", "Ctx", current_tokens, max_tokens),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" {:>6}: {}", "prov", state.settings.provider),
|
format!(" {:<6}: {}", "Prov", state.settings.provider),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" {:>6}: {}", "model", state.settings.model),
|
format!(" {:<6}: {}", "Model", state.settings.model),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(" {:>6}: {}", "calls", summary.api_calls),
|
format!(" {:<6}: {}", "Calls", summary.api_calls),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!(
|
format!(
|
||||||
" {:>6}: {}h {:02}m {:02}s",
|
" {:<6}: {}h {:02}m {:02}s",
|
||||||
"time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds
|
"Time", summary.elapsed_hours, summary.elapsed_minutes, summary.elapsed_seconds
|
||||||
),
|
),
|
||||||
Style::default().fg(Theme::TEXT_DIM),
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
)),
|
)),
|
||||||
@@ -155,7 +164,7 @@ fn draw_usage_widget(frame: &mut Frame, area: Rect, state: &crate::state::AppSta
|
|||||||
} else {
|
} else {
|
||||||
vec![Line::from(Span::styled(
|
vec![Line::from(Span::styled(
|
||||||
" No active session.",
|
" 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);
|
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(crate) struct UsageSummary {
|
||||||
pub main_tokens: u64,
|
pub main_tokens: u64,
|
||||||
pub self_learning_tokens: u64,
|
pub self_learning_tokens: u64,
|
||||||
@@ -177,10 +182,6 @@ pub(crate) struct UsageSummary {
|
|||||||
pub elapsed_seconds: i64,
|
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]
|
#[tracing::instrument]
|
||||||
pub(crate) fn compute_usage_summary(
|
pub(crate) fn compute_usage_summary(
|
||||||
usage: &zesdex_domain::core::UsageStats,
|
usage: &zesdex_domain::core::UsageStats,
|
||||||
|
|||||||
@@ -1,93 +1,90 @@
|
|||||||
//! Status bar rendering for the TUI — modern segmented bar design.
|
//! Status bar rendering for the TUI — modern segmented bar design.
|
||||||
//!
|
//! Implements the Component trait.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use super::theme::Theme;
|
use super::theme::Theme;
|
||||||
|
use crate::components::Component;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::Block;
|
use ratatui::widgets::Block;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
/// Render the single-line status bar at the bottom of the terminal.
|
#[derive(Default)]
|
||||||
///
|
pub struct StatusBarComponent;
|
||||||
/// 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
||||||
|
|
||||||
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
|
impl Component for StatusBarComponent {
|
||||||
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
|
fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
|
||||||
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
|
||||||
} else if state.misc.api_connected {
|
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
|
|
||||||
} else {
|
|
||||||
(" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG)
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_badge = Span::styled(
|
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
|
||||||
status_text,
|
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
|
||||||
Style::default()
|
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
|
||||||
.fg(status_fg)
|
} else if state.misc.api_connected {
|
||||||
.bg(badge_bg)
|
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
|
||||||
.add_modifier(Modifier::BOLD),
|
} else {
|
||||||
);
|
(" NOAPI ".to_string(), Theme::TEXT_DIM, Theme::BG)
|
||||||
|
};
|
||||||
|
|
||||||
let left_spans = vec![
|
let status_badge = Span::styled(
|
||||||
Span::styled(
|
status_text,
|
||||||
" ⚡zesdex ",
|
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Theme::TEXT)
|
.fg(status_fg)
|
||||||
|
.bg(badge_bg)
|
||||||
.add_modifier(Modifier::BOLD),
|
.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_str = format!(" Provider: {} | Model: {} ", state.settings.provider, state.settings.model);
|
||||||
let right_line = Line::from(Span::styled(
|
|
||||||
right_str,
|
|
||||||
Style::default().fg(Theme::TEXT_MUTED),
|
|
||||||
));
|
|
||||||
|
|
||||||
let center_line = if state.misc.lesson_running {
|
let left_line = Line::from(left_spans);
|
||||||
Line::from(vec![Span::styled(
|
let right_line = Line::from(Span::styled(
|
||||||
" 📘 Generating Lesson... ",
|
right_str,
|
||||||
Style::default()
|
Style::default().fg(Theme::TEXT_MUTED),
|
||||||
.fg(Theme::MODE_YOLO)
|
));
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)])
|
|
||||||
} else {
|
|
||||||
Line::from("")
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let center_line = if state.misc.lesson_running {
|
||||||
.direction(Direction::Horizontal)
|
Line::from(vec![Span::styled(
|
||||||
.constraints([
|
" 📘 Generating Lesson... ",
|
||||||
Constraint::Length(25),
|
Style::default()
|
||||||
Constraint::Min(10),
|
.fg(Theme::MODE_YOLO)
|
||||||
Constraint::Length(60),
|
.add_modifier(Modifier::BOLD),
|
||||||
])
|
)])
|
||||||
.split(area);
|
} 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());
|
let block = Block::default().style(Style::default().bg(Theme::STATUS_BAR_BG).fg(Theme::TEXT));
|
||||||
frame.render_widget(left_para, chunks[0]);
|
|
||||||
|
|
||||||
let center_para = ratatui::widgets::Paragraph::new(center_line)
|
let left_para = ratatui::widgets::Paragraph::new(left_line).block(block.clone());
|
||||||
.block(block.clone())
|
frame.render_widget(left_para, chunks[0]);
|
||||||
.alignment(Alignment::Center);
|
|
||||||
frame.render_widget(center_para, chunks[1]);
|
|
||||||
|
|
||||||
let right_para = ratatui::widgets::Paragraph::new(right_line)
|
let center_para = ratatui::widgets::Paragraph::new(center_line)
|
||||||
.block(block)
|
.block(block.clone())
|
||||||
.alignment(Alignment::Right);
|
.alignment(Alignment::Center);
|
||||||
frame.render_widget(right_para, chunks[2]);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,47 +10,49 @@ pub struct Theme;
|
|||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
// ── Base surface colors ──────────────────────────────────────────────
|
// ── Base surface colors ──────────────────────────────────────────────
|
||||||
pub const BG: Color = Color::Rgb(0x1a, 0x1b, 0x26);
|
pub const BG: Color = Color::Rgb(0x1e, 0x1e, 0x2e); // Catppuccin Mocha Base
|
||||||
pub const SURFACE: Color = Color::Rgb(0x1f, 0x23, 0x35);
|
pub const SURFACE: Color = Color::Rgb(0x31, 0x32, 0x44); // Catppuccin Mocha Surface0
|
||||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
pub const SURFACE_ELEVATED: Color = Color::Rgb(0x45, 0x47, 0x5a); // Catppuccin Mocha Surface1
|
||||||
|
|
||||||
// ── Text colors ──────────────────────────────────────────────────────
|
// ── Text colors ──────────────────────────────────────────────────────
|
||||||
pub const TEXT: Color = Color::Rgb(0xc0, 0xca, 0xf5);
|
pub const TEXT: Color = Color::Rgb(0xcd, 0xd6, 0xf4); // Text
|
||||||
pub const TEXT_MUTED: Color = Color::Rgb(0xa9, 0xb1, 0xd6);
|
pub const TEXT_MUTED: Color = Color::Rgb(0xba, 0xc2, 0xde); // Subtext1
|
||||||
pub const TEXT_DIM: Color = Color::Rgb(0x56, 0x5f, 0x89);
|
pub const TEXT_DIM: Color = Color::Rgb(0xa6, 0xad, 0xc8); // Subtext0
|
||||||
|
|
||||||
// ── Accent colors ────────────────────────────────────────────────────
|
// ── Accent colors ────────────────────────────────────────────────────
|
||||||
pub const PRIMARY: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
pub const PRIMARY: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue
|
||||||
pub const SUCCESS: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
pub const SUCCESS: Color = Color::Rgb(0xa6, 0xe3, 0xa1); // Green
|
||||||
pub const WARNING: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
pub const WARNING: Color = Color::Rgb(0xf9, 0xe2, 0xaf); // Yellow
|
||||||
pub const ERROR: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
pub const ERROR: Color = Color::Rgb(0xf3, 0x8b, 0xa8); // Red
|
||||||
pub const INFO: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
pub const INFO: Color = Color::Rgb(0x89, 0xdc, 0xeb); // Sky
|
||||||
|
|
||||||
// ── Extended accent palette ──────────────────────────────────────────
|
// ── Extended accent palette ──────────────────────────────────────────
|
||||||
pub const ACCENT_PURPLE: Color = Color::Rgb(0xbb, 0x9a, 0xf7);
|
pub const ACCENT_PURPLE: Color = Color::Rgb(0xcb, 0xa6, 0xf7); // Mauve
|
||||||
pub const ACCENT_ORANGE: Color = Color::Rgb(0xff, 0x9e, 0x64);
|
pub const ACCENT_ORANGE: Color = Color::Rgb(0xfa, 0xb3, 0x87); // Peach
|
||||||
pub const ACCENT_TEAL: Color = Color::Rgb(0x73, 0xda, 0xca);
|
pub const ACCENT_TEAL: Color = Color::Rgb(0x94, 0xe2, 0xd5); // Teal
|
||||||
|
pub const ACCENT_PINK: Color = Color::Rgb(0xf5, 0xc2, 0xe7); // Pink
|
||||||
|
|
||||||
// ── Border colors ────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────
|
// ── Role badge colors ────────────────────────────────────────────────
|
||||||
pub const ROLE_USER: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
pub const ROLE_USER: Color = Color::Rgb(0xa6, 0xe3, 0xa1); // Green
|
||||||
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x7a, 0xa2, 0xf7);
|
pub const ROLE_ASSISTANT: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue
|
||||||
pub const ROLE_SYSTEM: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
pub const ROLE_SYSTEM: Color = Color::Rgb(0x89, 0xdc, 0xeb); // Sky
|
||||||
pub const ROLE_TOOL: Color = Color::Rgb(0xe0, 0xaf, 0x68);
|
pub const ROLE_TOOL: Color = Color::Rgb(0xf9, 0xe2, 0xaf); // Yellow
|
||||||
|
|
||||||
// ── Status colors ────────────────────────────────────────────────────
|
// ── Status colors ────────────────────────────────────────────────────
|
||||||
pub const STATUS_BAR_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
pub const STATUS_BAR_BG: Color = Color::Rgb(0x18, 0x18, 0x25); // Mantle
|
||||||
pub const MODE_AUTO: Color = Color::Rgb(0x9e, 0xce, 0x6a);
|
pub const MODE_AUTO: Color = Color::Rgb(0xa6, 0xe3, 0xa1);
|
||||||
pub const MODE_YOLO: Color = Color::Rgb(0xf7, 0x76, 0x8e);
|
pub const MODE_YOLO: Color = Color::Rgb(0xf3, 0x8b, 0xa8);
|
||||||
|
|
||||||
// ── Code / markdown ──────────────────────────────────────────────────
|
// ── Code / markdown ──────────────────────────────────────────────────
|
||||||
pub const CODE_BG: Color = Color::Rgb(0x16, 0x16, 0x1e);
|
pub const CODE_BG: Color = Color::Rgb(0x11, 0x11, 0x1b); // Crust
|
||||||
pub const CODE_BAR: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
pub const CODE_BAR: Color = Color::Rgb(0x31, 0x32, 0x44); // Surface0
|
||||||
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0x7d, 0xcf, 0xff);
|
pub const BLOCKQUOTE_BAR: Color = Color::Rgb(0xcb, 0xa6, 0xf7); // Mauve
|
||||||
|
|
||||||
// ── Misc ─────────────────────────────────────────────────────────────
|
// ── Misc ─────────────────────────────────────────────────────────────
|
||||||
pub const HIGHLIGHT: Color = Color::Rgb(0x3d, 0x59, 0xa1);
|
pub const HIGHLIGHT: Color = Color::Rgb(0x89, 0xb4, 0xfa); // Blue
|
||||||
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x29, 0x2e, 0x42);
|
pub const HIGHLIGHT_DIM: Color = Color::Rgb(0x31, 0x32, 0x44); // Surface0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ pub fn draw_workflow_panel(
|
|||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
.border_type(ratatui::widgets::BorderType::Rounded)
|
||||||
.border_style(Style::default().fg(Theme::BORDER))
|
.border_style(Style::default().fg(Theme::BORDER))
|
||||||
|
.style(Style::default().bg(Theme::SURFACE))
|
||||||
.title(title);
|
.title(title);
|
||||||
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
|
|||||||
Reference in New Issue
Block a user