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.
This commit is contained in:
asepharyana
2026-07-13 05:42:17 +07:00
parent 2310c2df7f
commit 3f5f27c339
6 changed files with 977 additions and 554 deletions
+124 -73
View File
@@ -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<Span> 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<Span<'a>>) -> Vec<Line<'a>> {
let mut lines = Vec::new();
let mut current_spans = Vec::new();
@@ -53,44 +44,42 @@ fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
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<Line> = 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;
+90 -50
View File
@@ -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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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<Span<'static>> {
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;
+484 -270
View File
File diff suppressed because it is too large Load Diff
+59 -38
View File
@@ -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]);
}
+79 -23
View File
@@ -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);
}
+141 -100
View File
@@ -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<Line> = 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<Span> = Vec::new();
let mut pipe_spans: Vec<Span> = 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::<String>()),
pipe_spans.push(Span::styled(
format!("{} {} ", icon, agent.name.chars().take(10).collect::<String>()),
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("<prompt>", 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("<prompt>", 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<ListItem> = 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<Line> = 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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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<Lin
let msg_count = rt.messages.len();
lines.push(Line::from(vec![
Span::styled(" Messages ", Style::default().fg(Theme::DIM)),
Span::styled(" Messages ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(msg_count.to_string(), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)),
]));
lines.push(Line::from(vec![
Span::styled(" Tool calls", Style::default().fg(Theme::DIM)),
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)),
]));
if pending > 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;