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 //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a
//! header + markdown-rendered body per message (via `super::markdown`), //! visually rich transcript where each message is rendered as a "card"
//! appends a streaming spinner line when a turn is in flight, then //! with a role-colored left accent bar, a role badge pill, timestamp,
//! slices the combined line list to the currently visible scroll window //! and markdown body. A streaming spinner line is appended when a turn
//! before handing it to a ratatui `Paragraph`. //! 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 //! Design: messages are visually separated with vertical spacing, role
//! markdown wrapping depends on the current terminal width, which can //! badges are colored pills on the left, and each message has a thin
//! change between frames. //! role-colored border on its left side for quick visual scanning.
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier}; use ratatui::style::{Style, Modifier};
@@ -18,16 +19,6 @@ use ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. /// 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>> { fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let mut current_spans = 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 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 { match role {
crate::dto::chat::message::Role::User => "User", crate::dto::chat::message::Role::User => " YOU ",
crate::dto::chat::message::Role::Assistant => "Assistant", crate::dto::chat::message::Role::Assistant => " AI ",
crate::dto::chat::message::Role::System => "System", crate::dto::chat::message::Role::System => " SYS ",
crate::dto::chat::message::Role::Tool => "Tool", crate::dto::chat::message::Role::Tool => " TOOL ",
} }
} }
/// Map a message role to its short uppercase badge label for the chat header. fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color {
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str {
match role { match role {
crate::dto::chat::message::Role::User => "YOU", crate::dto::chat::message::Role::User => Theme::ROLE_USER,
crate::dto::chat::message::Role::Assistant => "AI ", crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT,
crate::dto::chat::message::Role::System => "SYS", crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM,
crate::dto::chat::message::Role::Tool => "TOOL", 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 { fn format_timestamp(ts: i64) -> String {
if ts <= 0 { return "".to_string(); } if ts <= 0 { return String::new(); }
let secs = ts / 1000; let secs = ts / 1000;
let mins = (secs / 60) % 60; let mins = (secs / 60) % 60;
let hrs = (secs / 3600) % 24; let hrs = (secs / 3600) % 24;
format!("{:02}:{:02}", hrs, mins) format!("{:02}:{:02}", hrs, mins)
} }
/// Render the scrollable chat transcript panel. /// Render the scrollable chat transcript panel with message card styling.
///
/// 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`.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::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;
@@ -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 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() { // ── Render messages as cards ─────────────────────────────────────────
let role_color = match msg.role { for (_msg_idx, msg) in messages.iter().enumerate() {
crate::dto::chat::message::Role::User => Theme::ROLE_USER, let accent = role_accent_color(&msg.role);
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);
let badge = role_badge(&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![ let header = Line::from(vec![
// Thin accent bar on the left
Span::styled( 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( Span::styled(
time_display, badge,
Style::default().fg(Theme::DIM), 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 is_last = std::ptr::eq(msg, messages.last().unwrap());
let content_str = if msg.content.trim().is_empty() { let content_str = if msg.content.trim().is_empty() {
if is_last && state.turn_in_flight() { 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 { } else {
msg.content.clone() 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)); content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
let message_lines = split_spans_into_lines(content_spans); let message_lines = split_spans_into_lines(content_spans);
display_lines.push(header);
for line in message_lines { for line in message_lines {
display_lines.push(line); display_lines.push(line);
} }
// ── Message separator ────────────────────────────────────────────
display_lines.push(Line::from(Span::raw(""))); display_lines.push(Line::from(Span::raw("")));
} }
// ── Streaming indicator ──────────────────────────────────────────────
if state.turn_in_flight() { if state.turn_in_flight() {
let spinner_frames = ["", "", "", "", "", "", "", "", "", ""]; 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![ display_lines.push(Line::from(vec![
Span::styled(" AI ", Style::default().fg(Theme::BG).bg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD)), Span::styled(
Span::styled(format!(" {} Generating...", frame), Style::default().fg(Theme::DIM)), "",
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(""))); display_lines.push(Line::from(Span::raw("")));
} }
let mut title = String::from(" Chat "); // ── Scrolling ────────────────────────────────────────────────────────
if !messages.is_empty() {
title.push_str(&format!("[{} msgs]", messages.len()));
}
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER)) .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 total = display_lines.len();
let max_offset = total.saturating_sub(max_visible); 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() 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) let paragraph = Paragraph::new(visible)
.block(block) .block(block)
.style(Style::default().bg(Theme::BG))
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: false });
frame.render_widget(paragraph, area); 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. //! Markdown-to-styled-spans rendering for the chat transcript.
//! //!
//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and //! Flow: `render_markdown` walks a `pulldown_cmark` event stream and
//! translates each markdown construct (headings, code blocks, links, //! translates each markdown construct into styled `ratatui::text::Span`s,
//! emphasis, block quotes, lists) into styled `ratatui::text::Span`s, //! then re-wraps the flat span list to a target column width.
//! then optionally re-wraps the flat span list to a target column width.
//! //!
//! Why: ratatui has no built-in markdown renderer, so this module bridges //! Design: code blocks get a dark background with a labeled top bar,
//! `pulldown_cmark`'s event-based parser to ratatui's span/line model. //! 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 ratatui::text::Span;
use super::theme::Theme;
/// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// Render a markdown string into styled terminal spans, word-wrapped to `width`.
/// ///
/// Flow: pulldown_cmark parses `text` into an event stream → each /// Flow: pulldown_cmark parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s /// 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 wraps long lines.
/// → 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.
/// ///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget. /// 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 mut spans = Vec::new();
let parser = pulldown_cmark::Parser::new(text); let parser = pulldown_cmark::Parser::new(text);
let mut in_code_block = false; 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 { for event in parser {
match event { match event {
@@ -36,52 +34,59 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
match tag { match tag {
pulldown_cmark::Tag::CodeBlock(_) => { pulldown_cmark::Tag::CodeBlock(_) => {
in_code_block = true; in_code_block = true;
// Code block top bar
spans.push(Span::styled( spans.push(Span::styled(
"```\n", "\n",
Style::default().fg(Color::DarkGray), 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, .. } => { pulldown_cmark::Tag::Heading { level, .. } => {
let color = match level { in_heading = true;
pulldown_cmark::HeadingLevel::H1 => Color::LightCyan, heading_level = match level {
pulldown_cmark::HeadingLevel::H2 => Color::Cyan, pulldown_cmark::HeadingLevel::H1 => 1,
_ => Color::White, pulldown_cmark::HeadingLevel::H2 => 2,
pulldown_cmark::HeadingLevel::H3 => 3,
_ => 4,
}; };
let prefix = match level { // No prefix, we'll handle in the text events
pulldown_cmark::HeadingLevel::H1 => "# ", }
pulldown_cmark::HeadingLevel::H2 => "## ", pulldown_cmark::Tag::Paragraph => {
pulldown_cmark::HeadingLevel::H3 => "### ", first_in_paragraph = true;
_ => "# ",
};
spans.push(Span::styled(
prefix,
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
} }
pulldown_cmark::Tag::Paragraph => {}
pulldown_cmark::Tag::Emphasis => {} pulldown_cmark::Tag::Emphasis => {}
pulldown_cmark::Tag::Strong => {} pulldown_cmark::Tag::Strong => {}
pulldown_cmark::Tag::List(_) => {} pulldown_cmark::Tag::List(_) => {}
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
// List item bullet
spans.push(Span::styled( spans.push(Span::styled(
" * ", " ",
Style::default().fg(Color::DarkGray), Style::default().fg(Theme::PRIMARY),
)); ));
} }
pulldown_cmark::Tag::Link { dest_url, .. } => { pulldown_cmark::Tag::Link { dest_url, .. } => {
spans.push(Span::styled( 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( spans.push(Span::styled(
format!("]({})", dest_url), format!("]({})", dest_url),
Style::default().fg(Color::Blue), Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
)); ));
} }
pulldown_cmark::Tag::BlockQuote(_) => { pulldown_cmark::Tag::BlockQuote(_) => {
spans.push(Span::styled( 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 { match tag {
pulldown_cmark::TagEnd::CodeBlock => { pulldown_cmark::TagEnd::CodeBlock => {
in_code_block = false; in_code_block = false;
// Code block bottom bar
spans.push(Span::styled( spans.push(Span::styled(
"\n```\n", "\n └─\n",
Style::default().fg(Color::DarkGray), Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
)); ));
} }
pulldown_cmark::TagEnd::Heading(_) => { pulldown_cmark::TagEnd::Heading(_) => {
in_heading = false;
heading_level = 0;
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
pulldown_cmark::TagEnd::Paragraph => { pulldown_cmark::TagEnd::Paragraph => {
first_in_paragraph = true;
spans.push(Span::raw("\n\n")); spans.push(Span::raw("\n\n"));
} }
pulldown_cmark::TagEnd::Emphasis => {} pulldown_cmark::TagEnd::Emphasis => {}
@@ -119,21 +128,47 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let s = text.to_string(); let s = text.to_string();
if in_code_block { if in_code_block {
spans.push(Span::styled( spans.push(Span::styled(
s, format!(" {}", s),
Style::default().fg(Color::Green), 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 { } 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)); spans.push(Span::raw(s));
} }
} }
pulldown_cmark::Event::Code(text) => { pulldown_cmark::Event::Code(text) => {
// Inline code with background
spans.push(Span::styled( spans.push(Span::styled(
format!("`{}`", text), format!(" {} ", text),
Style::default().fg(Color::Green), Style::default()
.fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR)
.add_modifier(Modifier::BOLD),
)); ));
} }
pulldown_cmark::Event::SoftBreak => { pulldown_cmark::Event::SoftBreak => {
spans.push(Span::raw("\n")); spans.push(Span::raw(" "));
} }
pulldown_cmark::Event::HardBreak => { pulldown_cmark::Event::HardBreak => {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
@@ -145,20 +180,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
if width > 0 { if width > 0 {
let mut spans_out = Vec::new(); let mut spans_out = Vec::new();
let mut line_len = 0; let mut line_len = 0;
let effective_width = (width as usize).saturating_sub(2); // leave margin
for span in &spans { for span in &spans {
let style = span.style; let style = span.style;
let s = span.content.clone(); let s = span.content.clone();
let text = s.as_ref(); let text_str = s.as_ref();
let remaining = text.len(); let remaining = text_str.len();
if line_len + remaining > width as usize && line_len > 0 {
if line_len + remaining > effective_width && line_len > 0 {
spans_out.push(Span::raw("\n")); spans_out.push(Span::raw("\n"));
line_len = 0; 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; line_len += remaining;
} else { } 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; 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 //! Flow: `draw_status_bar` reads live connection/turn state off
//! `AppStateRest` every frame and paints a single-line bar at the top //! `AppStateRest` every frame and paints a single-line bar at the
//! (or bottom, per layout) of the screen showing agent status, provider, //! bottom of the screen with three visual segments:
//! and model. //! [app name + status badge] [spinner + info] [provider · model · tokens]
//! //!
//! Why: kept as one small, self-contained render function rather than a //! Design: the status bar uses a dark background with carefully
//! widget struct, matching the other `view/*` modules' functional style. //! spaced segments so information is scannable at a glance.
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier}; use ratatui::style::{Style, Modifier};
@@ -15,53 +15,56 @@ use ratatui::widgets::Block;
use ratatui::Frame; use ratatui::Frame;
use super::theme::Theme; 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 /// Layout (left-to-right, space-filling):
/// connection state → build left ([zesdex] STATUS) and right /// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI)
/// (provider · model) span groups → render as one styled Line. /// CENTER: spinner + optional contextual info
/// /// RIGHT: provider · model · ↑tokens_in ↓tokens_out
/// Return: nothing; draws directly into `frame` at `area`.
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { 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 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()]; // ── Agent status badge ────────────────────────────────────────────────
(format!("{} PROG", frame), Theme::MODE_YOLO) 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 { } else if state.misc.api_connected {
("READY".to_string(), Theme::MODE_AUTO) (" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
} else { } 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() Style::default()
.fg(if agent_status == "NOAPI" { Theme::DIM } else { Theme::BG }) .fg(status_fg)
.bg(conn_color) .bg(status_bg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
); );
// Left chunk: [zesdex] STATUS // ── Left segment: app name ────────────────────────────────────────────
let mut spans = vec![ let left_spans = vec![
Span::styled(" [zesdex] ", Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)), Span::styled(
status, " ⚡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 right_str = if let Some(ref rt) = state.session_runtime {
let max_tokens = state.app_config.model_roles.values() let max_tokens = state.app_config.model_roles.values()
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model) .find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
.and_then(|role| role.context_window); .and_then(|role| role.context_window);
let total_chars: usize = rt.messages.iter() let total_chars: usize = rt.messages.iter()
.filter_map(|m| m.content.as_deref()) .filter_map(|m| m.content.as_deref())
.map(|c| c.len()) .map(|c| c.len())
.sum(); .sum();
let current_tokens = total_chars / 4; let current_tokens = total_chars / 4;
let mut parts = Vec::new(); let mut parts = Vec::new();
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { 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)); 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(format!("{}/{}", current_tokens, max_str));
parts.push(state.settings.provider.clone()); parts.push(state.settings.provider.clone());
parts.push(state.settings.model.clone()); parts.push(state.settings.model.clone());
format!(" {} ", parts.join(" · ")) format!(" {} ", parts.join(" · "))
} else { } else {
let max_tokens = state.app_config.model_roles.values() 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) 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, 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() let block = Block::default()
.style( .style(
@@ -94,6 +108,13 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
.fg(Theme::TEXT), .fg(Theme::TEXT),
); );
let paragraph = ratatui::widgets::Paragraph::new(line).block(block); // Left part
frame.render_widget(paragraph, area); 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` //! Flow: defines a single `Theme` marker struct with associated `Color`
//! consts, consumed by every `view/*` render function so styling stays //! consts, consumed by every `view/*` render function so styling stays
//! consistent and changeable from one place. //! consistent and changeable from one place.
//! //!
//! Why: a zero-sized struct with associated consts (rather than an enum //! Design: dark-primary background (#1a1b26 / Catppuccin Mocha inspired),
//! or a runtime-configured palette) keeps color lookups compile-time //! vibrant accent colors for semantic states, and muted tones for
//! constant and allocation-free. //! secondary/background elements. This gives a modern "neon dashboard"
//! look that is easy on the eyes during long sessions.
use ratatui::style::Color; use ratatui::style::Color;
/// Central palette of terminal colors used across all TUI render functions. /// 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. /// hardcoding `ratatui::style::Color` values inline.
pub struct Theme; pub struct Theme;
impl Theme { impl Theme {
pub const PRIMARY: Color = Color::Cyan; // ── Base surface colors ──────────────────────────────────────────────
pub const SUCCESS: Color = Color::Green; /// Deep background — used for the main chat area and overlays.
pub const WARNING: Color = Color::Yellow; pub const BG: Color = Color::Rgb(24, 25, 38);
pub const ERROR: Color = Color::Red; /// Slightly lighter surface — for panels, cards, and input bars.
pub const INFO: Color = Color::Blue; pub const SURFACE: Color = Color::Rgb(30, 32, 48);
pub const TEXT: Color = Color::White; /// Elevated surface — for dropdowns, toasts, and floating elements.
pub const DIM: Color = Color::DarkGray; pub const SURFACE_ELEVATED: Color = Color::Rgb(38, 40, 58);
pub const HIGHLIGHT: Color = Color::LightYellow;
pub const BORDER: Color = Color::Gray; // ── Text colors ──────────────────────────────────────────────────────
pub const ROLE_USER: Color = Color::Green; /// Primary text color (bright white).
pub const ROLE_ASSISTANT: Color = Color::Cyan; pub const TEXT: Color = Color::Rgb(220, 222, 245);
pub const ROLE_SYSTEM: Color = Color::Blue; /// Secondary / muted text.
pub const ROLE_TOOL: Color = Color::Yellow; pub const TEXT_MUTED: Color = Color::Rgb(150, 152, 180);
pub const BG: Color = Color::Reset; /// Dim / placeholder text.
pub const STATUS_BAR_BG: Color = Color::Blue; pub const TEXT_DIM: Color = Color::Rgb(90, 92, 120);
pub const MODE_AUTO: Color = Color::Green;
pub const MODE_YOLO: Color = Color::Red; // ── 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 //! Flow: `draw_workflow_panel` reads `state.workflow_engine` and renders a
//! rich panel showing agent statuses, findings count, session counters, //! panel showing agent statuses, findings count, session counters, and
//! and usage hints. //! usage hints.
//! //!
//! Division-aware: when the workflow is a company pipeline, shows the //! Design: agents are shown as compact cards with state-colored badges.
//! division pipeline header with visual arrows between stages. //! The division pipeline mode adds a visual pipeline flow with arrows.
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Style, Modifier}; use ratatui::style::{Style, Modifier};
use ratatui::text::{Line, Span}; 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 ratatui::Frame;
use super::theme::Theme; use super::theme::Theme;
use crate::app::workflow::engine::AgentState; use crate::app::workflow::engine::AgentState;
/// Icons for division states in the company pipeline. /// Icons for agent states.
fn div_icon(state: AgentState) -> &'static str { fn state_icon(state: AgentState) -> &'static str {
match state { match state {
AgentState::Idle => "", AgentState::Idle => "",
AgentState::Running => "", AgentState::Running => "",
@@ -25,13 +25,29 @@ fn div_icon(state: AgentState) -> &'static str {
} }
} }
/// Detect if the current workflow looks like a company pipeline by fn state_label(state: AgentState) -> &'static str {
/// checking agent names for division keywords. 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 { fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -> bool {
if agents.is_empty() { if agents.is_empty() {
return false; return false;
} }
// Company pipeline agents have names like "Strategy", "Engineering", etc.
let division_keywords = ["Strategy", "Engineering", "Quality", "Security", "Documentation"]; let division_keywords = ["Strategy", "Engineering", "Quality", "Security", "Documentation"];
agents.iter().any(|a| { agents.iter().any(|a| {
division_keywords.iter().any(|k| a.name.contains(k)) 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 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() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Theme::PRIMARY)) .border_style(Style::default().fg(Theme::BORDER))
.title({ .title(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))
}
});
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // header / hints Constraint::Length(3),
Constraint::Min(4), // agent list or placeholder Constraint::Min(4),
]) ])
.split(inner); .split(inner);
// ── Header ───────────────────────────────────────────────────────── // ── Header area ──────────────────────────────────────────────────────
let mut header_lines = vec![]; let mut header_lines: Vec<Line> = Vec::new();
if is_company { if is_company {
// Show the division pipeline header with visual arrows // Division pipeline overview
let agents = &state.workflow_engine.agents; 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() { for (i, agent) in agents.iter().enumerate() {
if i > 0 { 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 icon = state_icon(agent.status.state);
let (color, modif) = match agent.status.state { let color = state_color(agent.status.state);
AgentState::Idle => (Theme::DIM, Modifier::empty()), let modif = match agent.status.state {
AgentState::Running => (Theme::WARNING, Modifier::BOLD), AgentState::Idle => Modifier::empty(),
AgentState::Completed => (Theme::SUCCESS, Modifier::BOLD), _ => Modifier::BOLD,
AgentState::Failed => (Theme::ERROR, Modifier::BOLD),
}; };
pipeline_spans.push(Span::styled( pipe_spans.push(Span::styled(
format!("{} {} ", icon, agent.name.chars().take(12).collect::<String>()), format!("{} {} ", icon, agent.name.chars().take(10).collect::<String>()),
Style::default().fg(color).add_modifier(modif), 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![ 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("● 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)),
if state.turn_in_flight() { if state.turn_in_flight() {
Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD))
} else { } else {
@@ -122,70 +124,107 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
}, },
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
format!("Agents: {} Findings: {}", format!("Agents: {} | Findings: {}",
state.workflow_engine.agents.len(), state.workflow_engine.agents.len(),
state.workflow_engine.findings.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); let header = Paragraph::new(header_lines);
frame.render_widget(header, chunks[0]); frame.render_widget(header, chunks[0]);
// ── Body: agent/division list ────────────────────────────────────── // ── Body: agent cards ────────────────────────────────────────────────
if state.workflow_engine.agents.is_empty() { if state.workflow_engine.agents.is_empty() {
let session_lines = build_session_lines(state); let session_lines = build_session_lines(state);
let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false }); let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false });
frame.render_widget(placeholder, chunks[1]); frame.render_widget(placeholder, chunks[1]);
} else { } else {
let items: Vec<ListItem> = state.workflow_engine.agents.iter().map(|agent| { let mut card_lines: Vec<Line> = Vec::new();
let (state_str, state_color) = match agent.status.state { for agent in &state.workflow_engine.agents {
AgentState::Idle => ("○ Idle", Theme::DIM), let color = state_color(agent.status.state);
AgentState::Running => ("▶ Running…", Theme::WARNING), let icon = state_icon(agent.status.state);
AgentState::Completed => ("✓ Done", Theme::SUCCESS), let label = state_label(agent.status.state);
AgentState::Failed => ("✗ Failed", Theme::ERROR),
};
let duration_str = match (agent.status.started_at, agent.status.completed_at) { let duration_str = match (agent.status.started_at, agent.status.completed_at) {
(Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)), (Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)),
(Some(_), None) => " (running)".to_string(), (Some(_), None) => " (running)".to_string(),
_ => String::new(), _ => 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) // Agent card header
.highlight_style(Style::default().add_modifier(Modifier::BOLD)); 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]); 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>> { fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" No workflow running.", " No workflow running.",
Style::default().fg(Theme::DIM), Style::default().fg(Theme::TEXT_DIM),
))); )));
lines.push(Line::from(Span::raw(""))); 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(); let msg_count = rt.messages.len();
lines.push(Line::from(vec![ 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)), Span::styled(msg_count.to_string(), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)),
])); ]));
lines.push(Line::from(vec![ 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)), Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)),
])); ]));
if pending > 0 { if pending > 0 {
lines.push(Line::from(vec![ 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)), Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)),
])); ]));
} }
if bash_count > 0 { if bash_count > 0 {
lines.push(Line::from(vec![ 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)), Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)),
])); ]));
} }
} else { } else {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" (no active session)", " (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::raw("")));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
" Complex tasks auto-delegate to the company pipeline.", " 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 lines
} }
use ratatui::style::Color;