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;