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
+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;